Merge remote-tracking branch 'origin/implant-input-gate'
This commit is contained in:
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user