Compare commits

...
5 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.6 98100da0d0 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:00:23 +01:00
jpmschweitzerandClaude Opus 4.6 de136fc1a5 refactor(client): unify duplicate YAML parsers into YamlParser (#560)
Extract shared YamlParser utility (client/scripts/util/yaml_parser.gd)
with parse() for nested typed dicts and parse_flat() for dotted-key
string format. UIStrings._parse_yaml() and ChecklistEvaluator's inline
parser both delegate to YamlParser, removing ~140 lines of duplication.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:00:05 +01:00
jpmschweitzerandClaude Opus 4.6 964bb9c459 refactor(client): extract SnapshotEventRouter from main.gd (#559)
New SnapshotEventRouter class (46 lines) provides callable-based
snapshot dispatch via register(), register_always(), and dispatch().
main.gd _process() now calls _router.dispatch(snapshot) instead of
15+ inline if-has blocks. Handlers registered in _ready().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:59:56 +01:00
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
jpmschweitzerandClaude Opus 4.6 c8de1a0629 refactor(client): make stationary_ticks and zone_id server-authoritative (#557)
apply_snapshot() now reads stationary_ticks and zone_id directly from
the server snapshot when present (D-020 compliance). Client-side
accumulation and tile lookup retained as deprecated fallbacks until
the server populates these fields. Protocol.gd extended with decode
paths and TODO markers for the server team.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:59:38 +01:00
15 changed files with 1172 additions and 265 deletions
+6
View File
@@ -11,9 +11,15 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
- D-094: Spatial hierarchy — chunk (64×64 sim) → block (128×128 sim) → district (4×4 blocks, 256×256 visual), supersedes D-014 estimate
- D-095: Horizon stations and transport lore — span gates (human-built, dual-use), horizon stations (alien-built, 4-8 apertures), "The Ring" per-system naming, sequential hop travel, The Loop internal tram
- Generator architecture workshop brief (ticket #562) — top-down pipeline for district generation, targeting Q-036 resolution
- SnapshotEventRouter — callable-based snapshot dispatch replaces inline if-has blocks in main.gd (#559)
- YamlParser shared utility — unified YAML parsing for UI strings and checklist conditions (#560)
### Changed
- Sova station profile updated — horizon gates located at The Krenn Ring (800 AU), not on Station Sova; Admin Hub houses transit processing facility only
- game_state.gd: stationary_ticks and zone_id now read from server snapshot with deprecated client-side fallbacks (#557, D-020)
- dialogue_box.gd: decoupled from GameState and AudioManager via signals — zero direct autoload references (#558, D-020)
- main.gd: snapshot dispatch via SnapshotEventRouter, dialogue signal coordinator handlers (#559, #558)
- ui_strings.gd and checklist_evaluator.gd: delegate to YamlParser, ~140 lines of duplication removed (#560)
## [v0.1.19] — 2026-02-25
+36 -18
View File
@@ -109,14 +109,17 @@ var medium_sound_events: Array = []
var close_sound_events: Array = []
# D-071 (#530): Consecutive ticks without player position change.
# Incremented per snapshot in apply_snapshot(). Reset to 0 on movement.
# D-020: Server-authoritative — read from snapshot "stationary_ticks" field.
# Fallback: client-side accumulation (deprecated, remove when server populates field).
# ListeningFocus boost activates at 30+ ticks (main.gd manages the dip).
var stationary_ticks: int = 0
# DEPRECATED: Only used by client-side accumulation fallback. Remove with fallback.
var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previous position
# D-073 (#529): Server-authoritative zone_id from the player's current tile.
# Extracted in apply_snapshot() — avoids O(N) tile scan in main.gd per Tyre review.
# Empty string when zone_id field absent (server hasn't shipped OQ-09 yet).
# D-020: Read directly from snapshot "zone_id" field.
# Fallback: client-side tile lookup (deprecated, remove when server populates field).
# Empty string when zone_id field absent.
var current_zone_id: String = ""
func apply_snapshot(snapshot: Dictionary) -> void:
@@ -140,12 +143,19 @@ func apply_snapshot(snapshot: Dictionary) -> void:
push_warning("GameState: no Player entity found in %d entities" % [
visible_entities.size()])
# D-071 (#530): Track consecutive stationary ticks for ListeningFocus boost.
# Compares current player_position against previous snapshot's position.
if player_position == _prev_player_position:
stationary_ticks += 1
# D-020/D-071 (#530): Server-authoritative stationary_ticks for ListeningFocus boost.
# Prefer server-sent value; fall back to client-side accumulation until server populates.
if snapshot.has("stationary_ticks") and snapshot.stationary_ticks is int:
# D-020: direct field assignment from server-authoritative snapshot.
stationary_ticks = snapshot.stationary_ticks
else:
stationary_ticks = 0
# DEPRECATED fallback — client-side accumulation. Remove when server sends
# "stationary_ticks" in ObserverSnapshot (D-020 violation: derives behavior-
# driving state on the client). Server tracks this in ListeningFocus component.
if player_position == _prev_player_position:
stationary_ticks += 1
else:
stationary_ticks = 0
_prev_player_position = player_position
# Tiles for rendering: test mode sends "tiles", live server sends tile data in "visible_tiles"
@@ -295,16 +305,24 @@ func apply_snapshot(snapshot: Dictionary) -> void:
if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary:
player_knowledge = snapshot.player_knowledge
# D-073 (#529): O(1) zone_id lookup. Build coord→tile dict from member visible_tiles
# (populated above from either "tiles" test-mode key or "visible_tiles" live key).
# Must use the member var, not snapshot.visible_tiles, so test mode is covered.
var _tile_by_coord: Dictionary = {}
for vtile in visible_tiles:
if vtile is Dictionary and vtile.has("x") and vtile.has("y"):
_tile_by_coord[Vector2i(vtile.x, vtile.y)] = vtile
var player_pos_key := Vector2i(int(player_position.x), int(player_position.y))
var player_tile = _tile_by_coord.get(player_pos_key, null)
current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
# D-020/D-073 (#529): Server-authoritative zone_id for zone ambient crossfade.
# Prefer server-sent top-level value; fall back to client-side tile lookup until
# server populates top-level "zone_id" in ObserverSnapshot.
if snapshot.has("zone_id") and snapshot.zone_id is String:
# D-020: direct field assignment from server-authoritative snapshot.
current_zone_id = snapshot.zone_id
else:
# DEPRECATED fallback — client-side tile lookup. Remove when server sends
# top-level "zone_id" in ObserverSnapshot (D-020 violation: derives zone
# identity on the client via tile iteration). Server sends zone_id per
# VisibleTile but not as a top-level snapshot field.
var _tile_by_coord: Dictionary = {}
for vtile in visible_tiles:
if vtile is Dictionary and vtile.has("x") and vtile.has("y"):
_tile_by_coord[Vector2i(vtile.x, vtile.y)] = vtile
var player_pos_key := Vector2i(int(player_position.x), int(player_position.y))
var player_tile = _tile_by_coord.get(player_pos_key, null)
current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
# v2: visible_tiles with visibility sectors
# Derives visible_positions when not explicitly provided (real server mode)
+2 -40
View File
@@ -49,44 +49,6 @@ func reload() -> void:
## Parse YAML with arbitrary nesting depth.
## Returns flat Dictionary with dotted keys: { "section.sub.key": "value" }.
## Delegates to YamlParser.parse_flat() (#560).
static func _parse_yaml(text: String) -> Dictionary:
var strings := {}
var stack: Array = [] # [[indent, key], ...]
for line in text.split("\n"):
var stripped := line.strip_edges(false, true)
if stripped.is_empty() or stripped.begins_with("#"):
continue
var indent := line.length() - line.lstrip(" ").length()
var content := stripped.strip_edges()
var colon_pos := content.find(":")
if colon_pos < 0:
continue
var key := content.substr(0, colon_pos).strip_edges()
var val := content.substr(colon_pos + 1).strip_edges()
# Trailing comment without a value — treat as section header
if val.begins_with("#"):
val = ""
# Pop sections at same or deeper indent
while stack.size() > 0 and stack.back()[0] >= indent:
stack.pop_back()
if val.is_empty():
# Section header — push onto stack
stack.push_back([indent, key])
else:
# Leaf value — extract from quotes or strip inline comment
if val.begins_with("\""):
var end_quote := val.find("\"", 1)
if end_quote > 0:
val = val.substr(1, end_quote - 1)
else:
val = val.substr(1)
else:
var comment_pos := val.find(" #")
if comment_pos >= 0:
val = val.substr(0, comment_pos).strip_edges()
var dotted_key := ""
for entry in stack:
dotted_key += entry[1] + "."
dotted_key += key
strings[dotted_key] = val
return strings
return YamlParser.parse_flat(text)
+3 -105
View File
@@ -233,12 +233,7 @@ func _find_entity(entity_id: int) -> bool:
return false
# -- YAML parsing (checklist-specific) -----------------------------------------
# Handles the constrained checklist YAML format: top-level key:value pairs,
# a conditions array of flat dictionaries. No nested arrays or anchors.
#
# Limitation: unquoted values containing " #" are truncated at the comment marker.
# Use quoted strings ("value # with hash") if values must contain literal hashes.
# -- YAML parsing --------------------------------------------------------------
func _load_checklist_file(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
@@ -252,103 +247,6 @@ func _load_checklist_file(path: String) -> Dictionary:
return parse_checklist_yaml(text)
## Delegates to YamlParser.parse() (#560).
static func parse_checklist_yaml(text: String) -> Dictionary:
var result := {}
var conditions: Array = []
var current_item: Dictionary = {}
var in_conditions := false
for line in text.split("\n"):
var stripped := line.strip_edges(false, true)
if stripped.is_empty() or stripped.strip_edges().begins_with("#"):
continue
var indent := line.length() - line.lstrip(" ").length()
var content := stripped.strip_edges()
# Detect conditions: array header
if content == "conditions:":
in_conditions = true
continue
if not in_conditions:
# Top-level key: value
var colon := content.find(":")
if colon >= 0:
var key := content.substr(0, colon).strip_edges()
var val_str := content.substr(colon + 1).strip_edges()
result[key] = _parse_value(val_str)
else:
if content.begins_with("- "):
# New array item — flush previous
if not current_item.is_empty():
conditions.append(current_item)
current_item = {}
var rest := content.substr(2).strip_edges()
var colon := rest.find(":")
if colon >= 0:
var key := rest.substr(0, colon).strip_edges()
var val_str := rest.substr(colon + 1).strip_edges()
current_item[key] = _parse_value(val_str)
elif indent >= 2 and not current_item.is_empty():
# Continuation of current array item
var colon := content.find(":")
if colon >= 0:
var key := content.substr(0, colon).strip_edges()
var val_str := content.substr(colon + 1).strip_edges()
current_item[key] = _parse_value(val_str)
elif indent == 0:
# Back to top level — shouldn't happen in valid checklist YAML
in_conditions = false
if not current_item.is_empty():
conditions.append(current_item)
current_item = {}
var colon := content.find(":")
if colon >= 0:
var key := content.substr(0, colon).strip_edges()
var val_str := content.substr(colon + 1).strip_edges()
result[key] = _parse_value(val_str)
# Flush last item
if not current_item.is_empty():
conditions.append(current_item)
if not conditions.is_empty():
result["conditions"] = conditions
return result
static func _parse_value(val: String) -> Variant:
if val.is_empty():
return ""
# Strip inline comments (not inside quotes)
if not val.begins_with("\""):
var comment_pos := val.find(" #")
if comment_pos >= 0:
val = val.substr(0, comment_pos).strip_edges()
# Quoted string
if val.begins_with("\""):
var end_quote := val.find("\"", 1)
if end_quote > 0:
return val.substr(1, end_quote - 1)
return val.substr(1)
# Boolean
if val == "true":
return true
if val == "false":
return false
# Float (contains decimal point)
if val.contains(".") and val.is_valid_float():
return val.to_float()
# Integer
if val.is_valid_int():
return val.to_int()
# Plain string
return val
return YamlParser.parse(text)
+86 -86
View File
@@ -33,6 +33,7 @@ var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (
var _teleport_in_progress: bool = false # #501/#117: forces camera snap (not lerp) on next _process frame
var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival
var _current_zone: String = "" # D-073 (#529): zone tracking for ambient crossfades
var _router: SnapshotEventRouter # #559: callable-based snapshot dispatch
const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost activates
@@ -65,11 +66,51 @@ func _ready() -> void:
dialogue_box.confrontation_monologue.connect(_on_confrontation_monologue)
dialogue_box.pause_requested.connect(_on_dialogue_pause_requested)
dialogue_box.unpause_requested.connect(_on_dialogue_unpause_requested)
# D-020 (#558): Decoupled signals — coordinator routes state changes.
dialogue_box.dialogue_state_changed.connect(_on_dialogue_state_changed)
dialogue_box.audio_dip_requested.connect(_on_audio_dip_requested)
dialogue_box.audio_dip_cleared.connect(_on_audio_dip_cleared)
# #496: Print gauntlet session summary on disconnect
if gauntlet_hud:
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
# #559: Register snapshot dispatch handlers — replaces inline dispatch in _process().
_router = SnapshotEventRouter.new()
# Always-run: child nodes that update from GameState on every snapshot tick.
if world_renderer:
_router.register_always(world_renderer.update_from_state)
_router.register_always(_propagate_insert_state)
_router.register_always(_update_interaction_list)
if inventory_grid:
_router.register_always(inventory_grid.update_from_state)
if stance_indicator:
_router.register_always(stance_indicator.update_from_state)
if fog_entities:
_router.register_always(fog_entities.update_from_state)
_router.register_always(_play_recognition_chimes)
if gauntlet_hud:
_router.register_always(gauntlet_hud.update_from_state)
if checklist_overlay:
_router.register_always(checklist_overlay.update_from_state)
if time_display:
_router.register_always(time_display.update_from_state)
if journal_panel:
_router.register_always(journal_panel.update_from_state)
if debug_overlay:
_router.register_always(debug_overlay.update_from_state)
_router.register_always(_play_close_sound_events)
_router.register_always(_update_zone)
_router.register_always(_update_listening_focus)
_router.register_always(_consume_examine_result)
# Keyed: consume methods guarded by specific snapshot fields.
_router.register("current_monologue", _consume_monologue)
_router.register("current_dialogue", _consume_dialogue)
_router.register("conversation_events", _consume_conversation_events)
_router.register("conversation_ended", _consume_conversation_ended)
_router.register("dialogue_response", _consume_dialogue_response)
_router.register("save_result", _consume_save_result)
func _process(delta: float) -> void:
# Main game loop: poll snapshot, apply state, flush input
@@ -89,92 +130,9 @@ func _process(delta: float) -> void:
camera.global_position = GameState.player_position * Constants.TILE_SIZE
_camera_anchored = true
# Update renderers with new state
if world_renderer and world_renderer.has_method("update_from_state"):
world_renderer.update_from_state()
# OQ-07 (#522): propagate insert state to all z-layer-6 display nodes.
# Cursor shape still fires (D-056 option a) — only verb labels suppressed.
var insert_state := GameState.insert_active
if cursor_renderer and cursor_renderer.has_method("set_insert_active"):
cursor_renderer.set_insert_active(insert_state)
if interaction_list and interaction_list.has_method("set_insert_active"):
interaction_list.set_insert_active(insert_state)
if interaction_prompt and interaction_prompt.has_method("set_insert_active"):
interaction_prompt.set_insert_active(insert_state)
if minimap and minimap.has_method("set_insert_active"):
minimap.set_insert_active(insert_state)
# D-057: Update interaction list from game state
# Suppress during dialogue — player is in conversation, verb list is noise
if interaction_list and interaction_list.has_method("update_from_state"):
if dialogue_box and dialogue_box.is_dialogue_active():
if interaction_list.is_showing():
interaction_list.hide_list()
else:
interaction_list.update_from_state()
# D-065: Update inventory grid
if inventory_grid and inventory_grid.has_method("update_from_state"):
inventory_grid.update_from_state()
# D-053: Update stance indicator
if stance_indicator and stance_indicator.has_method("update_from_state"):
stance_indicator.update_from_state()
# D-059/D-060: Update fog entity visualization (#431)
if fog_entities and fog_entities.has_method("update_from_state"):
fog_entities.update_from_state()
# D-067: Recognition chime — fire sfx_monologue_chime on first fog recognition
_play_recognition_chimes()
# #496: Update gauntlet HUD (room timer + personal bests)
if gauntlet_hud and gauntlet_hud.has_method("update_from_state"):
gauntlet_hud.update_from_state()
# #503: Update checklist overlay (auto-checklist progress tracking)
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
checklist_overlay.update_from_state()
# #263: Update time display (D-013, D-031)
if time_display and time_display.has_method("update_from_state"):
time_display.update_from_state()
# #264: Update journal panel — auto-close on dialogue, refresh if open
if journal_panel and journal_panel.has_method("update_from_state"):
journal_panel.update_from_state()
# #511: Update debug overlay (F3 toggle, dev tool)
if debug_overlay and debug_overlay.has_method("update_from_state"):
debug_overlay.update_from_state()
# D-018 #125: Play close-range sound events via positional 2D audio
_play_close_sound_events()
# D-073 (#529): Zone ambient crossfade — detect player tile zone, trigger set_zone on change.
_update_zone()
# D-071 (#530): ListeningFocus boost — stationary 30+ ticks boosts WorldSFX.
# Only activates when no dialogue/confrontation dip is active (D-070).
_update_listening_focus()
# #174: Show examine result if server sent one this tick (#242)
_consume_examine_result()
# Show monologue if server sent one this tick (#414)
_consume_monologue()
# D-061: Show dialogue if server sent one this tick (#434)
_consume_dialogue()
# #535: Consume overheard conversation events and responses
_consume_conversation_events()
_consume_conversation_ended()
_consume_dialogue_response()
# #554: Show save/load result notification
_consume_save_result()
# #559: Dispatch snapshot to registered handlers (router pattern).
# Always-run handlers update child nodes; keyed handlers fire for present fields.
_router.dispatch(snapshot)
# Track camera to player (D-015: locked, fixed-north).
# #117: Manual exponential smoothing — same pattern as EntityRenderer.LERP_SPEED.
@@ -247,6 +205,32 @@ func _process(delta: float) -> void:
_pending_record_inputs.clear()
# OQ-07 (#522): Propagate insert state to all z-layer-6 display nodes.
# Cursor shape still fires (D-056 option a) — only verb labels suppressed.
func _propagate_insert_state() -> void:
var insert_state := GameState.insert_active
if cursor_renderer:
cursor_renderer.set_insert_active(insert_state)
if interaction_list:
interaction_list.set_insert_active(insert_state)
if interaction_prompt:
interaction_prompt.set_insert_active(insert_state)
if minimap:
minimap.set_insert_active(insert_state)
# D-057: Update interaction list from game state.
# Suppress during dialogue — player is in conversation, verb list is noise.
func _update_interaction_list() -> void:
if not interaction_list:
return
if dialogue_box and dialogue_box.is_dialogue_active():
if interaction_list.is_showing():
interaction_list.hide_list()
else:
interaction_list.update_from_state()
# D-018 #125: Play close-range sound events — fired once per snapshot tick.
# Each event is passed to AudioManager.play_sound_event() for 2D positional playback
# on the WorldSFX bus. Events with no registered asset are silently skipped (D-038).
@@ -456,6 +440,22 @@ func _on_dialogue_dismissed() -> void:
})
# D-020 (#558): Coordinator handles dialogue state changes from dialogue_box.
# Synchronous signal — GameState.dialogue_active updates same frame (D-064).
func _on_dialogue_state_changed(active: bool) -> void:
GameState.dialogue_active = active
# D-020 (#558): Coordinator routes audio dip requests from dialogue_box.
func _on_audio_dip_requested(profile: String) -> void:
AudioManager.apply_dip(profile)
# D-020 (#558): Coordinator routes audio dip clear from dialogue_box.
func _on_audio_dip_cleared() -> void:
AudioManager.clear_dip()
# #496: Finalize gauntlet stats on disconnect
func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud:
+20
View File
@@ -244,6 +244,24 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"error": raw_save.get("error"),
}
# TODO(server): Send stationary_ticks in ObserverSnapshot (D-071, D-020).
# Server already tracks this in ListeningFocus component (server/src/simulation/listening.rs).
# When server populates this field, client-side accumulation fallback in game_state.gd
# can be removed — apply_snapshot() should contain only direct field assignments.
var stationary_ticks: Variant = null
var raw_st: Variant = raw.get("stationary_ticks")
if raw_st != null:
stationary_ticks = int(raw_st)
# TODO(server): Send top-level zone_id string in ObserverSnapshot (D-073, D-020).
# Server sends zone_id per VisibleTile but not as a top-level snapshot field.
# When server populates this, client-side tile iteration fallback in game_state.gd
# can be removed — apply_snapshot() should contain only direct field assignments.
var zone_id: Variant = null
var raw_zid: Variant = raw.get("zone_id")
if raw_zid is String:
zone_id = raw_zid
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
# {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}],
# facts: [{fact_id, confidence, source, state, acquired_tick}]}
@@ -302,6 +320,8 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"examine_result": examine_result,
"player_knowledge": player_knowledge,
"save_result": save_result,
"stationary_ticks": stationary_ticks,
"zone_id": zone_id,
}
+45
View File
@@ -0,0 +1,45 @@
class_name SnapshotEventRouter
## Routes snapshot fields to registered handlers (D-020, #559).
##
## Decouples main.gd from knowing which child node handles which snapshot field.
## Handlers are registered in main.gd._ready(); dispatch() is called each snapshot tick.
##
## Two handler types:
## - Keyed: called only when the snapshot contains a specific field.
## - Always: called every dispatch (every snapshot tick), regardless of fields present.
##
## All handlers are zero-argument callables — they read from GameState directly.
## This preserves GameState as the single source of truth post-apply_snapshot().
## Keyed handlers: field_name → Array[Callable]
## Array per field allows multiple handlers on the same key (e.g., two consumers of same data).
var _keyed: Dictionary = {} # String → Array[Callable]
## Always handlers: called every dispatch regardless of snapshot content.
var _always: Array[Callable] = []
## Register a handler for a specific snapshot field key.
## Handler is called (with no arguments) when snapshot.has(field) is true.
## Multiple handlers per field are supported — they run in registration order.
func register(field: String, handler: Callable) -> void:
if not _keyed.has(field):
_keyed[field] = []
_keyed[field].append(handler)
## Register a handler that runs every dispatch tick (not keyed to a field).
## Use for child nodes that update from GameState on every snapshot, e.g. update_from_state().
func register_always(handler: Callable) -> void:
_always.append(handler)
## Dispatch a snapshot: call always handlers first, then keyed handlers for present fields.
## Handlers read from GameState.* directly — apply_snapshot() must be called before dispatch().
func dispatch(snapshot: Dictionary) -> void:
for handler in _always:
handler.call()
for field in _keyed:
if snapshot.has(field):
for handler in _keyed[field]:
handler.call()
+163
View File
@@ -0,0 +1,163 @@
class_name YamlParser
## Shared YAML parser — common subset used by ui_strings.gd and checklist_evaluator.gd.
##
## Handles: nested sections (maps), arrays of dict items (- key: val), typed values.
## Returns a hierarchical Dictionary. Use flatten() to convert to dotted-key format
## (as UIStrings._parse_yaml() requires).
##
## Limitations: single-line values only; no YAML anchors/aliases; no flow syntax.
## String values: quotes stripped. Booleans, ints, and floats are type-inferred.
##
## Spec ref: #560 (Sprint 20 — unify duplicate YAML parsers), D-030 (testability).
## Parse YAML text into a hierarchical Dictionary.
## Nested sections become nested dicts. Array items (- key: val) become Arrays.
## Values are type-inferred: bool, int, float, or String.
static func parse(text: String) -> Dictionary:
var root: Dictionary = {}
# Stack: [{indent: int, key: String}] — path of open section headers
var stack: Array = []
# Array state
var current_array: Variant = null # Array being built, or null
var current_item: Variant = null # Dict being built for current array item, or null
var array_parent_indent: int = -1 # indent of the "key:" line that owns the array
for raw_line in text.split("\n"):
var stripped := raw_line.strip_edges(false, true)
if stripped.is_empty() or stripped.strip_edges().begins_with("#"):
continue
var indent: int = raw_line.length() - raw_line.lstrip(" ").length()
var content: String = stripped.strip_edges()
# --- Array item (- key: value) ---
if content.begins_with("- "):
# First item: convert parent section's {} placeholder to []
if current_array == null and stack.size() > 0:
var parent := _node_at(root, stack, true)
var arr_key: String = stack.back()["key"]
var new_arr: Array = []
parent[arr_key] = new_arr
current_array = new_arr
array_parent_indent = stack.back()["indent"]
# Flush previous item and start a new one
if current_item != null:
current_array.append(current_item)
current_item = {}
var rest: String = content.substr(2).strip_edges()
var colon: int = rest.find(":")
if colon >= 0:
var k: String = rest.substr(0, colon).strip_edges()
var v: String = rest.substr(colon + 1).strip_edges()
current_item[k] = _parse_value(v)
continue
# --- Continuation line within current array item ---
if current_array != null and indent > array_parent_indent:
var colon: int = content.find(":")
if colon >= 0 and current_item != null:
var k: String = content.substr(0, colon).strip_edges()
var v: String = content.substr(colon + 1).strip_edges()
current_item[k] = _parse_value(v)
continue
# --- End of array (indent has returned to array level or above) ---
if current_array != null:
if current_item != null:
current_array.append(current_item)
current_item = null
current_array = null
array_parent_indent = -1
if stack.size() > 0:
stack.pop_back() # pop the array-owning key
# --- Regular key: value or section header ---
var colon: int = content.find(":")
if colon < 0:
continue
var key: String = content.substr(0, colon).strip_edges()
var val_str: String = content.substr(colon + 1).strip_edges()
# Pop sections at the same or deeper indent (we're back at a shallower level)
while stack.size() > 0 and stack.back()["indent"] >= indent:
stack.pop_back()
var node: Dictionary = _node_at(root, stack, false)
if val_str.is_empty() or val_str.begins_with("#"):
# Section header — create nested dict (may become Array if - items follow)
node[key] = {}
stack.push_back({"indent": indent, "key": key})
else:
node[key] = _parse_value(val_str)
# Flush the last array item if the file ended inside an array
if current_array != null and current_item != null:
current_array.append(current_item)
return root
## Convenience: parse text and flatten to dotted-key format in one call.
## Used by UIStrings._parse_yaml() — equivalent to flatten(parse(text)).
static func parse_flat(text: String) -> Dictionary:
return flatten(parse(text))
## Flatten a hierarchical dict to dotted-key format (for UIStrings compatibility).
## {"a": {"b": "v"}} → {"a.b": "v"}
## Arrays are skipped — dotted-key format does not represent them.
## All values are converted to String (UIStrings stores display text, not typed data).
static func flatten(d: Dictionary, prefix: String = "") -> Dictionary:
var result: Dictionary = {}
for k in d:
var full_key: String = (prefix + "." if not prefix.is_empty() else "") + str(k)
var v = d[k]
if v is Dictionary:
result.merge(flatten(v, full_key))
elif not v is Array:
result[full_key] = str(v)
return result
## Parse a single YAML value string into a typed GDScript value.
## Strips inline comments, handles quoted strings, infers bool/int/float/String.
static func _parse_value(val: String) -> Variant:
if val.is_empty():
return ""
# Strip inline comment outside quotes
if not val.begins_with("\""):
var comment_pos: int = val.find(" #")
if comment_pos >= 0:
val = val.substr(0, comment_pos).strip_edges()
# Quoted string — extract content between quotes
if val.begins_with("\""):
var end_quote: int = val.find("\"", 1)
if end_quote > 0:
return val.substr(1, end_quote - 1)
return val.substr(1)
# Boolean
if val == "true": return true
if val == "false": return false
# Float (must have decimal point)
if val.contains(".") and val.is_valid_float():
return val.to_float()
# Integer
if val.is_valid_int():
return val.to_int()
# Plain string
return val
## Navigate root following the stack key path.
## parent=true: navigate one level less (returns the parent node, not the leaf).
static func _node_at(root: Dictionary, stack: Array, parent: bool) -> Dictionary:
var node: Dictionary = root
var depth: int = stack.size() - (1 if parent else 0)
for i in range(depth):
var k: String = stack[i]["key"]
if node.has(k) and node[k] is Dictionary:
node = node[k]
else:
break
return node
+74
View File
@@ -596,3 +596,77 @@ func test_is_dialogue_active_true_after_show_dialogue() -> void:
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
+143
View File
@@ -0,0 +1,143 @@
## Sprint 20 #558: dialogue_box.gd decoupling tests.
##
## Verifies D-020 compliance: dialogue_box.gd emits signals instead of mutating
## GameState or calling AudioManager directly. main.gd wires the signal handlers.
##
## D-030: fixture-based, server-free, no subprocess required.
class_name TestDialogueSprint20
extends GdUnitTestSuite
func _make_dialogue_box() -> Control:
if not ResourceLoader.exists("res://ui/dialogue_box.tscn"):
push_warning("TestDialogueSprint20: 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 before_test() -> void:
GameState.dialogue_active = false
func after_test() -> void:
GameState.dialogue_active = false
# -- dialogue_state_changed signal -------------------------------------------
func test_show_dialogue_emits_dialogue_state_changed_true() -> void:
## show_dialogue() must emit dialogue_state_changed(true) not mutate GameState directly.
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
var received: Variant = null
box.dialogue_state_changed.connect(func(active: bool): received = active)
box.show_dialogue("NPC", "Hello.", [])
assert_that(received).override_failure_message(
"show_dialogue() must emit dialogue_state_changed(true) (#558)"
).is_equal(true)
func test_show_dialogue_does_not_mutate_game_state_directly() -> void:
## Without a connected handler, GameState.dialogue_active must stay false.
## Proves dialogue_box.gd has zero direct GameState mutation (D-020 #558).
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
GameState.dialogue_active = false
box.show_dialogue("NPC", "Hello.", [])
assert_bool(GameState.dialogue_active).override_failure_message(
"dialogue_box must not mutate GameState.dialogue_active directly (D-020 #558)"
).is_false()
func test_hide_dialogue_emits_dialogue_state_changed_false() -> void:
## hide_dialogue() must emit dialogue_state_changed(false).
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
box.show_dialogue("NPC", "Hello.", [])
var received: Variant = null
box.dialogue_state_changed.connect(func(active: bool): received = active)
box.hide_dialogue()
assert_that(received).override_failure_message(
"hide_dialogue() must emit dialogue_state_changed(false) (#558)"
).is_equal(false)
# -- audio_dip_requested / audio_dip_cleared signals -------------------------
func test_show_dialogue_emits_audio_dip_requested_dialogue() -> void:
## show_dialogue() must emit audio_dip_requested("dialogue") not call AudioManager.
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
var received_profile: Variant = null
box.audio_dip_requested.connect(func(profile: String): received_profile = profile)
box.show_dialogue("NPC", "Hello.", [])
assert_that(received_profile).override_failure_message(
"show_dialogue() must emit audio_dip_requested('dialogue') (#558)"
).is_equal("dialogue")
func test_show_dialogue_does_not_call_audio_manager_directly() -> void:
## Without a connected handler, AudioManager state must be unchanged by show_dialogue().
## Verifies no direct AudioManager call in dialogue_box.gd (D-020 #558).
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
var dip_before := AudioManager.get_active_dip()
box.show_dialogue("NPC", "Hello.", [])
var dip_after := AudioManager.get_active_dip()
assert_str(dip_after).override_failure_message(
"dialogue_box must not call AudioManager.apply_dip() directly (D-020 #558)"
).is_equal(dip_before)
func test_hide_dialogue_emits_audio_dip_cleared() -> void:
## Ending a conversation must emit audio_dip_cleared not call AudioManager directly.
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
box.show_dialogue("NPC", "Hello.", [])
var cleared := false
box.audio_dip_cleared.connect(func(): cleared = true)
box.hide_dialogue()
assert_bool(cleared).override_failure_message(
"hide_dialogue() must emit audio_dip_cleared (#558)"
).is_true()
func test_audio_dip_cleared_count_on_conversation_end() -> void:
## Verify audio_dip_cleared fires when conversation ends.
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
var cleared_count := 0
box.audio_dip_cleared.connect(func(): cleared_count += 1)
box.show_dialogue("NPC", "Speak.", [])
box.hide_dialogue()
assert_int(cleared_count).override_failure_message(
"audio_dip_cleared must fire at least once when conversation ends (#558)"
).is_greater_equal(1)
# -- coordinator wiring verification -----------------------------------------
func test_signal_handler_wires_game_state() -> void:
## Simulate main.gd: connect dialogue_state_changed to update GameState.dialogue_active.
## Verifies the coordinator pattern works end-to-end (D-020 #558).
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
box.dialogue_state_changed.connect(func(active: bool): GameState.dialogue_active = active)
box.show_dialogue("NPC", "Hello.", [])
assert_bool(GameState.dialogue_active).override_failure_message(
"With handler wired, GameState.dialogue_active must be true after show_dialogue (#558)"
).is_true()
box.hide_dialogue()
assert_bool(GameState.dialogue_active).override_failure_message(
"With handler wired, GameState.dialogue_active must be false after hide_dialogue (#558)"
).is_false()
+63 -4
View File
@@ -19,6 +19,8 @@ func before_each() -> void:
GameState.player_stance = "Walk"
GameState.player_inventory = []
GameState.stationary_ticks = 0
GameState._prev_player_position = Vector2(-1e9, -1e9)
GameState.current_zone_id = ""
GameState.insert_active = true
@@ -115,19 +117,49 @@ func test_apply_snapshot_inventory_absent_clears_list() -> void:
assert_that(GameState.player_inventory.size()).is_equal(0)
# -- Stationary tick counter (D-071) -------------------------------------
# -- Stationary ticks: server-authoritative (D-020/D-071) -----------------
func test_stationary_ticks_increments_when_player_position_unchanged() -> void:
func test_stationary_ticks_from_server_snapshot() -> void:
## D-020: When server sends stationary_ticks, client reads it directly.
GameState.apply_snapshot({"tick": 1, "entities": [], "stationary_ticks": 42})
assert_int(GameState.stationary_ticks).is_equal(42)
func test_stationary_ticks_server_value_overrides_client_accumulation() -> void:
## D-020: Server value takes priority — client must not accumulate on top of it.
var snapshot := {
"tick": 1,
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
"stationary_ticks": 5,
}
GameState.apply_snapshot(snapshot)
GameState.apply_snapshot(snapshot) # same position, but server sends 5 again
assert_int(GameState.stationary_ticks).is_equal(5) # server value, not 6
func test_stationary_ticks_missing_field_degrades_gracefully() -> void:
## D-020 fallback: when server omits stationary_ticks, no crash, defaults to 0.
GameState.stationary_ticks = 0
GameState.apply_snapshot({"tick": 1, "entities": []})
# No crash; field retains a valid integer value.
assert_int(GameState.stationary_ticks).is_greater_equal(0)
# -- Stationary ticks: deprecated client-side fallback (D-071) -----------
func test_stationary_ticks_fallback_increments_when_position_unchanged() -> void:
## Deprecated fallback: client accumulates when server omits the field.
var snapshot := {
"tick": 1,
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
}
GameState.apply_snapshot(snapshot) # first call: position changes from ZERO
GameState.apply_snapshot(snapshot) # first call: position changes from sentinel
GameState.apply_snapshot(snapshot) # second call: position unchanged → +1
assert_int(GameState.stationary_ticks).is_greater(0)
func test_stationary_ticks_resets_on_player_movement() -> void:
func test_stationary_ticks_fallback_resets_on_movement() -> void:
## Deprecated fallback: client resets on movement when server omits the field.
var s1 := {
"tick": 1,
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
@@ -143,6 +175,33 @@ func test_stationary_ticks_resets_on_player_movement() -> void:
assert_int(GameState.stationary_ticks).is_equal(0)
# -- Zone ID: server-authoritative (D-020/D-073) -------------------------
func test_zone_id_from_server_snapshot() -> void:
## D-020: When server sends top-level zone_id, client reads it directly.
GameState.apply_snapshot({"tick": 1, "entities": [], "zone_id": "zone_alpha"})
assert_that(GameState.current_zone_id).is_equal("zone_alpha")
func test_zone_id_server_value_overrides_tile_derivation() -> void:
## D-020: Server top-level zone_id takes priority over tile-derived zone_id.
var snapshot := {
"tick": 1,
"entities": [{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
"tiles": [{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_from_tile"}],
"zone_id": "zone_from_server",
}
GameState.apply_snapshot(snapshot)
assert_that(GameState.current_zone_id).is_equal("zone_from_server")
func test_zone_id_missing_field_degrades_gracefully() -> void:
## D-020 fallback: when server omits zone_id and no tiles match, defaults to "".
GameState.current_zone_id = ""
GameState.apply_snapshot({"tick": 1, "entities": []})
assert_that(GameState.current_zone_id).is_equal("")
# -- insert_active (OQ-07, #522) -----------------------------------------
func test_apply_snapshot_insert_active_false() -> void:
+164
View File
@@ -0,0 +1,164 @@
## Sprint 20 #557: GameState.apply_snapshot() refactor tests.
##
## Verifies D-020 compliance: apply_snapshot() reads server-authoritative values
## for stationary_ticks and zone_id directly from the snapshot when present,
## and degrades gracefully when the server has not yet added these fields.
##
## Does NOT replace test_game_state.gd or test_snapshot_zone_id.gd — those cover
## existing behaviour. This file covers the new code paths added in Sprint 20.
##
## D-030: fixture-based, server-free, no subprocess required.
class_name TestGameStateSprint20
extends GdUnitTestSuite
func before_each() -> void:
GameState.stationary_ticks = 0
GameState._prev_player_position = Vector2(-1e9, -1e9)
GameState.current_zone_id = ""
GameState.player_position = Vector2.ZERO
GameState.visible_tiles = []
GameState.visible_positions = {}
GameState.visibility_sectors = {}
# -- stationary_ticks: server-authoritative path (D-020) ----------------------
func test_stationary_ticks_reads_server_value_when_present() -> void:
# When snapshot includes stationary_ticks, apply_snapshot() must use the
# server value directly without client-side accumulation (D-020).
var snapshot := {
"tick": 5,
"entities": [
{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"stationary_ticks": 42,
}
GameState.apply_snapshot(snapshot)
assert_int(GameState.stationary_ticks).override_failure_message(
"apply_snapshot() must read stationary_ticks=42 from snapshot (D-020)"
).is_equal(42)
func test_stationary_ticks_server_value_does_not_accumulate() -> void:
# Server-sent value must be assigned directly — NOT added to existing value.
# Two calls with stationary_ticks=10 must yield 10, not 20.
var snapshot := {
"tick": 1,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"stationary_ticks": 10,
}
GameState.apply_snapshot(snapshot)
GameState.apply_snapshot(snapshot)
assert_int(GameState.stationary_ticks).override_failure_message(
"Server value must be assigned directly, not accumulated (D-020)"
).is_equal(10)
func test_stationary_ticks_server_can_reset_to_zero() -> void:
# Server sends 0 when player moves — client must accept this reset.
GameState.stationary_ticks = 50
var snapshot := {
"tick": 2,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"stationary_ticks": 0,
}
GameState.apply_snapshot(snapshot)
assert_int(GameState.stationary_ticks).override_failure_message(
"Server reset to 0 must override client-held value"
).is_equal(0)
# -- stationary_ticks: DEPRECATED fallback (graceful degradation) --------------
func test_stationary_ticks_fallback_when_field_absent() -> void:
# When snapshot has no stationary_ticks, client-side accumulation must still
# run (backward compat until server ships field). No crash.
var snapshot := {
"tick": 1,
"entities": [
{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
}
GameState.apply_snapshot(snapshot) # position changes from ZERO → resets to 0
GameState.apply_snapshot(snapshot) # position unchanged → increments
assert_int(GameState.stationary_ticks).override_failure_message(
"Client-side fallback must increment stationary_ticks when field absent"
).is_greater(0)
func test_apply_snapshot_missing_stationary_ticks_no_crash() -> void:
# Snapshots lacking stationary_ticks must not crash apply_snapshot().
var snapshot := {"tick": 1, "entities": []}
# No assertion needed beyond confirming no exception is raised.
GameState.apply_snapshot(snapshot)
assert_bool(true).is_true()
# -- zone_id: server-authoritative path (D-020/D-073) -------------------------
func test_current_zone_id_reads_server_value_when_present() -> void:
# When snapshot includes top-level zone_id, apply_snapshot() must use it
# directly without tile lookup (D-020).
var snapshot := {
"tick": 1,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"zone_id": "zone_server_direct",
}
GameState.apply_snapshot(snapshot)
assert_str(GameState.current_zone_id).override_failure_message(
"apply_snapshot() must read zone_id='zone_server_direct' from snapshot (D-020)"
).is_equal("zone_server_direct")
func test_current_zone_id_server_value_overrides_tile_data() -> void:
# When snapshot has both zone_id and visible_tiles with a different zone,
# the top-level zone_id field takes priority.
var snapshot := {
"tick": 1,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"zone_id": "zone_from_server",
"visible_tiles": [
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_from_tile"},
],
}
GameState.apply_snapshot(snapshot)
assert_str(GameState.current_zone_id).override_failure_message(
"Top-level zone_id must override tile-derived zone when both present"
).is_equal("zone_from_server")
# -- zone_id: DEPRECATED fallback (graceful degradation) ----------------------
func test_current_zone_id_fallback_to_tile_lookup_when_absent() -> void:
# When snapshot lacks top-level zone_id, tile lookup fallback must run.
# Existing test_snapshot_zone_id.gd covers detailed scenarios; this is a
# smoke test confirming the fallback path still works after Sprint 20 refactor.
var snapshot := {
"tick": 1,
"entities": [
{"entity_id": 1, "x": 3.0, "y": 3.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"visible_tiles": [
{"x": 3, "y": 3, "z": 0, "type": "floor", "zone_id": "zone_tile_fallback"},
],
}
GameState.apply_snapshot(snapshot)
assert_str(GameState.current_zone_id).override_failure_message(
"Tile lookup fallback must populate zone_id when top-level field absent"
).is_equal("zone_tile_fallback")
func test_apply_snapshot_missing_zone_id_no_crash() -> void:
# Snapshots lacking both zone_id and visible_tiles must not crash.
var snapshot := {"tick": 1, "entities": []}
GameState.apply_snapshot(snapshot)
assert_str(GameState.current_zone_id).is_equal("")
@@ -0,0 +1,89 @@
## SnapshotEventRouter unit tests (#559).
## Verifies callable-based dispatch: keyed handlers receive correct field values,
## always handlers run on every dispatch, absent fields don't trigger handlers.
##
## D-030: fixture-based, server-free, no subprocess required.
class_name TestSnapshotEventRouter
extends GdUnitTestSuite
# -- Keyed handlers -----------------------------------------------------------
func test_keyed_handler_called_when_field_present() -> void:
var router := SnapshotEventRouter.new()
var received: Array = []
router.register("current_monologue", func(): received.append("monologue"))
router.dispatch({"current_monologue": {"text": "Test."}})
assert_that(received.size()).is_equal(1)
assert_that(received[0]).is_equal("monologue")
func test_keyed_handler_not_called_when_field_absent() -> void:
var router := SnapshotEventRouter.new()
var received: Array = []
router.register("current_monologue", func(): received.append("monologue"))
router.dispatch({"tick": 1})
assert_that(received.size()).is_equal(0)
func test_multiple_keyed_handlers_same_field() -> void:
## Multiple handlers on the same key run in registration order.
var router := SnapshotEventRouter.new()
var order: Array = []
router.register("save_result", func(): order.append("first"))
router.register("save_result", func(): order.append("second"))
router.dispatch({"save_result": {"success": true}})
assert_that(order).is_equal(["first", "second"])
func test_keyed_handlers_multiple_fields() -> void:
## Each keyed handler fires only for its registered field.
var router := SnapshotEventRouter.new()
var received: Array = []
router.register("current_monologue", func(): received.append("mono"))
router.register("current_dialogue", func(): received.append("dlg"))
router.dispatch({"current_monologue": {"text": "Hi."}})
assert_that(received).is_equal(["mono"])
received.clear()
router.dispatch({"current_dialogue": {"speech": "Hello."}, "current_monologue": {"text": "Hmm."}})
assert_bool(received.has("mono")).is_true()
assert_bool(received.has("dlg")).is_true()
# -- Always handlers ----------------------------------------------------------
func test_always_handler_called_on_every_dispatch() -> void:
var router := SnapshotEventRouter.new()
var count: Array[int] = [0]
router.register_always(func(): count[0] += 1)
router.dispatch({"tick": 1})
router.dispatch({"tick": 2})
router.dispatch({})
assert_int(count[0]).is_equal(3)
func test_always_handler_runs_before_keyed() -> void:
## Always handlers run before keyed handlers (dispatch order guarantee).
var router := SnapshotEventRouter.new()
var order: Array = []
router.register("current_monologue", func(): order.append("keyed"))
router.register_always(func(): order.append("always"))
router.dispatch({"current_monologue": {"text": "Hi."}})
assert_that(order[0]).is_equal("always")
assert_that(order[1]).is_equal("keyed")
# -- Empty dispatch ------------------------------------------------------------
func test_empty_snapshot_no_crash() -> void:
var router := SnapshotEventRouter.new()
router.register("foo", func(): pass)
router.register_always(func(): pass)
# Must not crash
router.dispatch({})
func test_no_handlers_no_crash() -> void:
var router := SnapshotEventRouter.new()
# Must not crash
router.dispatch({"tick": 1, "entities": []})
+261
View File
@@ -0,0 +1,261 @@
## YamlParser unit tests (#560).
## Verifies parse() (nested, typed) and parse_flat() (dotted keys, string values).
## Covers: maps, nested maps, arrays of dicts, type conversion, comments,
## quoted strings, empty input, edge cases.
##
## D-030: fixture-based, server-free, no subprocess required.
class_name TestYamlParser
extends GdUnitTestSuite
# -- parse(): basic key-value pairs -------------------------------------------
func test_parse_simple_key_value() -> void:
var result := YamlParser.parse("key: value")
assert_that(result["key"]).is_equal("value")
func test_parse_quoted_string() -> void:
var result := YamlParser.parse('key: "hello world"')
assert_that(result["key"]).is_equal("hello world")
func test_parse_empty_quoted_string() -> void:
var result := YamlParser.parse('key: ""')
assert_that(result["key"]).is_equal("")
func test_parse_integer_value() -> void:
var result := YamlParser.parse("count: 42")
assert_that(result["count"]).is_equal(42)
assert_that(typeof(result["count"])).is_equal(TYPE_INT)
func test_parse_negative_integer() -> void:
var result := YamlParser.parse("offset: -3")
assert_that(result["offset"]).is_equal(-3)
func test_parse_float_value() -> void:
var result := YamlParser.parse("radius: 2.5")
assert_that(typeof(result["radius"])).is_equal(TYPE_FLOAT)
assert_float(result["radius"]).is_equal_approx(2.5, 0.001)
func test_parse_boolean_true() -> void:
var result := YamlParser.parse("enabled: true")
assert_that(result["enabled"]).is_equal(true)
assert_that(typeof(result["enabled"])).is_equal(TYPE_BOOL)
func test_parse_boolean_false() -> void:
var result := YamlParser.parse("enabled: false")
assert_that(result["enabled"]).is_equal(false)
func test_parse_empty_input() -> void:
var result := YamlParser.parse("")
assert_that(result.size()).is_equal(0)
func test_parse_only_comments() -> void:
var result := YamlParser.parse("# comment\n# another")
assert_that(result.size()).is_equal(0)
func test_parse_inline_comment_stripped() -> void:
var result := YamlParser.parse("room_id: test # this is a comment")
assert_that(result["room_id"]).is_equal("test")
func test_parse_quoted_value_with_hash() -> void:
## Quoted strings preserve literal # characters.
var result := YamlParser.parse('color: "#e0e8ff"')
assert_that(result["color"]).is_equal("#e0e8ff")
func test_parse_value_with_colon() -> void:
var result := YamlParser.parse('time: "12:30"')
assert_that(result["time"]).is_equal("12:30")
# -- parse(): nested maps -----------------------------------------------------
func test_parse_nested_map() -> void:
var yaml := "section:\n key: value"
var result := YamlParser.parse(yaml)
assert_that(result.has("section")).is_true()
assert_that(result["section"] is Dictionary).is_true()
assert_that(result["section"]["key"]).is_equal("value")
func test_parse_deeply_nested() -> void:
var yaml := "a:\n b:\n c: deep"
var result := YamlParser.parse(yaml)
assert_that(result["a"]["b"]["c"]).is_equal("deep")
func test_parse_multiple_sections() -> void:
var yaml := "hud:\n mode: Mode\ninteraction:\n talk: Talk"
var result := YamlParser.parse(yaml)
assert_that(result["hud"]["mode"]).is_equal("Mode")
assert_that(result["interaction"]["talk"]).is_equal("Talk")
func test_parse_multiple_keys_per_section() -> void:
var yaml := "hud:\n a: 1\n b: 2\n c: 3"
var result := YamlParser.parse(yaml)
assert_that(result["hud"]["a"]).is_equal(1)
assert_that(result["hud"]["b"]).is_equal(2)
assert_that(result["hud"]["c"]).is_equal(3)
func test_parse_sibling_subsections() -> void:
var yaml := "states:\n a:\n x: 1\n b:\n x: 2"
var result := YamlParser.parse(yaml)
assert_that(result["states"]["a"]["x"]).is_equal(1)
assert_that(result["states"]["b"]["x"]).is_equal(2)
func test_parse_section_with_trailing_comment() -> void:
## "section: # comment" should be treated as a section header.
var yaml := "section: # comment\n key: value"
var result := YamlParser.parse(yaml)
assert_that(result["section"]["key"]).is_equal("value")
# -- parse(): arrays of dicts -------------------------------------------------
func test_parse_single_array_item() -> void:
var yaml := "conditions:\n - id: test-1\n type: near\n x: 10"
var result := YamlParser.parse(yaml)
assert_that(result.has("conditions")).is_true()
assert_that(result["conditions"] is Array).is_true()
assert_that(result["conditions"].size()).is_equal(1)
assert_that(result["conditions"][0]["id"]).is_equal("test-1")
assert_that(result["conditions"][0]["type"]).is_equal("near")
assert_that(result["conditions"][0]["x"]).is_equal(10)
func test_parse_multiple_array_items() -> void:
var yaml := "conditions:\n - id: a\n x: 1\n - id: b\n x: 2"
var result := YamlParser.parse(yaml)
assert_that(result["conditions"].size()).is_equal(2)
assert_that(result["conditions"][0]["id"]).is_equal("a")
assert_that(result["conditions"][1]["id"]).is_equal("b")
func test_parse_array_items_with_blank_lines() -> void:
var yaml := "conditions:\n - id: a\n x: 1\n\n - id: b\n x: 2"
var result := YamlParser.parse(yaml)
assert_that(result["conditions"].size()).is_equal(2)
func test_parse_top_level_plus_array() -> void:
## Checklist format: top-level key-value pairs followed by a conditions array.
var yaml := "room_id: warehouse\nconditions:\n - id: c1\n type: near\n x: 5"
var result := YamlParser.parse(yaml)
assert_that(result["room_id"]).is_equal("warehouse")
assert_that(result["conditions"].size()).is_equal(1)
assert_that(result["conditions"][0]["id"]).is_equal("c1")
func test_parse_array_typed_values() -> void:
var yaml := "items:\n - id: t\n x: 42\n radius: 2.5\n active: true"
var result := YamlParser.parse(yaml)
var item: Dictionary = result["items"][0]
assert_that(item["x"]).is_equal(42)
assert_that(typeof(item["radius"])).is_equal(TYPE_FLOAT)
assert_that(item["active"]).is_equal(true)
# -- parse_flat(): dotted keys -------------------------------------------------
func test_flat_simple() -> void:
var yaml := "section:\n key: value"
var result := YamlParser.parse_flat(yaml)
assert_that(result.has("section.key")).is_true()
assert_that(result["section.key"]).is_equal("value")
func test_flat_deeply_nested() -> void:
var yaml := "a:\n b:\n c: deep"
var result := YamlParser.parse_flat(yaml)
assert_that(result["a.b.c"]).is_equal("deep")
func test_flat_multiple_sections() -> void:
var yaml := "hud:\n mode: Mode\ninteraction:\n talk: Talk"
var result := YamlParser.parse_flat(yaml)
assert_that(result["hud.mode"]).is_equal("Mode")
assert_that(result["interaction.talk"]).is_equal("Talk")
func test_flat_values_are_strings() -> void:
## parse_flat returns all values as strings, unlike parse() which returns typed.
var yaml := "section:\n count: 42\n rate: 0.9\n active: true"
var result := YamlParser.parse_flat(yaml)
assert_that(result["section.count"]).is_equal("42")
assert_that(result["section.rate"]).is_equal("0.9")
assert_that(result["section.active"]).is_equal("true")
func test_flat_quoted_value() -> void:
var yaml := 'section:\n key: "hello world"'
var result := YamlParser.parse_flat(yaml)
assert_that(result["section.key"]).is_equal("hello world")
func test_flat_inline_comment() -> void:
var yaml := "hud:\n mode: Standard # default"
var result := YamlParser.parse_flat(yaml)
assert_that(result["hud.mode"]).is_equal("Standard")
func test_flat_empty_quoted_value() -> void:
var yaml := 'hud:\n prefix: "" # No prefix'
var result := YamlParser.parse_flat(yaml)
assert_that(result.has("hud.prefix")).is_true()
assert_that(result["hud.prefix"]).is_equal("")
func test_flat_skips_arrays() -> void:
## Arrays have no dotted-key representation — they are skipped in flat output.
var yaml := "room_id: test\nconditions:\n - id: c1\n x: 5"
var result := YamlParser.parse_flat(yaml)
assert_that(result.has("room_id")).is_true()
# No dotted keys for array contents
assert_that(result.has("conditions")).is_false()
assert_that(result.has("conditions.0")).is_false()
func test_flat_empty_input() -> void:
var result := YamlParser.parse_flat("")
assert_that(result.size()).is_equal(0)
# -- Dialogue theme format (regression) ----------------------------------------
func test_dialogue_theme_format() -> void:
## dialogue-theme.yaml has top-level values and one nested map (npc_colors).
var yaml := "player_color: \"#e0e8ff\"\nnpc_colors:\n 0: \"#4a9ebb\"\n 1: \"#6bc9a6\"\npassive_opacity: 0.9"
var flat := YamlParser.parse_flat(yaml)
assert_that(flat["player_color"]).is_equal("#e0e8ff")
assert_that(flat["npc_colors.0"]).is_equal("#4a9ebb")
assert_that(flat["npc_colors.1"]).is_equal("#6bc9a6")
assert_that(flat["passive_opacity"]).is_equal("0.9")
# -- Checklist format (regression) ---------------------------------------------
func test_checklist_format() -> void:
## checklist.yaml: top-level kv + conditions array with typed values.
var yaml := "room_id: inventory_warehouse\nconditions:\n - id: test-1\n condition_type: player_near\n x: 10\n y: 20\n radius: 3.0"
var result := YamlParser.parse(yaml)
assert_that(result["room_id"]).is_equal("inventory_warehouse")
var cond: Dictionary = result["conditions"][0]
assert_that(cond["id"]).is_equal("test-1")
assert_that(cond["condition_type"]).is_equal("player_near")
assert_that(cond["x"]).is_equal(10)
assert_that(cond["y"]).is_equal(20)
assert_that(typeof(cond["radius"])).is_equal(TYPE_FLOAT)
+17 -12
View File
@@ -15,6 +15,11 @@ signal dialogue_dismissed # Walk-away or conversation end
signal confrontation_monologue(text: String, duration: float) # D-063: beat monologue
signal pause_requested # D-061: auto-pause — main.gd routes through input recording (#507)
signal unpause_requested # D-061: auto-unpause
# D-020 (#558): Decoupled signals — dialogue_box emits, main.gd (coordinator) handles.
# Replaces direct GameState.dialogue_active mutation and AudioManager calls.
signal dialogue_state_changed(active: bool)
signal audio_dip_requested(profile: String)
signal audio_dip_cleared
@onready var panel: PanelContainer = $PanelContainer
@onready var dialogue_log: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/DialogueLog
@@ -286,10 +291,10 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi
# Show panel
_ensure_visible()
mouse_filter = Control.MOUSE_FILTER_STOP
GameState.dialogue_active = true # D-064: block movement while in conversation
dialogue_state_changed.emit(true) # D-064: coordinator blocks movement
# D-069: Dialogue dip
AudioManager.apply_dip("dialogue")
# D-069: Dialogue dip — coordinator routes to AudioManager
audio_dip_requested.emit("dialogue")
# D-061: auto-pause — signal to main.gd for input recording (#507)
pause_requested.emit()
@@ -311,14 +316,14 @@ func _end_player_conversation() -> void:
entry.timestamp_msec = now
_log_dirty = true
# D-069: Clear dialogue/confrontation dip
AudioManager.clear_dip()
# D-069: Clear dialogue/confrontation dip — coordinator routes to AudioManager
audio_dip_cleared.emit()
# D-061: unpause — signal to main.gd for input recording (#507)
unpause_requested.emit()
# D-064: unblock movement immediately — log entries stay visible but don't block input.
GameState.dialogue_active = false
# D-064: unblock movement immediately — coordinator handles GameState update.
dialogue_state_changed.emit(false)
# If no entries remain, hide the panel with fade.
if _log_entries.is_empty():
@@ -331,11 +336,11 @@ func hide_dialogue() -> void:
_end_player_conversation()
return # _end_player_conversation may call hide_dialogue if log is empty
GameState.dialogue_active = false
dialogue_state_changed.emit(false) # D-020: coordinator handles GameState update
func is_dialogue_active() -> bool:
return _in_player_conversation or GameState.dialogue_active
return _in_player_conversation
func has_active_entries() -> bool:
@@ -428,12 +433,12 @@ func _start_confrontation_beat(response_id: String, text: String) -> void:
_active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2)
confrontation_monologue.emit(UIStrings.get_text(CONFRONTATION_MONOLOGUE_KEY), CONFRONTATION_BEAT_DURATION)
AudioManager.apply_dip("confrontation")
audio_dip_requested.emit("confrontation") # D-069: coordinator routes to AudioManager
_beat_tween = create_tween()
_beat_tween.tween_interval(CONFRONTATION_BEAT_DURATION)
_beat_tween.tween_callback(func():
AudioManager.clear_dip()
audio_dip_cleared.emit() # D-069: coordinator routes to AudioManager
option_selected.emit(response_id, text)
_end_player_conversation()
)
@@ -443,7 +448,7 @@ func _cancel_beat() -> void:
if _beat_tween and _beat_tween.is_valid():
_beat_tween.kill()
_beat_tween = null
AudioManager.clear_dip()
audio_dip_cleared.emit() # D-069: coordinator routes to AudioManager
# -- Log rendering --