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:
parent
e6b7c9b2ce
commit
922c18fb0c
4 changed files with 176 additions and 12 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue