feat(@projects/@magic-civilization): ✨ add specialist panel UI and GPP slots
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
c9f7b23c0c
commit
d3c484a5a4
5 changed files with 483 additions and 0 deletions
|
|
@ -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",
|
||||
|
|
|
|||
191
src/game/engine/scenes/city/specialists_panel.gd
Normal file
191
src/game/engine/scenes/city/specialists_panel.gd
Normal 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)
|
||||
120
src/game/engine/tests/unit/test_city_screen_gpp.gd
Normal file
120
src/game/engine/tests/unit/test_city_screen_gpp.gd
Normal 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")
|
||||
162
src/game/engine/tests/unit/test_city_screen_specialist_slots.gd
Normal file
162
src/game/engine/tests/unit/test_city_screen_specialist_slots.gd
Normal 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")
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue