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 0218f0e4..6202283b 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 @@ -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 — diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json index c599984b..7f7cc676 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: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": [ diff --git a/src/simulator/crates/mc-state/src/game_state.rs b/src/simulator/crates/mc-state/src/game_state.rs index 96cfc2f0..04365608 100644 --- a/src/simulator/crates/mc-state/src/game_state.rs +++ b/src/simulator/crates/mc-state/src/game_state.rs @@ -1008,6 +1008,11 @@ pub struct PlayerState { /// Cleared and rebuilt each turn by `process_trade_phase`. #[serde(default)] pub traded_luxuries: BTreeSet, + /// 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, /// 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. diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index b55bfe75..f1cbf763 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -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 = 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, + map_seed: u64, + ) -> (Vec, Vec) { + let mut luxuries: Vec = Vec::new(); + let mut strategics: Vec = 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;