feat(@projects/@magic-civilization): 💰 p3-23 part A — inter-player gold sales hit the treasury in-game

Wires the ResourceSale gold flow into the live economy (leveraging the p3-24
phase-1 economy port). GdTradeLedger gains gold_flow_for + incoming_strategics
#[func]s; GdEconomy gains a trade_gold param added to net gold AFTER the yield /
golden-age multipliers (a trade transfer must not be amplified by difficulty
handicap); economy.gd._player_trade_gold reads GameState.trade_ledger_json via
GdTradeLedger and passes the player's net flow. A seller now gains gold and a
buyer pays it each turn a sale is active.

Verified: GUT test_trade_gold_flows_into_net_gold (seller +3 → net 8, buyer −2 →
net 3, trade_gold echoed); dylib rebuilt + canonical GUT 748/0.

p3-23 stays partial — gold-trade flow now live (part A); remaining part B is the
strategic-resource gating (FFI sources tile_strategics, PlayerState.traded_strategics,
unit-gating reads incoming_strategics) + the deal UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-25 20:12:40 -04:00
parent e00c0477ab
commit a8c01cb5e1
5 changed files with 108 additions and 6 deletions

View file

@ -36,8 +36,12 @@ tradeable in concept, but no exchange path exists).
`#[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`.
**In-game application — part A (gold flow) DONE:** `GdTradeLedger.gold_flow_for`
+ `GdEconomy` `trade_gold` param + `economy.gd` sourcing → inter-player sales now
hit the treasury (GUT `test_trade_gold_flows_into_net_gold`, seller +/buyer ).
**[ ] Remaining: part B** (strategic-resource gating — FFI sources `tile_strategics`,
`PlayerState.traded_strategics`, unit-gating reads `incoming_strategics`) **+ deal
UI** → status stays `partial`.
## Progress (2026-06-25)
@ -47,6 +51,15 @@ halves: strategic-resource **swaps** (`StrategicSwap`) and gold **sales**
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.
**In-game application part A — gold flow LIVE (2026-06-25).** `GdTradeLedger`
gains `gold_flow_for` + `incoming_strategics` #[func]s; `GdEconomy` gains a
`trade_gold` param added to net gold after the yield/golden-age multipliers (a
transfer isn't amplified by difficulty); `economy.gd._player_trade_gold` reads the
ledger and passes it. Inter-player gold sales now move treasury in the played game.
Verified: GUT `test_trade_gold_flows_into_net_gold` (seller +3 → net 8, buyer 2 →
net 3); dylib rebuilt + GUT 748/0. **Remaining: part B** (strategic-resource gating
+ deal UI).
**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

View file

@ -1,12 +1,12 @@
{
"generated_at": "2026-06-25T23:39:51Z",
"generated_at": "2026-06-26T00:12:40Z",
"totals": {
"in_progress": 0,
"done": 295,
"stub": 0,
"oos": 31,
"partial": 2,
"missing": 0,
"done": 295,
"partial": 2,
"total": 328
},
"objectives": [

View file

@ -128,10 +128,31 @@ static func _build_params_json(player: RefCounted) -> String:
"current_gold": int(player.gold),
"deficit_floor": deficit_floor,
"yield_mult": yield_mult,
# p3-23: net per-turn gold from this player's active inter-player trade
# sales (ResourceSale). Rust adds it to net gold after the multipliers.
"trade_gold": _player_trade_gold(player),
}
)
static func _player_trade_gold(player: RefCounted) -> int:
## Net per-turn gold this player nets from active ResourceSale trades, read
## from the shared trade ledger via GdTradeLedger.gold_flow_for. Returns 0 when
## there is no ledger yet, the GDExtension class is absent, or no sales involve
## the player. Mirrors diplomacy.gd's ledger-read guard pattern.
if GameState.trade_ledger_json.is_empty():
return 0
if not ClassDB.class_exists("GdTradeLedger"):
return 0
var ledger: RefCounted = ClassDB.instantiate("GdTradeLedger") as RefCounted
if ledger == null:
return 0
ledger = ledger.from_json(GameState.trade_ledger_json)
if ledger == null:
return 0
return int(ledger.gold_flow_for(int(player.index)))
static func _disband_cheapest(player: RefCounted, count: int) -> void:
## All military units share the flat `UNIT_UPKEEP_FLAT` cost, so "cheapest"
## is just the first alive military unit encountered (matches the legacy

View file

@ -151,6 +151,44 @@ func test_insolvency_disbands_one_unit_when_below_deficit_floor() -> void:
assert_eq(int(result.get("new_gold", -1)), 0)
func test_trade_gold_flows_into_net_gold() -> void:
## p3-23: inter-player ResourceSale gold (params.trade_gold) is added to net
## gold after the multipliers. Seller (+) and buyer () both verified.
if not ClassDB.class_exists("GdEconomy"):
pending("GdEconomy extension not loaded in this headless run")
return
var cities: Array = [
{
"building_gold_effects": [5],
"gold_percent_effects": [],
"tile_gold": 0,
"building_upkeep": 0,
}
]
var bridge: RefCounted = ClassDB.instantiate("GdEconomy") as RefCounted
# Seller: +3 trade gold → income 5 + 3 = 8.
var seller_params: Dictionary = {
"golden_age_active": false, "golden_age_bonus": 0.2,
"current_gold": 0, "deficit_floor": -5, "trade_gold": 3,
}
var seller: Dictionary = bridge.process_turn(
JSON.stringify(cities), JSON.stringify([]), JSON.stringify(seller_params)
)
assert_eq(int(seller.get("net_gold", 0)), 8, "seller nets income + trade_gold")
assert_eq(int(seller.get("trade_gold", -1)), 3)
# Buyer: 2 trade gold → income 5 2 = 3.
var buyer_params: Dictionary = {
"golden_age_active": false, "golden_age_bonus": 0.2,
"current_gold": 0, "deficit_floor": -5, "trade_gold": -2,
}
var buyer: Dictionary = bridge.process_turn(
JSON.stringify(cities), JSON.stringify([]), JSON.stringify(buyer_params)
)
assert_eq(int(buyer.get("net_gold", 0)), 3, "buyer pays trade_gold out of net")
func test_wrapper_class_is_thin_static_and_preloads() -> void:
## Even without the extension, `Economy` itself must be a valid script
## with a static `process_turn` entry point — catches regressions where

View file

@ -7531,6 +7531,26 @@ impl GdTradeLedger {
}
}
/// 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 buys. economy.gd passes this to `GdEconomy` as `trade_gold` so the
/// sale hits the treasury. 0 when the player has no active sales.
#[func]
fn gold_flow_for(&self, player: i64) -> i64 {
self.inner.gold_flow_for(player as u8) as i64
}
/// Strategic resources this player gains from active StrategicSwap / strategic
/// ResourceSale deals (p3-23) — unit-gating access. Returned as an `Array[String]`.
#[func]
fn incoming_strategics(&self, player: i64) -> Array<GString> {
self.inner
.incoming_strategics(player as u8)
.into_iter()
.map(GString::from)
.collect()
}
/// Returns all active OpenBorders agreements as an `Array[GdOpenBordersAgreement]`.
#[func]
fn iter_open_borders(&self) -> Array<Gd<GdOpenBordersAgreement>> {
@ -7831,6 +7851,12 @@ struct EconomyParams {
/// handicap should help income, not amplify deficits. Default 1.0 = no-op.
#[serde(default = "default_yield_mult")]
yield_mult: f64,
/// Net per-turn gold from active inter-player ResourceSale deals (p3-23):
/// `+` for the seller, `` for the buyer. Sourced from
/// `GdTradeLedger.gold_flow_for` and added to net gold AFTER the yield/golden
/// -age multipliers (a trade transfer isn't amplified by difficulty handicap).
#[serde(default)]
trade_gold: i32,
}
fn default_yield_mult() -> f64 { 1.0 }
@ -7843,6 +7869,7 @@ impl Default for EconomyParams {
current_gold: 0,
deficit_floor: 0,
yield_mult: 1.0,
trade_gold: 0,
}
}
}
@ -7903,7 +7930,9 @@ impl GdEconomy {
// → 15 gold gross, which then nets out upkeep normally (warcouncil p1-31).
let yield_mult = params.yield_mult.max(0.0);
let gross_income_after_yield = (gross_income as f64 * yield_mult) as i32;
let net_gold = gross_income_after_yield - base_result.gold_expenses;
// p3-23: inter-player trade gold (ResourceSale flow) is a direct transfer —
// added after the multipliers so difficulty/golden-age don't amplify it.
let net_gold = gross_income_after_yield - base_result.gold_expenses + params.trade_gold;
// Apply net gold, then clamp to 0 if the result breaches the
// tech-scaled deficit floor (matches mc-turn's insolvency branch
@ -7925,6 +7954,7 @@ impl GdEconomy {
d.set("treasury_cap_hit", false);
d.set("gold_income", gross_income as i64);
d.set("gold_expenses", base_result.gold_expenses as i64);
d.set("trade_gold", params.trade_gold as i64);
d
}
}