feat(@projects/@magic-civilization): 💱 p3-25 step 4 — real trade sourcing + ledger persistence in the headless turn

The core of the rail-1 trade port: inter-player trades now form in the Rust headless
sim from REAL owned-tile resources (no proxy), persist to state, and apply.

- mc-turn::process_trade_phase: source_tradeable_resources sources each player's tradeable
  luxuries + strategics from its cities' owned tiles → deterministic tile_collectibles
  rolls (seed = map_seed ^ coord, stable across turns) → classified via
  GameState.resource_categories (dups kept for MIN_COPIES_TO_TRADE). Replaces the old
  proxy (tile_strategics: Vec::new(), tile_luxuries from traded_luxuries).
- Persists the re-derived swap/sale agreements into state.trade_ledger (retaining the
  persistent OpenBorders/SharedMap), so the projection/view can carry real trades.
- Writes PlayerState.traded_strategics (new serde-default field) + applies net per-turn
  gold flow (gold_flow_for: seller +, buyer −).

Verified: mc-turn source_tradeable_resources_classifies_owned_tile_collectibles
(determinism + classification purity + uncategorized-filtered + empty-categories no-op);
mc-turn+mc-state+mc-player-api 517/0; workspace cargo check clean (new PlayerState field
broke no literals). p3-25 steps 1-4 done; 5-6 remain (project trade deals into the view,
then GDScript view-only).

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

View file

@ -71,10 +71,17 @@ to `state.trade_ledger`, (d) projecting it all.
set_resource_categories_json` FFI loads it onto the held state (call after
`load_state_json`). **Done 2026-06-26**`load_resource_categories_parses_flat_map`
(mc-state 13/0); workspace `cargo check` clean (no GameState-literal breakage).
- [ ] **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.
- [x] **Step 4 — real trade sourcing + persistence.** `mc-turn::process_trade_phase`
now sources real owned-tile luxuries/strategics via `source_tradeable_resources`
(owned tiles → deterministic `tile_collectibles` rolls keyed `map_seed ^ coord`
classified by `resource_categories`; dups kept), runs `evaluate_trades`, **persists**
the re-derived swap/sale agreements into `state.trade_ledger` (retaining the
persistent OpenBorders/SharedMap), writes `PlayerState.traded_strategics` (new field),
and applies net gold flow (`gold_flow_for`). Replaced the old proxy inputs
(`tile_strategics: Vec::new()`). **Done 2026-06-26**
`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 —

View file

@ -1,12 +1,12 @@
{
"generated_at": "2026-06-26T05:37:59Z",
"generated_at": "2026-06-26T05:57:32Z",
"totals": {
"in_progress": 0,
"stub": 0,
"oos": 31,
"done": 296,
"missing": 0,
"partial": 2,
"missing": 0,
"stub": 0,
"in_progress": 0,
"done": 296,
"oos": 31,
"total": 329
},
"objectives": [

View file

@ -1008,6 +1008,11 @@ pub struct PlayerState {
/// Cleared and rebuilt each turn by `process_trade_phase`.
#[serde(default)]
pub traded_luxuries: BTreeSet<String>,
/// p3-25: strategic resource IDs received via active trade agreements this
/// turn (swaps + strategic sales). Grants unit-build access (gating) like a
/// tile-owned copy. Cleared and rebuilt each turn by `process_trade_phase`.
#[serde(default)]
pub traded_strategics: BTreeSet<String>,
/// Diplomatic relation states keyed by canonical pair `(min_idx, max_idx)`.
/// Shared across all players — only player_index 0 carries the authoritative
/// copy; `process_trade_phase` syncs it from the ledger.

View file

@ -716,17 +716,28 @@ impl TurnProcessor {
/// Populates each player's `traded_luxuries` so GDScript happiness.gd can
/// include them when calling `calculate_happiness`.
fn process_trade_phase(&self, state: &mut GameState) {
// Collect per-player inputs. tile_luxuries from strategic_axes proxy
// (real game populates this from owned tile collectibles via GDScript).
// p3-25 step 4: real per-player trade inputs sourced from each player's
// owned-tile collectibles (no longer a proxy). Owned tiles →
// `tile_collectibles` rolls → classified by `GameState.resource_categories`
// into luxury/strategic surpluses (duplicates kept for the
// `MIN_COPIES_TO_TRADE` rule). Empty grid or categories → empty inputs (a
// safe no-op: nothing trades until the harness loads them).
let map_seed = state.map_seed;
let inputs: Vec<PlayerTradeInput> = state
.players
.iter()
.map(|p| {
let willingness = *p.strategic_axes.get("trade_willingness").unwrap_or(&5);
let (tile_luxuries, tile_strategics) = Self::source_tradeable_resources(
p,
state.grid.as_ref(),
&state.resource_categories,
map_seed,
);
PlayerTradeInput {
player_index: p.player_index,
tile_luxuries: p.traded_luxuries.iter().cloned().collect(), // bench uses stub
tile_strategics: Vec::new(), // p3-23: bench path sources no strategics yet
tile_luxuries,
tile_strategics,
trade_willingness: willingness,
}
})
@ -753,17 +764,94 @@ impl TurnProcessor {
pairs
};
// Advance relation states.
let ledger = evaluate_trades(&inputs, &relations, state.turn);
advance_relations(&mut relations, &ledger, &combat_pairs, &all_pairs);
// Write traded_luxuries per player from ledger.
// Persist the re-derived swap/sale agreements into the authoritative
// ledger so the projection + view carry real trades. Swaps/sales are
// re-derived every turn, so drop the previous turn's first; the
// persistent OpenBorders/SharedMap agreements (player-signed, renewable)
// are kept untouched.
state.trade_ledger.agreements.retain(|ag| {
matches!(
ag,
mc_trade::DiplomaticAgreement::OpenBorders(_)
| mc_trade::DiplomaticAgreement::SharedMap(_)
)
});
state
.trade_ledger
.agreements
.extend(ledger.agreements.iter().cloned());
// Fan the ledger onto each player: traded luxuries (happiness pool),
// traded strategics (unit-build gating), and net per-turn gold flow
// (seller +, buyer ).
for player in &mut state.players {
player.traded_luxuries = ledger.incoming_luxuries(player.player_index);
let idx = player.player_index;
player.traded_luxuries = ledger.incoming_luxuries(idx);
player.traded_strategics = ledger.incoming_strategics(idx);
player.gold = (player.gold + ledger.gold_flow_for(idx)).max(0);
player.relations = relations.clone();
}
}
/// p3-25 step 4: source a player's tradeable luxury + strategic resources from
/// the collectible rolls of every tile its cities own. The city centre is
/// owned implicitly (from `city_positions`) until border expansion claims
/// more (`process_culture`). Collectibles are rolled deterministically per
/// tile (`map_seed ^ coord`) so a tile's resources are stable across turns,
/// and classified via `resource_categories`. Duplicates are kept so the
/// surplus rule (`MIN_COPIES_TO_TRADE`) can see multiple copies.
fn source_tradeable_resources(
p: &crate::game_state::PlayerState,
grid: Option<&mc_core::grid::GridState>,
categories: &std::collections::BTreeMap<String, String>,
map_seed: u64,
) -> (Vec<String>, Vec<String>) {
let mut luxuries: Vec<String> = Vec::new();
let mut strategics: Vec<String> = Vec::new();
let grid = match grid {
Some(g) => g,
None => return (luxuries, strategics),
};
if categories.is_empty() {
return (luxuries, strategics);
}
for (ci, city) in p.cities.iter().enumerate() {
let owned: Vec<(i32, i32)> = if city.owned_tiles.is_empty() {
p.city_positions
.get(ci)
.copied()
.map(|c| vec![c])
.unwrap_or_default()
} else {
city.owned_tiles.clone()
};
for (col, row) in owned {
let tile = match grid.tile(col, row) {
Some(t) => t,
None => continue,
};
let quality = tile.quality.max(0).min(u8::MAX as i32) as u8;
let seed = map_seed ^ ((col as u64) << 32) ^ (row as u64 & 0xFFFF_FFFF);
let mut rng = mc_core::collectibles::SplitMix64::new(seed);
for roll in mc_core::collectibles::tile_collectibles(
&tile.biome_label_id,
quality,
&mut rng,
) {
match categories.get(&roll.resource_id).map(String::as_str) {
Some("luxury") => luxuries.push(roll.resource_id),
Some("strategic") => strategics.push(roll.resource_id),
_ => {}
}
}
}
}
(luxuries, strategics)
}
// ── Phase 1: Economy ───────────────────────────────────────────────────
fn process_economy(&self, state: &mut GameState, pi: usize) {
@ -5589,6 +5677,56 @@ mod tests {
}
}
#[test]
fn source_tradeable_resources_classifies_owned_tile_collectibles() {
// p3-25 step 4: owned-tile collectibles → category-classified luxury /
// strategic surpluses, deterministically, with uncategorized resources
// filtered out.
use mc_core::grid::GridState;
let mut grid = GridState::new(20, 20);
let coords: Vec<(i32, i32)> = (0..12).map(|i| (i, 0)).collect();
for &(c, r) in &coords {
if let Some(t) = grid.tile_mut(c, r) {
t.biome_label_id = "tropical_rainforest".into(); // hardwood @0.80
t.quality = 5;
}
}
let mut categories = std::collections::BTreeMap::new();
categories.insert("hardwood".to_string(), "strategic".to_string());
categories.insert("rare_herbs".to_string(), "luxury".to_string());
// wild_game deliberately uncategorized → must never appear.
let mut p = crate::game_state::PlayerState::default();
p.player_index = 0;
p.cities = vec![CityState::starter()];
p.cities[0].owned_tiles = coords.clone();
p.city_positions = vec![(0, 0)];
let (lux, strat) =
TurnProcessor::source_tradeable_resources(&p, Some(&grid), &categories, 0xDEAD_BEEF);
let (lux2, strat2) =
TurnProcessor::source_tradeable_resources(&p, Some(&grid), &categories, 0xDEAD_BEEF);
assert_eq!((&lux, &strat), (&lux2, &strat2), "sourcing must be deterministic");
assert!(strat.iter().all(|r| r == "hardwood"), "strategics: {strat:?}");
assert!(lux.iter().all(|r| r == "rare_herbs"), "luxuries: {lux:?}");
assert!(
!strat.iter().any(|r| r == "wild_game") && !lux.iter().any(|r| r == "wild_game"),
"uncategorized wild_game must be filtered out"
);
assert!(
!strat.is_empty(),
"≥1 hardwood expected across 12 rainforest tiles"
);
// Empty categories → nothing tradeable (safe no-op).
let (el, es) = TurnProcessor::source_tradeable_resources(
&p,
Some(&grid),
&std::collections::BTreeMap::new(),
0xDEAD_BEEF,
);
assert!(el.is_empty() && es.is_empty(), "no categories → no tradeables");
}
#[test]
fn processor_is_deterministic() {
use mc_core::grid::GridState;