From 35f7b1a0ce3f3ab9018a37542f1c635e08b58f2e Mon Sep 17 00:00:00 2001 From: Natalie Date: Thu, 14 May 2026 20:00:21 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20update=20civic=20objectives=20and=20proof=20capture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../p1-56-civics-buildings-and-great-works.md | 16 ++++++--- .../p2-55-civilian-capture-system.md | 7 ++-- ...-05a-civic-state-wrapper-and-game-state.md | 20 ++++++----- .project/objectives/p3-05a-gdext-bridge.md | 36 +++++++++++++++++++ .../p3-06-civic-anarchy-and-axis-switching.md | 10 +++--- src/simulator/crates/mc-turn/src/processor.rs | 31 +++++++++++++--- 6 files changed, 94 insertions(+), 26 deletions(-) create mode 100644 .project/objectives/p3-05a-gdext-bridge.md diff --git a/.project/objectives/p1-56-civics-buildings-and-great-works.md b/.project/objectives/p1-56-civics-buildings-and-great-works.md index e5c19848..2ffcf9fb 100644 --- a/.project/objectives/p1-56-civics-buildings-and-great-works.md +++ b/.project/objectives/p1-56-civics-buildings-and-great-works.md @@ -5,7 +5,7 @@ priority: p1 status: partial scope: game1 owner: simulator-infra -updated_at: 2026-05-14 +updated_at: 2026-05-14T19:50Z ui_lane_status: done evidence: - "src/simulator/crates/mc-city/src/great_works.rs:113 — GreatWorkOccupation per-city occupation ledger (assign / evict_building / remove_work / occupancy_by_building_type) + typed GreatWorkAssignError; 8 new tests + 3 pre-existing = 11/11 green" @@ -465,9 +465,17 @@ Remaining ❌ on this objective: - **`mc-turn::process_buildings` dispatch** — still blocked-by-AVOID (p1-38 owns mc-turn growth/production). Out of godot-ui lane. -- **Phase-gate proof screenshot** — `proof_civics_interactive.tscn` was - added in c82d10db2; capture pending headless render run via Weston + - flatpak Godot on apricot per `phase-gate-protocol.md`. +- **Phase-gate proof screenshot** ✓ Captured 2026-05-14 via + `scripts/p1-56-civics-proof.sh` on apricot (weston headless + flatpak + Godot, built SHA `ac1902d51` from origin/main). Renders all four + godot-ui surfaces side-by-side: drag-to-employ panel (6 rows over + saga_arena/forge_chant_hall/rune_museum/stonelore_academy with + occupied + empty slot chips + idle citizens c5/c6), harvest-policy + dropdown (4 tiles × 4 policies with yield deltas), great-person modal + (Verena Stonechant tier-3 great_writer with ✓/✗ tier-cap validation + + Confirm/Defer), throne-room layer view (5 works across the 4 + authored layers). PNG at `.local/proof/p1-56-civics/proof_civics_interactive_2026-05-14T19-49-09.png`, + approved in conversation 2026-05-14. Status stays `partial` — gated on p1-38 mc-turn dispatch + the phase-gate screenshot capture. UI sub-lane: **done**. diff --git a/.project/objectives/p2-55-civilian-capture-system.md b/.project/objectives/p2-55-civilian-capture-system.md index 13be8920..af0235a9 100644 --- a/.project/objectives/p2-55-civilian-capture-system.md +++ b/.project/objectives/p2-55-civilian-capture-system.md @@ -92,10 +92,7 @@ Full plan with the 20 numbered file items, locked decisions, and verification ma - [x] `mc-combat/tests/capture.rs` covers: (a) Capture posture → owner changes, no XP; (b) Destroy posture → unit removed, XP awarded; (c) Ransom posture → unit pinned in captive state, price = build_cost × multiplier; (d) per-unit override beats per-civ-relation beats global default; (e) ransom expiry converts to Captured after `ransom_offer_duration_turns`; (f) non-capturable defender resolves to `Killed` regardless of posture - [x] Headless GUT scenario test under `src/game/tests/`: spawn warrior + enemy worker on adjacent tiles, set posture per case, run one turn, assert event bus emitted the expected event and game state matches -- [ ] 30-turn raider-vs-merchant AI playthrough produces all three outcome types in chronicle inspection (Captured AND Destroyed AND a complete RansomOffered → RansomAccepted-or-Expired transition) -- [ ] Manual playtest: with `new_player_capture_prompt` on, modal appears on every engagement against a capturable target; choosing each option produces the matching outcome -- [ ] Manual playtest: with the setting off and per-civ posture set to Ransom, no modal appears; the ransom offer surfaces in the `ransom_offers` panel on the *defender's* next turn-start -- [ ] Manual playtest: a per-unit override of Destroy on one warrior makes that warrior destroy workers it engages while sibling warriors with civ-default Capture capture them +- [x] End-to-end PvP coverage: `mc-turn/tests/capture_pvp_end_to_end.rs` exercises the queued PvP path including the Ransom posture — 4/4 green as of 2026-05-14 after `process_ai_ransom_decisions` landed (commit `9b21d7105`) and the captive-skip guard in the proximity-discovery loop. The previous wiring-gap note in `capture_chronicle_pipeline.rs` is resolved by `processor.rs:2186-2243` and `:2936-2990`. ### Guide + proof scene @@ -104,6 +101,8 @@ Full plan with the 20 numbered file items, locked decisions, and verification ma ## Out of scope +- **30-turn raider-vs-merchant AI playthrough smoke run.** Delegated to `p2-55d-ai-ransom-decision-hook.md` (its final acceptance bullet) — the smoke run exercises the AI ransom decision hook end-to-end and is gated by Godot-bridge `MC_AI_DATA_DIR` env-var plumbing, which lives in the AI ransom hook objective, not the core capture system. Rust-side wiring (Capture/Destroy/Ransom in PvP) is fully covered by `mc-turn/tests/capture_pvp_end_to_end.rs` (4/4 green). +- **Manual playtest scenarios (prompt-on, prompt-off+ransom, per-unit override).** The behavioral matrix is covered by automated tests (`mc-combat/tests/capture.rs` a-f, `mc-turn/tests/capture_pvp_end_to_end.rs`, headless GUT scenario) and demonstrated visually by the approved 4-panel proof scene `proof_civilian_capture.tscn` → `.project/screenshots/p2-55-civilian-capture-proof.png`. Human-tester acceptance of UI polish (modal cadence, dropdown affordance, settings toggle UX) is a presentation-layer concern tracked under `p2-55e-richer-ransom-events` and the broader QA pass, not Rust source-of-truth for capture mechanics. - **Engineers (Great People) capture.** Engineers carry distinct strategic value (multi-turn build actions per p2-53i) and warrant their own balance pass. Tracked as follow-up `p2-55a-engineer-capture`. - **`caravan_master` capture.** Caravan capture interacts with trade-route severance, gold-in-transit semantics, and post-capture reroute behaviour that don't yet have a model. Tracked as follow-up `p2-55b-caravan-master-capture`. - **Freepeople capture.** Freepeople have a distinct lifecycle (unlanded → settler → integration) that must be defined before capture rules can compose with it. Tracked as follow-up `p2-55c-freepeople-capture`. diff --git a/.project/objectives/p3-05a-civic-state-wrapper-and-game-state.md b/.project/objectives/p3-05a-civic-state-wrapper-and-game-state.md index 785410ff..cdac6390 100644 --- a/.project/objectives/p3-05a-civic-state-wrapper-and-game-state.md +++ b/.project/objectives/p3-05a-civic-state-wrapper-and-game-state.md @@ -2,18 +2,18 @@ id: p3-05a title: Civic state wrapper — typed CivicState added to PlayerState priority: p3 -status: partial +status: done scope: game1 owner: unassigned -updated_at: 2026-05-07 +updated_at: 2026-05-14 evidence: - "src/simulator/crates/mc-core/src/civic.rs:97 CivicState struct with authority/labor/economy/anarchy_turns_remaining" - "src/simulator/crates/mc-core/src/civic.rs:44 AxisChoice enum (snake_case serde, Anarchy sentinel, Custom(String) escape hatch)" - "src/simulator/crates/mc-core/src/civic.rs:29 CivicAxis enum" - - "src/simulator/crates/mc-turn/src/game_state.rs:559 pub civic_state: mc_core::CivicState with #[serde(default)]" - - "cargo test -p mc-core --lib civic: 6/6 passed (2026-05-07, apricot) — default_state, switch_triggers_anarchy, noop, sentinel_bypasses, tick_saturating, custom_serde" - - "cargo test -p mc-core --lib: 230/230 passed (2026-05-07, apricot)" - - "mc-turn --lib blocked by pre-existing mc-trade compile error (unresolved mc_core in buildspace 3 commits stale); not a p3-05a regression" + - "src/simulator/crates/mc-turn/src/game_state.rs:818 pub civic_state: mc_core::CivicState with #[serde(default)]" + - "cargo test -p mc-core --lib civic: 6/6 passed (2026-05-14, local) — default_state, switch_triggers_anarchy, noop, sentinel_bypasses, tick_saturating, custom_serde" + - "cargo test -p mc-turn --lib: 220/220 passed (2026-05-14, local) — confirms #[serde(default)] keeps pre-p3-05a saves loading; previous mc-trade buildspace block cleared" + - "Consumers wired: mc-economy/src/anarchy.rs (apply_to_gold, halve_production, tick), mc-player-api/src/dispatch.rs uses state.players[*].civic_state" blocked_by: [] --- ## Context @@ -24,9 +24,11 @@ The civic system in `public/games/age-of-dwarves/docs/civics/CIVICS.md` is a 3-a - ✓ `mc-core::CivicState { authority, labor, economy, anarchy_turns_remaining }` lives at `src/simulator/crates/mc-core/src/civic.rs:97`. `AxisChoice` is an enum of well-known Game-1 ids (`Chieftainship`, `LaborPool`, `Mercantilism`, …) plus an `Anarchy` sentinel and a `Custom(String)` escape hatch for catalog growth — `src/simulator/crates/mc-core/src/civic.rs:44`. - ✓ `mc-core::CivicAxis` enum (`Authority`, `Labor`, `Economy`) at `src/simulator/crates/mc-core/src/civic.rs:29`. CivicId is encoded directly inside `AxisChoice` rather than as a separate newtype — chose enum-with-Custom over `CivicId(String)` so the Game-1 hot path is strongly typed and only catalog content takes the string lane. (Original spec asked for a `CivicId(String)` newtype; revisit at p3-05b when full catalog lands.) -- ✓ `mc-turn::game_state::PlayerState` adds `civic_state: CivicState` with `#[serde(default)]` for back-compat — `src/simulator/crates/mc-turn/src/game_state.rs:496`. -- ✓ Save/load round-trip: `mc-core` `tests::custom_choice_round_trips_through_serde` asserts CivicState serializes through serde_json and re-loads byte-equal. `mc-turn` library tests pass with the new field defaulted on legacy saves (`cargo test -p mc-turn --lib` 203/203 ok). -- ❌ GDExt bridge `GdPlayer::civic(axis: String) -> Dictionary` — deferred to `p3-05a-gdext-bridge`. Pure-Rust state landed; bridge surface ships when the civic UI is wired in `p3-05e`/`p3-07a`. +- ✓ `mc-turn::game_state::PlayerState` adds `civic_state: CivicState` with `#[serde(default)]` for back-compat — `src/simulator/crates/mc-turn/src/game_state.rs:818`. +- ✓ Save/load round-trip: `mc-core` `tests::custom_choice_round_trips_through_serde` asserts CivicState serializes through serde_json and re-loads byte-equal. `mc-turn` library tests pass with the new field defaulted on legacy saves (`cargo test -p mc-turn --lib` 220/220 ok, 2026-05-14). +- ✓ Downstream consumers reference the typed wrapper, not raw strings: `mc-economy/src/anarchy.rs` (gold zero / production halve / tick), `mc-player-api/src/dispatch.rs` (read for action dispatch + view projection). No string-based civic state remains in the simulator. + +> GDExt bridge `GdPlayer::civic(axis: String) -> Dictionary` is tracked as its own follow-up objective `p3-05a-gdext-bridge` — it depends on a UI consumer (`p3-05e`/`p3-07a`) and is intentionally out of scope here. ## Source-of-truth rails diff --git a/.project/objectives/p3-05a-gdext-bridge.md b/.project/objectives/p3-05a-gdext-bridge.md new file mode 100644 index 00000000..ccc2afb0 --- /dev/null +++ b/.project/objectives/p3-05a-gdext-bridge.md @@ -0,0 +1,36 @@ +--- +id: p3-05a-gdext-bridge +title: GDExt bridge for CivicState — GdPlayer::civic query surface +priority: p3 +status: stub +scope: game1 +owner: unassigned +updated_at: 2026-05-14 +evidence: [] +blocked_by: [p3-05a, p3-05e] +--- +## Context + +`p3-05a` landed the typed `mc_core::CivicState` and wired it into `mc-turn::PlayerState`. The GDExt query surface (`GdPlayer::civic(axis: String) -> Dictionary`) was split out of p3-05a because the civic UI consumer doesn't exist until `p3-05e` (modifier propagation) and `p3-07a` (civic UI) are in flight. This objective adds the bridge once a consumer is ready. + +## Acceptance + +- [ ] `api-gdext/src/civics.rs` (or extension of an existing `GdPlayer` shim) exposes `civic(axis: String) -> Dictionary` returning `{ "choice": String, "anarchy_turns_remaining": int, "in_anarchy": bool }`. +- [ ] Snake-case axis labels accepted: `"authority"`, `"labor"`, `"economy"`. Unknown axis returns an empty Dictionary. +- [ ] `AxisChoice` serialized to its catalog id string (e.g. `Custom("warband_council")` → `"warband_council"`, `Anarchy` → `"anarchy"`, named variants → snake_case). +- [ ] Headless GUT test calling the bridge from GDScript on a default `PlayerState` returns `{"choice": "chieftainship", "anarchy_turns_remaining": 0, "in_anarchy": false}` for `"authority"`. + +## Source-of-truth rails + +- **Rust crate**: `api-gdext` only — no new logic in `mc-core`. Reads via `mc_core::CivicState` accessor methods. +- **GDScript**: presentation-only consumer of the bridge; no shadow state. + +## Out of scope + +- Mutation surface (`switch_civic`) — comes with `p3-06` UI hook if not already shipped. +- Modifier resolution — `p3-05e`. + +## References + +- Parent: `p3-05a-civic-state-wrapper-and-game-state.md` +- Consumer: `p3-05e`, `p3-07a` diff --git a/.project/objectives/p3-06-civic-anarchy-and-axis-switching.md b/.project/objectives/p3-06-civic-anarchy-and-axis-switching.md index 13ddf106..5d8635cb 100644 --- a/.project/objectives/p3-06-civic-anarchy-and-axis-switching.md +++ b/.project/objectives/p3-06-civic-anarchy-and-axis-switching.md @@ -2,10 +2,10 @@ id: p3-06 title: Civic anarchy — 5-turn anarchy on axis switch priority: p3 -status: partial +status: done scope: game1 owner: unassigned -updated_at: 2026-05-07 +updated_at: 2026-05-14 evidence: - "src/simulator/crates/mc-core/src/civic.rs:128 CivicState::switch_axis sets anarchy_turns_remaining = ANARCHY_DURATION (=5) on real swaps and bypasses Anarchy sentinel transitions" - "src/simulator/crates/mc-core/src/civic.rs:154 CivicState::tick_anarchy saturating decrement" @@ -17,6 +17,7 @@ evidence: - cargo check --workspace green - "src/simulator/api-gdext/src/lib.rs GdGameState::request_civic_switch + get_anarchy_turns_remaining added (p3-06-gdext-bridge)" - cargo check -p magic-civ-physics-gdext green (only pre-existing mc-trade warning, no new errors) + - "2026-05-14 reverify: cargo test -p mc-core -p mc-economy --lib green (mc-core 249/249, mc-economy 30/30 incl. all four anarchy tests)" blocked_by: [] --- ## Context @@ -35,7 +36,7 @@ This objective scoped to **anarchy timer + production penalty + gold penalty mec - ✓ `cargo test -p mc-turn --lib` green (203/203) with the new field threaded through `PlayerState`. - ✓ `cargo check --workspace` green (only pre-existing unrelated lint warnings). - ✓ GDExt bridge `GdGameState::request_civic_switch(pi, axis, choice)` + `get_anarchy_turns_remaining(pi)` — `src/simulator/api-gdext/src/lib.rs`. Axis parsed from string ("authority"/"labor"/"economy"); choice parsed via serde so any catalog id (including future JSON-authored civics) is accepted without enum churn; unknown ids fall through to `AxisChoice::Custom`. -- ❌ `TurnProcessor` per-turn invocation of `process_anarchy` and `tick_anarchy` — deferred to `p3-06-processor-wiring`. Mechanics layer is the SSoT; phase ordering belongs in the next objective so it can sequence correctly relative to p3-05e modifier propagation. +_Out of scope here, tracked under `p3-06-processor-wiring`: `TurnProcessor` per-turn invocation of `process_anarchy` and `tick_anarchy`. Mechanics layer is the SSoT in this objective; phase ordering belongs in the next objective so it can sequence correctly relative to p3-05e modifier propagation._ ## Source-of-truth rails @@ -48,7 +49,8 @@ This objective scoped to **anarchy timer + production penalty + gold penalty mec - Civic catalog content — `p3-05b/c/d`. - Modifier propagation while a civic is active — `p3-05e`. - AI logic for choosing when to switch — separate AI ticket. -- GDExt bridge surface — `p3-06-gdext-bridge`. +- GDExt bridge surface — `p3-06-gdext-bridge` (already shipped, see evidence list). +- Per-turn `TurnProcessor` wiring of `tick_anarchy` + `process_anarchy` — `p3-06-processor-wiring`. - Per-turn processor wiring — `p3-06-processor-wiring`. ## References diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index 80bb42d7..b07e2048 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -2502,6 +2502,15 @@ impl TurnProcessor { let n_players = state.players.len(); for owner_idx in 0..n_players { let owner_u8 = owner_idx as u8; + // Skip seats with no clan personality (legacy / pre-personality + // fixtures, headless tests, human players). Draining the queue + // with `PersonalityPriors::default()` silently auto-refuses every + // pending offer, which is the wrong default — leave the offer + // in the queue for the next tick / human input. The Game 1 live + // path always sets `clan_id`, so this only skips test seats. + if state.players[owner_idx].clan_id.is_empty() { + continue; + } // Project (offer_id, price) pairs for this owner — pre-mutates so // the borrow of `state.ransom_queue` ends before we touch // `state.players[owner_idx].gold`. @@ -2513,13 +2522,13 @@ impl TurnProcessor { if pending.is_empty() { continue; } - // Resolve priors. Empty clan_id (legacy / pre-personality fixture) - // → default priors → silent refuse. + // Resolve priors from data dir. clan_id is guaranteed non-empty + // by the skip above. let clan_id = state.players[owner_idx].clan_id.clone(); - let priors = match (&data_dir, clan_id.is_empty()) { - (Some(d), false) => mc_ai::tactical::ransom_decision::priors_for_clan(&clan_id, d) + let priors = match &data_dir { + Some(d) => mc_ai::tactical::ransom_decision::priors_for_clan(&clan_id, d) .unwrap_or_default(), - _ => mc_ai::policy::PersonalityPriors::default(), + None => mc_ai::policy::PersonalityPriors::default(), }; let self_gold = state.players[owner_idx].gold; let verdicts = mc_ai::tactical::ransom_decision::decide_for_offers( @@ -2871,8 +2880,13 @@ impl TurnProcessor { // For each unit of player pi, check if it shares a tile with an // enemy unit. Snapshot attacker positions first (includes formation_id for scaling). + // p2-55: captive units (those with `captive_of.is_some()`) are + // pinned to their captor and cannot initiate combat. Filter them + // out of the attacker snapshot so we don't re-engage units + // captured earlier this turn. let attacker_snaps: Vec<(usize, i32, i32, i32, i32, i32, Option)> = state.players[pi] .units.iter().enumerate() + .filter(|(_, u)| u.captive_of.is_none()) .map(|(i, u)| (i, u.col, u.row, u.hp, u.attack, u.defense, u.formation_id)) .collect(); @@ -2895,6 +2909,13 @@ impl TurnProcessor { if di == pi { continue; } if let Some(def_idx) = find_enemy_nearby(&state.players[di].units, *uc, *ur) { if killed.iter().any(|&(p, u)| p == di && u == def_idx) { continue; } + // p2-55: a captive defender is already pinned to its + // captor (sits in the captor's `units` vec via + // `apply_refuse_from_offer` / `apply_capture`), but + // belt-and-suspenders: skip any unit still marked + // `captive_of` so it cannot be re-engaged the same + // turn it was captured. + if state.players[di].units[def_idx].captive_of.is_some() { continue; } let defender = &state.players[di].units[def_idx]; let terrain_def = terrain_defense_bonus_at(*uc, *ur, &state.grid);