Files
settled-reach/client/scripts/autoloads/input_mapper.gd
T
jpmschweitzer 32a7b1e425 fix(client): T-1146 review round — gate the T-1088 seams, kill the settle fallback tautology, neutralize AutoPause leak
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.
2026-07-21 20:00:18 +02:00

462 lines
19 KiB
GDScript

extends Node
# Semantic actions — NO raw key codes cross the bridge
# Movement uses hold-to-move (polled each frame in _process).
# Discrete actions (interact, stance, etc.) use press events (_unhandled_input).
#
# D-054: Mouse-relative facing and movement.
# Mouse position determines facing direction (client-side float).
# WASD is relative to facing: W = toward cursor, S = away, A/D = strafe.
# Server receives facing octant only — the full float stays client-side.
#
# Movement throttle: client-side rate limit per stance (D-053).
# 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,
MOVE_EAST,
MOVE_SOUTHEAST,
MOVE_SOUTH,
MOVE_SOUTHWEST,
MOVE_WEST,
MOVE_NORTHWEST,
INTERACT,
USE_PERCEPTION_MODE,
OPEN_MENU,
PAUSE,
UNPAUSE,
TOGGLE_STANCE_UP,
TOGGLE_STANCE_DOWN,
BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server
OPEN_JOURNAL, # #264: J key — toggle knowledge journal panel, client-only
SET_FACING, # D-054: facing octant update (no movement)
TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel)
SAVE_GAME, # #554: F5 quicksave — sends SaveGame to server with save path
LOAD_GAME, # #554: F6 quickload — sends LoadGame to server with save path
DEBUG_COMMAND, # #581: debug console command dispatch — sends DebugCommandKind to server
CHANGE_SETTINGS, # #646: persist a setting to server SQLite — sends {key, value} to server
REQUEST_ALL_SETTINGS, # #646: request full settings dump from server after handshake (unit variant)
DELETE_SETTING, # #646: delete a setting by key from server SQLite (struct variant)
}
# Minimum milliseconds between movement commands, per stance.
# Tuned so Walk feels like walking, Sprint feels fast but readable.
const MOVE_INTERVAL_MS := {
"Sprint": 200, # 5/sec — fast but trackable
"Walk": 400, # 2.5/sec — comfortable walking pace
"Careful": 600, # ~1.7/sec — deliberate, scanning
"Crouch": 800, # 1.25/sec — creeping
}
var input_queue: Array[Dictionary] = []
# D-054: Client-side facing angle (radians). 0=East, -PI/2=North, PI/2=South.
# 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
# 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
# NAN = no update (deadzone/degenerate ray). While installed it fully replaces the
# 2D canvas-transform path in _update_facing_from_mouse(); unset (default) leaves
# 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()
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
(
input_queue
. append(
{
"action": Action.SET_FACING,
"timestamp_msec": Time.get_ticks_msec(),
"action_data": {"facing": facing_octant},
}
)
)
# Poll held WASD keys
var raw_dir := Vector2i.ZERO
if Input.is_action_pressed("move_north"):
raw_dir.y -= 1
if Input.is_action_pressed("move_south"):
raw_dir.y += 1
if Input.is_action_pressed("move_east"):
raw_dir.x += 1
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)
if now - _last_move_msec >= interval:
_last_move_msec = now
# D-054: Transform WASD input relative to mouse facing
var world_dir := _wasd_to_world_dir(raw_dir)
var action: Action = _dir_to_action(world_dir)
(
input_queue
. append(
{
"action": action,
"timestamp_msec": now,
}
)
)
# 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
# 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("bug_report"):
action = Action.BUG_REPORT
elif event.is_action_pressed("open_journal"):
action = Action.OPEN_JOURNAL
# 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 := {
"action": action,
"timestamp_msec": Time.get_ticks_msec(),
}
# #554: Attach save path for SaveGame/LoadGame actions
if action == Action.SAVE_GAME or action == Action.LOAD_GAME:
var game_id := GameState.current_game_id
if game_id.is_empty():
get_viewport().set_input_as_handled()
return # No active session — ignore save/load
entry["action_data"] = {"path": "user://saves/" + game_id + "/quicksave.sav"}
input_queue.append(entry)
get_viewport().set_input_as_handled()
func flush_queue() -> Array[Dictionary]:
var queue = input_queue.duplicate()
input_queue.clear()
return queue
# T-1088 click-to-move seam (design §9 / D-248): queue ONE 8-direction step from a
# sim tile delta (e.g. Vector2i(1, 0) = East, Vector2i(1, 1) = Southeast), gated by
# the SAME per-stance throttle held-WASD uses (_last_move_msec + MOVE_INTERVAL_MS).
# The sandbox path follower calls this once per frame while walking a client-planned
# path; the shared throttle paces it to the exact stance cadence (no duplicated
# interval table) and interleaves cleanly with WASD — a held key and a queued step
# can never both fire inside one window. Returns true when the step was queued,
# 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:
# 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
var now := Time.get_ticks_msec()
var interval: int = MOVE_INTERVAL_MS.get(GameState.player_stance, 200)
if now - _last_move_msec < interval:
return false
_last_move_msec = now
input_queue.append(
{
"action": _dir_to_action(tile_delta),
"timestamp_msec": now,
}
)
return true
# T-1088 double-RMB sprint-there seam: queue ONE ordinary stance toggle (up = toward
# 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 / occlusion), matching movement.
func queue_stance_toggle(up: bool) -> void:
if GameState.dialogue_active or GameState.free_camera_mode or _gameplay_occluded:
return
input_queue.append(
{
"action": Action.TOGGLE_STANCE_UP if up else Action.TOGGLE_STANCE_DOWN,
"timestamp_msec": Time.get_ticks_msec(),
}
)
## Reset facing state to default (North). Use in tests per D-030 testability.
func reset_facing_state() -> void:
facing_angle = -PI / 2.0
facing_octant = "North"
_last_sent_octant = "North"
# D-054: Compute facing angle from mouse position relative to player screen position.
# Uses viewport canvas transform to convert world coords to screen coords.
# Intentional coupling: reads GameState.player_position directly — InputMapper is an
# autoload that runs before game loop rendering, so position is always current-tick.
func _update_facing_from_mouse() -> void:
# T-1088 (design §9): provider seam. In a 3D scene the canvas-transform anchor
# below is a far-off-screen point, and the screen-space mouse angle is not a
# sim-space angle under the 45° map rotation + ortho pitch — an installed
# provider (e.g. SandboxMouseAimProvider) replaces this whole path.
if facing_angle_provider.is_valid():
var a: float = facing_angle_provider.call()
if is_finite(a):
facing_angle = a
facing_octant = _angle_to_octant(a)
return
var vp := get_viewport()
if vp == null:
return
var canvas_xf := vp.get_canvas_transform()
var C := load("res://scripts/constants.gd")
var player_world_px: Vector2 = GameState.player_position * C.TILE_SIZE
var player_screen: Vector2 = canvas_xf * player_world_px
var mouse_screen: Vector2 = vp.get_mouse_position()
var delta: Vector2 = mouse_screen - player_screen
# Only update if mouse is meaningfully distant from player (avoid jitter at center)
if delta.length_squared() > 4.0:
facing_angle = delta.angle()
facing_octant = _angle_to_octant(facing_angle)
# D-054: Transform raw WASD input (screen-space) to world direction relative to mouse facing.
# W (+Y up in input, mapped to forward), S (backward), A (strafe left), D (strafe right).
# Raw input: W=(-Y), S=(+Y), A=(-X), D=(+X) in screen coords.
# Forward = facing_angle direction. Output: nearest octant direction vector.
func _wasd_to_world_dir(raw_dir: Vector2i) -> Vector2i:
# Build a continuous direction vector relative to facing.
# raw_dir.y: -1 = W (forward), +1 = S (backward)
# raw_dir.x: -1 = A (strafe left), +1 = D (strafe right)
var forward := Vector2(cos(facing_angle), sin(facing_angle))
var right := Vector2(-forward.y, forward.x) # 90° clockwise
# Combine: forward/back from W/S, strafe from A/D
var world_float := forward * float(-raw_dir.y) + right * float(raw_dir.x)
# Snap to nearest octant direction
return _snap_to_octant_dir(world_float)
# Snap a floating-point direction vector to the nearest of 8 cardinal/diagonal directions.
static func _snap_to_octant_dir(dir: Vector2) -> Vector2i:
if dir.length_squared() < 0.001:
return Vector2i.ZERO
var angle := dir.angle()
# Quantize to nearest 45° (PI/4)
var octant := roundi(angle / (PI / 4.0))
match octant:
0:
return Vector2i(1, 0) # East
1:
return Vector2i(1, 1) # Southeast
2, -6:
return Vector2i(0, 1) # South
3, -5:
return Vector2i(-1, 1) # Southwest
4, -4:
return Vector2i(-1, 0) # West
-3, 5:
return Vector2i(-1, -1) # Northwest
-2:
return Vector2i(0, -1) # North
-1:
return Vector2i(1, -1) # Northeast
_:
return Vector2i.ZERO
# D-054: Convert a facing angle (radians) to the nearest octant name.
# Godot 2D: 0=East, PI/2=South, -PI/2=North.
static func _angle_to_octant(angle: float) -> String:
var octant := roundi(angle / (PI / 4.0))
match octant:
0:
return "East"
1:
return "Southeast"
2, -6:
return "South"
3, -5:
return "Southwest"
4, -4:
return "West"
-3, 5:
return "Northwest"
-2:
return "North"
-1:
return "Northeast"
_:
return "East"
# Map a direction vector to the corresponding movement Action.
# Handles all 8 directions via composite W+D, W+A, etc.
static func _dir_to_action(dir: Vector2i) -> Action:
match dir:
Vector2i(0, -1):
return Action.MOVE_NORTH
Vector2i(1, -1):
return Action.MOVE_NORTHEAST
Vector2i(1, 0):
return Action.MOVE_EAST
Vector2i(1, 1):
return Action.MOVE_SOUTHEAST
Vector2i(0, 1):
return Action.MOVE_SOUTH
Vector2i(-1, 1):
return Action.MOVE_SOUTHWEST
Vector2i(-1, 0):
return Action.MOVE_WEST
Vector2i(-1, -1):
return Action.MOVE_NORTHWEST
_:
return Action.MOVE_NORTH