6.3 KiB
| id | title | priority | status | scope | owner | updated_at | evidence | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| p3-04 | Per-hex improvement layer in `mc-core` / `mc-turn` — anchor improvements at (col,row) | p3 | done | game1-stretch | envoy | 2026-04-28 |
|
Summary
Improvements ship as data files (public/resources/improvements/*.json) but the
simulation has no per-hex anchor for them. Improvements currently live on
PlayerState.city_improvements: Vec<Vec<String>> (per-player / per-city,
unanchored). The grid's per-tile struct stores terrain only — no improvement
slot.
This blocks p3-03 acceptance bullets 4 (severance: pillaging Steam Track at hex (c,r) intercepts a courier whose route includes (c,r)), 5 (Hold-Network reroute when a Steam Track is severed), and the infrastructure-gating half of bullet 2 (Steam Messenger requires Steam Track tiles on its route, Resonance Telegrapher requires Resonance Wire tiles).
This is also a foundational gap that will block other Game-1-stretch features beyond couriers (tile-improvement pillaging, road network bonuses, fortification ZOC, defensive towers).
Acceptance criteria
- Schema decision documented:
Tile.improvement: Option<TileImprovement>on the grid struct vs a separate sparseBTreeMap<(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. TileImprovementstruct authored inmc-core(ormc-turn, wherever the schema decision puts it):id: String,hp: i32,severable: bool,pillaged: bool, plus a stableflags: BTreeSet<String>mirror of the JSONflagsarray.GameStateaccessors: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 + flipspillaged: true; non-severable improvements are destroyed instead).- DataLoader → runtime hydration: improvement JSONs in
public/resources/improvements/load into aBTreeMap<id, TileImprovementSpec>registry;set_improvementconsults the registry to populate hp/severable/flags from spec. - Serde round-trip:
GameStatewith 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.
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)
- ✓
TileImprovementstruct inmc-core - ✓
GameStateaccessors: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
- Engine units placing improvements (separate objective: an engineer unit + improvement-build orders).
- Pillage-by-enemy-unit AI (mc-ai concern, dispatched separately).
- Visual rendering of improvements on the world map (godot-renderer concern).
- Migration of
PlayerState.city_improvementsto use this layer — that's a related but separate refactor; for now the per-hex layer coexists with the per-city list.
Dependencies
- Inputs: existing
mc-core::GridState, the 5 improvement JSONs already authored in p3-01 cycle 2/3,improvement.schema.json. - Blocks on: nothing.
- Blocks: p3-03 (courier route resolver) bullets 4, 5, and the infra-gated half of 2.