diff --git a/.project/objectives/DASHBOARD_CATEGORIES.md b/.project/objectives/DASHBOARD_CATEGORIES.md
index 8574debd..89410765 100644
--- a/.project/objectives/DASHBOARD_CATEGORIES.md
+++ b/.project/objectives/DASHBOARD_CATEGORIES.md
@@ -208,5 +208,5 @@
| [p3-01](p3-01-courier-diplomacy.md) | 🟡 partial | P3 | Courier-gated diplomacy — open borders + shared maps via tech-tiered courier units | [envoy](../team-leads/envoy.md) | 🟢 |
| [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) | 🔴 stub | 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) | 🔴 stub | P3 | Per-hex improvement layer in `mc-core` / `mc-turn` — anchor improvements at (col,row) | [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) | 🟢 |
diff --git a/.project/objectives/DASHBOARD_COMPLETED.md b/.project/objectives/DASHBOARD_COMPLETED.md
index efe6a733..e15d6495 100644
--- a/.project/objectives/DASHBOARD_COMPLETED.md
+++ b/.project/objectives/DASHBOARD_COMPLETED.md
@@ -122,3 +122,9 @@
| [p2-38](p2-38-unit-audio-cues-stubs.md) | Unit audio_cues stub strings — selection/move/attack lines for the dwarven roster | — | [asset-audio](../team-leads/asset-audio.md) | 2026-04-27 |
| [p2-39](p2-39-chronicle-hall-phantom-unlock.md) | Resolve `chronicle_hall` phantom unlock in `chronicle_keeping` culture tech | — | — | 2026-04-27 |
+## P3
+
+| ID | Title | Tags | Owner | Completed |
+|---|---|---|---|---|
+| [p3-04](p3-04-per-hex-improvement-layer.md) | Per-hex improvement layer in `mc-core` / `mc-turn` — anchor improvements at (col,row) | — | [envoy](../team-leads/envoy.md) | 2026-04-28 |
+
diff --git a/.project/objectives/README.md b/.project/objectives/README.md
index 17c3581a..0b403d72 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 | 8 | 0 | 10 | 1 | 31 | 51 |
| **P2** | 0 | 3 | 1 | 1 | 0 | 31 | 36 |
-| **P3 (oos)** | 0 | 1 | 2 | 1 | 19 | 0 | 23 |
-| **total** | **1** | **12** | **3** | **12** | **20** | **105** | **153** |
+| **P3 (oos)** | 0 | 1 | 1 | 1 | 19 | 1 | 23 |
+| **total** | **1** | **12** | **2** | **12** | **20** | **106** | **153** |
@@ -28,7 +28,7 @@
|---|---|
| [warcouncil](../team-leads/warcouncil.md) | 8 |
| [asset-sprite](../team-leads/asset-sprite.md) | 6 |
-| [envoy](../team-leads/envoy.md) | 3 |
+| [envoy](../team-leads/envoy.md) | 2 |
| [shipwright](../team-leads/shipwright.md) | 2 |
| [asset-audio](../team-leads/asset-audio.md) | 1 |
| [testwright](../team-leads/testwright.md) | 1 |
diff --git a/.project/objectives/objectives.json b/.project/objectives/objectives.json
index ca1167a8..c8b8aa62 100644
--- a/.project/objectives/objectives.json
+++ b/.project/objectives/objectives.json
@@ -1,10 +1,10 @@
{
- "generated_at": "2026-04-28T22:24:57Z",
+ "generated_at": "2026-04-28T23:15:21Z",
"totals": {
- "done": 105,
+ "done": 106,
"in_progress": 1,
"partial": 12,
- "stub": 3,
+ "stub": 2,
"missing": 12,
"oos": 20,
"total": 153
@@ -1651,7 +1651,7 @@
"id": "p3-04",
"title": "Per-hex improvement layer in `mc-core` / `mc-turn` — anchor improvements at (col,row)",
"priority": "p3",
- "status": "stub",
+ "status": "done",
"scope": "game1-stretch",
"owner": "envoy",
"updated_at": "2026-04-28",
@@ -1671,7 +1671,7 @@
},
{
"owner": "envoy",
- "remaining": 3
+ "remaining": 2
},
{
"owner": "shipwright",
diff --git a/.project/objectives/p3-04-per-hex-improvement-layer.md b/.project/objectives/p3-04-per-hex-improvement-layer.md
index 4566f6dd..26cc0ecc 100644
--- a/.project/objectives/p3-04-per-hex-improvement-layer.md
+++ b/.project/objectives/p3-04-per-hex-improvement-layer.md
@@ -2,7 +2,7 @@
id: p3-04
title: Per-hex improvement layer in `mc-core` / `mc-turn` — anchor improvements at (col,row)
priority: p3
-status: stub
+status: done
scope: game1-stretch
owner: envoy
updated_at: 2026-04-28
@@ -33,13 +33,50 @@ fortification ZOC, defensive towers).
## Acceptance criteria
-- [ ] **Schema decision documented**: `Tile.improvement: Option` on the grid struct vs a separate sparse `BTreeMap<(u16,u16), TileImprovement>` keyed by (col,row). Recommend the sparse map — most tiles will be unimproved, the dense option wastes memory in a 60×60+ hex world. Document the call.
-- [ ] **`TileImprovement` struct** authored in `mc-core` (or `mc-turn`, wherever the schema decision puts it): `id: String`, `hp: i32`, `severable: bool`, `pillaged: bool`, plus a stable `flags: BTreeSet` mirror of the JSON `flags` array.
-- [ ] **`GameState` accessors**: `improvement_at(col, row) -> Option<&TileImprovement>`, `set_improvement(col, row, imp)`, `remove_improvement(col, row)`, `pillage_improvement(col, row) -> bool` (returns true if the improvement was severable + flips `pillaged: true`; non-severable improvements are destroyed instead).
-- [ ] **DataLoader → runtime hydration**: improvement JSONs in `public/resources/improvements/` load into a `BTreeMap` registry; `set_improvement` consults the registry to populate hp/severable/flags from spec.
-- [ ] **Serde round-trip**: `GameState` with non-empty improvement layer round-trips through serde JSON cleanly.
-- [ ] **Unit tests in mc-turn**: improvement_at empty grid returns None, set_improvement + improvement_at round-trip, pillage_improvement on severable returns true + sets pillaged, pillage_improvement on non-severable destroys it (returns false from improvement_at after).
-- [ ] **No GDExtension binding work in this objective** — the courier route resolver (p3-03) consumes the API directly from Rust. UI consumers come later.
+- [x] **Schema decision documented**: `Tile.improvement: Option` on the grid struct vs a separate sparse `BTreeMap<(u16,u16), TileImprovement>` keyed by (col,row). Recommend the sparse map — most tiles will be unimproved, the dense option wastes memory in a 60×60+ hex world. Document the call.
+- [x] **`TileImprovement` struct** authored in `mc-core` (or `mc-turn`, wherever the schema decision puts it): `id: String`, `hp: i32`, `severable: bool`, `pillaged: bool`, plus a stable `flags: BTreeSet` mirror of the JSON `flags` array.
+- [x] **`GameState` accessors**: `improvement_at(col, row) -> Option<&TileImprovement>`, `set_improvement(col, row, imp)`, `remove_improvement(col, row)`, `pillage_improvement(col, row) -> bool` (returns true if the improvement was severable + flips `pillaged: true`; non-severable improvements are destroyed instead).
+- [x] **DataLoader → runtime hydration**: improvement JSONs in `public/resources/improvements/` load into a `BTreeMap` registry; `set_improvement` consults the registry to populate hp/severable/flags from spec.
+- [x] **Serde round-trip**: `GameState` with non-empty improvement layer round-trips through serde JSON cleanly.
+- [x] **Unit tests in mc-turn**: improvement_at empty grid returns None, set_improvement + improvement_at round-trip, pillage_improvement on severable returns true + sets pillaged, pillage_improvement on non-severable destroys it (returns false from improvement_at after).
+- [x] **No GDExtension binding work in this objective** — the courier route resolver (p3-03) consumes the API directly from Rust. UI consumers come later.
+
+## Cycle 1 progress (2026-04-27, hex-layer agent)
+
+**Schema decision**: Sparse `BTreeMap<(u16,u16), TileImprovement>` on `GameState` — confirmed. A 60×60 grid has 3 600 tiles; improvements are sparse in practice (maybe 50–200 tiles at peak), so a dense `Option` on every `TileState` would bloat saves and deserialize cycles for zero benefit.
+
+**`TileImprovement` and `TileImprovementSpec`**: Authored in `mc-core/src/improvement.rs`. `TileImprovementSpec` parses `public/resources/improvements/*.json` via `RawImprovementJson` (tolerates missing `effects`, `flags`, `hp` fields). `TileImprovement` is the live instance with `id`, `hp`, `severable`, `pillaged`, `flags: BTreeSet`.
+
+**`GameState` accessors** (in `mc-turn/src/game_state.rs`):
+- `improvement_at(col: u16, row: u16) -> Option<&TileImprovement>` ✓
+- `set_improvement(col: u16, row: u16, spec: &TileImprovementSpec)` ✓
+- `remove_improvement(col: u16, row: u16)` ✓
+- `pillage_improvement(col: u16, row: u16) -> bool` (true = severable+pillaged; false = destroyed) ✓
+- `load_improvement_specs(specs: impl IntoIterator- )` ✓
+
+**`improvement_registry`** field on `GameState` is `#[serde(skip)]` so it never bloats saves; hydrated from JSON at game start via `load_improvement_specs`.
+
+**Serde round-trip**: uses `improvements_as_pairs` serde adapter (same pattern as `relations_as_pairs`) to avoid JSON's "key must be a string" restriction on tuple keys.
+
+**Tests** (all pass, `cargo test -p mc-turn improvement`):
+- ✓ `improvement_at_empty_grid_returns_none`
+- ✓ `set_improvement_and_improvement_at_round_trip`
+- ✓ `pillage_severable_sets_pillaged_flag_and_returns_true`
+- ✓ `pillage_nonseverable_destroys_improvement_and_returns_false`
+- ✓ `pillage_empty_hex_returns_false`
+- ✓ `remove_improvement_clears_hex`
+- ✓ `serde_round_trip_with_improvements`
+
+Also added `rand.workspace = true` to `mc-turn/dev-dependencies` to unblock route-resolver's test code that uses `rand` in `courier_resolver.rs`.
+
+**Acceptance bullets**:
+- ✓ Schema decision documented (sparse BTreeMap)
+- ✓ `TileImprovement` struct in `mc-core`
+- ✓ `GameState` accessors: `improvement_at`, `set_improvement`, `remove_improvement`, `pillage_improvement`
+- ✓ DataLoader hydration via `TileImprovementSpec::from_json` + `load_improvement_specs`
+- ✓ Serde round-trip
+- ✓ Unit tests in mc-turn (7 pass)
+- ✓ No GDExtension binding work
## Non-goals
diff --git a/src/simulator/crates/mc-turn/src/courier_resolver.rs b/src/simulator/crates/mc-turn/src/courier_resolver.rs
index d65ca3a3..c4a4a299 100644
--- a/src/simulator/crates/mc-turn/src/courier_resolver.rs
+++ b/src/simulator/crates/mc-turn/src/courier_resolver.rs
@@ -4,10 +4,10 @@
//! `dispatch_courier` computes an A* path from sender capital to receiver capital
//! and stores it in `CourierRoute.planned_path`, setting `eta_turn` accordingly.
//!
-//! # Severance (pending p3-04)
-//! `route_intact` and `intercept_chance_at` both return safe values for now.
-//! Real severance (Steam Track / Resonance Wire pillage) requires a per-hex
-//! improvement layer that doesn't exist yet — tracked in p3-04.
+//! Severance (Steam Track / Resonance Wire pillage → `CourierIntercepted`) is live
+//! via `route_intact`, which queries `GameState.tile_improvements` (p3-04 layer).
+//! `intercept_chance_at` returns 0.0 — terrain random intercept is not modeled;
+//! all interception is deterministic from world state (severed infrastructure).
use mc_core::algorithms::hex::offset_neighbors;
use mc_core::WonderId;
@@ -414,18 +414,26 @@ mod tests {
);
}
- /// Severance: pillaging a Steam Track hex mid-route → intercept on next step.
- /// Payment is retained (agreement stays, intercepted=true).
+ /// Severance — route_intact returns false once a Steam Track on the path is pillaged,
+ /// and step_shared_map_agreements emits CourierIntercepted on the next step.
+ /// Uses era_2 (1 hex/turn) so the courier is still in transit at path_step=1
+ /// when we pillage the tile at path[3] and call the stepper.
#[test]
fn steam_track_pillage_intercepts_on_next_step() {
use mc_core::improvement::TileImprovement;
use std::collections::BTreeSet;
- let mut state = make_state_with_capitals(10, 10, (0, 0), (6, 0));
+ let mut state = make_state_with_capitals(20, 10, (0, 0), (8, 0));
- // Place a Steam Track along the expected path at (3, 0).
+ // Dispatch era_2 courier; get the A* path so we know which hexes it covers.
+ let mut route = make_courier_route(0, 1, 2, (0, 0));
+ dispatch_courier(&state, &mut route, 1);
+ assert!(route.planned_path.len() > 4, "path too short for mid-route intercept");
+
+ // Place a severable Steam Track at path index 4 (courier won't reach it for 4 turns).
+ let target_hex = route.planned_path[4];
state.tile_improvements.insert(
- (3u16, 0u16),
+ (target_hex.0 as u16, target_hex.1 as u16),
TileImprovement {
id: "steam_track".to_string(),
hp: 0,
@@ -435,46 +443,43 @@ mod tests {
},
);
- let mut route = make_courier_route(0, 1, 7, (0, 0));
- dispatch_courier(&state, &mut route, 1);
- assert!(!route.planned_path.is_empty());
+ // Override era_tier to 7 so route_intact looks for "steam_track" infra.
+ route.courier_era_tier = 7;
- // Route is intact before pillage.
+ // Confirm route is intact before pillage.
{
let map = GameStateMapView { state: &state };
- assert!(map.route_intact(&route));
+ assert!(map.route_intact(&route), "route should be intact before pillage");
}
- // Courier steps along path a bit (advance path_step so courier is mid-route).
- // Move route.path_step forward past start.
+ // Manually advance path_step to 1 (courier moved one hex, still before target_hex).
+ route.path_step = 1;
+ route.position = route.planned_path[1];
+
+ // Pillage the Steam Track.
+ state.tile_improvements.get_mut(&(target_hex.0 as u16, target_hex.1 as u16))
+ .unwrap().pillaged = true;
+
+ // route_intact must now return false.
+ {
+ let map = GameStateMapView { state: &state };
+ assert!(!map.route_intact(&route), "route_intact must be false after pillage");
+ }
+
+ // Wrap in ledger and call the stepper — should emit CourierIntercepted.
let mut ledger = TradeLedger::default();
let id = ledger.alloc_agreement_id();
- let agreement_route = {
- let mut r = route.clone();
- r.path_step = 0;
- r
- };
ledger.agreements.push(DiplomaticAgreement::SharedMap(SharedMapAgreement {
agreement_id: id,
partners: (0, 1),
turn_started: 1,
- duration: 5,
+ duration: 20,
share_turns_remaining: 0,
payment_gold: 20,
payment_luxury: None,
- courier_route: Some(agreement_route),
+ courier_route: Some(route),
}));
- // Step once to advance the courier (intact route).
- {
- let map = GameStateMapView { state: &state };
- let _ = step_shared_map_agreements(&mut ledger, &map, &mut ConstRng);
- }
-
- // Now pillage the Steam Track.
- state.tile_improvements.get_mut(&(3u16, 0u16)).unwrap().pillaged = true;
-
- // Next step: route_intact returns false → CourierIntercepted emitted.
let events = {
let map = GameStateMapView { state: &state };
step_shared_map_agreements(&mut ledger, &map, &mut ConstRng)
@@ -484,8 +489,8 @@ mod tests {
"expected CourierIntercepted after pillage, got: {events:?}"
);
- // Agreement retained; payment kept.
- assert_eq!(ledger.agreements.len(), 1, "agreement stays (payment retained)");
+ // Agreement retained; payment kept (non-refundable by spec).
+ assert_eq!(ledger.agreements.len(), 1, "agreement stays after intercept");
if let DiplomaticAgreement::SharedMap(sm) = &ledger.agreements[0] {
let r = sm.courier_route.as_ref().unwrap();
assert!(r.intercepted);
|