feat(@projects/@magic-civilization): ✅ mark p3-04 hex improvement layer as complete
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
8ff01b67e0
commit
843081e152
6 changed files with 100 additions and 52 deletions
|
|
@ -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) | 🟢 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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** |
|
||||
|
||||
</td><td valign='top' style='padding-left:2em'>
|
||||
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<TileImprovement>` 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<String>` 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<id, TileImprovementSpec>` 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<TileImprovement>` 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<String>` 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<id, TileImprovementSpec>` 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<String>`.
|
||||
|
||||
**`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<Item = TileImprovementSpec>)` ✓
|
||||
|
||||
**`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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue