diff --git a/.project/designs/app/src/App.tsx b/.project/designs/app/src/App.tsx index a18c5433..c330b401 100644 --- a/.project/designs/app/src/App.tsx +++ b/.project/designs/app/src/App.tsx @@ -10,6 +10,7 @@ import { FreepeopleLifecyclePage } from "./pages/FreepeopleLifecycle"; import { TerrainEcologyPage } from "./pages/TerrainEcology"; import { FoodWebPage } from "./pages/FoodWeb"; import { HarvestPoliciesPage } from "./pages/HarvestPolicies"; +import { TreatyLifecyclePage } from "./pages/TreatyLifecycle"; import { CombatPreviewPage } from "./pages/CombatPreview"; import { CombatCalculatorPage } from "./pages/CombatCalculator"; import { PermutationsPage } from "./pages/Permutations"; @@ -72,6 +73,7 @@ export function App(): React.ReactElement { } /> } /> } /> + } /> } /> } /> } /> diff --git a/.project/designs/app/src/pages/Index.tsx b/.project/designs/app/src/pages/Index.tsx index 2c9160bf..3462fd46 100644 --- a/.project/designs/app/src/pages/Index.tsx +++ b/.project/designs/app/src/pages/Index.tsx @@ -160,7 +160,8 @@ const routeCategories: RouteCategory[] = [ { label: "Diplomacy & Empire", routes: [ - { path: "/diplomacy", label: "โ˜ฎ Diplomacy โ€” foreign relations matrix, deal builder, treaty options" }, + { path: "/diplomacy", label: "โ˜ฎ Diplomacy โ€” clan personalities (real axes), 5-state ladder, tribute, treaties" }, + { path: "/treaty-lifecycle", label: "๐Ÿ“œ Treaty Lifecycle โ€” durations, renewal mechanics, breach, edge cases" }, { path: "/empire", label: "๐Ÿ› Empire Dashboard โ€” multi-city overview, sortable columns, yields" }, ], }, diff --git a/.project/designs/app/src/pages/TreatyLifecycle.tsx b/.project/designs/app/src/pages/TreatyLifecycle.tsx new file mode 100644 index 00000000..6cc36b89 --- /dev/null +++ b/.project/designs/app/src/pages/TreatyLifecycle.tsx @@ -0,0 +1,285 @@ +import { type ReactElement } from "react"; +import { Link } from "react-router-dom"; +import styled from "styled-components"; +import { t } from "../theme"; + +interface TreatyRules { + description: string; + default_durations_turns: Record; + renewal: { + description: string; + auto_prompt_turns_before_expiry: number; + relationship_bonus_to_acceptance: number; + cost_multiplier_by_state: Record; + auto_renew_flag: { description: string; default: boolean }; + }; + breach: Record; + edge_cases: { id: string; description: string }[]; +} + +import rulesJson from "@resources/diplomacy/treaty_rules.json"; +const RULES = rulesJson as unknown as TreatyRules; + +const STATE_COLOR: Record = { + hostile: "#d95940", + wary: "#e69933", + neutral: "#ccbf73", + friendly: "#7cd9a0", + allied: "#66bfff", +}; + +const PageWrap = styled.div` + background: ${t.bg.menu}; + min-height: 100vh; + color: ${t.text.primary}; + font-family: ${t.font.body}; + padding-bottom: 60px; +`; +const Header = styled.div` + background: ${t.bg.panel}; + border-bottom: 1px solid ${t.border.panel}; + padding: 14px 28px; + display: flex; align-items: center; gap: 24px; +`; +const HeaderTitle = styled.div` + font-family: ${t.font.heading}; + font-size: 24px; color: ${t.text.title}; + letter-spacing: 0.04em; +`; +const BackLink = styled(Link)` + color: ${t.text.muted}; text-decoration: none; + font-size: 13px; font-family: ${t.font.mono}; + &:hover { color: ${t.accent.gold}; } +`; +const SourceTag = styled.div` + margin-left: auto; font-family: ${t.font.mono}; + font-size: 11px; color: ${t.text.muted}; +`; +const Content = styled.div` + max-width: 1200px; margin: 0 auto; + padding: 24px; + display: flex; flex-direction: column; gap: 22px; +`; +const Intro = styled.div` + background: ${t.bg.panel}; + border: 1px solid ${t.border.panel}; + border-radius: ${t.radius.panel}; + padding: 14px 18px; + font-size: 13px; color: ${t.text.secondary}; + line-height: 1.6; + & strong { color: ${t.accent.gold}; } + & code { color: ${t.accent.science}; font-family: ${t.font.mono}; font-size: 12px; } +`; +const Header2 = styled.h2` + font-family: ${t.font.heading}; + font-size: 16px; color: ${t.accent.gold}; + margin: 8px 0 4px; + letter-spacing: 0.04em; +`; +const Sub = styled.div` + font-size: 11px; color: ${t.text.muted}; + font-family: ${t.font.mono}; +`; +const Table = styled.table` + width: 100%; + border-collapse: collapse; + font-size: 12px; + font-family: ${t.font.mono}; + th, td { + padding: 6px 12px; text-align: left; + border-bottom: 1px solid ${t.border.divider}; + } + th { + color: ${t.text.muted}; + font-size: 10px; text-transform: uppercase; + letter-spacing: 0.06em; font-weight: 600; + } + td { color: ${t.text.primary}; } +`; +const Pill = styled.span<{ $color: string }>` + display: inline-block; + padding: 1px 7px; border-radius: 8px; + font-size: 10px; font-family: ${t.font.mono}; + background: ${p => p.$color + "22"}; + color: ${p => p.$color}; + border: 1px solid ${p => p.$color + "55"}; + margin-right: 4px; +`; +const Card = styled.div<{ $color: string }>` + background: ${t.bg.panel}; + border: 1px solid ${t.border.panel}; + border-left: 3px solid ${p => p.$color}; + border-radius: ${t.radius.panel}; + padding: 12px 14px; + display: flex; flex-direction: column; gap: 6px; +`; +const CardName = styled.div<{ $color: string }>` + font-family: ${t.font.heading}; + font-size: 14px; color: ${p => p.$color}; + margin-bottom: 2px; +`; +const CardDesc = styled.div` + font-size: 12px; + color: ${t.text.secondary}; + line-height: 1.5; +`; +const Grid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 10px; +`; + +function fmtDuration(turns: number | null): string { + if (turns === null) return "ongoing-while-conditions-hold"; + return `${turns} turns`; +} + +function fmtAgreementName(id: string): string { + return id.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()); +} + +export function TreatyLifecyclePage(): ReactElement { + return ( + +
+ โ† back + Treaty Lifecycle + + driven by @resources/diplomacy/treaty_rules.json + mc-trade::lib::*Agreement + +
+ + + + {RULES.description} +

+ What's shipping: turn counters + war-breaks-all behavior live in mc-trade. + What's authored but not yet wired into Rust: renewal rules, voluntary + cancellation, auto-renew flag, per-state cost multipliers. This page documents the + authoritative ruleset; the Rust side picks up the JSON via mc-trade::config + on a future objective. +
+ + Default Durations by Agreement Type + standard length when no caller-supplied duration is provided ยท null = ongoing while conditions hold + + + + + + {Object.entries(RULES.default_durations_turns).map(([id, turns]) => { + const isShipped = ["open_borders", "shared_map_window", "luxury_swap"].includes(id); + const isTribute = id.startsWith("tribute_"); + return ( + + + + + + ); + })} + +
AgreementDefault durationStatus
{fmtAgreementName(id)}{fmtDuration(turns)} + {isShipped && shipped (mc-trade)} + {isTribute && freepeople tribute} + {!isShipped && !isTribute && proposed (Game 1.5+)} +
+ + Renewal Mechanics + {RULES.renewal.description} + auto-prompt {RULES.renewal.auto_prompt_turns_before_expiry} turns before expiry ยท acceptance bonus +{RULES.renewal.relationship_bonus_to_acceptance} on the AI's trade_willingness axis + Renewal Cost Multiplier by Diplomatic State + + {Object.entries(RULES.renewal.cost_multiplier_by_state).map(([state, mult]) => { + const color = STATE_COLOR[state] ?? "#888"; + return ( + + {state.toUpperCase()} + + Renewal payment ร—{mult} the original signing cost. + {mult > 1 && " โ€” relationship-tax for hostile/wary partners."} + {mult === 1 && " โ€” baseline; same as initial signing."} + {mult < 1 && " โ€” friendly+ partners discount."} + + + ); + })} + + + Auto-Renew Flag + + {RULES.renewal.auto_renew_flag.description}{" "} + Default: {RULES.renewal.auto_renew_flag.default ? "ON" : "OFF"}. + + + + Breach & Cancellation + + {Object.entries(RULES.breach).map(([id, b]) => { + const isWar = id === "war_declaration"; + const color = isWar ? "#d95940" : "#e69933"; + return ( + + {fmtAgreementName(id)} + {b.description} +
+ breaks: {b.breaks} + {b.reputation_delta_per_clan !== undefined && ( + rep / clan: {b.reputation_delta_per_clan} + )} + {b.reputation_delta_with_partner !== undefined && ( + rep w/ partner: {b.reputation_delta_with_partner} + )} + {b.influence_decay_per_turn !== undefined && ( + influence: {b.influence_decay_per_turn}/turn + )} + {b.raid_resumes_after_turns !== undefined && ( + raid resumes after: {b.raid_resumes_after_turns}t + )} + {b.gold_refund_fraction !== undefined && ( + gold refund: {(b.gold_refund_fraction * 100).toFixed(0)}% + )} +
+
+ ); + })} +
+ + Edge Cases (Authoritative Resolution) + + {RULES.edge_cases.map(ec => ( + + {fmtAgreementName(ec.id)} + {ec.description} + + ))} + + + What Ships in Game 1 vs. What's Proposed + + Already in Rust (mc-trade): agreement structs with turns_remaining, + per-turn decrement, expiry events (OpenBordersExpired, SharedMapDurationExpired), + war-breaks-all, courier-intercepted void. +

+ Authored in JSON, awaiting Rust wire-up: default duration constants, + renewal mechanics (auto-prompt, cost multiplier per state, auto-renew flag), voluntary + cancellation with reputation cost, tribute-interruption decay rules. +

+ Future agreement types (Game 1.5+): Defensive Pact (30t), Declaration of + Friendship (30t), Research Agreement (until-tech-completes). Each will need a per-clan + acceptance heuristic in mc-ai/diplomacy.rs mirroring the existing OpenBorders + + SharedMap pattern. +
+
+
+ ); +} diff --git a/.project/designs/app/tsconfig.tsbuildinfo b/.project/designs/app/tsconfig.tsbuildinfo index a37ca97b..fb215e13 100644 --- a/.project/designs/app/tsconfig.tsbuildinfo +++ b/.project/designs/app/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/audiosynth.ts","./src/main.tsx","./src/theme.ts","./src/components/combat/combatantcard.tsx","./src/components/combat/damagematrix.tsx","./src/components/combat/hpafterbar.tsx","./src/components/combat/hpslider.tsx","./src/components/combat/kwbanner.tsx","./src/components/combat/modifierlist.tsx","./src/components/combat/probabilitybar.tsx","./src/components/combat/unitbrowser.tsx","./src/components/combat/unitrow.tsx","./src/components/tree/treeview.tsx","./src/components/tree/types.ts","./src/components/ui/button.tsx","./src/components/ui/panel.tsx","./src/components/ui/tabs.tsx","./src/components/ui/tag.tsx","./src/data/allbuildings.ts","./src/data/allunits.ts","./src/data/audiopacks.ts","./src/data/scenarios.ts","./src/data/units.ts","./src/pages/audiopackdetail.tsx","./src/pages/audiopacks.tsx","./src/pages/audiosystem.tsx","./src/pages/borders.tsx","./src/pages/buildingtrees.tsx","./src/pages/cityscreen.tsx","./src/pages/civilopedia.tsx","./src/pages/clanpersonality.tsx","./src/pages/combatcalculator.tsx","./src/pages/combatpreview.tsx","./src/pages/credits.tsx","./src/pages/culturetree.tsx","./src/pages/designgallery.tsx","./src/pages/diplomacy.tsx","./src/pages/empiredashboard.tsx","./src/pages/endgamesummary.tsx","./src/pages/eraprogression.tsx","./src/pages/foodweb.tsx","./src/pages/freepeoplelifecycle.tsx","./src/pages/gamesetup.tsx","./src/pages/gdrustbridge.tsx","./src/pages/gdrustmap.tsx","./src/pages/greatpeople.tsx","./src/pages/greatworks.tsx","./src/pages/harvestpolicies.tsx","./src/pages/hexformation.tsx","./src/pages/hud.tsx","./src/pages/index.tsx","./src/pages/mainmenu.tsx","./src/pages/notifications.tsx","./src/pages/pastgames.tsx","./src/pages/permutations.tsx","./src/pages/promotionpicker.tsx","./src/pages/replay.tsx","./src/pages/settings.tsx","./src/pages/specialists.tsx","./src/pages/statistics.tsx","./src/pages/techtree.tsx","./src/pages/terrainecology.tsx","./src/pages/throneroom.tsx","./src/pages/turnsummary.tsx","./src/pages/unitactions.tsx","./src/pages/worldgen.tsx","./src/pages/hud/minimap.tsx","./src/pages/hud/chrome.ts","./src/pages/hud/positions.ts","./src/pages/worldgen/biometransitions.tsx","./src/pages/worldgen/climate.tsx","./src/pages/worldgen/ecology.tsx","./src/pages/worldgen/hydrology.tsx","./src/pages/worldgen/lab.tsx","./src/pages/worldgen/mappanel.tsx","./src/pages/worldgen/noiseanatomy.tsx","./src/pages/worldgen/pipelinepanel.tsx","./src/pages/worldgen/presets.tsx","./src/pages/worldgen/rng.tsx","./src/pages/worldgen/substrate.tsx","./src/pages/worldgen/tectonics.tsx","./src/pages/worldgen/terraincatalog.tsx","./src/pages/worldgen/whittakerplot.tsx","./src/pages/worldgen/_layerpage.tsx","./src/pages/worldgen/lab/mapcanvas.tsx","./src/pages/worldgen/lab/overlaytoggles.tsx","./src/pages/worldgen/lab/presetbar.tsx","./src/pages/worldgen/lab/tileinspector.tsx","./src/pages/worldgen/lab/constants.ts","./src/pages/worldgen/lab/mapinteractions.ts","./src/pages/worldgen/lab/observations.ts","./src/pages/worldgen/lab/render.ts","./src/pages/worldgen/lab/types.ts","./src/utils/combatcalc.ts","./src/utils/wasm/smoke.ts","./src/utils/wasm/usewasmgrid.ts","./src/utils/worldgen/hexcanvas.test.ts","./src/utils/worldgen/hexcanvas.ts","./src/utils/worldgen/indicatordecorations.ts","./src/utils/worldgen/noise.ts","./src/utils/worldgen/poisson.ts","./src/utils/worldgen/terrain.ts","../../reports/gd-rust-relationships.json","../../../public/resources/audio/library.json","../../../public/games/age-of-dwarves/data/audio/manifest.json","../../../public/games/age-of-dwarves/data/audio/pools.json"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/audiosynth.ts","./src/main.tsx","./src/theme.ts","./src/components/combat/combatantcard.tsx","./src/components/combat/damagematrix.tsx","./src/components/combat/hpafterbar.tsx","./src/components/combat/hpslider.tsx","./src/components/combat/kwbanner.tsx","./src/components/combat/modifierlist.tsx","./src/components/combat/probabilitybar.tsx","./src/components/combat/unitbrowser.tsx","./src/components/combat/unitrow.tsx","./src/components/tree/treeview.tsx","./src/components/tree/types.ts","./src/components/ui/button.tsx","./src/components/ui/panel.tsx","./src/components/ui/tabs.tsx","./src/components/ui/tag.tsx","./src/data/allbuildings.ts","./src/data/allunits.ts","./src/data/audiopacks.ts","./src/data/scenarios.ts","./src/data/units.ts","./src/pages/audiopackdetail.tsx","./src/pages/audiopacks.tsx","./src/pages/audiosystem.tsx","./src/pages/borders.tsx","./src/pages/buildingtrees.tsx","./src/pages/cityscreen.tsx","./src/pages/civilopedia.tsx","./src/pages/clanpersonality.tsx","./src/pages/combatcalculator.tsx","./src/pages/combatpreview.tsx","./src/pages/credits.tsx","./src/pages/culturetree.tsx","./src/pages/designgallery.tsx","./src/pages/diplomacy.tsx","./src/pages/empiredashboard.tsx","./src/pages/endgamesummary.tsx","./src/pages/eraprogression.tsx","./src/pages/foodweb.tsx","./src/pages/freepeoplelifecycle.tsx","./src/pages/gamesetup.tsx","./src/pages/gdrustbridge.tsx","./src/pages/gdrustmap.tsx","./src/pages/greatpeople.tsx","./src/pages/greatworks.tsx","./src/pages/harvestpolicies.tsx","./src/pages/hexformation.tsx","./src/pages/hud.tsx","./src/pages/index.tsx","./src/pages/mainmenu.tsx","./src/pages/notifications.tsx","./src/pages/pastgames.tsx","./src/pages/permutations.tsx","./src/pages/promotionpicker.tsx","./src/pages/replay.tsx","./src/pages/settings.tsx","./src/pages/specialists.tsx","./src/pages/statistics.tsx","./src/pages/techtree.tsx","./src/pages/terrainecology.tsx","./src/pages/throneroom.tsx","./src/pages/treatylifecycle.tsx","./src/pages/turnsummary.tsx","./src/pages/unitactions.tsx","./src/pages/worldgen.tsx","./src/pages/hud/minimap.tsx","./src/pages/hud/chrome.ts","./src/pages/hud/positions.ts","./src/pages/worldgen/biometransitions.tsx","./src/pages/worldgen/climate.tsx","./src/pages/worldgen/ecology.tsx","./src/pages/worldgen/hydrology.tsx","./src/pages/worldgen/lab.tsx","./src/pages/worldgen/mappanel.tsx","./src/pages/worldgen/noiseanatomy.tsx","./src/pages/worldgen/pipelinepanel.tsx","./src/pages/worldgen/presets.tsx","./src/pages/worldgen/rng.tsx","./src/pages/worldgen/substrate.tsx","./src/pages/worldgen/tectonics.tsx","./src/pages/worldgen/terraincatalog.tsx","./src/pages/worldgen/whittakerplot.tsx","./src/pages/worldgen/_layerpage.tsx","./src/pages/worldgen/lab/mapcanvas.tsx","./src/pages/worldgen/lab/overlaytoggles.tsx","./src/pages/worldgen/lab/presetbar.tsx","./src/pages/worldgen/lab/tileinspector.tsx","./src/pages/worldgen/lab/constants.ts","./src/pages/worldgen/lab/mapinteractions.ts","./src/pages/worldgen/lab/observations.ts","./src/pages/worldgen/lab/render.ts","./src/pages/worldgen/lab/types.ts","./src/utils/combatcalc.ts","./src/utils/wasm/smoke.ts","./src/utils/wasm/usewasmgrid.ts","./src/utils/worldgen/hexcanvas.test.ts","./src/utils/worldgen/hexcanvas.ts","./src/utils/worldgen/indicatordecorations.ts","./src/utils/worldgen/noise.ts","./src/utils/worldgen/poisson.ts","./src/utils/worldgen/terrain.ts","../../reports/gd-rust-relationships.json","../../../public/resources/audio/library.json","../../../public/games/age-of-dwarves/data/audio/manifest.json","../../../public/games/age-of-dwarves/data/audio/pools.json"],"version":"5.9.3"} \ No newline at end of file diff --git a/public/resources/diplomacy/treaty_rules.json b/public/resources/diplomacy/treaty_rules.json new file mode 100644 index 00000000..b44eb851 --- /dev/null +++ b/public/resources/diplomacy/treaty_rules.json @@ -0,0 +1,84 @@ +{ + "id": "treaty_rules", + "name": "Treaty Length & Renewal Rules", + "description": "Authoritative default durations, renewal mechanics, breach rules, and reputation costs for all bilateral agreement types. Read by mc-trade and mc-ai::diplomacy at agreement creation + per-turn step.", + "default_durations_turns": { + "open_borders": 20, + "shared_map_window": 15, + "luxury_swap": 30, + "defensive_pact": 30, + "declaration_friendship": 30, + "research_agreement": null, + "tribute_non_aggression": null, + "tribute_mercenary": null, + "tribute_luxury_patron": null + }, + "renewal": { + "description": "When turns_remaining drops below 5, the AI evaluates a renewal offer using the same axis-based heuristic as the original signing, plus a +1 trade_willingness bonus for 'ongoing relationship'. The player is auto-prompted 5 turns before expiry.", + "auto_prompt_turns_before_expiry": 5, + "relationship_bonus_to_acceptance": 1, + "cost_multiplier_by_state": { + "hostile": 2, + "wary": 1.5, + "neutral": 1, + "friendly": 0.5, + "allied": 0.25 + }, + "auto_renew_flag": { + "description": "Player can mark an agreement auto_renew=true. The simulator attempts renewal automatically each cycle, drawing payment from the player's gold pool, capped by a player-set sliding budget. Auto-renew aborts if budget insufficient OR partner switches to hostile.", + "default": false + } + }, + "breach": { + "war_declaration": { + "breaks": "all_bilateral", + "reputation_delta_per_clan": -25, + "description": "Declaring war breaks all OpenBorders/SharedMap/LuxurySwap involving the warring pair, plus -25 reputation with every other clan that observed the breach." + }, + "voluntary_cancellation": { + "breaks": "single", + "reputation_delta_with_partner": -15, + "gold_refund_fraction": 0, + "description": "Player can cancel an agreement early. Partner's relation drops by 15. No gold refund (payment already spent)." + }, + "tribute_interruption": { + "breaks": "single", + "influence_decay_per_turn": -1, + "raid_resumes_after_turns": 3, + "description": "Stopping tribute payment to a freepeople camp shifts diplomatic state downward at -1 influence/turn. After 3 turns at lapsed tribute, raid_multiplier returns to baseline for that state." + }, + "courier_intercepted": { + "breaks": "shared_map_single", + "reputation_delta": 0, + "description": "If the SharedMap courier is intercepted, the deal is voided but no reputation cost โ€” both parties acted in good faith. Payment is non-refundable." + } + }, + "edge_cases": [ + { + "id": "war_during_renewal_window", + "description": "If war is declared during the 5-turn renewal-prompt window, the auto-prompt is suppressed and the agreement breaks normally on war." + }, + { + "id": "partner_eliminated", + "description": "If the partner clan is eliminated mid-treaty, the agreement is silently voided. No reputation impact (no-one left to offend)." + }, + { + "id": "tribute_during_war", + "description": "Tribute to a freepeople camp continues even if the player is at war with another player. Camps are independent factions and don't enter player-vs-player wars." + }, + { + "id": "shared_map_pre_delivery_war", + "description": "If war breaks before the SharedMap courier delivers, the deal voids and the courier returns. Payment retained by the original recipient." + } + ], + "encyclopedia": { + "category": "civilization", + "entry_type": "ruleset", + "detail_route": "/treaty-lifecycle", + "tags": [ + "diplomacy", + "treaty", + "renewal" + ] + } +} diff --git a/src/simulator/crates/mc-ai/Cargo.toml b/src/simulator/crates/mc-ai/Cargo.toml index 76e9cea3..6e893344 100644 --- a/src/simulator/crates/mc-ai/Cargo.toml +++ b/src/simulator/crates/mc-ai/Cargo.toml @@ -39,5 +39,13 @@ criterion = { version = "0.5", features = ["html_reports"] } name = "tactical_state_build" harness = false +# p1-30b cycle 6 โ€” MCTS rollout throughput at varying rollout counts. Probes +# whether `Tree::simulate_parallel`'s rayon-based root parallelism is actually +# yielding speedup at the rollout counts the live MCTS path uses (typically +# 1k-20k per decision under the wall-clock budget). +[[bench]] +name = "mcts_rollout_throughput" +harness = false + [lints] workspace = true diff --git a/src/simulator/crates/mc-ai/benches/mcts_rollout_throughput.rs b/src/simulator/crates/mc-ai/benches/mcts_rollout_throughput.rs new file mode 100644 index 00000000..a04522fd --- /dev/null +++ b/src/simulator/crates/mc-ai/benches/mcts_rollout_throughput.rs @@ -0,0 +1,115 @@ +#![allow(missing_docs)] +//! MCTS rollout throughput microbench (p1-30b cycle 6). +//! +//! Measures `Tree::simulate_parallel` throughput at 1k / 5k / 20k rollouts on a +//! synthetic `TreeState` with a configurable per-rollout work budget. The work +//! function (`rollout_fn`) does a small CPU-bound integer hash loop sized to +//! roughly match the cost of a real game rollout (~10-100ฮผs per rollout) so the +//! parallelism gain is observable; if the rollout itself is sub-microsecond, +//! rayon dispatch overhead dominates and parallelism doesn't show. +//! +//! # Determinism +//! +//! All benches set a fixed `base_seed` so output is byte-identical across runs. +//! The `clan_rollout_divergence` integration test in `tests/` is the durable +//! determinism witness; this bench is for throughput characterization only. +//! +//! # What the numbers tell us +//! +//! - At low rollout counts (1k), rayon dispatch overhead may dominate and the +//! parallel path can be SLOWER than a sequential equivalent. This is +//! expected; the parallel path's payoff is at higher rollout counts. +//! - At high rollout counts (20k+), the parallel path should show a roughly +//! linear speedup up to the number of physical cores. Beyond that, the +//! serial select+expand walk dominates. +//! - Compare wall-clock time to the `MCTS_DECISION_BUDGET_MS` knob the live +//! GdMcTreeController uses (typically 2000ms): if a 20k-rollout iteration +//! completes in <2000ms, the budget is sufficient at that scale. +//! +//! Rayon thread count is controlled by the `RAYON_NUM_THREADS` env var. For a +//! N=1 vs N=4 vs N=8 sweep, run with each value set: +//! RAYON_NUM_THREADS=1 cargo bench -p mc-ai --bench mcts_rollout_throughput +//! RAYON_NUM_THREADS=4 cargo bench -p mc-ai --bench mcts_rollout_throughput +//! RAYON_NUM_THREADS=8 cargo bench -p mc-ai --bench mcts_rollout_throughput + +use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion}; +use mc_ai::mcts::XorShift64; +use mc_ai::mcts_tree::{Tree, TreeState}; + +/// Synthetic state: 4-action branching, fixed depth. The action set models the +/// shape of a tactical-AI move space (move / settle / build / wait) without +/// pulling mc-turn into the bench (which would dominate compile time). +#[derive(Clone)] +struct BenchState { + moves: Vec, + max_depth: usize, +} + +impl BenchState { + fn new(max_depth: usize) -> Self { + Self { moves: Vec::new(), max_depth } + } +} + +impl TreeState for BenchState { + type Action = u8; + + fn legal_actions(&self) -> Vec { + if self.moves.len() >= self.max_depth { + Vec::new() + } else { + vec![0, 1, 2, 3] + } + } + + fn apply(&self, action: &u8) -> Self { + let mut next = self.clone(); + next.moves.push(*action); + next + } +} + +/// Synthetic rollout work unit. Does a small CPU-bound integer hash loop so the +/// per-rollout cost is non-trivial (~5-15ฮผs) โ€” otherwise rayon dispatch +/// overhead dwarfs the rollout and parallelism doesn't show. Tuned to roughly +/// match the cost of a softmax-sampled trajectory in `rollout::GameRolloutState`. +fn synthetic_rollout(_state: &BenchState, rng: &mut XorShift64) -> f32 { + let mut x = rng.next_u64(); + // ~1000 hash mixes โ€” chosen by trial to land near 5-15ฮผs/rollout on plum. + for _ in 0..1000 { + x = x.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + x ^= x >> 33; + } + // Convert to [0, 1] reward. Use the result so the optimizer can't elide. + (x as u32 as f32) / (u32::MAX as f32) +} + +fn bench_simulate_parallel_throughput(c: &mut Criterion) { + let mut group = c.benchmark_group("mcts_rollout_throughput"); + // Long enough for rayon dispatch to amortize but short enough for criterion + // to converge in <30s per case. + group.sample_size(20); + + for &n_rollouts in &[1_000usize, 5_000, 20_000] { + group.bench_with_input( + BenchmarkId::from_parameter(n_rollouts), + &n_rollouts, + |b, &n| { + b.iter(|| { + let mut tree = Tree::new(BenchState::new(8)); + tree.simulate_parallel( + black_box(n), + black_box(42u64), + synthetic_rollout, + None, + ); + black_box(&tree); + }); + }, + ); + } + group.finish(); +} + +criterion_group!(benches, bench_simulate_parallel_throughput); +criterion_main!(benches); diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index 1fa125a4..d36f0143 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -1817,6 +1817,78 @@ impl TurnProcessor { }); } + /// p2-55: drain expired ransom offers at start of turn. Each expiry + /// converts the captive into a capture (unit moves from owner's vec to + /// captor's vec, `captive_of` cleared, `UnitCapturedEvent` emitted). + fn process_ransom_expiry(state: &mut GameState, result: &mut TurnResult) { + let expired = state.ransom_queue.tick(state.turn); + for offer in expired { + // Locate the captive in the owner's vec by stable unit id. + let owner_idx = offer.owner as usize; + if owner_idx >= state.players.len() { + continue; + } + let Some(unit_idx) = state.players[owner_idx] + .units + .iter() + .position(|u| u.id == offer.unit_id) + else { + // Unit no longer exists (killed by another path) โ€” drop offer. + continue; + }; + // Snapshot pos for the event, then move the unit into the + // captor's vec via the same helper used for direct captures. + let (col, row) = ( + state.players[owner_idx].units[unit_idx].col, + state.players[owner_idx].units[unit_idx].row, + ); + // Clear the captive marker before transfer (transfer_captured_unit + // also clears it on the moved copy, but doing it here keeps the + // helper's pre-move snapshot consistent). + state.players[owner_idx].units[unit_idx].captive_of = None; + + // We don't have `&self` here (associated fn), so duplicate the + // small subset of transfer logic inline. Keeps the call site + // free of a self threading change. + let unit_snapshot = state.players[owner_idx].units[unit_idx].clone(); + let upkeep = state.players[owner_idx].unit_upkeep.get(unit_idx).copied(); + mc_combat::credit_resources( + &unit_snapshot.held_resources, + &mut state.players[owner_idx].strategic_ledger, + ); + let p = &mut state.players[owner_idx]; + p.units.swap_remove(unit_idx); + if unit_idx < p.unit_upkeep.len() { + p.unit_upkeep.swap_remove(unit_idx); + } + let mut moved = unit_snapshot; + moved.held_resources.clear(); + moved.captive_of = None; + moved.posture_override = None; + moved.is_fortified = false; + moved.is_sentrying = false; + moved.current_action = None; + moved.patrol_order = None; + moved.formation_id = None; + + let captor_idx = offer.captor as usize; + if captor_idx < state.players.len() { + let cs = &mut state.players[captor_idx]; + cs.units.push(moved); + cs.unit_upkeep.push(upkeep.unwrap_or(0)); + } + + result.units_captured.push(UnitCapturedEvent { + turn: state.turn, + unit_id: offer.unit_id, + captor: offer.captor, + prior_owner: offer.owner, + col, + row, + }); + } + } + fn enqueue_ransom_offer( &self, state: &mut GameState, diff --git a/src/simulator/crates/mc-turn/tests/capture_posture.rs b/src/simulator/crates/mc-turn/tests/capture_posture.rs new file mode 100644 index 00000000..a1f48b19 --- /dev/null +++ b/src/simulator/crates/mc-turn/tests/capture_posture.rs @@ -0,0 +1,96 @@ +//! p2-55 Wave 1 โ€” `CapturePosture` precedence and conversion to the resolver +//! enum. The user's task spec lists this file at +//! `src/simulator/crates/mc-state/tests/capture_posture.rs`, but `mc-state` +//! does not exist โ€” `MapUnit` and `PlayerState` live in `mc-turn`, so the +//! test lives here too. + +use mc_combat::resolver::PostureResolution; +use mc_turn::capture::{resolve_posture, CapturePosture, PromptUnresolved}; +use mc_turn::game_state::{MapUnit, PlayerState}; + +fn unit_with_override(o: Option) -> MapUnit { + MapUnit { posture_override: o, ..MapUnit::default() } +} + +#[test] +fn unit_override_wins_over_relation_and_global() { + let mut player = PlayerState::default(); + player.default_civilian_posture = CapturePosture::Capture; + player.civilian_posture.insert(7, CapturePosture::Ransom); + let unit = unit_with_override(Some(CapturePosture::Destroy)); + + assert_eq!( + resolve_posture(&player, &unit, 7), + CapturePosture::Destroy, + "per-unit override must beat per-relation map" + ); +} + +#[test] +fn relation_default_wins_over_global_when_no_override() { + let mut player = PlayerState::default(); + player.default_civilian_posture = CapturePosture::Capture; + player.civilian_posture.insert(2, CapturePosture::Ransom); + let unit = unit_with_override(None); + + assert_eq!( + resolve_posture(&player, &unit, 2), + CapturePosture::Ransom, + "relation default should be used when no per-unit override" + ); +} + +#[test] +fn falls_back_to_global_default_when_no_relation_or_override() { + let mut player = PlayerState::default(); + player.default_civilian_posture = CapturePosture::Destroy; + let unit = unit_with_override(None); + + assert_eq!( + resolve_posture(&player, &unit, 99), + CapturePosture::Destroy, + ); +} + +#[test] +fn ai_default_seed_is_capture() { + // The seeding itself happens at game-setup time outside this crate, but + // `Default` must produce `Capture` so AI seats inherit a sensible value + // without explicit initialisation. + assert_eq!(CapturePosture::default(), CapturePosture::Capture); + let player = PlayerState::default(); + assert_eq!(player.default_civilian_posture, CapturePosture::Capture); +} + +#[test] +fn human_seat_can_be_seeded_to_prompt() { + // Sanity: the enum's `Prompt` variant round-trips through `PlayerState`. + let mut player = PlayerState::default(); + player.default_civilian_posture = CapturePosture::Prompt; + let unit = unit_with_override(None); + assert_eq!(resolve_posture(&player, &unit, 0), CapturePosture::Prompt); +} + +#[test] +fn try_from_prompt_errors() { + assert_eq!( + PostureResolution::try_from(CapturePosture::Prompt), + Err(PromptUnresolved), + ); +} + +#[test] +fn try_from_concrete_postures_succeeds() { + assert_eq!( + PostureResolution::try_from(CapturePosture::Capture), + Ok(PostureResolution::Capture), + ); + assert_eq!( + PostureResolution::try_from(CapturePosture::Destroy), + Ok(PostureResolution::Destroy), + ); + assert_eq!( + PostureResolution::try_from(CapturePosture::Ransom), + Ok(PostureResolution::Ransom), + ); +} diff --git a/src/simulator/crates/mc-turn/tests/ransom.rs b/src/simulator/crates/mc-turn/tests/ransom.rs new file mode 100644 index 00000000..c35be923 --- /dev/null +++ b/src/simulator/crates/mc-turn/tests/ransom.rs @@ -0,0 +1,102 @@ +//! p2-55 Wave 1 โ€” `RansomQueue` lifecycle: push, tick (expiry), accept, +//! refuse, pending_for filter. + +use mc_turn::ransom::{RansomQueue, RANSOM_OFFER_DURATION_TURNS}; + +#[test] +fn push_returns_monotonic_ids_and_records_offer() { + let mut q = RansomQueue::default(); + let id0 = q.push(101, 1, 2, 50, 10); + let id1 = q.push(102, 1, 3, 75, 10); + + assert_eq!(id0, 0); + assert_eq!(id1, 1); + assert_eq!(q.len(), 2); + + let offer = q.iter().find(|o| o.id == id0).expect("offer 0 present"); + assert_eq!(offer.unit_id, 101); + assert_eq!(offer.captor, 1); + assert_eq!(offer.owner, 2); + assert_eq!(offer.price, 50); + assert_eq!(offer.created_turn, 10); + assert_eq!(offer.expires_turn, 10 + RANSOM_OFFER_DURATION_TURNS); +} + +#[test] +fn tick_at_creation_turn_does_not_expire() { + let mut q = RansomQueue::default(); + let _ = q.push(101, 1, 2, 50, 10); + let expired = q.tick(10); + assert!(expired.is_empty(), "fresh offer should not expire on creation turn"); + assert_eq!(q.len(), 1); +} + +#[test] +fn tick_at_expiry_turn_returns_and_removes() { + let mut q = RansomQueue::default(); + let _ = q.push(101, 1, 2, 50, 10); + let expires = 10 + RANSOM_OFFER_DURATION_TURNS; + + // Just before expiry โ€” still pending. + let early = q.tick(expires - 1); + assert!(early.is_empty()); + assert_eq!(q.len(), 1); + + // At expiry โ€” drained. + let expired = q.tick(expires); + assert_eq!(expired.len(), 1); + assert_eq!(expired[0].unit_id, 101); + assert!(q.is_empty()); +} + +#[test] +fn accept_removes_and_returns_offer() { + let mut q = RansomQueue::default(); + let id = q.push(101, 1, 2, 50, 10); + + let taken = q.accept(id).expect("accept returns offer"); + assert_eq!(taken.id, id); + assert!(q.is_empty()); + assert!(q.accept(id).is_none(), "second accept yields None"); +} + +#[test] +fn refuse_removes_and_returns_offer() { + let mut q = RansomQueue::default(); + let id = q.push(101, 1, 2, 50, 10); + + let taken = q.refuse(id).expect("refuse returns offer"); + assert_eq!(taken.id, id); + assert!(q.is_empty()); +} + +#[test] +fn pending_for_filters_by_owner() { + let mut q = RansomQueue::default(); + q.push(101, 1, 2, 50, 10); + q.push(102, 1, 3, 75, 10); + q.push(103, 1, 2, 25, 11); + + let owner_2: Vec<_> = q.pending_for(2).map(|o| o.unit_id).collect(); + let owner_3: Vec<_> = q.pending_for(3).map(|o| o.unit_id).collect(); + + assert_eq!(owner_2.len(), 2); + assert!(owner_2.contains(&101)); + assert!(owner_2.contains(&103)); + assert_eq!(owner_3, vec![102]); +} + +#[test] +fn tick_drains_only_expired_leaves_others() { + let mut q = RansomQueue::default(); + q.push(101, 1, 2, 50, 10); // expires at 10 + duration + q.push(102, 1, 2, 75, 50); // expires at 50 + duration + let early_expiry = 10 + RANSOM_OFFER_DURATION_TURNS; + + let expired = q.tick(early_expiry); + assert_eq!(expired.len(), 1); + assert_eq!(expired[0].unit_id, 101); + assert_eq!(q.len(), 1); + let remaining: Vec<_> = q.iter().map(|o| o.unit_id).collect(); + assert_eq!(remaining, vec![102]); +}