From cdc5396c2150c7b844479789e4ac957b1a06991d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 21 Jul 2026 18:56:08 +0200 Subject: [PATCH 1/4] =?UTF-8?q?chore(meta):=20activate=20T-1146=20?= =?UTF-8?q?=E2=80=94=20implant-open=20input=20leak=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .pql/changelog/ticket_history/2026-07.sql | 1 + .pql/changelog/tickets/2026-07.sql | 1 + 2 files changed, 2 insertions(+) diff --git a/.pql/changelog/ticket_history/2026-07.sql b/.pql/changelog/ticket_history/2026-07.sql index 6682c795f..7b82eb4c8 100644 --- a/.pql/changelog/ticket_history/2026-07.sql +++ b/.pql/changelog/ticket_history/2026-07.sql @@ -1598,3 +1598,4 @@ Scope expansion (Jeroen round-2, 2026-07-21): beyond window-local contrast, eval INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FRA2DAZ2PDWE2YH76YBSQ2DW', 'status', 'backlog', 'in_progress', NULL, '2026-07-21 14:17:33', '2026-07-21 14:17:33.958', '2026-07-21 14:17:33.958', NULL, '2e1259f990abb2360e258955d1a451ea', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FRA2DAZ2PDWE2YH76YBSQ2DW', 'status', 'in_progress', 'review', NULL, '2026-07-21 15:03:29', '2026-07-21 15:03:29.644', '2026-07-21 15:03:29.644', NULL, '190dbcb439bb02c1758b4c55f52c04c6', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FRA2DAZ2PDWE2YH76YBSQ2DW', 'status', 'review', 'done', NULL, '2026-07-21 15:08:57', '2026-07-21 15:08:57.829', '2026-07-21 15:08:57.829', NULL, '9ed4da25de534fabf827b4f6989a10a9', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FRA9Z84VKCZ3ECQDEGDW4MSW', 'status', 'backlog', 'in_progress', NULL, '2026-07-21 16:55:45', '2026-07-21 16:55:45.245', '2026-07-21 16:55:45.245', NULL, 'bc53fb15ce6f74470d7da22c01f52cb3', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-07.sql b/.pql/changelog/tickets/2026-07.sql index fbf2c3a69..70b053c78 100644 --- a/.pql/changelog/tickets/2026-07.sql +++ b/.pql/changelog/tickets/2026-07.sql @@ -2362,3 +2362,4 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FRACDBETDV1MGJGPPC4V55ZM', 'task', '06FBPPMZNNEV052DBYYY3A897C', 'Flaky gate: fog-shader perf test asserts a margin-less 0.5ms wall-clock ceiling', 'test_fog_shader.gd test_visibility_texture_update_performance asserts elapsed < 0.5ms with zero headroom — failed the pre-push gate at 0.501ms (2026-07-21, atlas-regional-polish push; branch touches only atlas window files, unrelated). Wall-clock perf assertions in the gate suite are load-sensitive (concurrent builds/drives on the same box make 0.2 percent overruns routine). Fix options: (a) median-of-N runs against the same ceiling, (b) headroom to a documented budget multiple (e.g. 2x the target with the true target logged), (c) move hard perf ceilings out of the blocking gate tier into a perf-report tier. Audit the suite for siblings (test_full_fog_update_under_1ms passed at 4ms wall on the same run — check what it actually measures). Do not silently raise thresholds without recording the true budget intent.', 'backlog', 'medium', NULL, 'client', NULL, '2026-07-21 15:00:52.726', '2026-07-21 15:00:52.726', NULL, 'c4596a7e6479d8c4b540c66881a44ad9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FRA2DAZ2PDWE2YH76YBSQ2DW', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Regional window round-2 friction — cover-fit, WASD plus edge-scroll pan, smoothed composite', 'Jeroen''s second hands-on descent (2026-07-21, KALLAST window): three items. (1) COVER-FIT: fit_window_view contains (min viewport dim) leaving large side margins in a wide viewport — change to cover (max viewport dim; the composite fills the screen edge to edge, data extends past the shorter axis into pan-space; the existing pan-edge refetch handles the view crossing window edges — verify no refetch churn at rest, i.e. the at-rest view inside one window must not trigger refetch when the window edge merely touches the viewport edge). (2) INPUT MODEL RULING: remove LMB-drag panning from the regional window entirely — clicks are reserved for map objects (settlements etc. will land in the window later). Pan = WASD + arrow keys (continuous, frame-rate independent, speed in screen px/s scaled by zoom) + EDGE SCROLLING (cursor within ~24px of a viewport edge pans toward it; disabled while the cursor is over UI). Wheel zoom unchanged. WASD/edge/zoom all set _user_adjusted. Esc still exits (keep the existing key path; W/A/S/D must not conflict with any current implant binding — check B/M etc. registry). Pole wall + wrap semantics unchanged and must keep working under the new pan inputs (extend the existing tests to drive WASD instead of drag; drag tests are REPLACED, not kept). (3) SMOOTHED COMPOSITE (interim presentation, pending the T-1143 design): render the window as an n x n Image/ImageTexture (one pixel per district, colors from the existing per-cell pipeline incl. overlays and ice tint) drawn scaled with LINEAR filtering instead of per-cell flat rects — GPU bilinear reads as terrain, not blocks; same treatment the planetary heightmap gets. Keep the per-cell path available if the overlay needs crisp mode later (a const toggle is fine). Record all three as one-line D-226 T-1124 SS5 amendment notes in the same PR (cover-fit supersedes the contain wording; input model supersedes drag-pan; smoothing noted as interim presentation pending T-1143). Live full-chain re-drive after (lead): WASD pan across a window edge, edge-scroll, cover assertion (no side margins at 16:9).', 'review', 'high', NULL, 'client', NULL, '2026-07-21 14:17:11.161', '2026-07-21 15:03:29.644', NULL, '2978c9f04b4e03ed4063c9bce640dcff', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FRA2DAZ2PDWE2YH76YBSQ2DW', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Regional window round-2 friction — cover-fit, WASD plus edge-scroll pan, smoothed composite', 'Jeroen''s second hands-on descent (2026-07-21, KALLAST window): three items. (1) COVER-FIT: fit_window_view contains (min viewport dim) leaving large side margins in a wide viewport — change to cover (max viewport dim; the composite fills the screen edge to edge, data extends past the shorter axis into pan-space; the existing pan-edge refetch handles the view crossing window edges — verify no refetch churn at rest, i.e. the at-rest view inside one window must not trigger refetch when the window edge merely touches the viewport edge). (2) INPUT MODEL RULING: remove LMB-drag panning from the regional window entirely — clicks are reserved for map objects (settlements etc. will land in the window later). Pan = WASD + arrow keys (continuous, frame-rate independent, speed in screen px/s scaled by zoom) + EDGE SCROLLING (cursor within ~24px of a viewport edge pans toward it; disabled while the cursor is over UI). Wheel zoom unchanged. WASD/edge/zoom all set _user_adjusted. Esc still exits (keep the existing key path; W/A/S/D must not conflict with any current implant binding — check B/M etc. registry). Pole wall + wrap semantics unchanged and must keep working under the new pan inputs (extend the existing tests to drive WASD instead of drag; drag tests are REPLACED, not kept). (3) SMOOTHED COMPOSITE (interim presentation, pending the T-1143 design): render the window as an n x n Image/ImageTexture (one pixel per district, colors from the existing per-cell pipeline incl. overlays and ice tint) drawn scaled with LINEAR filtering instead of per-cell flat rects — GPU bilinear reads as terrain, not blocks; same treatment the planetary heightmap gets. Keep the per-cell path available if the overlay needs crisp mode later (a const toggle is fine). Record all three as one-line D-226 T-1124 SS5 amendment notes in the same PR (cover-fit supersedes the contain wording; input model supersedes drag-pan; smoothing noted as interim presentation pending T-1143). Live full-chain re-drive after (lead): WASD pan across a window edge, edge-scroll, cover assertion (no side margins at 16:9).', 'done', 'high', NULL, 'client', NULL, '2026-07-21 14:17:11.161', '2026-07-21 15:08:57.829', NULL, 'f73aaf4a99a9b689490b82604723de89', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FRA9Z84VKCZ3ECQDEGDW4MSW', 'bug', '06FBPPMZNNEV052DBYYY3A897C', 'Gameplay movement input leaks to the server while an implant screen is open', 'Found by Stig during T-1145 (2026-07-21), pre-existing: InputMapper._process() polls the D-054 move_north/west/south/east actions unconditionally and the queue flush path (input_mapper.gd -> main.gd flush_queue -> sim_bridge.send_input) never checks HudGroups.gameplay_occluded / is_app_active — so pressing WASD while ANY implant screen is open (Atlas, browser, wiki...) queues real movement commands to the server and walks the character blind behind the fullscreen overlay. Affects every implant screen equally; the companion is immune only because readers cannot send inputs (T-1130 matrix). Fix shape: gate the InputMapper poll (or the queue flush) on not HudGroups.gameplay_occluded — decide whether stance/other gameplay actions need the same gate while there (they do — audit the full polled action set). Regression test: occluded -> WASD poll queues nothing; unoccluded -> queues normally. Note: T-1145''s regional-window pan deliberately reads raw physical keycodes, architecturally independent of this fix.', 'in_progress', 'high', NULL, 'client', NULL, '2026-07-21 14:50:12.902', '2026-07-21 16:55:45.245', NULL, '446193b1e6cabf995a806614fbc77144', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); -- 2.54.0 From cd52fe884323004afcf3e917d042f0fb223a9eac Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 21 Jul 2026 19:16:12 +0200 Subject: [PATCH 2/4] fix(client): T-1146 gameplay input gated while an implant screen occludes gameplay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InputMapper polled the D-054 move_* actions (and queued discrete gameplay actions) unconditionally — WASD with a fullscreen implant open walked the character blind. Poll-site gate (both _process and _unhandled_input, the file's existing dialogue/free-camera early-return idiom): nothing is enqueued while occluded, so no backlog can flush on close. Gate signal is HudGroups.gameplay_occluded, deliberately NOT is_implant_active(): that flag is also true for INSERT mode (economics monitor panel), where gameplay stays visible and playable by design — the naive gate would have broken WASD there. Local _gameplay_occluded mirror via the existing signal; regression tests pin the INSERT distinction. Held-state semantics: open-while-held stops on the next poll; close-while-held requires release-then-repress (a _suppress_move_until_release latch armed on the close transition, cleared only when EVERY movement key is released — one-of-two released does not clear, tested). Facing exempt from the latch (re-sync, not lurch). Audited action set: movement/facing-send/INTERACT/perception/pause/ stance/teleport/quicksave-load gated; OPEN_MENU exempt (Esc must close the implant), BUG_REPORT/OPEN_JOURNAL exempt (client-only, never reach send_input); dialog-driven direct SimBridge sends (settings, dialogue pause, quit-to-menu save) out of scope by design. The pre-existing server-side AutoPause defense observed firing correctly alongside. Drive-by: pre-existing gdlint class-definitions-order violation in input_mapper.gd fixed (public/private var ordering) — file lint-clean for the first time. Tests: 16 unit/state (real autoloads, real open_app/close_app transitions, Input.action_press engine state) + 2 LIVE against a real spawned server through the REAL client pipeline (position frozen for 20 held-W ticks while occluded; still frozen 10 ticks after close without re-press; resumes on re-press) — port-retry/wall-clock/ COOLDOWN_TICKS conventions reused from test_input_roundtrip. 12-suite regression sweep of every InputMapper consumer green. Co-Authored-By: Claude Fable 5 --- client/scripts/autoloads/input_mapper.gd | 149 ++++++-- client/tests/test_input_gate_live.gd | 317 ++++++++++++++++ .../tests/test_input_mapper_occlusion_gate.gd | 344 ++++++++++++++++++ 3 files changed, 788 insertions(+), 22 deletions(-) create mode 100644 client/tests/test_input_gate_live.gd create mode 100644 client/tests/test_input_mapper_occlusion_gate.gd diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index 47d92bc86..ad2d57a12 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -13,6 +13,27 @@ extends Node # Sprint=5/s, Walk=2.5/s, Careful=1.7/s, Crouch=1.25/s. # Server cooldown is authoritative, but the client throttle prevents # flooding and gives correct movement feel in test mode. +# +# T-1146: implant-occlusion gate. Every gameplay-sim-bound action this file can +# produce (movement, SET_FACING, and every _unhandled_input entry EXCEPT +# OPEN_MENU/BUG_REPORT/OPEN_JOURNAL, which are client-only and never reach +# send_input — see main.gd's dispatch loop) is gated on +# HudGroups.gameplay_occluded, at the POLL SITE (here), not the flush site +# (main.gd). Poll-site was T-1146's own recommendation and is also the +# structurally cleaner one: it slots into the existing dialogue_active / +# free_camera_mode early-return this file already has, and it means nothing is +# ever enqueued while occluded — a flush-site gate would still enqueue and then +# have to discard, which is exactly the kind of latent backlog this ticket is +# about. Movement is HELD-STATE polled (Input.is_action_pressed), so an +# occlusion-begin transition must be able to interrupt a key already down +# mid-press, and an occlusion-end transition must NOT silently resume a key +# still down — see _on_gameplay_occluded and _suppress_move_until_release +# below. Discrete actions (_unhandled_input) are edge-triggered per-press +# events, not held state, so there is no backlog-on-close risk for them: a +# gated press this frame simply never queues, and get_viewport() still has +# set_input_as_handled() called so the event doesn't fall through to some +# other handler either (mirrors the existing dialogue_active early-return +# already established below). enum Action { MOVE_NORTH, MOVE_NORTHEAST, @@ -56,8 +77,6 @@ var input_queue: Array[Dictionary] = [] # Updated every frame from mouse position. EntityRenderer reads this for indicator. var facing_angle: float = -PI / 2.0 # Default: North var facing_octant: String = "North" # Derived from facing_angle -var _last_sent_octant: String = "North" # Track to avoid redundant sends -var _last_move_msec: int = 0 # T-1088 (design §9): 3D facing seam. A 3D scene installs a provider returning # sim-space radians (0=East, +PI/2=South — same convention as facing_angle), or @@ -66,11 +85,66 @@ var _last_move_msec: int = 0 # the 2D path unchanged. Untyped Callable per the autoload parse-order rule. var facing_angle_provider = Callable() +# gdlint class-definitions-order: all public vars above this line, all +# private (underscore-prefixed) vars below — reordered while touching this +# file for T-1146 (pre-existing violation on main, unrelated to this ticket: +# facing_angle_provider used to sit after _last_sent_octant/_last_move_msec). +var _last_sent_octant: String = "North" # Track to avoid redundant sends +var _last_move_msec: int = 0 + +# T-1146: local mirror of HudGroups.gameplay_occluded, updated by the signal +# handler below. Deliberately NOT HudGroups.is_implant_active() / is_app_active() +# — those are true for BOTH FULLSCREEN and INSERT mode (e.g. main.gd opens +# "implant/economics" in INSERT mode, a small overlay alongside still-visible, +# still-playable gameplay — D-181/#824), whereas gameplay_occluded (and this +# mirror) is only ever true for FULLSCREEN, which is the actual "the player is +# not looking at their character" condition this gate exists for. Read directly +# in the hot _process() poll instead of calling HudGroups.get_active_mode() +# every frame — the signal already tells us exactly when it changes. +var _gameplay_occluded: bool = false + +# T-1146: latch set the instant gameplay becomes occluded WHILE a movement key +# is already held. Cleared only once the poll observes ALL movement keys +# released (raw_dir == ZERO) at least one frame after occlusion ends — a +# player who forgot their finger on W when the map closes must re-press, not +# lurch forward on the very next frame. Facing updates are exempt (see +# _process) — turning to look is not "movement" and re-syncing facing the +# instant the implant closes is expected, not a lurch. +var _suppress_move_until_release: bool = false + + +func _ready() -> void: + # Autoload-to-autoload signal wiring is fine at _ready (both are autoloads, + # so both are already fully constructed by the time either _ready() body + # runs) — the parse-order rule that bites autoloads only applies to + # referencing a class_name TYPE, not calling a method / connecting a + # signal on another autoload singleton. + HudGroups.gameplay_occluded.connect(_on_gameplay_occluded) + + +# T-1146: fires on BOTH the open transition (occluded=true) and the close +# transition (occluded=false). Open: mirror the flag — the very next +# _process() poll already gates on _gameplay_occluded below and simply stops +# producing, so a key held at the moment of opening is dropped this frame with +# no separate latch needed. Close: mirror the flag AND arm the release-latch so +# a key still held at the moment of closing cannot resume movement without an +# intervening release — see _suppress_move_until_release's own doc. +func _on_gameplay_occluded(occluded: bool) -> void: + _gameplay_occluded = occluded + if not occluded: + _suppress_move_until_release = true + # Hold-to-move: poll held direction keys each frame, throttled by stance. # D-054: WASD is now mouse-relative. W = toward cursor, A/D = strafe. # Server-side cooldown (D-053) is authoritative; this prevents client flooding. # D-064: movement suppressed during dialogue (walk-away handled by dialogue_box). +# T-1146: SET_FACING and movement are both gated on gameplay_occluded — an +# implant screen open means the player is not looking at their character, so +# neither "I turned to face something" nor "I walked" should reach the server. +# Facing-angle tracking itself (facing_angle/facing_octant, purely client-side) +# still updates every frame regardless — only the SEND to the server is gated, +# same as the existing test_mode split in sim_bridge.gd's send_input(). func _process(_delta: float) -> void: # D-054: Update facing angle from mouse position every frame _update_facing_from_mouse() @@ -78,6 +152,9 @@ func _process(_delta: float) -> void: if GameState.dialogue_active or GameState.free_camera_mode: return + if _gameplay_occluded: + return + # D-054: Send facing octant to server when it changes (even without movement) if facing_octant != _last_sent_octant: _last_sent_octant = facing_octant @@ -103,6 +180,15 @@ func _process(_delta: float) -> void: if Input.is_action_pressed("move_west"): raw_dir.x -= 1 + # T-1146: a key still held at the exact moment the implant closed must not + # resume movement on its own — the latch (armed in _on_gameplay_occluded) + # stays up until this poll observes every movement key released at least + # once, at which point a fresh press is required to move again. + if _suppress_move_until_release: + if raw_dir == Vector2i.ZERO: + _suppress_move_until_release = false + return + if raw_dir != Vector2i.ZERO: var now := Time.get_ticks_msec() var interval: int = MOVE_INTERVAL_MS.get(GameState.player_stance, 200) @@ -123,35 +209,54 @@ func _process(_delta: float) -> void: # Discrete actions: fire once on key press (not held). +# T-1146: OPEN_MENU/BUG_REPORT/OPEN_JOURNAL are checked and queued BEFORE the +# implant-occlusion gate — they are the implant/app-level keys the ticket +# explicitly carves out. OPEN_MENU is how the implant CLOSES (Esc/#528's +# priority chain in main.gd — HudGroups.close_app() before anything else), so +# gating it on occlusion would make the implant impossible to close with Esc. +# BUG_REPORT and OPEN_JOURNAL are client-only (main.gd's dispatch loop +# `continue`s on both before ever reaching SimBridge.send_input) — neither is +# gameplay-sim-bound, so neither belongs behind a gameplay-occlusion gate. +# Every other action in this function (INTERACT, USE_PERCEPTION_MODE, +# PAUSE/UNPAUSE, TOGGLE_STANCE_*, TELEPORT_HUB, SAVE_GAME, LOAD_GAME) reaches +# send_input unconditionally once queued (sim_bridge.gd's action_enum_to_wire +# has a wire case for every one of them) and IS gated below. func _unhandled_input(event: InputEvent) -> void: if GameState.dialogue_active or GameState.free_camera_mode: return - var action: Action = -1 - if event.is_action_pressed("interact"): - action = Action.INTERACT - elif event.is_action_pressed("perception_mode"): - action = Action.USE_PERCEPTION_MODE - elif event.is_action_pressed("open_menu"): + # Implant/app-level keys — never gated on occlusion, checked first so they + # always win regardless of implant state. + var action: Action = -1 + if event.is_action_pressed("open_menu"): action = Action.OPEN_MENU - elif event.is_action_pressed("pause"): - var tick_rate = GameState.game_time.get("tick_rate", "Full") - action = Action.UNPAUSE if tick_rate == "Paused" else Action.PAUSE - elif event.is_action_pressed("stance_up"): - action = Action.TOGGLE_STANCE_UP - elif event.is_action_pressed("stance_down"): - action = Action.TOGGLE_STANCE_DOWN elif event.is_action_pressed("bug_report"): action = Action.BUG_REPORT elif event.is_action_pressed("open_journal"): action = Action.OPEN_JOURNAL - elif event.is_action_pressed("teleport_hub"): - if GameState.gauntlet_mode: - action = Action.TELEPORT_HUB - elif event.is_action_pressed("quicksave"): - action = Action.SAVE_GAME - elif event.is_action_pressed("quickload"): - action = Action.LOAD_GAME + + # Gameplay-sim-bound keys — gated: skip the whole block while an implant + # screen is occluding gameplay, so none of these can even be recognized, + # let alone queued, while the player isn't looking at their character. + if action == -1 and not _gameplay_occluded: + if event.is_action_pressed("interact"): + action = Action.INTERACT + elif event.is_action_pressed("perception_mode"): + action = Action.USE_PERCEPTION_MODE + elif event.is_action_pressed("pause"): + var tick_rate = GameState.game_time.get("tick_rate", "Full") + action = Action.UNPAUSE if tick_rate == "Paused" else Action.PAUSE + elif event.is_action_pressed("stance_up"): + action = Action.TOGGLE_STANCE_UP + elif event.is_action_pressed("stance_down"): + action = Action.TOGGLE_STANCE_DOWN + elif event.is_action_pressed("teleport_hub"): + if GameState.gauntlet_mode: + action = Action.TELEPORT_HUB + elif event.is_action_pressed("quicksave"): + action = Action.SAVE_GAME + elif event.is_action_pressed("quickload"): + action = Action.LOAD_GAME if action != -1: var entry := { diff --git a/client/tests/test_input_gate_live.gd b/client/tests/test_input_gate_live.gd new file mode 100644 index 000000000..bd8c7ce58 --- /dev/null +++ b/client/tests/test_input_gate_live.gd @@ -0,0 +1,317 @@ +## T-1146: implant-occlusion gate — LIVE tier. Connects the real SimBridge/ +## InputMapper/HudGroups autoloads (not stubs, not TestHarness) to a spawned +## server binary and proves the actual client-side pipeline end to end: WASD +## held while a FULLSCREEN implant screen is open must not move the character +## on the server, and closing the screen must require a re-press before +## movement resumes. +## +## Reuses test_input_roundtrip.gd's/test_sprint2_proof.gd's hard-won live- +## server conventions verbatim (port-collision retry, wall-clock deadlines, +## COOLDOWN_TICKS pacing, handshake dance) — see their own header comments for +## the "why" behind each one. The one structural difference from those files: +## this test drives input through the REAL client pipeline (InputMapper poll + +## SimBridge.send_input, exactly mirroring main.gd's dispatch loop) instead of +## LocalBridge.send_message() directly — the gate under test lives in that +## pipeline, so a raw-protocol test would not exercise it at all. +class_name TestInputGateLive +extends GdUnitTestSuite + +const CONNECT_TIMEOUT: float = 3.0 +const RESPONSE_TIMEOUT: float = 10.0 +# Server move cooldown: the proof-room player is Walk stance (ticks_per_move = +# 2, server stance.rs) — see test_input_roundtrip.gd's own header comment for +# the full "why" (a dropped move stays dropped no matter how long the wait). +const COOLDOWN_TICKS: int = 3 +const MAX_PORT_ATTEMPTS: int = 5 +const TEST_APP := "implant/t1146_live_gate_test" + +var _server_pid: int = -1 +var _test_port: int = 0 + +# Autoload state to restore in after_test() — SimBridge/InputMapper/HudGroups +# are persistent singletons shared with every other suite in the run. +var _saved_test_mode: bool = true +var _saved_state: int = 0 +var _saved_bridge = null +var _saved_server_path: String = "" +var _saved_server_port: int = 9876 + + +func _server_binary_path() -> String: + var project_dir := ProjectSettings.globalize_path("res://") + return project_dir.path_join("../server/target/debug/settled-reach-server") + + +static func _random_test_port() -> int: + return 49152 + (randi() % (65535 - 49152 + 1)) + + +## Spawn the server binary directly (not through SimBridge.connect_to_sim's +## own spawn path) so this test controls port retry exactly like the sibling +## live-server tests — SimBridge is then pointed at the already-bound port +## with server_path left empty so it skips its own subprocess spawn and goes +## straight to the TCP-connect retry loop in its _process(). +func _spawn_server(server_path: String) -> bool: + for _attempt in range(MAX_PORT_ATTEMPTS): + _test_port = _random_test_port() + var addr := "127.0.0.1:%d" % _test_port + _server_pid = OS.create_process(server_path, [addr]) + if _server_pid <= 0: + continue + await get_tree().create_timer(0.15).timeout + if OS.is_process_running(_server_pid): + return true + _server_pid = -1 + return false + + +func before_test() -> void: + _saved_test_mode = SimBridge.test_mode + _saved_state = SimBridge.state + _saved_bridge = SimBridge._bridge + _saved_server_path = SimBridge.server_path + _saved_server_port = SimBridge.server_port + InputMapper.input_queue.clear() + InputMapper._gameplay_occluded = false + InputMapper._suppress_move_until_release = false + _release_all_movement_keys() + + +func after_test() -> void: + if SimBridge._bridge != null and SimBridge._bridge != _saved_bridge: + SimBridge._bridge.disconnect_from_server() + if _server_pid > 0 and OS.is_process_running(_server_pid): + OS.kill(_server_pid) + _server_pid = -1 + + SimBridge.test_mode = _saved_test_mode + SimBridge.state = _saved_state + SimBridge._bridge = _saved_bridge + SimBridge.server_path = _saved_server_path + SimBridge.server_port = _saved_server_port + SimBridge.reset_test_state() + + InputMapper.input_queue.clear() + InputMapper._gameplay_occluded = false + InputMapper._suppress_move_until_release = false + _release_all_movement_keys() + + HudGroups._active_app = "" + HudGroups._active_mode = HudGroups.Mode.GAMEPLAY + HudGroups._groups.erase(TEST_APP) + + +func _release_all_movement_keys() -> void: + Input.action_release("move_north") + Input.action_release("move_south") + Input.action_release("move_east") + Input.action_release("move_west") + + +## Drives SimBridge from DISCONNECTED all the way to CONNECTED against a +## freshly spawned server, awaiting real frames so SimBridge's own _process() +## (autoload, ticks automatically) does its TCP-connect + handshake dance. +func _connect_live(server_path: String) -> bool: + var spawned := await _spawn_server(server_path) + assert_bool(spawned).override_failure_message( + "server spawn failed after %d port attempts" % MAX_PORT_ATTEMPTS + ).is_true() + if not spawned: + return false + + SimBridge.test_mode = false + SimBridge.server_path = "" # already spawned ourselves — SimBridge must not spawn again + SimBridge.server_port = _test_port + SimBridge._bridge = null + SimBridge.state = SimBridge.ConnectionState.DISCONNECTED + SimBridge.connect_to_sim() + + var deadline_ms: int = Time.get_ticks_msec() + int(CONNECT_TIMEOUT * 1000.0) * 2 + while Time.get_ticks_msec() < deadline_ms: + if SimBridge.state == SimBridge.ConnectionState.CONNECTED: + return true + if SimBridge.state == SimBridge.ConnectionState.ERROR: + break + await get_tree().process_frame + return SimBridge.state == SimBridge.ConnectionState.CONNECTED + + +## Find the player entity in a decoded snapshot Dictionary. +static func _find_player(snapshot: Dictionary) -> Dictionary: + for entity in snapshot.get("entities", []): + if entity.kind.variant == "Player": + return entity + return {} + + +## Poll SimBridge.poll_snapshot() until one arrives (or the timeout elapses). +## Returns the last-seen snapshot Dictionary, or {} on timeout. +func _await_next_snapshot(timeout: float = RESPONSE_TIMEOUT) -> Dictionary: + var deadline_ms: int = Time.get_ticks_msec() + int(timeout * 1000.0) + while Time.get_ticks_msec() < deadline_ms: + var snap: Variant = SimBridge.poll_snapshot() + if snap != null: + return snap + await get_tree().process_frame + return {} + + +## Mirrors main.gd's dispatch loop exactly for the actions this test exercises +## (movement + SET_FACING): flush InputMapper's queue and forward each entry +## to SimBridge.send_input(). main.gd's client-only continues (BUG_REPORT, +## OPEN_JOURNAL, OPEN_MENU, INTERACT's target-resolution) are irrelevant here +## since this test never presses those keys — nothing to special-case. +func _flush_input_to_server() -> void: + var inputs: Array[Dictionary] = InputMapper.flush_queue() + for input in inputs: + SimBridge.send_input(input) + + +## Drive InputMapper._process() + the flush-to-server forwarding for one +## simulated frame, then await a real engine frame so SimBridge's own +## _process() gets a chance to actually transmit the outbound buffer. +func _tick(delta: float = 0.05) -> void: + InputMapper._process(delta) + _flush_input_to_server() + await get_tree().process_frame + + +# -- Live: WASD held while a FULLSCREEN implant is open must not move the player -- + +func test_wasd_held_during_implant_occlusion_does_not_move_player( + _do_skip := not FileAccess.file_exists(_server_binary_path()), + _skip_reason := "server binary not built — run `cargo build` in server/" +) -> void: + var server_path := _server_binary_path() + var connected := await _connect_live(server_path) + assert_bool(connected).override_failure_message( + "live SimBridge connection to spawned server failed within timeout" + ).is_true() + if not connected: + return + + GameState.player_stance = "Walk" + InputMapper._last_move_msec = Time.get_ticks_msec() - 1000 + + # Baseline: proof-room player starts at (16,16) -> render coords (16.5, 16.5). + var baseline: Dictionary = await _await_next_snapshot() + var start_player := _find_player(baseline) + assert_that(start_player.size()).override_failure_message( + "must receive at least one snapshot with a Player entity before testing" + ).is_greater(0) + var start_x: float = start_player.x + var start_y: float = start_player.y + assert_float(start_x).is_equal_approx(16.5, 0.001) + assert_float(start_y).is_equal_approx(16.5, 0.001) + + # Open a FULLSCREEN implant screen — gameplay is now occluded. + HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN) + assert_bool(InputMapper._gameplay_occluded).override_failure_message( + "gate precondition: HudGroups.open_app(FULLSCREEN) must occlude gameplay" + ).is_true() + + # Hold W (move_north) and drive several ticks — comfortably past the Walk + # stance's 400ms move interval, so if the gate were absent this would have + # produced multiple MoveNorth sends by now. + Input.action_press("move_north") + for _i in range(20): + await _tick(0.05) + Input.action_release("move_north") + + # Drain whatever the server actually streamed during the hold and confirm + # the player never left the starting tile. + var settle: Dictionary = await _await_next_snapshot(2.0) + if settle.is_empty(): + settle = baseline + var after_hold := _find_player(settle) + assert_that(after_hold.size()).is_greater(0) + assert_float(after_hold.x).override_failure_message( + ( + "player must NOT move while WASD is held during implant occlusion — x drifted from %.2f to %.2f" + % [start_x, after_hold.x] + ) + ).is_equal_approx(start_x, 0.001) + assert_float(after_hold.y).override_failure_message( + ( + "player must NOT move while WASD is held during implant occlusion — y drifted from %.2f to %.2f" + % [start_y, after_hold.y] + ) + ).is_equal_approx(start_y, 0.001) + + +# -- Live: close-while-held must not resume movement without a re-press -------- + +func test_close_while_held_requires_repress_before_movement_resumes_live( + _do_skip := not FileAccess.file_exists(_server_binary_path()), + _skip_reason := "server binary not built — run `cargo build` in server/" +) -> void: + var server_path := _server_binary_path() + var connected := await _connect_live(server_path) + assert_bool(connected).override_failure_message( + "live SimBridge connection to spawned server failed within timeout" + ).is_true() + if not connected: + return + + GameState.player_stance = "Walk" + InputMapper._last_move_msec = Time.get_ticks_msec() - 1000 + + var baseline: Dictionary = await _await_next_snapshot() + var start_player := _find_player(baseline) + assert_that(start_player.size()).is_greater(0) + var start_x: float = start_player.x + var start_y: float = start_player.y + + # Open the implant, hold W THROUGH the close transition (W is still down + # when close_app() fires — this is the "forgot their finger on W" case). + HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN) + Input.action_press("move_north") + for _i in range(6): + await _tick(0.05) + + HudGroups.close_app() + assert_bool(InputMapper._suppress_move_until_release).override_failure_message( + "the release-latch must arm the instant the implant closes" + ).is_true() + + # Still held for several more ticks post-close — must NOT resume. + for _i in range(10): + await _tick(0.05) + + var settle_still_held: Dictionary = await _await_next_snapshot(2.0) + if settle_still_held.is_empty(): + settle_still_held = baseline + var still_held_player := _find_player(settle_still_held) + assert_that(still_held_player.size()).is_greater(0) + assert_float(still_held_player.x).override_failure_message( + "player must NOT resume moving while W stays held through the close transition (no re-press yet)" + ).is_equal_approx(start_x, 0.001) + assert_float(still_held_player.y).override_failure_message( + "player must NOT resume moving while W stays held through the close transition (no re-press yet)" + ).is_equal_approx(start_y, 0.001) + + # Release, then re-press — movement must resume normally now. + Input.action_release("move_north") + await _tick(0.05) # observes the release, clears the latch + assert_bool(InputMapper._suppress_move_until_release).override_failure_message( + "the latch must clear once every movement key has been observed released" + ).is_false() + + InputMapper._last_move_msec = Time.get_ticks_msec() - 1000 + Input.action_press("move_north") + var resumed := false + for _i in range(30): + await _tick(0.05) + var snap: Dictionary = await _await_next_snapshot(0.5) + if not snap.is_empty(): + var p := _find_player(snap) + if ( + p.size() > 0 + and not (is_equal_approx(p.x, start_x) and is_equal_approx(p.y, start_y)) + ): + resumed = true + break + Input.action_release("move_north") + assert_bool(resumed).override_failure_message( + "movement must resume after a fresh re-press following the close-while-held transition" + ).is_true() diff --git a/client/tests/test_input_mapper_occlusion_gate.gd b/client/tests/test_input_mapper_occlusion_gate.gd new file mode 100644 index 000000000..aa51e29e0 --- /dev/null +++ b/client/tests/test_input_mapper_occlusion_gate.gd @@ -0,0 +1,344 @@ +## T-1146: implant-occlusion gate — InputMapper must not queue gameplay-sim-bound +## actions (movement, SET_FACING, and every gated _unhandled_input entry) while +## HudGroups.gameplay_occluded is true (a FULLSCREEN implant screen is open). +## Implant/app-level keys (OPEN_MENU/BUG_REPORT/OPEN_JOURNAL) are the explicit +## exception — they must keep working regardless of occlusion, since OPEN_MENU +## is how the implant closes. +## +## Unit/state tier (this file): drives InputMapper._process()/_unhandled_input() +## directly with real HudGroups.open_app()/close_app() transitions and real +## Input.action_press()/action_release() engine-global state — no live server, +## no scene tree beyond the autoloads. The LIVE tier (test_input_gate_live.gd, +## sibling file) proves the same behavior end-to-end against a spawned server. +class_name TestInputMapperOcclusionGate +extends GdUnitTestSuite + +const TEST_APP := "implant/t1146_occlusion_test" + + +func before_test() -> void: + GameState.player_stance = "Walk" + GameState.dialogue_active = false + GameState.free_camera_mode = false + GameState.gauntlet_mode = false + InputMapper.input_queue.clear() + InputMapper._gameplay_occluded = false + InputMapper._suppress_move_until_release = false + InputMapper._last_move_msec = 0 + InputMapper._last_sent_octant = InputMapper.facing_octant + _release_all_movement_keys() + + +func after_test() -> void: + InputMapper.input_queue.clear() + InputMapper._gameplay_occluded = false + InputMapper._suppress_move_until_release = false + _release_all_movement_keys() + # Restore HudGroups to a clean gameplay state regardless of what a test left + # behind — mirrors test_implant_app_lifecycle.gd's after_test convention. + HudGroups._active_app = "" + HudGroups._active_mode = HudGroups.Mode.GAMEPLAY + HudGroups._groups.erase(TEST_APP) + + +func _release_all_movement_keys() -> void: + Input.action_release("move_north") + Input.action_release("move_south") + Input.action_release("move_east") + Input.action_release("move_west") + + +# A throttle-safe "now" — MOVE_INTERVAL_MS["Walk"] is 400ms; setting +# _last_move_msec far enough in the past guarantees the next poll's interval +# check passes regardless of real wall-clock time elapsed during the test. +func _prime_throttle_ready() -> void: + InputMapper._last_move_msec = Time.get_ticks_msec() - 1000 + + +# ============================================================================= +# Occluded -> poll produces nothing (movement) +# ============================================================================= + + +func test_occluded_movement_poll_queues_nothing() -> void: + HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN) + assert_bool(InputMapper._gameplay_occluded).override_failure_message( + "HudGroups.open_app(FULLSCREEN) must occlude — gate precondition not met" + ).is_true() + _prime_throttle_ready() + Input.action_press("move_north") + InputMapper._process(0.016) + assert_int(InputMapper.input_queue.size()).override_failure_message( + "InputMapper must not enqueue MoveNorth while gameplay is occluded" + ).is_equal(0) + + +func test_occluded_set_facing_poll_queues_nothing() -> void: + # Force a facing_octant change so the (ungated in isolation) SET_FACING send + # branch would fire if the occlusion gate were not in place. + InputMapper.facing_angle = 0.0 # East + InputMapper.facing_octant = "East" + InputMapper._last_sent_octant = "North" + HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN) + InputMapper._process(0.016) + assert_int(InputMapper.input_queue.size()).override_failure_message( + "InputMapper must not enqueue SetFacing while gameplay is occluded" + ).is_equal(0) + + +# ============================================================================= +# Occluded -> _unhandled_input produces nothing for gameplay-sim-bound actions +# ============================================================================= + + +func test_occluded_interact_press_queues_nothing() -> void: + HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN) + var fake_event := InputEventAction.new() + fake_event.action = "interact" + fake_event.pressed = true + InputMapper._unhandled_input(fake_event) + assert_int(InputMapper.input_queue.size()).override_failure_message( + "InputMapper must not enqueue Interact while gameplay is occluded" + ).is_equal(0) + + +func test_occluded_stance_up_press_queues_nothing() -> void: + HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN) + var fake_event := InputEventAction.new() + fake_event.action = "stance_up" + fake_event.pressed = true + InputMapper._unhandled_input(fake_event) + assert_int(InputMapper.input_queue.size()).override_failure_message( + "InputMapper must not enqueue TOGGLE_STANCE_UP while gameplay is occluded" + ).is_equal(0) + + +func test_occluded_quicksave_press_queues_nothing() -> void: + GameState.current_game_id = "t1146-test-session" + HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN) + var fake_event := InputEventAction.new() + fake_event.action = "quicksave" + fake_event.pressed = true + InputMapper._unhandled_input(fake_event) + assert_int(InputMapper.input_queue.size()).override_failure_message( + "InputMapper must not enqueue SaveGame while gameplay is occluded" + ).is_equal(0) + GameState.current_game_id = "" + + +# ============================================================================= +# Exempt actions — never gated, regardless of occlusion +# ============================================================================= + + +func test_occluded_open_menu_press_still_queues() -> void: + # OPEN_MENU is how the implant CLOSES (Esc). Gating it would make the + # implant impossible to close with the keyboard. + HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN) + var fake_event := InputEventAction.new() + fake_event.action = "open_menu" + fake_event.pressed = true + InputMapper._unhandled_input(fake_event) + assert_int(InputMapper.input_queue.size()).override_failure_message( + "OPEN_MENU must still queue while gameplay is occluded — it's how the implant closes" + ).is_equal(1) + assert_int(InputMapper.input_queue[0]["action"]).is_equal(InputMapper.Action.OPEN_MENU) + + +func test_occluded_bug_report_press_still_queues() -> void: + # #495: client-only, never reaches send_input — no reason to gate it. + HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN) + var fake_event := InputEventAction.new() + fake_event.action = "bug_report" + fake_event.pressed = true + InputMapper._unhandled_input(fake_event) + assert_int(InputMapper.input_queue.size()).override_failure_message( + "BUG_REPORT must still queue while gameplay is occluded — it's client-only" + ).is_equal(1) + assert_int(InputMapper.input_queue[0]["action"]).is_equal(InputMapper.Action.BUG_REPORT) + + +func test_occluded_open_journal_press_still_queues() -> void: + # #264: client-only, never reaches send_input — no reason to gate it. + HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN) + var fake_event := InputEventAction.new() + fake_event.action = "open_journal" + fake_event.pressed = true + InputMapper._unhandled_input(fake_event) + assert_int(InputMapper.input_queue.size()).override_failure_message( + "OPEN_JOURNAL must still queue while gameplay is occluded — it's client-only" + ).is_equal(1) + assert_int(InputMapper.input_queue[0]["action"]).is_equal(InputMapper.Action.OPEN_JOURNAL) + + +# ============================================================================= +# Unoccluded -> poll produces normally +# ============================================================================= + + +func test_unoccluded_movement_poll_queues_normally() -> void: + assert_bool(InputMapper._gameplay_occluded).is_false() + _prime_throttle_ready() + Input.action_press("move_north") + InputMapper._process(0.016) + assert_int(InputMapper.input_queue.size()).override_failure_message( + "InputMapper must enqueue MoveNorth normally while gameplay is not occluded" + ).is_equal(1) + assert_int(InputMapper.input_queue[0]["action"]).is_equal(InputMapper.Action.MOVE_NORTH) + + +func test_unoccluded_interact_press_queues_normally() -> void: + var fake_event := InputEventAction.new() + fake_event.action = "interact" + fake_event.pressed = true + InputMapper._unhandled_input(fake_event) + assert_int(InputMapper.input_queue.size()).override_failure_message( + "InputMapper must enqueue Interact normally while gameplay is not occluded" + ).is_equal(1) + assert_int(InputMapper.input_queue[0]["action"]).is_equal(InputMapper.Action.INTERACT) + + +# ============================================================================= +# INSERT mode must NOT occlude/gate — e.g. main.gd's "implant/economics" panel +# (D-181/#824) renders alongside still-playable gameplay. +# ============================================================================= + + +func test_insert_mode_does_not_occlude() -> void: + HudGroups.open_app(TEST_APP, HudGroups.Mode.INSERT) + assert_bool(InputMapper._gameplay_occluded).override_failure_message( + "INSERT mode must not set gameplay_occluded — it's a side panel, gameplay stays playable" + ).is_false() + + +func test_insert_mode_movement_poll_queues_normally() -> void: + HudGroups.open_app(TEST_APP, HudGroups.Mode.INSERT) + _prime_throttle_ready() + Input.action_press("move_east") + InputMapper._process(0.016) + assert_int(InputMapper.input_queue.size()).override_failure_message( + "WASD must still move the character while an INSERT-mode panel (e.g. economics monitor) is open" + ).is_equal(1) + + +# ============================================================================= +# Held-state transitions (T-1146 requirement #2) +# ============================================================================= + + +## Open-while-held: a movement key already down when the implant opens must +## stop producing on the very next poll — no separate latch needed, the +## occlusion gate alone is sufficient since poll-site means nothing is ever +## enqueued once occluded. +func test_open_while_held_stops_movement_immediately() -> void: + _prime_throttle_ready() + Input.action_press("move_north") + InputMapper._process(0.016) + assert_int(InputMapper.input_queue.size()).override_failure_message( + "sanity: movement must queue before occlusion begins" + ).is_equal(1) + InputMapper.input_queue.clear() + + # Implant opens WHILE W is still held. + HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN) + InputMapper._process(0.016) + assert_int(InputMapper.input_queue.size()).override_failure_message( + "movement must stop the instant occlusion begins, even with the key still held" + ).is_equal(0) + + +## Close-while-held: a movement key still down at the moment the implant +## closes must NOT resume movement on the very next poll — a re-press is +## required. This is the release-latch (_suppress_move_until_release). +func test_close_while_held_does_not_resume_without_release() -> void: + HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN) + _prime_throttle_ready() + Input.action_press("move_north") # held throughout the whole close transition + InputMapper._process(0.016) + assert_int(InputMapper.input_queue.size()).override_failure_message( + "sanity: nothing should queue yet — still occluded" + ).is_equal(0) + + HudGroups.close_app() + assert_bool(InputMapper._suppress_move_until_release).override_failure_message( + "the release-latch must arm the instant occlusion ends" + ).is_true() + + _prime_throttle_ready() + InputMapper._process(0.016) # W is STILL held — must not resume + assert_int(InputMapper.input_queue.size()).override_failure_message( + "W held through the close transition must NOT resume movement without a re-press" + ).is_equal(0) + + # Still held on a second poll — latch must not self-clear from time alone. + _prime_throttle_ready() + InputMapper._process(0.016) + assert_int(InputMapper.input_queue.size()).override_failure_message( + "the latch must not clear on its own while the key stays held" + ).is_equal(0) + + # Release, then re-press — movement must resume normally. + Input.action_release("move_north") + InputMapper._process(0.016) # observes the release, clears the latch + assert_bool(InputMapper._suppress_move_until_release).override_failure_message( + "the latch must clear once the poll observes every movement key released" + ).is_false() + + _prime_throttle_ready() + Input.action_press("move_north") + InputMapper._process(0.016) + assert_int(InputMapper.input_queue.size()).override_failure_message( + "a fresh press after release must resume movement normally" + ).is_equal(1) + + +## Facing is exempt from the release-latch — turning to look on the frame the +## implant closes is expected (re-sync), not a "lurch". Installs the T-1088 +## facing_angle_provider seam so _update_facing_from_mouse() cannot silently +## overwrite the test's facing_octant from the headless-runner's real (and +## arbitrary) mouse/viewport state before the SET_FACING check runs. +func test_close_while_held_still_allows_facing_update_after_movement_key_release() -> void: + InputMapper.facing_angle_provider = func() -> float: return -PI / 2.0 # North + HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN) + InputMapper._process(0.016) # settle facing_octant to North, consume any pending SET_FACING + InputMapper.input_queue.clear() + HudGroups.close_app() + + # No movement key held at all — latch arms but has nothing to suppress; + # a facing change must still send normally on the very next poll. + InputMapper.facing_angle_provider = func() -> float: return 0.0 # East + InputMapper._process(0.016) + assert_int(InputMapper.input_queue.size()).override_failure_message( + "SET_FACING must send normally right after occlusion ends when no movement key is held" + ).is_equal(1) + assert_int(InputMapper.input_queue[0]["action"]).is_equal(InputMapper.Action.SET_FACING) + InputMapper.facing_angle_provider = Callable() + + +## The latch is scoped to raw_dir == ZERO across ALL four movement keys, not +## just the one that was held when occlusion began — releasing a DIFFERENT +## key than the one held at close-time must not falsely clear the latch while +## another movement key is still down. +func test_close_while_held_latch_requires_every_movement_key_released() -> void: + HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN) + Input.action_press("move_north") + Input.action_press("move_east") + HudGroups.close_app() + + # Release only one of the two held keys. + Input.action_release("move_north") + _prime_throttle_ready() + InputMapper._process(0.016) + assert_bool(InputMapper._suppress_move_until_release).override_failure_message( + "the latch must stay armed while ANY movement key is still held" + ).is_true() + assert_int(InputMapper.input_queue.size()).override_failure_message( + "movement must stay suppressed while any movement key is still held" + ).is_equal(0) + + # Release the second key — now all movement keys are up. + Input.action_release("move_east") + InputMapper._process(0.016) + assert_bool(InputMapper._suppress_move_until_release).override_failure_message( + "the latch must clear once every movement key has been released" + ).is_false() -- 2.54.0 From 4af3dd2607ff7b8bc00eb01e2ed64b9da2635762 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 21 Jul 2026 19:49:42 +0200 Subject: [PATCH 3/4] =?UTF-8?q?test(client):=20T-1146=20=E2=80=94=20full-s?= =?UTF-8?q?uite=20isolation=20for=20the=20occlusion-gate=20suites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit suite: pin facing to North via the T-1088 facing_angle_provider seam. D-054 makes movement facing-relative, and _update_facing_from_mouse() derives facing from engine/session globals (viewport mouse position vs the GameState.player_position anchor) — in full-suite order 'press W' resolved to MOVE_NORTHWEST (the gate failure at line 187). Live suite, three independent leaks from the shared autoloads, each of which passed in isolation and failed in gate order: - snapshot starvation: poll_snapshot() is consume-and-clear; a node leaked into the gdUnit tree by an earlier suite steals every snapshot during our awaits. Capture snapshot_received (fires on every consume, ours or theirs) alongside a direct poll + explicit SimBridge._process pump. - tick poisoning: SimBridge left test_mode=true + CONNECTED lets the leaked poller apply TestHarness mock snapshots (~tick 15+) into GameState.current_tick during our spawn awaits; the fresh server's own ticks (1..5) then stamp backward and its InputQueue panics ('tick ordering violated', last=16 new=3). Neutralize SimBridge before the first await, zero current_tick, clear _last_snapshot/_outbound_buffer both directions. - crash-on-assert-failure: gdUnit asserts don't halt; guard the player-entity dereferences so a missing Player reports cleanly instead of a runtime error. Full suite: 3368/3368. --- client/tests/test_input_gate_live.gd | 78 ++++++++++++++++++- .../tests/test_input_mapper_occlusion_gate.gd | 13 +++- 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/client/tests/test_input_gate_live.gd b/client/tests/test_input_gate_live.gd index bd8c7ce58..c2c5b1b45 100644 --- a/client/tests/test_input_gate_live.gd +++ b/client/tests/test_input_gate_live.gd @@ -28,6 +28,15 @@ const TEST_APP := "implant/t1146_live_gate_test" var _server_pid: int = -1 var _test_port: int = 0 +# SimBridge.poll_snapshot() is consume-and-clear on a shared autoload: any node +# an earlier suite left in the gdUnit scene tree that polls SimBridge each frame +# (main.tscn instances etc.) steals _last_snapshot during our awaits, so a +# direct poll here can starve forever in full-suite order while passing in +# isolation. poll_snapshot() emits snapshot_received on every consume — ours or +# a thief's — so a signal capture sees every snapshot regardless of who wins +# the poll race. _await_next_snapshot() checks both paths. +var _captured_snapshot: Variant = null + # Autoload state to restore in after_test() — SimBridge/InputMapper/HudGroups # are persistent singletons shared with every other suite in the run. var _saved_test_mode: bool = true @@ -35,6 +44,7 @@ var _saved_state: int = 0 var _saved_bridge = null var _saved_server_path: String = "" var _saved_server_port: int = 9876 +var _saved_current_tick: int = 0 func _server_binary_path() -> String: @@ -71,7 +81,36 @@ func before_test() -> void: _saved_bridge = SimBridge._bridge _saved_server_path = SimBridge.server_path _saved_server_port = SimBridge.server_port + # send_input stamps GameState.current_tick into every wire entry, and the + # server's InputQueue panics on non-monotonic ticks ("tick ordering + # violated"). A stale current_tick from an earlier suite's (or the previous + # test's) server — advanced whenever any leaked node applies snapshots — + # would stamp this test's first inputs above the fresh server's tick counter + # and then drop backward once new snapshots apply. Start each test at 0. + # Neutralize SimBridge BEFORE this test's first await (the server-spawn + # timer): earlier suites leave it test_mode=true + CONNECTED, and a node + # leaked into the gdUnit tree polls SimBridge every frame — in that ambient + # state poll_snapshot() serves TestHarness mock snapshots whose ticks (~15+) + # get applied into GameState.current_tick during our awaits, out-running the + # fresh server's own ticks (1..5). send_input stamps current_tick into every + # wire entry and the server's InputQueue panics on non-monotonic ticks + # ("tick ordering violated"), so a single poisoned stamp kills the whole + # connection. Disconnected + live mode makes poll_snapshot() return null for + # the leaked poller until OUR server connects, after which the only applied + # snapshots are its own — monotonic by construction. + SimBridge.test_mode = false + SimBridge.state = SimBridge.ConnectionState.DISCONNECTED + SimBridge._bridge = null + _saved_current_tick = GameState.current_tick + GameState.current_tick = 0 + # reset_test_state() clears neither of these, and both poison a fresh + # server connection the same way: a stale _last_snapshot gets consumed and + # applied right after connect, and stale _outbound_buffer entries flush to + # the new server carrying old tick stamps. + SimBridge._last_snapshot = null + SimBridge._outbound_buffer.clear() InputMapper.input_queue.clear() + InputMapper._last_sent_octant = InputMapper.facing_octant InputMapper._gameplay_occluded = false InputMapper._suppress_move_until_release = false _release_all_movement_keys() @@ -84,12 +123,20 @@ func after_test() -> void: OS.kill(_server_pid) _server_pid = -1 + if SimBridge.snapshot_received.is_connected(_on_snapshot_received): + SimBridge.snapshot_received.disconnect(_on_snapshot_received) + _captured_snapshot = null + SimBridge.test_mode = _saved_test_mode SimBridge.state = _saved_state SimBridge._bridge = _saved_bridge SimBridge.server_path = _saved_server_path SimBridge.server_port = _saved_server_port SimBridge.reset_test_state() + # Don't let MY servers' snapshots/inputs poison later suites either. + SimBridge._last_snapshot = null + SimBridge._outbound_buffer.clear() + GameState.current_tick = _saved_current_tick InputMapper.input_queue.clear() InputMapper._gameplay_occluded = false @@ -124,6 +171,8 @@ func _connect_live(server_path: String) -> bool: SimBridge.server_port = _test_port SimBridge._bridge = null SimBridge.state = SimBridge.ConnectionState.DISCONNECTED + if not SimBridge.snapshot_received.is_connected(_on_snapshot_received): + SimBridge.snapshot_received.connect(_on_snapshot_received) SimBridge.connect_to_sim() var deadline_ms: int = Time.get_ticks_msec() + int(CONNECT_TIMEOUT * 1000.0) * 2 @@ -144,18 +193,31 @@ static func _find_player(snapshot: Dictionary) -> Dictionary: return {} -## Poll SimBridge.poll_snapshot() until one arrives (or the timeout elapses). -## Returns the last-seen snapshot Dictionary, or {} on timeout. +## Wait for a snapshot (or the timeout). Two arrival paths, both required for +## full-suite robustness (see _captured_snapshot): a direct poll_snapshot() +## (wins when nothing else consumes), and the snapshot_received capture (wins +## when a leaked node from an earlier suite consumes first). The explicit +## SimBridge._process() pump keeps the TCP drain going even if ambient autoload +## processing is starved; double-pumping alongside the ambient call is a no-op +## (poll_message just drains an empty buffer). func _await_next_snapshot(timeout: float = RESPONSE_TIMEOUT) -> Dictionary: var deadline_ms: int = Time.get_ticks_msec() + int(timeout * 1000.0) while Time.get_ticks_msec() < deadline_ms: + SimBridge._process(0.016) var snap: Variant = SimBridge.poll_snapshot() + if snap == null and _captured_snapshot != null: + snap = _captured_snapshot + _captured_snapshot = null if snap != null: return snap await get_tree().process_frame return {} +func _on_snapshot_received(snapshot: Variant) -> void: + _captured_snapshot = snapshot + + ## Mirrors main.gd's dispatch loop exactly for the actions this test exercises ## (movement + SET_FACING): flush InputMapper's queue and forward each entry ## to SimBridge.send_input(). main.gd's client-only continues (BUG_REPORT, @@ -199,6 +261,8 @@ func test_wasd_held_during_implant_occlusion_does_not_move_player( assert_that(start_player.size()).override_failure_message( "must receive at least one snapshot with a Player entity before testing" ).is_greater(0) + if start_player.is_empty(): + return # gdUnit asserts don't halt — bail before dereferencing var start_x: float = start_player.x var start_y: float = start_player.y assert_float(start_x).is_equal_approx(16.5, 0.001) @@ -225,6 +289,8 @@ func test_wasd_held_during_implant_occlusion_does_not_move_player( settle = baseline var after_hold := _find_player(settle) assert_that(after_hold.size()).is_greater(0) + if after_hold.is_empty(): + return # gdUnit asserts don't halt — bail before dereferencing assert_float(after_hold.x).override_failure_message( ( "player must NOT move while WASD is held during implant occlusion — x drifted from %.2f to %.2f" @@ -258,7 +324,11 @@ func test_close_while_held_requires_repress_before_movement_resumes_live( var baseline: Dictionary = await _await_next_snapshot() var start_player := _find_player(baseline) - assert_that(start_player.size()).is_greater(0) + assert_that(start_player.size()).override_failure_message( + "must receive at least one snapshot with a Player entity before testing" + ).is_greater(0) + if start_player.is_empty(): + return # gdUnit asserts don't halt — bail before dereferencing var start_x: float = start_player.x var start_y: float = start_player.y @@ -283,6 +353,8 @@ func test_close_while_held_requires_repress_before_movement_resumes_live( settle_still_held = baseline var still_held_player := _find_player(settle_still_held) assert_that(still_held_player.size()).is_greater(0) + if still_held_player.is_empty(): + return # gdUnit asserts don't halt — bail before dereferencing assert_float(still_held_player.x).override_failure_message( "player must NOT resume moving while W stays held through the close transition (no re-press yet)" ).is_equal_approx(start_x, 0.001) diff --git a/client/tests/test_input_mapper_occlusion_gate.gd b/client/tests/test_input_mapper_occlusion_gate.gd index aa51e29e0..f53082ed0 100644 --- a/client/tests/test_input_mapper_occlusion_gate.gd +++ b/client/tests/test_input_mapper_occlusion_gate.gd @@ -25,7 +25,17 @@ func before_test() -> void: InputMapper._gameplay_occluded = false InputMapper._suppress_move_until_release = false InputMapper._last_move_msec = 0 - InputMapper._last_sent_octant = InputMapper.facing_octant + # D-054 movement is facing-relative, and _update_facing_from_mouse() derives + # facing from engine/session globals (viewport mouse position vs the + # GameState.player_position screen anchor under the live canvas transform) at + # the top of every _process. Earlier suites move all three, so in full-suite + # order "press W" can resolve to a diagonal (gate run: MOVE_NORTHWEST). + # Pin facing to North through the T-1088 provider seam so poll assertions + # are order-independent. + InputMapper.facing_angle_provider = func() -> float: return -PI / 2.0 + InputMapper.facing_angle = -PI / 2.0 + InputMapper.facing_octant = "North" + InputMapper._last_sent_octant = "North" _release_all_movement_keys() @@ -33,6 +43,7 @@ func after_test() -> void: InputMapper.input_queue.clear() InputMapper._gameplay_occluded = false InputMapper._suppress_move_until_release = false + InputMapper.facing_angle_provider = Callable() _release_all_movement_keys() # Restore HudGroups to a clean gameplay state regardless of what a test left # behind — mirrors test_implant_app_lifecycle.gd's after_test convention. -- 2.54.0 From 32a7b1e425a127bef7853a60d74aa6acb21206fb Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 21 Jul 2026 20:00:18 +0200 Subject: [PATCH 4/4] =?UTF-8?q?fix(client):=20T-1146=20review=20round=20?= =?UTF-8?q?=E2=80=94=20gate=20the=20T-1088=20seams,=20kill=20the=20settle?= =?UTF-8?q?=20fallback=20tautology,=20neutralize=20AutoPause=20leak?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #190 review (Hoshe + Tyre), all five findings addressed: - Tyre: queue_move_step/queue_stance_toggle (the T-1088 click-to-move seams) now share the full suppression predicate — all three input_queue producers in the module are gated, making the header's 'every action this file can produce' claim true (previously the seams relied on the sandbox caller's own freeze). +3 regression tests. - Hoshe: the live suite's settle=baseline timeout fallback compared baseline to itself, vacuously passing the no-movement claim — a timeout now fails loudly (both tests). - Hoshe: the unit suite's real open_app/close_app transitions fire the global auto-pause handler; with ambient SimBridge.state left CONNECTED by earlier suites (test_hub_teleport has no after_test) each transition silently appended AutoPause/AutoResume to the shared _outbound_buffer. before_test now forces DISCONNECTED (restored in after_test) and clears the buffer. - Hoshe: documented why the live suite's reset_test_state() call is inert but still correct. Full suite: 3374/3374. --- client/scripts/autoloads/input_mapper.gd | 11 ++-- client/tests/test_input_gate_live.gd | 18 +++++-- .../tests/test_input_mapper_occlusion_gate.gd | 51 +++++++++++++++++++ 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index ad2d57a12..d4f41a066 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -290,7 +290,12 @@ func flush_queue() -> Array[Dictionary]: # false when throttled or input-suppressed. Ordinary Move* actions only: zero # protocol change, no client prediction, the server validates every step (D-010). func queue_move_step(tile_delta: Vector2i) -> bool: - if GameState.dialogue_active or GameState.free_camera_mode: + # T-1146: same suppression predicate as the _process poll — all three + # input_queue producers in this module share it, so the occlusion gate is + # self-contained here rather than delegated to seam callers (today the + # sandbox freezes the path follower itself, but Phase 5/T-962 lifts + # click-to-move into the shared session driver). + if GameState.dialogue_active or GameState.free_camera_mode or _gameplay_occluded: return false if tile_delta == Vector2i.ZERO: return false @@ -312,9 +317,9 @@ func queue_move_step(tile_delta: Vector2i) -> bool: # Sprint, down = toward Crouch) through the same input_queue movement rides. Discrete, # so no throttle — the server's handle_toggle_stance has no cooldown, so the sandbox # follower can burst the exact ladder distance and trust it (server validates). No-op -# while input is suppressed (dialogue / free camera), matching movement. +# while input is suppressed (dialogue / free camera / occlusion), matching movement. func queue_stance_toggle(up: bool) -> void: - if GameState.dialogue_active or GameState.free_camera_mode: + if GameState.dialogue_active or GameState.free_camera_mode or _gameplay_occluded: return input_queue.append( { diff --git a/client/tests/test_input_gate_live.gd b/client/tests/test_input_gate_live.gd index c2c5b1b45..bb8826140 100644 --- a/client/tests/test_input_gate_live.gd +++ b/client/tests/test_input_gate_live.gd @@ -132,6 +132,9 @@ func after_test() -> void: SimBridge._bridge = _saved_bridge SimBridge.server_path = _saved_server_path SimBridge.server_port = _saved_server_port + # reset_test_state() touches only mock-side state (harness.reset() when a + # harness exists, star-map flag, SystemIndex cache) — inert for this live + # suite, but it restores whatever ambient mock the run left for later suites. SimBridge.reset_test_state() # Don't let MY servers' snapshots/inputs poison later suites either. SimBridge._last_snapshot = null @@ -283,10 +286,15 @@ func test_wasd_held_during_implant_occlusion_does_not_move_player( Input.action_release("move_north") # Drain whatever the server actually streamed during the hold and confirm - # the player never left the starting tile. + # the player never left the starting tile. A timeout here is a FAILURE, not + # a fallback case: substituting baseline would compare baseline to itself + # and vacuously pass the very claim under test (Hoshe, PR #190 review). var settle: Dictionary = await _await_next_snapshot(2.0) + assert_bool(not settle.is_empty()).override_failure_message( + "no snapshot arrived within 2s after the hold — cannot verify the no-movement claim" + ).is_true() if settle.is_empty(): - settle = baseline + return var after_hold := _find_player(settle) assert_that(after_hold.size()).is_greater(0) if after_hold.is_empty(): @@ -348,9 +356,13 @@ func test_close_while_held_requires_repress_before_movement_resumes_live( for _i in range(10): await _tick(0.05) + # Timeout is a failure, not a fallback — see the sibling test's comment. var settle_still_held: Dictionary = await _await_next_snapshot(2.0) + assert_bool(not settle_still_held.is_empty()).override_failure_message( + "no snapshot arrived within 2s after the held-through-close phase — cannot verify the no-resume claim" + ).is_true() if settle_still_held.is_empty(): - settle_still_held = baseline + return var still_held_player := _find_player(settle_still_held) assert_that(still_held_player.size()).is_greater(0) if still_held_player.is_empty(): diff --git a/client/tests/test_input_mapper_occlusion_gate.gd b/client/tests/test_input_mapper_occlusion_gate.gd index f53082ed0..05cc0b5a0 100644 --- a/client/tests/test_input_mapper_occlusion_gate.gd +++ b/client/tests/test_input_mapper_occlusion_gate.gd @@ -15,12 +15,26 @@ extends GdUnitTestSuite const TEST_APP := "implant/t1146_occlusion_test" +# Ambient SimBridge state to restore in after_test — see the neutralization +# comment in before_test. +var _saved_sim_state: int = 0 + func before_test() -> void: GameState.player_stance = "Walk" GameState.dialogue_active = false GameState.free_camera_mode = false GameState.gauntlet_mode = false + # This suite never touches SimBridge, but every real open_app()/close_app() + # transition below fires HudGroups' global auto-pause handler, which calls + # SimBridge.send_named_action("AutoPause"/"AutoResume") — a silent append to + # the shared _outbound_buffer whenever ambient state is CONNECTED (which + # other suites leave behind, e.g. test_hub_teleport.gd sets CONNECTED with + # no after_test). Force DISCONNECTED for the duration so those sends no-op, + # and clear any entries already leaked into the buffer (Hoshe, PR #190). + _saved_sim_state = SimBridge.state + SimBridge.state = SimBridge.ConnectionState.DISCONNECTED + SimBridge._outbound_buffer.clear() InputMapper.input_queue.clear() InputMapper._gameplay_occluded = false InputMapper._suppress_move_until_release = false @@ -40,6 +54,7 @@ func before_test() -> void: func after_test() -> void: + SimBridge.state = _saved_sim_state InputMapper.input_queue.clear() InputMapper._gameplay_occluded = false InputMapper._suppress_move_until_release = false @@ -209,6 +224,42 @@ func test_unoccluded_interact_press_queues_normally() -> void: assert_int(InputMapper.input_queue[0]["action"]).is_equal(InputMapper.Action.INTERACT) +# ============================================================================= +# T-1088 click-to-move seams share the same suppression predicate — all three +# input_queue producers in the module are gated (Tyre, PR #190 review) +# ============================================================================= + + +func test_occluded_queue_move_step_queues_nothing() -> void: + InputMapper._gameplay_occluded = true + _prime_throttle_ready() + var queued := InputMapper.queue_move_step(Vector2i(0, -1)) + assert_bool(queued).is_false() + assert_int(InputMapper.input_queue.size()).override_failure_message( + "queue_move_step must not enqueue while gameplay is occluded" + ).is_equal(0) + + +func test_occluded_queue_stance_toggle_queues_nothing() -> void: + InputMapper._gameplay_occluded = true + InputMapper.queue_stance_toggle(true) + assert_int(InputMapper.input_queue.size()).override_failure_message( + "queue_stance_toggle must not enqueue while gameplay is occluded" + ).is_equal(0) + + +func test_unoccluded_seams_queue_normally() -> void: + _prime_throttle_ready() + var queued := InputMapper.queue_move_step(Vector2i(0, -1)) + assert_bool(queued).is_true() + InputMapper.queue_stance_toggle(true) + assert_int(InputMapper.input_queue.size()).override_failure_message( + "both seams must keep queueing normally while gameplay is not occluded" + ).is_equal(2) + assert_int(InputMapper.input_queue[0]["action"]).is_equal(InputMapper.Action.MOVE_NORTH) + assert_int(InputMapper.input_queue[1]["action"]).is_equal(InputMapper.Action.TOGGLE_STANCE_UP) + + # ============================================================================= # INSERT mode must NOT occlude/gate — e.g. main.gd's "implant/economics" panel # (D-181/#824) renders alongside still-playable gameplay. -- 2.54.0