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
+317
View File
@@ -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()
@@ -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()