diff --git a/.project/designs/p2-80-bullet2-port-sizing.md b/.project/designs/p2-80-bullet2-port-sizing.md index a9419a45..f9c5593f 100644 --- a/.project/designs/p2-80-bullet2-port-sizing.md +++ b/.project/designs/p2-80-bullet2-port-sizing.md @@ -125,3 +125,37 @@ heuristics. Closing both is the substance of cutover step 1 — the Rust turn must produce games shaped like the GDScript autoplay (combat present, sane city counts) before any path cuts over to it. This is the genuinely multi-session leg. + +--- + +## Step 1 RESULT (2026-06-08) — Rust turn + real AI = live-grade. Foundation de-risked. + +The step-1 question ("can the Rust turn be the authoritative turn?") is answered +**yes, with evidence.** + +**Bug fixed:** `mc-turn` panicked at `processor.rs:2697` on turn 8 whenever the +real AI (`run_ai_turn`) drove an actual capture — a stale capture index into a +vec emptied by an earlier same-phase kill/capture. Fixed with a bounds guard +matching the existing `killed`-dedup intent (committed `e21381037`). 234 mc-turn +tests pass; the one failing test (`five_players_overflow`) is pre-existing +(fails identically on HEAD, unrelated — chip filed). + +**Viability proven:** with the panic fixed, the previously-blocked 250-turn +`long_game_transcript` (3 clans, slots 1+2 = real `run_ai_turn`) runs to turn 249 +and produces a **live-grade game**: **2531 units killed**, 50 cities founded, +slot 1 winning by military dominance (47 cities / 439 units) while slot 0 is +conquered from 8 cities down to 1/0. Real combat, conquest, city loss, a winner +by force. + +**The decisive contrast:** `dominion_bench` (inline `nearest_lair` AI) → **0** +PvP, 214-city runaway. `long_game_transcript` (`run_ai_turn`) → **2531 kills**, +47-city winner. **Same `mc-turn::step`, different AI.** The gap to live-grade was +never the rules or the turn processor — it is purely that the bench (and any +future authoritative path) must drive `run_ai_turn` via the controller registry, +not `mc-turn`'s inline stub. + +**Next increment (cutover proper):** route the bench / authoritative path through +`mc_player_api`'s controller dispatch (`drive_ai_slot` → `run_ai_turn`), +**retiring `mc-turn`'s inline `nearest_lair` movement** (dead-stub removal, +no-debt). Then expansion-pacing reconciliation if 47 cities still reads high vs +the GDScript autoplay on a matched map. diff --git a/src/simulator/crates/mc-turn/src/abstract_projection.rs b/src/simulator/crates/mc-turn/src/abstract_projection.rs index 52b7867c..e24d3d1e 100644 --- a/src/simulator/crates/mc-turn/src/abstract_projection.rs +++ b/src/simulator/crates/mc-turn/src/abstract_projection.rs @@ -33,9 +33,9 @@ use crate::game_state::{GameState, MapUnit, PlayerState, TechState}; /// [`AbstractRolloutState`]. /// /// Player slots are filled in `state.players` order, capped at -/// [`MAX_PLAYERS`] (= 5). Excess players are dropped — Game 1 ships 5-clan -/// max so this overflow path should never trigger. Missing slots stay -/// zero-initialised (the POD's `Zeroable` default). +/// [`MAX_PLAYERS`] (= 12, the `huge` map's `max_players`). Excess players are +/// dropped without panic. Missing slots stay zero-initialised (the POD's +/// `Zeroable` default). /// /// Determinism: same `GameState` → byte-identical POD. The only RNG-touching /// field is `rng_state`, derived via `derive_step(SeedDomain::AiRollout, …)`. diff --git a/src/simulator/crates/mc-turn/tests/abstract_projection.rs b/src/simulator/crates/mc-turn/tests/abstract_projection.rs index d94cdbc8..29dda119 100644 --- a/src/simulator/crates/mc-turn/tests/abstract_projection.rs +++ b/src/simulator/crates/mc-turn/tests/abstract_projection.rs @@ -216,23 +216,25 @@ fn four_player_projection_fills_every_slot() { } } -// ── 4. Five-player overflow ───────────────────────────────────────────────── +// ── 4. Player overflow ────────────────────────────────────────────────────── #[test] -fn five_players_overflow_truncates_to_max_players() { +fn overflow_truncates_to_max_players() { + // Push MAX_PLAYERS + 1 players to exercise the truncation path: the POD has + // exactly MAX_PLAYERS (= 12, the `huge` map's max_players) slots, so the + // excess player must be dropped without panic or overflow. let mut state = GameState::default(); - for i in 0..5u8 { + for i in 0..(MAX_PLAYERS as u8 + 1) { let mut p = player(i); p.gold = 7; state.players.push(p); } let pod = to_abstract_rollout_state(&state); - // Only the first MAX_PLAYERS=4 slots are populated. + // All MAX_PLAYERS slots are populated from the first MAX_PLAYERS players. for i in 0..MAX_PLAYERS { assert_eq!(pod.players[i].gold, 7, "slot {i} not populated"); } - // The 5th player is silently dropped — no panic, no overflow. - // (Only 4 POD slots exist; nothing more to assert.) + // The (MAX_PLAYERS+1)th player is silently dropped — no panic, no overflow. } // ── 5. Determinism ──────────────────────────────────────────────────────────