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 index 6202283b..30bd277c 100644 --- 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 @@ -82,12 +82,21 @@ to `state.trade_ledger`, (d) projecting it all. `source_tradeable_resources_classifies_owned_tile_collectibles` (determinism + classification purity + no-leak + empty-categories no-op); mc-turn+mc-state+ mc-player-api **517/0**. -- [ ] **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`. +- [x] **Step 5 — project trade deals.** `DiplomacyView` gains `trade_deals: + Vec` ({kind, you_receive, you_give, gold_per_turn}, viewer-perspective), + populated in `build_diplomacy` from the now-persisted `state.trade_ledger` + swap/sale agreements (`swap_deal_view`/`sale_deal_view`). **`view_json` now carries + real inter-player trades** — the headless "simulator provides everything" goal is met + (gate is the headless assertion, no UI screenshot). **Done 2026-06-26** — + `projection_surfaces_trade_deals_from_ledger` (mc-player-api 171/0). +- [ ] **Step 6 — live game adopts the unified `PlayerView` (large, separate).** The + *headless* path is now complete (steps 1-5). Making the **live game** GDScript + view-only for trades is a much bigger initiative: the live game reads `GameState` via + many `GdGameState` bridges + parses `trade_ledger_json` in `diplomacy.gd`, rather than + consuming a single projected `PlayerView`. Retiring `Diplomacy.process_turn`/ + `get_active_agreements` requires the live game to consume `PlayerView` end-to-end — its + own objective-sized refactor. Tracked as a follow-on; not required for the headless + view-completeness this objective delivers. ## Notes diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json index 7f7cc676..d4cd9fa9 100644 --- a/public/games/age-of-dwarves/data/objectives.json +++ b/public/games/age-of-dwarves/data/objectives.json @@ -1,12 +1,12 @@ { - "generated_at": "2026-06-26T05:57:32Z", + "generated_at": "2026-06-26T06:04:57Z", "totals": { - "partial": 2, - "missing": 0, - "stub": 0, - "in_progress": 0, "done": 296, + "stub": 0, + "missing": 0, + "in_progress": 0, "oos": 31, + "partial": 2, "total": 329 }, "objectives": [ diff --git a/src/simulator/crates/mc-player-api/src/projection.rs b/src/simulator/crates/mc-player-api/src/projection.rs index d619b7c7..344eaeed 100644 --- a/src/simulator/crates/mc-player-api/src/projection.rs +++ b/src/simulator/crates/mc-player-api/src/projection.rs @@ -747,15 +747,15 @@ 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. + // Real agreement + trade-deal state read from the authoritative trade + // ledger (p3-25): OpenBorders/SharedMap (signed by dispatch) plus the + // LuxurySwap/StrategicSwap/ResourceSale deals the headless turn persists + // each round (`process_trade_phase`). Replaces the former hardcoded stubs. let mut open_borders = false; let mut shared_map = false; let mut agreements_active: Vec = Vec::new(); + let mut trade_deals: Vec = Vec::new(); + let viewer = player_idx as u8; for ag in &state.trade_ledger.agreements { match ag { mc_trade::DiplomaticAgreement::OpenBorders(ob) if ob.partners == pair => { @@ -766,6 +766,15 @@ fn project_diplomacy( shared_map = true; agreements_active.push(format!("shared_map:{}", sm.agreement_id)); } + mc_trade::DiplomaticAgreement::LuxurySwap(ta) if ta.partners == pair => { + trade_deals.push(swap_deal_view(ta, viewer, "luxury_swap")); + } + mc_trade::DiplomaticAgreement::StrategicSwap(ta) if ta.partners == pair => { + trade_deals.push(swap_deal_view(ta, viewer, "strategic_swap")); + } + mc_trade::DiplomaticAgreement::ResourceSale(rs) if rs.partners == pair => { + trade_deals.push(sale_deal_view(rs, viewer)); + } _ => {} } } @@ -778,11 +787,55 @@ fn project_diplomacy( shared_map, agreements_active, pending_envelopes, + trade_deals, }); } out } +/// p3-25: describe a luxury/strategic swap from the viewer's perspective. +/// `gives_a` flows `partners.0 → partners.1`; `gives_b` the other way. +fn swap_deal_view( + ta: &mc_trade::TradeAgreement, + viewer: u8, + kind: &str, +) -> crate::view::TradeDealView { + let (you_receive, you_give) = if viewer == ta.partners.1 { + (ta.gives_a.clone(), ta.gives_b.clone()) + } else { + (ta.gives_b.clone(), ta.gives_a.clone()) + }; + crate::view::TradeDealView { + kind: kind.to_string(), + you_receive, + you_give, + gold_per_turn: 0, + } +} + +/// p3-25: describe a gold sale from the viewer's perspective — buyer gains the +/// resource and pays gold/turn; seller gives it and earns gold/turn. +fn sale_deal_view( + rs: &mc_trade::ResourceSaleAgreement, + viewer: u8, +) -> crate::view::TradeDealView { + if rs.buyer == viewer { + crate::view::TradeDealView { + kind: "resource_sale".into(), + you_receive: rs.resource.clone(), + you_give: String::new(), + gold_per_turn: -(rs.gold_per_turn as i32), + } + } else { + crate::view::TradeDealView { + kind: "resource_sale".into(), + you_receive: String::new(), + you_give: rs.resource.clone(), + gold_per_turn: rs.gold_per_turn as i32, + } + } +} + /// Communications Phase 2: build the `pending_envelopes` row for the /// (viewer, counterpart) pair. Only in-flight (Dispatched / InTransit) /// envelopes are surfaced; delivered + intercepted envelopes are @@ -2086,6 +2139,65 @@ mod tests { ); } + /// p3-25 step 5: active swap/sale trade deals from `state.trade_ledger` surface + /// in `DiplomacyView.trade_deals`, described from the viewer's perspective. + #[test] + fn projection_surfaces_trade_deals_from_ledger() { + let mut state = GameState::default(); + state.turn = 1; + state.grid = Some(mc_core::grid::GridState::new(20, 20)); + 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); + // LuxurySwap 0↔1: gives_a (0→1) = furs, gives_b (1→0) = silk. + state + .trade_ledger + .agreements + .push(mc_trade::DiplomaticAgreement::LuxurySwap( + mc_trade::TradeAgreement { + partners: (0, 1), + gives_a: "furs".into(), + gives_b: "silk".into(), + turn_started: 1, + }, + )); + // ResourceSale 0↔1: buyer 0 pays 2 gold/turn for horses. + state + .trade_ledger + .agreements + .push(mc_trade::DiplomaticAgreement::ResourceSale( + mc_trade::ResourceSaleAgreement { + partners: (0, 1), + seller: 1, + buyer: 0, + resource: "horses".into(), + strategic: true, + gold_per_turn: 2, + turn_started: 1, + }, + )); + + let view = project_view(&state, 0, /*omniscient=*/ true); + let deals = &view.diplomacy[0].trade_deals; + let lux = deals + .iter() + .find(|d| d.kind == "luxury_swap") + .expect("luxury swap present"); + assert_eq!(lux.you_receive, "silk"); // player 0 = partners.0 → gets gives_b + assert_eq!(lux.you_give, "furs"); + assert_eq!(lux.gold_per_turn, 0); + let sale = deals + .iter() + .find(|d| d.kind == "resource_sale") + .expect("resource sale present"); + assert_eq!(sale.you_receive, "horses"); // viewer is the buyer + assert_eq!(sale.you_give, ""); + assert_eq!(sale.gold_per_turn, -2); + } + /// 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 diff --git a/src/simulator/crates/mc-player-api/src/view.rs b/src/simulator/crates/mc-player-api/src/view.rs index acd2548a..9894cef2 100644 --- a/src/simulator/crates/mc-player-api/src/view.rs +++ b/src/simulator/crates/mc-player-api/src/view.rs @@ -235,6 +235,27 @@ pub struct DiplomacyView { /// of Phase 2 without a schema break. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub pending_envelopes: Vec, + /// p3-25: active inter-player trade deals with this counterpart (luxury / + /// strategic swaps + gold sales), described from the *viewer's* perspective. + /// Sourced from `state.trade_ledger`; empty when no trade is active. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub trade_deals: Vec, +} + +/// p3-25: one active trade deal between the viewer and a counterpart, described +/// from the viewer's side so adapters/UI can render it without re-deriving +/// direction. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TradeDealView { + /// `"luxury_swap"` | `"strategic_swap"` | `"resource_sale"`. + pub kind: String, + /// Resource the viewer GAINS (empty when the viewer is the seller of a sale). + pub you_receive: String, + /// Resource the viewer GIVES (empty for a sale where the viewer buys). + pub you_give: String, + /// Net gold/turn for the viewer: `+` earned (seller), `−` paid (buyer), + /// `0` for a barter swap. + pub gold_per_turn: i32, } /// Communications Phase 1: an in-flight courier envelope row surfaced in