Skip to content

Adopt observer-based settings with automatic save and UI sync#469

Merged
ikostan merged 29 commits intomainfrom
migrate-to-resource-persistence
Mar 16, 2026
Merged

Adopt observer-based settings with automatic save and UI sync#469
ikostan merged 29 commits intomainfrom
migrate-to-resource-persistence

Conversation

@ikostan
Copy link
Copy Markdown
Owner

@ikostan ikostan commented Mar 13, 2026


name: Default Pull Request Template
about: Suggesting changes to SkyLockAssault
title: ''
labels: ''
assignees: ''

Description

What does this PR do? (e.g., "Fixes player jump physics in level 2" or "Adds
new enemy AI script")

Related Issue

Closes #ISSUE_NUMBER (if applicable)

Changes

  • List key changes here (e.g., "Updated Jump.gd to use Godot 4.4's new Tween
    system")
  • Any breaking changes? (e.g., "Deprecated old signal; migrate to new one")

Testing

  • Ran the game in Godot v4.5 editor—describe what you tested (e.g., "Jump
    works on Win10 with 60 FPS")
  • Any new unit tests added? (Link to test scene if yes)
  • Screenshots/GIFs if UI-related: (Attach below)

Checklist

  • Code follows Godot style guide (e.g., snake_case for variables)
  • No console errors in editor/output
  • Ready for review!

Additional Notes

Anything else? (e.g., "Tested on Win10 64-bit; needs Linux validation")

Summary by Sourcery

Adopt an observer-based settings system that centralizes reactions to configuration changes, enabling automatic logging, persistence, and UI synchronization.

New Features:

  • Introduce a settings_changed signal on GameSettingsResource to notify observers of configuration updates.
  • Add support for an enable_debug_logging setting that can be toggled and persisted.
  • Wire gameplay and advanced settings UIs to observe GameSettingsResource so they react automatically to external changes.

Enhancements:

  • Refine settings properties to use validated setters/getters, including clamping difficulty and emitting change notifications.
  • Have Globals.gd observe GameSettingsResource changes to automatically log updates and save settings without direct UI calls.
  • Ensure gameplay difficulty UI stays in sync with the resource using signal-safe updates and removes redundant manual persistence calls.

Tests:

  • Add a GUT test suite validating the settings observer pattern, including signal emission, clamping behavior, persistence to disk, and UI reactivity.
  • Update the difficulty flow end-to-end test to match the new difficulty change log message text.

Summary by CodeRabbit

  • New Features

    • Real-time settings propagation so UI components update immediately when settings change.
  • Improvements

    • Settings now auto-persist and emit change notifications; UI no longer triggers manual saves.
    • Difficulty input is validated/clamped; UI syncs from external updates.
    • Minor log message wording updated for clarity.
  • Tests

    • Added tests covering observer behavior, clamping, persistence, and UI synchronization.

ikostan added 2 commits March 12, 2026 21:11
## TEST SUITE: Verifies the Observer Pattern implementation for game settings.
## This suite ensures that UI-driven changes to GameSettingsResource correctly
## emit signals and that Globals.gd reacts by persisting data, thereby
## decoupling UI logic from the persistence layer.
This Pull Request addresses **Issue #432** by refactoring the settings system to use the **Observer Pattern**, effectively decoupling the UI layer from persistence and logging logic.

### Summary of Changes

* **Observer Pattern Implementation**:
*
**Subject**: Enhanced `GameSettingsResource` with a `setting_changed` signal and property setters to automatically notify observers of state changes.

*
**Observer (Persistence)**: Refactored `Globals.gd` to listen for the `setting_changed` signal, centralizing all `log_message()` and `_save_settings()` calls in a single reactive handler.

*
**Observer (UI)**: Updated `gameplay_settings.gd` and `advanced_settings.gd` to sync their internal states when the resource is updated externally (e.g., during initialization or a reset).

* **Decoupling & Cleanup**:
* Removed "Leaky Abstractions" by stripping manual `_save_settings()` and logging calls from UI scripts; they now only update the resource data.

* **Testing**:
* Added a comprehensive GUT suite (`test_settings_observer.gd`) covering signal integrity, data clamping, and automated persistence.

* Resolved strict typing errors and API mismatches in the test environment.
@ikostan ikostan self-assigned this Mar 13, 2026
@ikostan ikostan added the enhancement New feature or request label Mar 13, 2026
@ikostan ikostan linked an issue Mar 13, 2026 that may be closed by this pull request
@ikostan ikostan added the menu label Mar 13, 2026
@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai Bot commented Mar 13, 2026

Reviewer's Guide

Introduce an observer-based settings flow where GameSettingsResource emits setting_changed signals, Globals listens for those to log and persist automatically, and the gameplay/advanced settings UIs plus tests are updated to align with this reactive, clamped, and auto-synced model.

Sequence diagram for observer-based settings update from UI to persistence

sequenceDiagram
    actor Player
    participant GameplaySettings as GameplaySettingsMenu
    participant GameSettingsResource
    participant Globals
    participant ConfigFile as SettingsConfig

    Player->>GameplaySettings: Adjust difficulty slider(value)
    GameplaySettings->>GameplaySettings: _on_difficulty_value_changed(value)
    GameplaySettings->>GameSettingsResource: set difficulty(value)
    GameSettingsResource->>GameSettingsResource: clamp(value, 0.5, 2.0)
    GameSettingsResource-->>GameSettingsResource: _difficulty updated
    GameSettingsResource-->>GameplaySettings: setting_changed(difficulty, clamped_value)
    GameSettingsResource-->>Globals: setting_changed(difficulty, clamped_value)

    Globals->>Globals: _on_setting_changed(difficulty, clamped_value)
    Globals->>Globals: log_message("Setting 'difficulty' updated to: ...")
    Globals->>SettingsConfig: _save_settings()
    SettingsConfig-->>Globals: save OK

    GameplaySettings->>GameplaySettings: difficulty_slider.value = settings.difficulty
    GameplaySettings->>GameplaySettings: difficulty_label.text = "{" + settings.difficulty + "}"
Loading

Sequence diagram for external settings change syncing back to UI

sequenceDiagram
    participant ExternalSystem as ExternalSystem
    participant GameSettingsResource
    participant Globals
    participant GameplaySettings as GameplaySettingsMenu

    ExternalSystem->>GameSettingsResource: set difficulty(new_value)
    GameSettingsResource->>GameSettingsResource: clamp(new_value, 0.5, 2.0)
    GameSettingsResource-->>GameSettingsResource: _difficulty updated
    GameSettingsResource-->>Globals: setting_changed(difficulty, clamped_value)
    GameSettingsResource-->>GameplaySettings: setting_changed(difficulty, clamped_value)

    Globals->>Globals: _on_setting_changed(difficulty, clamped_value)
    Globals->>Globals: log_message("Setting 'difficulty' updated to: ...")
    Globals->>Globals: _save_settings()

    GameplaySettings->>GameplaySettings: _on_external_setting_changed(difficulty, clamped_value)
    GameplaySettings->>GameplaySettings: difficulty_slider.set_value_no_signal(clamped_value)
    GameplaySettings->>GameplaySettings: difficulty_label.text = "{" + str(clamped_value) + "}"
Loading

Class diagram for observer-based GameSettingsResource and listeners

classDiagram
    class GameSettingsResource {
        <<Resource>>
        +signal setting_changed(setting_name: String, new_value: Variant)
        +current_log_level: int
        +enable_debug_logging: bool
        +difficulty: float
        +main_menu_scene: PackedScene
        +level_select_scene: PackedScene
        +training_scene: PackedScene
        +key_mapping_scene: PackedScene
        +options_scene: PackedScene
        -_current_log_level: int
        -_difficulty: float
        -_enable_debug_logging: bool
        +set_current_log_level(value: int) void
        +get_current_log_level() int
        +set_enable_debug_logging(value: bool) void
        +get_enable_debug_logging() bool
        +set_difficulty(value: float) void
        +get_difficulty() float
    }

    class Globals {
        <<Node>>
        +settings: GameSettingsResource
        +_ready() void
        +_on_setting_changed(setting_name: String, new_value: Variant) void
        +_load_settings(path: String) void
        +_save_settings(path: String) void
        +log_message(message: String, level: int) void
    }

    class GameplaySettingsMenu {
        <<Control>>
        +difficulty_slider
        +difficulty_label
        +_ready() void
        +_on_difficulty_value_changed(value: float) void
        +_on_external_setting_changed(setting_name: String, new_value: Variant) void
        +_on_tree_exited() void
    }

    class AdvancedSettingsMenu {
        <<Control>>
        +log_lvl_option
        +_on_log_level_item_selected(index: int) void
    }

    GameSettingsResource <.. Globals : observes
    GameSettingsResource <.. GameplaySettingsMenu : observes
    GameSettingsResource <.. AdvancedSettingsMenu : updated_by
    Globals o-- GameSettingsResource : settings
    GameplaySettingsMenu --> Globals : uses
    AdvancedSettingsMenu --> Globals : uses
Loading

File-Level Changes

Change Details Files
Turn GameSettingsResource into an observable settings source with clamped setters and backing fields.
  • Add a setting_changed(signal_name, new_value) signal and emit it from property setters.
  • Refactor current_log_level, enable_debug_logging, and difficulty into properties using private backing fields.
  • Clamp difficulty and log level values and early-return when unchanged to avoid recursive updates.
scripts/game_settings_resource.gd
Make Globals observe GameSettingsResource changes and centralize logging and persistence.
  • Connect Globals to the GameSettingsResource.setting_changed signal in _ready.
  • Add _on_setting_changed handler to log updates and call _save_settings automatically.
  • Extend _load_settings and _save_settings to handle the enable_debug_logging flag in config files.
scripts/globals.gd
Update gameplay and advanced settings menus to rely on the observable resource instead of manual save/logging.
  • Gameplay settings menu subscribes to GameSettingsResource.setting_changed to keep the difficulty slider/label in sync with external changes.
  • Disconnect gameplay settings observer and UI signals in _on_tree_exited to prevent stale references.
  • Change difficulty and log level handlers to set resource properties only, letting observers handle logging and saving.
scripts/gameplay_settings.gd
scripts/advanced_settings.gd
Align tests with the observer pattern and new logging format.
  • Update difficulty_flow_test to expect the new "difficulty updated to" log message text.
  • Add a GUT test suite that validates signal emission, clamping behavior, persistence via Globals._save_settings, UI synchronization, and debug logging flag persistence.
tests/difficulty_flow_test.py
test/gut/test_settings_observer.gd
test/gut/test_settings_observer.gd.uid

Assessment against linked issues

Issue Objective Addressed Explanation
#432 Replace ConfigFile-based persistence in Globals._load_settings() and Globals._save_settings() with Resource-backed persistence using ResourceLoader/ResourceSaver for GameSettingsResource (storing settings as .tres). The PR keeps using ConfigFile in Globals._load_settings() and Globals._save_settings(), only extending it to handle enable_debug_logging. There is no use of ResourceLoader.load() or ResourceSaver.save(), nor any .tres-based persistence paths introduced.
#432 Update tests to validate the new Resource-based settings persistence instead of relying on the legacy ConfigFile format. Existing tests remain ConfigFile-oriented (e.g., difficulty_flow_test.py checks logs around settings saved), and the new GUT tests in test_settings_observer.gd explicitly interact with ConfigFile and .cfg paths for persistence. No tests are added or modified to verify .tres Resource save/load behavior.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@ikostan ikostan moved this to In Progress in Sky Lock Assault Project Mar 13, 2026
@deepsource-io
Copy link
Copy Markdown

deepsource-io Bot commented Mar 13, 2026

DeepSource Code Review

We reviewed changes in 66a7339...038800a on this pull request. Below is the summary for the review, and you can see the individual issues we found as inline review comments.

See full review on DeepSource ↗

PR Report Card

Overall Grade   Security  

Reliability  

Complexity  

Hygiene  

Code Review Summary

Analyzer Status Updated (UTC) Details
Python Mar 16, 2026 5:16a.m. Review ↗
JavaScript Mar 16, 2026 5:16a.m. Review ↗

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • The new setters for current_log_level and enable_debug_logging assign back to the same property, which will recurse indefinitely; use a backing field (e.g. _current_log_level) or set_deferred/set() on the underlying data instead.
  • In gameplay_settings.gd, the connection to Globals.settings.setting_changed is never disconnected in _on_tree_exited, and it assumes Globals.settings is always non-null—consider guarding against a missing settings resource and explicitly disconnecting in the cleanup path to avoid leaks or stray callbacks.
  • Several comments contain [cite: ...] markers (e.g. in game_settings_resource.gd and tests); these look like tooling artifacts and should be removed or replaced with clearer internal references before merging.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new setters for `current_log_level` and `enable_debug_logging` assign back to the same property, which will recurse indefinitely; use a backing field (e.g. `_current_log_level`) or `set_deferred`/`set()` on the underlying data instead.
- In `gameplay_settings.gd`, the connection to `Globals.settings.setting_changed` is never disconnected in `_on_tree_exited`, and it assumes `Globals.settings` is always non-null—consider guarding against a missing settings resource and explicitly disconnecting in the cleanup path to avoid leaks or stray callbacks.
- Several comments contain `[cite: ...]` markers (e.g. in `game_settings_resource.gd` and tests); these look like tooling artifacts and should be removed or replaced with clearer internal references before merging.

## Individual Comments

### Comment 1
<location path="scripts/game_settings_resource.gd" line_range="20-23" />
<code_context>
 # Current log level: 0=DEBUG, 1=INFO, 2=WARNING, 3=ERROR, 4=NONE
-@export_range(0, 4, 1) var current_log_level: int = 1
-@export var enable_debug_logging: bool = false
+@export_range(0, 4, 1) var current_log_level: int = 1:
+	set(value):
+		current_log_level = value
+		setting_changed.emit("current_log_level", value)
+
+@export var enable_debug_logging: bool = false:
</code_context>
<issue_to_address>
**issue (bug_risk):** Setter for `current_log_level` is recursively assigning to itself and will never terminate.

Inside the setter, `current_log_level = value` re-enters the same setter and will recurse indefinitely. Use a separate backing field or write to the underlying storage instead. For example:

```gdscript
var _current_log_level := 1
@export_range(0, 4, 1) var current_log_level: int = 1:
    set(value):
        _current_log_level = value
        setting_changed.emit("current_log_level", value)
    get:
        return _current_log_level
```
</issue_to_address>

### Comment 2
<location path="scripts/game_settings_resource.gd" line_range="25-28" />
<code_context>
+		current_log_level = value
+		setting_changed.emit("current_log_level", value)
+
+@export var enable_debug_logging: bool = false:
+	set(value):
+		enable_debug_logging = value
+		setting_changed.emit("enable_debug_logging", value)

 @export_group("Gameplay")
</code_context>
<issue_to_address>
**issue (bug_risk):** The `enable_debug_logging` setter has the same self-recursive assignment problem as `current_log_level`.

Assigning to `enable_debug_logging` inside its own setter will recurse indefinitely. Use a separate backing field (e.g. `_enable_debug_logging`) and update it in the setter while emitting the signal.
</issue_to_address>

### Comment 3
<location path="scripts/gameplay_settings.gd" line_range="44" />
<code_context>
 	tree_exited.connect(_on_tree_exited)
+	
+	# NEW: The UI now observes the resource for external changes
+	Globals.settings.setting_changed.connect(_on_external_setting_changed)

 	if os_wrapper.has_feature("web"):
</code_context>
<issue_to_address>
**issue (bug_risk):** New connection to `Globals.settings.setting_changed` can be created multiple times without being disconnected.

If this node leaves and re-enters the scene tree, `_ready` may run multiple times and create duplicate connections, so `_on_external_setting_changed` would be invoked multiple times per signal. Since you already handle cleanup in `_on_tree_exited`, also disconnect this signal there and/or guard the connection with:

```gdscript
if not Globals.settings.setting_changed.is_connected(_on_external_setting_changed):
    Globals.settings.setting_changed.connect(_on_external_setting_changed)
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread scripts/game_settings_resource.gd Outdated
Comment thread scripts/game_settings_resource.gd Outdated
Comment thread scripts/gameplay_settings.gd Outdated
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 13, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

GameSettingsResource now emits setting_changed on property updates; Globals listens and persists via _save_settings; UI handlers were simplified to update state without directly saving; tests were added to validate signaling, clamping, persistence, and UI synchronization.

Changes

Cohort / File(s) Summary
Settings Resource
scripts/game_settings_resource.gd
Added signal setting_changed(setting_name, new_value) and setter/getter-backed properties (current_log_level, enable_debug_logging, difficulty) that emit setting_changed and perform clamping/guarding.
Globals Observer & Persistence
scripts/globals.gd
Connects settings.setting_changed to _on_setting_changed(setting_name, new_value) which logs and calls new _save_settings() to persist config.
UI Integrations
scripts/advanced_settings.gd, scripts/gameplay_settings.gd
Removed direct Globals._save_settings() and explicit log calls from UI handlers; gameplay_settings.gd subscribes to setting_changed and updates UI controls via _on_external_setting_changed to avoid save loops.
Tests
test/gut/test_settings_observer.gd, test/gut/test_settings_observer.gd.uid, tests/difficulty_flow_test.py
Added observer-focused Gut tests and UID; updated expected log text in difficulty_flow_test.py to match wording change ("updated").

Sequence Diagram

sequenceDiagram
    participant UI as UI Component (gameplay_settings)
    participant Resource as GameSettingsResource
    participant Globals as Globals
    participant Disk as Config File

    UI->>Resource: set difficulty / change setting
    activate Resource
    Resource->>Resource: update internal value / clamp
    Resource->>Resource: emit setting_changed(setting_name, new_value)
    deactivate Resource

    Resource->>Globals: setting_changed(setting_name, new_value)
    activate Globals
    Globals->>Globals: log change
    Globals->>Globals: _save_settings()
    Globals->>Disk: write config file
    deactivate Globals

    Resource->>UI: setting_changed(setting_name, new_value)
    activate UI
    UI->>UI: update controls/labels without saving
    deactivate UI
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Possibly related PRs

Suggested labels

GUI

Poem

🐰 I nudged a dial, a tiny ping,
A setting danced and sent its ring,
Globals scribbled, saved it tight,
UI followed, soft and light,
The rabbit hops — all systems sing.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description follows the template structure but is incomplete; it contains only checkboxes without providing actual details for most sections. Fill in the Description section with what the PR actually does, provide specific examples in Changes, describe actual testing performed, and add any relevant notes about the observer pattern implementation.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly summarizes the main architectural change: adopting an observer-based settings model with automatic persistence and UI synchronization.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch migrate-to-resource-persistence
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

ikostan added 5 commits March 12, 2026 21:43
…can be created multiple times without being disconnected.

If this node leaves and re-enters the scene tree, _ready may run multiple times and create duplicate connections, so _on_external_setting_changed would be invoked multiple times per signal. Since you already handle cleanup in _on_tree_exited, also disconnect this signal there and/or guard the connection
…ng to itself and will never terminate.

Inside the setter, current_log_level = value re-enters the same setter and will recurse indefinitely. Use a separate backing field or write to the underlying storage instead. For example:
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
scripts/advanced_settings.gd (1)

162-167: ⚠️ Potential issue | 🟡 Minor

Remove duplicate _save_settings() call.

Setting current_log_level triggers the setter which emits setting_changed, and the observer in Globals._on_setting_changed already calls _save_settings(). The explicit call here causes a duplicate disk write.

This is inconsistent with _on_log_level_item_selected (Line 272) where the call is correctly commented out.

🔧 Proposed fix
 func _on_advanced_reset_button_pressed() -> void:
 	Globals.log_message("Advanced Settings reset pressed.", Globals.LogLevel.DEBUG)
 	# Log level should be reset to INFO
 	Globals.settings.current_log_level = Globals.LogLevel.INFO
 	log_lvl_option.selected = Globals.LogLevel.values().find(Globals.LogLevel.INFO)
-	Globals._save_settings()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/advanced_settings.gd` around lines 162 - 167, In
_on_advanced_reset_button_pressed(), remove the explicit duplicate call to
Globals._save_settings() because setting Globals.settings.current_log_level uses
its setter which emits setting_changed and Globals._on_setting_changed already
calls _save_settings(); keep the current_log_level assignment and
log_lvl_option.selected update as-is so the observer-driven save handles
persistence, mirroring the approach used in _on_log_level_item_selected.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@scripts/advanced_settings.gd`:
- Around line 162-167: In _on_advanced_reset_button_pressed(), remove the
explicit duplicate call to Globals._save_settings() because setting
Globals.settings.current_log_level uses its setter which emits setting_changed
and Globals._on_setting_changed already calls _save_settings(); keep the
current_log_level assignment and log_lvl_option.selected update as-is so the
observer-driven save handles persistence, mirroring the approach used in
_on_log_level_item_selected.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 21c78242-0ba7-496f-b131-fb93853f9399

📥 Commits

Reviewing files that changed from the base of the PR and between 66a7339 and 9b57178.

📒 Files selected for processing (6)
  • scripts/advanced_settings.gd
  • scripts/game_settings_resource.gd
  • scripts/gameplay_settings.gd
  • scripts/globals.gd
  • test/gut/test_settings_observer.gd
  • test/gut/test_settings_observer.gd.uid
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Browser Functional Tests / test
🔇 Additional comments (13)
test/gut/test_settings_observer.gd.uid (1)

1-1: LGTM!

Standard Godot 4.x UID file for test resource tracking.

scripts/globals.gd (2)

52-56: LGTM!

Good ordering: connecting the signal after _load_settings() ensures that loading persisted values doesn't trigger redundant save operations.


58-67: LGTM!

Clean reactive handler centralizing persistence. The immediate save on every change is simple and ensures durability.

scripts/advanced_settings.gd (1)

267-272: LGTM!

Correct migration to the observer pattern. The commented-out _save_settings() call documents the intentional change while letting the observer handle persistence automatically.

scripts/gameplay_settings.gd (3)

43-45: LGTM!

Proper observer hookup allowing the UI to stay synchronized with external resource changes.


86-91: LGTM!

Good use of set_value_no_signal() to update the slider without triggering value_changed, preventing a circular signal loop.


251-256: LGTM!

Properly migrated to the observer pattern. The UI only modifies the resource and relies on Globals._on_setting_changed for automatic persistence.

test/gut/test_settings_observer.gd (4)

15-22: LGTM!

Good test isolation - using a dedicated test resource and cleaning up test config files between runs.


25-50: LGTM!

Comprehensive signal integrity tests covering both difficulty and log level changes, plus clamping validation.


53-68: LGTM!

Correct approach: manually connecting to the test resource and directing saves to _test_config_path ensures isolation from production config while validating actual persistence behavior.


109-115: LGTM!

Good end-to-end test verifying that external resource changes propagate to the UI slider through the observer pattern.

scripts/game_settings_resource.gd (2)

10-17: LGTM!

Well-documented signal declaration clearly explaining the Observer pattern intent and usage.


20-35: LGTM!

Consistent setter implementation across all observable properties. The difficulty setter correctly emits the clamped value rather than the raw input.

Comment thread test/gut/test_settings_observer.gd Outdated
…t problem as current_log_level.

issue (bug_risk): The enable_debug_logging setter has the same self-recursive assignment problem as current_log_level.

Assigning to enable_debug_logging inside its own setter will recurse indefinitely. Use a separate backing field (e.g. _enable_debug_logging) and update it in the setter while emitting the signal.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
scripts/game_settings_resource.gd (1)

20-25: Consider clamping current_log_level in the setter for consistency.

Unlike difficulty which clamps in its setter (line 38), current_log_level relies solely on @export_range which only constrains UI input, not programmatic assignments. For defensive consistency, consider adding range enforcement here as well.

🔧 Optional fix to add clamping
 `@export_range`(0, 4, 1) var current_log_level: int = 1:
 	set(value):
-		_current_log_level = value
+		_current_log_level = clampi(value, 0, 4)
-		setting_changed.emit("current_log_level", value)
+		setting_changed.emit("current_log_level", _current_log_level)
 	get:
 		return _current_log_level
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/game_settings_resource.gd` around lines 20 - 25, The
current_log_level setter should enforce the same 0–4 bounds as the UI
export_range to prevent invalid programmatic values; inside the set(value) for
current_log_level clamp value to the allowed range (0..4) before assigning to
_current_log_level and emitting setting_changed, mirroring the approach used in
the difficulty setter to ensure defensive consistency.
scripts/gameplay_settings.gd (1)

100-106: Missing disconnect for Globals.settings.setting_changed.

The cleanup disconnects local signals but doesn't disconnect from Globals.settings.setting_changed connected at line 45. For consistency with the other explicit disconnects and to prevent callbacks during the exit process, consider adding the disconnect here.

🔧 Proposed fix to add missing disconnect
 	# Disconnect Godot signals if still connected
 	if difficulty_slider.value_changed.is_connected(_on_difficulty_value_changed):
 		difficulty_slider.value_changed.disconnect(_on_difficulty_value_changed)
 	if gameplay_back_button.pressed.is_connected(_on_gameplay_back_button_pressed):
 		gameplay_back_button.pressed.disconnect(_on_gameplay_back_button_pressed)
 	if gameplay_reset_button.pressed.is_connected(_on_gameplay_reset_button_pressed):
 		gameplay_reset_button.pressed.disconnect(_on_gameplay_reset_button_pressed)
+	if Globals.settings.setting_changed.is_connected(_on_external_setting_changed):
+		Globals.settings.setting_changed.disconnect(_on_external_setting_changed)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/gameplay_settings.gd` around lines 100 - 106, The cleanup block is
missing a disconnect for Globals.settings.setting_changed which was connected
earlier; update the same teardown section where difficulty_slider,
gameplay_back_button, and gameplay_reset_button are disconnected to also check
if Globals.settings.setting_changed.is_connected(_on_setting_changed) and if so
call Globals.settings.setting_changed.disconnect(_on_setting_changed) so the
Settings callback (_on_setting_changed) is properly removed during exit.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@scripts/gameplay_settings.gd`:
- Around line 252-257: The label shows the raw parameter instead of the clamped
setting; change code so difficulty_label.text uses the clamped value stored in
Globals.settings.difficulty (not the incoming value) to keep UI consistent:
after assigning Globals.settings.difficulty = value and updating
difficulty_slider.value, set difficulty_label.text to use
Globals.settings.difficulty (formatted as before) and keep logging via
Globals.log_message as-is.

---

Nitpick comments:
In `@scripts/game_settings_resource.gd`:
- Around line 20-25: The current_log_level setter should enforce the same 0–4
bounds as the UI export_range to prevent invalid programmatic values; inside the
set(value) for current_log_level clamp value to the allowed range (0..4) before
assigning to _current_log_level and emitting setting_changed, mirroring the
approach used in the difficulty setter to ensure defensive consistency.

In `@scripts/gameplay_settings.gd`:
- Around line 100-106: The cleanup block is missing a disconnect for
Globals.settings.setting_changed which was connected earlier; update the same
teardown section where difficulty_slider, gameplay_back_button, and
gameplay_reset_button are disconnected to also check if
Globals.settings.setting_changed.is_connected(_on_setting_changed) and if so
call Globals.settings.setting_changed.disconnect(_on_setting_changed) so the
Settings callback (_on_setting_changed) is properly removed during exit.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 34f9f2af-1be4-4549-8938-dc4ab61ccb3e

📥 Commits

Reviewing files that changed from the base of the PR and between 9b57178 and 7ba43f1.

📒 Files selected for processing (2)
  • scripts/game_settings_resource.gd
  • scripts/gameplay_settings.gd
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Browser Functional Tests / test
🔇 Additional comments (6)
scripts/game_settings_resource.gd (4)

10-16: Well-structured Observer pattern implementation.

The signal documentation is clear and the Variant type for new_value provides flexibility for different setting types.


27-32: LGTM!

Boolean setter correctly uses backing field and emits the signal.


36-41: LGTM!

The setter correctly clamps the value before storing and emits the signal with the clamped value, ensuring observers receive the actual applied value.


49-52: LGTM!

Backing field defaults correctly match the @export defaults, preventing initialization mismatches.

scripts/gameplay_settings.gd (2)

43-45: LGTM!

Good use of connection guard to prevent duplicate signal handlers.


87-91: LGTM!

Using set_value_no_signal correctly prevents circular signal emissions when external changes update the UI.

Comment thread scripts/gameplay_settings.gd Outdated
ikostan added 2 commits March 13, 2026 21:09
Line 254 correctly uses the clamped value from Globals.settings.difficulty for the slider, but line 255 uses the raw value parameter for the label. If a user or JS callback passes a value outside 0.5-2.0 (e.g., 2.5), the slider will show 2.0 but the label will show "{2.5}".
Calling sut._ready() at Line 77 causes globals.gd to reload settings from res://config_resources/default_settings.tres (see globals.gd Line 40), which overwrites the _resource assignment at Line 76. The signal connection then binds to the production resource, not the test resource.

The assertion at Line 81 only verifies that _resource.difficulty holds the assigned value—it doesn't confirm that the observer in sut received the signal or triggered persistence.

Consider either:

Mocking the resource load in _ready(), or
Manually connecting the signal after _ready() and verifying persistence to _test_config_path.
@ikostan
Copy link
Copy Markdown
Owner Author

ikostan commented Mar 14, 2026

@sourcery-ai review

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • In gameplay_settings.gd, _ready() connects to Globals.settings.setting_changed, but _on_tree_exited() doesn’t disconnect from that signal; consider explicitly disconnecting there to avoid stale connections if the node is freed and re-instantiated.
  • In globals.gd’s _ready(), you connect settings.setting_changed to _on_setting_changed without checking is_connected; for robustness (especially in editor reloads or tests that may reassign Globals.settings), mirror the defensive pattern used in gameplay_settings.gd and guard the connection.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `gameplay_settings.gd`, `_ready()` connects to `Globals.settings.setting_changed`, but `_on_tree_exited()` doesn’t disconnect from that signal; consider explicitly disconnecting there to avoid stale connections if the node is freed and re-instantiated.
- In `globals.gd`’s `_ready()`, you connect `settings.setting_changed` to `_on_setting_changed` without checking `is_connected`; for robustness (especially in editor reloads or tests that may reassign `Globals.settings`), mirror the defensive pattern used in `gameplay_settings.gd` and guard the connection.

## Individual Comments

### Comment 1
<location path="scripts/gameplay_settings.gd" line_range="87-91" />
<code_context>
 	Globals.log_message("Gameplay Settings menu loaded.", Globals.LogLevel.DEBUG)


+func _on_external_setting_changed(setting_name: String, new_value: Variant) -> void:
+	if setting_name == "difficulty":
+		# Update UI without triggering a circular signal loop
+		difficulty_slider.set_value_no_signal(new_value)
+		difficulty_label.text = "{" + str(new_value) + "}"
+
+
</code_context>
<issue_to_address>
**suggestion (bug_risk):** UI updates for difficulty are now split between the external observer and the local value-changed handler, which is redundant and risks subtle signal loops.

The UI is now updated in both `_on_external_setting_changed` and `_on_difficulty_value_changed`. The observer sets the slider with `set_value_no_signal` and updates the label from `new_value`, while the local handler updates `Globals.settings.difficulty` (emitting the signal) and then updates the slider and label again from the resource.

This duplication makes the flow harder to follow and increases the risk of subtle signal loops if the slider ever emits on unchanged `value`. Consider having the local handler only update the resource and letting `_on_external_setting_changed` be the single place that updates the UI, or consistently using `set_value_no_signal` for programmatic slider changes.

Suggested implementation:

```
func _on_external_setting_changed(setting_name: String, new_value: Variant) -> void:
	if setting_name == "difficulty":
		# Single source of truth for difficulty UI: reflect resource changes without emitting signals
		difficulty_slider.set_value_no_signal(new_value)
		difficulty_label.text = "{" + str(new_value) + "}"

```

You’ll also need to adjust the local difficulty slider handler (and any similar handlers) in the same file:

1. In `func _on_difficulty_value_changed(value: float) -> void` (or similarly named), remove any direct UI updates, keeping only the resource write. For example, change a block like:
   - `Globals.settings.difficulty = int(value)`
   - `difficulty_slider.value = value` (or `set_value_no_signal`)
   - `difficulty_label.text = "{" + str(value) + "}"`

   to only:
   - `Globals.settings.difficulty = int(value)`

   The `setting_changed` signal from the resource will then invoke `_on_external_setting_changed`, which will update the slider and label exactly once, using `set_value_no_signal` to avoid loops.

2. If there are any other places in `gameplay_settings.gd` where the difficulty slider or label are updated programmatically (e.g. when loading settings, resetting to defaults), change those to:
   - Write the new value to `Globals.settings.difficulty` instead of directly setting `difficulty_slider` / `difficulty_label`.
   - Rely on the `setting_changed` signal + `_on_external_setting_changed` to refresh the UI.

3. Verify that the difficulty slider’s `value_changed` signal is connected only to the local handler (`_on_difficulty_value_changed`) and not to both handlers, so the flow remains: **UI → local handler → resource → setting_changed → external handler → UI**.
</issue_to_address>

### Comment 2
<location path="scripts/advanced_settings.gd" line_range="264-272" />
<code_context>
 	var selected_enum: Globals.LogLevel = log_level_display_to_enum.get(
 		selected_name, Globals.LogLevel.INFO
 	)
+	# Only update the resource; the Observer handles the rest
 	Globals.settings.current_log_level = selected_enum
 	log_lvl_option.selected = Globals.LogLevel.values().find(selected_enum)
 	# Temporary raw print to bypass log_message
 	Globals.log_message("Log level changed to: " + selected_name, Globals.LogLevel.DEBUG)
-	Globals._save_settings()
+	# Globals._save_settings()


</code_context>
<issue_to_address>
**suggestion:** Log messages for log-level changes are now duplicated: once here and once via the centralized Observer.

Since `_on_setting_changed` in `globals.gd` now logs every `current_log_level` change, this `Globals.log_message("Log level changed to: " + selected_name, ...)` causes duplicate entries. Consider removing this call or changing it to a more UI-specific message or different level so it doesn’t overlap with the centralized logging.

```suggestion
	var selected_enum: Globals.LogLevel = log_level_display_to_enum.get(
		selected_name, Globals.LogLevel.INFO
	)
	# Only update the resource; the Observer handles the rest (including logging)
	Globals.settings.current_log_level = selected_enum
	log_lvl_option.selected = Globals.LogLevel.values().find(selected_enum)
	# Logging of log-level changes is handled centrally in Globals._on_setting_changed
	# Globals._save_settings()
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread scripts/gameplay_settings.gd
Comment thread scripts/advanced_settings.gd
@ikostan
Copy link
Copy Markdown
Owner Author

ikostan commented Mar 14, 2026

The following feedback is technically valid but highlights a discrepancy between the architectural goal and the current implementation.

Issue Objective Addressed Explanation
#432 Replace ConfigFile-based persistence in Globals._save_settings() and Globals._load_settings() with ResourceSaver.save() / ResourceLoader.load() using GameSettingsResource .tres assets. The diff does not modify _save_settings() or _load_settings() in globals.gd to use ResourceSaver/ResourceLoader or .tres paths; persistence remains ConfigFile-based, only now triggered via the new setting_changed signal.
#432 Update tests to validate the new Resource-backed persistence mechanism instead of relying on ConfigFile format and behavior. The new tests in test_settings_observer.gd explicitly use ConfigFile, asserting values stored in a .cfg file and calling Globals._save_settings with a config path; they still test ConfigFile-based persistence rather than Resource-backed .tres persistence.

This PR goal was specifically "Decoupling": therefore this work is a successful first step. We have fixed the "Leaky Abstractions" by moving logic to the Resource and Globals, which makes the eventual switch to .tres persistence much easier.


Based on the current development status of SkyLockAssault, the issues in Milestone 14 focus on migrating hard-coded elements (Fuel, Speed, Weapons, Bullets, and Globals) into Godot Resources. While this covers the data conversion, there are a few "missing links" in the logic and testing departments that you might want to address with additional issues.

Suggested Additional Issues

1. [FEATURE] Centralized Resource Manager

Currently, you have separate issues for converting individual elements (Fuel, Speed, etc.). You may need a dedicated issue to implement a Resource Manager or update the ResourcePreloader to handle loading these new .tres files at runtime, ensuring they don't stay isolated from the game loop.

2. [TEST] Resource Serialization & Persistence Validation

The feedback from your recent code review highlighted that while the Observer Pattern is in place, you aren't yet fully utilizing ResourceSaver.

  • Issue Recommendation: Create an issue specifically for migrating Globals._save_settings() from ConfigFile (.cfg) to ResourceSaver.save() (.tres) to ensure your persistence mechanism is fully "Resource-backed" as originally intended.

3. [REFACTOR] Signal Decoupling for New Resources

Since you are adding signals for Fuel and Speed systems, you should ensure these signals are emitted by the Resources themselves rather than the Player.gd script.

  • Issue Recommendation: A refactor task to move signal emission logic into the new Godot Resources, following the Observer Pattern we just established for the settings.

Milestone 14 Checklist Comparison

Category Current Issues in MS 14 Recommended Additions
Data Fuel, Speed, Weapons, Bullets, Globals Level/Environment Resources
Logic Core Gameplay Signals Centralized Resource Loader/Saver
Testing General Resource Persistence .tres vs .cfg Validation Tests

In order to address all the above following bugs/feature requests were opened:

@ikostan
Copy link
Copy Markdown
Owner Author

ikostan commented Mar 14, 2026

@sourcery-ai title

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
scripts/gameplay_settings.gd (1)

87-91: Consolidate duplicated difficulty-UI sync logic into one helper.

_on_external_setting_changed and _on_difficulty_value_changed both update slider/label state. Extracting a shared UI-sync helper will reduce drift risk.

♻️ Suggested refactor
+func _sync_difficulty_ui(value: float, emit_signal: bool) -> void:
+	if emit_signal:
+		difficulty_slider.value = value
+	else:
+		difficulty_slider.set_value_no_signal(value)
+	difficulty_label.text = "{" + str(value) + "}"

 func _on_external_setting_changed(setting_name: String, new_value: Variant) -> void:
 	if setting_name == "difficulty":
-		# Update UI without triggering a circular signal loop
-		difficulty_slider.set_value_no_signal(new_value)
-		difficulty_label.text = "{" + str(new_value) + "}"
+		_sync_difficulty_ui(float(new_value), false)

 func _on_difficulty_value_changed(value: float) -> void:
 	# Update the resource first (this triggers clamping in the setter)
 	Globals.settings.difficulty = value
 	# Update the UI components using the ALREADY CLAMPED value from the resource
-	difficulty_slider.value = Globals.settings.difficulty
-	difficulty_label.text = "{" + str(Globals.settings.difficulty) + "}"
+	_sync_difficulty_ui(Globals.settings.difficulty, true)

Also applies to: 252-256

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/gameplay_settings.gd` around lines 87 - 91, Create a single helper
(e.g., sync_difficulty_ui(value, use_no_signal: bool)) and move the shared UI
update logic into it: set the slider value via
difficulty_slider.set_value_no_signal(value) when use_no_signal is true or
difficulty_slider.set_value(value) otherwise, and update difficulty_label.text =
"{" + str(value) + "}". Replace the duplicated blocks in
_on_external_setting_changed and _on_difficulty_value_changed to call
sync_difficulty_ui(new_value, true) for the external setting path and
sync_difficulty_ui(value, false) (or just sync_difficulty_ui(value)) for the
local change path so all label/slider updates are centralized.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@scripts/gameplay_settings.gd`:
- Around line 87-91: Create a single helper (e.g., sync_difficulty_ui(value,
use_no_signal: bool)) and move the shared UI update logic into it: set the
slider value via difficulty_slider.set_value_no_signal(value) when use_no_signal
is true or difficulty_slider.set_value(value) otherwise, and update
difficulty_label.text = "{" + str(value) + "}". Replace the duplicated blocks in
_on_external_setting_changed and _on_difficulty_value_changed to call
sync_difficulty_ui(new_value, true) for the external setting path and
sync_difficulty_ui(value, false) (or just sync_difficulty_ui(value)) for the
local change path so all label/slider updates are centralized.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 07774eac-9223-44bc-a528-45d0e37ab00f

📥 Commits

Reviewing files that changed from the base of the PR and between 1babb51 and d176792.

📒 Files selected for processing (3)
  • scripts/gameplay_settings.gd
  • test/gut/test_settings_observer.gd
  • tests/difficulty_flow_test.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/gut/test_settings_observer.gd
📜 Review details
🔇 Additional comments (3)
tests/difficulty_flow_test.py (1)

175-175: Assertion text now correctly matches the observer-based log wording.

This update is aligned with the current message emitted by the gameplay settings flow, so the test intent remains correct.

scripts/gameplay_settings.gd (2)

43-45: Observer connection guard is implemented correctly.

Using is_connected before connecting Globals.settings.setting_changed prevents duplicate signal hookups in normal menu lifecycle.


257-259: Clamped-value logging text update is consistent with the new flow.

Logging "Difficulty updated to: ..." with the clamped resource value keeps messaging consistent with the observer-based settings model.

ikostan added 3 commits March 15, 2026 21:19
…n the external observer and the local value-changed handler, which is redundant and risks subtle signal loops.

he UI is now updated in both _on_external_setting_changed and _on_difficulty_value_changed. The observer sets the slider with set_value_no_signal and updates the label from new_value, while the local handler updates Globals.settings.difficulty (emitting the signal) and then updates the slider and label again from the resource.

This duplication makes the flow harder to follow and increases the risk of subtle signal loops if the slider ever emits on unchanged value. Consider having the local handler only update the resource and letting _on_external_setting_changed be the single place that updates the UI, or consistently using set_value_no_signal for programmatic slider changes.
…ce here and once via the centralized Observer.

Since _on_setting_changed in globals.gd now logs every current_log_level change, this Globals.log_message("Log level changed to: " + selected_name, ...) causes duplicate entries. Consider removing this call or changing it to a more UI-specific message or different level so it doesn’t overlap with the centralized logging.
@ikostan
Copy link
Copy Markdown
Owner Author

ikostan commented Mar 16, 2026

@sourcery-ai review

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • In gameplay_settings.gd::_on_external_setting_changed, reassigning Globals.settings.difficulty when handling the difficulty change is redundant because the resource already holds new_value (and the setter early-returns on same value); you can drop that assignment and just update the UI to avoid unnecessary calls into the settings resource.
  • The new Globals.settings.setting_changed connection in gameplay_settings.gd is never disconnected in _on_tree_exited, which can leave stale observers around if the menu is created/destroyed multiple times; consider explicitly disconnecting the signal in _on_tree_exited.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `gameplay_settings.gd::_on_external_setting_changed`, reassigning `Globals.settings.difficulty` when handling the `difficulty` change is redundant because the resource already holds `new_value` (and the setter early-returns on same value); you can drop that assignment and just update the UI to avoid unnecessary calls into the settings resource.
- The new `Globals.settings.setting_changed` connection in `gameplay_settings.gd` is never disconnected in `_on_tree_exited`, which can leave stale observers around if the menu is created/destroyed multiple times; consider explicitly disconnecting the signal in `_on_tree_exited`.

## Individual Comments

### Comment 1
<location path="scripts/gameplay_settings.gd" line_range="87-95" />
<code_context>
 	Globals.log_message("Gameplay Settings menu loaded.", Globals.LogLevel.DEBUG)


+func _on_external_setting_changed(setting_name: String, new_value: Variant) -> void:
+	if setting_name == "difficulty":
+		# Prevent Circular Updates in the UI
+		if difficulty_slider.value != float(new_value):
+			Globals.settings.difficulty = float(new_value)
+		# Update UI without triggering a circular signal loop
+		# Use set_value_no_signal to avoid re-triggering _on_difficulty_value_changed
+		difficulty_slider.set_value_no_signal(new_value)
+		difficulty_label.text = "{" + str(new_value) + "}"
+
+
</code_context>
<issue_to_address>
**suggestion:** The write-back to `Globals.settings.difficulty` in the external observer looks unnecessary and slightly convoluted.

In `_on_external_setting_changed`, when `setting_name == "difficulty"`, this callback is already responding to a change in `Globals.settings`. Writing `Globals.settings.difficulty = float(new_value)` again is redundant and depends on the setter guard to avoid loops. Consider using this handler only to sync UI (`difficulty_slider` and `difficulty_label`) from `new_value`, and omit the write-back to the resource.

```suggestion
func _on_external_setting_changed(setting_name: String, new_value: Variant) -> void:
	if setting_name != "difficulty":
		return

	# Sync UI from the updated resource value without emitting UI change signals
	difficulty_slider.set_value_no_signal(float(new_value))
	difficulty_label.text = "{" + str(new_value) + "}"
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread scripts/gameplay_settings.gd
…rnal observer looks unnecessary and slightly convoluted.

In _on_external_setting_changed, when setting_name == "difficulty", this callback is already responding to a change in Globals.settings. Writing Globals.settings.difficulty = float(new_value) again is redundant and depends on the setter guard to avoid loops. Consider using this handler only to sync UI (difficulty_slider and difficulty_label) from new_value, and omit the write-back to the resource.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
scripts/advanced_settings.gd (1)

255-272: ⚠️ Potential issue | 🟡 Minor

Advanced settings is still not observing the resource it now relies on.

After removing the direct save/log calls, this menu never subscribes to Globals.settings.setting_changed. If current_log_level changes anywhere else while the panel is open, log_lvl_option stays stale, so this screen is not actually synced to GameSettingsResource yet.

Mirror the external-setting hookup used in scripts/gameplay_settings.gd and drive programmatic selection through that observer path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/advanced_settings.gd` around lines 255 - 272, The log level selector
never subscribes to Globals.settings.setting_changed, so UI stays out-of-sync;
update the panel to connect to the same observer pattern used in
gameplay_settings.gd (connect to Globals.settings.setting_changed in _ready or
when the panel is shown), implement or reuse the handler used there to update
log_lvl_option.selected programmatically (instead of setting selected directly
in _on_log_level_item_selected), and ensure you disconnect the signal when the
panel is hidden/freed to avoid leaks; reference _on_log_level_item_selected,
Globals.settings.setting_changed, and log_lvl_option when making the changes.
🧹 Nitpick comments (1)
scripts/gameplay_settings.gd (1)

87-95: Keep _on_external_setting_changed() UI-only.

setting_changed is emitted after the resource has already stored the canonical difficulty. Writing Globals.settings.difficulty again here is redundant and keeps a second model-write path alive for the same event.

Small cleanup
 func _on_external_setting_changed(setting_name: String, new_value: Variant) -> void:
 	if setting_name == "difficulty":
-		# Prevent Circular Updates in the UI
-		if difficulty_slider.value != float(new_value):
-			Globals.settings.difficulty = float(new_value)
-		# Update UI without triggering a circular signal loop
-		# Use set_value_no_signal to avoid re-triggering _on_difficulty_value_changed
-		difficulty_slider.set_value_no_signal(new_value)
-		difficulty_label.text = "{" + str(new_value) + "}"
+		var difficulty_value: float = float(new_value)
+		difficulty_slider.set_value_no_signal(difficulty_value)
+		difficulty_label.text = "{" + str(difficulty_value) + "}"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/gameplay_settings.gd` around lines 87 - 95, The handler
_on_external_setting_changed is performing a redundant model write
(Globals.settings.difficulty = float(new_value)) — remove that assignment so the
function only updates the UI; keep the circular-update guard and update calls on
difficulty_slider and difficulty_label, and ensure you pass a numeric value into
difficulty_slider.set_value_no_signal (e.g., float(new_value)) and format
difficulty_label.text as before; reference symbols:
_on_external_setting_changed, Globals.settings.difficulty,
difficulty_slider.set_value_no_signal, difficulty_label.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@scripts/game_settings_resource.gd`:
- Around line 27-30: Update the globals save/load to persist the new
enable_debug_logging flag: modify scripts/globals.gd::_save_settings() to
include the setting (use the exported name "enable_debug_logging" or the backing
value _enable_debug_logging from GameSettings) when serializing settings, and
modify scripts/globals.gd:_load_settings() to read that key and set
GameSettings.enable_debug_logging (or the backing field) with a sensible default
if missing; ensure the load path uses the same key name so the emitted
setting_changed flow actually survives reloads.
- Around line 20-23: The setter for current_log_level currently assigns any
integer and immediately emits setting_changed; update the setter on
current_log_level to validate the incoming value (ensure it's an integer within
the allowed range 0..4) before writing to _current_log_level and before calling
setting_changed.emit; if the value is out-of-range, either clamp it to the
nearest valid value or reject it (e.g., return/warn) so invalid values are never
stored or broadcast from current_log_level, and reference the current_log_level
setter, the backing field _current_log_level, and the signal setting_changed
when making the change.

In `@scripts/gameplay_settings.gd`:
- Around line 261-263: Remove the duplicate debug log that reports difficulty
changes: delete the Globals.log_message(...) call that uses
Globals.settings.difficulty (the local log inside the difficulty setter/update
block) so changes are only logged centrally by the existing setting_changed
signal handler (Globals._on_setting_changed). Ensure the assignment
Globals.settings.difficulty = value remains but no extra logging is performed
locally.

---

Outside diff comments:
In `@scripts/advanced_settings.gd`:
- Around line 255-272: The log level selector never subscribes to
Globals.settings.setting_changed, so UI stays out-of-sync; update the panel to
connect to the same observer pattern used in gameplay_settings.gd (connect to
Globals.settings.setting_changed in _ready or when the panel is shown),
implement or reuse the handler used there to update log_lvl_option.selected
programmatically (instead of setting selected directly in
_on_log_level_item_selected), and ensure you disconnect the signal when the
panel is hidden/freed to avoid leaks; reference _on_log_level_item_selected,
Globals.settings.setting_changed, and log_lvl_option when making the changes.

---

Nitpick comments:
In `@scripts/gameplay_settings.gd`:
- Around line 87-95: The handler _on_external_setting_changed is performing a
redundant model write (Globals.settings.difficulty = float(new_value)) — remove
that assignment so the function only updates the UI; keep the circular-update
guard and update calls on difficulty_slider and difficulty_label, and ensure you
pass a numeric value into difficulty_slider.set_value_no_signal (e.g.,
float(new_value)) and format difficulty_label.text as before; reference symbols:
_on_external_setting_changed, Globals.settings.difficulty,
difficulty_slider.set_value_no_signal, difficulty_label.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: cf0f3add-a560-4327-bd62-6ae38984e2a7

📥 Commits

Reviewing files that changed from the base of the PR and between d176792 and b786831.

📒 Files selected for processing (3)
  • scripts/advanced_settings.gd
  • scripts/game_settings_resource.gd
  • scripts/gameplay_settings.gd
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Browser Functional Tests / test

Comment thread scripts/game_settings_resource.gd Outdated
Comment thread scripts/game_settings_resource.gd Outdated
Comment thread scripts/gameplay_settings.gd Outdated
ikostan and others added 5 commits March 15, 2026 21:39
The new Globals.settings.setting_changed connection in gameplay_settings.gd is never disconnected in _on_tree_exited, which can leave stale observers around if the menu is created/destroyed multiple times; consider explicitly disconnecting the signal in _on_tree_exited.
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
To ensure that enable_debug_logging persists across reloads, you must update the serialization logic in globals.gd. Currently, your centralized save flow is triggered by the signal in GameSettingsResource, but the _save_settings() and _load_settings() functions in globals.gd only handle log_level and difficulty.

While your refactored GameSettingsResource correctly emits the setting_changed signal, the Observer (Globals.gd) was only "notified" that something happened; it didn't know it needed to write that specific new field to the disk. By updating these two functions, you complete the persistence loop for the debug flag.
@ikostan
Copy link
Copy Markdown
Owner Author

ikostan commented Mar 16, 2026

@sourcery-ai review

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • GameSettingsResource.enable_debug_logging always emits setting_changed even when the value is unchanged, unlike difficulty/current_log_level; consider adding an early return on no-op updates to avoid redundant logging and saves.
  • The tests repeatedly wire custom lambdas to setting_changed to call Globals._save_settings with a test path; you might simplify this by extracting the save-on-change behavior into a small helper that takes a Config path so both production code and tests can reuse the same observer logic.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- GameSettingsResource.enable_debug_logging always emits setting_changed even when the value is unchanged, unlike difficulty/current_log_level; consider adding an early return on no-op updates to avoid redundant logging and saves.
- The tests repeatedly wire custom lambdas to setting_changed to call Globals._save_settings with a test path; you might simplify this by extracting the save-on-change behavior into a small helper that takes a Config path so both production code and tests can reuse the same observer logic.

## Individual Comments

### Comment 1
<location path="scripts/game_settings_resource.gd" line_range="30-35" />
<code_context>
+	get:
+		return _current_log_level
+
+@export var enable_debug_logging: bool = false:
+	set(value):
+		_enable_debug_logging = value
+		setting_changed.emit("enable_debug_logging", value)
+	get:
+		return _enable_debug_logging

 @export_group("Gameplay")
</code_context>
<issue_to_address>
**suggestion:** Guard the debug logging setter to avoid redundant signal emissions

Here `enable_debug_logging` emits `setting_changed` even when the value hasn’t changed, unlike `current_log_level` and `difficulty`. This causes unnecessary log + save operations when the UI reassigns the same value. Consider normalizing the input (if needed) and returning early when `_enable_debug_logging == value` before emitting the signal.

```suggestion
@export var enable_debug_logging: bool = false:
	set(value):
		var new_value: bool = bool(value)
		if _enable_debug_logging == new_value:
			return
		_enable_debug_logging = new_value
		setting_changed.emit("enable_debug_logging", new_value)
	get:
		return _enable_debug_logging
```
</issue_to_address>

### Comment 2
<location path="scripts/globals.gd" line_range="59-67" />
<code_context>
+
+
+## Reactive handler for the Observer Pattern [cite: 141]
+func _on_setting_changed(setting_name: String, new_value: Variant) -> void:
+	# FIX: Ensure we are comparing String to String or using correct types
+	var log_msg: String = "Setting '%s' updated to: %s" % [setting_name, str(new_value)]
+
+	# Automatically log the change [cite: 59]
+	log_message(log_msg, LogLevel.DEBUG)
+
+	# Automatically persist to disk [cite: 53]
+	_save_settings()


</code_context>
<issue_to_address>
**suggestion (performance):** Observer handler may cause excessive disk writes and logging during bulk loads

Since `_on_setting_changed` logs and calls `_save_settings()` on every property update, bulk operations like `_load_settings()` will result in one disk write and log entry per field. To avoid this overhead, introduce a way to disable persistence/logging during bulk updates (e.g., a `is_loading_settings` flag or a batch apply API) and perform a single save once loading is complete, while preserving reactive behavior for normal runtime changes.

Suggested implementation:

```
var _is_loading_settings: bool = false


## Reactive handler for the Observer Pattern [cite: 141]
func _on_setting_changed(setting_name: String, new_value: Variant) -> void:
	# Suppress reactive side effects when performing bulk load / batch updates
	if _is_loading_settings:
		return

	# FIX: Ensure we are comparing String to String or using correct types
	var log_msg: String = "Setting '%s' updated to: %s" % [setting_name, str(new_value)]

	# Automatically log the change [cite: 59]
	log_message(log_msg, LogLevel.DEBUG)

	# Automatically persist to disk [cite: 53]
	_save_settings()

```

To fully support bulk operations without excessive logging/disk writes, adjust the loading/batch APIs elsewhere in `scripts/globals.gd` as follows:

1. In your bulk loading function (likely named `_load_settings()` or similar), wrap the bulk updates:

```gdscript
func _load_settings() -> void:
	_is_loading_settings = true
	# ... existing logic that sets many settings / triggers setting_changed ...
	_is_loading_settings = false
	_save_settings()  # optional: single save after all updates
```

2. If you have any other batch-apply APIs that update multiple settings, they should also set `_is_loading_settings = true` before the batch and reset it to `false` afterwards, optionally performing a single `_save_settings()` at the end.

This ensures that normal runtime changes still trigger logging and persistence, while bulk loads perform only a single save and avoid noisy logs.
</issue_to_address>

### Comment 3
<location path="scripts/advanced_settings.gd" line_range="264-272" />
<code_context>
 	var selected_enum: Globals.LogLevel = log_level_display_to_enum.get(
 		selected_name, Globals.LogLevel.INFO
 	)
+	# Only update the resource; the Observer handles the rest
 	Globals.settings.current_log_level = selected_enum
 	log_lvl_option.selected = Globals.LogLevel.values().find(selected_enum)
 	# Temporary raw print to bypass log_message
 	Globals.log_message("Log level changed to: " + selected_name, Globals.LogLevel.DEBUG)
-	Globals._save_settings()
+	# Globals._save_settings()


</code_context>
<issue_to_address>
**suggestion:** Avoid duplicating logging behavior now handled by the settings observer

Since `current_log_level` changes are already logged and persisted via `_on_setting_changed`, this extra `Globals.log_message` will create duplicate entries for a single change. Please either remove this call or move the logging responsibility entirely into the observer so there’s a single source of truth for log side effects.

```suggestion
	var selected_enum: Globals.LogLevel = log_level_display_to_enum.get(
		selected_name, Globals.LogLevel.INFO
	)
	# Only update the resource; the Observer handles logging & persistence
	Globals.settings.current_log_level = selected_enum
	log_lvl_option.selected = Globals.LogLevel.values().find(selected_enum)
	# Globals._save_settings()
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread scripts/game_settings_resource.gd
Comment thread scripts/globals.gd
Comment thread scripts/advanced_settings.gd
ikostan and others added 6 commits March 15, 2026 21:56
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
…rites and logging during bulk loads

Since _on_setting_changed logs and calls _save_settings() on every property update, bulk operations like _load_settings() will result in one disk write and log entry per field. To avoid this overhead, introduce a way to disable persistence/logging during bulk updates (e.g., a is_loading_settings flag or a batch apply API) and perform a single save once loading is complete, while preserving reactive behavior for normal runtime changes.
This commit fixes the style issues introduced in d5d21b2 according to the output
from Black and isort.

Details: #469
@ikostan ikostan merged commit 52b5ba0 into main Mar 16, 2026
12 checks passed
@github-project-automation github-project-automation Bot moved this from In Progress to Done in Sky Lock Assault Project Mar 16, 2026
@ikostan ikostan deleted the migrate-to-resource-persistence branch March 16, 2026 05:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request menu testing

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[FEATURE] Migrate to Resource persistence

1 participant