feat(combat): add lair Assault/Raid/Siege mode picker on lair engagement
Advances p3-10a. Moving a stack onto a wild-lair tile now opens a small
CanvasLayer mode picker (modeled on promotion_picker.tscn) before combat:
Assault (enabled), Raid (disabled — p3-10c), Siege (disabled — p3-10b). The
picker emits mode_chosen(mode) / cancelled(); world_map_combat.initiate_lair_combat
opens it and routes the Assault branch through _begin_lair_assault → the existing
p0-17 show_lair_preview → _handle_lair_clear path (per p3-10a, "the existing path
IS the assault"), so the working lair-clear flow is not regressed.
Scope note: Assault routes through the live p0-17 flow, NOT GdLair.assault()
(api-gdext/src/lair.rs) — that bridge is the 7-arg JSON marshaller and would
require building attacker/defender JSON + loading tier_NN.json + applying
loot/survivor/clear outcomes in GDScript, duplicating the working path. The
p3-10a bullet therefore stays ◐ (bridge not exercised end-to-end; no picker
proof screenshot yet).
GUT: tests/unit/test_lair_mode_picker.gd 5/5 green on apricot headless
(only-Assault-enabled, Assault emits mode_chosen("assault"), Cancel emits
cancelled() and no mode, disabled Raid/Siege never emit via the in-handler
guard, target label resolves the lair name). All-Dwarf vocab keys
(lair_picker_*, lair_mode_*) authored in vocabulary.json.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1c0d136117
commit
fd690d2483
6 changed files with 355 additions and 4 deletions
|
|
@ -59,7 +59,7 @@ Phase A (typed-enum + Assault wiring) is **done**. Phase B (loot JSON files) and
|
|||
- ✓ **`resolve_assault` + three-variant `AssaultOutcome`** — `mc-combat/src/lair.rs:484`; `Cleared/Repulsed/Withdrawn` present.
|
||||
- ✓ **`LAIR_DEFENDER_POSTURE_BONUS = 0.25`** — applied in `lair.rs`; documented in `LAIRS.md`.
|
||||
- ✓ **Per-tier loot JSON** — `public/resources/lairs/loot/tier_01.json … tier_10.json` all present (`{tier,id,description,loot_table:[{resource,amount,chance}]}`, each with ≥1 `chance 1.0` drop). Test `every_tier_loot_file_parses_and_drives_resolve_assault` reads each file from disk and runs `resolve_assault` (real JSON→resolver coupling, not a serde round-trip).
|
||||
- ◐ **`GdLair::assault` GDExt method + Assault/Siege/Raid UI mode picker** — **bridge half DONE 2026-06-04 (Wave B):** `api-gdext/src/lair.rs::GdLair::assault` over `resolve_assault` (6/6 tests, lib 29/29, workspace check 0). The mode-picker scene/panel in `scenes/` is still ABSENT — godot-ui follow-up, NOT this Rust lane.
|
||||
- ◐ **`GdLair::assault` GDExt method + Assault/Siege/Raid UI mode picker** — **bridge half DONE 2026-06-04 (Wave B):** `api-gdext/src/lair.rs::GdLair::assault` over `resolve_assault` (6/6 tests, lib 29/29, workspace check 0). **UI picker DONE 2026-06-04 (bridge-cse Wave-A):** `src/game/engine/scenes/combat/lair_mode_picker.{gd,tscn}` — a CanvasLayer modal (modeled on `promotion_picker.tscn`) offering Assault (enabled) / Raid (disabled, p3-10c) / Siege (disabled, p3-10b), emitting `mode_chosen(mode)` / `cancelled()`. Wired into `world_map_combat.gd`: `initiate_lair_combat` now opens the picker; the Assault branch routes through `_begin_lair_assault` → the existing p0-17 `show_lair_preview` → `_handle_lair_clear` flow (per this objective: "the existing path IS the assault"). GUT-covered: `tests/unit/test_lair_mode_picker.gd` 5/5 GREEN on apricot headless (only-Assault-enabled, Assault emits `mode_chosen("assault")`, Cancel emits `cancelled()` and no mode, disabled Raid/Siege never emit, target label resolves lair name). All-Dwarf vocab keys (`lair_picker_*`, `lair_mode_*`) authored in `vocabulary.json`. **Stays ◐ — two sub-claims unmet:** (a) the picker routes Assault through the live p0-17 path, NOT through `GdLair.assault()` (the bridge func is the 7-arg JSON marshaller, which would require building attacker/defender JSON + loading `tier_NN.json` + applying loot/survivor/clear outcomes in GDScript — a larger change that duplicates the working flow; routing through p0-17 avoids regressing the p0-17 clear path and is GUT-testable without a gdext rebuild). The `GdLair.assault` bridge therefore is NOT exercised end-to-end by the picker. (b) No picker proof screenshot captured yet (the picker is a transient overlay over the live world map; the existing proof scenes do not surface it). Flip to ✓ requires either routing Assault through the bridge end-to-end OR an explicit operator decision that the p0-17-routed picker satisfies the bullet, plus a picker proof screenshot.
|
||||
- ✓ **`cargo test -p mc-combat` assault/mode tests** — named tests cited in spec; full lair suite green per the close-out (not re-run this session, but the test bodies are present in `lair.rs`).
|
||||
|
||||
**Key finding — p0-17 lair clearing IS already reachable WITHOUT the bridge:** `scenes/world_map/world_map_combat.gd` has `get_lair_at()`, `initiate_lair_combat()`, `show_lair_preview()`, and `_handle_lair_clear()`. Moving a stack onto a lair tile resolves combat through the existing p0-17 path today. The missing `GdLair::assault` is an ALTERNATE entry that also surfaces the mode picker — it is NOT the only way to clear a lair.
|
||||
|
|
|
|||
|
|
@ -804,6 +804,16 @@
|
|||
"fmt_boss_tile": "Location: (%d, %d)",
|
||||
"fmt_boss_devastation_tier": "Devastation: Tier %d and below",
|
||||
"fmt_boss_range": "Range: %d hexes",
|
||||
"lair_picker_title": "Engage the Lair",
|
||||
"lair_picker_target": "Den: %s",
|
||||
"lair_picker_prompt": "How will the warband fall upon it?",
|
||||
"lair_picker_cancel": "Hold Back",
|
||||
"lair_mode_assault": "Assault — storm the den and clear it",
|
||||
"lair_mode_assault_desc": "Enter the lair tile and fight to the finish; the den is cleared on victory.",
|
||||
"lair_mode_raid": "Raid — grab the hoard and withdraw (pending)",
|
||||
"lair_mode_raid_pending": "Raid tactics are not yet taught to the clans.",
|
||||
"lair_mode_siege": "Siege — bleed it from without (pending)",
|
||||
"lair_mode_siege_pending": "Siege tactics are not yet taught to the clans.",
|
||||
"loot_popup_title": "Loot Acquired",
|
||||
"loot_tier_legendary": "Legendary",
|
||||
"loot_tier_rare": "Rare",
|
||||
|
|
|
|||
102
src/game/engine/scenes/combat/lair_mode_picker.gd
Normal file
102
src/game/engine/scenes/combat/lair_mode_picker.gd
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
extends CanvasLayer
|
||||
## Modal lair-engagement mode picker (p3-10a).
|
||||
##
|
||||
## Shown when a stack moves onto a wild-lair tile. Offers the three engagement
|
||||
## modes from LAIRS.md — Assault (enter and clear), Raid (grab-and-exit), and
|
||||
## Siege (multi-turn pressure). For Game-1 only Assault is wired: the Assault
|
||||
## branch routes through the existing p0-17 lair-clear path
|
||||
## (world_map_combat.gd::initiate_lair_combat → combat_preview → _handle_lair_clear),
|
||||
## which IS the assault per p3-10a. Raid (p3-10c) and Siege (p3-10b) are shown
|
||||
## disabled until their dispatcher branches land — no stub, honest degradation.
|
||||
##
|
||||
## Rail-3: presentation only. This scene chooses a mode and hands it back via the
|
||||
## mode_chosen signal; it runs no combat math itself. The GdLair::assault bridge
|
||||
## (api-gdext/src/lair.rs) is the alternate Rust entry that exercises the same
|
||||
## resolver; this Game-1 picker routes Assault through the live p0-17 flow rather
|
||||
## than re-marshalling stack/defender JSON in GDScript, so the working clear path
|
||||
## is not regressed.
|
||||
|
||||
signal mode_chosen(mode: String)
|
||||
signal cancelled()
|
||||
|
||||
const MODE_ASSAULT: String = "assault"
|
||||
const MODE_RAID: String = "raid"
|
||||
const MODE_SIEGE: String = "siege"
|
||||
|
||||
@onready var _title_label: Label = %TitleLabel
|
||||
@onready var _lair_label: Label = %LairLabel
|
||||
@onready var _prompt_label: Label = %PromptLabel
|
||||
@onready var _assault_button: Button = %AssaultButton
|
||||
@onready var _raid_button: Button = %RaidButton
|
||||
@onready var _siege_button: Button = %SiegeButton
|
||||
@onready var _cancel_button: Button = %CancelButton
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_title_label.text = ThemeVocabulary.lookup("lair_picker_title")
|
||||
_prompt_label.text = ThemeVocabulary.lookup("lair_picker_prompt")
|
||||
_assault_button.text = ThemeVocabulary.lookup("lair_mode_assault")
|
||||
_assault_button.tooltip_text = ThemeVocabulary.lookup("lair_mode_assault_desc")
|
||||
_raid_button.text = ThemeVocabulary.lookup("lair_mode_raid")
|
||||
_raid_button.tooltip_text = ThemeVocabulary.lookup("lair_mode_raid_pending")
|
||||
_siege_button.text = ThemeVocabulary.lookup("lair_mode_siege")
|
||||
_siege_button.tooltip_text = ThemeVocabulary.lookup("lair_mode_siege_pending")
|
||||
_cancel_button.text = ThemeVocabulary.lookup("lair_picker_cancel")
|
||||
|
||||
## Only Assault is wired in Game-1; Raid/Siege await p3-10c/p3-10b.
|
||||
_raid_button.disabled = true
|
||||
_siege_button.disabled = true
|
||||
|
||||
_assault_button.pressed.connect(_on_assault)
|
||||
_raid_button.pressed.connect(_on_raid)
|
||||
_siege_button.pressed.connect(_on_siege)
|
||||
_cancel_button.pressed.connect(_on_cancel)
|
||||
visible = false
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not visible:
|
||||
return
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
_on_cancel()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
## Open the picker for the named lair. [param lair_name] is the resolved display
|
||||
## name from world_map_combat.get_lair_at (already theme-flavoured).
|
||||
func show_picker(lair_name: String) -> void:
|
||||
var label_fmt: String = ThemeVocabulary.lookup("lair_picker_target")
|
||||
## Guard against a vocabulary entry that lacks the %s slot (e.g. the
|
||||
## title-case fallback when the theme is not loaded) — formatting a string
|
||||
## with no placeholder errors in GDScript.
|
||||
if label_fmt.contains("%s"):
|
||||
_lair_label.text = label_fmt % lair_name
|
||||
else:
|
||||
_lair_label.text = "%s: %s" % [label_fmt, lair_name]
|
||||
_assault_button.grab_focus()
|
||||
visible = true
|
||||
|
||||
|
||||
func _on_assault() -> void:
|
||||
visible = false
|
||||
mode_chosen.emit(MODE_ASSAULT)
|
||||
|
||||
|
||||
func _on_raid() -> void:
|
||||
## Disabled in Game-1; guarded so a future enable can't silently no-op.
|
||||
if _raid_button.disabled:
|
||||
return
|
||||
visible = false
|
||||
mode_chosen.emit(MODE_RAID)
|
||||
|
||||
|
||||
func _on_siege() -> void:
|
||||
if _siege_button.disabled:
|
||||
return
|
||||
visible = false
|
||||
mode_chosen.emit(MODE_SIEGE)
|
||||
|
||||
|
||||
func _on_cancel() -> void:
|
||||
visible = false
|
||||
cancelled.emit()
|
||||
88
src/game/engine/scenes/combat/lair_mode_picker.tscn
Normal file
88
src/game/engine/scenes/combat/lair_mode_picker.tscn
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
[gd_scene load_steps=2 format=3 uid="uid://lair_mode_picker"]
|
||||
|
||||
[ext_resource type="Script" path="res://engine/scenes/combat/lair_mode_picker.gd" id="1_lairpicker"]
|
||||
|
||||
[node name="LairModePicker" type="CanvasLayer"]
|
||||
layer = 11
|
||||
script = ExtResource("1_lairpicker")
|
||||
|
||||
[node name="Background" type="ColorRect" parent="."]
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
color = Color(0, 0, 0, 0.6)
|
||||
|
||||
[node name="Panel" type="PanelContainer" parent="."]
|
||||
anchors_preset = 8
|
||||
anchor_left = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -240.0
|
||||
offset_top = -160.0
|
||||
offset_right = 240.0
|
||||
offset_bottom = 160.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="Panel"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 16
|
||||
theme_override_constants/margin_top = 12
|
||||
theme_override_constants/margin_right = 16
|
||||
theme_override_constants/margin_bottom = 12
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="Panel/MarginContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="Panel/MarginContainer/VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="LairLabel" type="Label" parent="Panel/MarginContainer/VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="Separator" type="HSeparator" parent="Panel/MarginContainer/VBox"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="PromptLabel" type="Label" parent="Panel/MarginContainer/VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
|
||||
[node name="ModeButtons" type="VBoxContainer" parent="Panel/MarginContainer/VBox"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
theme_override_constants/separation = 6
|
||||
|
||||
[node name="AssaultButton" type="Button" parent="Panel/MarginContainer/VBox/ModeButtons"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(0, 44)
|
||||
|
||||
[node name="RaidButton" type="Button" parent="Panel/MarginContainer/VBox/ModeButtons"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(0, 44)
|
||||
disabled = true
|
||||
|
||||
[node name="SiegeButton" type="Button" parent="Panel/MarginContainer/VBox/ModeButtons"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(0, 44)
|
||||
disabled = true
|
||||
|
||||
[node name="Separator2" type="HSeparator" parent="Panel/MarginContainer/VBox"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Buttons" type="HBoxContainer" parent="Panel/MarginContainer/VBox"]
|
||||
layout_mode = 2
|
||||
alignment = 1
|
||||
|
||||
[node name="CancelButton" type="Button" parent="Panel/MarginContainer/VBox/Buttons"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(120, 36)
|
||||
|
|
@ -13,13 +13,20 @@ const CombatResultScene: PackedScene = preload(
|
|||
const PromotionPickerScene: PackedScene = preload(
|
||||
"res://engine/scenes/combat/promotion_picker.tscn"
|
||||
)
|
||||
const LairModePickerScene: PackedScene = preload(
|
||||
"res://engine/scenes/combat/lair_mode_picker.tscn"
|
||||
)
|
||||
|
||||
var _combat_preview: CanvasLayer = null
|
||||
var _combat_result: CanvasLayer = null
|
||||
var _promotion_picker: CanvasLayer = null
|
||||
var _lair_mode_picker: CanvasLayer = null
|
||||
var _pending_attacker: RefCounted = null
|
||||
var _pending_defender: RefCounted = null
|
||||
|
||||
## Lair engagement awaiting a mode choice from the picker.
|
||||
var _pending_lair: Dictionary = {}
|
||||
|
||||
|
||||
func setup(parent: Node) -> void:
|
||||
_combat_preview = CombatPreviewScene.instantiate()
|
||||
|
|
@ -31,11 +38,16 @@ func setup(parent: Node) -> void:
|
|||
_promotion_picker = PromotionPickerScene.instantiate()
|
||||
parent.add_child(_promotion_picker)
|
||||
|
||||
_lair_mode_picker = LairModePickerScene.instantiate()
|
||||
parent.add_child(_lair_mode_picker)
|
||||
|
||||
EventBus.combat_resolved.connect(_on_combat_resolved)
|
||||
_combat_result.result_dismissed.connect(_on_result_dismissed)
|
||||
_combat_result.promotion_requested.connect(_on_promotion_requested)
|
||||
_promotion_picker.promotion_chosen.connect(_on_promotion_chosen)
|
||||
_promotion_picker.promotion_cancelled.connect(_on_promotion_cancelled)
|
||||
_lair_mode_picker.mode_chosen.connect(_on_lair_mode_chosen)
|
||||
_lair_mode_picker.cancelled.connect(_on_lair_mode_cancelled)
|
||||
|
||||
|
||||
func get_lair_at(axial: Vector2i) -> Dictionary:
|
||||
|
|
@ -99,14 +111,59 @@ func initiate_lair_combat(
|
|||
lair_diet: String,
|
||||
lair_name: String,
|
||||
) -> void:
|
||||
## Show combat preview for attacker vs a lair creature at lair_pos.
|
||||
## Uses GdCombatResolver.wild_stats() for the defender.
|
||||
## Open the Assault / Raid / Siege mode picker for a lair engagement (p3-10a).
|
||||
## The chosen mode routes through _on_lair_mode_chosen; Assault proceeds to
|
||||
## the p0-17 lair-clear flow (the existing assault path). Raid/Siege are shown
|
||||
## disabled by the picker until p3-10c/p3-10b land their dispatcher branches.
|
||||
_pending_lair = {
|
||||
"attacker": attacker,
|
||||
"pos": lair_pos,
|
||||
"tier": lair_tier,
|
||||
"size": lair_size,
|
||||
"diet": lair_diet,
|
||||
"name": lair_name,
|
||||
}
|
||||
if _lair_mode_picker == null:
|
||||
## No picker wired (e.g. headless unit context) — fall back to direct
|
||||
## assault so the p0-17 clear path is never regressed.
|
||||
_begin_lair_assault(_pending_lair)
|
||||
_pending_lair = {}
|
||||
return
|
||||
_lair_mode_picker.show_picker(lair_name)
|
||||
|
||||
|
||||
func _on_lair_mode_chosen(mode: String) -> void:
|
||||
var lair: Dictionary = _pending_lair
|
||||
_pending_lair = {}
|
||||
if lair.is_empty():
|
||||
return
|
||||
## Only Assault is wired in Game-1 (Raid/Siege are disabled in the picker).
|
||||
if mode == _lair_mode_picker.MODE_ASSAULT:
|
||||
_begin_lair_assault(lair)
|
||||
|
||||
|
||||
func _on_lair_mode_cancelled() -> void:
|
||||
## Player backed out of the engagement; the stack stays where it is.
|
||||
_pending_lair = {}
|
||||
|
||||
|
||||
func _begin_lair_assault(lair: Dictionary) -> void:
|
||||
## Show the combat preview for the attacker vs the lair creature.
|
||||
## Uses GdCombatResolver.wild_stats() for the defender (inside the preview).
|
||||
var attacker: RefCounted = lair.get("attacker")
|
||||
if attacker == null:
|
||||
return
|
||||
_pending_attacker = attacker
|
||||
_pending_defender = null
|
||||
var game_map: RefCounted = GameState.get_game_map()
|
||||
var all_units: Array = _collect_all_units()
|
||||
_combat_preview.show_lair_preview(
|
||||
attacker, lair_pos, lair_tier, lair_size, lair_diet, lair_name,
|
||||
attacker,
|
||||
lair.get("pos", Vector2i.ZERO),
|
||||
int(lair.get("tier", 4)),
|
||||
str(lair.get("size", "medium")),
|
||||
str(lair.get("diet", "carnivore")),
|
||||
str(lair.get("name", "Lair Creature")),
|
||||
game_map, all_units
|
||||
)
|
||||
|
||||
|
|
|
|||
94
src/game/engine/tests/unit/test_lair_mode_picker.gd
Normal file
94
src/game/engine/tests/unit/test_lair_mode_picker.gd
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
extends GutTest
|
||||
## p3-10a — GUT unit test for the lair engagement mode picker.
|
||||
##
|
||||
## Boots the REAL lair_mode_picker.tscn (the @onready %-named layout) and
|
||||
## asserts the Game-1 wiring contract: Assault is enabled and emits
|
||||
## mode_chosen("assault"); Raid and Siege are shown disabled (p3-10c/p3-10b)
|
||||
## and never emit; Cancel emits cancelled(). Rail-3: the picker chooses a mode
|
||||
## only — no combat math here, so this test asserts UI state + signals, not
|
||||
## resolver behaviour (that lives in mc-combat / GdLair tests).
|
||||
|
||||
const LairModePickerScene: PackedScene = preload(
|
||||
"res://engine/scenes/combat/lair_mode_picker.tscn"
|
||||
)
|
||||
|
||||
var _picker: CanvasLayer = null
|
||||
|
||||
|
||||
func before_all() -> void:
|
||||
## Resolve real Dwarf copy (else lookups fall back to title-case keys).
|
||||
if get_tree().root.has_node("ThemeVocabulary"):
|
||||
ThemeVocabulary.load_vocabulary("age-of-dwarves")
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
_picker = LairModePickerScene.instantiate() as CanvasLayer
|
||||
add_child_autofree(_picker)
|
||||
await get_tree().process_frame
|
||||
|
||||
|
||||
func after_each() -> void:
|
||||
if is_instance_valid(_picker):
|
||||
_picker.queue_free()
|
||||
_picker = null
|
||||
|
||||
|
||||
func _btn(name: String) -> Button:
|
||||
return _picker.get_node("%" + name) as Button
|
||||
|
||||
|
||||
## Only Assault is wired in Game-1; Raid and Siege are disabled until their
|
||||
## dispatcher branches land (p3-10c / p3-10b).
|
||||
func test_only_assault_is_enabled() -> void:
|
||||
assert_false(_btn("AssaultButton").disabled,
|
||||
"Assault must be enabled in Game-1")
|
||||
assert_true(_btn("RaidButton").disabled,
|
||||
"Raid must be disabled until p3-10c")
|
||||
assert_true(_btn("SiegeButton").disabled,
|
||||
"Siege must be disabled until p3-10b")
|
||||
|
||||
|
||||
## Pressing Assault emits mode_chosen with the assault mode id and hides.
|
||||
func test_assault_emits_mode_chosen() -> void:
|
||||
watch_signals(_picker)
|
||||
_picker.show_picker("Wolf Den")
|
||||
assert_true(_picker.visible, "picker visible after show_picker")
|
||||
_btn("AssaultButton").pressed.emit()
|
||||
await get_tree().process_frame
|
||||
assert_signal_emitted_with_parameters(
|
||||
_picker, "mode_chosen", [_picker.MODE_ASSAULT])
|
||||
assert_false(_picker.visible, "picker hides after a mode is chosen")
|
||||
|
||||
|
||||
## Cancel emits cancelled() and emits no mode_chosen.
|
||||
func test_cancel_emits_cancelled() -> void:
|
||||
watch_signals(_picker)
|
||||
_picker.show_picker("Wolf Den")
|
||||
_btn("CancelButton").pressed.emit()
|
||||
await get_tree().process_frame
|
||||
assert_signal_emitted(_picker, "cancelled",
|
||||
"Cancel must emit cancelled()")
|
||||
assert_signal_not_emitted(_picker, "mode_chosen",
|
||||
"Cancel must not choose a mode")
|
||||
|
||||
|
||||
## A disabled Raid press (forced via the handler) must not emit a mode — the
|
||||
## guard protects against a future enable that forgets to re-check.
|
||||
func test_disabled_raid_does_not_emit() -> void:
|
||||
watch_signals(_picker)
|
||||
_picker.show_picker("Wolf Den")
|
||||
## Buttons emit nothing when disabled, but call the handler directly to
|
||||
## exercise the in-handler guard explicitly.
|
||||
_picker._on_raid()
|
||||
_picker._on_siege()
|
||||
await get_tree().process_frame
|
||||
assert_signal_not_emitted(_picker, "mode_chosen",
|
||||
"disabled Raid/Siege must never emit mode_chosen")
|
||||
|
||||
|
||||
## The target label resolves the lair name through the format vocab key.
|
||||
func test_target_label_shows_lair_name() -> void:
|
||||
_picker.show_picker("Goblin Warren")
|
||||
var lbl: Label = _picker.get_node("%LairLabel") as Label
|
||||
assert_string_contains(lbl.text, "Goblin Warren",
|
||||
"target label must include the lair name")
|
||||
Loading…
Add table
Reference in a new issue