feat(@projects/@magic-civilization): add specialist panel UI and GPP slots

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-04 01:10:14 -04:00
parent c9f7b23c0c
commit d3c484a5a4
5 changed files with 483 additions and 0 deletions

View file

@ -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",

View file

@ -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)

View file

@ -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")

View file

@ -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")

View file

@ -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 {