From 3765dc8b9fe3ba705f818487afb8e3dfc8b814fe Mon Sep 17 00:00:00 2001 From: Natalie Date: Mon, 8 Jun 2026 00:35:20 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20debug=20pvp=20combat=20logic=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .project/designs/p2-80-bullet2-port-sizing.md | 39 +++++++++++++++++++ src/simulator/crates/mc-turn/src/processor.rs | 10 +++++ 2 files changed, 49 insertions(+) diff --git a/.project/designs/p2-80-bullet2-port-sizing.md b/.project/designs/p2-80-bullet2-port-sizing.md index 1418d3ad..a9419a45 100644 --- a/.project/designs/p2-80-bullet2-port-sizing.md +++ b/.project/designs/p2-80-bullet2-port-sizing.md @@ -86,3 +86,42 @@ world-events via the bridge, orchestrated by `turn_manager.gd`), proven 2 is the residual *architectural-purity* question of **where the turn orchestration lives** (Rust vs GDScript) — unblockable only by the inversion above. + +--- + +## Step 1 root-cause (2026-06-08) — why the Rust turn isn't live-grade + +Probed `dominion_bench` (real `mc-turn::TurnProcessor::step`, 4 AI clans) at two +map sizes. **Both produce functional but militarily-inert games:** + +| probe | victory | PvP battles | captures | runaway | +|---|---|---|---|---| +| 40×40, 4p | P2 city_count @ t87 | **0** | **0** | P1 → 111 cities | +| 20×20, 4p (forced adjacency) | P2 city_count @ t51 | **0** | **0** | P2 → **214 cities / 400 tiles** | + +Even with four clans in adjacent corners of a 20×20 map, **zero** inter-player +combat. P0/P1/P3 end with 0 cities/0 units yet `cities_lost = cities_captured = +pvp_d = 0` — eliminated without recorded combat (P2's unbounded founding +consumed the map). + +**Verified root cause:** the capability is all present and composed — +`mc-turn` wires `process_pvp_combat` (`processor.rs:522`), city siege (`:524`), +and `mc_ai::tactical`; `mc-ai` evaluates offensive moves ("advance toward each +enemy city", `evaluator.rs:134-150`). **But `mc-turn`'s per-unit movement loop +seeks `nearest_lair` (`processor.rs:2025, 2271` — PvE only), bypassing +`mc-ai`'s tactical targeting.** Units never move toward enemies → never become +adjacent → `process_pvp_combat` never triggers. This is the "bench-grade AI +decision layer" the `mc-turn` header names: real rules crates, minimal movement +heuristics. + +**Two gaps to close (pure-Rust, benched, zero Godot risk):** +1. **Movement → mc-ai tactical.** Drive per-unit movement from `mc-ai`'s + tactical advance/defend candidates (which already target enemy cities), not + the inline lair-seek. This makes contact happen → PvP/siege fire. +2. **Expansion sanity.** `try_found_city` needs city-spacing + a soft cap so + the expansion axis stops carpeting the map (214 cities/400 tiles is + degenerate; the live game spaces cities). + +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. diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index 9403346c..c2892cb0 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -3455,6 +3455,16 @@ impl TurnProcessor { if killed.iter().any(|&(p, u)| p == def_pi && u == def_ui) { continue; } + // Skip if the unit no longer exists: an earlier kill/capture in this + // same phase `swap_remove`d it (or emptied the defender's vec), + // leaving this pending-capture index stale. Indexing the shrunk vec + // would panic — this is the turn-8 PvP crash at processor.rs:2697 + // ("len is 0 but the index is 0") that surfaces once the real AI + // (`run_ai_turn`) drives actual captures. Same intent as the + // `killed` dedup above: a unit that left the vec is not capturable. + if def_pi >= state.players.len() || def_ui >= state.players[def_pi].units.len() { + continue; + } self.transfer_captured_unit(state, def_pi, def_ui, captor); // Re-index the leftover `killed` and `ransom_pending` entries that // referred to indices ≥ def_ui in the same player vec. swap_remove