feat(@projects/@magic-civilization): 🔧 p3-23 revival step 1 — reconcile diplomacy↔process_trades contract (safe, isolation-proven)

Owner greenlit "revive carefully". First safe step: make diplomacy.gd's contract
correct and prove it works in isolation, WITHOUT enabling the turn-loop call.

- diplomacy.gd now matches the current GdTrade.process_trades {ledger} contract:
  _serialize_players emits the PlayerTradeInput shape (player_index, tile_luxuries,
  tile_strategics, trade_willingness), sourcing each player's controlled luxuries +
  strategics from owned tiles classified by resource `category`; process_turn reads
  result["ledger"], stores it, and _apply_ledger_resources fans the ledger's
  incoming_luxuries/incoming_strategics onto each player (buyer gains the resource).
- Removed the dead _apply_trade_changes/_apply_relation_changes (they matched an old
  contract that returned new_trades/relation_changes; process_trades returns {ledger}).
- player.gd gains traded_strategics (field + serialize/deserialize); _clear_pair_luxuries
  clears it on war. GdTradeLedger.incoming_luxuries #[func] added (mirrors incoming_strategics).
- test_diplomacy.gd: replaced the 4 stale _apply_trade_changes tests with ledger-based
  tests, incl. a full round-trip (PlayerTradeInput JSON → process_trades → ledger →
  _apply_ledger_resources → buyers gain wine/horses/silk/iron).

Verified: cargo check gdext; dylib rebuilt; canonical GUT 746/0 (both new tests pass).
Turn-loop call REMAINS disabled (next step enables it carefully). p3-23 stays partial.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-25 21:53:35 -04:00
parent de983fac54
commit e926345ad2
6 changed files with 157 additions and 126 deletions

View file

@ -68,6 +68,21 @@ 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.
**Revival step 1 — contract reconciled + proven (2026-06-25, owner greenlit "revive
carefully").** diplomacy.gd now matches the current `process_trades` `{ledger}`
contract: `_serialize_players` emits the `PlayerTradeInput` shape (sourcing each
player's controlled luxuries + strategics from owned tiles, classified by resource
`category`); `process_turn` reads `result["ledger"]`, stores it, and
`_apply_ledger_resources` fans the ledger's `incoming_luxuries`/`incoming_strategics`
onto each player (new `PlayerState.traded_strategics` field + save/load; `GdTradeLedger.
incoming_luxuries` FFI added). Dead `_apply_trade_changes`/`_apply_relation_changes`
removed. GUT `test_ledger_roundtrip_applies_traded_resources` proves the full path
(PlayerTradeInput → process_trades → ledger → buyers gain wine/horses/silk/iron) in
isolation; dylib rebuilt + GUT 746/0. **The turn-loop call stays disabled** (the safe
step). **Next:** step 2 — enable `Diplomacy.process_turn` in turn_manager.gd (verify
it doesn't abort the arena loop, headless + GUT); step 3 — unit-gating reads
`traded_strategics`; then relation advancement + deal UI.
**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

View file

@ -1,12 +1,12 @@
{
"generated_at": "2026-06-26T00:41:24Z",
"generated_at": "2026-06-26T01:53:35Z",
"totals": {
"in_progress": 0,
"oos": 31,
"partial": 2,
"done": 295,
"stub": 0,
"missing": 0,
"in_progress": 0,
"done": 295,
"oos": 31,
"partial": 2,
"total": 328
},
"objectives": [

View file

@ -61,6 +61,10 @@ var gold_per_turn: int = 0
## Written by Diplomacy.process_turn(); unioned with tile-owned luxuries
## in happiness.gd before the mc-happiness calculation.
var traded_luxuries: Array[String] = []
## Strategic resource ids received via active trade agreements this turn (p3-23).
## Written by Diplomacy.process_turn() from the trade ledger; unioned with
## tile-owned strategic resources for unit-build gating.
var traded_strategics: Array[String] = []
var golden_age_active: bool = false
## Remaining turns in the current Golden Age (0 when inactive).
var golden_age_turns: int = 0
@ -206,6 +210,7 @@ func serialize() -> Dictionary:
"units": unit_data,
"gold_per_turn": gold_per_turn,
"traded_luxuries": traded_luxuries.duplicate(),
"traded_strategics": traded_strategics.duplicate(),
"golden_age_active": golden_age_active,
"golden_age_turns": golden_age_turns,
"golden_age_progress": golden_age_progress,
@ -249,6 +254,10 @@ func deserialize(data: Dictionary) -> void:
traded_luxuries = []
for t: String in traded_raw:
traded_luxuries.append(t)
var traded_strat_raw: Array = data.get("traded_strategics", []) as Array
traded_strategics = []
for s: String in traded_strat_raw:
traded_strategics.append(s)
golden_age_active = bool(data.get("golden_age_active", golden_age_active))
golden_age_turns = int(data.get("golden_age_turns", golden_age_turns))
golden_age_progress = int(data.get("golden_age_progress", golden_age_progress))

View file

@ -10,7 +10,16 @@ extends RefCounted
## "%d_%d" % [mini(a, b), maxi(a, b)]
static func process_turn(players: Array, turn_number: int) -> void:
static func process_turn(players: Array, turn_number: int, game_map: RefCounted) -> void:
## Evaluate inter-player trades for this turn and apply the result (p3-23 revival).
## Matches the current GdTrade.process_trades contract: sends PlayerTradeInput
## records (per-player controlled luxuries + strategics + trade_willingness),
## receives `{ledger}`, stores it, and fans the active ledger's incoming
## luxuries/strategics back onto each player (buyers gain the resource).
##
## NOTE: relation advancement (peaceful→friendly progression) is a deferred
## follow-up — process_trades does not yet return relation deltas. Relations
## still change via explicit declare_war/accept_peace. See objective p3-23.
if not ClassDB.class_exists("GdTrade"):
push_error("[Diplomacy] GdTrade GDExtension class not registered")
return
@ -19,17 +28,16 @@ static func process_turn(players: Array, turn_number: int) -> void:
push_error("[Diplomacy] GdTrade GDExtension class not available")
return
var players_json: String = _serialize_players(players)
var players_json: String = _serialize_players(players, game_map)
var diplomacy_json: String = JSON.stringify(GameState.diplomacy)
var result: Dictionary = gd_trade.process_trades(players_json, diplomacy_json, turn_number)
if result.has("error"):
push_error("[Diplomacy] process_trades error: %s" % result["error"])
return
_apply_relation_changes(result.get("relation_changes", []))
_apply_trade_changes(players, result.get("new_trades", []), result.get("broken_trades", []))
if result.has("trade_ledger_json"):
GameState.trade_ledger_json = String(result["trade_ledger_json"])
var ledger_json: String = String(result.get("ledger", ""))
GameState.trade_ledger_json = ledger_json
_apply_ledger_resources(players, ledger_json)
## Player-initiated war declaration. Sets relation to "war", clears the pair's
@ -141,27 +149,60 @@ static func _clear_pair_luxuries(a: int, b: int) -> void:
var player_b: RefCounted = GameState.get_player(b)
if player_a != null:
player_a.traded_luxuries = empty.duplicate()
player_a.traded_strategics = empty.duplicate()
if player_b != null:
player_b.traded_luxuries = empty.duplicate()
player_b.traded_strategics = empty.duplicate()
static func _relation_key(a: int, b: int) -> String:
return "%d_%d" % [mini(a, b), maxi(a, b)]
static func _serialize_players(players: Array) -> String:
static func _serialize_players(players: Array, game_map: RefCounted) -> String:
## Build the mc-trade `PlayerTradeInput` array: per-player controlled luxury +
## strategic resource IDs (with duplicates, for the surplus rule) plus the clan
## `trade_willingness`. Pure data extraction — resources are classified by their
## `category` (luxury/strategic) from DataLoader; no trade logic here.
var arr: Array = []
for player: RefCounted in players:
if player == null:
continue
var personality: Dictionary = _get_personality(player)
var resources: Array = _collect_tradeable_resources(player, game_map)
arr.append({
"index": player.get("index"),
"traded_luxuries": player.get("traded_luxuries"),
"personality": _get_personality(player),
"player_index": int(player.get("index")),
"tile_luxuries": resources[0],
"tile_strategics": resources[1],
"trade_willingness": int(personality.get("trade_willingness", 5)),
})
return JSON.stringify(arr)
static func _collect_tradeable_resources(player: RefCounted, game_map: RefCounted) -> Array:
## Returns `[luxuries: Array[String], strategics: Array[String]]` for the
## player's owned tiles, classified by each resource's `category`. Duplicates
## are kept so mc-trade's `MIN_COPIES_TO_TRADE` surplus rule applies.
var luxuries: Array[String] = []
var strategics: Array[String] = []
if game_map == null or not game_map.has_method("get_tile"):
return [luxuries, strategics]
for city: RefCounted in player.cities:
for tile_pos: Vector2i in city.owned_tiles:
var tile: Resource = game_map.get_tile(tile_pos)
if tile == null:
continue
var rid: String = String(tile.resource_id)
if rid.is_empty():
continue
match String(DataLoader.get_resource(rid).get("category", "")):
"luxury":
luxuries.append(rid)
"strategic":
strategics.append(rid)
return [luxuries, strategics]
static func _get_personality(player: RefCounted) -> Dictionary:
## Returns trade_willingness + grudge_persistence for the player's clan.
## Human players get neutral defaults (5/5) — no personality gate applies.
@ -177,60 +218,33 @@ static func _get_personality(player: RefCounted) -> Dictionary:
}
static func _apply_relation_changes(changes: Array) -> void:
for change: Dictionary in changes:
var a: int = int(change.get("player_a", -1))
var b: int = int(change.get("player_b", -1))
if a < 0 or b < 0:
continue
var key: String = _relation_key(a, b)
var old_relation: String = GameState.diplomacy.get(key, "neutral")
var new_relation: String = String(change.get("new_relation", "neutral"))
if old_relation == new_relation:
continue
GameState.diplomacy[key] = new_relation
EventBus.relation_changed.emit(a, b, old_relation, new_relation)
static func _apply_trade_changes(
players: Array, new_trades: Array, broken_trades: Array
) -> void:
var player_by_index: Dictionary = {}
for player: RefCounted in players:
if player != null:
player_by_index[int(player.get("index"))] = player
# Clear traded_luxuries before re-populating from the current active-trade set.
static func _apply_ledger_resources(players: Array, ledger_json: String) -> void:
## Reset each player's traded luxuries/strategics, then re-populate from the
## active ledger via GdTradeLedger (p3-23 revival). The buyer of a swap/sale
## gains the resource — luxuries feed the happiness pool, strategics gate
## unit-building. Replaces the old `new_trades`/`broken_trades` dict-diff path,
## which no longer matches the `{ledger}` process_trades contract.
for player: RefCounted in players:
if player != null:
player.set("traded_luxuries", [])
for trade: Dictionary in broken_trades:
var a: int = int(trade.get("player_a", -1))
var b: int = int(trade.get("player_b", -1))
var gives_a: String = String(trade.get("gives_a", ""))
var gives_b: String = String(trade.get("gives_b", ""))
EventBus.trade_broken.emit(a, b, gives_a, gives_b)
for trade: Dictionary in new_trades:
var a: int = int(trade.get("player_a", -1))
var b: int = int(trade.get("player_b", -1))
var gives_a: String = String(trade.get("gives_a", ""))
var gives_b: String = String(trade.get("gives_b", ""))
if a < 0 or b < 0:
player.set("traded_strategics", [])
if ledger_json.is_empty() or not ClassDB.class_exists("GdTradeLedger"):
return
var ledger: RefCounted = ClassDB.instantiate("GdTradeLedger") as RefCounted
if ledger == null:
return
ledger = ledger.from_json(ledger_json)
if ledger == null:
return
for player: RefCounted in players:
if player == null:
continue
if not (a in player_by_index) or not (b in player_by_index):
continue
# A gives gives_a to B — B receives it as a traded luxury.
if gives_a != "" and b in player_by_index:
var pb: RefCounted = player_by_index[b]
var pb_luxuries: Array = pb.get("traded_luxuries")
if gives_a not in pb_luxuries:
pb_luxuries.append(gives_a)
# B gives gives_b to A — A receives it.
if gives_b != "" and a in player_by_index:
var pa: RefCounted = player_by_index[a]
var pa_luxuries: Array = pa.get("traded_luxuries")
if gives_b not in pa_luxuries:
pa_luxuries.append(gives_b)
EventBus.trade_agreed.emit(a, b, gives_a, gives_b)
var idx: int = int(player.get("index"))
var lux_typed: Array[String] = []
for l: String in ledger.incoming_luxuries(idx):
lux_typed.append(l)
var strat_typed: Array[String] = []
for s: String in ledger.incoming_strategics(idx):
strat_typed.append(s)
player.set("traded_luxuries", lux_typed)
player.set("traded_strategics", strat_typed)

View file

@ -1,9 +1,11 @@
extends GutTest
## Unit tests for Diplomacy GDScript wrapper and happiness.gd traded_luxuries extension.
##
## Diplomacy.process_turn() depends on GdTrade GDExtension — those tests are marked
## pending until the GDExtension surface lands. The helper methods (_apply_relation_changes,
## _apply_trade_changes, _collect_unique_luxury_ids) are pure and tested here without GdTrade.
## Diplomacy.process_turn() depends on GdTrade GDExtension (mc-trade). p3-23 revival:
## the trade-application path is now ledger-based (_apply_ledger_resources reads the
## GdTradeLedger), tested here both empty (pure) and via a full process_trades → ledger
## → apply round-trip (guarded on the extension). _relation_key + happiness.gd's
## _collect_unique_luxury_ids are pure and tested without GdTrade.
const DiplomacyScript: GDScript = preload(
"res://engine/src/modules/empire/diplomacy.gd"
@ -55,69 +57,49 @@ func test_relation_key_high_indices() -> void:
# ---------------------------------------------------------------------------
# _apply_trade_changes
# _apply_ledger_resources (p3-23 revival — ledger → player traded resources)
# ---------------------------------------------------------------------------
func test_apply_trade_changes_populates_traded_luxuries() -> void:
var pa: RefCounted = _make_player(0)
var pb: RefCounted = _make_player(1)
var players: Array = [pa, pb]
var new_trades: Array = [
{"player_a": 0, "player_b": 1, "gives_a": "silk", "gives_b": "wine"}
]
var broken_trades: Array = []
DiplomacyScript._apply_trade_changes(players, new_trades, broken_trades)
# A gives silk → B receives silk
assert_true("silk" in _get_traded(pb), "B should receive silk from A")
# B gives wine → A receives wine
assert_true("wine" in _get_traded(pa), "A should receive wine from B")
func test_apply_trade_changes_clears_stale_luxuries() -> void:
func test_apply_ledger_resources_clears_when_empty() -> void:
var pa: RefCounted = _make_player(0)
pa.set("traded_luxuries", ["stale_gem"])
var pb: RefCounted = _make_player(1)
var players: Array = [pa, pb]
# No active trades this turn.
DiplomacyScript._apply_trade_changes(players, [], [])
assert_eq(_get_traded(pa).size(), 0, "stale traded luxury must be cleared")
pa.set("traded_strategics", ["stale_iron"])
# Empty ledger → both lists cleared (no extension needed for the clear path).
DiplomacyScript._apply_ledger_resources([pa], "")
assert_eq(pa.get("traded_luxuries").size(), 0, "empty ledger clears stale luxuries")
assert_eq(pa.get("traded_strategics").size(), 0, "empty ledger clears stale strategics")
func test_apply_trade_changes_no_duplicate_entries() -> void:
func test_ledger_roundtrip_applies_traded_resources() -> void:
## Full revived contract: PlayerTradeInput-shaped JSON → process_trades →
## {ledger} → _apply_ledger_resources → buyers gain luxuries + strategics.
if not ClassDB.class_exists("GdTrade") or not ClassDB.class_exists("GdTradeLedger"):
pending("requires GdTrade + GdTradeLedger GDExtension")
return
var players_json: String = JSON.stringify([
{
"player_index": 0, "tile_luxuries": ["silk", "silk"],
"tile_strategics": ["iron", "iron"], "trade_willingness": 8,
},
{
"player_index": 1, "tile_luxuries": ["wine", "wine"],
"tile_strategics": ["horses", "horses"], "trade_willingness": 8,
},
])
var gd_trade: RefCounted = ClassDB.instantiate("GdTrade") as RefCounted
var result: Dictionary = gd_trade.process_trades(players_json, "{}", 1)
assert_false(result.has("error"), "process_trades must accept the PlayerTradeInput shape")
var ledger_json: String = String(result.get("ledger", ""))
assert_false(ledger_json.is_empty(), "a ledger must be produced")
var pa: RefCounted = _make_player(0)
var pb: RefCounted = _make_player(1)
var players: Array = [pa, pb]
# Same luxury appears in two separate trades (edge case).
var new_trades: Array = [
{"player_a": 0, "player_b": 1, "gives_a": "silk", "gives_b": "wine"},
{"player_a": 0, "player_b": 1, "gives_a": "silk", "gives_b": "wine"},
]
DiplomacyScript._apply_trade_changes(players, new_trades, [])
var silk_count: int = 0
for luxury: String in _get_traded(pb):
if luxury == "silk":
silk_count += 1
assert_eq(silk_count, 1, "duplicate luxury from multiple trades must appear only once")
func test_apply_trade_changes_skips_invalid_indices() -> void:
var pa: RefCounted = _make_player(0)
var players: Array = [pa]
# Trade references player index 99 which doesn't exist.
var new_trades: Array = [
{"player_a": 0, "player_b": 99, "gives_a": "silk", "gives_b": "wine"}
]
DiplomacyScript._apply_trade_changes(players, new_trades, [])
# Should not crash; pa receives nothing because pb doesn't exist.
assert_eq(_get_traded(pa).size(), 0, "trade with missing partner must not crash or add luxuries")
DiplomacyScript._apply_ledger_resources([pa, pb], ledger_json)
# p0 gains wine (luxury) + horses (strategic); p1 gains silk + iron.
assert_true("wine" in pa.get("traded_luxuries"), "p0 gains wine luxury via swap")
assert_true("horses" in pa.get("traded_strategics"), "p0 gains horses strategic via swap")
assert_true("silk" in pb.get("traded_luxuries"), "p1 gains silk luxury")
assert_true("iron" in pb.get("traded_strategics"), "p1 gains iron strategic")
# ---------------------------------------------------------------------------

View file

@ -7540,6 +7540,17 @@ impl GdTradeLedger {
self.inner.gold_flow_for(player as u8) as i64
}
/// Luxuries this player gains from active LuxurySwap / luxury ResourceSale
/// deals (p3-23) — feed the happiness pool. Returned as an `Array[String]`.
#[func]
fn incoming_luxuries(&self, player: i64) -> Array<GString> {
self.inner
.incoming_luxuries(player as u8)
.into_iter()
.map(GString::from)
.collect()
}
/// Strategic resources this player gains from active StrategicSwap / strategic
/// ResourceSale deals (p3-23) — unit-gating access. Returned as an `Array[String]`.
#[func]