feat(@projects/@magic-civilization): update civic objectives and proof capture

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-14 20:00:21 -07:00
parent febfaf4cb9
commit 35f7b1a0ce
6 changed files with 94 additions and 26 deletions

View file

@ -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**.

View file

@ -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`.

View file

@ -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

View file

@ -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`

View file

@ -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

View file

@ -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<u32>)> = 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);