diff --git a/.project/objectives/p3-23-trade-richness-gold-strategic.md b/.project/objectives/p3-23-trade-richness-gold-strategic.md index 7fbf0e19..094e6e83 100644 --- a/.project/objectives/p3-23-trade-richness-gold-strategic.md +++ b/.project/objectives/p3-23-trade-richness-gold-strategic.md @@ -19,32 +19,39 @@ tradeable in concept, but no exchange path exists). ## Acceptance -- [ ] `mc-trade` supports **gold ↔ resource** deals (buy/sell a luxury or - strategic resource for gold-per-turn or lump sum). — *not started; needs an - mc-economy gold-flow integration. Next sub-pass.* +- [x] `mc-trade` supports **gold ↔ resource** deals. `DiplomaticAgreement::ResourceSale` + forms as a barter *fallback* when a pair can't swap (one side has a surplus the + other lacks): the holder SELLS for `SALE_GOLD_PER_TURN`. `gold_flow_for(player)` + exposes the per-turn flow (seller +, buyer −); `incoming_luxuries`/`incoming_strategics` + route the bought resource. Cargo-tested. - [x] `mc-trade` supports **strategic-resource** trades (e.g. iron for horses), with the same "keep your last copy" protection (`MIN_COPIES_TO_TRADE`). `DiplomaticAgreement::StrategicSwap`, `evaluate_trades` forms one per pair independently of the luxury swap (`swap_candidates` helper), `incoming_strategics` / `has_strategic_agreement` accessors, breaks on war. Surplus + complementarity gated, deterministic. -- [x] AI evaluates strategic swaps via the same willingness + relation gate - (`evaluate_trades` is the shared evaluation surface). *(In-game: the api-gdext - FFI caller must source each player's `tile_strategics` into the input JSON — - forward-compatible via `#[serde(default)]`. Wiring follow-up.)* -- [x] Logic in Rust (`mc-trade`); strategic-for-strategic deals evaluate + activate - (4 cargo tests). [ ] gold-for-luxury test pending the gold-flow half. +- [x] AI evaluates swaps + sales via the same willingness + relation gate + (`evaluate_trades` is the shared evaluation surface). *(In-game: the FFI caller + must source each player's `tile_strategics` — forward-compatible via + `#[serde(default)]`. Wiring follow-up.)* +- [~] Logic in Rust (`mc-trade`): gold-for-luxury sale + strategic-for-strategic + swap + strategic sale all evaluate + activate (8 cargo tests; mc-trade 66/0). + **[ ] GDScript deal-UI surfacing + in-game application not done** → status stays + `partial`. ## Progress (2026-06-25) -Strategic-resource swap **evaluator** complete + cargo-tested (mc-trade 62/0): -`strategic_swap_forms_from_complementary_surplus`, -`strategic_swap_needs_surplus_and_complementarity`, -`luxury_and_strategic_swaps_coexist_for_a_pair`, `strategic_swap_breaks_on_war`. -api-gdext compiles with the new variant. **Remaining for `done`:** (a) gold ↔ -resource deals + mc-economy gold flow; (b) in-game wiring — FFI sources -`tile_strategics`, new `PlayerState.traded_strategics`, unit-gating consumes -`incoming_strategics`, dylib rebuild + GUT proof. +Trade-richness **simulation logic complete + cargo-tested (mc-trade 66/0)** — both +halves: strategic-resource **swaps** (`StrategicSwap`) and gold **sales** +(`ResourceSale`, `gold_flow_for`). Tests: strategic swap forms/needs-surplus/ +coexists/breaks-on-war; gold sale forms-as-fallback/no-sale-when-swap/strategic- +routing/breaks-on-war. api-gdext + mc-turn compile with both new variants. + +**Remaining for `done` (the in-game application — overlaps p3-24's economy port):** +(a) `mc-economy::process_gold` consumes `gold_flow_for` so sales hit the treasury; +(b) FFI sources `tile_strategics`, new `PlayerState.traded_strategics`, unit-gating +reads `incoming_strategics`, happiness reads sale luxuries; (c) GDScript deal UI; +(d) dylib rebuild + GUT proof. ## Code sites diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json index 75206986..e1383ac3 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-25T21:50:45Z", + "generated_at": "2026-06-25T22:18:25Z", "totals": { "done": 295, - "missing": 0, - "in_progress": 0, "stub": 0, - "oos": 31, + "in_progress": 0, "partial": 2, + "oos": 31, + "missing": 0, "total": 328 }, "objectives": [ diff --git a/src/simulator/crates/mc-trade/src/lib.rs b/src/simulator/crates/mc-trade/src/lib.rs index b843b2ff..410a6705 100644 --- a/src/simulator/crates/mc-trade/src/lib.rs +++ b/src/simulator/crates/mc-trade/src/lib.rs @@ -27,6 +27,10 @@ pub const MIN_TRADE_WILLINGNESS: u8 = 3; /// the happiness bonus yourself. pub const MIN_COPIES_TO_TRADE: usize = 2; +/// Gold-per-turn a buyer pays for a resource bought via a `ResourceSale` (p3-23). +/// Flat price for EA; can be data-driven (by resource rarity) later. +pub const SALE_GOLD_PER_TURN: u32 = 2; + // ── Core types ────────────────────────────────────────────────────────────── /// A single bilateral luxury swap active for the current turn. @@ -42,6 +46,29 @@ pub struct TradeAgreement { pub turn_started: u32, } +/// A one-sided sale of a resource for gold-per-turn (p3-23). Forms as a barter +/// *fallback* when a pair cannot swap (only one side has a surplus the other +/// lacks): the surplus holder `seller` sells `resource` to `buyer` for +/// `gold_per_turn`. `strategic` routes the bought resource to the buyer's +/// strategic vs. luxury pool. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResourceSaleAgreement { + /// Canonical pair key (min_idx, max_idx). + pub partners: (u8, u8), + /// The player giving the resource and receiving gold. + pub seller: u8, + /// The player paying gold and receiving the resource. + pub buyer: u8, + /// Resource ID sold. + pub resource: String, + /// Whether `resource` is a strategic (true) or luxury (false) resource. + pub strategic: bool, + /// Gold the buyer pays the seller each turn the sale is active. + pub gold_per_turn: u32, + /// Turn on which the sale was formed. + pub turn_started: u32, +} + /// Snapshot of all active diplomatic agreements for a given turn. /// /// Migrated from `Vec` to `Vec` (p3-01 c4). @@ -63,12 +90,19 @@ impl TradeLedger { pub fn incoming_luxuries(&self, player: u8) -> BTreeSet { let mut out = BTreeSet::new(); for ag in &self.agreements { - if let DiplomaticAgreement::LuxurySwap(ta) = ag { - if ta.partners.0 == player { - out.insert(ta.gives_b.clone()); - } else if ta.partners.1 == player { - out.insert(ta.gives_a.clone()); + match ag { + DiplomaticAgreement::LuxurySwap(ta) => { + if ta.partners.0 == player { + out.insert(ta.gives_b.clone()); + } else if ta.partners.1 == player { + out.insert(ta.gives_a.clone()); + } } + // p3-23: a luxury bought via a gold sale also reaches the buyer. + DiplomaticAgreement::ResourceSale(rs) if !rs.strategic && rs.buyer == player => { + out.insert(rs.resource.clone()); + } + _ => {} } } out @@ -92,12 +126,19 @@ impl TradeLedger { pub fn incoming_strategics(&self, player: u8) -> BTreeSet { let mut out = BTreeSet::new(); for ag in &self.agreements { - if let DiplomaticAgreement::StrategicSwap(ta) = ag { - if ta.partners.0 == player { - out.insert(ta.gives_b.clone()); - } else if ta.partners.1 == player { - out.insert(ta.gives_a.clone()); + match ag { + DiplomaticAgreement::StrategicSwap(ta) => { + if ta.partners.0 == player { + out.insert(ta.gives_b.clone()); + } else if ta.partners.1 == player { + out.insert(ta.gives_a.clone()); + } } + // p3-23: a strategic resource bought via a gold sale reaches the buyer. + DiplomaticAgreement::ResourceSale(rs) if rs.strategic && rs.buyer == player => { + out.insert(rs.resource.clone()); + } + _ => {} } } out @@ -111,6 +152,32 @@ impl TradeLedger { .any(|ag| matches!(ag, DiplomaticAgreement::StrategicSwap(ta) if ta.partners == key)) } + /// True if any ResourceSale exists between this pair (p3-23). + pub fn has_resource_sale(&self, a: u8, b: u8) -> bool { + let key = pair_key(a, b); + self.agreements + .iter() + .any(|ag| matches!(ag, DiplomaticAgreement::ResourceSale(rs) if rs.partners == key)) + } + + /// Net gold-per-turn this player nets from active ResourceSale deals (p3-23): + /// `+gold_per_turn` for each sale it is the seller of, `-gold_per_turn` for + /// each it is the buyer of. Feed into the economy's per-turn gold (the buyer + /// pays the seller). Zero if the player has no active sales. + pub fn gold_flow_for(&self, player: u8) -> i32 { + let mut net: i32 = 0; + for ag in &self.agreements { + if let DiplomaticAgreement::ResourceSale(rs) = ag { + if rs.seller == player { + net += rs.gold_per_turn as i32; + } else if rs.buyer == player { + net -= rs.gold_per_turn as i32; + } + } + } + net + } + /// Allocate the next agreement id and advance the counter. pub fn alloc_agreement_id(&mut self) -> u64 { let id = self.next_agreement_id; @@ -196,6 +263,33 @@ fn swap_candidates( Some((a_gives, b_gives)) } +/// Find a one-sided sale (p3-23): a resource one player holds a tradeable +/// surplus of that the other lacks. Probes deterministically — pa-as-seller +/// before pb, luxuries before strategics, alphabetically-first resource. +/// Returns `(seller, buyer, resource, is_strategic)` or `None`. +fn sale_candidate(pa: &PlayerTradeInput, pb: &PlayerTradeInput) -> Option<(u8, u8, String, bool)> { + let a_lux_t = pa.tradeable_set(); + let a_lux_o = pa.owned_set(); + let b_lux_t = pb.tradeable_set(); + let b_lux_o = pb.owned_set(); + let a_str_t = pa.tradeable_strategics(); + let a_str_o = pa.owned_strategics(); + let b_str_t = pb.tradeable_strategics(); + let b_str_o = pb.owned_strategics(); + let probes: [(&BTreeSet, &BTreeSet, u8, u8, bool); 4] = [ + (&a_lux_t, &b_lux_o, pa.player_index, pb.player_index, false), + (&b_lux_t, &a_lux_o, pb.player_index, pa.player_index, false), + (&a_str_t, &b_str_o, pa.player_index, pb.player_index, true), + (&b_str_t, &a_str_o, pb.player_index, pa.player_index, true), + ]; + for (seller_tradeable, buyer_owned, seller, buyer, strategic) in probes { + if let Some(res) = seller_tradeable.difference(buyer_owned).next() { + return Some((seller, buyer, res.clone(), strategic)); + } + } + None +} + /// Evaluate which trades to form this turn given current relations and /// personality axes. /// @@ -277,6 +371,27 @@ pub fn evaluate_trades( })); } } + + // ── Gold-for-resource sale (p3-23) ── barter fallback: only when the + // pair formed no swap (one side has a surplus the other lacks but not + // vice-versa). The surplus holder sells for gold-per-turn. + let formed_swap = ledger.has_agreement(pa.player_index, pb.player_index) + || ledger.has_strategic_agreement(pa.player_index, pb.player_index); + if !formed_swap && !ledger.has_resource_sale(pa.player_index, pb.player_index) { + if let Some((seller, buyer, resource, strategic)) = sale_candidate(pa, pb) { + ledger + .agreements + .push(DiplomaticAgreement::ResourceSale(ResourceSaleAgreement { + partners: key, + seller, + buyer, + resource, + strategic, + gold_per_turn: SALE_GOLD_PER_TURN, + turn_started: current_turn, + })); + } + } } } @@ -298,6 +413,7 @@ pub fn break_trades_on_war( DiplomaticAgreement::LuxurySwap(ta) | DiplomaticAgreement::StrategicSwap(ta) => { ta.partners } + DiplomaticAgreement::ResourceSale(rs) => rs.partners, DiplomaticAgreement::OpenBorders(ob) => ob.partners, DiplomaticAgreement::SharedMap(sm) => sm.partners, }; @@ -324,6 +440,9 @@ pub enum DiplomaticAgreement { /// the resource (unit-gating) rather than a happiness bonus. Not renewable /// and carries no `agreement_id` — re-derived each turn by `evaluate_trades`. StrategicSwap(TradeAgreement), + /// Gold-for-resource sale (p3-23) — barter fallback when a pair can't swap. + /// Re-derived each turn by `evaluate_trades`; not renewable. + ResourceSale(ResourceSaleAgreement), /// Allows one civ's units to move through the other's territory for N turns. /// Payment made at signing; effect is immediate. No courier route required. OpenBorders(OpenBordersAgreement), @@ -796,8 +915,10 @@ pub fn step_shared_map_agreements( } } - DiplomaticAgreement::LuxurySwap(_) | DiplomaticAgreement::StrategicSwap(_) => { - // Luxury & strategic swaps are re-derived each turn by + DiplomaticAgreement::LuxurySwap(_) + | DiplomaticAgreement::StrategicSwap(_) + | DiplomaticAgreement::ResourceSale(_) => { + // Luxury/strategic swaps & gold sales are re-derived each turn by // evaluate_trades; the courier-renewal stepper doesn't touch them. } } @@ -891,6 +1012,86 @@ mod tests { ); } + #[test] + fn gold_sale_forms_as_barter_fallback() { + // p3-23: A has spare wine, B has no wine and nothing to swap back → no + // swap is possible, so A SELLS wine to B for gold-per-turn. Gold flows + // (A +, B -) and B receives the luxury. + let players = vec![ + make_player(0, &["wine", "wine"], 8), + make_player(1, &[], 8), + ]; + let ledger = evaluate_trades(&players, &empty_relations(), 1); + + let sale = ledger + .agreements + .iter() + .find_map(|ag| match ag { + DiplomaticAgreement::ResourceSale(rs) => Some(rs), + _ => None, + }) + .expect("a gold sale should form as the barter fallback"); + assert_eq!(sale.seller, 0); + assert_eq!(sale.buyer, 1); + assert_eq!(sale.resource, "wine"); + assert!(!sale.strategic); + assert_eq!(sale.gold_per_turn, SALE_GOLD_PER_TURN); + + assert_eq!(ledger.gold_flow_for(0), SALE_GOLD_PER_TURN as i32, "seller earns"); + assert_eq!(ledger.gold_flow_for(1), -(SALE_GOLD_PER_TURN as i32), "buyer pays"); + assert!(ledger.incoming_luxuries(1).contains("wine"), "buyer receives the luxury"); + assert!(ledger.has_resource_sale(0, 1)); + } + + #[test] + fn no_sale_when_a_swap_already_forms() { + // Complementary surplus → a swap forms, so the sale fallback stays idle. + let players = vec![ + make_player(0, &["silk", "silk"], 8), + make_player(1, &["wine", "wine"], 8), + ]; + let ledger = evaluate_trades(&players, &empty_relations(), 1); + assert!(ledger.has_agreement(0, 1), "a luxury swap forms"); + assert!(!ledger.has_resource_sale(0, 1), "no sale when a swap covered the pair"); + assert_eq!(ledger.gold_flow_for(0), 0); + } + + #[test] + fn strategic_sale_routes_to_strategics_pool() { + // A sells a strategic resource the buyer lacks → buyer's incoming_strategics. + let players = vec![ + make_strategic_player(0, &["iron", "iron"], 8), + make_strategic_player(1, &[], 8), + ]; + let ledger = evaluate_trades(&players, &empty_relations(), 1); + let sale = ledger + .agreements + .iter() + .find_map(|ag| match ag { + DiplomaticAgreement::ResourceSale(rs) => Some(rs), + _ => None, + }) + .expect("a strategic gold sale should form"); + assert!(sale.strategic); + assert_eq!(sale.resource, "iron"); + assert!(ledger.incoming_strategics(1).contains("iron"), "buyer gains strategic access"); + assert!(ledger.incoming_luxuries(1).is_empty(), "not routed to luxuries"); + } + + #[test] + fn gold_sale_breaks_on_war() { + let players = vec![ + make_player(0, &["wine", "wine"], 8), + make_player(1, &[], 8), + ]; + let mut ledger = evaluate_trades(&players, &empty_relations(), 1); + assert!(ledger.has_resource_sale(0, 1)); + let broken = break_trades_on_war(&mut ledger, 0, 1); + assert_eq!(broken.len(), 1); + assert!(!ledger.has_resource_sale(0, 1)); + assert_eq!(ledger.gold_flow_for(0), 0, "gold flow stops after the sale breaks"); + } + #[test] fn luxury_and_strategic_swaps_coexist_for_a_pair() { // p3-23: a pair can hold BOTH a luxury swap and a strategic swap. @@ -986,10 +1187,13 @@ mod tests { } #[test] - fn no_trade_when_no_surplus() { + fn no_trade_when_neither_has_surplus() { + // Single copies on both sides → no tradeable surplus → no swap AND no + // gold sale (p3-23: a sale still requires a sellable surplus). With a + // one-sided surplus a sale WOULD form — see gold_sale_forms_as_barter_fallback. let players = vec![ make_player(0, &["silk"], 8), - make_player(1, &["wine", "wine"], 8), + make_player(1, &["wine"], 8), ]; let ledger = evaluate_trades(&players, &empty_relations(), 1); assert!(ledger.agreements.is_empty()); diff --git a/src/simulator/crates/mc-trade/src/renewal.rs b/src/simulator/crates/mc-trade/src/renewal.rs index 0cea92d4..9d696aac 100644 --- a/src/simulator/crates/mc-trade/src/renewal.rs +++ b/src/simulator/crates/mc-trade/src/renewal.rs @@ -166,7 +166,9 @@ pub fn propose_renewal( .position(|ag| match ag { DiplomaticAgreement::OpenBorders(ob) => ob.agreement_id == agreement_id, DiplomaticAgreement::SharedMap(sm) => sm.agreement_id == agreement_id, - DiplomaticAgreement::LuxurySwap(_) | DiplomaticAgreement::StrategicSwap(_) => false, + DiplomaticAgreement::LuxurySwap(_) + | DiplomaticAgreement::StrategicSwap(_) + | DiplomaticAgreement::ResourceSale(_) => false, }) { Some(i) => i, None => return RenewalDecision::NotFound { agreement_id }, @@ -185,7 +187,9 @@ pub fn propose_renewal( .unwrap_or(sm.duration); (sm.payment_gold, dur) } - DiplomaticAgreement::LuxurySwap(_) | DiplomaticAgreement::StrategicSwap(_) => { + DiplomaticAgreement::LuxurySwap(_) + | DiplomaticAgreement::StrategicSwap(_) + | DiplomaticAgreement::ResourceSale(_) => { unreachable!() } }; @@ -218,7 +222,9 @@ pub fn propose_renewal( DiplomaticAgreement::SharedMap(sm) => { sm.share_turns_remaining = default_dur; } - DiplomaticAgreement::LuxurySwap(_) | DiplomaticAgreement::StrategicSwap(_) => { + DiplomaticAgreement::LuxurySwap(_) + | DiplomaticAgreement::StrategicSwap(_) + | DiplomaticAgreement::ResourceSale(_) => { unreachable!() } } @@ -333,7 +339,9 @@ pub fn voluntary_cancel( let Some(idx) = ledger.agreements.iter().position(|ag| match ag { DiplomaticAgreement::OpenBorders(ob) => ob.agreement_id == agreement_id, DiplomaticAgreement::SharedMap(sm) => sm.agreement_id == agreement_id, - DiplomaticAgreement::LuxurySwap(_) | DiplomaticAgreement::StrategicSwap(_) => false, + DiplomaticAgreement::LuxurySwap(_) + | DiplomaticAgreement::StrategicSwap(_) + | DiplomaticAgreement::ResourceSale(_) => false, }) else { return CancelDecision::NotFound { agreement_id }; }; @@ -341,7 +349,9 @@ pub fn voluntary_cancel( let partners = match &ledger.agreements[idx] { DiplomaticAgreement::OpenBorders(ob) => ob.partners, DiplomaticAgreement::SharedMap(sm) => sm.partners, - DiplomaticAgreement::LuxurySwap(_) | DiplomaticAgreement::StrategicSwap(_) => { + DiplomaticAgreement::LuxurySwap(_) + | DiplomaticAgreement::StrategicSwap(_) + | DiplomaticAgreement::ResourceSale(_) => { unreachable!() } };