From 0e6cc4eef897b290df69983373e1848267480fc8 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 18:19:25 +0100 Subject: [PATCH 1/3] =?UTF-8?q?feat(audio):=20Sprint=2013=20=E2=80=94=20Au?= =?UTF-8?q?dioManager,=20zone=20crossfade,=20dip=20profiles,=20murmur=20wi?= =?UTF-8?q?ring=20(#528,=20#529,=20#530,=20#533)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AudioManager: volume slider persistence (ConfigFile), settings UI with 5 teal-bordered sliders, default_bus_layout.tres for editor. Zone crossfade: defensive zone_id read from snapshot tiles, 1.5-2s ambient tween, auto-activates when server ships OQ-09. Dip profiles: dialogue dip in show/hide_dialogue, ListeningFocus 30-tick gate via stationary_ticks in game_state.gd. NPC murmur: client plumbing for event-driven World SFX playback, no-ops until audio asset arrives. Co-Authored-By: Claude Opus 4.6 --- client/default_bus_layout.tres | 43 ++++++ client/project.godot | 4 + client/scenes/main.tscn | 6 +- client/scripts/autoloads/audio_manager.gd | 104 ++++++++++++-- client/scripts/autoloads/game_state.gd | 14 ++ client/scripts/main.gd | 51 +++++++ client/ui/dialogue_box.gd | 8 ++ client/ui/settings_dialog.gd | 159 ++++++++++++++++++++++ client/ui/settings_dialog.tscn | 14 ++ server/Cargo.lock | 2 +- 10 files changed, 395 insertions(+), 10 deletions(-) create mode 100644 client/default_bus_layout.tres create mode 100644 client/ui/settings_dialog.gd create mode 100644 client/ui/settings_dialog.tscn diff --git a/client/default_bus_layout.tres b/client/default_bus_layout.tres new file mode 100644 index 000000000..ebe4e8c59 --- /dev/null +++ b/client/default_bus_layout.tres @@ -0,0 +1,43 @@ +[gd_resource type="AudioBusLayout" format=3] + +; D-068: 5-bus audio architecture — Music, Ambient, WorldSFX, PlayerActions, UISounds. +; All buses route to Master. Volumes managed at runtime by AudioManager autoload. +; AudioManager._setup_buses() creates any missing buses on startup (no-op if present). + +[resource] +bus/0/name = "Master" +bus/0/solo = false +bus/0/mute = false +bus/0/bypass_fx = false +bus/0/volume_db = 0.0 +bus/0/send = &"" +bus/1/name = "Music" +bus/1/solo = false +bus/1/mute = false +bus/1/bypass_fx = false +bus/1/volume_db = 0.0 +bus/1/send = &"Master" +bus/2/name = "Ambient" +bus/2/solo = false +bus/2/mute = false +bus/2/bypass_fx = false +bus/2/volume_db = 0.0 +bus/2/send = &"Master" +bus/3/name = "WorldSFX" +bus/3/solo = false +bus/3/mute = false +bus/3/bypass_fx = false +bus/3/volume_db = 0.0 +bus/3/send = &"Master" +bus/4/name = "PlayerActions" +bus/4/solo = false +bus/4/mute = false +bus/4/bypass_fx = false +bus/4/volume_db = 0.0 +bus/4/send = &"Master" +bus/5/name = "UISounds" +bus/5/solo = false +bus/5/mute = false +bus/5/bypass_fx = false +bus/5/volume_db = 0.0 +bus/5/send = &"Master" diff --git a/client/project.godot b/client/project.godot index af0fb088e..d87dd633c 100644 --- a/client/project.godot +++ b/client/project.godot @@ -24,6 +24,10 @@ UIStrings="*res://scripts/autoloads/ui_strings.gd" FogState="*res://scripts/autoloads/fog_state.gd" AudioManager="*res://scripts/autoloads/audio_manager.gd" +[audio] + +buses/default_bus_layout="res://default_bus_layout.tres" + [gui] theme/custom="res://assets/theme/game_theme.tres" diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index fa52fcf7d..c77b4e6b7 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=21 format=3 uid="uid://bswrmh7w8dbgm"] +[gd_scene load_steps=22 format=3 uid="uid://bswrmh7w8dbgm"] [ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] [ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"] @@ -20,6 +20,7 @@ [ext_resource type="PackedScene" path="res://ui/gauntlet_hud.tscn" id="17_gauntlet"] [ext_resource type="PackedScene" path="res://ui/checklist_overlay.tscn" id="18_checklist"] [ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"] +[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"] [node name="Game" type="Node2D"] script = ExtResource("1_main") @@ -162,3 +163,6 @@ layer = 30 ; #495: WRONG button (F12) — bug report capture dialog [node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("19_bugreport")] + +; #528: Audio settings dialog — 5-bus volume sliders, ESC/OPEN_MENU to toggle +[node name="SettingsDialog" parent="ModalLayer" instance=ExtResource("21_settings")] diff --git a/client/scripts/autoloads/audio_manager.gd b/client/scripts/autoloads/audio_manager.gd index 3fc0f64be..ba35c5b20 100644 --- a/client/scripts/autoloads/audio_manager.gd +++ b/client/scripts/autoloads/audio_manager.gd @@ -41,6 +41,18 @@ const DIP_SPECS := { # Low-pass filter default cutoff — effectively bypassed at this value. const FILTER_CUTOFF_DEFAULT := 20500.0 +# --- D-073: Zone crossfade constants --- +const CROSSFADE_DURATION := 1.8 # D-073: 1.5-2s spec, mid-range + +# Maps server zone_id strings to ambient asset keys (filenames in res://audio/). +# Hub and Workplace share the same ambient layer — same location type. +const ZONE_ASSETS: Dictionary = { + "hub": "amb_hub_layer", + "workplace": "amb_hub_layer", + "bar": "amb_bar_layer", + "corridor": "amb_corridor_layer", +} + # Asset registry: filename stem (e.g. "amb_station_base") → AudioStream var _registry: Dictionary = {} @@ -57,12 +69,19 @@ var _ambient_filter: AudioEffectLowPassFilter = null # Ambient loop players keyed by asset_key (D-073 zone crossfade) var _ambient_players: Dictionary = {} +# D-073: Zone crossfade state +var _current_zone_id: String = "" +var _zone_tweens: Array = [] + signal dip_changed(profile: String) +const PREFS_PATH := "user://audio_prefs.cfg" + func _ready() -> void: _setup_buses() _scan_registry() + _load_prefs() # --- Bus setup --- @@ -173,9 +192,8 @@ func stop_all_loops() -> void: # --- Audio asset registry: event type → asset key (D-018, #125) --- # Maps server-sent sound event_type strings to audio asset keys. # Keys match filename stems in res://audio/ (scanned by _scan_registry). -# Audio assets per D-038: footstep variants (walk / run). -# No asset for Voice events in v0.1 — play_sound_event no-ops gracefully -# (D-038: "renders as audio if asset exists, or visual indicator + monologue if not"). +# Audio assets per D-038: footstep variants (walk / run), NPC murmur (D-072, #532). +# Missing assets no-op gracefully (D-038 fallback pattern). const SOUND_EVENT_ASSETS: Dictionary = { "Footstep": "sfx_footstep_metal_walk", "FootstepWalk": "sfx_footstep_metal_walk", @@ -183,6 +201,10 @@ const SOUND_EVENT_ASSETS: Dictionary = { "FootstepCrouch": "sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands "FootstepSprint": "sfx_footstep_metal_run", "FootstepRun": "sfx_footstep_metal_run", + # D-072 (#533): NPC conversation murmur — single universal asset, World SFX bus. + # Zone ambient provides natural conspicuousness (bar buries it, corridor exposes it). + "Voice": "sfx_npc_murmur", + "VoiceConversation": "sfx_npc_murmur", } @@ -304,19 +326,85 @@ func set_volume(bus: String, volume_db: float) -> void: var buses: Dictionary = spec.get("buses", {}) var offset_db: float = buses.get(bus, 0.0) AudioServer.set_bus_volume_db(idx, volume_db + offset_db) + _save_prefs() func get_volume(bus: String) -> float: return _bus_volumes.get(bus, 0.0) -# --- Zone crossfade (D-073 stub) --- +# --- Volume persistence (user://audio_prefs.cfg) --- + +func _load_prefs() -> void: + var cfg := ConfigFile.new() + if cfg.load(PREFS_PATH) != OK: + return + for bus_name in BUSES: + if cfg.has_section_key("audio", bus_name): + var db: float = cfg.get_value("audio", bus_name, 0.0) + # Apply directly: bypass _save_prefs() on initial load. + _bus_volumes[bus_name] = db + var idx := AudioServer.get_bus_index(bus_name) + if idx >= 0: + AudioServer.set_bus_volume_db(idx, db) + + +func _save_prefs() -> void: + var cfg := ConfigFile.new() + for bus_name in BUSES: + cfg.set_value("audio", bus_name, _bus_volumes.get(bus_name, 0.0)) + var err := cfg.save(PREFS_PATH) + if err != OK: + push_warning("AudioManager: failed to save prefs to %s (error %d)" % [PREFS_PATH, err]) + + +# --- Zone crossfade (D-073) --- ## Handle zone transition. Server sends zone_id per tile in ObserverSnapshot. -## Full crossfade implementation deferred to Sprint 9+ (D-073). -## Stub exists so server integration can call it without conditional checks. -func set_zone(_zone_id: String) -> void: - pass +## Hard boundary trigger with 1.5-2s audio crossfade between ambient layers. +## Interruptible — mid-crossfade zone change tweens from current position. +## No-op if assets absent (D-038) or same zone. +func set_zone(zone_id: String) -> void: + if zone_id == _current_zone_id: + return + var new_asset: String = ZONE_ASSETS.get(zone_id, "") + var old_asset: String = ZONE_ASSETS.get(_current_zone_id, "") + _current_zone_id = zone_id + _kill_zone_tweens() + + # Fade out old ambient layer (if different asset from incoming zone) + if not old_asset.is_empty() and old_asset != new_asset: + if _ambient_players.has(old_asset): + var old_player: AudioStreamPlayer = _ambient_players[old_asset] + if is_instance_valid(old_player): + var tween := create_tween() + tween.tween_property(old_player, "volume_db", -80.0, CROSSFADE_DURATION) + tween.tween_callback(stop_loop.bind(old_asset)) + _zone_tweens.append(tween) + + # Fade in new ambient layer + if not new_asset.is_empty(): + if _ambient_players.has(new_asset): + # Already playing (interrupted reverse crossfade) — tween from current volume + var existing: AudioStreamPlayer = _ambient_players[new_asset] + if is_instance_valid(existing): + var tween := create_tween() + tween.tween_property(existing, "volume_db", 0.0, CROSSFADE_DURATION) + _zone_tweens.append(tween) + elif has_asset(new_asset): + var new_player := play_loop(new_asset, BUS_AMBIENT) + if new_player: + new_player.volume_db = -80.0 + var tween := create_tween() + tween.tween_property(new_player, "volume_db", 0.0, CROSSFADE_DURATION) + _zone_tweens.append(tween) + + +func _kill_zone_tweens() -> void: + for tween in _zone_tweens: + if tween != null and tween.is_valid(): + tween.kill() + _zone_tweens.clear() # --- Internal helpers --- diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 4f3045158..2b912d2e5 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -62,6 +62,12 @@ var medium_sound_events: Array = [] # Format: [{x, y, event_type, range_category}] — consumed once per tick in main.gd. 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. +# ListeningFocus boost activates at 30+ ticks (main.gd manages the dip). +var stationary_ticks: int = 0 +var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previous position + func apply_snapshot(snapshot: Dictionary) -> void: current_snapshot = snapshot @@ -83,6 +89,14 @@ 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 + else: + stationary_ticks = 0 + _prev_player_position = player_position + # Tiles for rendering: test mode sends "tiles", live server sends tile data in "visible_tiles" if snapshot.has("tiles"): visible_tiles = snapshot.tiles diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 677c7ebf4..16b4eb2dd 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -15,6 +15,7 @@ extends Node2D @onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests @onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress @onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button +@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU) var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input var _camera_anchored: bool = false @@ -24,6 +25,9 @@ var _known_recognition_ids: Dictionary = {} # D-067: entity_ids that have alrea var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber) var _teleport_in_progress: bool = false # #501: defer smoothing re-enable by one frame after teleport 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 + +const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost activates func _ready() -> void: print("The Settled Reach — client initialized") @@ -128,6 +132,13 @@ func _process(_delta: float) -> void: # 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() + # Show monologue if server sent one this tick (#414) _consume_monologue() @@ -162,6 +173,14 @@ func _process(_delta: float) -> void: if bug_report_dialog and not bug_report_dialog.is_active(): bug_report_dialog.start_capture() continue + # #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog + if input.action == InputMapper.Action.OPEN_MENU: + if settings_dialog: + if settings_dialog.is_open(): + settings_dialog.close() + else: + settings_dialog.open() + continue if input.action == InputMapper.Action.INTERACT: # D-057: prefer interaction list (multi-verb), fall back to prompt (v0.1) var target_id: int = -1 @@ -235,6 +254,38 @@ func _play_recognition_chimes() -> void: _known_recognition_ids.erase(eid) +# D-073 (#529): Zone ambient crossfade — read zone_id from player's current tile. +# Calls AudioManager.set_zone() when zone changes (AudioManager handles crossfade). +# zone_id field is server-authoritative; missing field treated as empty (no zone). +func _update_zone() -> void: + var px := int(GameState.player_position.x) + var py := int(GameState.player_position.y) + var zone: String = "" + for tile in GameState.visible_tiles: + if not tile is Dictionary: + continue + if tile.get("x") == px and tile.get("y") == py: + zone = tile.get("zone_id", "") + break + if zone != _current_zone: + _current_zone = zone + AudioManager.set_zone(zone) + + +# D-071 (#530): ListeningFocus boost — World SFX +2.5dB when stationary 30+ ticks. +# Uses AudioManager.get_active_dip() as single source of truth (no separate flag). +# Only activates when no other dip (dialogue/confrontation) is running. +# Only deactivates its own dip — never touches dialogue/confrontation. +# D-070: no UI indicator — the boost is "felt, not computed." +func _update_listening_focus() -> void: + var current_dip := AudioManager.get_active_dip() + var threshold_met := GameState.stationary_ticks >= LISTENING_FOCUS_TICKS + if threshold_met and current_dip == "": + AudioManager.apply_dip("listening_focus") + elif not threshold_met and current_dip == "listening_focus": + AudioManager.clear_dip() + + # Consume-once per tick: show monologue text, then clear. # Tick guard prevents re-triggering when the same tick is polled multiple # times (client FPS > sim tick rate). diff --git a/client/ui/dialogue_box.gd b/client/ui/dialogue_box.gd index 62c71b53b..e64363929 100644 --- a/client/ui/dialogue_box.gd +++ b/client/ui/dialogue_box.gd @@ -139,6 +139,10 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi _is_showing = true GameState.dialogue_active = true # D-064: block movement while dialogue visible/fading + # D-069: Dialogue dip — reduce ambient noise to foreground conversation. + # Confrontation options override this with apply_dip("confrontation") in _start_confrontation_beat(). + AudioManager.apply_dip("dialogue") + # D-061: auto-pause in single-player when dialogue opens SimBridge.send_input({"action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec()}) @@ -156,6 +160,10 @@ func hide_dialogue() -> void: _is_showing = false mouse_filter = Control.MOUSE_FILTER_IGNORE + # D-069: Clear dialogue/confrontation dip — restore bus volumes to player slider settings. + # Safe to call even if confrontation beat already cleared the dip (no-op when empty). + AudioManager.clear_dip() + # D-061: unpause when dialogue closes SimBridge.send_input({"action": InputMapper.Action.UNPAUSE, "timestamp_msec": Time.get_ticks_msec()}) diff --git a/client/ui/settings_dialog.gd b/client/ui/settings_dialog.gd new file mode 100644 index 000000000..588e9b4eb --- /dev/null +++ b/client/ui/settings_dialog.gd @@ -0,0 +1,159 @@ +extends Control + +## #528: Audio settings dialog — 5-bus volume sliders. +## Opens on OPEN_MENU (ESC) from main.gd. Closes on OPEN_MENU again or CLOSE button. +## Volumes persist via AudioManager._save_prefs() on each slider change. + +const BG_COLOR := Color(0.05, 0.05, 0.08, 0.90) +const BORDER_COLOR := Color("#4a9ebb") +const TEXT_COLOR := Color(0.878, 0.969, 0.98, 1) +const TITLE_COLOR := Color("#4a9ebb") +const FONT_SIZE := 14 + +const BOX_WIDTH := 460 +const BOX_HEIGHT := 340 +const PADDING := 20 +const ROW_HEIGHT := 36 + +# Bus display labels → bus name strings (must match AudioManager BUS_* constants) +const BUS_ROWS: Array = [ + ["Music", "Music"], + ["Ambient", "Ambient"], + ["World SFX", "WorldSFX"], + ["Player Actions", "PlayerActions"], + ["UI Sounds", "UISounds"], +] + +var _active: bool = false +var _container: VBoxContainer = null + +signal closed + + +func _ready() -> void: + visible = false + mouse_filter = Control.MOUSE_FILTER_STOP + + +func open() -> void: + if _active: + return + _active = true + visible = true + _build_ui() + queue_redraw() + + +func close() -> void: + if not _active: + return + _active = false + visible = false + _destroy_ui() + queue_redraw() + closed.emit() + + +func is_open() -> bool: + return _active + + +func _build_ui() -> void: + var vp_size := get_viewport_rect().size + var box_pos := Vector2( + (vp_size.x - BOX_WIDTH) / 2.0, + (vp_size.y - BOX_HEIGHT) / 2.0 + ) + + _container = VBoxContainer.new() + _container.position = box_pos + Vector2(PADDING, PADDING + 28) + _container.custom_minimum_size = Vector2(BOX_WIDTH - PADDING * 2, 0) + _container.add_theme_constant_override("separation", 4) + add_child(_container) + + for row_data in BUS_ROWS: + var label_text: String = row_data[0] + var bus_name: String = row_data[1] + + var hbox := HBoxContainer.new() + hbox.custom_minimum_size = Vector2(0, ROW_HEIGHT) + _container.add_child(hbox) + + var label := Label.new() + label.text = label_text + label.custom_minimum_size = Vector2(150, 0) + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + label.add_theme_font_size_override("font_size", FONT_SIZE) + label.add_theme_color_override("font_color", TEXT_COLOR) + hbox.add_child(label) + + var slider := HSlider.new() + slider.min_value = -40.0 + slider.max_value = 0.0 + slider.step = 0.5 + slider.value = AudioManager.get_volume(bus_name) + slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL + hbox.add_child(slider) + + var db_label := Label.new() + db_label.text = _format_db(slider.value) + db_label.custom_minimum_size = Vector2(70, 0) + db_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + db_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + db_label.add_theme_font_size_override("font_size", FONT_SIZE) + db_label.add_theme_color_override("font_color", TEXT_COLOR) + hbox.add_child(db_label) + + slider.value_changed.connect(func(value: float) -> void: + AudioManager.set_volume(bus_name, value) + db_label.text = _format_db(value) + ) + + # Spacer + var spacer := Control.new() + spacer.custom_minimum_size = Vector2(0, 8) + _container.add_child(spacer) + + # Close button + var close_btn := Button.new() + close_btn.text = "CLOSE" + close_btn.add_theme_font_size_override("font_size", FONT_SIZE) + close_btn.pressed.connect(close) + _container.add_child(close_btn) + + +func _destroy_ui() -> void: + if _container: + _container.queue_free() + _container = null + + +func _draw() -> void: + if not _active: + return + var viewport_size := get_viewport_rect().size + + # Dim overlay + draw_rect(Rect2(Vector2.ZERO, viewport_size), BG_COLOR) + + # Dialog box + var box_pos := Vector2( + (viewport_size.x - BOX_WIDTH) / 2.0, + (viewport_size.y - BOX_HEIGHT) / 2.0 + ) + var box_rect := Rect2(box_pos, Vector2(BOX_WIDTH, BOX_HEIGHT)) + draw_rect(box_rect, Color(0.08, 0.08, 0.12, 0.95)) + draw_rect(box_rect, BORDER_COLOR, false, 1.0) + + # Title + var font := ThemeDB.fallback_font + draw_string(font, + box_pos + Vector2(PADDING, PADDING + 18), + "AUDIO SETTINGS", + HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 2, TITLE_COLOR) + + +static func _format_db(db: float) -> String: + if db <= -40.0: + return "mute" + return "%d dB" % int(db) diff --git a/client/ui/settings_dialog.tscn b/client/ui/settings_dialog.tscn new file mode 100644 index 000000000..77cf97319 --- /dev/null +++ b/client/ui/settings_dialog.tscn @@ -0,0 +1,14 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://ui/settings_dialog.gd" id="1_settings"] + +; #528: Audio settings dialog — 5-bus volume sliders, OPEN_MENU (ESC) to toggle +[node name="SettingsDialog" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +script = ExtResource("1_settings") diff --git a/server/Cargo.lock b/server/Cargo.lock index 21c88b2a2..ea1dcf2fe 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1092,7 +1092,7 @@ dependencies = [ [[package]] name = "settled-reach-server" -version = "0.1.11" +version = "0.1.12" dependencies = [ "bevy_app", "bevy_ecs", From 513682522feb183225368a1c4a88ac85111a2504 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 18:19:32 +0100 Subject: [PATCH 2/3] test(audio): Sprint 13 test suite + lambda capture fix (#528-#533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 57 new tests in test_audio_sprint13.gd covering bus setup, dip profiles, zone crossfade, recognition chime, and murmur wiring. Fix lambda capture bug in 3 signal tests (String → Array wrapper for reliable closure capture in Godot 4). Add bus volume reset to before_test() for test isolation against prefs-loaded state. Co-Authored-By: Claude Opus 4.6 --- client/tests/test_audio_bus_routing.gd | 82 +++- client/tests/test_audio_sprint13.gd | 560 +++++++++++++++++++++++++ 2 files changed, 633 insertions(+), 9 deletions(-) create mode 100644 client/tests/test_audio_sprint13.gd diff --git a/client/tests/test_audio_bus_routing.gd b/client/tests/test_audio_bus_routing.gd index f0df0eb2b..8dda3e962 100644 --- a/client/tests/test_audio_bus_routing.gd +++ b/client/tests/test_audio_bus_routing.gd @@ -15,10 +15,16 @@ extends GdUnitTestSuite func before_test() -> void: AudioManager.clear_dip() + for bus in AudioManager.BUSES: + AudioManager.set_volume(bus, 0.0) + GameState.stationary_ticks = 0 + GameState._prev_player_position = Vector2(-1e9, -1e9) func after_test() -> void: AudioManager.clear_dip() + GameState.stationary_ticks = 0 + GameState._prev_player_position = Vector2(-1e9, -1e9) # ============================================================================== @@ -171,22 +177,25 @@ func test_audio_manager_apply_unknown_dip_leaves_state_unchanged() -> void: func test_audio_manager_apply_dip_emits_dip_changed_signal() -> void: ## apply_dip() must emit dip_changed(profile) synchronously. - var received_profile := "" - var conn := func(p: String) -> void: received_profile = p + ## Array wrapper used for lambda capture — GDScript 4 captures String locals by value, + ## so a mutable reference type is required to observe signal argument inside the closure. + var received := [""] + var conn := func(p: String) -> void: received[0] = p AudioManager.dip_changed.connect(conn) AudioManager.apply_dip("dialogue") AudioManager.dip_changed.disconnect(conn) - assert_that(received_profile).is_equal("dialogue") + assert_that(received[0]).is_equal("dialogue") func test_audio_manager_clear_dip_emits_dip_changed_empty() -> void: ## clear_dip() must emit dip_changed("") to signal audio restored. + ## Array wrapper used for lambda capture — same reason as apply_dip signal test above. AudioManager.apply_dip("dialogue") - var received_profile := "sentinel" - var conn := func(p: String) -> void: received_profile = p + var received := ["sentinel"] + var conn := func(p: String) -> void: received[0] = p AudioManager.dip_changed.connect(conn) AudioManager.clear_dip() AudioManager.dip_changed.disconnect(conn) - assert_that(received_profile).is_equal("") + assert_that(received[0]).is_equal("") func test_audio_manager_apply_dip_interrupts_previous() -> void: ## Switching profiles mid-dip: active profile must update to the new one. @@ -196,13 +205,14 @@ func test_audio_manager_apply_dip_interrupts_previous() -> void: func test_audio_manager_dip_changed_fires_on_profile_switch() -> void: ## Switching from dialogue to confrontation emits dip_changed("confrontation"). + ## Array wrapper used for lambda capture — same reason as apply_dip signal test above. AudioManager.apply_dip("dialogue") - var received_profile := "" - var conn := func(p: String) -> void: received_profile = p + var received := [""] + var conn := func(p: String) -> void: received[0] = p AudioManager.dip_changed.connect(conn) AudioManager.apply_dip("confrontation") AudioManager.dip_changed.disconnect(conn) - assert_that(received_profile).is_equal("confrontation") + assert_that(received[0]).is_equal("confrontation") func test_audio_manager_has_asset_false_for_unknown_key() -> void: ## has_asset() must return false for a key that was never registered. @@ -291,3 +301,57 @@ func test_audio_manager_play_sound_event_noop_for_empty_type() -> void: ## #125: Empty event type string must be a no-op. AudioManager.play_sound_event("", Vector2(5.0, 5.0)) # No assertion needed — absence of crash is the test. + + +# ============================================================================== +# Layer 6: D-071 (#530) — Stationary tick tracking for ListeningFocus +# ============================================================================== + +func test_d071_stationary_ticks_increments_when_position_unchanged() -> void: + ## D-071: stationary_ticks must increment on each snapshot where player doesn't move. + var player_entity := {"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}} + GameState.apply_snapshot({"tick": 1, "entities": [player_entity]}) + GameState.apply_snapshot({"tick": 2, "entities": [player_entity]}) + GameState.apply_snapshot({"tick": 3, "entities": [player_entity]}) + assert_that(GameState.stationary_ticks).is_equal(2) # 2 ticks of no movement (tick 2 and 3) + +func test_d071_stationary_ticks_resets_on_movement() -> void: + ## D-071: stationary_ticks must reset to 0 when the player position changes. + var pos_a := {"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}} + var pos_b := {"entity_id": 1, "x": 11.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}} + GameState.apply_snapshot({"tick": 1, "entities": [pos_a]}) + GameState.apply_snapshot({"tick": 2, "entities": [pos_a]}) + assert_that(GameState.stationary_ticks).is_equal(1) + GameState.apply_snapshot({"tick": 3, "entities": [pos_b]}) + assert_that(GameState.stationary_ticks).is_equal(0) + +func test_d071_stationary_ticks_reaches_threshold() -> void: + ## D-071: stationary_ticks must be able to reach 30+ for ListeningFocus activation. + var player_entity := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}} + for tick in range(31): + GameState.apply_snapshot({"tick": tick, "entities": [player_entity]}) + assert_that(GameState.stationary_ticks >= 30).is_true() + + +# ============================================================================== +# Layer 7: D-069 (#530) — Dialogue dip profile interaction +# ============================================================================== + +func test_d069_dialogue_dip_overridden_by_confrontation() -> void: + ## D-069: Confrontation dip must override dialogue dip (apply_dip interrupts). + AudioManager.apply_dip("dialogue") + assert_that(AudioManager.get_active_dip()).is_equal("dialogue") + AudioManager.apply_dip("confrontation") + assert_that(AudioManager.get_active_dip()).is_equal("confrontation") + +func test_d069_listening_focus_cleared_by_dialogue() -> void: + ## D-069: Dialogue dip must override listening_focus (higher priority focus state). + AudioManager.apply_dip("listening_focus") + assert_that(AudioManager.get_active_dip()).is_equal("listening_focus") + AudioManager.apply_dip("dialogue") + assert_that(AudioManager.get_active_dip()).is_equal("dialogue") + +func test_d069_clear_dip_noop_when_empty() -> void: + ## D-069: clear_dip() when no dip active must be a safe no-op. + AudioManager.clear_dip() # Should not crash + assert_that(AudioManager.get_active_dip()).is_equal("") diff --git a/client/tests/test_audio_sprint13.gd b/client/tests/test_audio_sprint13.gd new file mode 100644 index 000000000..f8a3cc79d --- /dev/null +++ b/client/tests/test_audio_sprint13.gd @@ -0,0 +1,560 @@ +## Test suite for Sprint 13 audio tickets (D-067, D-068, D-069, D-071, D-072, D-073). +## +## Spec refs: +## D-067: Recognition chime fires at ONSET of cognitive delay, not at completion. +## D-068: 5-bus audio architecture (Music, Ambient, WorldSFX, PlayerActions, UISounds). +## D-069: Audio dip profiles — timing values, dB offsets, interruptibility. +## D-071: ListeningFocus boost (30+ tick gate, caller's responsibility). +## D-072: Universal NPC conversation murmur on WorldSFX — no zone-specific variants. +## D-073: Zone crossfade — hard boundary trigger, 1.5-2s tween, interruptible. +## +## Tickets covered: +## #529 — Zone crossfade implementation (set_zone body + tween timing) +## #530 — Dip profile call sites (dialogue/confrontation/ListeningFocus wiring) +## #531 — Recognition chime fires at cognitive delay onset +## #533 — NPC conversation murmur wired to WorldSFX bus +## +## Test layers: +## 1. Zone crossfade spec and API (D-073 / #529) +## 2. D-069 dip timing and dB spec values (supplementing test_audio_bus_routing) +## 3. Volume slider proportional dip (D-068 / D-069) +## 4. Dip call site wiring via GameState snapshot (D-069 / D-070 / #530) +## 5. Recognition chime onset verification (D-067 / #531) +## 6. NPC murmur routing (D-072 / #533) +## 7. AudioManager no-op fallback sanity (D-068 / D-038) +class_name TestAudioSprint13 +extends GdUnitTestSuite + + +func before_test() -> void: + AudioManager.clear_dip() + AudioManager.stop_all_loops() + AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0) + AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, 0.0) + AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, 0.0) + AudioManager.set_volume(AudioManager.BUS_PLAYER_ACTIONS, 0.0) + AudioManager.set_volume(AudioManager.BUS_MUSIC, 0.0) + + +func after_test() -> void: + AudioManager.clear_dip() + AudioManager.stop_all_loops() + + +# ============================================================================== +# Layer 1: Zone Crossfade — D-073 / #529 +# set_zone() stub exists now; full implementation lands in #529. +# ============================================================================== + +func test_d073_set_zone_method_exists() -> void: + ## D-073 / #529: AudioManager must expose set_zone(zone_id: String). + assert_that(AudioManager.has_method("set_zone")).is_true() + + +func test_d073_set_zone_hub_does_not_crash() -> void: + ## D-073: set_zone("hub") is a safe call. No crash even before #529 implementation. + AudioManager.set_zone("hub") + + +func test_d073_set_zone_bar_does_not_crash() -> void: + ## D-073: set_zone("bar") is a safe call. + AudioManager.set_zone("bar") + + +func test_d073_set_zone_corridor_does_not_crash() -> void: + ## D-073: set_zone("corridor") is a safe call. + AudioManager.set_zone("corridor") + + +func test_d073_set_zone_empty_string_does_not_crash() -> void: + ## D-073: set_zone("") edge case — no zone ID. Must not crash. + AudioManager.set_zone("") + + +func test_d073_set_zone_unknown_zone_does_not_crash() -> void: + ## D-073: Unmapped zone ID (no matching asset) — no crash, graceful no-op. + AudioManager.set_zone("nonexistent_zone_xyz") + + +func test_d073_zone_asset_hub_key_matches_filename_convention() -> void: + ## D-073 / #529: v0.1 zone-to-asset mapping per sprint brief. + ## hub/workplace → amb_hub_layer (must match filename stem in res://audio/). + ## Test verifies naming convention is documentable. Activates once #529 adds the map. + if not "ZONE_ASSETS" in AudioManager: + push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test") + return + var zone_assets: Dictionary = AudioManager.ZONE_ASSETS + assert_that(zone_assets.has("hub")).is_true() + assert_that(zone_assets["hub"]).is_equal("amb_hub_layer") + + +func test_d073_zone_asset_bar_key_matches_filename_convention() -> void: + ## D-073 / #529: bar → amb_bar_layer + if not "ZONE_ASSETS" in AudioManager: + push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test") + return + var zone_assets: Dictionary = AudioManager.ZONE_ASSETS + assert_that(zone_assets.has("bar")).is_true() + assert_that(zone_assets["bar"]).is_equal("amb_bar_layer") + + +func test_d073_zone_asset_corridor_key_matches_filename_convention() -> void: + ## D-073 / #529: smuggling corridor → amb_corridor_layer + if not "ZONE_ASSETS" in AudioManager: + push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test") + return + var zone_assets: Dictionary = AudioManager.ZONE_ASSETS + assert_that(zone_assets.has("corridor")).is_true() + assert_that(zone_assets["corridor"]).is_equal("amb_corridor_layer") + + +func test_d073_crossfade_duration_in_1_5_to_2_0s_range() -> void: + ## D-073: Crossfade tween duration must be 1.5-2.0s. + ## Activates once #529 defines the duration constant. + if not "CROSSFADE_DURATION" in AudioManager: + push_warning("TestAudioSprint13: CROSSFADE_DURATION not yet defined (#529 pending) — skip duration test") + return + var duration: float = AudioManager.CROSSFADE_DURATION + assert_that(duration >= 1.5 and duration <= 2.0).is_true() + + +func test_d073_set_zone_same_zone_repeated_is_noop() -> void: + ## D-073: Crossing back to the current zone should not restart a crossfade. + ## (No audio pops when zone boundary is ambiguous.) Activates post-#529. + if not "ZONE_ASSETS" in AudioManager: + push_warning("TestAudioSprint13: set_zone body not yet implemented (#529) — skip no-op test") + return + AudioManager.set_zone("hub") + AudioManager.set_zone("hub") + ## Expect exactly one or zero ambient players after same-zone calls (no stacked tweens). + ## Stub: assert no crash and ambient_players size is 0 or 1, not 2. + assert_that(AudioManager._ambient_players.size() <= 1).is_true() + + +# ============================================================================== +# Layer 2: D-069 Dip Timing and dB Spec Values +# These tests supplement test_audio_bus_routing.gd with timing and dB precision. +# ============================================================================== + +func test_d069_dialogue_ease_in_is_300ms() -> void: + ## D-069: Dialogue dip ease-in = 300ms (0.3s). + var ease_in: float = AudioManager.DIP_SPECS["dialogue"]["ease_in"] + assert_that(ease_in).is_equal_approx(0.3, 0.05) + + +func test_d069_dialogue_ease_out_is_500ms() -> void: + ## D-069: Dialogue dip ease-out = 500ms (0.5s). + var ease_out: float = AudioManager.DIP_SPECS["dialogue"]["ease_out"] + assert_that(ease_out).is_equal_approx(0.5, 0.05) + + +func test_d069_confrontation_ease_in_is_500ms() -> void: + ## D-069: Confrontation dip ease-in = 500ms (0.5s). + var ease_in: float = AudioManager.DIP_SPECS["confrontation"]["ease_in"] + assert_that(ease_in).is_equal_approx(0.5, 0.05) + + +func test_d069_confrontation_ease_out_is_1000ms() -> void: + ## D-069: Confrontation dip ease-out = 1000ms (1.0s) — longer exit for immersion. + var ease_out: float = AudioManager.DIP_SPECS["confrontation"]["ease_out"] + assert_that(ease_out).is_equal_approx(1.0, 0.05) + + +func test_d069_dialogue_ambient_dip_within_6_to_8_db() -> void: + ## D-069: Dialogue dip: Ambient -6 to -8dB. Mid-range value (-7) used. + var dip: float = AudioManager.DIP_SPECS["dialogue"]["buses"]["Ambient"] + assert_that(dip >= -8.0 and dip <= -6.0).is_true() + + +func test_d069_confrontation_ambient_dip_within_10_to_12_db() -> void: + ## D-069: Confrontation dip: Ambient -10 to -12dB. Mid-range value (-11) used. + var dip: float = AudioManager.DIP_SPECS["confrontation"]["buses"]["Ambient"] + assert_that(dip >= -12.0 and dip <= -10.0).is_true() + + +func test_d069_confrontation_world_sfx_dip_within_4_to_6_db() -> void: + ## D-069: Confrontation dip: WorldSFX -4 to -6dB (graduated — loud events break through). + var dip: float = AudioManager.DIP_SPECS["confrontation"]["buses"]["WorldSFX"] + assert_that(dip >= -6.0 and dip <= -4.0).is_true() + + +func test_d069_listening_focus_world_sfx_boost_within_2_to_3_db() -> void: + ## D-069 / D-071: ListeningFocus boosts WorldSFX +2 to +3dB (eavesdrop bonus). + var boost: float = AudioManager.DIP_SPECS["listening_focus"]["buses"]["WorldSFX"] + assert_that(boost >= 2.0 and boost <= 3.0).is_true() + + +func test_d069_dialogue_spec_only_affects_ambient_bus() -> void: + ## D-069: Dialogue dip touches ONLY Ambient. WorldSFX, PlayerActions, UISounds, Music + ## must NOT appear in the spec — world events remain audible during conversation. + var buses: Dictionary = AudioManager.DIP_SPECS["dialogue"]["buses"] + assert_that(buses.has("Ambient")).is_true() + assert_that(buses.has("WorldSFX")).is_false() + assert_that(buses.has("PlayerActions")).is_false() + assert_that(buses.has("UISounds")).is_false() + assert_that(buses.has("Music")).is_false() + + +func test_d069_confrontation_spec_does_not_affect_player_actions() -> void: + ## D-069: Confrontation dip leaves PlayerActions at 0 — player sounds are NOT muffled. + var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"] + assert_that(buses.has("PlayerActions")).is_false() + + +func test_d069_confrontation_spec_does_not_affect_ui_sounds() -> void: + ## D-069: Confrontation dip leaves UISounds at 0 — chimes and UI remain audible. + var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"] + assert_that(buses.has("UISounds")).is_false() + + +func test_d069_listening_focus_spec_does_not_affect_ambient() -> void: + ## D-071: ListeningFocus boost is ONLY on WorldSFX. Ambient is NOT modified. + ## D-071: Eavesdropping requires MORE ambient awareness, not less — no ambient dip. + var buses: Dictionary = AudioManager.DIP_SPECS["listening_focus"]["buses"] + assert_that(buses.has("Ambient")).is_false() + + +func test_d069_filter_cutoff_default_is_approx_20khz() -> void: + ## D-069: Default low-pass filter cutoff is ~20kHz — effectively bypassed. + ## Confrontation dip sweeps it down to 800Hz. Default must be >= 20000Hz. + assert_that(AudioManager.FILTER_CUTOFF_DEFAULT >= 20000.0).is_true() + + +func test_d069_confrontation_filter_hz_is_800hz() -> void: + ## D-069: Confrontation sweeps low-pass filter to ~800Hz for muffled feel (D-070). + var filter_hz: float = AudioManager.DIP_SPECS["confrontation"]["filter_hz"] + assert_that(filter_hz).is_equal_approx(800.0, 50.0) + + +# ============================================================================== +# Layer 3: Volume Slider Proportional Dip — D-068 / D-069 +# ============================================================================== + +func test_d068_set_volume_get_volume_roundtrip() -> void: + ## D-068: set_volume / get_volume roundtrip preserves the slider value. + AudioManager.set_volume(AudioManager.BUS_AMBIENT, -6.0) + assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-6.0, 0.01) + AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0) + + +func test_d068_set_volume_persists_across_all_buses() -> void: + ## D-068: Each of the 5 buses has an independent volume setting. + AudioManager.set_volume(AudioManager.BUS_MUSIC, -10.0) + AudioManager.set_volume(AudioManager.BUS_AMBIENT, -5.0) + AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, -3.0) + AudioManager.set_volume(AudioManager.BUS_PLAYER_ACTIONS, -2.0) + AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, -1.0) + assert_that(AudioManager.get_volume(AudioManager.BUS_MUSIC)).is_equal_approx(-10.0, 0.01) + assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-5.0, 0.01) + assert_that(AudioManager.get_volume(AudioManager.BUS_WORLD_SFX)).is_equal_approx(-3.0, 0.01) + assert_that(AudioManager.get_volume(AudioManager.BUS_PLAYER_ACTIONS)).is_equal_approx(-2.0, 0.01) + assert_that(AudioManager.get_volume(AudioManager.BUS_UI_SOUNDS)).is_equal_approx(-1.0, 0.01) + + +func test_d069_get_volume_returns_slider_base_not_effective_volume() -> void: + ## D-069: get_volume() always returns the player slider setting (base). + ## The effective AudioServer volume during a dip = base + offset_db. + ## Callers storing the slider value must always read get_volume(), not AudioServer directly. + AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0) + AudioManager.apply_dip("dialogue") + ## Even during dip, get_volume returns the base (not base + dip offset). + assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(0.0, 0.01) + + +func test_d069_set_volume_during_active_dip_updates_base() -> void: + ## D-069: Changing slider mid-dip must update the base so the proportional + ## calculation uses the new slider value (not the pre-dip value). + AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0) + AudioManager.apply_dip("dialogue") + AudioManager.set_volume(AudioManager.BUS_AMBIENT, -3.0) + assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-3.0, 0.01) + + +func test_d069_clear_dip_does_not_change_stored_slider_value() -> void: + ## D-069: clear_dip() restores AudioServer volumes to base, but get_volume() + ## must still reflect the player slider setting (not the dipped value). + AudioManager.set_volume(AudioManager.BUS_AMBIENT, -5.0) + AudioManager.apply_dip("dialogue") + AudioManager.clear_dip() + assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-5.0, 0.01) + + +# ============================================================================== +# Layer 4: Dip Call Site Wiring — D-069 / D-070 / D-071 / #530 +# +# These tests verify GameState has the snapshot fields needed for #530 wiring. +# The assertions on AudioManager dip activation are stubbed pending implementation. +# ============================================================================== + +func test_d069_game_state_current_dialogue_field_exists() -> void: + ## #530 precondition: GameState.current_dialogue is the trigger for dialogue dip. + ## Field must exist so #530 wiring can check it. + assert_that("current_dialogue" in GameState).is_true() + + +func test_d069_snapshot_with_dialogue_sets_current_dialogue() -> void: + ## #530 precondition: apply_snapshot() with current_dialogue populates GameState correctly. + GameState.apply_snapshot({ + "tick": 1, + "current_dialogue": { + "npc_name": "Kael", "npc_entity_id": 2, + "speech": "Haven't seen you around.", "options": [], + }, + }) + assert_that(GameState.current_dialogue != null).is_true() + assert_that(GameState.current_dialogue is Dictionary).is_true() + GameState.apply_snapshot({"tick": 2}) + + +func test_d069_snapshot_without_dialogue_clears_current_dialogue() -> void: + ## #530 precondition: Snapshot without current_dialogue → current_dialogue is null. + GameState.apply_snapshot({ + "tick": 1, + "current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []}, + }) + GameState.apply_snapshot({"tick": 2}) + assert_that(GameState.current_dialogue == null).is_true() + + +func test_d069_dialogue_dip_wired_to_game_state_dialogue_activation() -> void: + ## D-069 / #530: When current_dialogue becomes active, apply_dip("dialogue") fires. + ## TODO(#530): Uncomment the assertion once call site is wired in sim_bridge/game_state. + AudioManager.clear_dip() + GameState.apply_snapshot({ + "tick": 1, + "current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []}, + }) + ## Precondition: dialogue IS active in GameState. + assert_that(GameState.current_dialogue != null).is_true() + ## ASSERTION (activate once #530 is implemented): + ## assert_that(AudioManager.get_active_dip()).is_equal("dialogue") + GameState.apply_snapshot({"tick": 2}) + AudioManager.clear_dip() + + +func test_d069_dialogue_dip_cleared_when_dialogue_ends() -> void: + ## D-069 / #530: When current_dialogue returns to null, clear_dip() fires. + ## TODO(#530): Uncomment the assertion once call site is wired. + GameState.apply_snapshot({ + "tick": 1, + "current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []}, + }) + GameState.apply_snapshot({"tick": 2}) # dialogue ends + ## ASSERTION (activate once #530 is implemented): + ## assert_that(AudioManager.get_active_dip()).is_equal("") + assert_that(GameState.current_dialogue == null).is_true() + + +func test_d071_listening_focus_gate_is_30_ticks() -> void: + ## D-069 / D-071: ListeningFocus boost activates after 30+ stationary ticks. + ## The tick gate is the CALLER's responsibility per AudioManager comment. + ## This test documents the threshold so it doesn't silently drift. + ## Wiring via sim_bridge.gd tracking stationary_ticks lands in #530. + const LISTENING_FOCUS_TICK_GATE := 30 + ## Stub: verify the spec value is documented. + assert_that(LISTENING_FOCUS_TICK_GATE).is_equal(30) + + +func test_d070_no_ui_indicator_means_no_signal_named_confrontation_ui() -> void: + ## D-070: Confrontation muffling is felt, not announced. No UI indicator. + ## Verify AudioManager does not expose a confrontation_ui_shown signal. + var signals: Array = AudioManager.get_signal_list().map( + func(s: Dictionary) -> String: return s.name + ) + assert_that(signals.has("confrontation_ui_shown")).is_false() + assert_that(signals.has("listening_focus_shown")).is_false() + + +# ============================================================================== +# Layer 5: Recognition Chime Onset — D-067 / #531 +# +# Chime fires at ONSET of cognitive delay (when entity FIRST appears in +# pending_recognitions), NOT at completion (when it leaves). +# ============================================================================== + +func test_d067_chime_recognition_constant_defined() -> void: + ## D-067 / D-038: AudioManager must expose CHIME_RECOGNITION asset key constant. + assert_that("CHIME_RECOGNITION" in AudioManager).is_true() + + +func test_d067_chime_recognition_matches_d038_asset_key() -> void: + ## D-067 / D-038: sfx_monologue_chime = "neural lattice firing" feel. + ## Key must match filename stem in res://audio/. + assert_that(AudioManager.CHIME_RECOGNITION).is_equal("sfx_monologue_chime") + + +func test_d067_play_routes_to_ui_sounds_bus_by_default() -> void: + ## D-067: play(CHIME_RECOGNITION) routes to BUS_UI_SOUNDS by default. + ## Chime is a cognitive/UI signal, not a world sound — must NOT go on WorldSFX. + ## Verify play() default bus is UISounds (the chime caller uses the default). + ## Edge: BUS_WORLD_SFX and BUS_UI_SOUNDS must be distinct. + assert_that(AudioManager.BUS_WORLD_SFX).is_not_equal(AudioManager.BUS_UI_SOUNDS) + + +func test_d067_play_chime_recognition_noop_when_asset_absent() -> void: + ## D-067 / D-038: play(CHIME_RECOGNITION) is a silent no-op if asset file is absent. + ## Client must not crash when audio branch has not yet provided the .ogg file. + AudioManager.play(AudioManager.CHIME_RECOGNITION) + ## No assertion needed — absence of crash is the test. + + +func test_d067_onset_is_when_remaining_equals_total_delay_ticks() -> void: + ## D-067: "Onset" of cognitive delay = first frame an entity appears in + ## pending_recognitions, at remaining_ticks == total_delay_ticks. + ## This is the moment the chime must fire. + GameState.apply_snapshot({ + "tick": 1, + "pending_recognitions": [ + {"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0, + "remaining_ticks": 6, "total_delay_ticks": 6}, + ], + }) + assert_that(GameState.pending_recognitions.size()).is_equal(1) + var rec: Dictionary = GameState.pending_recognitions[0] + ## Onset condition: remaining == total (delay just started) + assert_that(rec.remaining_ticks).is_equal(rec.total_delay_ticks) + GameState.apply_snapshot({"tick": 2}) + + +func test_d067_completion_is_when_entity_absent_from_pending() -> void: + ## D-067: Recognition COMPLETES (blob transitions) when entity leaves pending_recognitions. + ## The chime must NOT fire at this point — it fired at onset. + GameState.apply_snapshot({ + "tick": 1, + "pending_recognitions": [ + {"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0, + "remaining_ticks": 1, "total_delay_ticks": 6}, + ], + }) + assert_that(GameState.pending_recognitions.size()).is_equal(1) + ## Completion: entity removed from array + GameState.apply_snapshot({"tick": 2, "pending_recognitions": []}) + assert_that(GameState.pending_recognitions.size()).is_equal(0) + ## Chime state: AudioManager must not have a dip triggered by recognition. + ## (Chime is a play() call, not a dip — this verifies no side effects on dip state.) + assert_that(AudioManager.get_active_dip()).is_equal("") + + +func test_d067_chime_fires_at_fog_entity_spawn_not_removal() -> void: + ## D-067 / #531: The chime call site in fog_entities.gd / entity_renderer.gd + ## must be inside the "new entity" branch (not entity.has(eid)), NOT the cleanup loop. + ## This test verifies FogEntities correctly identifies the onset condition. + ## An entity with remaining_ticks == total_delay_ticks is a NEW entity entering delay. + var onset_tick := {"entity_id": 5, "x": 5.0, "y": 5.0, "z": 0, + "remaining_ticks": 6, "total_delay_ticks": 6} + var mid_tick := {"entity_id": 5, "x": 5.0, "y": 5.0, "z": 0, + "remaining_ticks": 3, "total_delay_ticks": 6} + ## Frame 1: entity APPEARS (onset — chime should fire here) + GameState.apply_snapshot({"tick": 1, "pending_recognitions": [onset_tick]}) + assert_that(GameState.pending_recognitions[0].remaining_ticks + == GameState.pending_recognitions[0].total_delay_ticks).is_true() + ## Frame 2: entity mid-progress (chime must NOT re-fire) + GameState.apply_snapshot({"tick": 2, "pending_recognitions": [mid_tick]}) + assert_that(GameState.pending_recognitions[0].remaining_ticks).is_equal(3) + ## Frame 3: entity completes (chime must NOT fire) + GameState.apply_snapshot({"tick": 3, "pending_recognitions": []}) + assert_that(GameState.pending_recognitions.size()).is_equal(0) + + +func test_d067_chime_duration_spec_is_300_to_400ms() -> void: + ## D-067: Chime duration is 300-400ms per spec. The asset (.ogg) carries this duration. + ## This test documents the spec range so asset authoring can be validated. + ## When sfx_monologue_chime.ogg is present, its AudioStream.get_length() should + ## return a value in this range. + const CHIME_MIN_DURATION := 0.3 + const CHIME_MAX_DURATION := 0.4 + if not AudioManager.has_asset(AudioManager.CHIME_RECOGNITION): + push_warning("TestAudioSprint13: sfx_monologue_chime asset absent — skip duration test") + return + var stream: AudioStream = AudioManager._registry.get(AudioManager.CHIME_RECOGNITION) + if stream == null: + push_warning("TestAudioSprint13: could not retrieve chime stream from registry") + return + assert_that(stream.get_length() >= CHIME_MIN_DURATION + and stream.get_length() <= CHIME_MAX_DURATION).is_true() + + +# ============================================================================== +# Layer 6: NPC Murmur Routing — D-072 / #533 +# Single universal asset, WorldSFX bus, no zone-specific variants. +# ============================================================================== + +func test_d072_world_sfx_bus_is_correct_for_murmur() -> void: + ## D-072 / #533: NPC murmur routes to WorldSFX bus per D-068 architecture. + ## BUS_WORLD_SFX must be "WorldSFX" — zone ambient conspicuousness is determined + ## by that bus's noise floor relative to the murmur volume. + assert_that(AudioManager.BUS_WORLD_SFX).is_equal("WorldSFX") + + +func test_d072_play_at_method_accepts_bus_parameter() -> void: + ## D-072 / #533: play_at() must accept an optional bus string parameter. + ## Proximity murmur uses play_at(asset_key, world_pos, BUS_WORLD_SFX). + assert_that(AudioManager.has_method("play_at")).is_true() + + +func test_d072_play_at_noop_when_murmur_asset_absent() -> void: + ## D-072 / D-038: sfx_npc_murmur.ogg arrives from audio branch (#532). + ## Until then, play_at("sfx_npc_murmur", ...) must be a silent no-op. + AudioManager.play_at("sfx_npc_murmur", Vector2(100.0, 100.0), AudioManager.BUS_WORLD_SFX) + ## No assertion — absence of crash is the test. + + +func test_d072_play_noop_when_murmur_asset_absent() -> void: + ## D-072 / D-038: play("sfx_npc_murmur", BUS_WORLD_SFX) also no-ops gracefully. + AudioManager.play("sfx_npc_murmur", AudioManager.BUS_WORLD_SFX) + ## No assertion — absence of crash is the test. + + +func test_d072_no_zone_specific_murmur_variants() -> void: + ## D-072: SINGLE universal murmur asset — no zone-specific variants. + ## "One murmur asset + zone-dependent conspicuousness creates the signal/noise + ## dynamic naturally." Zone-specific variants MUST NOT exist in the registry. + assert_that(AudioManager.has_asset("sfx_npc_murmur_bar")).is_false() + assert_that(AudioManager.has_asset("sfx_npc_murmur_corridor")).is_false() + assert_that(AudioManager.has_asset("sfx_npc_murmur_hub")).is_false() + assert_that(AudioManager.has_asset("sfx_npc_murmur_workplace")).is_false() + + +func test_d072_murmur_does_not_use_ambient_bus() -> void: + ## D-072: Bar ambient murmur is baked into amb_bar_layer (continuous background). + ## The NPC proximity murmur is a SEPARATE event-driven asset on WorldSFX, not Ambient. + ## Verify bus constant distinction. + assert_that(AudioManager.BUS_AMBIENT).is_not_equal(AudioManager.BUS_WORLD_SFX) + + +# ============================================================================== +# Layer 7: AudioManager No-Op Fallback Sanity — D-068 / D-038 +# ============================================================================== + +func test_d068_registry_size_is_non_negative() -> void: + ## D-068: Registry is empty when res://audio/ is absent, non-negative always. + assert_that(AudioManager.get_registry_size() >= 0).is_true() + + +func test_d068_play_loop_returns_null_for_missing_asset() -> void: + ## D-068 / D-038: play_loop() with unregistered asset key returns null (no crash). + var result: Variant = AudioManager.play_loop("nonexistent_ambient_xyzabc") + assert_that(result == null).is_true() + + +func test_d068_stop_loop_noop_for_unknown_key() -> void: + ## D-068: stop_loop() on a key never started — no crash, no error. + AudioManager.stop_loop("nonexistent_key_xyzabc") + + +func test_d068_stop_all_loops_when_none_playing() -> void: + ## D-068: stop_all_loops() with no active ambient players — no crash. + AudioManager.stop_all_loops() + + +func test_d068_has_asset_returns_false_for_unknown_key() -> void: + ## D-068 / D-038: has_asset() guards all play methods. Verify false for unknown key. + assert_that(AudioManager.has_asset("totally_unknown_asset_key_abc123")).is_false() + + +func test_d068_play_noop_does_not_change_dip_state() -> void: + ## D-068: play() on a missing asset must not modify the dip state machine. + ## Verifies no-op fallback has zero side effects. + AudioManager.apply_dip("dialogue") + AudioManager.play("nonexistent_asset_xyzabc") + assert_that(AudioManager.get_active_dip()).is_equal("dialogue") From 8985340bdd402df8574b4d4227a02fe2634e4df2 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 18:37:23 +0100 Subject: [PATCH 3/3] =?UTF-8?q?fix(client):=20address=20PR=20#47=20review?= =?UTF-8?q?=20=E2=80=94=20murmur=20deferral,=20zone=20extraction,=206=20te?= =?UTF-8?q?sts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove premature Voice/VoiceConversation from SOUND_EVENT_ASSETS (D-072 defers murmur to end-to-end sprint). Extract current_zone_id in GameState.apply_snapshot() as first-class field, eliminating O(N) tile scan in main.gd (D-020 server-authoritative). Add dir.list_dir_end() after registry scan. Add rapid zone-crossing + _load_prefs() roundtrip tests. Enhance comments on hub/workplace same-asset pattern, station base hum, and confrontation dip replacement semantics. Co-Authored-By: Claude Opus 4.6 --- client/scripts/autoloads/audio_manager.gd | 12 ++-- client/scripts/autoloads/game_state.gd | 16 +++++ client/scripts/main.gd | 14 +---- client/tests/test_audio_sprint13.gd | 74 +++++++++++++++++++++++ client/ui/dialogue_box.gd | 4 +- 5 files changed, 103 insertions(+), 17 deletions(-) diff --git a/client/scripts/autoloads/audio_manager.gd b/client/scripts/autoloads/audio_manager.gd index ba35c5b20..7aab28d75 100644 --- a/client/scripts/autoloads/audio_manager.gd +++ b/client/scripts/autoloads/audio_manager.gd @@ -45,7 +45,12 @@ const FILTER_CUTOFF_DEFAULT := 20500.0 const CROSSFADE_DURATION := 1.8 # D-073: 1.5-2s spec, mid-range # Maps server zone_id strings to ambient asset keys (filenames in res://audio/). -# Hub and Workplace share the same ambient layer — same location type. +# Hub and Workplace intentionally share the same ambient layer (amb_hub_layer) — +# they are the same location type, so hub→workplace transition is a same-asset no-op +# (old_asset != new_asset guard skips the fade-out). Sprint brief consolidates +# D-038's "amb_workplace_layer" to "amb_hub_layer" for v0.1. +# Note: amb_station_base (D-038 global base hum) plays globally via play_loop() +# at startup — it is not zone-dependent and has no ZONE_ASSETS entry. const ZONE_ASSETS: Dictionary = { "hub": "amb_hub_layer", "workplace": "amb_hub_layer", @@ -129,6 +134,7 @@ func _scan_dir(path: String) -> void: if stream: _registry[file_name.get_basename()] = stream file_name = dir.get_next() + dir.list_dir_end() func has_asset(asset_key: String) -> bool: @@ -201,10 +207,6 @@ const SOUND_EVENT_ASSETS: Dictionary = { "FootstepCrouch": "sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands "FootstepSprint": "sfx_footstep_metal_run", "FootstepRun": "sfx_footstep_metal_run", - # D-072 (#533): NPC conversation murmur — single universal asset, World SFX bus. - # Zone ambient provides natural conspicuousness (bar buries it, corridor exposes it). - "Voice": "sfx_npc_murmur", - "VoiceConversation": "sfx_npc_murmur", } diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 2b912d2e5..19187264f 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -68,6 +68,11 @@ var close_sound_events: Array = [] var stationary_ticks: int = 0 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). +var current_zone_id: String = "" + func apply_snapshot(snapshot: Dictionary) -> void: current_snapshot = snapshot @@ -197,6 +202,17 @@ func apply_snapshot(snapshot: Dictionary) -> void: medium_sound_events = [] close_sound_events = [] + # D-073 (#529): Extract zone_id from the player's current tile (server-authoritative). + # O(1) via visible_positions dict would be ideal, but tiles are arrays without + # positional indexing — use the same tile iteration below instead. + current_zone_id = "" + var _px := int(player_position.x) + var _py := int(player_position.y) + for _ztile in visible_tiles: + if _ztile is Dictionary and _ztile.get("x") == _px and _ztile.get("y") == _py: + current_zone_id = _ztile.get("zone_id", "") + break + # v2: visible_tiles with visibility sectors # Derives visible_positions when not explicitly provided (real server mode) if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0: diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 16b4eb2dd..c7ab84c59 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -254,19 +254,11 @@ func _play_recognition_chimes() -> void: _known_recognition_ids.erase(eid) -# D-073 (#529): Zone ambient crossfade — read zone_id from player's current tile. +# D-073 (#529): Zone ambient crossfade — reads zone_id from GameState.current_zone_id +# (extracted in apply_snapshot(), server-authoritative per D-020). # Calls AudioManager.set_zone() when zone changes (AudioManager handles crossfade). -# zone_id field is server-authoritative; missing field treated as empty (no zone). func _update_zone() -> void: - var px := int(GameState.player_position.x) - var py := int(GameState.player_position.y) - var zone: String = "" - for tile in GameState.visible_tiles: - if not tile is Dictionary: - continue - if tile.get("x") == px and tile.get("y") == py: - zone = tile.get("zone_id", "") - break + var zone := GameState.current_zone_id if zone != _current_zone: _current_zone = zone AudioManager.set_zone(zone) diff --git a/client/tests/test_audio_sprint13.gd b/client/tests/test_audio_sprint13.gd index f8a3cc79d..b7580ad34 100644 --- a/client/tests/test_audio_sprint13.gd +++ b/client/tests/test_audio_sprint13.gd @@ -108,6 +108,35 @@ func test_d073_zone_asset_corridor_key_matches_filename_convention() -> void: assert_that(zone_assets["corridor"]).is_equal("amb_corridor_layer") +func test_d073_game_state_extracts_zone_id_from_player_tile() -> void: + ## D-073 / Tyre review: zone_id is extracted in GameState.apply_snapshot() as + ## a first-class field (like player_facing, player_stance), avoiding O(N) tile + ## scan in main.gd per D-020 server-authoritative state. + var player := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}} + GameState.apply_snapshot({ + "tick": 1, + "entities": [player], + "tiles": [ + {"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "bar"}, + {"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "hub"}, + ], + }) + assert_that(GameState.current_zone_id).is_equal("bar") + GameState.apply_snapshot({"tick": 2}) + + +func test_d073_game_state_zone_id_empty_when_field_absent() -> void: + ## D-073: Defensive — zone_id missing from tile data (server hasn't shipped OQ-09). + var player := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}} + GameState.apply_snapshot({ + "tick": 1, + "entities": [player], + "tiles": [{"x": 5, "y": 5, "z": 0, "type": "floor"}], + }) + assert_that(GameState.current_zone_id).is_equal("") + GameState.apply_snapshot({"tick": 2}) + + func test_d073_crossfade_duration_in_1_5_to_2_0s_range() -> void: ## D-073: Crossfade tween duration must be 1.5-2.0s. ## Activates once #529 defines the duration constant. @@ -131,6 +160,51 @@ func test_d073_set_zone_same_zone_repeated_is_noop() -> void: assert_that(AudioManager._ambient_players.size() <= 1).is_true() +func test_d073_rapid_zone_crossing_interruptible() -> void: + ## D-073: Rapid back-and-forth zone crossing (interruptible crossfade). + ## When _kill_zone_tweens() fires mid-fade, old player stays at intermediate + ## volume — new tween starts from current position. No stacked tweens, no crash. + if not "ZONE_ASSETS" in AudioManager: + push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined — skip rapid crossing test") + return + # Cross hub → bar → hub rapidly (simulates player walking back and forth) + AudioManager.set_zone("hub") + AudioManager.set_zone("bar") # Interrupts hub fade-in mid-tween + AudioManager.set_zone("hub") # Interrupts bar fade-in mid-tween + AudioManager.set_zone("corridor") # Interrupts hub fade-in mid-tween + ## After rapid crossing: at most 2 ambient players (outgoing fade-out + incoming fade-in). + ## No stacked tweens — _kill_zone_tweens clears previous tweens each time. + assert_that(AudioManager._ambient_players.size() <= 2).is_true() + ## Zone tweens array should only contain active tweens from the last set_zone call. + assert_that(AudioManager._zone_tweens.size() <= 2).is_true() + AudioManager.stop_all_loops() + + +func test_d068_load_prefs_persistence_roundtrip() -> void: + ## #528: Volume persistence — save/load roundtrip via ConfigFile. + ## Sets non-default volumes, saves, reloads, and verifies values match. + AudioManager.set_volume(AudioManager.BUS_AMBIENT, -8.5) + AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, -3.0) + AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, -12.0) + ## _save_prefs() fires inside set_volume() — prefs file is written. + ## Reload prefs by calling _load_prefs() directly. + AudioManager._load_prefs() + assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-8.5, 0.01) + assert_that(AudioManager.get_volume(AudioManager.BUS_WORLD_SFX)).is_equal_approx(-3.0, 0.01) + assert_that(AudioManager.get_volume(AudioManager.BUS_UI_SOUNDS)).is_equal_approx(-12.0, 0.01) + ## Reset to defaults for subsequent tests. + for bus in AudioManager.BUSES: + AudioManager.set_volume(bus, 0.0) + + +func test_d068_load_prefs_handles_missing_config_gracefully() -> void: + ## #528: _load_prefs() with a missing/corrupted config file must not crash. + ## The config path is user://audio_prefs.cfg — if absent, _load_prefs returns early. + ## This test documents the graceful fallback behavior. + AudioManager._load_prefs() + ## No assertion — absence of crash is the test. + + # ============================================================================== # Layer 2: D-069 Dip Timing and dB Spec Values # These tests supplement test_audio_bus_routing.gd with timing and dB precision. diff --git a/client/ui/dialogue_box.gd b/client/ui/dialogue_box.gd index e64363929..62eb59c8c 100644 --- a/client/ui/dialogue_box.gd +++ b/client/ui/dialogue_box.gd @@ -140,7 +140,9 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi GameState.dialogue_active = true # D-064: block movement while dialogue visible/fading # D-069: Dialogue dip — reduce ambient noise to foreground conversation. - # Confrontation options override this with apply_dip("confrontation") in _start_confrontation_beat(). + # Confrontation options REPLACE (not nest) this dip via apply_dip("confrontation") + # in _start_confrontation_beat(). hide_dialogue()'s clear_dip() restores to base + # volumes regardless of which profile was last active — intentional replacement semantics. AudioManager.apply_dip("dialogue") # D-061: auto-pause in single-player when dialogue opens