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:
autocommit 2026-06-04 18:17:38 -07:00
parent 1c0d136117
commit fd690d2483
6 changed files with 355 additions and 4 deletions

View file

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

View file

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

View 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()

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

View file

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

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