From d3c484a5a489d04a27bd2064db5beb8fbbadf93c Mon Sep 17 00:00:00 2001 From: Natalie Date: Mon, 4 May 2026 01:10:14 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20specialist=20panel=20UI=20and=20GPP=20slots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- public/games/age-of-dwarves/vocabulary.json | 4 + .../engine/scenes/city/specialists_panel.gd | 191 ++++++++++++++++++ .../engine/tests/unit/test_city_screen_gpp.gd | 120 +++++++++++ .../unit/test_city_screen_specialist_slots.gd | 162 +++++++++++++++ .../crates/mc-city/src/harvest_policy.rs | 6 + 5 files changed, 483 insertions(+) create mode 100644 src/game/engine/scenes/city/specialists_panel.gd create mode 100644 src/game/engine/tests/unit/test_city_screen_gpp.gd create mode 100644 src/game/engine/tests/unit/test_city_screen_specialist_slots.gd diff --git a/public/games/age-of-dwarves/vocabulary.json b/public/games/age-of-dwarves/vocabulary.json index fc4b8d2a..289f60c3 100644 --- a/public/games/age-of-dwarves/vocabulary.json +++ b/public/games/age-of-dwarves/vocabulary.json @@ -581,6 +581,10 @@ "city_screen_section_queue": " QUEUE ", "city_screen_section_build_new": " BUILD NEW ", "city_screen_section_buy_tiles": " BUY TILES ", + "city_screen_section_specialists": " SPECIALISTS ", + "city_screen_section_gpp": " GREAT PERSON POINTS / TURN ", + "city_screen_section_great_works": " GREAT WORK SLOTS ", + "city_screen_no_specialist_slots": "No specialist slots in this city", "city_screen_food_zero": "Food: 0", "city_screen_production_zero": "Production: 0", "city_screen_gold_zero": "Gold: 0", diff --git a/src/game/engine/scenes/city/specialists_panel.gd b/src/game/engine/scenes/city/specialists_panel.gd new file mode 100644 index 00000000..7094ba37 --- /dev/null +++ b/src/game/engine/scenes/city/specialists_panel.gd @@ -0,0 +1,191 @@ +extends VBoxContainer +## City-screen panel — renders specialist slot capacity, GPP per-turn yield, +## and great-work slot capacity for the currently focused city. +## +## Pure presentation: reads from `GdSpecialistRegistry`, `GdGreatWorkRegistry`, +## and `GdBuildingCivics` (typed Rust → Dictionary marshalling). NO game +## rules in GDScript. Per-city runtime state (GPP accumulator, slot +## occupation, employ/unemploy actions) is intentionally NOT rendered here — +## that runtime does not yet exist in `mc-city`. Once it lands, this panel +## extends to read it via signals. +## +## The seven GPP types and four great-work categories are mirrored from +## `mc_core::gpp` — keep these arrays in sync if Rust adds variants. + +const GPP_TYPES: Array[String] = [ + "writing", "music", "art", "statuary", + "scholarship", "trade", "engineering", +] + +## The four "art-form" GPP types that have a matching great-work category. +## Scholarship/trade/engineering specialists do NOT spawn great works. +const GREAT_WORK_TYPES: Array[String] = [ + "writing", "music", "art", "statuary", +] + +## Visible state, emitted on rebuild so callers (and tests) can inspect. +signal rebuilt(snapshot: Dictionary) + +var _specialist_registry: RefCounted = null ## GdSpecialistRegistry +var _great_work_registry: RefCounted = null ## GdGreatWorkRegistry +var _building_civics: RefCounted = null ## GdBuildingCivics + +var _city_building_ids: Array[String] = [] +var _last_snapshot: Dictionary = {} + +## Inject the GDExtension registries. Either pass loaded registries, or +## leave null — in headless tests with no extension, the panel renders +## empty-state rows (no crashes). +func setup( + specialist_registry: RefCounted, + great_work_registry: RefCounted, + building_civics: RefCounted, +) -> void: + _specialist_registry = specialist_registry + _great_work_registry = great_work_registry + _building_civics = building_civics + + +## Set the list of building ids the city has built. Triggers a rebuild. +func set_city_buildings(building_ids: Array[String]) -> void: + _city_building_ids = building_ids.duplicate() + rebuild() + + +## Recompute the rendered snapshot. Idempotent. Emits `rebuilt(snapshot)`. +func rebuild() -> void: + for child in get_children(): + child.queue_free() + + var snapshot: Dictionary = { + "specialist_slots": _compute_specialist_slots(), + "gpp_per_turn": _compute_gpp_per_turn(), + "great_work_slots": _compute_great_work_slots(), + } + _last_snapshot = snapshot + + _render_specialist_section(snapshot["specialist_slots"]) + _render_gpp_section(snapshot["gpp_per_turn"]) + _render_great_works_section(snapshot["great_work_slots"]) + + rebuilt.emit(snapshot) + + +## Returns the last computed snapshot (used by tests + the city_screen). +func snapshot() -> Dictionary: + return _last_snapshot + + +# ── computation (read-only aggregation over Rust registry) ────────────── + +## Returns Array[Dictionary] — one entry per (building_id, specialist_id). +func _compute_specialist_slots() -> Array: + var rows: Array = [] + if _building_civics == null: + return rows + for bid in _city_building_ids: + var ids: Array = _building_civics.specialist_slots(bid) + for sid in ids: + var sid_s: String = String(sid) + rows.append({ + "building_id": bid, + "specialist_id": sid_s, + "specialist": _specialist_dict(sid_s), + }) + return rows + + +## Returns Dictionary{gpp_type → int per-turn yield}. Always has all 7 keys. +func _compute_gpp_per_turn() -> Dictionary: + var out: Dictionary = {} + for t in GPP_TYPES: + out[t] = 0 + if _building_civics == null: + return out + var ids_arr: Array[String] = _city_building_ids.duplicate() + for t in GPP_TYPES: + out[t] = int(_building_civics.sum_gpp_yield(ids_arr, t)) + return out + + +## Returns Dictionary{work_type → int slot capacity}. Always has all 4 keys. +func _compute_great_work_slots() -> Dictionary: + var out: Dictionary = {} + for t in GREAT_WORK_TYPES: + out[t] = 0 + if _building_civics == null: + return out + var ids_arr: Array[String] = _city_building_ids.duplicate() + for t in GREAT_WORK_TYPES: + out[t] = int(_building_civics.sum_great_work_slots(ids_arr, t)) + return out + + +func _specialist_dict(specialist_id: String) -> Dictionary: + if _specialist_registry == null: + return {} + return _specialist_registry.get_specialist(specialist_id) + + +# ── rendering (pure layout, no game logic) ────────────────────────────── + +func _render_specialist_section(rows: Array) -> void: + var title: Label = Label.new() + title.text = ThemeVocabulary.lookup("city_screen_section_specialists") + title.add_theme_font_size_override("font_size", 14) + add_child(title) + + if rows.is_empty(): + var empty: Label = Label.new() + empty.text = ThemeVocabulary.lookup("city_screen_no_specialist_slots") + add_child(empty) + return + + for row in rows: + var line: HBoxContainer = HBoxContainer.new() + var label: Label = Label.new() + var sd: Dictionary = row.get("specialist", {}) + var s_name: String = String(sd.get("name", row["specialist_id"])) + var b_label: String = String(row["building_id"]) + label.text = "%s — %s" % [b_label, s_name] + line.add_child(label) + add_child(line) + + +func _render_gpp_section(per_turn: Dictionary) -> void: + var title: Label = Label.new() + title.text = ThemeVocabulary.lookup("city_screen_section_gpp") + title.add_theme_font_size_override("font_size", 14) + add_child(title) + + for t in GPP_TYPES: + var line: HBoxContainer = HBoxContainer.new() + var name_label: Label = Label.new() + name_label.text = t.capitalize() + name_label.custom_minimum_size = Vector2(120, 0) + var value_label: Label = Label.new() + value_label.text = "+%d" % int(per_turn.get(t, 0)) + line.add_child(name_label) + line.add_child(value_label) + add_child(line) + + +func _render_great_works_section(slots: Dictionary) -> void: + var title: Label = Label.new() + title.text = ThemeVocabulary.lookup("city_screen_section_great_works") + title.add_theme_font_size_override("font_size", 14) + add_child(title) + + for t in GREAT_WORK_TYPES: + var line: HBoxContainer = HBoxContainer.new() + var name_label: Label = Label.new() + name_label.text = t.capitalize() + name_label.custom_minimum_size = Vector2(120, 0) + var value_label: Label = Label.new() + var cap: int = int(slots.get(t, 0)) + # "0/cap" placeholder for occupation — actual occupants come from + # the per-city occupation runtime (not yet implemented in mc-city). + value_label.text = "0 / %d" % cap + line.add_child(name_label) + line.add_child(value_label) + add_child(line) diff --git a/src/game/engine/tests/unit/test_city_screen_gpp.gd b/src/game/engine/tests/unit/test_city_screen_gpp.gd new file mode 100644 index 00000000..46ba797b --- /dev/null +++ b/src/game/engine/tests/unit/test_city_screen_gpp.gd @@ -0,0 +1,120 @@ +extends GutTest +## Unit tests for `specialists_panel.gd` — GPP-per-turn aggregation and +## great-work slot capacity. +## +## Mocks `GdBuildingCivics` so the per-turn Rust aggregation contract +## (sum over the city's built buildings) is exercised without the +## GDExtension. Any divergence between this test's mock shape and the +## real Rust API is a contract regression — see +## `src/simulator/api-gdext/src/civics.rs::GdBuildingCivics`. + +const PanelScript: GDScript = preload("res://engine/scenes/city/specialists_panel.gd") + + +class _MockBuildings extends RefCounted: + var _gpp: Dictionary = {} + var _gw: Dictionary = {} + + func set_gpp(building_id: String, gpp_type: String, value: int) -> void: + _gpp["%s|%s" % [building_id, gpp_type]] = value + + func set_great_work_slots(building_id: String, work_type: String, value: int) -> void: + _gw["%s|%s" % [building_id, work_type]] = value + + func specialist_slots(_building_id: String) -> Array: + return [] + + func sum_gpp_yield(building_ids: Array, gpp_type: String) -> int: + var total: int = 0 + for bid in building_ids: + total += int(_gpp.get("%s|%s" % [String(bid), gpp_type], 0)) + return total + + func sum_great_work_slots(building_ids: Array, work_type: String) -> int: + var total: int = 0 + for bid in building_ids: + total += int(_gw.get("%s|%s" % [String(bid), work_type], 0)) + return total + + +func _make_panel() -> Node: + var p: Node = PanelScript.new() + add_child_autofree(p) + return p + + +func test_gpp_snapshot_has_all_seven_types() -> void: + ## The seven GPP types mirror `mc_core::gpp::GppType::ALL`. + var panel: Node = _make_panel() + panel.setup(null, null, _MockBuildings.new()) + panel.set_city_buildings([] as Array[String]) + var per_turn: Dictionary = panel.snapshot()["gpp_per_turn"] + for t in PanelScript.GPP_TYPES: + assert_true(per_turn.has(t), "GPP snapshot has key %s" % t) + assert_eq(int(per_turn[t]), 0, "empty city yields 0 %s GPP/turn" % t) + + +func test_gpp_per_turn_sums_across_buildings() -> void: + ## Acceptance: city GPP/turn is the sum of each building's `gpp_yield`. + var panel: Node = _make_panel() + var bc: _MockBuildings = _MockBuildings.new() + bc.set_gpp("saga_arena", "writing", 1) + bc.set_gpp("saga_chronicle", "writing", 3) + bc.set_gpp("forge_chant_hall", "music", 2) + panel.setup(null, null, bc) + panel.set_city_buildings( + ["saga_arena", "saga_chronicle", "forge_chant_hall"] as Array[String] + ) + + var per_turn: Dictionary = panel.snapshot()["gpp_per_turn"] + assert_eq(int(per_turn["writing"]), 4, "1 + 3 = 4 writing GPP/turn") + assert_eq(int(per_turn["music"]), 2, "music GPP from forge_chant_hall") + assert_eq(int(per_turn["art"]), 0, "no art-yielding building present") + + +func test_great_work_slot_capacity_sums_across_buildings() -> void: + ## Acceptance: city great-work slot capacity is the sum across built + ## buildings. The four work types mirror `mc_core::gpp::GreatWorkType`. + var panel: Node = _make_panel() + var bc: _MockBuildings = _MockBuildings.new() + bc.set_great_work_slots("saga_arena", "writing", 1) + bc.set_great_work_slots("saga_chronicle", "writing", 2) + bc.set_great_work_slots("rune_museum", "art", 1) + panel.setup(null, null, bc) + panel.set_city_buildings( + ["saga_arena", "saga_chronicle", "rune_museum"] as Array[String] + ) + + var slots: Dictionary = panel.snapshot()["great_work_slots"] + assert_eq(int(slots["writing"]), 3, "1 + 2 = 3 writing slots") + assert_eq(int(slots["art"]), 1, "1 art slot") + assert_eq(int(slots["music"]), 0, "no music slots") + assert_eq(int(slots["statuary"]), 0, "no statuary slots") + + +func test_great_work_snapshot_has_all_four_types() -> void: + var panel: Node = _make_panel() + panel.setup(null, null, _MockBuildings.new()) + panel.set_city_buildings([] as Array[String]) + var slots: Dictionary = panel.snapshot()["great_work_slots"] + for t in PanelScript.GREAT_WORK_TYPES: + assert_true(slots.has(t), "great-work slot snapshot has key %s" % t) + assert_eq(int(slots[t]), 0, "empty city has 0 %s slots" % t) + + +func test_panel_does_not_render_runtime_progress() -> void: + ## Rail check: this cycle does NOT expose per-city GPP accumulator + ## progress (X/threshold) — that runtime does not exist in mc-city + ## yet. The snapshot must NOT carry an "accumulator" or "progress" + ## key (a regression here would mean we shipped GDScript shadow + ## logic — Rail 1 violation). + var panel: Node = _make_panel() + panel.setup(null, null, _MockBuildings.new()) + panel.set_city_buildings([] as Array[String]) + var snap: Dictionary = panel.snapshot() + assert_false(snap.has("gpp_accumulator"), + "snapshot must not carry runtime accumulator state") + assert_false(snap.has("gpp_progress"), + "snapshot must not carry runtime progress state") + assert_false(snap.has("great_work_occupants"), + "snapshot must not carry runtime occupation state") diff --git a/src/game/engine/tests/unit/test_city_screen_specialist_slots.gd b/src/game/engine/tests/unit/test_city_screen_specialist_slots.gd new file mode 100644 index 00000000..9c529b96 --- /dev/null +++ b/src/game/engine/tests/unit/test_city_screen_specialist_slots.gd @@ -0,0 +1,162 @@ +extends GutTest +## Unit tests for `specialists_panel.gd` — specialist slot rendering. +## +## Mocks the GdBuildingCivics + GdSpecialistRegistry surfaces so the panel +## logic is exercised without requiring the GDExtension to be loaded in the +## headless test runner. The mocks honour the same Dictionary shapes that +## `api-gdext::civics` returns to GDScript (see +## `src/simulator/api-gdext/src/civics.rs`). + +const PanelScript: GDScript = preload("res://engine/scenes/city/specialists_panel.gd") + + +class _MockSpecialists extends RefCounted: + var _by_id: Dictionary = {} + + func add(specialist_id: String, name: String, gpp_type: String) -> void: + _by_id[specialist_id] = { + "id": specialist_id, + "name": name, + "gpp_type": gpp_type, + "description": "", + "flavor": "", + "population_cost": 1, + "employed_in": [], + "yields": [], + } + + func get_specialist(specialist_id: String) -> Dictionary: + return _by_id.get(specialist_id, {}) + + +class _MockBuildings extends RefCounted: + ## building_id → Array[String] specialist ids + var _slots: Dictionary = {} + ## (building_id, gpp_type) → int per-turn yield + var _gpp: Dictionary = {} + ## (building_id, work_type) → int slot capacity + var _gw_slots: Dictionary = {} + + func set_slots(building_id: String, ids: Array) -> void: + _slots[building_id] = ids + + func set_gpp(building_id: String, gpp_type: String, value: int) -> void: + _gpp["%s|%s" % [building_id, gpp_type]] = value + + func set_great_work_slots(building_id: String, work_type: String, value: int) -> void: + _gw_slots["%s|%s" % [building_id, work_type]] = value + + func specialist_slots(building_id: String) -> Array: + return _slots.get(building_id, []) + + func sum_gpp_yield(building_ids: Array, gpp_type: String) -> int: + var total: int = 0 + for bid in building_ids: + total += int(_gpp.get("%s|%s" % [String(bid), gpp_type], 0)) + return total + + func sum_great_work_slots(building_ids: Array, work_type: String) -> int: + var total: int = 0 + for bid in building_ids: + total += int(_gw_slots.get("%s|%s" % [String(bid), work_type], 0)) + return total + + +func _make_panel() -> Node: + var p: Node = PanelScript.new() + # specialists_panel.gd extends VBoxContainer; the @tool _enter_tree path + # is fine without a parent, but we add it to the tree so signal emission + # round-trips work cleanly. + add_child_autofree(p) + return p + + +func test_specialist_slot_aggregation_mirrors_building_def() -> void: + ## Acceptance: panel surfaces specialist slots per building from registry. + var panel: Node = _make_panel() + var sp: _MockSpecialists = _MockSpecialists.new() + sp.add("saga_writer", "Saga Writer", "writing") + sp.add("forge_chanter", "Forge Chanter", "music") + var bc: _MockBuildings = _MockBuildings.new() + bc.set_slots("saga_arena", ["saga_writer"]) + bc.set_slots("forge_chant_hall", ["forge_chanter"]) + + panel.setup(sp, null, bc) + panel.set_city_buildings(["saga_arena", "forge_chant_hall"] as Array[String]) + + var snap: Dictionary = panel.snapshot() + var rows: Array = snap["specialist_slots"] + assert_eq(rows.size(), 2, "two specialist slots aggregated across two buildings") + assert_eq(String(rows[0]["building_id"]), "saga_arena") + assert_eq(String(rows[0]["specialist_id"]), "saga_writer") + assert_eq(String(rows[1]["building_id"]), "forge_chant_hall") + assert_eq(String(rows[1]["specialist_id"]), "forge_chanter") + + +func test_no_buildings_renders_empty_specialists() -> void: + ## A city with no buildings shows zero specialist rows. + var panel: Node = _make_panel() + panel.setup(_MockSpecialists.new(), null, _MockBuildings.new()) + panel.set_city_buildings([] as Array[String]) + + var snap: Dictionary = panel.snapshot() + assert_true((snap["specialist_slots"] as Array).is_empty(), + "empty city has zero specialist slots") + + +func test_specialist_dict_lookup_uses_registry() -> void: + ## Acceptance: specialist row carries the registry-resolved specialist dict. + var panel: Node = _make_panel() + var sp: _MockSpecialists = _MockSpecialists.new() + sp.add("saga_writer", "Saga Writer", "writing") + var bc: _MockBuildings = _MockBuildings.new() + bc.set_slots("saga_arena", ["saga_writer"]) + + panel.setup(sp, null, bc) + panel.set_city_buildings(["saga_arena"] as Array[String]) + + var snap: Dictionary = panel.snapshot() + var rows: Array = snap["specialist_slots"] + assert_eq(rows.size(), 1) + var resolved: Dictionary = rows[0]["specialist"] + assert_eq(String(resolved.get("name", "")), "Saga Writer", + "specialist name resolved via GdSpecialistRegistry mock") + + +func test_panel_rebuilds_when_buildings_change() -> void: + ## Acceptance: changing the city's building list re-emits the snapshot. + var panel: Node = _make_panel() + var sp: _MockSpecialists = _MockSpecialists.new() + sp.add("saga_writer", "Saga Writer", "writing") + var bc: _MockBuildings = _MockBuildings.new() + bc.set_slots("saga_arena", ["saga_writer"]) + + panel.setup(sp, null, bc) + var emissions: Array = [] + panel.rebuilt.connect(func(snap: Dictionary) -> void: emissions.append(snap)) + + panel.set_city_buildings([] as Array[String]) + panel.set_city_buildings(["saga_arena"] as Array[String]) + + assert_eq(emissions.size(), 2, "rebuilt fires once per set_city_buildings call") + assert_eq((emissions[0]["specialist_slots"] as Array).size(), 0) + assert_eq((emissions[1]["specialist_slots"] as Array).size(), 1) + + +func test_panel_safe_when_registries_unset() -> void: + ## Defensive: with null registries (extension absent), panel renders + ## empty-state rows without crashing. + var panel: Node = _make_panel() + panel.setup(null, null, null) + panel.set_city_buildings(["saga_arena"] as Array[String]) + var snap: Dictionary = panel.snapshot() + assert_true((snap["specialist_slots"] as Array).is_empty(), + "null GdBuildingCivics yields zero rows") + + +func test_panel_extends_vbox_container() -> void: + ## The panel must be a VBoxContainer so it slots cleanly into the + ## city_screen layout. (No live .tscn — script-only contract.) + var panel: Node = _make_panel() + assert_true(panel is VBoxContainer, + "specialists_panel.gd extends VBoxContainer for city_screen embed") diff --git a/src/simulator/crates/mc-city/src/harvest_policy.rs b/src/simulator/crates/mc-city/src/harvest_policy.rs index b5ce1068..d526fddb 100644 --- a/src/simulator/crates/mc-city/src/harvest_policy.rs +++ b/src/simulator/crates/mc-city/src/harvest_policy.rs @@ -87,6 +87,12 @@ impl HarvestPolicyRegistry { self.policies.iter().find(|p| p.id == id) } + /// All policies as a flat slice. Used by UI bridges to enumerate + /// available policies for dropdowns. + pub fn all(&self) -> &[HarvestPolicy] { + &self.policies + } + /// Returns `true` if the policy is applicable to the given terrain id. /// If `applicable_terrains` is empty the policy is unrestricted. pub fn is_applicable(&self, policy_id: &str, terrain_id: &str) -> bool {