Guard destructive settings and radio actions
- 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:
1 parent
f6aa26e6ca
commit
7315166361
10 files changed
+121
-14
No files matched your search
@@ -20,6 +20,9 @@ Semantic Versioning.
|
||||
- The HUD target chip grows and caps with the selected font scale, top labels
|
||||
clip safely, and long action-button labels ellipsize instead of pushing panels
|
||||
wider than the screen.
|
||||
- Campaign reset and tutorial replay now share a guarded confirmation dialog;
|
||||
repeated presses cannot stack dialogs or fire the action twice. Emergency
|
||||
hold-to-send still submits once and ignores the release after submission.
|
||||
- NPC reason answers now use role-specific dialogue contexts, so staff, guests,
|
||||
delivery workers, performers and other archetypes answer instead of silently
|
||||
deflecting the Reason verb.
|
||||
|
||||
@@ -61,6 +61,11 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
||||
gallery is 13 good / 0 bad, the screen-design judge is 13/13 and a clean
|
||||
portrait measured a 0.732 vision gap against the photoreal concept. No
|
||||
release build, version bump or tag was created at this gate.
|
||||
- **Destructive actions (2026-11-02):** `SettingsPanel._open_destructive_dialog()`
|
||||
owns one confirmation instance for reset/replay; repeated presses focus the
|
||||
existing dialog, OK emits once, Cancel is inert. Emergency send remains the
|
||||
RadioPanel hold path; `_holding` clears before submit so button-up cannot
|
||||
double-send. Tests cover both.
|
||||
- **Font/layout limits (2026-11-02):** HUD scale is clamped to 0.75–1.75 and only
|
||||
applies to HUD labels. `Hud.set_target_label()` measures the scaled font, widens
|
||||
the chip to 180–420 px and re-measures after a scale change; top labels clip.
|
||||
@@ -723,7 +728,7 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
||||
- Venue `door_staff_lines` are logged through `GameManager` (inspect / wand / refuse). They are not a separate dialogue UI.
|
||||
- Access loadout is implemented; do not re-fix `override_access` as if it were still unread.
|
||||
- `Save.SETTINGS_FILE` is declared and unused; settings persistence goes through `Save.load_settings()` / `save_settings()`.
|
||||
- Headless suite is **260/260**; the windowed smokes, screenshot compare and judge pass. Remaining visual follow-ups are in `TODO.md`.
|
||||
- Headless suite is **261/261**; the windowed smokes, screenshot compare and judge pass. Remaining visual follow-ups are in `TODO.md`.
|
||||
- Typed spawn signal extra args: handlers must accept the 4th `event: Dictionary` or Godot will not call them (`test_shift_coordinator_runs_a_shift` failed until `_on_spawn` took four args).
|
||||
- GDScript inference trap that broke the main scene: a `var` typed as a base class
|
||||
(`radial: CanvasLayer`) exposes script members as `Variant`, so `var x := radial.is_open` is a
|
||||
|
||||
@@ -7,7 +7,7 @@ see `AGENTS.md` for the workflow and `MEMORY.md` for the decisions behind these
|
||||
|
||||
Baseline: **v0.9.2** (2026-11-02). Three playable venues, tutorial/campaign/checkpoint resume,
|
||||
difficulty/help, audio/accessibility settings, a 24-NPC budget baseline, a complete
|
||||
escalation/access/radio loop, shift review, the Blender asset library, 260 headless + 97 Python
|
||||
escalation/access/radio loop, shift review, the Blender asset library, 261 headless + 97 Python
|
||||
tests, a clean screen-design judge and an optional Forward+ Desktop HQ build. This is a strong
|
||||
release-candidate foundation, not yet a 0.10.0 release candidate.
|
||||
|
||||
@@ -94,8 +94,10 @@ screen-design judge before any model/texture changes. No gameplay rules change.
|
||||
strings; fix clipping, overflow and overlapping layouts. (Target chip now
|
||||
measures/caps scaled text, HUD top labels clip, and action buttons ellipsize;
|
||||
regression checks are in `test_accessibility.gd` and `test_ui_buttons.gd`.)
|
||||
- [ ] Test destructive/irreversible actions (campaign reset, tutorial replay,
|
||||
- [x] Test destructive/irreversible actions (campaign reset, tutorial replay,
|
||||
emergency send) require the intended confirmation/hold and cannot double-fire.
|
||||
(Reset/replay share one guarded confirmation dialog; emergency hold submits
|
||||
once and ignores the post-submit release. Covered by session/radio tests.)
|
||||
- [ ] Verify radio normal-send vs emergency hold, inspection/wand allow/deny,
|
||||
review continue/retry/menu and Help search/filter behavior end to end.
|
||||
|
||||
|
||||
@@ -121,7 +121,10 @@
|
||||
"ui.settings_reset_campaign": "Reset campaign",
|
||||
"ui.settings_controls_help": "Controls (click a binding, then press a key or pad button)",
|
||||
"ui.settings_cancel": "Cancel",
|
||||
"ui.settings_reset_title": "Reset campaign",
|
||||
"ui.settings_reset_confirm": "Clear campaign progress and restart from the Hollywood Boulevard shift?",
|
||||
"ui.settings_replay_title": "Replay tutorial",
|
||||
"ui.settings_replay_confirm": "Replay the tutorial from the Hollywood Boulevard shift? The current shift will end.",
|
||||
"ui.settings_press": "Press…",
|
||||
"ui.pause_title": "Paused",
|
||||
"ui.pause_resume": "Resume",
|
||||
|
||||
@@ -5,7 +5,7 @@ Implemented so far:
|
||||
- Godot 4.7.2 project scaffold, input map, procedural world builder (CI/local/release unified)
|
||||
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
|
||||
- Three authored scenarios (El Capitan Entertainment Centre / Hollywood Boulevard, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
|
||||
- Headless unit tests: **260 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, input remaps, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material, player movement, NPC character layers/LOD/queue, input bindings)
|
||||
- Headless unit tests: **261 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, input remaps, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material, player movement, NPC character layers/LOD/queue, input bindings)
|
||||
- Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event
|
||||
- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
|
||||
- Control pass: focus cone on NPC heads with a billboarded blue circle in front of the face and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`)
|
||||
@@ -51,6 +51,7 @@ Implemented so far:
|
||||
- Joke Shop / Sunset Strip map (map optimization): the second venue is now a stylized reconstruction of The Comedy Store block at 8433 Sunset Blvd, with a 27 m black hall, signature-covered sloping canopy, circular **THE JOKE SHOP** blade sign, red velvet curtain bays, a west-end entrance, cream and white low-rise neighbours, a dry hillside with stucco houses and palms behind the roofline, and a low retail/billboard frontage across Sunset. The same geometry serves day and late lighting through the phase system. Real-site references and limits are in [`joke-shop-map.md`](joke-shop-map.md); the map measures **22,680 triangles across 454 meshes** in the budget test.
|
||||
- Venue geometry (v0.7.0, Joke Shop frontage superseded by the map-optimization pass): the TV Studio builds a stone facade, bulb canopy, glossy black car and barrier line. `WorldBuilder.VENUE_SPECS` drives facade/street/nav dimensions, and `GameManager` rebuilds only the world container when the venue changes.
|
||||
- Gear fit (post-v0.6.0): Blender gear is measured against the avatar head/skirt silhouette and the checks run in both the Python tooling and the headless suite. Sunglasses, the shoulder bag and the firefighter helmet were fitted to the new faces; the Influencer portrait and gallery were recaptured with a before/after report.
|
||||
- Destructive-action pass (0.9.3 work): reset campaign and tutorial replay share a guarded confirmation dialog (one open instance, one emission, Cancel is inert); emergency send remains hold-to-confirm with no post-submit double-fire. Covered by session/radio tests and recorded in `docs/ui-copy.md`.
|
||||
- Font/layout pass (0.9.3 work): HUD font scale was exercised at 0.75× and 1.75×; the target chip measures and caps scaled names, top labels clip, and shared action buttons ellipsize long 1.5–2× labels instead of overflowing. Covered by accessibility/button tests and recorded in `docs/ui-copy.md`.
|
||||
- Modal-close pass (0.9.3 work): one top-modal close path handles Esc/right-click for every overlay, Settings opened from the menu restores the menu, Help no longer opens Pause behind it, and pointer/guard freeze state is recaptured after each close. The session smoke covers these paths.
|
||||
- Keyboard/gamepad focus pass (0.9.3 work): `UiFocus` rings wrap through visible/enabled controls, hidden Continue and disabled timeline actions are skipped, each modal has a default focus, and A/B are bound to `ui_accept`/`ui_cancel` for pad-only navigation. Recorded in `docs/ui-copy.md`.
|
||||
|
||||
@@ -46,3 +46,4 @@
|
||||
44. The Joke Shop opens on the Sunset Strip club: the black hall, signature canopy, circular **THE JOKE SHOP** sign, red curtains, west entrance, palms, dry hillside with houses, neighbouring frontage and the opposite side of Sunset all read from the door camera. Day lighting and late lighting both look correct; the guard can walk the sidewalk without leaving the nav bounds.
|
||||
45. Esc and right-click close only the top panel (Help, Settings, Pause, Review, Radio, timeline, inspection/wand, action wheel); Settings opened from the venue menu returns to the menu, Help no longer opens Pause behind it, and closing a modal recaptures the mouse without leaving the guard frozen.
|
||||
46. At 0.75× and 1.75× HUD font scale, long target names stay inside the target chip, the chip grows/caps correctly, and long button labels ellipsize instead of pushing panels off-screen.
|
||||
47. Reset campaign and Replay tutorial both ask for confirmation; a second click reuses the open dialog, Cancel does nothing, and OK fires once. Emergency send requires the full hold, sends exactly once, and does not send again on release.
|
||||
+18
-4
@@ -1,6 +1,6 @@
|
||||
# UI copy and button inventory
|
||||
|
||||
Status: dialogue, button-state, focus, modal-close and font/layout passes complete; destructive-action and end-to-end audit in progress for 0.9.3.
|
||||
Status: dialogue, button-state, focus, modal-close, font/layout and destructive-action passes complete; end-to-end audit in progress for 0.9.3.
|
||||
|
||||
## Screen inventory
|
||||
|
||||
@@ -14,7 +14,7 @@ Status: dialogue, button-state, focus, modal-close and font/layout passes comple
|
||||
| Wand screening | `hud.gd` | A = admit, D = refuse | Shows alert and carried items. |
|
||||
| Radio / emergency | `radio_panel.gd` | Send request / hold Send emergency, Cancel | Normal click vs emergency hold. |
|
||||
| Pause | `pause_overlay.gd` | Resume, restart, Settings, quit to menu | Esc resumes. |
|
||||
| Settings | `settings_panel.gd` | Apply, Cancel, Replay tutorial, Reset campaign, rebind controls | Destructive reset asks for confirmation. |
|
||||
| Settings | `settings_panel.gd` | Apply, Cancel, Replay tutorial, Reset campaign, rebind controls | Both reset and tutorial replay ask for confirmation. |
|
||||
| Help | `help_overlay.gd` | Filter controls, Close | Policy card and current bindings. |
|
||||
| Shift review | `shift_review.gd` | Continue campaign, timeline, return to menu, quit | Campaign next step is shown here. |
|
||||
| Timeline | `event_timeline.gd` | Previous, Next, Close | Review evidence stepper. |
|
||||
@@ -54,8 +54,8 @@ Status: dialogue, button-state, focus, modal-close and font/layout passes comple
|
||||
| Pause | Quit to menu | Return to venue menu | Standard button |
|
||||
| Settings | Apply settings | Persist settings | Standard button |
|
||||
| Settings | Cancel | Discard edits | Standard button |
|
||||
| Settings | Replay tutorial | Clear tutorial flags and restart Hollywood | Standard button |
|
||||
| Settings | Reset campaign | Confirmed campaign reset | Confirmation dialog |
|
||||
| Settings | Replay tutorial | Confirm, then clear tutorial flags and restart Hollywood | Confirmation dialog |
|
||||
| Settings | Reset campaign | Confirm, then reset campaign progress | Confirmation dialog |
|
||||
| Help | Close | Close overlay | Standard button |
|
||||
| Review | Continue campaign | Start next venue | Hidden when campaign complete |
|
||||
| Review | Body camera timeline | Open event timeline | Standard button |
|
||||
@@ -152,6 +152,20 @@ resumes; `_show_start_menu()` keeps the pointer visible at the menu.
|
||||
The session smoke now covers menu → Settings → Cancel → menu, Help closing on
|
||||
right-click, and Settings cancel from pause leaving the guard unfrozen.
|
||||
|
||||
## Destructive actions
|
||||
|
||||
Campaign reset and tutorial replay both open a `ConfirmationDialog`; pressing
|
||||
the button again while it is open focuses the existing dialog instead of
|
||||
stacking a second one. The confirmation is dismissed and the signal is emitted
|
||||
at most once on OK; Cancel closes without emitting. The emergency radio remains
|
||||
a press-and-hold action: a normal click does nothing for an emergency, a short
|
||||
hold cancels, a full 1.6 s hold submits once, and the release after a completed
|
||||
hold cannot submit again.
|
||||
|
||||
Regression coverage:
|
||||
- `test_session_shell.gd` checks reset reuse/one-shot and replay confirmation/cancel.
|
||||
- `test_radio_panel.gd` checks normal click, short hold, full hold and post-submit release.
|
||||
|
||||
## Font-scale and long-string layout
|
||||
|
||||
The HUD scale is tested at its **0.75×** and **1.75×** bounds. HUD top/time labels
|
||||
|
||||
@@ -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", {})
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user