diff --git a/.project/objectives/p3-23-trade-richness-gold-strategic.md b/.project/objectives/p3-23-trade-richness-gold-strategic.md index 388d1d20..9a1f33a5 100644 --- a/.project/objectives/p3-23-trade-richness-gold-strategic.md +++ b/.project/objectives/p3-23-trade-richness-gold-strategic.md @@ -121,10 +121,24 @@ access (step 3) · gold sale → `gold_flow_for` → net gold (`test_trade_gold_ (step 2, exit 0). Gold flow is **no longer inert** — `Diplomacy.process_turn` now populates `GameState.trade_ledger_json` each round, which `economy.gd` reads. -**Only remaining for `done`:** the **deal UI** — surface active deals in the diplomacy -panel + wire the dangling `EventBus.trade_agreed` signal (needs a `GdTradeLedger` -agreements-enumeration `#[func]` to describe each deal to the panel/chronicle). Status -stays `partial` until that lands. +**Revival step 5 — deal UI implemented + GUT-proven (2026-06-25).** Active inter-player +trade deals now surface in the diplomacy panel's per-rival agreement section, alongside +the existing open-borders / shared-map rows. No new FFI was needed: `get_active_agreements` +parses the serde-tagged `LuxurySwap` / `StrategicSwap` / `ResourceSale` entries straight +out of `GameState.trade_ledger_json` (`_append_trade_deals` + `_swap_entry` + `_sale_entry`, +pure GDScript) and `diplomacy_panel._make_agreement_section` renders each (you-receive / +you-give for swaps; buy/sell + gold/turn for sales). Six `diplomacy_*` vocabulary keys +added. GUT `test_get_active_agreements_surfaces_trade_deals` asserts all three deal types ++ partner/direction/resource fields; panel script compiles + its tests pass; full suite +**750/0**. + +**Status — implementation + logic COMPLETE and GUT-proven (750/0).** Every acceptance +bullet's code is done: gold↔resource + strategic swaps (mc-trade) · AI evaluation · +in-game pipeline revived end-to-end (steps 1–4) · deal UI (step 5). The **single +remaining item before flipping `status: done`** is a phase-gate **proof screenshot** of +the trade-deal rows in the diplomacy panel — which needs a crafted live-game state +(human player holding an active ledger deal; not reproducible in the all-AI arena). +Until that capture lands, status stays `partial` per objective-integrity. **In-game application part A — gold flow LIVE (2026-06-25).** `GdTradeLedger` gains `gold_flow_for` + `incoming_strategics` #[func]s; `GdEconomy` gains a diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json index b03f1c4a..49d2d159 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-26T03:08:26Z", + "generated_at": "2026-06-26T03:38:21Z", "totals": { - "stub": 0, "oos": 31, - "in_progress": 0, - "partial": 2, - "done": 295, "missing": 0, + "done": 295, + "partial": 2, + "in_progress": 0, + "stub": 0, "total": 328 }, "objectives": [ diff --git a/public/games/age-of-dwarves/vocabulary.json b/public/games/age-of-dwarves/vocabulary.json index ae1d5b37..dc7992ff 100644 --- a/public/games/age-of-dwarves/vocabulary.json +++ b/public/games/age-of-dwarves/vocabulary.json @@ -709,6 +709,12 @@ "diplomacy_open_borders_status_active": "Active (%d turns)", "diplomacy_open_borders_status_none": "Not agreed", "diplomacy_offer_open_borders": "Offer Open Borders", + "diplomacy_luxury_swap_label": "Luxury Trade", + "diplomacy_strategic_swap_label": "Strategic Trade", + "diplomacy_trade_swap_status": "Receiving %s for %s", + "diplomacy_resource_sale_label": "Resource Sale", + "diplomacy_resource_sale_buy": "Buying %s (−%d gold/turn)", + "diplomacy_resource_sale_sell": "Selling %s (+%d gold/turn)", "diplomacy_shared_map_label": "Shared Map", "diplomacy_shared_map_status_none": "Not agreed", "diplomacy_shared_map_status_transit": "Courier en route (ETA turn %d)", diff --git a/src/game/engine/scenes/hud/diplomacy_panel.gd b/src/game/engine/scenes/hud/diplomacy_panel.gd index 4941a4d6..dcb046d7 100644 --- a/src/game/engine/scenes/hud/diplomacy_panel.gd +++ b/src/game/engine/scenes/hud/diplomacy_panel.gd @@ -234,6 +234,43 @@ func _make_agreement_section(target_idx: int) -> Control: lbl.theme_type_variation = "LabelPositive" vbox.add_child(lbl) has_any = true + elif ag_type == "luxury_swap" or ag_type == "strategic_swap": + # p3-23: active inter-player resource swap (re-derived each turn). + var label_key: String = ( + "diplomacy_luxury_swap_label" if ag_type == "luxury_swap" + else "diplomacy_strategic_swap_label" + ) + var lbl: Label = Label.new() + lbl.text = "%s: %s" % [ + ThemeVocabulary.lookup(label_key), + ThemeVocabulary.lookup("diplomacy_trade_swap_status") % [ + str(ag.get("you_receive", "")).capitalize(), + str(ag.get("you_give", "")).capitalize(), + ], + ] + lbl.add_theme_font_size_override("font_size", 13) + lbl.add_theme_color_override("font_color", ThemeAssets.color("semantic.trade")) + vbox.add_child(lbl) + has_any = true + elif ag_type == "resource_sale": + # p3-23: one-sided gold sale (seller +gold/turn, buyer −gold/turn). + var role: String = str(ag.get("role", "")) + var status_key: String = ( + "diplomacy_resource_sale_buy" if role == "buyer" + else "diplomacy_resource_sale_sell" + ) + var lbl: Label = Label.new() + lbl.text = "%s: %s" % [ + ThemeVocabulary.lookup("diplomacy_resource_sale_label"), + ThemeVocabulary.lookup(status_key) % [ + str(ag.get("resource", "")).capitalize(), + int(ag.get("gold_per_turn", 0)), + ], + ] + lbl.add_theme_font_size_override("font_size", 13) + lbl.add_theme_color_override("font_color", ThemeAssets.color("semantic.trade")) + vbox.add_child(lbl) + has_any = true if not has_any: return Control.new() return vbox diff --git a/src/game/engine/src/modules/empire/diplomacy.gd b/src/game/engine/src/modules/empire/diplomacy.gd index 1d4b2526..0760c6d6 100644 --- a/src/game/engine/src/modules/empire/diplomacy.gd +++ b/src/game/engine/src/modules/empire/diplomacy.gd @@ -120,9 +120,76 @@ static func get_active_agreements(player_idx: int) -> Array[Dictionary]: var route_obj: RefCounted = sm.get_courier_route() entry["courier_route"] = route_obj.to_dict() if route_obj != null else {} out.append(entry) + # p3-23: surface active inter-player trade deals (luxury/strategic swaps + gold + # sales). GdTradeLedger has no iterator for these, so parse the serde-tagged + # agreement list from the same ledger JSON the swaps were just read from. + _append_trade_deals(out, player_idx) return out +## Parse LuxurySwap / StrategicSwap / ResourceSale entries out of the ledger JSON +## and append a display dict for each one involving `player_idx`. Pure GDScript — +## the ledger JSON is the source the simulator already wrote to GameState. +static func _append_trade_deals(out: Array[Dictionary], player_idx: int) -> void: + if GameState.trade_ledger_json.is_empty(): + return + var json: JSON = JSON.new() + if json.parse(GameState.trade_ledger_json) != OK: + return + if not (json.data is Dictionary): + return + var root: Dictionary = json.data + var agreements: Array = root.get("agreements", []) + for ag: Dictionary in agreements: + var entry: Dictionary = {} + if ag.has("LuxurySwap"): + entry = _swap_entry(ag["LuxurySwap"], player_idx, "luxury_swap") + elif ag.has("StrategicSwap"): + entry = _swap_entry(ag["StrategicSwap"], player_idx, "strategic_swap") + elif ag.has("ResourceSale"): + entry = _sale_entry(ag["ResourceSale"], player_idx) + if not entry.is_empty(): + out.append(entry) + + +static func _swap_entry(swap: Dictionary, player_idx: int, kind: String) -> Dictionary: + var partners: Array = swap.get("partners", []) + if partners.size() != 2: + return {} + var a: int = int(partners[0]) + var b: int = int(partners[1]) + if a != player_idx and b != player_idx: + return {} + # gives_a flows a→b, gives_b flows b→a. you_receive is what player_idx gains. + var gives_a: String = str(swap.get("gives_a", "")) + var gives_b: String = str(swap.get("gives_b", "")) + return { + "type": kind, + "partner": b if a == player_idx else a, + "you_receive": gives_a if player_idx == b else gives_b, + "you_give": gives_b if player_idx == b else gives_a, + } + + +static func _sale_entry(sale: Dictionary, player_idx: int) -> Dictionary: + var partners: Array = sale.get("partners", []) + if partners.size() != 2: + return {} + var a: int = int(partners[0]) + var b: int = int(partners[1]) + if a != player_idx and b != player_idx: + return {} + var buyer: int = int(sale.get("buyer", -1)) + return { + "type": "resource_sale", + "partner": b if a == player_idx else a, + "role": "buyer" if player_idx == buyer else "seller", + "resource": str(sale.get("resource", "")), + "gold_per_turn": int(sale.get("gold_per_turn", 0)), + "strategic": bool(sale.get("strategic", false)), + } + + ## EA policy: AI always rejects. Emits signal for UI feedback. static func offer_open_borders( from_player: int, to_player: int, gold: int, luxury_id: String diff --git a/src/game/engine/tests/unit/test_diplomacy.gd b/src/game/engine/tests/unit/test_diplomacy.gd index f8ac1abf..c4d8dc83 100644 --- a/src/game/engine/tests/unit/test_diplomacy.gd +++ b/src/game/engine/tests/unit/test_diplomacy.gd @@ -208,6 +208,46 @@ func test_process_turn_pending_gd_trade() -> void: ) +# --------------------------------------------------------------------------- +# get_active_agreements — trade deals surfaced for the deal UI (p3-23 step 5) +# --------------------------------------------------------------------------- + +func test_get_active_agreements_surfaces_trade_deals() -> void: + if not ClassDB.class_exists("GdTradeLedger"): + pending("requires GdTradeLedger GDExtension") + return + var prev: String = GameState.trade_ledger_json + GameState.trade_ledger_json = ( + '{"agreements":[' + + '{"LuxurySwap":{"partners":[0,1],"gives_a":"silk",' + + '"gives_b":"furs","turn_started":1}},' + + '{"StrategicSwap":{"partners":[0,2],"gives_a":"iron_ore",' + + '"gives_b":"horses","turn_started":1}},' + + '{"ResourceSale":{"partners":[0,3],"seller":3,"buyer":0,' + + '"resource":"coal_seam","strategic":true,"gold_per_turn":2,' + + '"turn_started":1}}' + + '],"next_agreement_id":0}' + ) + var deals: Array = DiplomacyScript.get_active_agreements(0) + GameState.trade_ledger_json = prev + + var by_type: Dictionary = {} + for d: Dictionary in deals: + by_type[str(d.get("type", ""))] = d + assert_true(by_type.has("luxury_swap"), "luxury swap surfaced") + assert_true(by_type.has("strategic_swap"), "strategic swap surfaced") + assert_true(by_type.has("resource_sale"), "resource sale surfaced") + + var lux: Dictionary = by_type.get("luxury_swap", {}) + assert_eq(int(lux.get("partner", -1)), 1, "luxury swap partner is player 1") + assert_eq(str(lux.get("you_receive", "")), "furs", "player 0 receives furs (gives_b)") + assert_eq(str(lux.get("you_give", "")), "silk", "player 0 gives silk (gives_a)") + + var sale: Dictionary = by_type.get("resource_sale", {}) + assert_eq(str(sale.get("role", "")), "buyer", "player 0 is the buyer") + assert_eq(str(sale.get("resource", "")), "coal_seam", "sale resource is coal_seam") + + # --------------------------------------------------------------------------- # Inner helper class — minimal Player property shim for happiness tests # ---------------------------------------------------------------------------