diff --git a/.project/objectives/p3-10a-lair-assault-mode.md b/.project/objectives/p3-10a-lair-assault-mode.md index 675c6145..d77d4e0e 100644 --- a/.project/objectives/p3-10a-lair-assault-mode.md +++ b/.project/objectives/p3-10a-lair-assault-mode.md @@ -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. diff --git a/public/games/age-of-dwarves/vocabulary.json b/public/games/age-of-dwarves/vocabulary.json index a72f3728..aabc9fb9 100644 --- a/public/games/age-of-dwarves/vocabulary.json +++ b/public/games/age-of-dwarves/vocabulary.json @@ -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", diff --git a/src/game/engine/scenes/combat/lair_mode_picker.gd b/src/game/engine/scenes/combat/lair_mode_picker.gd new file mode 100644 index 00000000..ca357e55 --- /dev/null +++ b/src/game/engine/scenes/combat/lair_mode_picker.gd @@ -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() diff --git a/src/game/engine/scenes/combat/lair_mode_picker.tscn b/src/game/engine/scenes/combat/lair_mode_picker.tscn new file mode 100644 index 00000000..f55c0593 --- /dev/null +++ b/src/game/engine/scenes/combat/lair_mode_picker.tscn @@ -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) diff --git a/src/game/engine/scenes/world_map/world_map_combat.gd b/src/game/engine/scenes/world_map/world_map_combat.gd index 328dfb07..27233c2f 100644 --- a/src/game/engine/scenes/world_map/world_map_combat.gd +++ b/src/game/engine/scenes/world_map/world_map_combat.gd @@ -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 ) diff --git a/src/game/engine/tests/unit/test_lair_mode_picker.gd b/src/game/engine/tests/unit/test_lair_mode_picker.gd new file mode 100644 index 00000000..a9b8270d --- /dev/null +++ b/src/game/engine/tests/unit/test_lair_mode_picker.gd @@ -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")