fix(client): T-1146 gameplay input gated while an implant screen occludes gameplay

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 19:16:12 +02:00
co-authored by Claude Fable 5
parent cdc5396c21
commit cd52fe8843
3 changed files with 788 additions and 22 deletions
+127 -22
View File
@@ -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 := {