Files
TheTree_mock/tests/ui/test_defense_management.gd
Takeshi b84bade045 Save The Tree implementation and acceptance work in progress
Preserve production code, regression fixtures, audio assets and provenance, test infrastructure, and the original-completion plan and progress ledger. Final game and Windows acceptance remain pending; excluded local artifacts and native experiments are not part of this snapshot.
2026-10-11 15:58:00 +09:00

330 lines
24 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
extends SceneTree
const FixtureCleanup := preload("res://tests/helpers/ui_audio_fixture_cleanup.gd")
## Command-backed UI acceptance. Incidents are disclosed threat fixtures;
## combat progression uses the ordinary game clock.
var _checks := 0
var _failures := 0
var _main: Control
var _runtime: Variant
var _clock: Variant
var _panel: DefenseManagementPanel
var _facility_id: StringName = &""
var _path := "user://tests/defense_management_%d/slot" % OS.get_process_id()
var _slots: Array[String] = []
func _initialize() -> void:
call_deferred("_run")
func _expect(ok: bool, message: String) -> void:
_checks += 1
if not ok:
_failures += 1
push_error(message)
func _run() -> void:
root.gui_embed_subwindows = true
root.size = Vector2i(1440, 810)
_runtime = root.get_node("SimulationRuntime")
_clock = root.get_node("GameClock")
_clock.reset(1, 6, 0)
root.get_node("GameState").reset()
_runtime.initialize(_clock, root.get_node("GameState"), 42540)
_clock.set_paused(true)
_main = load("res://scenes/main/main.tscn").instantiate()
_main.set("manual_save_path", _path)
root.add_child(_main)
_runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false})
await process_frame
await process_frame
_main.call("_set_management_panel_open", true)
_main.find_child("DefenseManagementButton", true, false).pressed.emit()
_panel = _main.get("defense_manager").find_child("DefenseManagementPanel", true, false)
_expect(_main.get("defense_manager").visible and _panel.selected_raid_id.is_empty(), "ordinary management button opens empty defense screen")
_expect("ありません" in _panel.summary_label.text and _panel.start_button.disabled, "no incident has no actionable start")
# Paid normal construction and hiring establish real target/defender links.
_main.get("defense_manager").hide()
_main.call("_select_build_definition", &"reception")
_main.call("_on_map_cell_clicked", Vector3i(-8, 0, 0))
_main.call("_clear_build_selection")
_clock.advance_minutes(120)
for facility: FacilityInstance in _runtime.facility_service.get_instances():
if facility.definition_id == &"reception": _facility_id = facility.instance_id
_expect(not _facility_id.is_empty(), "normal paid reception construction provides a real target")
_main.get("staff_candidate_toggle_button").pressed.emit()
_main.get("staff_candidate_buttons")[&"staff_klaus"].pressed.emit()
_expect(_runtime.staff_service.has_staff(&"staff_klaus"), "ordinary recruitment hires guard with existing combat data")
if _facility_id.is_empty() or not _runtime.staff_service.has_staff(&"staff_klaus"):
_finish()
return
_expect(_create_incident(&"ui.defense.fixture").is_success(), "disclosed threat fixture creates an existing preparing incident")
_main.find_child("DefenseManagementButton", true, false).pressed.emit()
_expect(_panel.selected_raid_id == &"ui.defense.fixture" and "準備中" in _panel.summary_label.text, "real popup observes Runtime incident publication")
_expect(_panel.find_child("DefenseCancelPreparation", true, false) == null and &"cancel_defense_raid" not in DefenseManagementPanel.COMMANDS, "preparation has no free incident-cancellation button or allowed UI command")
var preparing_before: Dictionary = _runtime.create_snapshot()
_panel.call("_submit", &"cancel_defense_raid")
_panel.retreat_button.pressed.emit()
_expect(not _panel.confirmation.visible and (_panel.get("_confirmation_context") as Dictionary).is_empty(), "preparation cannot open a withdrawal confirmation even through a stale hidden button signal")
_expect(preparing_before == _runtime.create_snapshot(), "forbidden preparation cancellation and withdrawal do not enqueue commands or mutate any Runtime state")
var before: Dictionary = _runtime.create_snapshot()
var report: Dictionary = _runtime.get_defense_management_report(&"ui.defense.fixture")
_expect(before == _runtime.create_snapshot(), "management report does not mutate gameplay or RNG")
report[&"selected"][&"objective_ids"].clear()
report[&"staff"][0][&"combat"] = 999
_expect(before == _runtime.create_snapshot(), "compact projections are detached from Defense and Staff")
_expect(not _panel.register_staff_button.disabled and _panel.start_button.disabled, "unregistered objective explains and prevents start")
_panel.register_staff_button.pressed.emit()
var raid: Dictionary = _runtime.defense_service.get_raid(&"ui.defense.fixture")
var guard: StaffRuntime = _runtime.staff_service.get_staff(&"staff_klaus")
_expect(raid[&"combatants"].has(&"staff_klaus"), "real participation button registers defender through Runtime command")
_expect(raid[&"combatants"][&"staff_klaus"][&"combat"] == guard.skills[&"combat"] and guard.state == StaffRuntime.State.UNAVAILABLE, "defender uses actual stored skill and becomes unavailable for work")
_panel.register_objective_button.pressed.emit()
raid = _runtime.defense_service.get_raid(&"ui.defense.fixture")
_expect(raid[&"assets"].has(_facility_id) and not _panel.start_button.disabled, "real target button registers existing facility and enables start")
_expect(raid[&"assets"][_facility_id][&"integrity"] == _runtime.facility_service.get_instance(_facility_id).durability, "objective reflects current facility durability")
# Policy edits survive publication until explicitly applied, and do not
# change current policy before the player presses the apply button.
_panel.policy_option.select(2)
_panel.policy_option.item_selected.emit(2)
_panel.threshold_spin.value = 7
_panel.refresh()
_expect(_panel.policy_option.selected == 2 and _panel.threshold_spin.value == 7, "uncommitted retreat draft survives UI refresh")
_expect(_runtime.defense_service.get_raid(&"ui.defense.fixture")[&"retreat_policy"] == &"HOLD", "editing a draft alone does not alter defense")
_panel.policy_button.pressed.emit()
_expect(_runtime.defense_service.get_raid(&"ui.defense.fixture")[&"retreat_policy"] == &"THRESHOLD" and _runtime.defense_service.get_raid(&"ui.defense.fixture")[&"defender_retreat_threshold"] == 7, "apply button updates existing retreat policy contract")
_main.call("_save_manual")
_slots.append(_path)
var package := SaveService.load_snapshot(_path)
_expect(package.get(&"ok", false) and package.get(&"manifest", {}).get("interface_selection", {}).get("defense_id") == "ui.defense.fixture" and package.get(&"manifest", {}).get("interface_selection", {}).get("defense_open") == true, "normal save captures selected incident and open popup")
# Change a real policy after saving so the following read proves restoration.
_panel.policy_option.select(0)
_panel.policy_option.item_selected.emit(0)
_panel.policy_button.pressed.emit()
_expect(_runtime.defense_service.get_raid(&"ui.defense.fixture")[&"retreat_policy"] == &"HOLD", "ordinary policy button changes live preparation after the saved checkpoint")
_main.get("defense_manager").hide()
_main.call("_load_manual", true)
_expect(_main.get("defense_manager").visible and _panel.selected_raid_id == &"ui.defense.fixture", "manual load restores actual saved incident and visibility")
var restored_preparing: Dictionary = _runtime.defense_service.get_raid(&"ui.defense.fixture")
_expect(restored_preparing[&"state"] == DefenseService.RaidState.PREPARING and _runtime.staff_service.get_staff(&"staff_klaus").state == StaffRuntime.State.UNAVAILABLE, "saved preparation remains a live threat and keeps its actual defender locked")
_expect(restored_preparing[&"retreat_policy"] == &"THRESHOLD" and restored_preparing[&"defender_retreat_threshold"] == 7, "manual load restores the saved policy instead of the later live edit")
_clock.set_paused(false)
_panel.start_button.pressed.emit()
_panel.start_button.pressed.emit()
var queued := 0
for command: Dictionary in _runtime.command_queue.create_snapshot().get("pending", []):
if command.get("command_type", "") == "start_defense_raid": queued += 1
_expect(queued == 1 and _panel.start_button.disabled, "playing simulation accepts a single pending start despite repeated button activation")
_expect(_runtime.defense_service.get_raid(&"ui.defense.fixture")[&"state"] == DefenseService.RaidState.PREPARING, "accepted running command has not mutated the incident before its tick")
_clock.advance_minutes(1)
_clock.set_paused(true)
_expect(_runtime.defense_service.get_raid(&"ui.defense.fixture")[&"state"] == DefenseService.RaidState.ACTIVE and _panel.retreat_button.visible, "start button enters actual battle and exposes withdrawal")
# Stale start invocation is rejected by Runtime and never restarts combat.
var active: Dictionary = _runtime.defense_service.create_snapshot()
_panel.start_button.pressed.emit()
_expect(active == _runtime.defense_service.create_snapshot(), "invalid repeated start preserves active battle state")
# Withdrawal is available only after the existing incident starts. Backing
# out changes presentation alone and retains the threat and defender locks.
var active_before_return: Dictionary = _runtime.create_snapshot()
_panel.retreat_button.pressed.emit()
_expect(_panel.confirmation.visible and _panel.confirmation.title == "撤退の確認", "active battle opens only a withdrawal confirmation")
_panel.confirmation.canceled.emit()
_panel.confirmation.hide()
_expect((_panel.get("_confirmation_context") as Dictionary).is_empty() and active_before_return == _runtime.create_snapshot(), "returning from active withdrawal clears confirmation without changing gameplay or queued commands")
# Save the ACTIVE incident, then replace its queue through an actual load.
_main.call("_save_manual")
var active_package := SaveService.load_snapshot(_path)
_expect(active_package.get(&"ok", false), "normal save writes the active battle before stale-context testing")
_panel.retreat_button.pressed.emit()
var context: Dictionary = _panel.get("_confirmation_context").duplicate(true)
_expect(_panel.confirmation.visible and not context.is_empty(), "active withdrawal captures the current command queue before load")
_main.call("_load_manual", true)
_expect(_main.get("build_status_label").text == "手動セーブを読み込みました。" and _main.get("defense_manager").visible and _panel.selected_raid_id == &"ui.defense.fixture", "successful strict read restores active incident selection and popup")
_expect(_runtime.defense_service.get_raid(&"ui.defense.fixture")[&"state"] == DefenseService.RaidState.ACTIVE, "active save restores actual battle state")
_expect(not _panel.confirmation.visible and (_panel.get("_confirmation_context") as Dictionary).is_empty(), "successful active load dismisses and invalidates the old withdrawal confirmation")
_expect(_runtime.command_queue.get_instance_id() != int(context.get(&"queue_instance_id", 0)), "successful load uses a different command queue than the pending confirmation")
var loaded_before_replay: Dictionary = _runtime.create_snapshot()
_panel.set("_confirmation_context", context)
_panel.confirmation.confirmed.emit()
_expect(loaded_before_replay == _runtime.create_snapshot(), "replayed old-queue withdrawal context leaves every loaded Runtime subsystem and command unchanged")
_expect("対象が変わりました" in _panel.result_label.text and _runtime.staff_service.get_staff(&"staff_klaus").state == StaffRuntime.State.UNAVAILABLE, "stale withdrawal reports rejection and leaves the loaded defender assigned")
# The disclosed fixture creates only the threat. Combat now progresses from
# ordinary game minutes without a round button or a synthetic player command.
var interval := int(_runtime.defense_service.get_raid(&"ui.defense.fixture")[&"round_minutes"])
_clock.advance_minutes(interval - 1)
_expect(_runtime.defense_service.get_raid(&"ui.defense.fixture")[&"current_round"] == 0, "ordinary battle waits for its persisted round interval")
_clock.advance_minutes(1)
_expect(_runtime.defense_service.get_raid(&"ui.defense.fixture")[&"current_round"] == 1, "ordinary clock automatically resolves the first attack")
_expect(_runtime.defense_service.get_raid(&"ui.defense.fixture")[&"state"] == DefenseService.RaidState.BETWEEN_WAVES and _panel.next_wave_button.visible, "service wave outcome exposes ordinary next-wave control")
_panel.next_wave_button.pressed.emit()
_expect(_runtime.defense_service.get_raid(&"ui.defense.fixture")[&"state"] == DefenseService.RaidState.ACTIVE, "player next-wave button advances existing incident through Runtime")
_panel.retreat_button.pressed.emit()
_panel.confirmation.confirmed.emit()
_panel.confirmation.hide()
_expect(_runtime.defense_service.get_raid(&"ui.defense.fixture")[&"state"] == DefenseService.RaidState.DEFENDERS_RETREATED, "confirmed withdrawal terminates battle through Runtime")
_expect(_runtime.staff_service.get_staff(&"staff_klaus").state == StaffRuntime.State.FLEEING and _panel.policy_button.disabled, "retreat reconciles staff and terminal UI disables commands")
# Failed gameplay loads retain current selection. Optional bad presentation
# must not invalidate a valid gameplay load or reopen this inspector.
var selected := _panel.selected_raid_id
_expect(not _runtime.load_from_path(_path + "_absent").get(&"ok", true), "missing package load fails explicitly")
_expect(_panel.selected_raid_id == selected and _main.get("defense_manager").visible, "failed load preserves visible defense selection")
for entry: Array in [["types", []], ["missing", "missing.raid"], ["long", "x".repeat(257)]]:
var slot: String = _path + "_" + entry[0]
_slots.append(slot)
_expect(SaveService.save_snapshot(slot, _runtime.create_snapshot(), {&"interface_selection": {&"version": 1, &"defense_id": entry[1], &"defense_open": true}}).get(&"ok", false), "write malformed optional UI metadata fixture " + entry[0])
_expect(_runtime.load_from_path(slot).get(&"ok", false), "valid gameplay still loads with optional invalid UI metadata " + entry[0])
_expect(not _main.get("defense_manager").visible, "invalid incident selection does not reopen popup " + entry[0])
_main.find_child("DefenseManagementButton", true, false).pressed.emit()
await _test_incident_presentation()
var capture := OS.get_environment("THE_TREE_DEFENSE_MANAGEMENT_CAPTURE")
if not capture.is_empty():
for frame: int in 4: await process_frame
await RenderingServer.frame_post_draw
_expect(root.get_texture().get_image().save_png(capture) == OK, "capture actual defense popup after source-label and saved-selection checks")
_finish()
func _create_incident(id: StringName) -> CommandResult:
return _runtime.submit_command(&"create_defense_raid", {&"raid_id": id, &"definition": {
&"entry_points": [&"fixture.gate"], &"objective_ids": [_facility_id],
&"waves": [
{&"wave_id": &"fixture.wave.one", &"threat_strength": 5, &"max_rounds": 1},
{&"wave_id": &"fixture.wave.two", &"threat_strength": 5, &"max_rounds": 1},
],
}}, &"test.fixture")
func _finish() -> void:
if is_instance_valid(_main):
var _audio_cleanup_final: Dictionary = await FixtureCleanup.free_owner_and_wait(self, _main)
if not bool(_audio_cleanup_final.get(&"passed", false)):
push_error("Ordinary fixture audio cleanup failed: %s" % str(_audio_cleanup_final))
quit(96)
return
for path: String in _slots:
SaveService.delete_save(path)
if _failures == 0: print("PASS: %d checks" % _checks)
else: push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
quit(_failures)
## Display acceptance uses explicit disclosed causes through the actual Event
## queue. These cases do not claim normal threat generation or approve balance.
func _test_incident_presentation() -> void:
var expected := {"THIEF": "盗賊の襲撃", "BANKRUPTCY_RIOT": "客の暴動", "BANK_DELINQUENCY": "銀行による取り立て", "AUTHORITY_ESCALATION": "監査による摘発"}
var event_ids: Dictionary = {}
for kind: String in expected:
var event_id := StringName("ui.presentation.event." + kind)
var instance_id := StringName("ui.presentation.instance." + kind)
var action_id := &"ui.presentation.threat"
var action := EventAction.new().configure(action_id, EventAction.Type.CREATE_DEFENSE_RAID, {
&"template_id": &"ui.presentation.disclosed.template", &"source_kind": kind,
&"definition": _presentation_raid_definition(),
})
var registered: CommandResult = _runtime.submit_command(&"register_event_definition", {&"definition": {
&"event_id": event_id, &"category": &"DEFENSE", &"foreground": false,
&"transaction_policy": &"ROLLBACK_ALL", &"automatic_actions": [action.create_snapshot()],
}}, &"test.fixture")
_expect(registered.is_success(), "disclosed presentation source registers " + kind)
var queued: CommandResult = _runtime.submit_command(&"enqueue_event", {
&"event_id": event_id, &"actor_ids": [], &"options": {
&"event_instance_id": instance_id,
&"context": {&"threat_source_id": StringName("ui.presentation.cause." + kind)},
},
}, &"test.fixture")
_expect(queued.is_success(), "disclosed presentation cause enters the real event queue " + kind)
_clock.advance_minutes(1)
var raid_id := StringName("event_defense_raid." + JSON.stringify([String(instance_id), String(action_id)], "", true, true).sha256_text())
event_ids[kind] = raid_id
var raid: Dictionary = _runtime.defense_service.get_raid(raid_id)
_expect(not raid.is_empty() and raid.get(&"event_source", {}).get("source_kind", "") == kind, "ordinary event processing supplies explicit source provenance " + kind)
# The two manual incidents deliberately resemble a source enum. They have
# no event provenance and must remain generic defense records.
var manual_one := &"THIEF"
var manual_two := &"ui.presentation.manual.thief.source"
for id: StringName in [manual_one, manual_two]:
var definition := _presentation_raid_definition()
definition[&"source_id"] = &"THIEF"
var created: CommandResult = _runtime.submit_command(&"create_defense_raid", {&"raid_id": id, &"definition": definition}, &"test.fixture")
_expect(created.is_success(), "manual same-minute presentation incident is disclosed and created")
_main.find_child("DefenseManagementButton", true, false).pressed.emit()
_panel.refresh()
var before: Dictionary = _runtime.create_snapshot()
var report: Dictionary = _runtime.get_defense_management_report(event_ids["THIEF"])
var selected: Dictionary = report.get(&"selected", {})
_expect(selected.get(&"source_kind", "") == "THIEF" and typeof(selected.get(&"created_at")) == TYPE_INT, "compact management report exposes only explicit cause and persisted creation time")
selected[&"source_kind"] = "BANK_DELINQUENCY"
selected[&"created_at"] = -1
_expect(_runtime.create_snapshot() == before, "display projection fields remain owned and cannot mutate gameplay, source or RNG")
for kind: String in expected:
var raid_id := StringName(event_ids[kind])
_select_presentation_option(raid_id)
var index := _panel.raid_option.selected
_expect(_panel.selected_raid_id == raid_id and _panel.raid_option.get_item_metadata(index) == raid_id, "cause label never replaces internal selection metadata " + kind)
_expect(expected[kind] in _panel.raid_option.get_item_text(index) and "日目" in _panel.raid_option.get_item_text(index), "option shows readable cause and occurrence time " + kind)
_expect(expected[kind] in _panel.summary_label.text and String(raid_id) not in _panel.summary_label.text and "event_defense_raid." not in _panel.raid_option.get_item_text(index), "summary and option keep the SHA out of user text " + kind)
var again: Dictionary = _runtime.create_snapshot()
_panel.refresh()
_expect(_runtime.create_snapshot() == again and _panel.selected_raid_id == raid_id, "repeated label refresh preserves every gameplay system and selected record " + kind)
var manual_labels: Array[String] = []
for id: StringName in [manual_one, manual_two]:
_select_presentation_option(id)
manual_labels.append(_panel.raid_option.get_item_text(_panel.raid_option.selected))
_expect(_panel.selected_raid_id == id and "防衛戦" in _panel.summary_label.text and "盗賊" not in _panel.summary_label.text, "manual ID and source_id never imply a thief cause")
_expect(manual_labels[0] != manual_labels[1] and "(1)" in manual_labels[0] and "(2)" in manual_labels[1], "same cause/time manual incidents remain separately readable")
_expect(_panel.call("_raid_base_label", {&"raid_id": &"THIEF", &"source_id": &"THIEF"}) == "防衛戦 · 時刻不明", "legacy projection without cause/time never fabricates a source or midnight timestamp")
_expect("防衛戦" in String(_panel.call("_raid_base_label", {&"source_kind": "FUTURE_KIND", &"created_at": 0})), "unknown cause text receives a neutral label")
var saved_id := StringName(event_ids["THIEF"])
_select_presentation_option(saved_id)
var saved_text := _panel.summary_label.text
_panel.policy_option.select(2)
_panel.policy_option.item_selected.emit(2)
_panel.threshold_spin.value = 17
_panel.policy_button.pressed.emit()
_expect(_runtime.defense_service.get_raid(saved_id)[&"retreat_policy"] == &"THRESHOLD" and _runtime.defense_service.get_raid(saved_id)[&"defender_retreat_threshold"] == 17, "readable event selection sends the ordinary policy command to its actual internal raid")
_expect(_runtime.defense_service.get_raid(event_ids["BANK_DELINQUENCY"])[&"retreat_policy"] == &"HOLD", "selected policy action never affects another similarly displayed incident")
_main.call("_save_manual")
var package := SaveService.load_snapshot(_path)
_expect(package.get(&"ok", false) and package.get(&"manifest", {}).get("interface_selection", {}).get("defense_id") == String(saved_id), "normal UI save retains the full internal event raid ID")
_select_presentation_option(manual_two)
_main.get("defense_manager").hide()
_main.call("_load_manual", true)
_expect(_main.get("build_status_label").text == "手動セーブを読み込みました。" and _main.get("defense_manager").visible, "strict normal load restores the saved readable defense panel")
_expect(_panel.selected_raid_id == saved_id and _panel.summary_label.text == saved_text, "cause, creation time, state and exact selected event raid survive normal save/load")
_expect(_runtime.defense_service.get_raid(saved_id)[&"event_source"]["source_kind"] == "THIEF", "display labels never overwrite restored event provenance")
_expect(_runtime.defense_service.get_raid(saved_id)[&"retreat_policy"] == &"THRESHOLD" and _panel.threshold_spin.value == 17, "saved user command and policy controls restore on the same readable incident")
var restored_labels: Array[String] = []
for id: StringName in [manual_one, manual_two]:
_select_presentation_option(id)
restored_labels.append(_panel.raid_option.get_item_text(_panel.raid_option.selected))
_expect(restored_labels == manual_labels, "duplicate occurrence labels retain the same order after restoration")
_select_presentation_option(saved_id)
var title_before_failed_load := _panel.summary_label.text
_expect(not _runtime.load_from_path(_path + "_presentation_absent").get(&"ok", true), "failed load remains explicit during source-label acceptance")
_expect(_panel.selected_raid_id == saved_id and _panel.summary_label.text == title_before_failed_load, "failed read preserves readable selection and title")
for frame: int in 4: await process_frame
_expect(not _panel.raid_option.fit_to_longest_item and _panel.raid_option.clip_text, "raid selector cannot force window width from an internal or future long label")
_expect(_main.get("defense_manager").size.x <= 520 and _panel.raid_option.get_global_rect().size.x <= _main.get("defense_manager").size.x, "actual 520px defense dialog and selector stay within their intended width")
_expect(_panel.raid_option.get_item_tooltip(_panel.raid_option.selected) == _panel.raid_option.get_item_text(_panel.raid_option.selected), "clipped selector offers the full human-readable text instead of a technical ID")
func _select_presentation_option(raid_id: StringName) -> void:
for index: int in _panel.raid_option.item_count:
if _panel.raid_option.get_item_metadata(index) == raid_id:
_panel.raid_option.select(index)
_panel.raid_option.item_selected.emit(index)
return
_expect(false, "requested presentation raid exists in actual option metadata")
func _presentation_raid_definition() -> Dictionary:
return {
&"entry_points": [&"ui.presentation.disclosed.gate"], &"objective_ids": [_facility_id],
&"round_minutes": 5, &"max_duration_minutes": 120,
&"retreat_policy": &"HOLD", &"defender_retreat_threshold": 0,
&"terrain_bonus": 0, &"command_bonus": 0, &"surprise_penalty": 0, &"multiple_entry_penalty": 0,
&"waves": [{&"wave_id": &"ui.presentation.disclosed.wave", &"threat_strength": 5, &"max_rounds": 1, &"retreat_threshold": 0}],
}