## 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()