Guard destructive settings and radio actions
Full build / Import + headless tests (push) Successful in 36s
Full build / Full desktop build + optional release (push) Skipped

- Route reset campaign and tutorial replay through one reusable confirmation dialog; repeated presses cannot stack or emit twice.
- Keep emergency hold-to-send single-shot and cover the post-submit release.
- Add destructive-action regression checks, strings and documentation.
This commit is contained in:
Gig Line Dev committed 2026-11-02 16:29:26 +01:00
1 parent f6aa26e6ca
commit 7315166361
10 files changed
+121 -14

No files matched your search

+37 -5
View File
@@ -32,6 +32,7 @@ var _reduce_motion: CheckButton = null
var _captions: CheckButton = null
var _bind_buttons := {}
var _listening_action := ""
var _destructive_dialog: ConfirmationDialog = null
var settings: Dictionary = {}
func _ready() -> void:
@@ -136,7 +137,7 @@ func _build() -> void:
var replay := Button.new()
replay.name = "ReplayTutorialButton"
replay.text = Strings.lookup("ui.settings_replay")
replay.pressed.connect(func(): replay_tutorial_requested.emit())
replay.pressed.connect(_confirm_replay_tutorial)
UiStyles.apply_button(replay, 16)
replay_row.add_child(replay)
var reset_campaign := Button.new()
@@ -284,15 +285,46 @@ func _on_difficulty_selected(index: int) -> void:
settings["ai_difficulty"] = ["easy", "normal", "hard"][clampi(index, 0, 2)]
func _confirm_reset_campaign() -> void:
_open_destructive_dialog(Strings.lookup("ui.settings_reset_title"),
Strings.lookup("ui.settings_reset_confirm"),
func(): reset_campaign_requested.emit())
func _confirm_replay_tutorial() -> void:
_open_destructive_dialog(Strings.lookup("ui.settings_replay_title"),
Strings.lookup("ui.settings_replay_confirm"),
func(): replay_tutorial_requested.emit())
## One modal at a time; a second press while it is open focuses the existing
## dialog instead of creating another one (and possibly emitting twice).
func _open_destructive_dialog(title: String, body: String, callback: Callable) -> void:
if _destructive_dialog != null and is_instance_valid(_destructive_dialog):
# Reuse the open confirmation instead of stacking a second one. The
# dialog is dismissed on confirm/cancel, so an existing instance is
# always the one waiting for the player's decision.
if _destructive_dialog.is_inside_tree():
_destructive_dialog.grab_focus()
return
var dialog := ConfirmationDialog.new()
dialog.title = "Reset campaign"
dialog.dialog_text = Strings.lookup("ui.settings_reset_confirm")
dialog.confirmed.connect(func(): reset_campaign_requested.emit())
_destructive_dialog = dialog
dialog.title = title
dialog.dialog_text = body
dialog.confirmed.connect(func():
if _destructive_dialog != dialog:
return
_dismiss_destructive_dialog(dialog)
callback.call())
dialog.canceled.connect(func(): _dismiss_destructive_dialog(dialog))
add_child(dialog)
UiStyles.apply_button(dialog.get_ok_button(), 16)
UiStyles.apply_button(dialog.get_cancel_button(), 16)
dialog.popup_centered()
dialog.confirmed.connect(dialog.queue_free)
func _dismiss_destructive_dialog(dialog: ConfirmationDialog) -> void:
if _destructive_dialog == dialog:
_destructive_dialog = null
if is_instance_valid(dialog):
dialog.hide()
dialog.queue_free()
func _refresh_bind_buttons() -> void:
var remaps: Dictionary = settings.get("input_bindings", {})
+4 -1
View File
@@ -35,5 +35,8 @@ func test_emergency_requires_hold() -> bool:
panel.action_button.button_down.emit()
panel._process(RadioPanel.HOLD_NEEDED + 0.1)
var c := TestUtils.check_eq(int(calls["count"]), 1, "full hold sends exactly once")
# Releasing after the hold has already submitted must not send again.
panel.action_button.button_up.emit()
var d := TestUtils.check_eq(int(calls["count"]), 1, "release after a completed hold does not double-send")
panel.free()
return a and b and c
return a and b and c and d
+43
View File
@@ -96,6 +96,49 @@ func test_settings_panel_cancel_discards() -> bool:
panel.free()
return a and b
func test_destructive_settings_require_confirmation() -> bool:
var panel: Node = SettingsPanel.new()
var tree := Engine.get_main_loop() as SceneTree
if tree:
tree.root.add_child(panel)
panel._build()
var resets := {"count": 0}
var replays := {"count": 0}
panel.reset_campaign_requested.connect(func(): resets["count"] += 1)
panel.replay_tutorial_requested.connect(func(): replays["count"] += 1)
var reset_button: Button = panel.find_child("ResetCampaignButton", true, false) as Button
if reset_button:
reset_button.pressed.emit()
var first: ConfirmationDialog = panel._destructive_dialog
panel._confirm_reset_campaign()
var a := TestUtils.check(first != null and panel._destructive_dialog == first,
"a second reset press reuses the open confirmation")
if first:
first.confirmed.emit()
var b := TestUtils.check_eq(resets["count"], 1, "reset emits once after confirmation")
var c := TestUtils.check(panel._destructive_dialog == null,
"the reset confirmation is dismissed after use")
var replay_button: Button = panel.find_child("ReplayTutorialButton", true, false) as Button
if replay_button:
replay_button.pressed.emit()
var replay_dialog: ConfirmationDialog = panel._destructive_dialog
var d := TestUtils.check_eq(replays["count"], 0,
"tutorial replay waits for confirmation")
if replay_dialog:
replay_dialog.confirmed.emit()
var e := TestUtils.check_eq(replays["count"], 1,
"tutorial replay emits once from its confirmation")
panel._confirm_replay_tutorial()
var cancel_dialog: ConfirmationDialog = panel._destructive_dialog
if cancel_dialog:
cancel_dialog.canceled.emit()
var f := TestUtils.check_eq(replays["count"], 1,
"canceling tutorial replay does not emit")
if tree:
tree.root.remove_child(panel)
panel.free()
return a and b and c and d and e and f
func test_routine_traffic_is_ephemeral() -> bool:
var avatar: Node = NpcAvatar.new()
var a := TestUtils.check(avatar.ephemeral, "avatars default to ephemeral")