Skip to content

Commit 9ddba69

Browse files
authored
Merge pull request #488 from ikostan/settings-labels-display-unclamped-values
Harden gameplay settings JS bridge and add comprehensive tests
2 parents 164a2b8 + 976147a commit 9ddba69

13 files changed

Lines changed: 629 additions & 73 deletions

scripts/gameplay_settings.gd

Lines changed: 148 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ extends Control
66
## actual engine singletons.
77
var js_bridge_wrapper: JavaScriptBridgeWrapper = JavaScriptBridgeWrapper.new()
88
var os_wrapper: OSWrapper = OSWrapper.new()
9-
var js_window: JavaScriptObject
9+
var js_window: Variant
1010
var _change_difficulty_cb: JavaScriptObject
1111
var _gameplay_back_button_pressed_cb: JavaScriptObject
1212
var _gameplay_reset_cb: JavaScriptObject
@@ -27,22 +27,39 @@ func _ready() -> void:
2727
# Configure for web overlays (invisible but positioned)
2828
process_mode = Node.PROCESS_MODE_ALWAYS # Ignore pause
2929

30-
difficulty_slider.value_changed.connect(_on_difficulty_value_changed)
31-
# Set initial difficulty label (sync with global)
32-
difficulty_slider.value = Globals.settings.difficulty
33-
difficulty_label.text = "{" + str(Globals.settings.difficulty) + "}"
30+
var settings_res := Globals.settings if is_instance_valid(Globals) else null
31+
32+
# ADD GUARDS HERE:
33+
if not difficulty_slider.value_changed.is_connected(_on_difficulty_value_changed):
34+
difficulty_slider.value_changed.connect(_on_difficulty_value_changed)
35+
36+
# Set initial difficulty label (sync with global if available)
37+
# FIX: Use the local reference for consistency
38+
if is_instance_valid(settings_res):
39+
difficulty_slider.value = settings_res.difficulty
40+
difficulty_label.text = "{" + str(settings_res.difficulty) + "}"
41+
else:
42+
difficulty_slider.value = _default_difficulty
43+
difficulty_label.text = "{" + str(_default_difficulty) + "}"
44+
3445
# Back button
3546
if not gameplay_back_button.pressed.is_connected(_on_gameplay_back_button_pressed):
3647
gameplay_back_button.pressed.connect(_on_gameplay_back_button_pressed)
3748
# Reset button listener
3849
if not gameplay_reset_button.pressed.is_connected(_on_gameplay_reset_button_pressed):
3950
gameplay_reset_button.pressed.connect(_on_gameplay_reset_button_pressed)
4051
# NEW: Attach tree_exited for unexpected removal cleanup (like other settings scripts)
41-
tree_exited.connect(_on_tree_exited)
52+
if not tree_exited.is_connected(_on_tree_exited):
53+
tree_exited.connect(_on_tree_exited)
4254

4355
# NEW: The UI now observes the resource for external changes
44-
if not Globals.settings.setting_changed.is_connected(_on_external_setting_changed):
45-
Globals.settings.setting_changed.connect(_on_external_setting_changed)
56+
# if not Globals.settings.setting_changed.is_connected(_on_external_setting_changed):
57+
# Globals.settings.setting_changed.connect(_on_external_setting_changed)
58+
if (
59+
is_instance_valid(settings_res)
60+
and not settings_res.setting_changed.is_connected(_on_external_setting_changed)
61+
):
62+
settings_res.setting_changed.connect(_on_external_setting_changed)
4663

4764
if os_wrapper.has_feature("web"):
4865
# Toggle overlays...
@@ -85,10 +102,15 @@ func _ready() -> void:
85102

86103

87104
func _on_external_setting_changed(setting_name: String, new_value: Variant) -> void:
105+
## SYNC UI ONLY:
106+
## This observer reacts to changes from the resource.
107+
## We must ensure the UI nodes are still valid before updating them.
88108
if setting_name == "difficulty":
89-
# SYNC UI ONLY:
90-
# The resource has already been updated, so we only need to update the UI components.
91-
# Use set_value_no_signal to prevent re-triggering the local _on_difficulty_value_changed handler.
109+
# FIX: Guard against 'previously freed' errors during teardown/unit tests
110+
if not is_instance_valid(difficulty_slider) or not is_instance_valid(difficulty_label):
111+
return
112+
113+
# Use set_value_no_signal to prevent re-triggering local handlers
92114
difficulty_slider.set_value_no_signal(float(new_value))
93115
difficulty_label.text = "{" + str(new_value) + "}"
94116

@@ -97,29 +119,34 @@ func _on_tree_exited() -> void:
97119
## Cleanup on unexpected tree exit (e.g. parent removed without calling back button).
98120
## Disconnects signals, restores previous menu if not intentional, clears JS/DOM state.
99121
## :rtype: void
100-
Globals.log_message("Gameplay Settings _on_tree_exited called.", Globals.LogLevel.DEBUG)
122+
## Cleanup on unexpected tree exit.
101123

102-
# Disconnect the global resource observer to prevent stale references
103-
# GUARD: Ensure Globals and the settings resource are still valid before disconnecting
104-
# Use a local variable to safely check and access the settings resource
105-
var settings_res := Globals.settings if is_instance_valid(Globals) else null
124+
# FIX: Guard the initial log message against a torn-down Globals singleton
125+
if is_instance_valid(Globals):
126+
Globals.log_message("Gameplay Settings _on_tree_exited called.", Globals.LogLevel.DEBUG)
106127

128+
# 1. Safe Global Resource Disconnection
129+
var settings_res := Globals.settings if is_instance_valid(Globals) else null
107130
if is_instance_valid(settings_res):
108131
if settings_res.setting_changed.is_connected(_on_external_setting_changed):
109132
settings_res.setting_changed.disconnect(_on_external_setting_changed)
110133

111-
# Disconnect Godot signals if still connected
112-
if difficulty_slider.value_changed.is_connected(_on_difficulty_value_changed):
113-
difficulty_slider.value_changed.disconnect(_on_difficulty_value_changed)
114-
if gameplay_back_button.pressed.is_connected(_on_gameplay_back_button_pressed):
115-
gameplay_back_button.pressed.disconnect(_on_gameplay_back_button_pressed)
116-
if gameplay_reset_button.pressed.is_connected(_on_gameplay_reset_button_pressed):
117-
gameplay_reset_button.pressed.disconnect(_on_gameplay_reset_button_pressed)
134+
# 2. FIX: Guarded Local Disconnections
135+
# We must check if the nodes still exist before accessing 'value_changed' or 'pressed'
136+
if is_instance_valid(difficulty_slider):
137+
if difficulty_slider.value_changed.is_connected(_on_difficulty_value_changed):
138+
difficulty_slider.value_changed.disconnect(_on_difficulty_value_changed)
118139

119-
# Clean up JS callbacks on window object
120-
_unset_gameplay_settings_window_callbacks()
140+
if is_instance_valid(gameplay_back_button):
141+
if gameplay_back_button.pressed.is_connected(_on_gameplay_back_button_pressed):
142+
gameplay_back_button.pressed.disconnect(_on_gameplay_back_button_pressed)
143+
144+
if is_instance_valid(gameplay_reset_button):
145+
if gameplay_reset_button.pressed.is_connected(_on_gameplay_reset_button_pressed):
146+
gameplay_reset_button.pressed.disconnect(_on_gameplay_reset_button_pressed)
121147

122-
# Null out stored callback references
148+
# 3. Clean up JS/Web state
149+
_unset_gameplay_settings_window_callbacks()
123150
_change_difficulty_cb = null
124151
_gameplay_back_button_pressed_cb = null
125152
_gameplay_reset_cb = null
@@ -133,7 +160,12 @@ func _on_tree_exited() -> void:
133160
document.getElementById('gameplay-reset-button').style.display = 'none';
134161
"""
135162

136-
if not _intentional_exit and not Globals.hidden_menus.is_empty():
163+
# FIX: Guard the hidden_menus array check against a torn-down Globals singleton
164+
if (
165+
not _intentional_exit
166+
and is_instance_valid(Globals)
167+
and not Globals.hidden_menus.is_empty()
168+
):
137169
# Unexpected exit → restore previous menu and options overlays
138170
var prev_menu: Node = Globals.hidden_menus.pop_back()
139171
if is_instance_valid(prev_menu):
@@ -261,75 +293,132 @@ func _on_difficulty_value_changed(value: float) -> void:
261293
## :type value: float
262294
## :rtype: void
263295
# Update the resource first (this triggers clamping in the setter)
264-
Globals.settings.difficulty = value
296+
#Globals.settings.difficulty = value
265297
# Update the UI components using the ALREADY CLAMPED value from the resource
266-
difficulty_slider.value = Globals.settings.difficulty
267-
difficulty_label.text = "{" + str(Globals.settings.difficulty) + "}"
298+
#difficulty_slider.value = Globals.settings.difficulty
299+
#difficulty_label.text = "{" + str(Globals.settings.difficulty) + "}"
300+
var settings_res := Globals.settings if is_instance_valid(Globals) else null
301+
302+
# FIX: Use the local reference exclusively
303+
if not is_instance_valid(settings_res):
304+
Globals.log_message(
305+
"Gameplay Settings: settings_res unavailable; skipping difficulty update.",
306+
Globals.LogLevel.WARNING
307+
)
308+
return
309+
310+
settings_res.difficulty = value
311+
if is_instance_valid(difficulty_slider):
312+
difficulty_slider.set_value_no_signal(settings_res.difficulty)
313+
if is_instance_valid(difficulty_label):
314+
difficulty_label.text = "{" + str(settings_res.difficulty) + "}"
268315

269316

270317
# New: JS-specific callback (exactly one Array arg, no default)
271318
func _on_change_difficulty_js(args: Array) -> void:
272319
## JS callback for changing difficulty.
273320
##
274-
## Routes to the signal handler.
321+
## Routes to the signal handler after performing strict type and
322+
## bounds validation to prevent engine crashes on malformed JS input.
275323
##
276324
## :param args: Array containing the value (from JS).
277325
## :type args: Array
278326
## :rtype: void
279-
if args.is_empty():
280-
Globals.log_message(
281-
"JS difficulty callback received empty args—skipping.", Globals.LogLevel.WARNING
282-
)
327+
328+
var potential_value: Variant = _extract_js_difficulty(args)
329+
330+
if potential_value == null:
283331
return
284332

285-
var first_arg: Variant = args[0]
333+
# GS-JS-12/15/22: Validate that the extracted value is a convertible type
286334
if (
287-
first_arg is not JavaScriptObject
288-
and typeof(first_arg) != TYPE_ARRAY
289-
and first_arg.size() == 0
290-
and first_arg.is_empty()
335+
typeof(potential_value) != TYPE_INT
336+
and typeof(potential_value) != TYPE_FLOAT
337+
and typeof(potential_value) != TYPE_STRING
291338
):
292339
Globals.log_message(
293-
(
294-
"JS difficulty callback received invalid first arg (not a non-empty array): "
295-
+ str(args)
296-
),
340+
"JS difficulty callback received non-convertible value: " + str(potential_value),
297341
Globals.LogLevel.WARNING
298342
)
299343
return
300344

301-
var potential_value: Variant = first_arg[0]
302-
if (
303-
typeof(potential_value) != TYPE_INT
304-
and typeof(potential_value) != TYPE_FLOAT
305-
and typeof(potential_value) != TYPE_STRING
306-
):
345+
# GS-JS-03: Coerce to float (e.g., "1.5" becomes 1.5)
346+
# FIX: Ensure strings are numeric before conversion to prevent 0.0/clamping reset
347+
if typeof(potential_value) == TYPE_STRING and not potential_value.is_valid_float():
307348
Globals.log_message(
308-
"JS difficulty callback received non-convertible value: " + str(args),
349+
"JS difficulty callback: Rejected non-numeric string: " + str(potential_value),
309350
Globals.LogLevel.WARNING
310351
)
311352
return
312353

313354
var value: float = float(potential_value)
355+
356+
# GS-JS-30: Guard against missing UI nodes during callback
357+
if not is_instance_valid(difficulty_slider):
358+
Globals.log_message(
359+
"JS difficulty callback: Slider node is invalid/freed.", Globals.LogLevel.WARNING
360+
)
361+
362+
# FIX: Safely check for Globals and Settings before falling back
363+
var settings_res := Globals.settings if is_instance_valid(Globals) else null
364+
if is_instance_valid(settings_res):
365+
settings_res.difficulty = value # Update resource even if UI is gone
366+
return
367+
368+
# GS-JS-04/05: Validate bounds against the UI constraints
314369
if value < difficulty_slider.min_value or value > difficulty_slider.max_value:
315370
Globals.log_message(
316-
(
317-
"JS difficulty callback received out-of-bounds value: "
318-
+ str(value)
319-
+ " (args: "
320-
+ str(args)
321-
+ ")"
322-
),
371+
"JS difficulty callback received out-of-bounds value: " + str(value),
323372
Globals.LogLevel.WARNING
324373
)
325-
return
326374

327375
Globals.log_message(
328376
"JS difficulty callback called with valid value: " + str(value), Globals.LogLevel.DEBUG
329377
)
378+
379+
# Pass the validated value to the standard handler
330380
_on_difficulty_value_changed(value)
331381

332382

383+
## GS-JS: Helper to extract a potential value from diverse JS bridge payloads.
384+
## Isolates branching logic for standard Arrays, JavaScriptObjects, and scalars.
385+
func _extract_js_difficulty(args: Array) -> Variant:
386+
# GS-JS-10: Guard against entirely empty arguments from the bridge
387+
if args.is_empty():
388+
Globals.log_message(
389+
"JS difficulty callback received empty args—skipping.", Globals.LogLevel.WARNING
390+
)
391+
return null
392+
393+
var first_arg: Variant = args[0]
394+
395+
# GS-JS-20/21: Branch logic to handle TYPE_ARRAY and JavaScriptObject separately
396+
if typeof(first_arg) == TYPE_ARRAY:
397+
# Safe to use .size() and indexing on standard GDScript Arrays
398+
if first_arg.size() > 0:
399+
return first_arg[0]
400+
401+
Globals.log_message("JS callback: Array is empty.", Globals.LogLevel.WARNING)
402+
return null
403+
404+
if first_arg is JavaScriptObject:
405+
# BUG RISK FIX: Validate the 'length' property exists and is numeric
406+
# before treating the object as an array.
407+
# Note: Must use dot notation, as .get() attempts to call a JS method.
408+
var js_length: Variant = first_arg.length
409+
410+
if js_length != null and typeof(js_length) in [TYPE_INT, TYPE_FLOAT] and js_length > 0:
411+
# JS-FIX: If we receive a JS Object (like from Playwright),
412+
# we must index it to get the raw value before the type check.
413+
return first_arg[0]
414+
415+
# It is a generic JS object or a non-array; treat as a scalar reference
416+
return first_arg
417+
418+
# Handle scalar values (e.g., [1.5]) directly
419+
return first_arg
420+
421+
333422
## Grabs initial focus on the difficulty slider using the global helper.
334423
## Ensures the slider is focused when the menu opens.
335424
## Falls back to other controls if needed.

scripts/globals.gd

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,12 @@ func _ready() -> void:
5151
log_message("Log level set to: " + LogLevel.keys()[settings.current_log_level], LogLevel.DEBUG)
5252
_load_settings() # Load persisted settings first
5353

54-
# Connect to the resource signal to centralize side effects [cite: 151]
54+
# Connect to the resource signal to centralize side effects
5555
if settings:
5656
settings.setting_changed.connect(_on_setting_changed)
5757

5858

59-
## Reactive handler for the Observer Pattern [cite: 141]
59+
## Reactive handler for the Observer Pattern
6060
func _on_setting_changed(setting_name: String, new_value: Variant) -> void:
6161
# Skip persistence and logging if we are in a bulk-loading state
6262
if _is_loading_settings:
@@ -65,10 +65,10 @@ func _on_setting_changed(setting_name: String, new_value: Variant) -> void:
6565
# FIX: Ensure we are comparing String to String or using correct types
6666
var log_msg: String = "Setting '%s' updated to: %s" % [setting_name, str(new_value)]
6767

68-
# Automatically log the change [cite: 59]
68+
# Automatically log the change
6969
log_message(log_msg, LogLevel.DEBUG)
7070

71-
# Automatically persist to disk [cite: 53]
71+
# Automatically persist to disk
7272
_save_settings()
7373

7474

0 commit comments

Comments
 (0)