feat(@projects/@magic-civilization): 💰 p3-23 (part 2) — gold-for-resource sales in mc-trade

Completes the trade-richness simulation logic. New DiplomaticAgreement::ResourceSale
forms as a barter FALLBACK: when a pair can't swap (one side has a surplus the
other lacks but not vice-versa), the surplus holder SELLS the resource to the
buyer for SALE_GOLD_PER_TURN. evaluate_trades produces it after the swap passes
(sale_candidate probes pa-then-pb, luxuries-then-strategics, deterministic).
TradeLedger.gold_flow_for(player) exposes the per-turn flow (seller +, buyer −);
incoming_luxuries/incoming_strategics route the bought resource to the right pool;
has_resource_sale + breaks-on-war. Variant grouped with the swap arms across the
renewal/courier matches; break_trades_on_war gets its own ResourceSale arm.

Verified: 4 new cargo tests (forms-as-fallback, no-sale-when-swap, strategic-
routing, breaks-on-war); existing no-surplus test updated for the new fallback;
mc-trade 66/0; api-gdext + mc-turn compile.

p3-23 stays partial — both trade-logic halves (swaps + sales) now done + tested;
remaining is the in-game application (mc-economy gold flow + traded-resource
gating/happiness + GDScript deal UI), which lands with p3-24's economy port.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-25 18:18:25 -04:00
parent 4a97ab1d02
commit d45ba32a3a
4 changed files with 261 additions and 40 deletions

View file

@ -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

View file

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

View file

@ -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<TradeAgreement>` to `Vec<DiplomaticAgreement>` (p3-01 c4).
@ -63,12 +90,19 @@ impl TradeLedger {
pub fn incoming_luxuries(&self, player: u8) -> BTreeSet<String> {
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<String> {
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<String>, &BTreeSet<String>, 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());

View file

@ -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!()
}
};