diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index 47d92bc86..d4f41a066 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 := { @@ -185,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 @@ -207,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 new file mode 100644 index 000000000..bb8826140 --- /dev/null +++ b/client/tests/test_input_gate_live.gd @@ -0,0 +1,401 @@ +## 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 + +# 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 +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: + 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 + # 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() + + +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 + + 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 + # 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 + SimBridge._outbound_buffer.clear() + GameState.current_tick = _saved_current_tick + + 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 + 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 + 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 {} + + +## 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, +## 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) + 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) + 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. 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(): + return + 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" + % [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()).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 + + # 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) + + # 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(): + 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(): + 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) + 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..05cc0b5a0 --- /dev/null +++ b/client/tests/test_input_mapper_occlusion_gate.gd @@ -0,0 +1,406 @@ +## 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" + +# 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 + InputMapper._last_move_msec = 0 + # 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() + + +func after_test() -> void: + SimBridge.state = _saved_sim_state + 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. + 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) + + +# ============================================================================= +# 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. +# ============================================================================= + + +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()