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:
parent
e376920766
commit
1a7fd849c0
4 changed files with 167 additions and 17 deletions
|
|
@ -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 —
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue