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);