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