From 6e01bc92b6627b2d252153b46dc56cbfd45b68e0 Mon Sep 17 00:00:00 2001 From: espanakosta-jpg Date: Sun, 19 Apr 2026 12:22:15 +0200 Subject: [PATCH 01/49] Create slider.wav --- files/sounds/sfx/slider.wav | Bin 0 -> 40814 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 files/sounds/sfx/slider.wav diff --git a/files/sounds/sfx/slider.wav b/files/sounds/sfx/slider.wav new file mode 100644 index 0000000000000000000000000000000000000000..c3f3a34a00efd323bbcb512befcc245a2ea7ba50 GIT binary patch literal 40814 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 z%0D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 Date: Sun, 19 Apr 2026 12:23:17 +0200 Subject: [PATCH 02/49] Create slider.wav.import --- files/sounds/sfx/slider.wav.import | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 files/sounds/sfx/slider.wav.import diff --git a/files/sounds/sfx/slider.wav.import b/files/sounds/sfx/slider.wav.import new file mode 100644 index 000000000..be6f9da71 --- /dev/null +++ b/files/sounds/sfx/slider.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://6asmf6p6ftp5" +path="res://.godot/imported/slider.wav-562467666e90bfee2364ab6985972073.sample" + +[deps] + +source_file="res://files/sounds/sfx/slider.wav" +dest_files=["res://.godot/imported/slider.wav-562467666e90bfee2364ab6985972073.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 From 6f83b6fc1c8e7aedd74f9c033f9854f15ab4e327 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Thu, 23 Apr 2026 15:34:37 -0700 Subject: [PATCH 03/49] [DOCUMENTATION] Milestone #16 README.md update #456 Having reviewed the details of Release v0.9.18 and PR #582, there are definitely a few key areas in your README.md that need to be updated to stay aligned with the current state of the project. --- README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/README.md b/README.md index d0af7d3dd..474212d24 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ You can play this game on [Itch.io](https://ikostan.itch.io/sky-lock-assault) - [Release Drafter](https://github.com/release-drafter/release-drafter?tab=readme-ov-file#readme) - [Close Stale Issues and PRs](https://github.com/actions/stale) - [AllContributors GitHub App](https://allcontributors.org/docs/en/bot/installation) + - [Deepsource](https://github.com/deepsource) 9. [Free Web Browser Game Deployment Platforms](files/docs/Platforms_for_Web_Deployment_Guide.md) @@ -145,6 +146,21 @@ these GPL requirements, a separate license is available upon request. - Observer-based Settings System: Centralized GameSettingsResource that handles automatic persistence and UI synchronization through signals. + +### Project Structure (`scripts/`) + +Post-Refactor Phase 4 (PR `#582`), the root `scripts/` directory has been fully +reorganised into purpose-specific sub-directories: + +| Directory | Contents | +|----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `scripts/core/` | Foundational systems: `game_paths.gd` (centralised path registry), `globals.gd`, `main_scene.gd`, `settings.gd` | +| `scripts/resources/` | Data containers & configuration: `game_settings_resource.gd`, `audio_constants.gd` | +| `scripts/entities/` | Game objects: `player.gd`, `bullet.gd`, `weapon.gd` | +| `scripts/system/` | Platform wrappers & integrations: `audio_web_bridge.gd`, `JavaScriptBridgeWrapper.gd`, `OSWrapper.gd` | +| `scripts/managers/` | Game-loop managers: `audio_manager.gd`, `parallax_manager.gd`, `resource_preloader.gd` | +| `scripts/ui/` | Interface layer: `hud.gd`; sub-dirs `menus/` (main, pause, options, audio, gameplay, key-mapping, advanced), `screens/` (splash, loading), `components/` (volume slider, input remap button) | + --- ## 🟢 Current Development Status @@ -289,6 +305,19 @@ to user input devices: - Modifier-aware remapping requires explicit key+modifier press for unique bindings. +### Milestone 14 + +**Status:** Stable gameplay loop with synced UI systems and GUT-based +unit testing. +**Active Focus:** Gameplay expansion (AI enemies, multiplayer, levels). +**Version:** v0.9.18 + +### Milestone 16 + +**Status:** Stable gameplay loop with fully refactored scripts architecture, +synced UI systems, and GUT-based unit testing. +**Active Focus:** Gameplay expansion (AI enemies, multiplayer, levels). + Track progress via [Milestones](https://github.com/ikostan/SkyLockAssault/milestones). --- From 84b3cffbb7b4e0a02e24bb1367a06363dccd6646 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Thu, 23 Apr 2026 15:40:21 -0700 Subject: [PATCH 04/49] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 474212d24..122328ab4 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ these GPL requirements, a separate license is available upon request. Post-Refactor Phase 4 (PR `#582`), the root `scripts/` directory has been fully reorganised into purpose-specific sub-directories: - + | Directory | Contents | |----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `scripts/core/` | Foundational systems: `game_paths.gd` (centralised path registry), `globals.gd`, `main_scene.gd`, `settings.gd` | @@ -160,7 +160,7 @@ reorganised into purpose-specific sub-directories: | `scripts/system/` | Platform wrappers & integrations: `audio_web_bridge.gd`, `JavaScriptBridgeWrapper.gd`, `OSWrapper.gd` | | `scripts/managers/` | Game-loop managers: `audio_manager.gd`, `parallax_manager.gd`, `resource_preloader.gd` | | `scripts/ui/` | Interface layer: `hud.gd`; sub-dirs `menus/` (main, pause, options, audio, gameplay, key-mapping, advanced), `screens/` (splash, loading), `components/` (volume slider, input remap button) | - + --- ## 🟢 Current Development Status From 5ddea6da48dceeb389c218a03c0d55c95dd28a2f Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Thu, 23 Apr 2026 16:34:05 -0700 Subject: [PATCH 05/49] https://github.com/ikostan/SkyLockAssault/issues/565 Description: Currently, UI components like VolumeSlider lack a standardized way to trigger audio feedback.To ensure performance and consistency for Milestone 18, we need to extend the AudioManager singleton with a play_sfx() method. This method will handle non-positional sound effects, specifically targeting the existing SFX_Menu bus for UI interactions. Proposed Implementation: 1. Function Signature: Add the following method to AudioManager. It must default to AudioConstants.BUS_SFX_MENU to ensure slider feedback is correctly routed. --- scripts/managers/audio_manager.gd | 59 +++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/scripts/managers/audio_manager.gd b/scripts/managers/audio_manager.gd index 888329fae..476042f43 100644 --- a/scripts/managers/audio_manager.gd +++ b/scripts/managers/audio_manager.gd @@ -12,6 +12,10 @@ signal volume_changed(bus_name: String, volume: float) signal mute_toggled(bus_name: String, is_muted: bool) # -------------------------------------------- +# --- NEW: SFX CACHING & MANAGEMENT --- +## Base path for all UI sound effects. +const SFX_DIR_PATH: String = "res://files/sounds/sfx/" + @export_category("Master Volume") @export var master_volume: float @export var master_muted: bool @@ -31,6 +35,9 @@ signal mute_toggled(bus_name: String, is_muted: bool) @export var menu_muted: bool var current_config_path: String = Settings.CONFIG_PATH +# --- NEW: SFX CACHING & MANAGEMENT --- +## Dictionary to store preloaded AudioStreams to prevent disk I/O stutter. +var _sfx_cache: Dictionary = {} func _ready() -> void: @@ -333,3 +340,55 @@ func reset_volumes() -> void: apply_all_volumes() save_volumes() Globals.log_message("Audio volumes reset to defaults.", Globals.LogLevel.DEBUG) + + +## Centralized SFX Playback API (Issue #565) +## Handles non-positional audio with caching and auto-cleanup. +## :param sfx_name: The filename without extension (e.g., "slider"). +## :param bus_name: Target audio bus (defaults to SFX_Menu). +## :param pitch_scale: Pitch override for variety. +## :param volume_db: Volume offset in decibels. +func play_sfx( + sfx_name: String, + bus_name: String = AudioConstants.BUS_SFX_MENU, + pitch_scale: float = 1.0, + volume_db: float = 0.0 +) -> void: + if sfx_name.is_empty(): + return + + # 1. Resolve and Cache the AudioStream + if not _sfx_cache.has(sfx_name): + var full_path: String = SFX_DIR_PATH + sfx_name + ".wav" + if not FileAccess.file_exists(full_path): + Globals.log_message("SFX file not found: " + full_path, Globals.LogLevel.WARNING) + return + + var stream: AudioStream = load(full_path) + if stream: + _sfx_cache[sfx_name] = stream + else: + Globals.log_message("Failed to load SFX stream: " + sfx_name, Globals.LogLevel.ERROR) + return + + # 2. Instantiate and Configure the Player + var player := AudioStreamPlayer.new() + add_child(player) + + player.stream = _sfx_cache[sfx_name] + player.pitch_scale = pitch_scale + player.volume_db = volume_db + + # 3. Bus Validation & Routing + if AudioServer.get_bus_index(bus_name) == -1: + Globals.log_message( + "Invalid bus '%s' requested for SFX. Falling back to SFX_Menu." % bus_name, + Globals.LogLevel.WARNING + ) + player.bus = AudioConstants.BUS_SFX_MENU + else: + player.bus = bus_name + + # 4. Play and Auto-Cleanup + player.play() + player.finished.connect(player.queue_free) From 77c27addd61227e34a1f0f7b9b126dba8ace17e5 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Thu, 23 Apr 2026 16:58:24 -0700 Subject: [PATCH 06/49] [FEATURE] Connect VolumeSlider to Contextual Audio Feedback #566 Description: With the centralized play_sfx() API available in AudioManager, the VolumeSlider component needs to provide distinct auditory feedback (slider.wav) when adjusted by the user. Crucially, this feedback must only trigger during manual interaction and must be strictly rate-limited. Tying the audio playback naively to input events or the value_changed signal will cause "sound storms" during initializations, programmatic updates, or rapid dragging. --- scripts/core/globals.gd | 9 ++- scripts/ui/components/volume_slider.gd | 87 +++++++++++++++++++++----- 2 files changed, 79 insertions(+), 17 deletions(-) diff --git a/scripts/core/globals.gd b/scripts/core/globals.gd index 518009a77..9a53488bc 100644 --- a/scripts/core/globals.gd +++ b/scripts/core/globals.gd @@ -377,7 +377,8 @@ static func set_game_version_for_tests(value: String) -> void: ## Use _input instead of _unhandled_input to catch events BEFORE the UI consumes them. func _input(_event: InputEvent) -> void: # The Ultimate Menu Check: Does a UI element currently have keyboard/gamepad focus? - var ui_has_focus: bool = is_instance_valid(get_viewport().gui_get_focus_owner()) + var focus_owner: Control = get_viewport().gui_get_focus_owner() + var ui_has_focus: bool = is_instance_valid(focus_owner) # Gate 1: Only play UI sounds if a UI element is focused OR we are in a known menu state var is_menu_context: bool = ( @@ -392,6 +393,12 @@ func _input(_event: InputEvent) -> void: # We use the global Input singleton here because it perfectly handles # analog joystick deadzone debouncing, which event.is_echo() misses. if Input.is_action_just_pressed(action): + + # NEW: Prevent double-audio when adjusting sliders. + # If a slider has focus, left/right adjusts the value instead of navigating. + if focus_owner is Slider and (action == "ui_left" or action == "ui_right"): + return + _play_ui_navigation_sfx() return # Exit once sound is triggered to avoid double-plays diff --git a/scripts/ui/components/volume_slider.gd b/scripts/ui/components/volume_slider.gd index d90d11409..47693f673 100644 --- a/scripts/ui/components/volume_slider.gd +++ b/scripts/ui/components/volume_slider.gd @@ -1,28 +1,40 @@ ## Copyright (C) 2025 Egor Kostan ## SPDX-License-Identifier: GPL-3.0-or-later -# New: Register as global class for testing and reuse -class_name VolumeSlider +## volume_slider.gd +## +## Handles the volume control slider UI component. +## Sends volume updates to AudioManager and handles debounced saving. +## Plays rate-limited SFX exclusively on manual user interactions. +class_name VolumeSlider extends HSlider @export var bus_name: String var bus_index: int -# New: Debounce timer for saving settings +## Debounce timer for saving settings to avoid disk I/O spam var save_debounce_timer: Timer +# --- SFX Rate Limiting and State --- +var _last_sfx_time: int = 0 +const SFX_COOLDOWN_MS: int = 60 +var _previous_value: float = -1.0 +var _is_dragging: bool = false + func _ready() -> void: # Get bus id by name bus_index = AudioServer.get_bus_index(bus_name) # Set current bus volume value first (without triggering signal yet) - value = db_to_linear(AudioServer.get_bus_volume_db(bus_index)) + var initial_val: float = db_to_linear(AudioServer.get_bus_volume_db(bus_index)) + _previous_value = initial_val + value = initial_val # Now connect signal for future changes value_changed.connect(_on_value_changed) - # New: Initialize debounce timer (0.5s delay, one-shot) + # Initialize debounce timer (0.5s delay, one-shot) save_debounce_timer = Timer.new() save_debounce_timer.wait_time = 0.5 save_debounce_timer.one_shot = true @@ -30,19 +42,62 @@ func _ready() -> void: add_child(save_debounce_timer) # Add to scene tree for auto-processing -# change bus value/volume -func _on_value_changed(value: float) -> void: - AudioServer.set_bus_volume_db(bus_index, linear_to_db(value)) - AudioManager.set_volume(bus_name, value) - # New: Start/restart debounce timer instead of immediate save - if save_debounce_timer.is_stopped(): - save_debounce_timer.start() - else: - save_debounce_timer.stop() - save_debounce_timer.start() +## Tracks mouse drag state for accurate interaction gating, even if the cursor +## leaves the slider's bounding box while dragging. +## :param event: The input event passed by the UI system. +## :type event: InputEvent +## :rtype: void +func _gui_input(event: InputEvent) -> void: + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: + _is_dragging = event.pressed + + +## Signal listener for when the slider value changes (manual or programmatic). +## :param new_value: The new volume level from the slider (0.0 to 1.0). +## :type new_value: float +## :rtype: void +func _on_value_changed(new_value: float) -> void: + AudioServer.set_bus_volume_db(bus_index, linear_to_db(new_value)) + AudioManager.set_volume(bus_name, new_value) + + # Attempt to play interaction feedback + _handle_slider_sfx(new_value) + + # Godot automatically restarts an active timer when start() is called + save_debounce_timer.start() + + +## Guards SFX playback against programmatic changes, redundant values, and rapid spam. +## Ensures sound only plays during legitimate, rate-limited user interactions. +## :param new_value: The updated slider value. +## :type new_value: float +## :rtype: void +func _handle_slider_sfx(new_value: float) -> void: + # Guard 1: Only play if the value actually changed (float-safe delta check) + if is_equal_approx(new_value, _previous_value): + return + + # Guard 2: Only play if user is actively interacting + var is_mouse_active: bool = _is_dragging + var is_keyboard_active: bool = has_focus() + + if not (is_mouse_active or is_keyboard_active): + return + + # Guard 3: Rate limit to prevent audio spam during rapid drags + var current_time: int = Time.get_ticks_msec() + if current_time - _last_sfx_time < SFX_COOLDOWN_MS: + return + + # Commit state only after all guards pass + _last_sfx_time = current_time + _previous_value = new_value + + AudioManager.play_sfx("slider") -# New: Called on timer timeout—perform the batched save +## Called on timer timeout—performs the batched disk save. +## :rtype: void func _on_debounce_timeout() -> void: AudioManager.save_volumes() Globals.log_message("Debounced settings save triggered.", Globals.LogLevel.DEBUG) From c415e2221d2c077b840ba1b1189cb8fb9d1a434e Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Thu, 23 Apr 2026 17:02:30 -0700 Subject: [PATCH 07/49] Update globals.gd --- scripts/core/globals.gd | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/core/globals.gd b/scripts/core/globals.gd index 9a53488bc..a108a3a09 100644 --- a/scripts/core/globals.gd +++ b/scripts/core/globals.gd @@ -393,12 +393,11 @@ func _input(_event: InputEvent) -> void: # We use the global Input singleton here because it perfectly handles # analog joystick deadzone debouncing, which event.is_echo() misses. if Input.is_action_just_pressed(action): - # NEW: Prevent double-audio when adjusting sliders. # If a slider has focus, left/right adjusts the value instead of navigating. if focus_owner is Slider and (action == "ui_left" or action == "ui_right"): - return - + return + _play_ui_navigation_sfx() return # Exit once sound is triggered to avoid double-plays From 461b09d5b3799f491bfcecdbefa917bbc4d81957 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Thu, 23 Apr 2026 17:02:33 -0700 Subject: [PATCH 08/49] Update volume_slider.gd --- scripts/ui/components/volume_slider.gd | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/scripts/ui/components/volume_slider.gd b/scripts/ui/components/volume_slider.gd index 47693f673..8cd617fcc 100644 --- a/scripts/ui/components/volume_slider.gd +++ b/scripts/ui/components/volume_slider.gd @@ -9,15 +9,15 @@ class_name VolumeSlider extends HSlider +const SFX_COOLDOWN_MS: int = 60 + @export var bus_name: String -var bus_index: int +var bus_index: int ## Debounce timer for saving settings to avoid disk I/O spam var save_debounce_timer: Timer - # --- SFX Rate Limiting and State --- var _last_sfx_time: int = 0 -const SFX_COOLDOWN_MS: int = 60 var _previous_value: float = -1.0 var _is_dragging: bool = false @@ -59,10 +59,10 @@ func _gui_input(event: InputEvent) -> void: func _on_value_changed(new_value: float) -> void: AudioServer.set_bus_volume_db(bus_index, linear_to_db(new_value)) AudioManager.set_volume(bus_name, new_value) - + # Attempt to play interaction feedback _handle_slider_sfx(new_value) - + # Godot automatically restarts an active timer when start() is called save_debounce_timer.start() @@ -76,23 +76,23 @@ func _handle_slider_sfx(new_value: float) -> void: # Guard 1: Only play if the value actually changed (float-safe delta check) if is_equal_approx(new_value, _previous_value): return - + # Guard 2: Only play if user is actively interacting var is_mouse_active: bool = _is_dragging var is_keyboard_active: bool = has_focus() - + if not (is_mouse_active or is_keyboard_active): return - + # Guard 3: Rate limit to prevent audio spam during rapid drags var current_time: int = Time.get_ticks_msec() if current_time - _last_sfx_time < SFX_COOLDOWN_MS: return - + # Commit state only after all guards pass _last_sfx_time = current_time _previous_value = new_value - + AudioManager.play_sfx("slider") From d74766260f956c06c945012122e0f478a1083fcf Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Thu, 23 Apr 2026 17:18:27 -0700 Subject: [PATCH 09/49] Update .all-contributorsrc --- .all-contributorsrc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index bf6e2695a..a0ba71869 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -6,5 +6,6 @@ "files": ["README.md"], "imageSize": 100, "contributorsPerLine": 7, - "badgeTemplate": "[/<%= projectName %>?color=ee8449&style=flat-square\" >](<%= contributorsUrl %>)" + "contributors": [], + "badgeTemplate": "..." } \ No newline at end of file From d668e654796e162de264bd997e3f1b66af3c4ffa Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Thu, 23 Apr 2026 17:24:11 -0700 Subject: [PATCH 10/49] =?UTF-8?q?suggestion=20(bug=5Frisk):=20Programmatic?= =?UTF-8?q?=20updates=20while=20the=20slider=20has=20focus=20will=20still?= =?UTF-8?q?=20trigger=20SFX=20as=20=E2=80=9Ckeyboard=20interaction?= =?UTF-8?q?=E2=80=9D.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Because is_keyboard_active is based only on has_focus(), any programmatic volume change while this slider is focused will still trigger SFX (e.g. state restore, presets, external sync). To keep SFX limited to intentional user input, you could add a guard flag (e.g. _is_programmatic_change) around programmatic value updates and early-return in _handle_slider_sfx when it’s set, or route actual keyboard-driven changes through _gui_input and infer input type there instead of using focus alone. --- scripts/ui/components/volume_slider.gd | 41 +++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/scripts/ui/components/volume_slider.gd b/scripts/ui/components/volume_slider.gd index 8cd617fcc..2514f259d 100644 --- a/scripts/ui/components/volume_slider.gd +++ b/scripts/ui/components/volume_slider.gd @@ -4,24 +4,42 @@ ## ## Handles the volume control slider UI component. ## Sends volume updates to AudioManager and handles debounced saving. -## Plays rate-limited SFX exclusively on manual user interactions. +## Plays rate-limited SFX exclusively on manual user interactions, +## safely ignoring programmatic volume changes to prevent audio spam. class_name VolumeSlider extends HSlider +## The cooldown in milliseconds to prevent audio spam during rapid slider drags. const SFX_COOLDOWN_MS: int = 60 +## The name of the audio bus this slider controls (e.g., "Master", "Music"). @export var bus_name: String +## The internal index of the audio bus, resolved at runtime. var bus_index: int -## Debounce timer for saving settings to avoid disk I/O spam + +## Debounce timer for saving settings to avoid disk I/O spam during continuous sliding. var save_debounce_timer: Timer + # --- SFX Rate Limiting and State --- + +## Timestamp of the last played SFX to enforce the cooldown. var _last_sfx_time: int = 0 + +## Tracks the previous value to ensure SFX only plays on actual deltas. var _previous_value: float = -1.0 + +## Tracks whether the user is actively holding the mouse button down over the slider. var _is_dragging: bool = false +## Guard flag to explicitly mute SFX during programmatic value updates (e.g., loading presets). +var _is_programmatic_change: bool = false + +## Initializes the slider, resolves the bus index, syncs the initial value without +## triggering signals, and sets up the debounce timer. +## :rtype: void func _ready() -> void: # Get bus id by name bus_index = AudioServer.get_bus_index(bus_name) @@ -39,7 +57,18 @@ func _ready() -> void: save_debounce_timer.wait_time = 0.5 save_debounce_timer.one_shot = true save_debounce_timer.timeout.connect(_on_debounce_timeout) - add_child(save_debounce_timer) # Add to scene tree for auto-processing + add_child(save_debounce_timer) + + +## Safe method for external scripts to update the slider without triggering SFX. +## Use this instead of modifying `value` directly when restoring settings. +## :param new_value: The target volume (0.0 to 1.0). +## :type new_value: float +## :rtype: void +func set_value_programmatically(new_value: float) -> void: + _is_programmatic_change = true + value = new_value + _is_programmatic_change = false ## Tracks mouse drag state for accurate interaction gating, even if the cursor @@ -73,11 +102,15 @@ func _on_value_changed(new_value: float) -> void: ## :type new_value: float ## :rtype: void func _handle_slider_sfx(new_value: float) -> void: + # Guard 0: Ignore explicitly marked programmatic changes (e.g. from UI syncs) + if _is_programmatic_change: + return + # Guard 1: Only play if the value actually changed (float-safe delta check) if is_equal_approx(new_value, _previous_value): return - # Guard 2: Only play if user is actively interacting + # Guard 2: Only play if user is actively interacting (Mouse Drag or Keyboard Focus) var is_mouse_active: bool = _is_dragging var is_keyboard_active: bool = has_focus() From 41a65f52c4656fd0b1367be820ca327a80775588 Mon Sep 17 00:00:00 2001 From: Egor Kostan <20955183+ikostan@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:26:08 -0700 Subject: [PATCH 11/49] Update README.md Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 122328ab4..b23533d53 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ You can play this game on [Itch.io](https://ikostan.itch.io/sky-lock-assault) - [Release Drafter](https://github.com/release-drafter/release-drafter?tab=readme-ov-file#readme) - [Close Stale Issues and PRs](https://github.com/actions/stale) - [AllContributors GitHub App](https://allcontributors.org/docs/en/bot/installation) - - [Deepsource](https://github.com/deepsource) + - [DeepSource](https://github.com/deepsource) 9. [Free Web Browser Game Deployment Platforms](files/docs/Platforms_for_Web_Deployment_Guide.md) From 95acc706b2a9e207fb3083499e39ae3d6ba9840f Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Thu, 23 Apr 2026 17:32:54 -0700 Subject: [PATCH 12/49] Update .all-contributorsrc --- .all-contributorsrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index a0ba71869..fcc323b3f 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -7,5 +7,5 @@ "imageSize": 100, "contributorsPerLine": 7, "contributors": [], - "badgeTemplate": "..." + "badgeTemplate": "[/<%= projectName %>?color=ee8449&style=flat-square\" >](<%= contributorsUrl %>)" } \ No newline at end of file From 3f5112ffa17b8ea3e2126d955b69a5ae956def09 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Thu, 23 Apr 2026 17:43:42 -0700 Subject: [PATCH 13/49] Update volume_slider.gd --- scripts/ui/components/volume_slider.gd | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/ui/components/volume_slider.gd b/scripts/ui/components/volume_slider.gd index 2514f259d..a2d8039f7 100644 --- a/scripts/ui/components/volume_slider.gd +++ b/scripts/ui/components/volume_slider.gd @@ -52,6 +52,9 @@ func _ready() -> void: # Now connect signal for future changes value_changed.connect(_on_value_changed) + # Safely track input without overriding the base _gui_input virtual method + gui_input.connect(_on_gui_input) + # Initialize debounce timer (0.5s delay, one-shot) save_debounce_timer = Timer.new() save_debounce_timer.wait_time = 0.5 @@ -76,7 +79,7 @@ func set_value_programmatically(new_value: float) -> void: ## :param event: The input event passed by the UI system. ## :type event: InputEvent ## :rtype: void -func _gui_input(event: InputEvent) -> void: +func _on_gui_input(event: InputEvent) -> void: if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: _is_dragging = event.pressed From ba625396a39d17e2f22b8c1e405de67cb708633a Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Thu, 23 Apr 2026 17:47:36 -0700 Subject: [PATCH 14/49] Update volume_slider.gd Programmatic volume updates via set_value_programmatically are still starting the debounce timer and causing a save, which can lead to unnecessary disk writes on load; consider skipping save_debounce_timer.start() when _is_programmatic_change is true. This feedback highlights an excellent optimization point. Since Godot's signals are synchronous, setting value programmatically immediately triggers _on_value_changed while the _is_programmatic_change flag is still true. If we don't block the timer during this process, loading settings or hitting a "Reset to Defaults" button will fire off the debounce timer for every single slider on the screen, causing unnecessary disk write operations half a second later. To fix this, we simply need to wrap the save_debounce_timer.start() call inside _on_value_changed with the same _is_programmatic_change guard we used for the sound effects. --- scripts/ui/components/volume_slider.gd | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/ui/components/volume_slider.gd b/scripts/ui/components/volume_slider.gd index a2d8039f7..3080026cf 100644 --- a/scripts/ui/components/volume_slider.gd +++ b/scripts/ui/components/volume_slider.gd @@ -33,7 +33,7 @@ var _previous_value: float = -1.0 ## Tracks whether the user is actively holding the mouse button down over the slider. var _is_dragging: bool = false -## Guard flag to explicitly mute SFX during programmatic value updates (e.g., loading presets). +## Guard flag to explicitly mute SFX and prevent saves during programmatic value updates. var _is_programmatic_change: bool = false @@ -63,7 +63,7 @@ func _ready() -> void: add_child(save_debounce_timer) -## Safe method for external scripts to update the slider without triggering SFX. +## Safe method for external scripts to update the slider without triggering SFX or saves. ## Use this instead of modifying `value` directly when restoring settings. ## :param new_value: The target volume (0.0 to 1.0). ## :type new_value: float @@ -95,8 +95,10 @@ func _on_value_changed(new_value: float) -> void: # Attempt to play interaction feedback _handle_slider_sfx(new_value) - # Godot automatically restarts an active timer when start() is called - save_debounce_timer.start() + # Prevent disk I/O spam during programmatic updates (like initial load or presets) + if not _is_programmatic_change: + # Godot automatically restarts an active timer when start() is called + save_debounce_timer.start() ## Guards SFX playback against programmatic changes, redundant values, and rapid spam. From 07411790b1f490fd57b325926c49f9a231d10e76 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Thu, 23 Apr 2026 17:53:55 -0700 Subject: [PATCH 15/49] audio_constants.gd singleton specifically built for preventing typos with bus names, it is the absolute perfect home for these SFX IDs. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SFX name "slider" is currently a hardcoded string in VolumeSlider; consider introducing a small constants enum or central mapping for SFX IDs in AudioManager to avoid stringly‑typed coupling across UI components. This is another brilliant piece of architectural feedback. Using "magic strings" like "slider" scattered across different UI components makes the codebase brittle. If you ever want to change the file name or rename the ID, hunting down every hardcoded string is a nightmare. Since we already have a dedicated audio_constants.gd singleton specifically built for preventing typos with bus names, it is the absolute perfect home for these SFX IDs. --- scripts/resources/audio_constants.gd | 6 ++++++ scripts/ui/components/volume_slider.gd | 13 +++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/scripts/resources/audio_constants.gd b/scripts/resources/audio_constants.gd index bec134db2..c8f7883d6 100644 --- a/scripts/resources/audio_constants.gd +++ b/scripts/resources/audio_constants.gd @@ -7,6 +7,7 @@ extends Node +# --- Audio Bus Names --- const BUS_MASTER: String = "Master" const BUS_MUSIC: String = "Music" const BUS_SFX: String = "SFX" @@ -14,6 +15,11 @@ const BUS_SFX_ROTORS: String = "SFX_Rotors" const BUS_SFX_WEAPON: String = "SFX_Weapon" const BUS_SFX_MENU: String = "SFX_Menu" +# --- SFX Asset IDs --- +const SFX_SLIDER: String = "slider" +const SFX_MUTE_TOGGLE: String = "mute_toggle" # For future CheckButton task +const SFX_UI_NAVIGATION: String = "ui_navigation" + # Centralized config with defaults and var mappings const BUS_CONFIG: Dictionary = { BUS_MASTER: diff --git a/scripts/ui/components/volume_slider.gd b/scripts/ui/components/volume_slider.gd index 3080026cf..e9005e49b 100644 --- a/scripts/ui/components/volume_slider.gd +++ b/scripts/ui/components/volume_slider.gd @@ -114,24 +114,25 @@ func _handle_slider_sfx(new_value: float) -> void: # Guard 1: Only play if the value actually changed (float-safe delta check) if is_equal_approx(new_value, _previous_value): return - + # Guard 2: Only play if user is actively interacting (Mouse Drag or Keyboard Focus) var is_mouse_active: bool = _is_dragging var is_keyboard_active: bool = has_focus() - + if not (is_mouse_active or is_keyboard_active): return - + # Guard 3: Rate limit to prevent audio spam during rapid drags var current_time: int = Time.get_ticks_msec() if current_time - _last_sfx_time < SFX_COOLDOWN_MS: return - + # Commit state only after all guards pass _last_sfx_time = current_time _previous_value = new_value - - AudioManager.play_sfx("slider") + + # NO MORE MAGIC STRINGS! + AudioManager.play_sfx(AudioConstants.SFX_SLIDER) ## Called on timer timeout—performs the batched disk save. From 2e5bca6c7cc5e5dd10f1a65b5b85e23345674b51 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Thu, 23 Apr 2026 17:55:31 -0700 Subject: [PATCH 16/49] Update volume_slider.gd --- scripts/ui/components/volume_slider.gd | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/ui/components/volume_slider.gd b/scripts/ui/components/volume_slider.gd index e9005e49b..cae6d6932 100644 --- a/scripts/ui/components/volume_slider.gd +++ b/scripts/ui/components/volume_slider.gd @@ -114,23 +114,23 @@ func _handle_slider_sfx(new_value: float) -> void: # Guard 1: Only play if the value actually changed (float-safe delta check) if is_equal_approx(new_value, _previous_value): return - + # Guard 2: Only play if user is actively interacting (Mouse Drag or Keyboard Focus) var is_mouse_active: bool = _is_dragging var is_keyboard_active: bool = has_focus() - + if not (is_mouse_active or is_keyboard_active): return - + # Guard 3: Rate limit to prevent audio spam during rapid drags var current_time: int = Time.get_ticks_msec() if current_time - _last_sfx_time < SFX_COOLDOWN_MS: return - + # Commit state only after all guards pass _last_sfx_time = current_time _previous_value = new_value - + # NO MORE MAGIC STRINGS! AudioManager.play_sfx(AudioConstants.SFX_SLIDER) From 63e6a2ab832d41363d7c94cb828e8faa6a48b42c Mon Sep 17 00:00:00 2001 From: Egor Kostan <20955183+ikostan@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:59:30 -0700 Subject: [PATCH 17/49] Update README.md Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b23533d53..a2024e372 100644 --- a/README.md +++ b/README.md @@ -314,7 +314,7 @@ unit testing. ### Milestone 16 -**Status:** Stable gameplay loop with fully refactored scripts architecture, +**Status:** Stable gameplay loop with fully refactored script architecture, synced UI systems, and GUT-based unit testing. **Active Focus:** Gameplay expansion (AI enemies, multiplayer, levels). From 28d30a31842a61be021ca732d17136424b61f4ce Mon Sep 17 00:00:00 2001 From: Egor Kostan <20955183+ikostan@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:00:41 -0700 Subject: [PATCH 18/49] Update scripts/managers/audio_manager.gd Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- scripts/managers/audio_manager.gd | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/scripts/managers/audio_manager.gd b/scripts/managers/audio_manager.gd index 476042f43..b465cfadf 100644 --- a/scripts/managers/audio_manager.gd +++ b/scripts/managers/audio_manager.gd @@ -360,15 +360,11 @@ func play_sfx( # 1. Resolve and Cache the AudioStream if not _sfx_cache.has(sfx_name): var full_path: String = SFX_DIR_PATH + sfx_name + ".wav" - if not FileAccess.file_exists(full_path): - Globals.log_message("SFX file not found: " + full_path, Globals.LogLevel.WARNING) - return - var stream: AudioStream = load(full_path) if stream: _sfx_cache[sfx_name] = stream else: - Globals.log_message("Failed to load SFX stream: " + sfx_name, Globals.LogLevel.ERROR) + Globals.log_message("SFX file not found or failed to load: " + full_path, Globals.LogLevel.WARNING) return # 2. Instantiate and Configure the Player From 4d17a2fdc38cf49eadb775e9ba6e90fc158573f3 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Thu, 23 Apr 2026 18:01:30 -0700 Subject: [PATCH 19/49] Update audio_manager.gd --- scripts/managers/audio_manager.gd | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/managers/audio_manager.gd b/scripts/managers/audio_manager.gd index b465cfadf..b9d1e122f 100644 --- a/scripts/managers/audio_manager.gd +++ b/scripts/managers/audio_manager.gd @@ -364,7 +364,9 @@ func play_sfx( if stream: _sfx_cache[sfx_name] = stream else: - Globals.log_message("SFX file not found or failed to load: " + full_path, Globals.LogLevel.WARNING) + Globals.log_message( + "SFX file not found or failed to load: " + full_path, Globals.LogLevel.WARNING + ) return # 2. Instantiate and Configure the Player From 9261c1a082f8fb3bfdda8f9e67655b45c5b0f768 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Thu, 23 Apr 2026 18:27:10 -0700 Subject: [PATCH 20/49] [FEATURE] Verify Signal Decoupling for Web and UI Sync #567 --- test/gut/test_audio_sync_decoupling.gd | 84 ++++++++++++++++++++++ test/gut/test_audio_sync_decoupling.gd.uid | 1 + 2 files changed, 85 insertions(+) create mode 100644 test/gut/test_audio_sync_decoupling.gd create mode 100644 test/gut/test_audio_sync_decoupling.gd.uid diff --git a/test/gut/test_audio_sync_decoupling.gd b/test/gut/test_audio_sync_decoupling.gd new file mode 100644 index 000000000..8ad6b8fd3 --- /dev/null +++ b/test/gut/test_audio_sync_decoupling.gd @@ -0,0 +1,84 @@ +## Copyright (C) 2026 Egor Kostan +## SPDX-License-Identifier: GPL-3.0-or-later +## test_audio_sync_decoupling.gd +## +## TEST SUITE: Verifies Signal Decoupling for Web and UI Sync (Issue #567). +## Ensures that programmatic volume updates from the Web Bridge or AudioManager +## do not trigger the slider's value_changed signal, preventing audio feedback +## loops and redundant disk I/O. + +extends "res://addons/gut/test.gd" + +var audio_scene: PackedScene = load(GamePaths.AUDIO_SETTINGS_SCENE) +var audio_instance: Control +var test_config_path: String = "user://test_audio_sync.cfg" + +## Per-test setup: Instantiate audio scene, reset state +## :rtype: void +func before_each() -> void: + if FileAccess.file_exists(test_config_path): + DirAccess.remove_absolute(test_config_path) + + AudioManager.current_config_path = test_config_path + AudioManager.master_volume = 1.0 + AudioManager.sfx_volume = 1.0 + + audio_instance = audio_scene.instantiate() as Control + add_child_autofree(audio_instance) + + +## Per-test cleanup: Free audio_instance safely. +## :rtype: void +func after_each() -> void: + if is_instance_valid(audio_instance): + if is_instance_valid(audio_instance.master_warning_dialog): + audio_instance.master_warning_dialog.hide() + if is_instance_valid(audio_instance.sfx_warning_dialog): + audio_instance.sfx_warning_dialog.hide() + remove_child(audio_instance) + audio_instance.queue_free() + + if FileAccess.file_exists(test_config_path): + DirAccess.remove_absolute(test_config_path) + await get_tree().process_frame + + +## Verifies that global volume changes (e.g., from Web Bridge) update the UI +## without firing the value_changed signal. +## :rtype: void +func test_global_volume_changed_bypasses_signals() -> void: + # Precondition: Ensure the timer is stopped + assert_true(audio_instance.master_slider.save_debounce_timer.is_stopped(), "Timer should be stopped initially.") + + # Act: Simulate an incoming Web Bridge sync event + var new_volume: float = 0.35 + audio_instance._on_global_volume_changed(AudioConstants.BUS_MASTER, new_volume) + + # Assert: The slider visually updated, but the timer (and thus SFX) was NOT triggered + assert_eq(audio_instance.master_slider.value, new_volume, "Slider value should reflect the global change.") + assert_true( + audio_instance.master_slider.save_debounce_timer.is_stopped(), + "Debounce timer MUST remain stopped. If it started, set_value_no_signal() was not used, risking an audio feedback loop." + ) + + +## Verifies that syncing the UI from the AudioManager (e.g., during Reset) +## updates all sliders without firing their signals. +## :rtype: void +func test_sync_ui_from_manager_bypasses_signals() -> void: + # Precondition: Ensure the timer is stopped + assert_true(audio_instance.sfx_slider.save_debounce_timer.is_stopped(), "Timer should be stopped initially.") + + # Setup: Change the backend AudioManager state silently + var new_sfx_volume: float = 0.8 + AudioManager.sfx_volume = new_sfx_volume + + # Act: Force the UI to pull the latest state + audio_instance._sync_ui_from_manager() + + # Assert: The slider updated, but no signals were emitted + assert_eq(audio_instance.sfx_slider.value, new_sfx_volume, "SFX Slider should sync to the new AudioManager value.") + assert_true( + audio_instance.sfx_slider.save_debounce_timer.is_stopped(), + "Debounce timer MUST remain stopped during a full UI sync. Ensures no initialization sound storms." + ) diff --git a/test/gut/test_audio_sync_decoupling.gd.uid b/test/gut/test_audio_sync_decoupling.gd.uid new file mode 100644 index 000000000..fe3dea009 --- /dev/null +++ b/test/gut/test_audio_sync_decoupling.gd.uid @@ -0,0 +1 @@ +uid://esn13vgqofel From b71e4a9bc59ebd800b0a3c297885c0a680c650b8 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Thu, 23 Apr 2026 18:35:03 -0700 Subject: [PATCH 21/49] New GUT unit tests --- test/gut/test_volume_slider.gd | 120 +++++++++++++++++++++++++++++ test/gut/test_volume_slider.gd.uid | 1 + 2 files changed, 121 insertions(+) create mode 100644 test/gut/test_volume_slider.gd create mode 100644 test/gut/test_volume_slider.gd.uid diff --git a/test/gut/test_volume_slider.gd b/test/gut/test_volume_slider.gd new file mode 100644 index 000000000..dd21caff5 --- /dev/null +++ b/test/gut/test_volume_slider.gd @@ -0,0 +1,120 @@ +## Copyright (C) 2026 Egor Kostan +## SPDX-License-Identifier: GPL-3.0-or-later +## test_volume_slider.gd +## +## TEST SUITE: Verifies the isolated logic of the VolumeSlider component. +## Covers initialization, programmatic update guards, and SFX interaction/rate-limiting guards. + +extends "res://addons/gut/test.gd" + +var _slider: VolumeSlider + + +func before_each() -> void: + _slider = VolumeSlider.new() + _slider.bus_name = AudioConstants.BUS_MASTER + + # FIX: Replicate the Inspector settings for a volume slider + # Without this, HSlider defaults to step=1.0 and snaps all floats! + _slider.max_value = 1.0 + _slider.step = 0.001 + + # Add to the tree to ensure _ready() fires and UI state works + add_child_autoqfree(_slider) + + +# ========================================== +# INITIALIZATION & PROGRAMMATIC GUARDS +# ========================================== + +func test_initialization() -> void: + assert_not_null(_slider.save_debounce_timer, "Debounce timer should be created on _ready") + assert_true(_slider.save_debounce_timer.is_stopped(), "Timer should not be running immediately after initialization") + + +func test_programmatic_change_blocks_debounce_timer() -> void: + _slider.set_value_programmatically(0.5) + + assert_eq(_slider.value, 0.5, "Slider value should reflect the programmatic update") + assert_true( + _slider.save_debounce_timer.is_stopped(), + "Debounce timer MUST remain stopped during programmatic changes to prevent disk I/O spam" + ) + + +func test_manual_value_change_starts_debounce_timer() -> void: + # Simulate a standard UI value change + _slider.value = 0.8 + + assert_false( + _slider.save_debounce_timer.is_stopped(), + "Debounce timer MUST start when a value is changed manually" + ) + + +func test_programmatic_change_does_not_alter_drag_state() -> void: + _slider.set_value_programmatically(0.2) + assert_false(_slider._is_dragging, "Programmatic changes should not affect the _is_dragging state") + + +# ========================================== +# SFX UX GUARDS +# ========================================== + +func test_sfx_guard_blocks_identical_values() -> void: + # Setup: Set an initial value and simulate an interaction + _slider.value = 0.5 + _slider._previous_value = 0.5 + _slider._is_dragging = true + var initial_sfx_time: int = _slider._last_sfx_time + + # Act: Try to trigger SFX with the exact same value + _slider._handle_slider_sfx(0.5) + + # Assert: The time shouldn't update because Guard 1 blocked it + assert_eq(_slider._last_sfx_time, initial_sfx_time, "SFX must be blocked if the value hasn't actually changed.") + + +func test_sfx_guard_blocks_no_interaction() -> void: + # Setup: New value, but NO interaction (not dragging, no focus) + _slider.value = 0.5 + _slider._previous_value = 0.2 + _slider._is_dragging = false + _slider.release_focus() + var initial_sfx_time: int = _slider._last_sfx_time + + # Act: Try to trigger SFX + _slider._handle_slider_sfx(0.5) + + # Assert: The time shouldn't update because Guard 2 blocked it + assert_eq(_slider._last_sfx_time, initial_sfx_time, "SFX must be blocked if the user isn't actively interacting.") + + +func test_sfx_guard_allows_valid_interaction() -> void: + # Setup: Different value AND active interaction + _slider._previous_value = 0.2 + _slider._is_dragging = true + _slider._last_sfx_time = 0 # Ensure no cooldown interference + + # Act: Trigger SFX + _slider._handle_slider_sfx(0.5) + + # Assert: All guards passed, state was committed + assert_ne(_slider._last_sfx_time, 0, "SFX time should update when a valid, manual value delta occurs.") + assert_eq(_slider._previous_value, 0.5, "Previous value should be updated after successful SFX trigger.") + + +func test_sfx_guard_enforces_rate_limiting() -> void: + # Setup: Valid interaction, but we JUST played a sound + _slider._previous_value = 0.2 + _slider._is_dragging = true + + # Force the last sfx time to be right now + var current_time: int = Time.get_ticks_msec() + _slider._last_sfx_time = current_time + + # Act: Try to trigger another sound immediately with a new value + _slider._handle_slider_sfx(0.6) + + # Assert: It should have been blocked by the SFX_COOLDOWN_MS guard + assert_eq(_slider._last_sfx_time, current_time, "Rate limiter MUST block sounds requested faster than the cooldown window.") diff --git a/test/gut/test_volume_slider.gd.uid b/test/gut/test_volume_slider.gd.uid new file mode 100644 index 000000000..6be536555 --- /dev/null +++ b/test/gut/test_volume_slider.gd.uid @@ -0,0 +1 @@ +uid://33pfsdmk7jbg From 11f4eba4b55dd1dcae512567977d8fc4e458dd79 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Thu, 23 Apr 2026 18:41:37 -0700 Subject: [PATCH 22/49] Update volume_slider.gd In VolumeSlider.set_value_programmatically, you currently guard behavior with _is_programmatic_change; Godot 4 provides set_value_no_signal() for this pattern, which would allow you to avoid a manual flag and reduce the chance of future callers forgetting to wrap changes correctly. This feedback points out a much cleaner, more "Godot-native" approach. Relying on custom boolean flags like _is_programmatic_change to block signal logic is a common workaround, but since Godot natively provides set_value_no_signal(), we can achieve the exact same protection without the messy state tracking. Because set_value_no_signal() updates the visual slider without emitting the value_changed signal, it completely bypasses the _on_value_changed method. This means the SFX and debounce timer will naturally never be triggered during programmatic updates, allowing us to safely strip out the manual guard flag entirely. --- scripts/ui/components/volume_slider.gd | 52 +++++++++++++------------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/scripts/ui/components/volume_slider.gd b/scripts/ui/components/volume_slider.gd index cae6d6932..199033735 100644 --- a/scripts/ui/components/volume_slider.gd +++ b/scripts/ui/components/volume_slider.gd @@ -33,9 +33,6 @@ var _previous_value: float = -1.0 ## Tracks whether the user is actively holding the mouse button down over the slider. var _is_dragging: bool = false -## Guard flag to explicitly mute SFX and prevent saves during programmatic value updates. -var _is_programmatic_change: bool = false - ## Initializes the slider, resolves the bus index, syncs the initial value without ## triggering signals, and sets up the debounce timer. @@ -51,7 +48,7 @@ func _ready() -> void: # Now connect signal for future changes value_changed.connect(_on_value_changed) - + # Safely track input without overriding the base _gui_input virtual method gui_input.connect(_on_gui_input) @@ -69,9 +66,15 @@ func _ready() -> void: ## :type new_value: float ## :rtype: void func set_value_programmatically(new_value: float) -> void: - _is_programmatic_change = true - value = new_value - _is_programmatic_change = false + # Godot 4 native method: updates visual value without emitting 'value_changed' + set_value_no_signal(new_value) + + # Explicitly sync the audio backend, since the signal was bypassed + AudioServer.set_bus_volume_db(bus_index, linear_to_db(new_value)) + AudioManager.set_volume(bus_name, new_value) + + # Sync the delta tracker so the next manual interaction calculates correctly + _previous_value = new_value ## Tracks mouse drag state for accurate interaction gating, even if the cursor @@ -84,54 +87,49 @@ func _on_gui_input(event: InputEvent) -> void: _is_dragging = event.pressed -## Signal listener for when the slider value changes (manual or programmatic). +## Signal listener for when the slider value changes manually. ## :param new_value: The new volume level from the slider (0.0 to 1.0). ## :type new_value: float ## :rtype: void func _on_value_changed(new_value: float) -> void: AudioServer.set_bus_volume_db(bus_index, linear_to_db(new_value)) AudioManager.set_volume(bus_name, new_value) - + # Attempt to play interaction feedback _handle_slider_sfx(new_value) + + # Godot automatically restarts an active timer when start() is called + save_debounce_timer.start() - # Prevent disk I/O spam during programmatic updates (like initial load or presets) - if not _is_programmatic_change: - # Godot automatically restarts an active timer when start() is called - save_debounce_timer.start() - -## Guards SFX playback against programmatic changes, redundant values, and rapid spam. +## Guards SFX playback against redundant values and rapid spam. ## Ensures sound only plays during legitimate, rate-limited user interactions. ## :param new_value: The updated slider value. ## :type new_value: float ## :rtype: void func _handle_slider_sfx(new_value: float) -> void: - # Guard 0: Ignore explicitly marked programmatic changes (e.g. from UI syncs) - if _is_programmatic_change: - return - # Guard 1: Only play if the value actually changed (float-safe delta check) if is_equal_approx(new_value, _previous_value): return - + + # Commit the value tracker early so delta checks stay accurate even if rate-limited + _previous_value = new_value + # Guard 2: Only play if user is actively interacting (Mouse Drag or Keyboard Focus) var is_mouse_active: bool = _is_dragging var is_keyboard_active: bool = has_focus() - + if not (is_mouse_active or is_keyboard_active): return - + # Guard 3: Rate limit to prevent audio spam during rapid drags var current_time: int = Time.get_ticks_msec() if current_time - _last_sfx_time < SFX_COOLDOWN_MS: return - - # Commit state only after all guards pass + + # Commit time state only after all guards pass _last_sfx_time = current_time - _previous_value = new_value - - # NO MORE MAGIC STRINGS! + AudioManager.play_sfx(AudioConstants.SFX_SLIDER) From dc1ebea97f7147e7c26aa83c6009d3bdbf21541e Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Thu, 23 Apr 2026 18:41:54 -0700 Subject: [PATCH 23/49] Update volume_slider.gd --- scripts/ui/components/volume_slider.gd | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/scripts/ui/components/volume_slider.gd b/scripts/ui/components/volume_slider.gd index 199033735..e5b5c47f9 100644 --- a/scripts/ui/components/volume_slider.gd +++ b/scripts/ui/components/volume_slider.gd @@ -48,7 +48,7 @@ func _ready() -> void: # Now connect signal for future changes value_changed.connect(_on_value_changed) - + # Safely track input without overriding the base _gui_input virtual method gui_input.connect(_on_gui_input) @@ -68,11 +68,11 @@ func _ready() -> void: func set_value_programmatically(new_value: float) -> void: # Godot 4 native method: updates visual value without emitting 'value_changed' set_value_no_signal(new_value) - + # Explicitly sync the audio backend, since the signal was bypassed AudioServer.set_bus_volume_db(bus_index, linear_to_db(new_value)) AudioManager.set_volume(bus_name, new_value) - + # Sync the delta tracker so the next manual interaction calculates correctly _previous_value = new_value @@ -94,10 +94,10 @@ func _on_gui_input(event: InputEvent) -> void: func _on_value_changed(new_value: float) -> void: AudioServer.set_bus_volume_db(bus_index, linear_to_db(new_value)) AudioManager.set_volume(bus_name, new_value) - + # Attempt to play interaction feedback _handle_slider_sfx(new_value) - + # Godot automatically restarts an active timer when start() is called save_debounce_timer.start() @@ -111,25 +111,25 @@ func _handle_slider_sfx(new_value: float) -> void: # Guard 1: Only play if the value actually changed (float-safe delta check) if is_equal_approx(new_value, _previous_value): return - + # Commit the value tracker early so delta checks stay accurate even if rate-limited _previous_value = new_value - + # Guard 2: Only play if user is actively interacting (Mouse Drag or Keyboard Focus) var is_mouse_active: bool = _is_dragging var is_keyboard_active: bool = has_focus() - + if not (is_mouse_active or is_keyboard_active): return - + # Guard 3: Rate limit to prevent audio spam during rapid drags var current_time: int = Time.get_ticks_msec() if current_time - _last_sfx_time < SFX_COOLDOWN_MS: return - + # Commit time state only after all guards pass _last_sfx_time = current_time - + AudioManager.play_sfx(AudioConstants.SFX_SLIDER) From ec7ecb0681c95338527d9380310c8589996ae501 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Thu, 23 Apr 2026 18:46:38 -0700 Subject: [PATCH 24/49] Update audio_manager.gd In AudioManager.play_sfx, _sfx_cache grows monotonically and is never pruned; if you expect many distinct SFX variants over a long session, consider adding a simple eviction strategy or a hard cap on cached entries to avoid unbounded memory growth. This is a very sharp observation. While UI SFX files are usually tiny (a few kilobytes each), a long play session with a growing library of dynamic sounds (especially if you later use this for randomized impact sounds or dialogue) could slowly bloat the RAM. Godot 4 dictionaries actually preserve insertion order, which gives us an incredibly easy way to implement a Least Recently Used (LRU) cache eviction strategy without any complicated data structures. We can add a hard cap constraint, and simply move a sound to the "back" of the dictionary every time it is played. If the cache hits the limit, we just pop the key at index [0]. --- scripts/managers/audio_manager.gd | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/scripts/managers/audio_manager.gd b/scripts/managers/audio_manager.gd index b9d1e122f..98039460a 100644 --- a/scripts/managers/audio_manager.gd +++ b/scripts/managers/audio_manager.gd @@ -16,6 +16,9 @@ signal mute_toggled(bus_name: String, is_muted: bool) ## Base path for all UI sound effects. const SFX_DIR_PATH: String = "res://files/sounds/sfx/" +## Hard cap for cached SFX streams to prevent unbounded memory growth. +const MAX_SFX_CACHE_SIZE: int = 20 + @export_category("Master Volume") @export var master_volume: float @export var master_muted: bool @@ -343,7 +346,7 @@ func reset_volumes() -> void: ## Centralized SFX Playback API (Issue #565) -## Handles non-positional audio with caching and auto-cleanup. +## Handles non-positional audio with LRU caching and auto-cleanup. ## :param sfx_name: The filename without extension (e.g., "slider"). ## :param bus_name: Target audio bus (defaults to SFX_Menu). ## :param pitch_scale: Pitch override for variety. @@ -357,17 +360,30 @@ func play_sfx( if sfx_name.is_empty(): return - # 1. Resolve and Cache the AudioStream + # 1. Resolve and Cache the AudioStream (with LRU Eviction) if not _sfx_cache.has(sfx_name): var full_path: String = SFX_DIR_PATH + sfx_name + ".wav" var stream: AudioStream = load(full_path) + if stream: + # Eviction strategy: If cache is full, remove the oldest (first) entry + if _sfx_cache.size() >= MAX_SFX_CACHE_SIZE: + var oldest_key: String = _sfx_cache.keys()[0] + _sfx_cache.erase(oldest_key) + Globals.log_message("SFX cache full. Evicted: " + oldest_key, Globals.LogLevel.DEBUG) + _sfx_cache[sfx_name] = stream else: Globals.log_message( "SFX file not found or failed to load: " + full_path, Globals.LogLevel.WARNING ) return + else: + # LRU Update: Godot 4 Dictionaries preserve insertion order. + # By erasing and re-inserting, we push this active sound to the "newest" end of the dictionary. + var stream: AudioStream = _sfx_cache[sfx_name] + _sfx_cache.erase(sfx_name) + _sfx_cache[sfx_name] = stream # 2. Instantiate and Configure the Player var player := AudioStreamPlayer.new() From 14a6f1d600847fa7cd83f6bbc4517e9203a8b297 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Thu, 23 Apr 2026 18:52:43 -0700 Subject: [PATCH 25/49] Update audio_manager.gd --- scripts/managers/audio_manager.gd | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/managers/audio_manager.gd b/scripts/managers/audio_manager.gd index 98039460a..73722815b 100644 --- a/scripts/managers/audio_manager.gd +++ b/scripts/managers/audio_manager.gd @@ -364,14 +364,16 @@ func play_sfx( if not _sfx_cache.has(sfx_name): var full_path: String = SFX_DIR_PATH + sfx_name + ".wav" var stream: AudioStream = load(full_path) - + if stream: # Eviction strategy: If cache is full, remove the oldest (first) entry if _sfx_cache.size() >= MAX_SFX_CACHE_SIZE: var oldest_key: String = _sfx_cache.keys()[0] _sfx_cache.erase(oldest_key) - Globals.log_message("SFX cache full. Evicted: " + oldest_key, Globals.LogLevel.DEBUG) - + Globals.log_message( + "SFX cache full. Evicted: " + oldest_key, Globals.LogLevel.DEBUG + ) + _sfx_cache[sfx_name] = stream else: Globals.log_message( From 1caf738d1c6f4193a6df5fa7af949bce3de88ad4 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Thu, 23 Apr 2026 19:19:29 -0700 Subject: [PATCH 26/49] New GUT tests The diff contains no changes to audio_web_bridge.gd and no tests or documentation updates that explicitly cover or assert the web-bridge DOM update behavior. There is no visible implementation or verification of this objective in the PR. Verify and, if necessary, enforce that audio_web_bridge.gd updates the HTML DOM directly without re-emitting signals back into the Godot HSliders, preventing feedback from the web layer into UI slider signals. --- test/gut/test_audio_web_bridge_dom_sync.gd | 133 ++++++++++++++++++ .../gut/test_audio_web_bridge_dom_sync.gd.uid | 1 + 2 files changed, 134 insertions(+) create mode 100644 test/gut/test_audio_web_bridge_dom_sync.gd create mode 100644 test/gut/test_audio_web_bridge_dom_sync.gd.uid diff --git a/test/gut/test_audio_web_bridge_dom_sync.gd b/test/gut/test_audio_web_bridge_dom_sync.gd new file mode 100644 index 000000000..b17cb1e66 --- /dev/null +++ b/test/gut/test_audio_web_bridge_dom_sync.gd @@ -0,0 +1,133 @@ +## Copyright (C) 2026 Egor Kostan +## SPDX-License-Identifier: GPL-3.0-or-later +## test_audio_web_bridge_dom_sync.gd +## +## TEST SUITE: Verifies DOM Sync Decoupling for Web Bridge (Issue #567). +## This suite proves that Godot state changes execute strictly one-way JavaScript +## DOM updates without emitting signals back into the engine. +## This is a critical safety test to prevent infinite feedback loops. + +extends "res://addons/gut/test.gd" + +# ========================================== +# MOCKS +# ========================================== + +## WHY: Mocks the OS environment to bypass the self-destruction check in +## AudioWebBridge._ready() when running tests in a non-web environment. +class MockOSWrapper extends OSWrapper: + func has_feature(feature: String) -> bool: + return feature == "web" + +## WHY: Mocks the JavaScriptBridge to record the exact strings passed to the browser. +class MockJSBridgeWrapper extends JavaScriptBridgeWrapper: + var eval_calls: Array[String] = [] + # FIX: Use a non-empty dictionary. In GDScript, {} is falsy, + # which caused the bridge to return early. + var mock_window := {"is_mock": true} + + func eval(code: String, _global_exec: bool = false) -> Variant: + eval_calls.append(code) + return null + + func get_interface(interface: String) -> Variant: + if interface == "window": + return mock_window + return null + + func create_callback(_callable: Callable) -> Variant: + return {} + + +# ========================================== +# TESTS +# ========================================== + +var web_bridge: Node +var mock_js: MockJSBridgeWrapper + +## WHY: Prepares the test environment by instantiating the bridge with mocks. +## WHAT: Loads AudioWebBridge using GamePaths and injects dependencies. +## EXPECTED: The script loads and is initialized for isolated evaluation. +func before_each() -> void: + var path: String = GamePaths.AUDIO_WEB_BRIDGE + var bridge_script: Script = load(path) + + if bridge_script == null: + fail_test("Failed to load AudioWebBridge script at: " + path) + return + + web_bridge = bridge_script.new() + + # Injection must happen BEFORE add_child so _ready() uses the mocks + mock_js = MockJSBridgeWrapper.new() + web_bridge.js_bridge_wrapper = mock_js + web_bridge.os_wrapper = MockOSWrapper.new() + + add_child_autoqfree(web_bridge) + + +## WHY: Ensures volume changes in Godot update the HTML DOM via raw property assignment. +## WHAT: Simulates a volume change signal from the AudioManager for the Master bus. +## EXPECTED: The bridge generates a JS string setting the '.value' property. This +## bypasses browser events to prevent Godot from receiving its own changes back. +func test_dom_volume_sync_executes_js_only() -> void: + mock_js.eval_calls.clear() + + # Act: Directly trigger the bridge's internal signal listener + var test_volume: float = 0.85 + web_bridge._on_godot_volume_changed(AudioConstants.BUS_MASTER, test_volume) + + # Assert: Verify exactly one JS command was issued + assert_eq(mock_js.eval_calls.size(), 1, "Only one DOM update should be triggered.") + + if mock_js.eval_calls.size() > 0: + var expected_js: String = "document.getElementById('master-slider').value = 0.85" + assert_eq( + mock_js.eval_calls[0], + expected_js, + "Bridge must update HTML DOM directly to prevent feedback loops." + ) + + +## WHY: Ensures that Godot's 'muted' state is correctly inverted for the DOM. +## WHAT: Simulates Godot muting the Music bus (muted = true). +## EXPECTED: DOM state corresponds to 'checked = false' translated via property update. +## Direct assignment prevents the browser from firing an 'onchange' event. +func test_dom_mute_sync_executes_js_only() -> void: + mock_js.eval_calls.clear() + + # Act: Broadcast a mute action from Godot + web_bridge._on_godot_mute_toggled(AudioConstants.BUS_MUSIC, true) + + assert_eq(mock_js.eval_calls.size(), 1, "Only one DOM update should be triggered.") + + if mock_js.eval_calls.size() > 0: + var expected_js: String = "document.getElementById('mute-music').checked = false" + assert_eq( + mock_js.eval_calls[0], + expected_js, + "Bridge must directly uncheck the HTML element without Godot signals." + ) + + +## WHY: Ensures that Godot's 'unmuted' state is correctly reflected in the DOM. +## WHAT: Simulates Godot unmuting the SFX bus (muted = false). +## EXPECTED: The bridge translates this to 'checked = true' in JavaScript. +## Property assignment ensures the browser shell remains in sync with the engine. +func test_dom_unmute_sync_executes_js_only() -> void: + mock_js.eval_calls.clear() + + # Act: Broadcast an unmute action from Godot + web_bridge._on_godot_mute_toggled(AudioConstants.BUS_SFX, false) + + assert_eq(mock_js.eval_calls.size(), 1, "Only one DOM update should be triggered.") + + if mock_js.eval_calls.size() > 0: + var expected_js: String = "document.getElementById('mute-sfx').checked = true" + assert_eq( + mock_js.eval_calls[0], + expected_js, + "Bridge must directly check the HTML element without Godot signals." + ) + diff --git a/test/gut/test_audio_web_bridge_dom_sync.gd.uid b/test/gut/test_audio_web_bridge_dom_sync.gd.uid new file mode 100644 index 000000000..f746f80da --- /dev/null +++ b/test/gut/test_audio_web_bridge_dom_sync.gd.uid @@ -0,0 +1 @@ +uid://bxeniyxr24v2t From 4eb74e070102250b5c87d09236ddb685ada954b8 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Thu, 23 Apr 2026 19:25:31 -0700 Subject: [PATCH 27/49] Update test_volume_slider.gd Here is the documented version of test_volume_slider.gd. Every test case now includes a detailed block explaining the purpose, method, and expected outcome of the verification, while the original code, assertions, and comments remain completely untouched. --- test/gut/test_volume_slider.gd | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/gut/test_volume_slider.gd b/test/gut/test_volume_slider.gd index dd21caff5..359ad2b99 100644 --- a/test/gut/test_volume_slider.gd +++ b/test/gut/test_volume_slider.gd @@ -27,11 +27,17 @@ func before_each() -> void: # INITIALIZATION & PROGRAMMATIC GUARDS # ========================================== +## WHY: Verifies that the component starts in a clean, predictable state. +## WHAT: Checks if the debounce timer is instantiated but not active. +## EXPECTED: Timer is not null and is currently stopped. func test_initialization() -> void: assert_not_null(_slider.save_debounce_timer, "Debounce timer should be created on _ready") assert_true(_slider.save_debounce_timer.is_stopped(), "Timer should not be running immediately after initialization") +## WHY: Proves that programmatic updates (e.g., from Web Bridge or Init) are decoupled. +## WHAT: Updates the slider value using the set_value_programmatically() helper. +## EXPECTED: The value reflects the update, but the save timer remains stopped to prevent disk I/O spam. func test_programmatic_change_blocks_debounce_timer() -> void: _slider.set_value_programmatically(0.5) @@ -42,6 +48,9 @@ func test_programmatic_change_blocks_debounce_timer() -> void: ) +## WHY: Ensures that intentional user interaction correctly schedules a save operation. +## WHAT: Directly modifies the slider 'value' property to simulate a manual change event. +## EXPECTED: The save_debounce_timer is started to handle the persistence. func test_manual_value_change_starts_debounce_timer() -> void: # Simulate a standard UI value change _slider.value = 0.8 @@ -52,6 +61,9 @@ func test_manual_value_change_starts_debounce_timer() -> void: ) +## WHY: Confirms that non-interactive updates do not inadvertently flip interaction flags. +## WHAT: Performs a programmatic update and checks the internal _is_dragging state. +## EXPECTED: The _is_dragging flag remains false. func test_programmatic_change_does_not_alter_drag_state() -> void: _slider.set_value_programmatically(0.2) assert_false(_slider._is_dragging, "Programmatic changes should not affect the _is_dragging state") @@ -61,6 +73,9 @@ func test_programmatic_change_does_not_alter_drag_state() -> void: # SFX UX GUARDS # ========================================== +## WHY: Prevents audio spam when a slider event fires without a meaningful value change. +## WHAT: Attempts to trigger SFX logic using a value identical to the previous state. +## EXPECTED: Guard 1 blocks the playback; _last_sfx_time is not updated. func test_sfx_guard_blocks_identical_values() -> void: # Setup: Set an initial value and simulate an interaction _slider.value = 0.5 @@ -75,6 +90,9 @@ func test_sfx_guard_blocks_identical_values() -> void: assert_eq(_slider._last_sfx_time, initial_sfx_time, "SFX must be blocked if the value hasn't actually changed.") +## WHY: Restricts SFX playback strictly to active user engagement. +## WHAT: Changes the value while the slider is neither being dragged nor focused. +## EXPECTED: Guard 2 blocks playback; _last_sfx_time remains at its initial value. func test_sfx_guard_blocks_no_interaction() -> void: # Setup: New value, but NO interaction (not dragging, no focus) _slider.value = 0.5 @@ -90,6 +108,9 @@ func test_sfx_guard_blocks_no_interaction() -> void: assert_eq(_slider._last_sfx_time, initial_sfx_time, "SFX must be blocked if the user isn't actively interacting.") +## WHY: Validates the "Happy Path" for manual interaction audio feedback. +## WHAT: Simulates a manual drag interaction accompanied by a value delta. +## EXPECTED: All guards pass; _last_sfx_time is updated and _previous_value is committed. func test_sfx_guard_allows_valid_interaction() -> void: # Setup: Different value AND active interaction _slider._previous_value = 0.2 @@ -104,6 +125,9 @@ func test_sfx_guard_allows_valid_interaction() -> void: assert_eq(_slider._previous_value, 0.5, "Previous value should be updated after successful SFX trigger.") +## WHY: Protects the user from ear-piercing noise during rapid mouse movements. +## WHAT: Attempts to trigger a second SFX trigger immediately after a successful one. +## EXPECTED: Guard 3 (Rate Limiter) blocks the second trigger based on SFX_COOLDOWN_MS. func test_sfx_guard_enforces_rate_limiting() -> void: # Setup: Valid interaction, but we JUST played a sound _slider._previous_value = 0.2 From 2c92711e17b7df221247b882f0cad4cca69c1972 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Fri, 24 Apr 2026 21:00:08 -0700 Subject: [PATCH 28/49] issue (bug_risk): Guard against invalid audio bus names to avoid runtime errors issue (bug_risk): Guard against invalid audio bus names to avoid runtime errors If bus_name is misconfigured or renamed, AudioServer.get_bus_index(bus_name) returns -1, and AudioServer.set_bus_volume_db(bus_index, ...) will error. Please handle the -1 case (e.g., log a warning and disable the slider or use a safe default bus) to make this more robust to configuration changes. --- scripts/ui/components/volume_slider.gd | 17 +++++++++ test/gut/test_volume_slider.gd | 48 ++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/scripts/ui/components/volume_slider.gd b/scripts/ui/components/volume_slider.gd index e5b5c47f9..e032ce3f4 100644 --- a/scripts/ui/components/volume_slider.gd +++ b/scripts/ui/components/volume_slider.gd @@ -41,6 +41,19 @@ func _ready() -> void: # Get bus id by name bus_index = AudioServer.get_bus_index(bus_name) + # Guard against invalid audio bus names to avoid runtime errors + if bus_index == -1: + var err_msg: String = ( + "VolumeSlider Error: Invalid audio bus name '%s'. Disabling slider." % bus_name + ) + Globals.log_message(err_msg, Globals.LogLevel.ERROR) + + # Kill all interactions on this dead component + editable = false + mouse_filter = Control.MOUSE_FILTER_IGNORE + focus_mode = Control.FOCUS_NONE + return + # Set current bus volume value first (without triggering signal yet) var initial_val: float = db_to_linear(AudioServer.get_bus_volume_db(bus_index)) _previous_value = initial_val @@ -66,6 +79,10 @@ func _ready() -> void: ## :type new_value: float ## :rtype: void func set_value_programmatically(new_value: float) -> void: + # Guard against external updates if the bus is invalid + if bus_index == -1: + return + # Godot 4 native method: updates visual value without emitting 'value_changed' set_value_no_signal(new_value) diff --git a/test/gut/test_volume_slider.gd b/test/gut/test_volume_slider.gd index 359ad2b99..f4feea9ee 100644 --- a/test/gut/test_volume_slider.gd +++ b/test/gut/test_volume_slider.gd @@ -142,3 +142,51 @@ func test_sfx_guard_enforces_rate_limiting() -> void: # Assert: It should have been blocked by the SFX_COOLDOWN_MS guard assert_eq(_slider._last_sfx_time, current_time, "Rate limiter MUST block sounds requested faster than the cooldown window.") + + +# ========================================== +# INVALID BUS GUARDS (Bug Risk) +# ========================================== + +## WHY: Ensures the game doesn't crash and fully locks out inputs if an audio bus name is typoed. +## WHAT: Initializes a new slider with a fake bus name. +## EXPECTED: The slider detects the -1 index, logs the error to Globals, disables itself, drops focus/mouse handling, and aborts safely. +func test_invalid_bus_disables_slider() -> void: + var bad_slider: VolumeSlider = VolumeSlider.new() + bad_slider.bus_name = "NonExistentBus123" + + # Add to tree to trigger _ready() + add_child_autoqfree(bad_slider) + + assert_false(bad_slider.editable, "Slider must disable itself if the audio bus is invalid.") + assert_eq(bad_slider.mouse_filter, Control.MOUSE_FILTER_IGNORE, "Slider must ignore mouse events if invalid.") + assert_eq(bad_slider.focus_mode, Control.FOCUS_NONE, "Slider must drop keyboard/controller focus if invalid.") + assert_null(bad_slider.save_debounce_timer, "Initialization should abort early, leaving the timer null.") + + +## WHY: Prevents external scripts from forcing updates on a broken slider. +## WHAT: Attempts to programmatically set the value of an invalid slider. +## EXPECTED: The guard clause blocks the update, leaving state trackers at their default values. +func test_invalid_bus_blocks_programmatic_updates() -> void: + var bad_slider: VolumeSlider = VolumeSlider.new() + bad_slider.bus_name = "AnotherFakeBus" + add_child_autoqfree(bad_slider) + + # Act: Try to force a value update + bad_slider.set_value_programmatically(0.8) + + # Assert: The values should remain at their uninitialized defaults + assert_eq(bad_slider._previous_value, -1.0, "The delta tracker should not update for an invalid bus.") + assert_eq(bad_slider.value, 0.0, "The visual slider value should not update for an invalid bus.") + + +## WHY: Guarantees no SFX or volume updates can occur from user interaction on a dead slider. +## WHAT: Verifies the signal connections are bypassed during a failed initialization. +## EXPECTED: value_changed and gui_input signals are never connected to their respective handlers. +func test_invalid_bus_prevents_signal_connections() -> void: + var bad_slider: VolumeSlider = VolumeSlider.new() + bad_slider.bus_name = "GhostBus" + add_child_autoqfree(bad_slider) + + assert_false(bad_slider.value_changed.is_connected(bad_slider._on_value_changed), "Value changed signal must remain disconnected.") + assert_false(bad_slider.gui_input.is_connected(bad_slider._on_gui_input), "GUI input signal must remain disconnected.") From fc74ecc7e6e3c256740f08440b09502a7fb5eff3 Mon Sep 17 00:00:00 2001 From: Egor Kostan <20955183+ikostan@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:03:41 -0700 Subject: [PATCH 29/49] Update scripts/ui/components/volume_slider.gd Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- scripts/ui/components/volume_slider.gd | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/ui/components/volume_slider.gd b/scripts/ui/components/volume_slider.gd index e032ce3f4..4e2ae3b09 100644 --- a/scripts/ui/components/volume_slider.gd +++ b/scripts/ui/components/volume_slider.gd @@ -83,15 +83,18 @@ func set_value_programmatically(new_value: float) -> void: if bus_index == -1: return + # Clamp to the slider's configured range to avoid UI/backend divergence + var clamped_value := clamp(new_value, min_value, max_value) + # Godot 4 native method: updates visual value without emitting 'value_changed' - set_value_no_signal(new_value) + set_value_no_signal(clamped_value) # Explicitly sync the audio backend, since the signal was bypassed - AudioServer.set_bus_volume_db(bus_index, linear_to_db(new_value)) - AudioManager.set_volume(bus_name, new_value) + AudioServer.set_bus_volume_db(bus_index, linear_to_db(clamped_value)) + AudioManager.set_volume(bus_name, clamped_value) # Sync the delta tracker so the next manual interaction calculates correctly - _previous_value = new_value + _previous_value = clamped_value ## Tracks mouse drag state for accurate interaction gating, even if the cursor From 0b6700affa0194eb9c0c91dde795226f7009e08d Mon Sep 17 00:00:00 2001 From: Egor Kostan <20955183+ikostan@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:04:47 -0700 Subject: [PATCH 30/49] Update scripts/ui/components/volume_slider.gd Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- scripts/ui/components/volume_slider.gd | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/ui/components/volume_slider.gd b/scripts/ui/components/volume_slider.gd index 4e2ae3b09..864673e9c 100644 --- a/scripts/ui/components/volume_slider.gd +++ b/scripts/ui/components/volume_slider.gd @@ -105,6 +105,12 @@ func set_value_programmatically(new_value: float) -> void: func _on_gui_input(event: InputEvent) -> void: if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: _is_dragging = event.pressed + elif event is InputEventScreenTouch: + # Touch down/up should mirror mouse press/release behavior. + _is_dragging = event.pressed + elif event is InputEventScreenDrag: + # Any active drag implies the pointer is currently dragging the slider. + _is_dragging = true ## Signal listener for when the slider value changes manually. From 951c849a6f0511b71aa017c5e9c910b9e0bb919e Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Fri, 24 Apr 2026 21:10:24 -0700 Subject: [PATCH 31/49] Test updates --- scripts/ui/components/volume_slider.gd | 2 +- test/gdunit4/test_volume_slider.gd | 37 +++++++++++++------------- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/scripts/ui/components/volume_slider.gd b/scripts/ui/components/volume_slider.gd index 864673e9c..de1479603 100644 --- a/scripts/ui/components/volume_slider.gd +++ b/scripts/ui/components/volume_slider.gd @@ -84,7 +84,7 @@ func set_value_programmatically(new_value: float) -> void: return # Clamp to the slider's configured range to avoid UI/backend divergence - var clamped_value := clamp(new_value, min_value, max_value) + var clamped_value: float = clamp(new_value, min_value, max_value) # Godot 4 native method: updates visual value without emitting 'value_changed' set_value_no_signal(clamped_value) diff --git a/test/gdunit4/test_volume_slider.gd b/test/gdunit4/test_volume_slider.gd index dd4bbe242..1c595a5a3 100644 --- a/test/gdunit4/test_volume_slider.gd +++ b/test/gdunit4/test_volume_slider.gd @@ -1,4 +1,4 @@ -## Copyright (C) 2025 Egor Kostan +## Copyright (C) 2026 Egor Kostan ## SPDX-License-Identifier: GPL-3.0-or-later ## test_volume_slider.gd ## Unit tests for volume_slider.gd. @@ -11,34 +11,34 @@ extends GdUnitTestSuite var slider: VolumeSlider +# Explicitly preload the script to bypass GdUnit4's class_name registry bugs +const VolumeSliderScript = preload("res://scripts/ui/components/volume_slider.gd") + func before_test() -> void: - ## Per-test setup: Instantiate slider, reset state. - ## - ## :rtype: void - # 1. Reset state BEFORE triggering _ready() AudioManager.master_volume = 1.0 AudioManager.apply_all_volumes() # Ensure AudioServer matches the manager - # 2. Instantiate and add to tree - slider = auto_free(VolumeSlider.new()) - slider.bus_name = "Master" # Test with Master + # 2. Instantiate safely using the preloaded script resource + slider = auto_free(VolumeSliderScript.new()) + + # Use the constant instead of a hardcoded string to prevent typos + slider.bus_name = AudioConstants.BUS_MASTER + + # FIX: Replicate the Inspector settings to prevent float snapping! + slider.max_value = 1.0 + slider.step = 0.001 + add_child(slider) # Trigger _ready func after_test() -> void: - ## Cleanup: Reset volume to avoid pollution. - ## - ## :rtype: void AudioManager.master_volume = 1.0 AudioManager.apply_all_volumes() func test_ready_sets_value_and_timer() -> void: - ## Tests _ready gets index, sets value, connects, creates timer. - ## - ## :rtype: void - assert_int(slider.bus_index).is_equal(AudioServer.get_bus_index("Master")) + assert_int(slider.bus_index).is_equal(AudioServer.get_bus_index(AudioConstants.BUS_MASTER)) assert_float(slider.value).is_equal(db_to_linear(AudioServer.get_bus_volume_db(slider.bus_index))) assert_bool(slider.value_changed.is_connected(slider._on_value_changed)).is_true() assert_object(slider.save_debounce_timer).is_not_null() @@ -47,12 +47,11 @@ func test_ready_sets_value_and_timer() -> void: func test_value_changed_updates_volume_and_starts_timer() -> void: - ## Tests value change sets db, updates manager, starts timer. - ## - ## :rtype: void var test_value: float = 0.5 slider._on_value_changed(test_value) assert_float(AudioServer.get_bus_volume_db(slider.bus_index)).is_equal_approx(linear_to_db(test_value), 0.0001) assert_float(AudioManager.master_volume).is_equal(test_value) - assert_bool(not slider.save_debounce_timer.is_stopped()).is_true() # Started + + # Cleaner assertion for the timer state + assert_bool(slider.save_debounce_timer.is_stopped()).is_false() From 07d4012bccfe24802affc41dc182f6dab4c0cf8a Mon Sep 17 00:00:00 2001 From: Egor Kostan <20955183+ikostan@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:16:55 -0700 Subject: [PATCH 32/49] Update scripts/ui/components/volume_slider.gd Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- scripts/ui/components/volume_slider.gd | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/ui/components/volume_slider.gd b/scripts/ui/components/volume_slider.gd index de1479603..a171d4615 100644 --- a/scripts/ui/components/volume_slider.gd +++ b/scripts/ui/components/volume_slider.gd @@ -105,6 +105,11 @@ func set_value_programmatically(new_value: float) -> void: func _on_gui_input(event: InputEvent) -> void: if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: _is_dragging = event.pressed + + +func _notification(what: int) -> void: + if what == NOTIFICATION_FOCUS_EXIT or what == NOTIFICATION_WM_WINDOW_FOCUS_OUT: + _is_dragging = false elif event is InputEventScreenTouch: # Touch down/up should mirror mouse press/release behavior. _is_dragging = event.pressed From df74365c3e6100e81673a2c324078fa341b8f9fa Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Fri, 24 Apr 2026 21:25:38 -0700 Subject: [PATCH 33/49] Drag state may get stuck if release event is missed. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drag state may get stuck if release event is missed. _is_dragging is only cleared on a MOUSE_BUTTON_LEFT release routed through this control's gui_input. Edge cases (focus loss, popups stealing input, window focus loss mid-drag, or touch event ending without a corresponding mouse release) can leave _is_dragging = true, silently re-enabling SFX on subsequent programmatic value_changed emissions. Clear _is_dragging on NOTIFICATION_FOCUS_EXIT and NOTIFICATION_WM_WINDOW_FOCUS_OUT. Do not use NOTIFICATION_MOUSE_EXIT—it fires when the cursor leaves the slider's bounds during a legitimate drag, which would break out-of-bounds dragging. --- scripts/ui/components/volume_slider.gd | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/scripts/ui/components/volume_slider.gd b/scripts/ui/components/volume_slider.gd index a171d4615..b3eb896bd 100644 --- a/scripts/ui/components/volume_slider.gd +++ b/scripts/ui/components/volume_slider.gd @@ -97,7 +97,7 @@ func set_value_programmatically(new_value: float) -> void: _previous_value = clamped_value -## Tracks mouse drag state for accurate interaction gating, even if the cursor +## Tracks mouse and touch drag state for accurate interaction gating, even if the cursor ## leaves the slider's bounding box while dragging. ## :param event: The input event passed by the UI system. ## :type event: InputEvent @@ -105,11 +105,6 @@ func set_value_programmatically(new_value: float) -> void: func _on_gui_input(event: InputEvent) -> void: if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: _is_dragging = event.pressed - - -func _notification(what: int) -> void: - if what == NOTIFICATION_FOCUS_EXIT or what == NOTIFICATION_WM_WINDOW_FOCUS_OUT: - _is_dragging = false elif event is InputEventScreenTouch: # Touch down/up should mirror mouse press/release behavior. _is_dragging = event.pressed @@ -118,6 +113,16 @@ func _notification(what: int) -> void: _is_dragging = true +## Catches edge cases where input release events are dropped (e.g., ALT-Tabbing +## or unexpected focus stealing), preventing the drag state from getting stuck. +## :param what: The notification ID from the engine. +## :type what: int +## :rtype: void +func _notification(what: int) -> void: + if what == NOTIFICATION_FOCUS_EXIT or what == NOTIFICATION_WM_WINDOW_FOCUS_OUT: + _is_dragging = false + + ## Signal listener for when the slider value changes manually. ## :param new_value: The new volume level from the slider (0.0 to 1.0). ## :type new_value: float From 47596d658280cb79fb07e673b7c44a44227f15ec Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Sun, 26 Apr 2026 11:33:14 -0700 Subject: [PATCH 34/49] suggestion (performance): Avoid repeated warnings and load attempts for missing SFX by caching failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit suggestion (performance): Avoid repeated warnings and load attempts for missing SFX by caching failures Right now, a missing/failed SFX will trigger a load() and a warning on every play_sfx call with that name. To avoid repeated failed loads and log spam, cache a sentinel value (e.g., null or a dedicated marker object) in _sfx_cache for missing assets so subsequent calls can immediately return without reloading or logging. Alternatively, track missing IDs in a separate Set and short‑circuit when encountered. --- scripts/managers/audio_manager.gd | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/managers/audio_manager.gd b/scripts/managers/audio_manager.gd index 73722815b..a33fce8f1 100644 --- a/scripts/managers/audio_manager.gd +++ b/scripts/managers/audio_manager.gd @@ -38,10 +38,14 @@ const MAX_SFX_CACHE_SIZE: int = 20 @export var menu_muted: bool var current_config_path: String = Settings.CONFIG_PATH + # --- NEW: SFX CACHING & MANAGEMENT --- ## Dictionary to store preloaded AudioStreams to prevent disk I/O stutter. var _sfx_cache: Dictionary = {} +## Dictionary acting as a set to track missing SFX and prevent repeated load attempts/log spam. +var _missing_sfx_cache: Dictionary = {} + func _ready() -> void: ## Initializes to defaults and loads/applies volumes. @@ -360,6 +364,10 @@ func play_sfx( if sfx_name.is_empty(): return + # Short-circuit: If we already know this file is missing, do not attempt to load it again. + if _missing_sfx_cache.has(sfx_name): + return + # 1. Resolve and Cache the AudioStream (with LRU Eviction) if not _sfx_cache.has(sfx_name): var full_path: String = SFX_DIR_PATH + sfx_name + ".wav" @@ -379,6 +387,8 @@ func play_sfx( Globals.log_message( "SFX file not found or failed to load: " + full_path, Globals.LogLevel.WARNING ) + # Cache the failure so we don't spam the disk and logs on subsequent requests + _missing_sfx_cache[sfx_name] = true return else: # LRU Update: Godot 4 Dictionaries preserve insertion order. From 71997b540d0580bfd84ca6330e67717bd3acff82 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Sun, 26 Apr 2026 11:37:44 -0700 Subject: [PATCH 35/49] Use one English variant consistently (centralised vs centralized). Please standardize spelling across the README to avoid mixed variants in project docs (Line 157 currently uses centralised). --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a2024e372..29576f7e3 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ reorganised into purpose-specific sub-directories: | Directory | Contents | |----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `scripts/core/` | Foundational systems: `game_paths.gd` (centralised path registry), `globals.gd`, `main_scene.gd`, `settings.gd` | +| `scripts/core/` | Foundational systems: `game_paths.gd` (centralized path registry), `globals.gd`, `main_scene.gd`, `settings.gd` | | `scripts/resources/` | Data containers & configuration: `game_settings_resource.gd`, `audio_constants.gd` | | `scripts/entities/` | Game objects: `player.gd`, `bullet.gd`, `weapon.gd` | | `scripts/system/` | Platform wrappers & integrations: `audio_web_bridge.gd`, `JavaScriptBridgeWrapper.gd`, `OSWrapper.gd` | From b1872ebf72c4b52fca0768cffe6f9b00b165307c Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Sun, 26 Apr 2026 11:49:45 -0700 Subject: [PATCH 36/49] Restore AudioManager state after each test to prevent cross-suite leakage. 18-43: Restore AudioManager state after each test to prevent cross-suite leakage. AudioManager is an autoload singleton, so mutations to current_config_path, master_volume, and sfx_volume here persist after this suite finishes. A later suite that calls AudioManager.save_volumes() could end up writing to user://test_audio_sync.cfg (which this suite deletes in after_each), or reading volumes this suite left at 1.0. Sourcery AI caught a critical testing anti-pattern here. Because AudioManager is an autoloaded singleton, its state persists across the entire test suite run. If this specific test suite changes the config path or volume levels and fails to put them back, any test that runs after this one is going to inherit that dirty state, leading to flaky, order-dependent failures that are incredibly frustrating to debug. --- test/gut/test_audio_sync_decoupling.gd | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/test/gut/test_audio_sync_decoupling.gd b/test/gut/test_audio_sync_decoupling.gd index 8ad6b8fd3..7426f69d1 100644 --- a/test/gut/test_audio_sync_decoupling.gd +++ b/test/gut/test_audio_sync_decoupling.gd @@ -13,12 +13,24 @@ var audio_scene: PackedScene = load(GamePaths.AUDIO_SETTINGS_SCENE) var audio_instance: Control var test_config_path: String = "user://test_audio_sync.cfg" -## Per-test setup: Instantiate audio scene, reset state +# State snapshot variables to prevent cross-suite leakage +var _orig_config_path: String +var _orig_master_volume: float +var _orig_sfx_volume: float + + +## Per-test setup: Instantiate audio scene, snapshot singleton, and reset state ## :rtype: void func before_each() -> void: + # Capture original AudioManager state + _orig_config_path = AudioManager.current_config_path + _orig_master_volume = AudioManager.master_volume + _orig_sfx_volume = AudioManager.sfx_volume + if FileAccess.file_exists(test_config_path): DirAccess.remove_absolute(test_config_path) + # Apply isolated test state AudioManager.current_config_path = test_config_path AudioManager.master_volume = 1.0 AudioManager.sfx_volume = 1.0 @@ -27,7 +39,7 @@ func before_each() -> void: add_child_autofree(audio_instance) -## Per-test cleanup: Free audio_instance safely. +## Per-test cleanup: Free audio_instance safely and restore singleton state. ## :rtype: void func after_each() -> void: if is_instance_valid(audio_instance): @@ -40,6 +52,12 @@ func after_each() -> void: if FileAccess.file_exists(test_config_path): DirAccess.remove_absolute(test_config_path) + + # Restore original AudioManager state to prevent leakage + AudioManager.current_config_path = _orig_config_path + AudioManager.master_volume = _orig_master_volume + AudioManager.sfx_volume = _orig_sfx_volume + await get_tree().process_frame From 566f5bc79bb0037f00a6f6ad93e2f9d57e96c581 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Sun, 26 Apr 2026 11:54:16 -0700 Subject: [PATCH 37/49] Rate-limit test can be flaky under load. 107-120: Rate-limit test can be flaky under load. Guard 3 uses Time.get_ticks_msec() - _last_sfx_time < SFX_COOLDOWN_MS (60 ms). Setting _last_sfx_time = current_time just before _handle_slider_sfx(0.6) relies on the gap between those two lines staying under 60 ms. On a loaded CI runner (GC pause, scheduling delay) the gap can exceed 60 ms, causing the guard to pass and the assertion on line 120 to fail. A more deterministic formulation: set _last_sfx_time = Time.get_ticks_msec() + SFX_COOLDOWN_MS (or a large future value) so the cooldown is guaranteed to be in the future regardless of execution speed. This is a spot-on catch by Sourcery AI. Time-based tests are notoriously flaky in CI/CD pipelines (like GitHub Actions) because virtual machines often experience CPU scheduling delays, garbage collection pauses, or hypervisor throttling. If the runner stalls for even 61 milliseconds between your variable declaration and the function call, the 60ms cooldown window will expire, the guard will allow the sound to play, the timestamp will update, and the test will fail randomly. By intentionally setting the _last_sfx_time to a timestamp in the future, the delta calculation inside the slider (current_time - _last_sfx_time) results in a negative number. Since a negative number is always less than the SFX_COOLDOWN_MS, the guard becomes 100% deterministic and mathematically impossible to bypass, regardless of how slow the CI runner is. --- test/gut/test_volume_slider.gd | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/test/gut/test_volume_slider.gd b/test/gut/test_volume_slider.gd index f4feea9ee..5475e4525 100644 --- a/test/gut/test_volume_slider.gd +++ b/test/gut/test_volume_slider.gd @@ -133,15 +133,20 @@ func test_sfx_guard_enforces_rate_limiting() -> void: _slider._previous_value = 0.2 _slider._is_dragging = true - # Force the last sfx time to be right now - var current_time: int = Time.get_ticks_msec() - _slider._last_sfx_time = current_time + # Force the last sfx time into the future to guarantee a deterministic block + # regardless of CI thread pauses or garbage collection spikes. + var future_time: int = Time.get_ticks_msec() + _slider.SFX_COOLDOWN_MS + 1000 + _slider._last_sfx_time = future_time # Act: Try to trigger another sound immediately with a new value _slider._handle_slider_sfx(0.6) # Assert: It should have been blocked by the SFX_COOLDOWN_MS guard - assert_eq(_slider._last_sfx_time, current_time, "Rate limiter MUST block sounds requested faster than the cooldown window.") + assert_eq( + _slider._last_sfx_time, + future_time, + "Rate limiter MUST block sounds requested faster than the cooldown window." + ) # ========================================== From 2db4d9b18f2defd962bcb2f4dbcc5ca8c953810f Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Sun, 26 Apr 2026 11:58:01 -0700 Subject: [PATCH 38/49] Test mutates live AudioManager / audio server state without isolation. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 13-52: Test mutates live AudioManager / audio server state without isolation. _slider.value = 0.8 on line 47 fires _on_value_changed, which writes to AudioServer.set_bus_volume_db and AudioManager.set_volume (mutating the real autoload), and also starts the real save_debounce_timer (0.5 s). In a slow CI run the timer can fire before add_child_autoqfree reaps the slider, triggering AudioManager.save_volumes() → a write to the real user:// config (this suite, unlike test_audio_sync_decoupling.gd, doesn't override AudioManager.current_config_path). At minimum: Override AudioManager.current_config_path to a test path in before_each and clean it up in after_each. Snapshot & restore AudioManager.master_volume around the test. This is another excellent catch by Sourcery AI. Because the AudioManager is a global Autoload, any test that touches it is essentially touching a live wire. In test_manual_value_change_starts_debounce_timer, you simulate a user dragging the slider by setting _slider.value = 0.8. This triggers the actual _on_value_changed signal, which updates the live AudioManager and starts the 0.5-second save timer. If a CI runner lags and takes more than 0.5 seconds to clean up the test, that timer will time out and execute AudioManager.save_volumes(). Without isolation, it would overwrite your game's real user:// configuration file with the dummy test values! To fix this, we need to apply the exact same snapshot-and-restore pattern here that we used in the decoupling suite. --- test/gut/test_volume_slider.gd | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/gut/test_volume_slider.gd b/test/gut/test_volume_slider.gd index 5475e4525..6613f38ba 100644 --- a/test/gut/test_volume_slider.gd +++ b/test/gut/test_volume_slider.gd @@ -9,8 +9,20 @@ extends "res://addons/gut/test.gd" var _slider: VolumeSlider +# Snapshot variables for state isolation +var _orig_config_path: String +var _orig_master_volume: float +const _TEST_CONFIG_PATH: String = "user://test_volume_slider.cfg" + func before_each() -> void: + # Snapshot global state to prevent cross-suite leakage + _orig_config_path = AudioManager.current_config_path + _orig_master_volume = AudioManager.master_volume + + # Isolate the config path so any rogue debounce saves hit a throwaway file + AudioManager.current_config_path = _TEST_CONFIG_PATH + _slider = VolumeSlider.new() _slider.bus_name = AudioConstants.BUS_MASTER @@ -23,6 +35,16 @@ func before_each() -> void: add_child_autoqfree(_slider) +func after_each() -> void: + # Restore global state + AudioManager.current_config_path = _orig_config_path + AudioManager.master_volume = _orig_master_volume + + # Clean up any test config generated if the debounce timer fired during CI lag + if FileAccess.file_exists(_TEST_CONFIG_PATH): + DirAccess.remove_absolute(_TEST_CONFIG_PATH) + + # ========================================== # INITIALIZATION & PROGRAMMATIC GUARDS # ========================================== From a760da5aea7f407ca9344d1a6df409977eb32c6c Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Sun, 26 Apr 2026 12:10:10 -0700 Subject: [PATCH 39/49] test_sfx_guard_allows_valid_interaction will trigger real audio playback. 93-104: test_sfx_guard_allows_valid_interaction will trigger real audio playback. Once all three guards pass, _handle_slider_sfx calls AudioManager.play_sfx(AudioConstants.SFX_SLIDER), which loads slider.wav from disk and spawns a real AudioStreamPlayer on the autoload. That's audible during local test runs and adds a transient node to AudioManager. Consider stubbing the call (partial double on AudioManager) or asserting on an observable side-effect (e.g., a signal) instead, so the test proves the guard logic without I/O. --- scripts/ui/components/volume_slider.gd | 3 +- test/gut/test_volume_slider.gd | 39 ++++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/scripts/ui/components/volume_slider.gd b/scripts/ui/components/volume_slider.gd index b3eb896bd..cfcc8fad6 100644 --- a/scripts/ui/components/volume_slider.gd +++ b/scripts/ui/components/volume_slider.gd @@ -166,7 +166,8 @@ func _handle_slider_sfx(new_value: float) -> void: # Commit time state only after all guards pass _last_sfx_time = current_time - AudioManager.play_sfx(AudioConstants.SFX_SLIDER) + # Use get_node to allow for GUT testing/doubling of the autoload + get_node("/root/AudioManager").play_sfx(AudioConstants.SFX_SLIDER) ## Called on timer timeout—performs the batched disk save. diff --git a/test/gut/test_volume_slider.gd b/test/gut/test_volume_slider.gd index 6613f38ba..57dfa61bc 100644 --- a/test/gut/test_volume_slider.gd +++ b/test/gut/test_volume_slider.gd @@ -7,6 +7,20 @@ extends "res://addons/gut/test.gd" +# ========================================== +# MOCKS +# ========================================== + +class MockAudioManager extends Node: + var played_sfx: Array[String] = [] + + func play_sfx(sfx_name: String, _bus_name: String = "", _pitch_scale: float = 1.0, _volume_db: float = 0.0) -> void: + played_sfx.append(sfx_name) + +# ========================================== +# TESTS +# ========================================== + var _slider: VolumeSlider # Snapshot variables for state isolation @@ -134,17 +148,36 @@ func test_sfx_guard_blocks_no_interaction() -> void: ## WHAT: Simulates a manual drag interaction accompanied by a value delta. ## EXPECTED: All guards pass; _last_sfx_time is updated and _previous_value is committed. func test_sfx_guard_allows_valid_interaction() -> void: - # Setup: Different value AND active interaction + # 1. Setup the manual mock to block real audio I/O + var mock_am: MockAudioManager = MockAudioManager.new() + + # Swap out the real AudioManager in the scene tree + var root := get_tree().root + var real_am := root.get_node("AudioManager") + root.remove_child(real_am) + root.add_child(mock_am) + mock_am.name = "AudioManager" + + # 2. Setup slider interaction variables _slider._previous_value = 0.2 _slider._is_dragging = true _slider._last_sfx_time = 0 # Ensure no cooldown interference - # Act: Trigger SFX + # 3. Act: Trigger SFX _slider._handle_slider_sfx(0.5) - # Assert: All guards passed, state was committed + # 4. Assert local state assert_ne(_slider._last_sfx_time, 0, "SFX time should update when a valid, manual value delta occurs.") assert_eq(_slider._previous_value, 0.5, "Previous value should be updated after successful SFX trigger.") + + # 5. Assert the mock received the play_sfx call, proving the guards passed + assert_eq(mock_am.played_sfx.size(), 1, "play_sfx should be called exactly once.") + assert_eq(mock_am.played_sfx[0], AudioConstants.SFX_SLIDER, "The correct SFX constant should be played.") + + # 6. Cleanup: Safely restore the original AudioManager + root.remove_child(mock_am) + root.add_child(real_am) + mock_am.free() ## WHY: Protects the user from ear-piercing noise during rapid mouse movements. From 9b50fbb1bb37d8606959c3d9d11134ee3432e493 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Sun, 26 Apr 2026 12:13:42 -0700 Subject: [PATCH 40/49] Duplicate section header. 41-43: Duplicate section header. Line 41 repeats the exact same # --- NEW: SFX CACHING & MANAGEMENT --- banner already placed at line 15. Since both declarations belong to the same logical group but are split by the @export_category blocks, consider either consolidating them (move _sfx_cache next to the constants) or differentiating the headers (e.g., # --- SFX CACHE STATE ---) so readers aren't confused by two identical "NEW" sections. --- scripts/managers/audio_manager.gd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/managers/audio_manager.gd b/scripts/managers/audio_manager.gd index a33fce8f1..9695bb599 100644 --- a/scripts/managers/audio_manager.gd +++ b/scripts/managers/audio_manager.gd @@ -39,7 +39,7 @@ const MAX_SFX_CACHE_SIZE: int = 20 var current_config_path: String = Settings.CONFIG_PATH -# --- NEW: SFX CACHING & MANAGEMENT --- +# --- SFX CACHE STATE --- ## Dictionary to store preloaded AudioStreams to prevent disk I/O stutter. var _sfx_cache: Dictionary = {} From b8c448f4534bf54915aadda6a07e3742a38c2135 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Sun, 26 Apr 2026 12:17:21 -0700 Subject: [PATCH 41/49] Connect finished before calling play() for robustness. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 408-410: Connect finished before calling play() for robustness. Connecting the signal after play() risks a race condition and reads as reversed intent. Swap the order and use CONNECT_ONE_SHOT to make the single-fire cleanup explicit. This is a classic event-driven programming catch. Sourcery AI is absolutely correct.Connecting a signal after triggering the action that emits it creates a tiny, but real, race condition window. If an audio file is extremely short, or if the engine experiences a heavy thread stutter, the finished signal could theoretically fire before the connect line executes. If that happens, the AudioStreamPlayer will never be freed, resulting in a persistent memory leak (an orphaned node).Adding CONNECT_ONE_SHOT is also an excellent best practice here—it explicitly tells Godot's engine that this connection is meant to fire exactly once and then automatically disconnect itself, keeping the signal registry perfectly clean. --- scripts/managers/audio_manager.gd | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/managers/audio_manager.gd b/scripts/managers/audio_manager.gd index 9695bb599..04f80d5f6 100644 --- a/scripts/managers/audio_manager.gd +++ b/scripts/managers/audio_manager.gd @@ -415,6 +415,6 @@ func play_sfx( else: player.bus = bus_name - # 4. Play and Auto-Cleanup + # 4. Play and Auto-Cleanup (Swapped order to prevent race conditions) + player.finished.connect(player.queue_free, CONNECT_ONE_SHOT) player.play() - player.finished.connect(player.queue_free) From e14872c8900eb24894d677e62124b8f1cd6a8f93 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Sun, 26 Apr 2026 12:24:40 -0700 Subject: [PATCH 42/49] Update audio_manager.gd The new VolumeSlider tests reach into several internal members and helper methods (e.g., _is_dragging, _previous_value, _handle_slider_sfx); consider adding small public/query helpers or refactoring the logic behind a non-underscored method so tests rely less on private implementation details and are more resilient to refactors. In AudioManager.play_sfx, you create a new AudioStreamPlayer node on every call and only rely on the finished signal for cleanup; if slider or UI sounds can be triggered frequently, consider pooling or reusing a small number of players to avoid excessive node churn and signal connections at runtime. --- scripts/managers/audio_manager.gd | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/scripts/managers/audio_manager.gd b/scripts/managers/audio_manager.gd index 04f80d5f6..e6287ff44 100644 --- a/scripts/managers/audio_manager.gd +++ b/scripts/managers/audio_manager.gd @@ -19,6 +19,9 @@ const SFX_DIR_PATH: String = "res://files/sounds/sfx/" ## Hard cap for cached SFX streams to prevent unbounded memory growth. const MAX_SFX_CACHE_SIZE: int = 20 +## Number of reusable AudioStreamPlayers to keep in memory for UI sounds. +const SFX_POOL_SIZE: int = 8 + @export_category("Master Volume") @export var master_volume: float @export var master_muted: bool @@ -46,13 +49,21 @@ var _sfx_cache: Dictionary = {} ## Dictionary acting as a set to track missing SFX and prevent repeated load attempts/log spam. var _missing_sfx_cache: Dictionary = {} +## Array of pre-instantiated AudioStreamPlayers to prevent node instantiation churn. +var _sfx_pool: Array[AudioStreamPlayer] = [] + func _ready() -> void: ## Initializes to defaults and loads/applies volumes. - ## :rtype: void _init_to_defaults() # Set to defaults from AudioConstants load_volumes() # Load persisted volumes (overrides defaults if saved) apply_all_volumes() # Apply to AudioServer buses + + # Initialize the SFX object pool + for i in range(SFX_POOL_SIZE): + var p := AudioStreamPlayer.new() + add_child(p) + _sfx_pool.append(p) ## Initialize all volumes and mutes to defaults from AudioConstants @@ -397,9 +408,17 @@ func play_sfx( _sfx_cache.erase(sfx_name) _sfx_cache[sfx_name] = stream - # 2. Instantiate and Configure the Player - var player := AudioStreamPlayer.new() - add_child(player) + # 2. Grab an available player from the object pool + var player: AudioStreamPlayer = null + for p: AudioStreamPlayer in _sfx_pool: + if not p.playing: + player = p + break + + # Fallback: If all players are busy, hijack the first one in the pool + # to prevent dropping the new sound entirely. + if player == null: + player = _sfx_pool[0] player.stream = _sfx_cache[sfx_name] player.pitch_scale = pitch_scale @@ -415,6 +434,5 @@ func play_sfx( else: player.bus = bus_name - # 4. Play and Auto-Cleanup (Swapped order to prevent race conditions) - player.finished.connect(player.queue_free, CONNECT_ONE_SHOT) + # 4. Play (No queue_free needed since we reuse the nodes) player.play() From a001b09caaa4b5dbe59c9ffb882aa8e94de4134d Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Sun, 26 Apr 2026 12:25:51 -0700 Subject: [PATCH 43/49] Update audio_manager.gd --- scripts/managers/audio_manager.gd | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/managers/audio_manager.gd b/scripts/managers/audio_manager.gd index e6287ff44..fd8297a9b 100644 --- a/scripts/managers/audio_manager.gd +++ b/scripts/managers/audio_manager.gd @@ -58,7 +58,7 @@ func _ready() -> void: _init_to_defaults() # Set to defaults from AudioConstants load_volumes() # Load persisted volumes (overrides defaults if saved) apply_all_volumes() # Apply to AudioServer buses - + # Initialize the SFX object pool for i in range(SFX_POOL_SIZE): var p := AudioStreamPlayer.new() @@ -414,8 +414,8 @@ func play_sfx( if not p.playing: player = p break - - # Fallback: If all players are busy, hijack the first one in the pool + + # Fallback: If all players are busy, hijack the first one in the pool # to prevent dropping the new sound entirely. if player == null: player = _sfx_pool[0] From becbbbb232e8ea213dfb90ac1e74c2bf04d6b4a5 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Sun, 26 Apr 2026 12:56:08 -0700 Subject: [PATCH 44/49] Encapsulating VolumeSlider In AudioManager.play_sfx, you create a new AudioStreamPlayer node on every call and only rely on the finished signal for cleanup; if slider or UI sounds can be triggered frequently, consider pooling or reusing a small number of players to avoid excessive node churn and signal connections at runtime. --- scripts/ui/components/volume_slider.gd | 20 ++++++++++++++++++++ test/gut/test_volume_slider.gd | 16 ++++++++-------- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/scripts/ui/components/volume_slider.gd b/scripts/ui/components/volume_slider.gd index cfcc8fad6..3755fd2a5 100644 --- a/scripts/ui/components/volume_slider.gd +++ b/scripts/ui/components/volume_slider.gd @@ -175,3 +175,23 @@ func _handle_slider_sfx(new_value: float) -> void: func _on_debounce_timeout() -> void: AudioManager.save_volumes() Globals.log_message("Debounced settings save triggered.", Globals.LogLevel.DEBUG) + + +# ========================================== +# PUBLIC GETTERS FOR TESTING & VALIDATION +# ========================================== + +## Returns the last recorded delta value used for SFX checks. +## :rtype: float +func get_previous_value() -> float: + return _previous_value + +## Returns the raw timestamp of the last played interaction sound. +## :rtype: int +func get_last_sfx_time() -> int: + return _last_sfx_time + +## Returns whether the user is actively dragging the slider UI. +## :rtype: bool +func is_user_dragging() -> bool: + return _is_dragging diff --git a/test/gut/test_volume_slider.gd b/test/gut/test_volume_slider.gd index 57dfa61bc..05ef3ed15 100644 --- a/test/gut/test_volume_slider.gd +++ b/test/gut/test_volume_slider.gd @@ -102,7 +102,7 @@ func test_manual_value_change_starts_debounce_timer() -> void: ## EXPECTED: The _is_dragging flag remains false. func test_programmatic_change_does_not_alter_drag_state() -> void: _slider.set_value_programmatically(0.2) - assert_false(_slider._is_dragging, "Programmatic changes should not affect the _is_dragging state") + assert_false(_slider.is_user_dragging(), "Programmatic changes should not affect the _is_dragging state") # ========================================== @@ -117,13 +117,13 @@ func test_sfx_guard_blocks_identical_values() -> void: _slider.value = 0.5 _slider._previous_value = 0.5 _slider._is_dragging = true - var initial_sfx_time: int = _slider._last_sfx_time + var initial_sfx_time: int = _slider.get_last_sfx_time() # Act: Try to trigger SFX with the exact same value _slider._handle_slider_sfx(0.5) # Assert: The time shouldn't update because Guard 1 blocked it - assert_eq(_slider._last_sfx_time, initial_sfx_time, "SFX must be blocked if the value hasn't actually changed.") + assert_eq(_slider.get_last_sfx_time(), initial_sfx_time, "SFX must be blocked if the value hasn't actually changed.") ## WHY: Restricts SFX playback strictly to active user engagement. @@ -135,13 +135,13 @@ func test_sfx_guard_blocks_no_interaction() -> void: _slider._previous_value = 0.2 _slider._is_dragging = false _slider.release_focus() - var initial_sfx_time: int = _slider._last_sfx_time + var initial_sfx_time: int = _slider.get_last_sfx_time() # Act: Try to trigger SFX _slider._handle_slider_sfx(0.5) # Assert: The time shouldn't update because Guard 2 blocked it - assert_eq(_slider._last_sfx_time, initial_sfx_time, "SFX must be blocked if the user isn't actively interacting.") + assert_eq(_slider.get_last_sfx_time(), initial_sfx_time, "SFX must be blocked if the user isn't actively interacting.") ## WHY: Validates the "Happy Path" for manual interaction audio feedback. @@ -167,8 +167,8 @@ func test_sfx_guard_allows_valid_interaction() -> void: _slider._handle_slider_sfx(0.5) # 4. Assert local state - assert_ne(_slider._last_sfx_time, 0, "SFX time should update when a valid, manual value delta occurs.") - assert_eq(_slider._previous_value, 0.5, "Previous value should be updated after successful SFX trigger.") + assert_ne(_slider.get_last_sfx_time(), 0, "SFX time should update when a valid, manual value delta occurs.") + assert_eq(_slider.get_previous_value(), 0.5, "Previous value should be updated after successful SFX trigger.") # 5. Assert the mock received the play_sfx call, proving the guards passed assert_eq(mock_am.played_sfx.size(), 1, "play_sfx should be called exactly once.") @@ -198,7 +198,7 @@ func test_sfx_guard_enforces_rate_limiting() -> void: # Assert: It should have been blocked by the SFX_COOLDOWN_MS guard assert_eq( - _slider._last_sfx_time, + _slider.get_last_sfx_time(), future_time, "Rate limiter MUST block sounds requested faster than the cooldown window." ) From 7c3afcd25f71e4edb406dad8ac46c77088923d71 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Sun, 26 Apr 2026 12:56:56 -0700 Subject: [PATCH 45/49] Update volume_slider.gd --- scripts/ui/components/volume_slider.gd | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/ui/components/volume_slider.gd b/scripts/ui/components/volume_slider.gd index 3755fd2a5..6f83fb2e0 100644 --- a/scripts/ui/components/volume_slider.gd +++ b/scripts/ui/components/volume_slider.gd @@ -181,16 +181,19 @@ func _on_debounce_timeout() -> void: # PUBLIC GETTERS FOR TESTING & VALIDATION # ========================================== + ## Returns the last recorded delta value used for SFX checks. ## :rtype: float func get_previous_value() -> float: return _previous_value + ## Returns the raw timestamp of the last played interaction sound. ## :rtype: int func get_last_sfx_time() -> int: return _last_sfx_time + ## Returns whether the user is actively dragging the slider UI. ## :rtype: bool func is_user_dragging() -> bool: From d545aa16962b0e35a562bbac1cb8a61dbcc45dbf Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Sun, 26 Apr 2026 13:01:39 -0700 Subject: [PATCH 46/49] Guard ordering: _previous_value is committed before interaction/rate guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 127-150: Guard ordering: _previous_value is committed before interaction/rate guards. The early commit at Line 133 (before the interaction and cooldown checks) is intentional per the comment, but it has a subtle consequence: if a non-interactive value_changed slips through (e.g., a future code path writes to value directly without set_value_no_signal), _previous_value silently advances and the next genuine user interaction may see no delta and skip SFX. Since the current upstream paths all use set_value_programmatically, this is latent rather than active — worth a brief inline note or moving the commit after the interaction guard so user-driven deltas remain authoritative. --- scripts/ui/components/volume_slider.gd | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/ui/components/volume_slider.gd b/scripts/ui/components/volume_slider.gd index 6f83fb2e0..dc343faa1 100644 --- a/scripts/ui/components/volume_slider.gd +++ b/scripts/ui/components/volume_slider.gd @@ -148,9 +148,6 @@ func _handle_slider_sfx(new_value: float) -> void: if is_equal_approx(new_value, _previous_value): return - # Commit the value tracker early so delta checks stay accurate even if rate-limited - _previous_value = new_value - # Guard 2: Only play if user is actively interacting (Mouse Drag or Keyboard Focus) var is_mouse_active: bool = _is_dragging var is_keyboard_active: bool = has_focus() @@ -158,6 +155,10 @@ func _handle_slider_sfx(new_value: float) -> void: if not (is_mouse_active or is_keyboard_active): return + # Commit the value tracker only for valid user interactions. + # This ensures rogue programmatic updates don't swallow the next valid SFX delta. + _previous_value = new_value + # Guard 3: Rate limit to prevent audio spam during rapid drags var current_time: int = Time.get_ticks_msec() if current_time - _last_sfx_time < SFX_COOLDOWN_MS: From ddf40157e99634a04ba5baefdafb4e4a65176c2b Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Sun, 26 Apr 2026 13:07:30 -0700 Subject: [PATCH 47/49] Debounce timer restarts even when the new value is effectively identical. 111-119: Debounce timer restarts even when the new value is effectively identical. _on_value_changed unconditionally calls save_debounce_timer.start() and re-applies the dB write. While value_changed typically only fires on real changes, sub-epsilon float jitter (especially from keyboard step rounding or external value writes that bypassed set_value_no_signal) can still trigger this path and schedule a redundant disk save 0.5 s later. Consider gating both the audio write and the debounce on the is_equal_approx check that's currently only used inside _handle_slider_sfx --- scripts/ui/components/volume_slider.gd | 23 +++++------ test/gut/test_volume_slider.gd | 57 +++++++++++++++----------- 2 files changed, 45 insertions(+), 35 deletions(-) diff --git a/scripts/ui/components/volume_slider.gd b/scripts/ui/components/volume_slider.gd index dc343faa1..71a4a7e3e 100644 --- a/scripts/ui/components/volume_slider.gd +++ b/scripts/ui/components/volume_slider.gd @@ -128,6 +128,13 @@ func _notification(what: int) -> void: ## :type new_value: float ## :rtype: void func _on_value_changed(new_value: float) -> void: + # Early return: Prevent redundant backend I/O and disk saves on float jitter + if is_equal_approx(new_value, _previous_value): + return + + # Commit the delta tracker immediately + _previous_value = new_value + AudioServer.set_bus_volume_db(bus_index, linear_to_db(new_value)) AudioManager.set_volume(bus_name, new_value) @@ -138,28 +145,20 @@ func _on_value_changed(new_value: float) -> void: save_debounce_timer.start() -## Guards SFX playback against redundant values and rapid spam. +## Guards SFX playback against rapid spam. ## Ensures sound only plays during legitimate, rate-limited user interactions. ## :param new_value: The updated slider value. ## :type new_value: float ## :rtype: void -func _handle_slider_sfx(new_value: float) -> void: - # Guard 1: Only play if the value actually changed (float-safe delta check) - if is_equal_approx(new_value, _previous_value): - return - - # Guard 2: Only play if user is actively interacting (Mouse Drag or Keyboard Focus) +func _handle_slider_sfx(_new_value: float) -> void: + # Guard 1: Only play if user is actively interacting (Mouse Drag or Keyboard Focus) var is_mouse_active: bool = _is_dragging var is_keyboard_active: bool = has_focus() if not (is_mouse_active or is_keyboard_active): return - # Commit the value tracker only for valid user interactions. - # This ensures rogue programmatic updates don't swallow the next valid SFX delta. - _previous_value = new_value - - # Guard 3: Rate limit to prevent audio spam during rapid drags + # Guard 2: Rate limit to prevent audio spam during rapid drags var current_time: int = Time.get_ticks_msec() if current_time - _last_sfx_time < SFX_COOLDOWN_MS: return diff --git a/test/gut/test_volume_slider.gd b/test/gut/test_volume_slider.gd index 05ef3ed15..2af0384a3 100644 --- a/test/gut/test_volume_slider.gd +++ b/test/gut/test_volume_slider.gd @@ -119,31 +119,13 @@ func test_sfx_guard_blocks_identical_values() -> void: _slider._is_dragging = true var initial_sfx_time: int = _slider.get_last_sfx_time() - # Act: Try to trigger SFX with the exact same value - _slider._handle_slider_sfx(0.5) + # Act: Try to trigger the full pipeline with the exact same value + _slider._on_value_changed(0.5) - # Assert: The time shouldn't update because Guard 1 blocked it + # Assert: The time shouldn't update because the early return blocked it assert_eq(_slider.get_last_sfx_time(), initial_sfx_time, "SFX must be blocked if the value hasn't actually changed.") -## WHY: Restricts SFX playback strictly to active user engagement. -## WHAT: Changes the value while the slider is neither being dragged nor focused. -## EXPECTED: Guard 2 blocks playback; _last_sfx_time remains at its initial value. -func test_sfx_guard_blocks_no_interaction() -> void: - # Setup: New value, but NO interaction (not dragging, no focus) - _slider.value = 0.5 - _slider._previous_value = 0.2 - _slider._is_dragging = false - _slider.release_focus() - var initial_sfx_time: int = _slider.get_last_sfx_time() - - # Act: Try to trigger SFX - _slider._handle_slider_sfx(0.5) - - # Assert: The time shouldn't update because Guard 2 blocked it - assert_eq(_slider.get_last_sfx_time(), initial_sfx_time, "SFX must be blocked if the user isn't actively interacting.") - - ## WHY: Validates the "Happy Path" for manual interaction audio feedback. ## WHAT: Simulates a manual drag interaction accompanied by a value delta. ## EXPECTED: All guards pass; _last_sfx_time is updated and _previous_value is committed. @@ -163,8 +145,8 @@ func test_sfx_guard_allows_valid_interaction() -> void: _slider._is_dragging = true _slider._last_sfx_time = 0 # Ensure no cooldown interference - # 3. Act: Trigger SFX - _slider._handle_slider_sfx(0.5) + # 3. Act: Trigger the full pipeline + _slider._on_value_changed(0.5) # 4. Assert local state assert_ne(_slider.get_last_sfx_time(), 0, "SFX time should update when a valid, manual value delta occurs.") @@ -178,6 +160,35 @@ func test_sfx_guard_allows_valid_interaction() -> void: root.remove_child(mock_am) root.add_child(real_am) mock_am.free() + # Setup: Set an initial value and simulate an interaction + _slider.value = 0.5 + _slider._previous_value = 0.5 + _slider._is_dragging = true + var initial_sfx_time: int = _slider.get_last_sfx_time() + + # Act: Try to trigger SFX with the exact same value + _slider._handle_slider_sfx(0.5) + + # Assert: The time shouldn't update because Guard 1 blocked it + assert_eq(_slider.get_last_sfx_time(), initial_sfx_time, "SFX must be blocked if the value hasn't actually changed.") + + +## WHY: Restricts SFX playback strictly to active user engagement. +## WHAT: Changes the value while the slider is neither being dragged nor focused. +## EXPECTED: Guard 2 blocks playback; _last_sfx_time remains at its initial value. +func test_sfx_guard_blocks_no_interaction() -> void: + # Setup: New value, but NO interaction (not dragging, no focus) + _slider.value = 0.5 + _slider._previous_value = 0.2 + _slider._is_dragging = false + _slider.release_focus() + var initial_sfx_time: int = _slider.get_last_sfx_time() + + # Act: Try to trigger SFX + _slider._handle_slider_sfx(0.5) + + # Assert: The time shouldn't update because Guard 2 blocked it + assert_eq(_slider.get_last_sfx_time(), initial_sfx_time, "SFX must be blocked if the user isn't actively interacting.") ## WHY: Protects the user from ear-piercing noise during rapid mouse movements. From c179b1d455f76d3921aa93c513d3d71ed09dffae Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Sun, 26 Apr 2026 13:15:43 -0700 Subject: [PATCH 48/49] Update test_volume_slider.gd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In test_volume_slider.gd (GUT suite), test_sfx_guard_allows_valid_interaction contains a second, unrelated block after the cleanup that reinitializes _slider state and calls _handle_slider_sfx; this looks like a copy-paste from the identical-value test and should either be split into a separate test or removed to keep the test’s intent clear and focused. --- test/gut/test_volume_slider.gd | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/test/gut/test_volume_slider.gd b/test/gut/test_volume_slider.gd index 2af0384a3..76219054c 100644 --- a/test/gut/test_volume_slider.gd +++ b/test/gut/test_volume_slider.gd @@ -160,17 +160,6 @@ func test_sfx_guard_allows_valid_interaction() -> void: root.remove_child(mock_am) root.add_child(real_am) mock_am.free() - # Setup: Set an initial value and simulate an interaction - _slider.value = 0.5 - _slider._previous_value = 0.5 - _slider._is_dragging = true - var initial_sfx_time: int = _slider.get_last_sfx_time() - - # Act: Try to trigger SFX with the exact same value - _slider._handle_slider_sfx(0.5) - - # Assert: The time shouldn't update because Guard 1 blocked it - assert_eq(_slider.get_last_sfx_time(), initial_sfx_time, "SFX must be blocked if the value hasn't actually changed.") ## WHY: Restricts SFX playback strictly to active user engagement. From 12c21c36d8a4926389d64d3d0cea34dfa2666fd3 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Sun, 26 Apr 2026 13:17:38 -0700 Subject: [PATCH 49/49] Update volume_slider.gd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In VolumeSlider.set_value_programmatically, you always propagate the clamped value to AudioServer and AudioManager even if it hasn’t changed; adding a short-circuit when is_equal_approx(clamped_value, _previous_value) is true would avoid redundant backend calls and keep behavior consistent with _on_value_changed’s duplicate-guard. --- scripts/ui/components/volume_slider.gd | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/ui/components/volume_slider.gd b/scripts/ui/components/volume_slider.gd index 71a4a7e3e..48a0188ea 100644 --- a/scripts/ui/components/volume_slider.gd +++ b/scripts/ui/components/volume_slider.gd @@ -86,6 +86,10 @@ func set_value_programmatically(new_value: float) -> void: # Clamp to the slider's configured range to avoid UI/backend divergence var clamped_value: float = clamp(new_value, min_value, max_value) + # Early return: Prevent redundant backend I/O if the value hasn't changed + if is_equal_approx(clamped_value, _previous_value): + return + # Godot 4 native method: updates visual value without emitting 'value_changed' set_value_no_signal(clamped_value)