diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index ad2d57a12..d4f41a066 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -290,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 @@ -312,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 index c2c5b1b45..bb8826140 100644 --- a/client/tests/test_input_gate_live.gd +++ b/client/tests/test_input_gate_live.gd @@ -132,6 +132,9 @@ func after_test() -> void: 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 @@ -283,10 +286,15 @@ func test_wasd_held_during_implant_occlusion_does_not_move_player( Input.action_release("move_north") # Drain whatever the server actually streamed during the hold and confirm - # the player never left the starting tile. + # 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(): - settle = baseline + return var after_hold := _find_player(settle) assert_that(after_hold.size()).is_greater(0) if after_hold.is_empty(): @@ -348,9 +356,13 @@ func test_close_while_held_requires_repress_before_movement_resumes_live( 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(): - settle_still_held = baseline + 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(): diff --git a/client/tests/test_input_mapper_occlusion_gate.gd b/client/tests/test_input_mapper_occlusion_gate.gd index f53082ed0..05cc0b5a0 100644 --- a/client/tests/test_input_mapper_occlusion_gate.gd +++ b/client/tests/test_input_mapper_occlusion_gate.gd @@ -15,12 +15,26 @@ 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 @@ -40,6 +54,7 @@ func before_test() -> void: func after_test() -> void: + SimBridge.state = _saved_sim_state InputMapper.input_queue.clear() InputMapper._gameplay_occluded = false InputMapper._suppress_move_until_release = false @@ -209,6 +224,42 @@ func test_unoccluded_interact_press_queues_normally() -> void: 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.