feat(@projects/@magic-civilization): 🛤️ p3-25 step 1 — projection reads real agreement state (de-stub DiplomacyView)

Owner directive: "gd should only be UI view of simulation / simulator provides
everything / no stubs". Root cause (verified): the sim holds two parallel city models —
authoritative mc_city::City (presentation_cities, has owned_tiles) vs bench
mc_state::CityState (GameState.players[].cities, no territory) — and project_view reads
the bench one, so view_json is structurally blind to territory + trades. New objective
p3-25 captures the full sequenced unification plan.

Step 1 (this commit) de-stubs what real bench state already carries, no fabrication:
- projection.rs build_diplomacy: DiplomacyView.{open_borders,shared_map,agreements_active}
  now read the real OpenBorders/SharedMap entries from state.trade_ledger (the entries
  dispatch writes on signing), replacing hardcoded false/false/empty stubs.
- CityView.owned_tiles left honestly TODO (center-only would mislead; fills in step 2 when
  bench territory + border expansion are ported).

Verified: cargo test -p mc-player-api 169/0 (incl. new projection_surfaces_open_borders_
from_ledger). Rust-only headless-view change; no GDScript touched, live game path
unaffected. Swap/sale trade deals NOT yet in the view — they need the sourcing+persistence
port (p3-25 steps 2-5); not faked here per "no stubs".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-26 00:43:46 -04:00
parent e6b7c9b2ce
commit 922c18fb0c
4 changed files with 176 additions and 12 deletions

View file

@ -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** |
</td><td valign='top' style='padding-left:2em'>
@ -26,7 +26,7 @@
| Team Lead | Remaining |
|---|---|
| [warcouncil](../team-leads/warcouncil.md) | 1 |
| [warcouncil](../team-leads/warcouncil.md) | 2 |
</td></tr></table>

View file

@ -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<Vec<mc_city::City>>` — 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<mc_state::CityState>` — 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).

View file

@ -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<Vec<mc_city::City>>` — 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<mc_state::CityState>` — 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."
}
]
}

View file

@ -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<String> = 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