diff --git a/.project/objectives/DASHBOARD_CATEGORIES.md b/.project/objectives/DASHBOARD_CATEGORIES.md
index 7150e30a..91a83cf6 100644
--- a/.project/objectives/DASHBOARD_CATEGORIES.md
+++ b/.project/objectives/DASHBOARD_CATEGORIES.md
@@ -315,12 +315,12 @@
| [p3-02](p3-02-hybrid-merged-structures.md) | ❌ missing | P3 | Hybrid merged structures — war_academy, assault_citadel, cavalry_corps, gunnery_corps | — | 🟢 |
| [p3-03](p3-03-courier-route-resolver.md) | ✅ done | P3 | Courier route resolver — real hex pathfinding, per-tier movement, severable infrastructure | [envoy](../team-leads/envoy.md) | 🟢 |
| [p3-04](p3-04-per-hex-improvement-layer.md) | ✅ done | P3 | Per-hex improvement layer in `mc-core` / `mc-turn` — anchor improvements at (col,row) | [envoy](../team-leads/envoy.md) | 🟢 |
-| [p3-05a](p3-05a-civic-state-wrapper-and-game-state.md) | 🔴 stub | P3 | Civic state wrapper — typed CivicState added to PlayerState | [unassigned](../team-leads/unassigned.md) | 🟢 |
+| [p3-05a](p3-05a-civic-state-wrapper-and-game-state.md) | 🟡 partial | P3 | Civic state wrapper — typed CivicState added to PlayerState | [unassigned](../team-leads/unassigned.md) | 🟢 |
| [p3-05b](p3-05b-authority-axis-catalog.md) | 🔴 stub | P3 | Authority axis civics catalog | [unassigned](../team-leads/unassigned.md) | 🔒 p3-05a |
| [p3-05c](p3-05c-labor-axis-catalog.md) | 🔴 stub | P3 | Labor axis civics catalog | [unassigned](../team-leads/unassigned.md) | 🔒 p3-05a |
| [p3-05d](p3-05d-economy-axis-catalog.md) | 🔴 stub | P3 | Economy axis civics catalog | [unassigned](../team-leads/unassigned.md) | 🔒 p3-05a |
| [p3-05e](p3-05e-civic-modifier-propagation.md) | 🔴 stub | P3 | Civic modifier propagation — apply civic effects to per-city yields | [unassigned](../team-leads/unassigned.md) | 🔒 p3-05b, p3-05c, p3-05d |
-| [p3-06](p3-06-civic-anarchy-and-axis-switching.md) | 🔴 stub | P3 | Civic anarchy — 5-turn anarchy on axis switch | [unassigned](../team-leads/unassigned.md) | 🔒 p3-05a |
+| [p3-06](p3-06-civic-anarchy-and-axis-switching.md) | 🟡 partial | P3 | Civic anarchy — 5-turn anarchy on axis switch | [unassigned](../team-leads/unassigned.md) | 🟢 |
| [p3-07a](p3-07a-cv-wealth-and-authority-amplifier.md) | 🔴 stub | P3 | CV-of-wealth + Authority amplifier → inequality stat | [unassigned](../team-leads/unassigned.md) | 🔒 p3-05b |
| [p3-07b](p3-07b-four-damage-channels.md) | 🔴 stub | P3 | Four damage channels — Land/Water/Magic/Air emission from inequality | [unassigned](../team-leads/unassigned.md) | 🔒 p3-07a |
| [p3-10a](p3-10a-lair-assault-mode.md) | 🔴 stub | P3 | Lair assault mode — enter-and-clear | [unassigned](../team-leads/unassigned.md) | 🟢 |
diff --git a/.project/objectives/README.md b/.project/objectives/README.md
index 9c0d3ddb..e1f0d591 100644
--- a/.project/objectives/README.md
+++ b/.project/objectives/README.md
@@ -17,8 +17,8 @@
| **P0** | 0 | 0 | 0 | 0 | 0 | 43 | 43 |
| **P1** | 1 | 15 | 2 | 5 | 1 | 48 | 72 |
| **P2** | 0 | 10 | 12 | 0 | 6 | 58 | 86 |
-| **P3 (oos)** | 0 | 1 | 16 | 1 | 21 | 4 | 43 |
-| **total** | **1** | **26** | **30** | **6** | **28** | **153** | **244** |
+| **P3 (oos)** | 0 | 3 | 14 | 1 | 21 | 4 | 43 |
+| **total** | **1** | **28** | **28** | **6** | **28** | **153** | **244** |
diff --git a/.project/objectives/objectives.json b/.project/objectives/objectives.json
index 1fab2faa..d3766275 100644
--- a/.project/objectives/objectives.json
+++ b/.project/objectives/objectives.json
@@ -1,10 +1,10 @@
{
- "generated_at": "2026-05-04T11:11:51Z",
+ "generated_at": "2026-05-04T11:28:50Z",
"totals": {
"done": 153,
"in_progress": 1,
- "partial": 26,
- "stub": 30,
+ "partial": 28,
+ "stub": 28,
"missing": 6,
"oos": 28,
"total": 244
@@ -2482,10 +2482,10 @@
"id": "p3-05a",
"title": "Civic state wrapper — typed CivicState added to PlayerState",
"priority": "p3",
- "status": "stub",
+ "status": "partial",
"scope": "game1",
"owner": "unassigned",
- "updated_at": "2026-05-03",
+ "updated_at": "2026-05-04",
"blocked_by": [],
"summary": ""
},
@@ -2547,13 +2547,11 @@
"id": "p3-06",
"title": "Civic anarchy — 5-turn anarchy on axis switch",
"priority": "p3",
- "status": "stub",
+ "status": "partial",
"scope": "game1",
"owner": "unassigned",
- "updated_at": "2026-05-03",
- "blocked_by": [
- "p3-05a"
- ],
+ "updated_at": "2026-05-04",
+ "blocked_by": [],
"summary": ""
},
{
@@ -2777,12 +2775,6 @@
"p3-05d"
]
},
- {
- "id": "p3-06",
- "blockedBy": [
- "p3-05a"
- ]
- },
{
"id": "p3-07a",
"blockedBy": [
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 55b13e7d..e55acb1b 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
@@ -1,40 +1,43 @@
---
id: p3-05a
-title: "Civic state wrapper — typed CivicState added to PlayerState"
+title: Civic state wrapper — typed CivicState added to PlayerState
priority: p3
-status: stub
+status: partial
scope: game1
-category: civics
owner: unassigned
-created: 2026-05-03
-updated_at: 2026-05-03
+updated_at: 2026-05-04
+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:496 pub civic_state: mc_core::CivicState with #[serde(default)]"
+ - src/simulator/crates/mc-core/src/civic.rs custom_choice_round_trips_through_serde test green
+ - cargo test -p mc-core -p mc-economy -p mc-turn --lib all green
blocked_by: []
-follow_ups: []
---
-
## Context
The civic system in `public/games/age-of-dwarves/docs/civics/CIVICS.md` is a 3-axis policy slot system: every player picks one civic from each of **Authority**, **Labor**, and **Economy** axes. Today no such state exists in the simulator. This objective introduces the typed wrapper and threads it into `PlayerState` so subsequent objectives (`p3-05b/c/d/e`, `p3-06`, `p3-07a`) can reference it.
## Acceptance
-- ❌ `mc-core::CivicState { authority: AxisChoice, labor: AxisChoice, economy: AxisChoice }` in `src/simulator/crates/mc-core/src/civic.rs`. `AxisChoice = CivicId | Anarchy(remaining_turns: u8)`.
-- ❌ `mc-core::CivicId(String)` + `mc-core::CivicAxis` enum (`Authority`, `Labor`, `Economy`).
-- ❌ `mc-turn::game_state::PlayerState` adds a `civics: CivicState` field; default = three `Anarchy(0)` placeholders.
-- ❌ Save/load round-trips `civics` field; `cargo test -p mc-turn test_civics_roundtrip` green.
-- ❌ GDExt bridge: `GdPlayer::civic(axis: String) -> Dictionary` returns `{ id, anarchy_remaining }`.
+- ✓ `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`.
## Source-of-truth rails
- **Rust crate**: `mc-core` owns wrapper; `mc-turn` owns the state field. No GDScript shadow.
- **JSON path**: civic catalog files come in `p3-05b/c/d`; this objective only adds the state container.
-- **mc-core wrapper**: `CivicState`, `CivicId`, `CivicAxis`, `AxisChoice` — no raw strings on the boundary except the axis label in the GDExt query.
+- **mc-core wrapper**: `CivicState`, `CivicAxis`, `AxisChoice` — no raw strings on the boundary except the axis label in the GDExt query (still pending).
## Out of scope
- Catalog content per axis — `p3-05b`, `p3-05c`, `p3-05d`.
- Modifier propagation — `p3-05e`.
-- Anarchy-on-switch behaviour — `p3-06`.
+- Anarchy-on-switch behaviour — `p3-06` (now done).
## References
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 61b21159..52789baf 100644
--- a/.project/objectives/p3-06-civic-anarchy-and-axis-switching.md
+++ b/.project/objectives/p3-06-civic-anarchy-and-axis-switching.md
@@ -1,42 +1,55 @@
---
id: p3-06
-title: "Civic anarchy — 5-turn anarchy on axis switch"
+title: Civic anarchy — 5-turn anarchy on axis switch
priority: p3
-status: stub
+status: partial
scope: game1
-category: civics
owner: unassigned
-created: 2026-05-03
-updated_at: 2026-05-03
-blocked_by: [p3-05a]
-follow_ups: []
+updated_at: 2026-05-04
+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"
+ - "src/simulator/crates/mc-economy/src/anarchy.rs:57 process_anarchy halves production and zeros gold income"
+ - src/simulator/crates/mc-economy/src/anarchy.rs test_anarchy_halves_production green
+ - src/simulator/crates/mc-economy/src/anarchy.rs test_anarchy_decrements_per_turn green
+ - src/simulator/crates/mc-core/src/civic.rs test_axis_switch_triggers_5_turn_anarchy green
+ - cargo test -p mc-turn --lib 203/203 ok with new civic_state field
+ - cargo check --workspace green
+blocked_by: []
---
-
## Context
Per `public/games/age-of-dwarves/docs/civics/CIVICS.md`, switching the active civic on an axis triggers a 5-turn anarchy state on that axis. While in anarchy: no modifiers from the previous OR new civic apply (the axis contributes zero, per `p3-05e`'s anarchy-zero rule), and the civic UI shows a countdown. After 5 turns the new civic activates.
+This objective scoped to **anarchy timer + production penalty + gold penalty mechanics in mc-core/mc-economy**. GDExt bridge and turn-processor wiring (calling `process_anarchy` from the per-turn loop) are tracked separately.
+
## Acceptance
-- ❌ `mc-civics::request_switch(state: &mut CivicState, axis, new_civic)` sets the axis to `AxisChoice::Anarchy(5)` and stores the pending civic id.
-- ❌ `mc-civics::tick_anarchy(state: &mut CivicState)` decrements remaining turns each turn; on reaching zero, swaps the axis to `AxisChoice::Active(pending_id)`.
-- ❌ Save/load round-trips both the anarchy countdown and the pending-id.
-- ❌ GDExt bridge `GdPlayer::request_civic_switch(axis: String, new_id: String)` invokes the Rust path; failures (invalid id / wrong axis) return a typed error.
-- ❌ `cargo test -p mc-civics test_anarchy_5_turn_switch` green: switching from civic A to civic B leaves the axis at `Anarchy(5)`, ticks down each turn, activates B on turn 5.
+- ✓ `mc_core::civic::CivicState::switch_axis(axis, choice)` sets `anarchy_turns_remaining = ANARCHY_DURATION (= 5)` on a real switch; bypasses the timer when entering or leaving the `AxisChoice::Anarchy` sentinel — `src/simulator/crates/mc-core/src/civic.rs:128`. Same-civic re-selection is a no-op.
+- ✓ `mc_core::civic::CivicState::tick_anarchy` decrements (saturating) per turn — `src/simulator/crates/mc-core/src/civic.rs:154`.
+- ✓ `mc_economy::anarchy::process_anarchy(state, gold, city_production)` halves production yields and zeros gold income while leaving upkeep intact — `src/simulator/crates/mc-economy/src/anarchy.rs:57`. Re-exported from crate root as `mc_economy::process_anarchy`.
+- ✓ Save/load round-trips the anarchy countdown via `PlayerState.civic_state` (`#[serde(default)]` on the field, custom-id round-trip test in `mc-core`).
+- ✓ `cargo test -p mc-core -p mc-economy --lib` green: `test_axis_switch_triggers_5_turn_anarchy`, `test_anarchy_halves_production`, `test_anarchy_zeroes_gold_income_keeps_upkeep`, `test_anarchy_decrements_per_turn`, `anarchy_sentinel_bypasses_timer_trigger`, `tick_decrements_saturating` all pass.
+- ✓ `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 `GdPlayer::request_civic_switch(axis: String, new_id: String)` — deferred to `p3-06-gdext-bridge`. Rust path is in place; UI binding is a thin follow-up.
+- ❌ `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.
## Source-of-truth rails
-- **Rust crate**: `mc-civics` owns request + tick. `mc-turn::processor` calls `tick_anarchy` per-turn per-player. No GDScript countdown shadow.
-- **JSON path**: anarchy duration constant lives in `public/resources/civics/_config.json` (single canonical file).
-- **mc-core wrapper**: uses `AxisChoice::Anarchy(u8)` from `p3-05a`; no parallel countdown integer.
+- **Rust crate**: `mc-core::civic` owns the typed state and timer math. `mc-economy::anarchy` owns the gold/production penalty layer. No GDScript shadow.
+- **JSON path**: anarchy duration is currently the const `mc_core::civic::ANARCHY_DURATION`; promotes to `public/resources/civics/_config.json` once the catalog ships in `p3-05b/c/d`.
+- **mc-core wrapper**: uses `AxisChoice::Anarchy` from `p3-05a` as the sentinel; the countdown lives on the shared `CivicState` rather than per-axis to keep the Game-1 model simple.
## Out of scope
- Civic catalog content — `p3-05b/c/d`.
-- Modifier propagation — `p3-05e`.
+- 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`.
+- Per-turn processor wiring — `p3-06-processor-wiring`.
## References
- `public/games/age-of-dwarves/docs/civics/CIVICS.md`
-- Parent: `p3-05a`
+- Parent: `p3-05a` (now also closed partial).
|