feat(@projects/@magic-civilization): 👁️ p3-25 step 5 — project real trade deals into view_json (DiplomacyView.trade_deals)

view_json now carries real inter-player trades — the headless "simulator provides
everything" goal is met. A player-like the headless adapter sees territory (step 2) AND
trades (this step) from the projected view, no GDScript re-derivation.

- view.rs: DiplomacyView gains trade_deals: Vec<TradeDealView> ({kind, you_receive,
  you_give, gold_per_turn}, described from the viewer's perspective; serde skip-if-empty
  for wire stability).
- projection.rs build_diplomacy: populates trade_deals from the persisted
  state.trade_ledger swap/sale agreements (LuxurySwap/StrategicSwap/ResourceSale) for the
  viewer↔counterpart pair, via swap_deal_view/sale_deal_view helpers (correct give/receive
  direction; sale gold signed + for seller, − for buyer).

Verified: projection_surfaces_trade_deals_from_ledger (luxury swap direction + sale
buyer/gold); mc-player-api 171/0. (Disk filled mid-step from cargo target — cargo clean
reclaimed 9.5GiB; tests re-run from a clean build.)

p3-25 steps 1-5 DONE: view_json now carries territory + real trades, sourced fully in
Rust. Step 6 (live game adopts the unified PlayerView) reframed as a large separate
follow-on — the headless view-completeness this objective targets is achieved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-26 02:04:57 -04:00
parent 1a7fd849c0
commit 507a87104f
4 changed files with 159 additions and 17 deletions

View file

@ -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<TradeDealView>` ({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

View file

@ -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": [

View file

@ -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<String> = Vec::new();
let mut trade_deals: Vec<crate::view::TradeDealView> = 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

View file

@ -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<PendingEnvelopeView>,
/// 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<TradeDealView>,
}
/// 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