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:
parent
de983fac54
commit
e926345ad2
6 changed files with 157 additions and 126 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue