Files
settled-reach/client/tests/test_dialogue_sprint18.gd
T
jpmschweitzerandClaude Opus 4.6 6f9c97ca03 refactor(client): decouple dialogue_box from GameState and AudioManager (#558)
Replace 3 direct GameState.dialogue_active mutations and all
AudioManager.apply_dip/clear_dip calls with signals:
dialogue_state_changed, audio_dip_requested, audio_dip_cleared.
dialogue_box.gd now has zero references to GameState or AudioManager.
main.gd wires coordinator handlers in _ready() (D-020).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:59:47 +01:00

673 lines
25 KiB
GDScript
Raw 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.
## Sprint 18 — Dialogue UI hardening + examine result display (#174)
## Spec refs: D-061 (dialogue box), D-062 (invisible locked options), D-063 (confrontation),
## D-064 (walk-away), D-078 (overheard log)
##
## Test plan from joint.md:
## "Manual: examine result appears as overlay, auto-dismisses.
## Dialogue options confirmed: no locked/grayed options visible.
## Confrontation option in italic voice."
##
## Unit-testable coverage here:
## - D-062: no locked/grayed option mechanism in dialogue_box.gd
## - D-063: confrontation beat duration, signal, dim alpha
## - D-064: walk-away fires dialogue_dismissed signal
## - GameState: current_dialogue parsing
## - GameState: current_examine_result parsing (test-first — impl TBD in #174)
## - BBCode: escape contract (Hoshe #2 regression guard)
## - Dirty flag: _log_dirty optimization (Hoshe #1 regression guard)
class_name TestDialogueSprint18
extends GdUnitTestSuite
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
func _make_dialogue_box() -> Control:
if not ResourceLoader.exists("res://ui/dialogue_box.tscn"):
push_warning("TestDialogueSprint18: dialogue_box.tscn not found — scene tests skipped")
return null
var node: Control = load("res://ui/dialogue_box.tscn").instantiate()
add_child(node)
return node
func _make_options(texts: Array[String], confrontation_flags: Array[bool] = []) -> Array:
var opts: Array = []
for i in range(texts.size()):
var opt: Dictionary = {
"text": texts[i],
"response_id": "r%d" % i,
"priority": i,
}
if confrontation_flags.size() > i:
opt["confrontation"] = confrontation_flags[i]
opts.append(opt)
return opts
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func before_test() -> void:
GameState.current_dialogue = null
GameState.dialogue_active = false
if GameState.has("current_examine_result"):
GameState.current_examine_result = null
func after_test() -> void:
GameState.current_dialogue = null
GameState.dialogue_active = false
if GameState.has("current_examine_result"):
GameState.current_examine_result = null
# ---------------------------------------------------------------------------
# D-062: No locked/grayed options
## Per D-062: locked options are INVISIBLE — not shown at all.
## The client renders all received options as active, clickable labels.
## Server responsibility: omit locked options from the array.
## Test verifies: no disabled/locked styling is applied to any rendered option.
# ---------------------------------------------------------------------------
func test_d062_rendered_options_have_no_disabled_state() -> void:
## D-062: All options from server render as active controls.
## No option should have mouse_filter=IGNORE (which would indicate disabled).
var box := _make_dialogue_box()
if box == null: return
box.show_dialogue("NPC", "Hello.", _make_options(["Option A", "Option B", "Option C"]))
var options_container := box.get_node_or_null("PanelContainer/MarginContainer/VBoxContainer/OptionsContainer")
assert_that(options_container != null).override_failure_message("OptionsContainer must exist").is_true()
var labels := options_container.get_children()
assert_int(labels.size()).override_failure_message("3 options must render as 3 labels").is_equal(3)
for label in labels:
# MOUSE_FILTER_STOP = 0: active and clickable — correct for D-062
# MOUSE_FILTER_IGNORE = 2: would indicate disabled — D-062 violation
assert_int(label.mouse_filter).override_failure_message(
"Option '%s' must be mouse_filter=STOP (0), not IGNORE (2) — D-062 requires no locked options" % label.text
).is_not_equal(Control.MOUSE_FILTER_IGNORE)
box.queue_free()
func test_d062_no_lock_icon_children_on_options() -> void:
## D-062: Options must have no lock icon children (no TextureRect/Sprite2D children).
## Any child node on an option label would indicate a locked-option indicator.
var box := _make_dialogue_box()
if box == null: return
box.show_dialogue("NPC", "Speech.", _make_options(["Only option"]))
var options_container := box.get_node_or_null("PanelContainer/MarginContainer/VBoxContainer/OptionsContainer")
if options_container == null: box.queue_free(); return
var labels := options_container.get_children()
assert_int(labels.size()).is_greater(0)
for label in labels:
assert_int(label.get_child_count()).override_failure_message(
"Option label must have no child nodes — no lock icons, no decorators (D-062)"
).is_equal(0)
box.queue_free()
func test_d062_max_options_constant_is_three() -> void:
## D-061: max 3 response options. D-062: if >3 options arrive, only top 3 by priority
## are shown — server must omit locked ones, client only truncates to 3.
## Verify MAX_OPTIONS constant is locked at 3.
var box := _make_dialogue_box()
if box == null: return
assert_int(box.MAX_OPTIONS).override_failure_message(
"MAX_OPTIONS must be 3 per D-061 spec"
).is_equal(3)
box.queue_free()
func test_d062_server_sends_four_options_only_three_render() -> void:
## D-061/D-062: If server sends 4 options (shouldn't happen but guard),
## only top 3 by priority render. No 4th option appears.
var box := _make_dialogue_box()
if box == null: return
var opts := _make_options(["A", "B", "C", "D"])
# Assign explicit priorities so sort is deterministic
for i in range(opts.size()):
opts[i]["priority"] = i
box.show_dialogue("NPC", "Speech.", opts)
var options_container := box.get_node_or_null("PanelContainer/MarginContainer/VBoxContainer/OptionsContainer")
if options_container == null: box.queue_free(); return
assert_int(options_container.get_child_count()).override_failure_message(
"Only 3 options must render even when server sends 4 (D-061 truncation)"
).is_equal(3)
box.queue_free()
# ---------------------------------------------------------------------------
# D-063: Confrontation beat
## Confrontation options trigger a 1.5s pre-delivery monologue beat.
## Panel dims during beat. confrontation_monologue signal fires.
# ---------------------------------------------------------------------------
func test_d063_beat_duration_within_spec() -> void:
## D-063: The beat duration must be 12 seconds per spec.
## Current implementation: CONFRONTATION_BEAT_DURATION = 1.5s.
var box := _make_dialogue_box()
if box == null: return
assert_float(box.CONFRONTATION_BEAT_DURATION).override_failure_message(
"D-063: confrontation beat must be 1.02.0 seconds"
).is_between(1.0, 2.0)
box.queue_free()
func test_d063_dim_alpha_is_set() -> void:
## D-063: The dialogue box dims during the confrontation beat.
## CONFRONTATION_DIM_ALPHA must be below 1.0 (not full opacity).
var box := _make_dialogue_box()
if box == null: return
assert_float(box.CONFRONTATION_DIM_ALPHA).override_failure_message(
"D-063: confrontation dim alpha must be < 1.0 (panel visibly dims)"
).is_less(1.0)
assert_float(box.CONFRONTATION_DIM_ALPHA).override_failure_message(
"D-063: confrontation dim alpha must be > 0.0 (panel still visible)"
).is_greater(0.0)
box.queue_free()
func test_d063_confrontation_signal_fires_on_confrontation_option() -> void:
## D-063: Selecting a confrontation option fires confrontation_monologue signal.
## This delivers the 1-2 second internal monologue beat to MonologueDisplay.
var box := _make_dialogue_box()
if box == null: return
var signal_fired := false
var received_text := ""
box.confrontation_monologue.connect(func(text: String, _dur: float):
signal_fired = true
received_text = text
)
# Show dialogue with one confrontation option
var opts := _make_options(["I know what you did."], [true])
box.show_dialogue("NPC", "Everything is fine.", opts)
# Press option 1 (index 0)
box._on_option_pressed(0)
assert_bool(signal_fired).override_failure_message(
"D-063: confrontation_monologue signal must fire when confrontation option is selected"
).is_true()
box.queue_free()
func test_d063_non_confrontation_option_does_not_fire_beat_signal() -> void:
## D-063: Standard (non-confrontation) options must NOT fire confrontation_monologue.
var box := _make_dialogue_box()
if box == null: return
var signal_fired := false
box.confrontation_monologue.connect(func(_text: String, _dur: float):
signal_fired = true
)
var opts := _make_options(["A normal response."], [false])
box.show_dialogue("NPC", "Hello.", opts)
box._on_option_pressed(0)
assert_bool(signal_fired).override_failure_message(
"D-063: confrontation_monologue must NOT fire for standard options"
).is_false()
box.queue_free()
# ---------------------------------------------------------------------------
# D-064: Walk-away mechanic
## WASD during active conversation fires dialogue_dismissed signal.
# ---------------------------------------------------------------------------
func test_d064_walk_away_actions_constant_not_empty() -> void:
## D-064: _WALK_AWAY_ACTIONS must include at least the 8 movement directions.
## Prevents accidental empty-array regression.
var box := _make_dialogue_box()
if box == null: return
assert_int(box._WALK_AWAY_ACTIONS.size()).override_failure_message(
"D-064: _WALK_AWAY_ACTIONS must list movement directions (minimum 4)"
).is_greater_equal(4)
box.queue_free()
func test_d064_walk_away_actions_include_cardinal_directions() -> void:
## D-064: All four cardinal directions (WASD) must be walk-away triggers.
var box := _make_dialogue_box()
if box == null: return
var actions: Array = box._WALK_AWAY_ACTIONS
for required in [&"move_north", &"move_south", &"move_east", &"move_west"]:
assert_bool(required in actions).override_failure_message(
"D-064: '%s' must be in _WALK_AWAY_ACTIONS" % required
).is_true()
box.queue_free()
func test_d064_dialogue_dismissed_signal_connection() -> void:
## D-064: dialogue_dismissed signal must exist on DialogueBox.
## The signal drives walk-away behavior in main.gd.
var box := _make_dialogue_box()
if box == null: return
assert_bool(box.has_signal("dialogue_dismissed")).override_failure_message(
"D-064: dialogue_dismissed signal must exist on DialogueBox"
).is_true()
box.queue_free()
# ---------------------------------------------------------------------------
# GameState: current_dialogue snapshot parsing
## D-061: current_dialogue is set from snapshot, null when absent.
# ---------------------------------------------------------------------------
func test_gamestate_current_dialogue_set_from_snapshot() -> void:
## apply_snapshot with current_dialogue dict populates the field.
GameState.apply_snapshot({
"tick": 1,
"current_dialogue": {
"npc_name": "Kael Davan",
"npc_entity_id": 42,
"speech": "You don't belong here.",
"options": [{"text": "I'm just passing through.", "response_id": "r001", "priority": 1}],
},
})
assert_that(GameState.current_dialogue).is_not_null()
assert_that(GameState.current_dialogue.get("npc_name")).is_equal("Kael Davan")
func test_gamestate_current_dialogue_null_when_absent() -> void:
## apply_snapshot without current_dialogue clears the field.
## Prevents stale dialogue from persisting across ticks.
GameState.current_dialogue = {"npc_name": "Ghost", "speech": "Stale."}
GameState.apply_snapshot({"tick": 2})
assert_that(GameState.current_dialogue).is_null()
func test_gamestate_current_dialogue_null_when_non_dict() -> void:
## Non-dict current_dialogue is rejected — defensive against malformed server data.
GameState.apply_snapshot({"tick": 1, "current_dialogue": "not-a-dict"})
assert_that(GameState.current_dialogue).is_null()
func test_gamestate_current_dialogue_options_survive_roundtrip() -> void:
## The options array must survive snapshot parsing for DialogueBox to render them.
var options := [
{"text": "A", "response_id": "r1", "priority": 1},
{"text": "B", "response_id": "r2", "priority": 2},
]
GameState.apply_snapshot({
"tick": 1,
"current_dialogue": {
"npc_name": "NPC",
"npc_entity_id": 1,
"speech": "Choose.",
"options": options,
},
})
assert_that(GameState.current_dialogue).is_not_null()
var parsed_opts: Array = GameState.current_dialogue.get("options", [])
assert_int(parsed_opts.size()).is_equal(2)
assert_that(parsed_opts[0].get("response_id")).is_equal("r1")
# ---------------------------------------------------------------------------
# GameState: current_examine_result snapshot parsing (test-first, #174)
## Sprint 18: examine verb returns character-filtered observation text.
## Field: examine_result: {entity_id: int, text: String, confidence: String} | null
## GameState must expose current_examine_result for the overlay display node.
# ---------------------------------------------------------------------------
func test_gamestate_examine_result_field_exists() -> void:
## GameState must have a current_examine_result field (Sprint 18, #174).
## Fails until Stig adds the field to game_state.gd.
assert_bool(GameState.has("current_examine_result")).override_failure_message(
"GameState must have 'current_examine_result' field (Sprint 18 #174 — add to game_state.gd)"
).is_true()
func test_gamestate_examine_result_null_by_default() -> void:
## current_examine_result defaults to null (no examine active).
if not GameState.has("current_examine_result"):
push_warning("test_gamestate_examine_result_null_by_default: field not yet added — skip")
return
GameState.current_examine_result = null
assert_that(GameState.current_examine_result).is_null()
func test_gamestate_examine_result_set_from_snapshot() -> void:
## apply_snapshot with examine_result dict populates current_examine_result.
## Wire format (joint.md): {entity_id: int, text: String, confidence: String}
if not GameState.has("current_examine_result"):
push_warning("test_gamestate_examine_result_set_from_snapshot: field not yet added — skip")
return
GameState.apply_snapshot({
"tick": 5,
"examine_result": {
"entity_id": 12,
"text": "Kael Davan — nervous energy. He's scanning exits.",
"confidence": "KnowsOf",
},
})
assert_that(GameState.current_examine_result).is_not_null()
assert_that(GameState.current_examine_result.get("text")).contains("Kael Davan")
func test_gamestate_examine_result_null_when_absent() -> void:
## apply_snapshot without examine_result must clear the field.
## Prevents stale examine overlay persisting beyond auto-dismiss window.
if not GameState.has("current_examine_result"):
push_warning("test_gamestate_examine_result_null_when_absent: field not yet added — skip")
return
GameState.current_examine_result = {"entity_id": 5, "text": "Stale.", "confidence": "Suspects"}
GameState.apply_snapshot({"tick": 6})
assert_that(GameState.current_examine_result).is_null()
func test_gamestate_examine_result_null_when_non_dict() -> void:
## Malformed examine_result (not a dict) must be rejected.
if not GameState.has("current_examine_result"):
push_warning("test_gamestate_examine_result_null_when_non_dict: field not yet added — skip")
return
GameState.apply_snapshot({"tick": 1, "examine_result": "bad-value"})
assert_that(GameState.current_examine_result).is_null()
func test_gamestate_examine_result_entity_id_survives_roundtrip() -> void:
## entity_id is needed to anchor the overlay above the correct entity.
if not GameState.has("current_examine_result"):
push_warning("test_gamestate_examine_result_entity_id_survives_roundtrip: field not yet added — skip")
return
GameState.apply_snapshot({
"tick": 1,
"examine_result": {"entity_id": 99, "text": "Observed.", "confidence": "Direct"},
})
assert_int(GameState.current_examine_result.get("entity_id", -1)).is_equal(99)
# ---------------------------------------------------------------------------
# BBCode injection guard (regression: Hoshe #2)
## Server-sourced text containing BBCode brackets must be escaped.
## Note: dialogue_box.gd has no class_name — call _escape_bbcode via instance.
# ---------------------------------------------------------------------------
func test_escape_bbcode_brackets_in_server_text() -> void:
## _escape_bbcode must convert '[' to '[lb]' to prevent BBCode injection.
## Regression test: a malicious NPC name like "[wave]Evil[/wave]" must render
## as plain text in the dialogue log.
var box := _make_dialogue_box()
if box == null: return
var escaped: String = box._escape_bbcode("[wave]Evil NPC[/wave]")
assert_that(escaped).is_not_equal("[wave]Evil NPC[/wave]")
assert_that(escaped).contains("[lb]")
assert_bool(escaped.begins_with("[")).is_false()
box.queue_free()
func test_escape_bbcode_plain_text_unchanged() -> void:
## Non-BBCode text must not be modified by _escape_bbcode.
var box := _make_dialogue_box()
if box == null: return
var plain := "Kael Davan"
assert_that(box._escape_bbcode(plain)).is_equal(plain)
box.queue_free()
func test_escape_bbcode_multiple_brackets() -> void:
## Multiple '[' characters all get escaped.
var box := _make_dialogue_box()
if box == null: return
var text := "[b]Bold[/b] and [i]italic[/i]"
var escaped: String = box._escape_bbcode(text)
assert_bool(escaped.contains("[b]")).is_false()
assert_bool(escaped.contains("[i]")).is_false()
box.queue_free()
# ---------------------------------------------------------------------------
# Log dirty flag optimization (regression: Hoshe #1)
## _log_dirty prevents per-frame O(n) BBCode rebuilds.
# ---------------------------------------------------------------------------
func test_log_dirty_false_on_init() -> void:
## _log_dirty starts false — no rebuild needed before first line.
var box := _make_dialogue_box()
if box == null: return
assert_bool(box._log_dirty).override_failure_message(
"_log_dirty must be false on init — no unnecessary rebuild"
).is_false()
box.queue_free()
func test_log_dirty_set_after_append_line() -> void:
## Appending a line sets _log_dirty = true.
var box := _make_dialogue_box()
if box == null: return
box.append_line("NPC", "Player", "Hello.", false)
assert_bool(box._log_dirty).override_failure_message(
"_log_dirty must be true after append_line to trigger rebuild next _process"
).is_true()
box.queue_free()
func test_log_dirty_cleared_after_process() -> void:
## After _process(), _log_dirty is cleared (rebuild done).
var box := _make_dialogue_box()
if box == null: return
box.append_line("NPC", "Player", "One line.", false)
assert_bool(box._log_dirty).is_true()
box._process(0.0)
assert_bool(box._log_dirty).override_failure_message(
"_log_dirty must be false after _process (rebuild consumed the flag)"
).is_false()
box.queue_free()
# ---------------------------------------------------------------------------
# D-061: Dialogue box size constraints
# ---------------------------------------------------------------------------
func test_d061_max_height_ratio_is_twenty_percent() -> void:
## D-061: dialogue box must occupy max 20% viewport height.
var box := _make_dialogue_box()
if box == null: return
assert_float(box.MAX_HEIGHT_RATIO).override_failure_message(
"D-061: MAX_HEIGHT_RATIO must be 0.2 (20% viewport height)"
).is_equal_approx(0.2, 0.001)
box.queue_free()
func test_d061_fade_in_is_200ms() -> void:
## D-061: fade-in on dialogue appearance is 0.2s.
var box := _make_dialogue_box()
if box == null: return
assert_float(box.FADE_IN).override_failure_message(
"D-061: FADE_IN must be 0.2s"
).is_equal_approx(0.2, 0.001)
box.queue_free()
func test_d064_fade_out_is_300ms() -> void:
## D-064: walk-away fade-out is 300ms per spec.
var box := _make_dialogue_box()
if box == null: return
assert_float(box.FADE_OUT).override_failure_message(
"D-064: FADE_OUT must be 0.3s (300ms walk-away fade)"
).is_equal_approx(0.3, 0.001)
box.queue_free()
# ---------------------------------------------------------------------------
# Passive log entry (D-078: overheard NPC-NPC)
# ---------------------------------------------------------------------------
func test_passive_entry_uses_bar_glyph() -> void:
## D-078: Overheard NPC-NPC lines get ┃ prefix (PASSIVE_GLYPH).
## Verifies the glyph constant is the correct Unicode bar character.
var box := _make_dialogue_box()
if box == null: return
assert_that(box.PASSIVE_GLYPH).override_failure_message(
"D-078: PASSIVE_GLYPH must be ┃ (U+2503) + space"
).is_equal("\u2503 ")
box.queue_free()
func test_passive_entry_appended_and_marked_is_passive() -> void:
## D-078: append_conversation_event creates a passive log entry.
var box := _make_dialogue_box()
if box == null: return
var event := {
"speaker_id": 10, "target_id": 11,
"speaker_name": "Guard A", "target_name": "Guard B",
"occluded_line": "Did you see the detective?",
"speaker_color_index": 0, "target_color_index": 1,
}
box.append_conversation_event(event)
assert_int(box._log_entries.size()).is_equal(1)
assert_bool(box._log_entries[0].is_passive).override_failure_message(
"D-078: overheard entry must be marked is_passive = true"
).is_true()
box.queue_free()
func test_passive_entry_empty_text_not_appended() -> void:
## D-078: Conversation event with empty occluded_line is silently dropped.
var box := _make_dialogue_box()
if box == null: return
box.append_conversation_event({
"speaker_id": 1, "target_id": 2,
"speaker_name": "A", "target_name": "B",
"occluded_line": "",
})
assert_int(box._log_entries.size()).override_failure_message(
"D-078: empty occluded_line must not produce a log entry"
).is_equal(0)
box.queue_free()
# ---------------------------------------------------------------------------
# has_active_entries / is_dialogue_active
# ---------------------------------------------------------------------------
func test_has_active_entries_false_on_init() -> void:
var box := _make_dialogue_box()
if box == null: return
assert_bool(box.has_active_entries()).is_false()
box.queue_free()
func test_has_active_entries_true_after_append() -> void:
var box := _make_dialogue_box()
if box == null: return
box.append_line("NPC", "Player", "Hey.", false)
assert_bool(box.has_active_entries()).is_true()
box.queue_free()
func test_is_dialogue_active_false_on_init() -> void:
var box := _make_dialogue_box()
if box == null: return
assert_bool(box.is_dialogue_active()).is_false()
box.queue_free()
func test_is_dialogue_active_true_after_show_dialogue() -> void:
var box := _make_dialogue_box()
if box == null: return
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
assert_bool(box.is_dialogue_active()).is_true()
box.queue_free()
# ---------------------------------------------------------------------------
# D-020 (#558): Signal decoupling — dialogue_box emits signals instead of
# directly mutating GameState or calling AudioManager.
# ---------------------------------------------------------------------------
func test_dialogue_state_changed_emits_true_on_show() -> void:
## D-020: show_dialogue() must emit dialogue_state_changed(true).
var box := _make_dialogue_box()
if box == null: return
var received: Array = []
box.dialogue_state_changed.connect(func(active): received.append(active))
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
assert_bool(received.has(true)).override_failure_message(
"dialogue_state_changed(true) must be emitted on show_dialogue"
).is_true()
box.queue_free()
func test_dialogue_state_changed_emits_false_on_hide() -> void:
## D-020: hide_dialogue() must emit dialogue_state_changed(false).
var box := _make_dialogue_box()
if box == null: return
var received: Array = []
box.dialogue_state_changed.connect(func(active): received.append(active))
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
box.hide_dialogue()
assert_bool(received.has(false)).override_failure_message(
"dialogue_state_changed(false) must be emitted on hide_dialogue"
).is_true()
box.queue_free()
func test_audio_dip_requested_emits_dialogue_on_show() -> void:
## D-020: show_dialogue() must emit audio_dip_requested("dialogue").
var box := _make_dialogue_box()
if box == null: return
var received: Array = []
box.audio_dip_requested.connect(func(profile): received.append(profile))
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
assert_bool(received.has("dialogue")).override_failure_message(
"audio_dip_requested('dialogue') must be emitted on show_dialogue"
).is_true()
box.queue_free()
func test_audio_dip_cleared_emits_on_hide() -> void:
## D-020: hide_dialogue() must emit audio_dip_cleared.
var box := _make_dialogue_box()
if box == null: return
var cleared := [false]
box.audio_dip_cleared.connect(func(): cleared[0] = true)
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
box.hide_dialogue()
assert_bool(cleared[0]).override_failure_message(
"audio_dip_cleared must be emitted on hide_dialogue"
).is_true()
box.queue_free()
func test_no_direct_game_state_mutation() -> void:
## D-020: dialogue_box must not directly mutate GameState.dialogue_active.
## After show_dialogue, GameState.dialogue_active should remain unchanged
## (only the coordinator updates it via signal handler).
var box := _make_dialogue_box()
if box == null: return
GameState.dialogue_active = false
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
assert_bool(GameState.dialogue_active).override_failure_message(
"GameState.dialogue_active must NOT be mutated directly by dialogue_box"
).is_false()
box.queue_free()
GameState.dialogue_active = false