diff --git a/.project/objectives/README.md b/.project/objectives/README.md index 9b4ffa1a..b6cbbbd6 100644 --- a/.project/objectives/README.md +++ b/.project/objectives/README.md @@ -17,8 +17,8 @@ | **P0** | 44 | 0 | 0 | 0 | 0 | 0 | 44 | | **P1** | 88 | 0 | 0 | 0 | 0 | 1 | 89 | | **P2** | 130 | 0 | 0 | 0 | 0 | 1 | 131 | -| **P3 (oos)** | 34 | 0 | 1 | 0 | 0 | 29 | 64 | -| **total** | **296** | **0** | **1** | **0** | **0** | **31** | **328** | +| **P3 (oos)** | 34 | 0 | 2 | 0 | 0 | 29 | 65 | +| **total** | **296** | **0** | **2** | **0** | **0** | **31** | **329** | @@ -26,7 +26,7 @@ | Team Lead | Remaining | |---|---| -| [warcouncil](../team-leads/warcouncil.md) | 1 | +| [warcouncil](../team-leads/warcouncil.md) | 2 | diff --git a/.project/objectives/p3-25-rail1-city-model-unify-headless-view-completeness.md b/.project/objectives/p3-25-rail1-city-model-unify-headless-view-completeness.md new file mode 100644 index 00000000..709e8531 --- /dev/null +++ b/.project/objectives/p3-25-rail1-city-model-unify-headless-view-completeness.md @@ -0,0 +1,85 @@ +--- +id: p3-25 +title: Rail-1 — unify dual city model so view_json carries territory + trades (GDScript = view only) +priority: p3 +scope: game1 +owner: warcouncil +status: partial +updated_at: 2026-06-26 +--- + +## Summary + +> **Owner directive (2026-06-26):** "gd should only be UI view of simulation" + +> "simulator should provide everything" + "no stubs — prod code only". A player-like +> the headless adapter (or any UI) must get the *full* game state from the simulator's +> projected view (`GdPlayerApi.view_json` → `PlayerView`), not by GDScript re-deriving +> simulation facts. + +**Root cause (verify-first investigation).** The simulator holds **two parallel city +models** ([api-gdext/src/city_slot.rs:3-6](../../src/simulator/api-gdext/src/city_slot.rs)): + +- `GdGameState.presentation_cities: Vec>` — the **authoritative** + rich model: `owned_tiles`, `worked_tiles`, `position`, `culture_stored`, buildings + ([mc-city/src/city.rs](../../src/simulator/crates/mc-city/src/city.rs)). +- `GameState.players[pi].cities: Vec` — a **bench** model with NO + territory ([mc-city/src/lib.rs:126](../../src/simulator/crates/mc-city/src/lib.rs)), + explicitly *"left untouched"*. City position lives in the parallel + `PlayerState.city_positions`. + +`project_view` ([mc-player-api/src/projection.rs](../../src/simulator/crates/mc-player-api/src/projection.rs)) +reads the **bench** model, so `view_json` is structurally blind to territory — and thus +to worked tiles and to inter-player trades (which need owned-tile resource sourcing). +`GdPlayerApi` (the headless harness) holds only `GameState` — no `presentation_cities` — +so the fix for the headless path is to give the bench state real territory, not to reach +into the rich `City`. + +Consequences observed: `CityView.owned_tiles` and `DiplomacyView.{open_borders, +shared_map,agreements_active}` were hardcoded stubs in the projection; there are no +trade-deal fields on `DiplomacyView` at all; `mc-turn::process_trade_phase` sources trade +inputs from bench proxies (`tile_strategics: Vec::new()`, `tile_luxuries` proxy) and does +not persist its computed ledger to `state.trade_ledger`. The live game's working trade +path (p3-23) is GDScript-orchestrated and parses `trade_ledger_json` itself — a +presentation-layer workaround that this objective supersedes for the headless/sim path. + +The data needed *does* exist in Rust: `GridState.tile(col,row)→{biome,quality}` + +`mc_core::collectibles::tile_collectibles(biome,quality,rng)` resolve a tile's resources +deterministically. What's missing is (a) owned-tile territory in the bench state, (b) a +resource-category catalog (luxury/strategic) in Rust, (c) persistence of swap/sale deals +to `state.trade_ledger`, (d) projecting it all. + +## Acceptance (sequenced; each step keeps cargo + GUT green) + +- [~] **Step 1 — de-stub the projection for data already in bench state.** + `DiplomacyView.{open_borders,shared_map,agreements_active}` now read the real + OpenBorders/SharedMap entries from `state.trade_ledger` (no fabrication). **Done + 2026-06-26** (`projection_surfaces_open_borders_from_ledger`; mc-player-api projection + 32/0). `CityView.owned_tiles` left honestly TODO (center-only would mislead; fills in + Step 2 when expansion territory lands). +- [ ] **Step 2 — territory into the bench state.** Add a parallel per-city `owned_tiles` + store to `GameState`/`PlayerState` (mirroring `city_positions`), seed it at headless + `found_city` (canonical rule: center tile, per `City::found`), port border expansion + into the bench turn so it grows. Project `CityView.owned_tiles` from it. +- [ ] **Step 3 — resource-category catalog in Rust.** Load luxury/strategic categories + (currently GDScript-only via DataLoader) into Rust via a `set_resources_catalog_json` + + storage, so the sim can classify owned-tile resources. +- [ ] **Step 4 — real trade sourcing + persistence.** `mc-turn::process_trade_phase` + sources real owned-tile luxuries/strategics (owned tiles → `tile_collectibles` → + category-classify), runs `evaluate_trades`, and **persists** the swap/sale ledger into + `state.trade_ledger` (merging with OB/SM), writes `traded_strategics`, applies gold. +- [ ] **Step 5 — project trade deals.** Add trade-deal fields to `DiplomacyView` + (incoming luxuries/strategics, gold flow, per-deal list) sourced from + `state.trade_ledger`; `view_json` now carries trades. Headless assertion is the gate — + no UI screenshot. +- [ ] **Step 6 — GDScript becomes view-only for trades.** Retire `Diplomacy.process_turn` + trade orchestration + `get_active_agreements` JSON parsing; the panel reads `view_json`. + +## Notes + +Created 2026-06-26 from the p3-23 follow-up thread. This is the keystone Rail-1 debt — +it underlies "simulator provides everything" for the whole headless/view path, not just +trades. Large + central (touches `GameState`, projection, `mc-turn`, the `GdGameState` +bridge); executed in safe increments with cargo + GUT green at each step. Related: +[[p3-24-rail1-economy-turn-logic-port]] (the per-turn glue port) and +[[p3-23-trade-richness-gold-strategic]] (the live GDScript trade path this supersedes for +the sim/headless side). diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json index 43bdf58c..cdd63a99 100644 --- a/public/games/age-of-dwarves/data/objectives.json +++ b/public/games/age-of-dwarves/data/objectives.json @@ -1,13 +1,13 @@ { - "generated_at": "2026-06-26T04:07:21Z", + "generated_at": "2026-06-26T04:43:46Z", "totals": { - "oos": 31, - "done": 296, - "partial": 1, + "in_progress": 0, "stub": 0, "missing": 0, - "in_progress": 0, - "total": 328 + "partial": 2, + "done": 296, + "oos": 31, + "total": 329 }, "objectives": [ { @@ -3289,6 +3289,16 @@ "owner": "warcouncil", "updated_at": "2026-06-25", "summary": "The core economy/happiness/event MATH is in Rust (mc-economy, mc-happiness,\nmc-city, mc-climate, mc-trade) — good Rail-1 health. **But real per-turn game\nlogic still lives in GDScript**, violating \"GDScript is presentation only\":\n\n- `economy.gd:66-72` computes gold *in GDScript* (building-gold sum, gold-per-pop,\n gold-from-mines) before/around the `GdEconomy` call — not a thin wrapper.\n- `happiness.gd` assembles + partially computes the happiness inputs (luxury map,\n building-effect collection) in GDScript.\n- `climate_effects.gd:125` mutates game state in GDScript (`unit.hp -= hp_loss`).\n- `turn_processor.gd` / `turn_manager.gd` own the per-turn ORCHESTRATION (sequence\n the Rust crates, dispatch events, apply results) in GDScript.\n\nThis is the same class of debt as the AI port (p0-26, done) — but for the\neconomy/happiness/event/turn surface." + }, + { + "id": "p3-25", + "title": "Rail-1 — unify dual city model so view_json carries territory + trades (GDScript = view only)", + "priority": "p3", + "status": "partial", + "scope": "game1", + "owner": "warcouncil", + "updated_at": "2026-06-26", + "summary": "> **Owner directive (2026-06-26):** \"gd should only be UI view of simulation\" +\n> \"simulator should provide everything\" + \"no stubs — prod code only\". A player-like\n> the headless adapter (or any UI) must get the *full* game state from the simulator's\n> projected view (`GdPlayerApi.view_json` → `PlayerView`), not by GDScript re-deriving\n> simulation facts.\n\n**Root cause (verify-first investigation).** The simulator holds **two parallel city\nmodels** ([api-gdext/src/city_slot.rs:3-6](../../src/simulator/api-gdext/src/city_slot.rs)):\n\n- `GdGameState.presentation_cities: Vec>` — the **authoritative**\n rich model: `owned_tiles`, `worked_tiles`, `position`, `culture_stored`, buildings\n ([mc-city/src/city.rs](../../src/simulator/crates/mc-city/src/city.rs)).\n- `GameState.players[pi].cities: Vec` — a **bench** model with NO\n territory ([mc-city/src/lib.rs:126](../../src/simulator/crates/mc-city/src/lib.rs)),\n explicitly *\"left untouched\"*. City position lives in the parallel\n `PlayerState.city_positions`.\n\n`project_view` ([mc-player-api/src/projection.rs](../../src/simulator/crates/mc-player-api/src/projection.rs))\nreads the **bench** model, so `view_json` is structurally blind to territory — and thus\nto worked tiles and to inter-player trades (which need owned-tile resource sourcing).\n`GdPlayerApi` (the headless harness) holds only `GameState` — no `presentation_cities` —\nso the fix for the headless path is to give the bench state real territory, not to reach\ninto the rich `City`.\n\nConsequences observed: `CityView.owned_tiles` and `DiplomacyView.{open_borders,\nshared_map,agreements_active}` were hardcoded stubs in the projection; there are no\ntrade-deal fields on `DiplomacyView` at all; `mc-turn::process_trade_phase` sources trade\ninputs from bench proxies (`tile_strategics: Vec::new()`, `tile_luxuries` proxy) and does\nnot persist its computed ledger to `state.trade_ledger`. The live game's working trade\npath (p3-23) is GDScript-orchestrated and parses `trade_ledger_json` itself — a\npresentation-layer workaround that this objective supersedes for the headless/sim path.\n\nThe data needed *does* exist in Rust: `GridState.tile(col,row)→{biome,quality}` +\n`mc_core::collectibles::tile_collectibles(biome,quality,rng)` resolve a tile's resources\ndeterministically. What's missing is (a) owned-tile territory in the bench state, (b) a\nresource-category catalog (luxury/strategic) in Rust, (c) persistence of swap/sale deals\nto `state.trade_ledger`, (d) projecting it all." } ] } diff --git a/src/simulator/crates/mc-player-api/src/projection.rs b/src/simulator/crates/mc-player-api/src/projection.rs index 969f91f9..6c709f82 100644 --- a/src/simulator/crates/mc-player-api/src/projection.rs +++ b/src/simulator/crates/mc-player-api/src/projection.rs @@ -742,14 +742,36 @@ fn project_diplomacy( "peace".into() }; let pending_envelopes = collect_pending_envelopes(state, player_idx as u8, p_idx as u8); + // Real agreement state read from the authoritative trade ledger (the + // OpenBorders/SharedMap entries dispatch writes on signing). Replaces the + // former hardcoded `false`/empty stubs. Swap/sale trade deals are NOT yet + // surfaced here — they require the headless trade-sourcing port (the bench + // turn does not persist swaps to `state.trade_ledger` yet); tracked by the + // rail-1 headless-economy objective. + let mut open_borders = false; + let mut shared_map = false; + let mut agreements_active: Vec = Vec::new(); + for ag in &state.trade_ledger.agreements { + match ag { + mc_trade::DiplomaticAgreement::OpenBorders(ob) if ob.partners == pair => { + open_borders = true; + agreements_active.push(format!("open_borders:{}", ob.agreement_id)); + } + mc_trade::DiplomaticAgreement::SharedMap(sm) if sm.partners == pair => { + shared_map = true; + agreements_active.push(format!("shared_map:{}", sm.agreement_id)); + } + _ => {} + } + } out.push(DiplomacyView { player: p_idx as PlayerId, race: "dwarf".into(), name: format!("Player {}", p_idx), relation, - open_borders: false, - shared_map: false, - agreements_active: Vec::new(), + open_borders, + shared_map, + agreements_active, pending_envelopes, }); } @@ -1980,6 +2002,53 @@ mod tests { assert!(view.diplomacy[0].pending_envelopes.is_empty()); } + /// p3-24 rail-1: the diplomacy projection reads real OpenBorders/SharedMap + /// agreement state from `state.trade_ledger` (replacing the former hardcoded + /// `false`/empty stubs). + #[test] + fn projection_surfaces_open_borders_from_ledger() { + let mut state = GameState::default(); + state.turn = 1; + state.grid = Some(mc_core::grid::GridState::new(60, 60)); + let mut a = PlayerState::default(); + a.player_index = 0; + let mut b = PlayerState::default(); + b.player_index = 1; + state.players.push(a); + state.players.push(b); + state + .trade_ledger + .agreements + .push(mc_trade::DiplomaticAgreement::OpenBorders( + mc_trade::OpenBordersAgreement { + agreement_id: 7, + partners: (0, 1), + turn_started: 1, + turns_remaining: 20, + payment_gold: 0, + payment_luxury: None, + }, + )); + + let view = project_view(&state, 0, /*omniscient=*/ true); + assert_eq!(view.diplomacy.len(), 1); + assert!( + view.diplomacy[0].open_borders, + "open-borders agreement in the ledger must surface in the view" + ); + assert!( + view.diplomacy[0] + .agreements_active + .iter() + .any(|s| s == "open_borders:7"), + "agreement id must appear in agreements_active" + ); + assert!( + !view.diplomacy[0].shared_map, + "no shared-map agreement present" + ); + } + /// Communications Phase 2: in-flight envelopes between the viewer /// and a counterpart surface in the diplomacy row's /// `pending_envelopes` vec. Outbound vs inbound is annotated so the