From e926345ad2dd99c0ce0bde78525da4dab80c510a Mon Sep 17 00:00:00 2001 From: Natalie Date: Thu, 25 Jun 2026 21:53:35 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=94=A7=20p3-23=20revival=20step=201=20=E2=80=94=20reconci?= =?UTF-8?q?le=20diplomacy=E2=86=94process=5Ftrades=20contract=20(safe,=20i?= =?UTF-8?q?solation-proven)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../p3-23-trade-richness-gold-strategic.md | 15 ++ .../games/age-of-dwarves/data/objectives.json | 10 +- src/game/engine/src/entities/player.gd | 9 ++ .../engine/src/modules/empire/diplomacy.gd | 140 ++++++++++-------- src/game/engine/tests/unit/test_diplomacy.gd | 98 +++++------- src/simulator/api-gdext/src/lib.rs | 11 ++ 6 files changed, 157 insertions(+), 126 deletions(-) diff --git a/.project/objectives/p3-23-trade-richness-gold-strategic.md b/.project/objectives/p3-23-trade-richness-gold-strategic.md index 6db4f10f..f3d5c5b5 100644 --- a/.project/objectives/p3-23-trade-richness-gold-strategic.md +++ b/.project/objectives/p3-23-trade-richness-gold-strategic.md @@ -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 diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json index 80cf7f14..c153966d 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-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": [ diff --git a/src/game/engine/src/entities/player.gd b/src/game/engine/src/entities/player.gd index 53005ccd..fe5bd1ec 100644 --- a/src/game/engine/src/entities/player.gd +++ b/src/game/engine/src/entities/player.gd @@ -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)) diff --git a/src/game/engine/src/modules/empire/diplomacy.gd b/src/game/engine/src/modules/empire/diplomacy.gd index a29f3310..1d4b2526 100644 --- a/src/game/engine/src/modules/empire/diplomacy.gd +++ b/src/game/engine/src/modules/empire/diplomacy.gd @@ -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) diff --git a/src/game/engine/tests/unit/test_diplomacy.gd b/src/game/engine/tests/unit/test_diplomacy.gd index 546949e3..5b2bc8e7 100644 --- a/src/game/engine/tests/unit/test_diplomacy.gd +++ b/src/game/engine/tests/unit/test_diplomacy.gd @@ -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") # --------------------------------------------------------------------------- diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 5e5c9365..99de068e 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -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 { + 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]