From 72741516716b0b69d6c321e7807eeddc7ede275d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 08:46:02 +0100 Subject: [PATCH 1/3] =?UTF-8?q?feat(client):=20hub=20teleport=20UX=20?= =?UTF-8?q?=E2=80=94=20Home=20key,=20fade=20transition=20(#501)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home key sends TeleportToHub in Gauntlet mode. Camera snaps to hub spawn with 0.3s fade-from-black. Clears dialogue/monologue buffers on teleport. Teleport detection uses distance threshold (>5 tiles) so future teleport types get the transition for free. Co-Authored-By: Claude Opus 4.6 --- client/project.godot | 5 + client/scripts/autoloads/input_mapper.gd | 4 + client/scripts/autoloads/sim_bridge.gd | 7 + client/scripts/main.gd | 38 ++++ client/tests/test_hub_teleport.gd | 260 +++++++++++++++++++++++ 5 files changed, 314 insertions(+) create mode 100644 client/tests/test_hub_teleport.gd diff --git a/client/project.godot b/client/project.godot index 7c46700bd..af0fb088e 100644 --- a/client/project.godot +++ b/client/project.godot @@ -116,6 +116,11 @@ bug_report={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194343,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) ] } +teleport_hub={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194317,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} [rendering] diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index 10e90b8b8..a5fcc077b 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -20,6 +20,7 @@ enum Action { TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN, BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server SET_FACING, # D-054: facing octant update (no movement) + TELEPORT_HUB, # #501: Home key — teleport to hub (Gauntlet-only) } var input_queue: Array[Dictionary] = [] @@ -105,6 +106,9 @@ func _unhandled_input(event: InputEvent) -> void: action = Action.TOGGLE_STANCE_DOWN elif event.is_action_pressed("bug_report"): action = Action.BUG_REPORT + elif event.is_action_pressed("teleport_hub"): + if GameState.gauntlet_mode: + action = Action.TELEPORT_HUB if action != -1: input_queue.append({ diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 5880219e6..1b75e8e5e 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -269,6 +269,8 @@ static func _action_enum_to_wire(action: int) -> String: return "" # Client-only action (#495), not part of wire protocol InputMapper.Action.SET_FACING: return "SetFacing" # D-054: facing octant update (no movement) + InputMapper.Action.TELEPORT_HUB: + return "TeleportToHub" # #501: hub teleport (Gauntlet-only) _: push_warning("SimBridge: unknown action enum %s" % action) return "" @@ -284,6 +286,11 @@ func _test_snapshot() -> Dictionary: # Process queued inputs for action_name in _test_input_queue: + if action_name == "TeleportToHub": + # #501: Reset to hub spawn position, clear dialogue + _test_player_pos = Vector2i(10, 10) + _test_in_dialogue = false + continue if action_name == "Interact": # Mock dialogue trigger (#434): if near NPC, start dialogue var npc_pos := Vector2i(12, 9) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 85344e8d4..75d2b3766 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -62,8 +62,13 @@ func _process(_delta: float) -> void: # Main game loop: poll snapshot, apply state, flush input var snapshot: Variant = SimBridge.poll_snapshot() if snapshot != null: + var old_pos := GameState.player_position GameState.apply_snapshot(snapshot) + # #501: Detect teleport (large position jump > 5 tiles) and trigger fade + if _camera_anchored and _detect_teleport(old_pos, GameState.player_position): + _teleport_transition() + # Late anchor: live mode — first snapshot arrives during _process. # Smoothing is already OFF (disabled in _ready), so setting # global_position takes effect immediately with no lerp. @@ -230,6 +235,39 @@ func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_stat gauntlet_hud.finalize() +# #501: Detect large position jump indicating a teleport (not normal movement). +func _detect_teleport(old_pos: Vector2, new_pos: Vector2) -> bool: + return old_pos.distance_to(new_pos) > 5.0 + + +# #501: Hub teleport transition — snap camera + 0.3s fade-from-black. +# Clears dialogue/monologue/interaction state (server clears its side too). +func _teleport_transition() -> void: + # Snap camera: disable smoothing, force re-anchor + camera.position_smoothing_enabled = false + camera.global_position = GameState.player_position * Constants.TILE_SIZE + _camera_anchored = true + + # Clear client-side buffers + GameState.current_monologue = null + GameState.current_dialogue = null + GameState.dialogue_active = false + if dialogue_box and dialogue_box.is_dialogue_active(): + dialogue_box.hide_dialogue() + + # Fade from black: instant black overlay, fades to transparent over 0.3s + if _flash_rect and is_instance_valid(_flash_rect): + _flash_rect.queue_free() + _flash_rect = ColorRect.new() + _flash_rect.color = Color(0, 0, 0, 1.0) + _flash_rect.anchors_preset = Control.PRESET_FULL_RECT + _flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE + $UILayer.add_child(_flash_rect) + var tween := create_tween() + tween.tween_property(_flash_rect, "color:a", 0.0, 0.3) + tween.tween_callback(_flash_rect.queue_free) + + # #502: Full-screen color flash — fades from color to transparent over duration. # Used for room reset amber flash. Creates ephemeral ColorRect on UILayer. func _screen_flash(color: Color, duration: float) -> void: diff --git a/client/tests/test_hub_teleport.gd b/client/tests/test_hub_teleport.gd new file mode 100644 index 000000000..3a0d145a0 --- /dev/null +++ b/client/tests/test_hub_teleport.gd @@ -0,0 +1,260 @@ +## #501: Hub teleport client UX — QA test suite +## Spec refs: D-020 (protocol), D-030 (testability) +## Sprint Completion Proof (joint.md): +## - Home key sends TeleportToHub in Gauntlet mode +## - 0.3s fade-to-black-and-back plays on teleport +## - Dialogue/monologue/interaction buffer cleared on teleport +## - Non-Gauntlet: action rejected, client shows no effect +class_name TestHubTeleport +extends GdUnitTestSuite + + +# -- Fixtures ------------------------------------------------------------------ + +var _gauntlet_snapshot := { + "tick": 1, + "version": Protocol.PROTOCOL_VERSION, + "game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"}, + "player_facing": "North", + "player_stance": "Walk", + "player_inventory": [], + "entities": [ + {"entity_id": 1, "x": 50.0, "y": 50.0, "z": 0, "kind": {"variant": "Player", "data": null}, "visibility": "Forward"}, + ], + "tiles": [], + "visible_tiles": [], + "visible_positions": [], + "nearby_interactions": [], + "current_monologue": null, + "current_dialogue": null, + "pending_recognitions": [], + "gauntlet_mode": true, + "room_id": "proof_room", +} + +var _normal_snapshot := { + "tick": 1, + "version": Protocol.PROTOCOL_VERSION, + "game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"}, + "player_facing": "North", + "player_stance": "Walk", + "player_inventory": [], + "entities": [ + {"entity_id": 1, "x": 50.0, "y": 50.0, "z": 0, "kind": {"variant": "Player", "data": null}, "visibility": "Forward"}, + ], + "tiles": [], + "visible_tiles": [], + "visible_positions": [], + "nearby_interactions": [], + "current_monologue": null, + "current_dialogue": null, + "pending_recognitions": [], +} + + +func before_test() -> void: + GameState.gauntlet_mode = false + GameState.current_monologue = null + GameState.current_dialogue = null + GameState.dialogue_active = false + GameState.room_id = null + InputMapper.input_queue.clear() + SimBridge.reset_test_state() + + +# -- InputMapper: TELEPORT_HUB action enum ------------------------------------ + +func test_teleport_hub_action_exists() -> void: + # Verify the enum value exists and is distinct + var action: int = InputMapper.Action.TELEPORT_HUB + assert_that(action).is_not_equal(InputMapper.Action.INTERACT) + assert_that(action).is_not_equal(InputMapper.Action.MOVE_NORTH) + + +# -- InputMapper: Gauntlet mode guard ----------------------------------------- + +func test_teleport_hub_gated_by_gauntlet_mode() -> void: + # Non-gauntlet: Home key should NOT queue TELEPORT_HUB + GameState.gauntlet_mode = false + # Simulate what _unhandled_input does: check gauntlet_mode before queuing + # (We test the guard logic, not the full input event pipeline) + var should_queue := GameState.gauntlet_mode + assert_that(should_queue).is_false() + + +func test_teleport_hub_allowed_in_gauntlet_mode() -> void: + # Gauntlet mode: Home key SHOULD queue TELEPORT_HUB + GameState.gauntlet_mode = true + var should_queue := GameState.gauntlet_mode + assert_that(should_queue).is_true() + + +# -- GameState: gauntlet_mode from snapshot ------------------------------------ + +func test_gauntlet_mode_set_from_snapshot() -> void: + GameState.apply_snapshot(_gauntlet_snapshot) + assert_that(GameState.gauntlet_mode).is_true() + assert_that(GameState.room_id).is_equal("proof_room") + + +func test_gauntlet_mode_false_when_absent() -> void: + GameState.apply_snapshot(_normal_snapshot) + assert_that(GameState.gauntlet_mode).is_false() + assert_that(GameState.room_id).is_null() + + +func test_gauntlet_mode_transitions_off() -> void: + # Gauntlet on → off: mode should clear + GameState.apply_snapshot(_gauntlet_snapshot) + assert_that(GameState.gauntlet_mode).is_true() + GameState.apply_snapshot(_normal_snapshot) + assert_that(GameState.gauntlet_mode).is_false() + + +# -- SimBridge: wire format encoding ------------------------------------------- + +func test_teleport_hub_wire_name() -> void: + # TELEPORT_HUB must encode to "TeleportToHub" on the wire (matching Rust PlayerAction) + var wire_name := SimBridge._action_enum_to_wire(InputMapper.Action.TELEPORT_HUB) + assert_that(wire_name).is_equal("TeleportToHub") + + +func test_teleport_hub_wire_not_empty() -> void: + # Wire name must not be empty (empty = client-only, not sent to server) + var wire_name := SimBridge._action_enum_to_wire(InputMapper.Action.TELEPORT_HUB) + assert_that(wire_name.is_empty()).is_false() + + +func test_teleport_hub_encode_roundtrip() -> void: + # Verify MessagePack encode→decode roundtrip for TeleportToHub + var encoded: PackedByteArray = Protocol.encode_player_input(42, "TeleportToHub") + assert_that(encoded.size()).is_greater(0) + var decoded: Variant = Protocol.decode_player_input(encoded) + assert_that(decoded).is_not_null() + assert_that(decoded.tick).is_equal(42) + assert_that(decoded.action.variant).is_equal("TeleportToHub") + assert_that(decoded.action.data).is_null() + + +# -- SimBridge: test mode teleport behavior ------------------------------------ + +func test_test_mode_teleport_resets_position() -> void: + # In test mode, TeleportToHub should reset player to hub spawn (10, 10) + SimBridge.reset_test_state() + # Move player away first + SimBridge._test_player_pos = Vector2i(50, 50) + SimBridge._test_input_queue.append("TeleportToHub") + var snap: Dictionary = SimBridge._test_snapshot() + # Player should be back at hub spawn + var player_entity: Dictionary = snap.entities[0] + assert_that(player_entity.x).is_equal(10.0) + assert_that(player_entity.y).is_equal(10.0) + + +func test_test_mode_teleport_clears_dialogue() -> void: + # TeleportToHub in test mode should clear dialogue state + SimBridge.reset_test_state() + SimBridge._test_in_dialogue = true + SimBridge._test_input_queue.append("TeleportToHub") + SimBridge._test_snapshot() + assert_that(SimBridge._test_in_dialogue).is_false() + + +# -- Teleport detection ------------------------------------------------------- + +func test_detect_teleport_large_jump() -> void: + # Position jump > 5 tiles should be detected as teleport + # _detect_teleport is a method on the main scene — test the math directly + var old_pos := Vector2(10.0, 10.0) + var new_pos := Vector2(50.0, 50.0) + var distance := old_pos.distance_to(new_pos) + assert_that(distance > 5.0).is_true() + + +func test_detect_teleport_normal_movement() -> void: + # Normal 1-tile movement should NOT be detected as teleport + var old_pos := Vector2(10.0, 10.0) + var new_pos := Vector2(11.0, 10.0) + var distance := old_pos.distance_to(new_pos) + assert_that(distance > 5.0).is_false() + + +func test_detect_teleport_diagonal_movement() -> void: + # Diagonal movement (1,1) — distance ~1.41, not a teleport + var old_pos := Vector2(10.0, 10.0) + var new_pos := Vector2(11.0, 11.0) + var distance := old_pos.distance_to(new_pos) + assert_that(distance > 5.0).is_false() + + +func test_detect_teleport_boundary_exactly_five() -> void: + # Exactly 5.0 tiles — should NOT trigger (threshold is > 5.0, not >=) + var old_pos := Vector2(10.0, 10.0) + var new_pos := Vector2(15.0, 10.0) + var distance := old_pos.distance_to(new_pos) + assert_that(distance > 5.0).is_false() + + +func test_detect_teleport_boundary_just_over_five() -> void: + # 5.1 tiles — should trigger + var old_pos := Vector2(10.0, 10.0) + var new_pos := Vector2(15.1, 10.0) + var distance := old_pos.distance_to(new_pos) + assert_that(distance > 5.0).is_true() + + +# -- Buffer clearing on teleport ----------------------------------------------- + +func test_teleport_clears_monologue_state() -> void: + # Teleport transition must clear current_monologue + GameState.current_monologue = {"id": "test_mono", "text": "test", "duration_seconds": 5.0} + # Simulate what _teleport_transition does + GameState.current_monologue = null + assert_that(GameState.current_monologue).is_null() + + +func test_teleport_clears_dialogue_state() -> void: + # Teleport transition must clear current_dialogue and dialogue_active + GameState.current_dialogue = {"npc_name": "Kael", "speech": "test", "options": []} + GameState.dialogue_active = true + # Simulate what _teleport_transition does + GameState.current_dialogue = null + GameState.dialogue_active = false + assert_that(GameState.current_dialogue).is_null() + assert_that(GameState.dialogue_active).is_false() + + +# -- Send input integration (test mode) ---------------------------------------- + +func test_send_teleport_hub_in_test_mode() -> void: + # Verify send_input accepts TELEPORT_HUB in test mode + SimBridge.reset_test_state() + SimBridge.state = SimBridge.ConnectionState.CONNECTED + var err := SimBridge.send_input({ + "action": InputMapper.Action.TELEPORT_HUB, + "timestamp_msec": 12345, + }) + assert_that(err).is_equal(OK) + + +func test_send_teleport_hub_queues_wire_action() -> void: + # Verify TELEPORT_HUB is queued as "TeleportToHub" in test mode + SimBridge.reset_test_state() + SimBridge.state = SimBridge.ConnectionState.CONNECTED + SimBridge.send_input({ + "action": InputMapper.Action.TELEPORT_HUB, + "timestamp_msec": 12345, + }) + assert_that(SimBridge._test_input_queue.has("TeleportToHub")).is_true() + + +# -- Live mode outbound encoding ----------------------------------------------- + +func test_teleport_hub_outbound_entry() -> void: + # In live mode, TELEPORT_HUB should produce a valid outbound buffer entry + # (We can't test full live mode in unit tests, but we test the encode path) + var encoded: PackedByteArray = Protocol.encode_player_input(100, "TeleportToHub") + assert_that(encoded.size()).is_greater(0) + # Decode and verify + var decoded: Variant = Protocol.decode_player_input(encoded) + assert_that(decoded.action.variant).is_equal("TeleportToHub") From 58e592fd5e46e007021acfdda962dab86c78ed30 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 08:46:12 +0100 Subject: [PATCH 2/3] feat(client): confrontation D-033 color shift (#521, D-063) Entity renderer now tracks relationship per entity and tweens D-033 tint color over 0.7s when relationship changes (e.g. on confrontation delivery). Uses manual lerp in _process() for testability instead of SceneTree tweens. Cursor hover tint cascades automatically via Constants.color_for_entity_kind(). Co-Authored-By: Claude Opus 4.6 --- client/scripts/constants.gd | 13 +- client/scripts/rendering/entity_renderer.gd | 39 ++- client/tests/test_color_shift.gd | 338 ++++++++++++++++++++ 3 files changed, 386 insertions(+), 4 deletions(-) create mode 100644 client/tests/test_color_shift.gd diff --git a/client/scripts/constants.gd b/client/scripts/constants.gd index 9acda78e1..d8f66f72a 100644 --- a/client/scripts/constants.gd +++ b/client/scripts/constants.gd @@ -59,13 +59,22 @@ const ENTITY_COLOR_HOSTILE: Color = Color("#d45d5d") # Hostile/Dangerous — const ENTITY_COLOR_OBJECT: Color = Color("#8b8ba0") # Static objects — muted grey const ENTITY_COLOR_PLAYER: Color = Color("#e0e8ff") # Player character (detective) -# D-033 color lookup by entity kind (Phase 1: defaults, Phase 2 #361: relationship-based) +# D-033 color lookup by relationship string (#521) +static func color_for_relationship(relationship: String) -> Color: + match relationship: + "Friendly": return ENTITY_COLOR_FRIENDLY + "PersonOfInterest": return ENTITY_COLOR_POI + "Hostile": return ENTITY_COLOR_HOSTILE + "Unknown": return ENTITY_COLOR_UNKNOWN + _: return ENTITY_COLOR_UNKNOWN + +# D-033 color lookup by entity data — uses relationship for NPCs (#521) static func color_for_entity_kind(entity_data: Dictionary) -> Color: var kind_variant: String = entity_data.get("kind", {}).get("variant", "") match kind_variant: "Player": return ENTITY_COLOR_PLAYER - "Npc": return ENTITY_COLOR_UNKNOWN "Object", "Terrain": return ENTITY_COLOR_OBJECT + "Npc": return color_for_relationship(entity_data.get("relationship", "Unknown")) _: return ENTITY_COLOR_OBJECT # D-048/D-056: Insert-styled UI color palette diff --git a/client/scripts/rendering/entity_renderer.gd b/client/scripts/rendering/entity_renderer.gd index 9accd5809..5425888fa 100644 --- a/client/scripts/rendering/entity_renderer.gd +++ b/client/scripts/rendering/entity_renderer.gd @@ -23,6 +23,11 @@ const LERP_SPEED: float = 12.0 var entity_nodes: Dictionary = {} # entity_id -> Node2D var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel position) +var _entity_relationships: Dictionary = {} # #521: entity_id -> String (last relationship) +var _entity_tweens: Dictionary = {} # #521: entity_id -> {target: Color, elapsed: float} + +# #521: Color transition duration in seconds (D-033/D-063: 0.5-1s spec, 0.7s chosen) +const COLOR_FADE_DURATION: float = 0.7 func _ready() -> void: print("EntityRenderer: Initialized") @@ -40,6 +45,22 @@ func _process(delta: float) -> void: if not node.position.is_equal_approx(target): node.position = node.position.lerp(target, weight) + # #521: Advance color transitions (manual lerp, testable without SceneTree) + var finished_ids: Array = [] + for entity_id in _entity_tweens.keys(): + if not entity_nodes.has(entity_id): + finished_ids.append(entity_id) + continue + var tween_data: Dictionary = _entity_tweens[entity_id] + tween_data.elapsed += delta + var t := clampf(tween_data.elapsed / COLOR_FADE_DURATION, 0.0, 1.0) + var node_c: ColorRect = entity_nodes[entity_id] as ColorRect + node_c.color = tween_data.from.lerp(tween_data.target, t) + if t >= 1.0: + finished_ids.append(entity_id) + for eid in finished_ids: + _entity_tweens.erase(eid) + # Update entities from snapshot data func update_entities(entities: Array) -> void: @@ -75,12 +96,12 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void: entity_node.size = Vector2(ENTITY_SIZE, ENTITY_SIZE) entity_node.pivot_offset = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0) - # D-033 color by entity kind (Phase 1 default) - # TODO(#361): derive from RelationshipState via knowledge graph + # D-033 color by relationship (#521) entity_node.color = _color_for_kind(entity_data) add_child(entity_node) entity_nodes[entity_id] = entity_node + _entity_relationships[entity_id] = entity_data.get("relationship", "Unknown") # Add facing indicator for the player entity if entity_id == GameState.player_entity_id: @@ -112,6 +133,18 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void: floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET ) + # #521: Detect relationship change → fade D-033 color (0.7s via _process) + var new_rel: String = entity_data.get("relationship", "Unknown") + var old_rel: String = _entity_relationships.get(entity_id, "Unknown") + if new_rel != old_rel: + _entity_relationships[entity_id] = new_rel + var new_color := _color_for_kind(entity_data) + _entity_tweens[entity_id] = { + "from": entity_node.color, + "target": new_color, + "elapsed": 0.0, + } + # v2: Peripheral vision dimming (D-015) # null visibility (v1 backward compat) defaults to full alpha var visibility: Variant = entity_data.get("visibility") @@ -137,6 +170,8 @@ func _remove_entity_node(entity_id: int) -> void: entity_node.queue_free() entity_nodes.erase(entity_id) _entity_targets.erase(entity_id) + _entity_relationships.erase(entity_id) + _entity_tweens.erase(entity_id) # D-033 color by entity kind — delegates to Constants.color_for_entity_kind static func _color_for_kind(entity_data: Dictionary) -> Color: diff --git a/client/tests/test_color_shift.gd b/client/tests/test_color_shift.gd new file mode 100644 index 000000000..398fccd11 --- /dev/null +++ b/client/tests/test_color_shift.gd @@ -0,0 +1,338 @@ +## #521: Confrontation D-033 color shift — QA test suite +## Spec refs: D-033 (entity color = relationship), D-063 (confrontation same box) +## Sprint Completion Proof (joint.md): +## - Entity tint fades 0.5-1s to new relationship color on confrontation delivery +## - Cursor hover tint also updates to match relationship +## - Palette matches D-033 exactly +## +## Tests are structured in layers: +## 1. D-033 color palette constants — always pass (no implementation dependency) +## 2. Relationship-to-color mapping — tests the lookup function +## 3. Entity renderer relationship coloring — tests that entities USE relationship +## 4. Tween on relationship change — tests fade behavior (0.5-1s) +## 5. Protocol/GameState passthrough — tests data pipeline integrity +class_name TestColorShift +extends GdUnitTestSuite + +var EntityRendererScript: GDScript = load("res://scripts/rendering/entity_renderer.gd") +var ConstantsScript: GDScript = load("res://scripts/constants.gd") + +# -- Test data ----------------------------------------------------------------- + +# Entity with relationship field (v4 protocol format) +func _make_entity(entity_id: int, kind: String, relationship: String, x: float = 5.0, y: float = 5.0) -> Dictionary: + return { + "entity_id": entity_id, + "x": x, "y": y, "z": 0, + "kind": {"variant": kind, "data": null}, + "visibility": "Forward", + "relationship": relationship, + "observation": "Visible", + } + +func _make_entity_renderer() -> Node2D: + var renderer: Node2D = Node2D.new() + renderer.set_script(EntityRendererScript) + add_child(renderer) + return renderer + + +func before_test() -> void: + GameState.player_entity_id = 1 + + +# ============================================================================== +# Layer 1: D-033 Color Palette Constants +# These tests verify the palette is defined correctly. No implementation needed. +# ============================================================================== + +func test_d033_unknown_teal() -> void: + assert_that(Constants.ENTITY_COLOR_UNKNOWN).is_equal(Color("#4a9ebb")) + +func test_d033_friendly_green() -> void: + assert_that(Constants.ENTITY_COLOR_FRIENDLY).is_equal(Color("#6bc9a6")) + +func test_d033_poi_amber() -> void: + assert_that(Constants.ENTITY_COLOR_POI).is_equal(Color("#e8c547")) + +func test_d033_hostile_red() -> void: + assert_that(Constants.ENTITY_COLOR_HOSTILE).is_equal(Color("#d45d5d")) + +func test_d033_object_grey() -> void: + assert_that(Constants.ENTITY_COLOR_OBJECT).is_equal(Color("#8b8ba0")) + +func test_d033_player_cool_white() -> void: + assert_that(Constants.ENTITY_COLOR_PLAYER).is_equal(Color("#e0e8ff")) + +func test_d033_palette_all_distinct() -> void: + # All 6 D-033 colors must be distinct from each other + var colors: Array[Color] = [ + Constants.ENTITY_COLOR_UNKNOWN, + Constants.ENTITY_COLOR_FRIENDLY, + Constants.ENTITY_COLOR_POI, + Constants.ENTITY_COLOR_HOSTILE, + Constants.ENTITY_COLOR_OBJECT, + Constants.ENTITY_COLOR_PLAYER, + ] + for i in range(colors.size()): + for j in range(i + 1, colors.size()): + assert_that(colors[i] != colors[j]).is_true() + + +# ============================================================================== +# Layer 2: Relationship-to-Color Mapping +# Tests the lookup function that maps relationship strings to D-033 colors. +# Depends on #521 adding color_for_relationship() to Constants. +# Uses ConstantsScript method list to skip gracefully if not yet implemented. +# ============================================================================== + +func _has_color_for_relationship() -> bool: + # Check if the Constants script has a color_for_relationship method. + for method in ConstantsScript.get_script_method_list(): + if method.name == "color_for_relationship": + return true + return false + +func test_relationship_color_unknown() -> void: + if not _has_color_for_relationship(): + push_warning("TestColorShift: color_for_relationship not implemented yet — awaiting #521") + return + var color: Color = Constants.color_for_relationship("Unknown") + assert_that(color).is_equal(Constants.ENTITY_COLOR_UNKNOWN) + +func test_relationship_color_friendly() -> void: + if not _has_color_for_relationship(): + return + var color: Color = Constants.color_for_relationship("Friendly") + assert_that(color).is_equal(Constants.ENTITY_COLOR_FRIENDLY) + +func test_relationship_color_person_of_interest() -> void: + if not _has_color_for_relationship(): + return + var color: Color = Constants.color_for_relationship("PersonOfInterest") + assert_that(color).is_equal(Constants.ENTITY_COLOR_POI) + +func test_relationship_color_hostile() -> void: + if not _has_color_for_relationship(): + return + var color: Color = Constants.color_for_relationship("Hostile") + assert_that(color).is_equal(Constants.ENTITY_COLOR_HOSTILE) + +func test_relationship_color_fallback() -> void: + if not _has_color_for_relationship(): + return + var color: Color = Constants.color_for_relationship("SomethingWeird") + assert_that(color).is_equal(Constants.ENTITY_COLOR_UNKNOWN) + + +# ============================================================================== +# Layer 3: Entity Renderer — Relationship-Based Coloring +# Tests that entity_renderer uses the relationship field for NPC colors. +# Player and Object entities should remain unaffected by relationship field. +# ============================================================================== + +func test_npc_uses_relationship_color_unknown() -> void: + var renderer: Node2D = _make_entity_renderer() + var entities: Array = [_make_entity(2, "Npc", "Unknown")] + renderer.update_entities(entities) + var node: ColorRect = renderer.entity_nodes[2] as ColorRect + # Unknown -> teal (both Phase 1 and Phase 2 produce the same result) + assert_that(node.color).is_equal(Constants.ENTITY_COLOR_UNKNOWN) + renderer.queue_free() + +func test_npc_uses_relationship_color_friendly() -> void: + var renderer: Node2D = _make_entity_renderer() + var entities: Array = [_make_entity(2, "Npc", "Friendly")] + renderer.update_entities(entities) + var node: ColorRect = renderer.entity_nodes[2] as ColorRect + if not _entity_uses_relationship(renderer): + push_warning("TestColorShift: entity renderer not yet using relationship for color — awaiting #521") + renderer.queue_free() + return + assert_that(node.color).is_equal(Constants.ENTITY_COLOR_FRIENDLY) + renderer.queue_free() + +func test_npc_uses_relationship_color_poi() -> void: + var renderer: Node2D = _make_entity_renderer() + var entities: Array = [_make_entity(2, "Npc", "PersonOfInterest")] + renderer.update_entities(entities) + var node: ColorRect = renderer.entity_nodes[2] as ColorRect + if not _entity_uses_relationship(renderer): + renderer.queue_free() + return + assert_that(node.color).is_equal(Constants.ENTITY_COLOR_POI) + renderer.queue_free() + +func test_npc_uses_relationship_color_hostile() -> void: + var renderer: Node2D = _make_entity_renderer() + var entities: Array = [_make_entity(2, "Npc", "Hostile")] + renderer.update_entities(entities) + var node: ColorRect = renderer.entity_nodes[2] as ColorRect + if not _entity_uses_relationship(renderer): + renderer.queue_free() + return + assert_that(node.color).is_equal(Constants.ENTITY_COLOR_HOSTILE) + renderer.queue_free() + +func test_player_color_ignores_relationship() -> void: + # Player entity always uses ENTITY_COLOR_PLAYER regardless of relationship + var renderer: Node2D = _make_entity_renderer() + var entities: Array = [_make_entity(1, "Player", "Hostile")] + renderer.update_entities(entities) + var node: ColorRect = renderer.entity_nodes[1] as ColorRect + assert_that(node.color).is_equal(Constants.ENTITY_COLOR_PLAYER) + renderer.queue_free() + +func test_object_color_ignores_relationship() -> void: + # Object entities always use ENTITY_COLOR_OBJECT + var renderer: Node2D = _make_entity_renderer() + var entities: Array = [_make_entity(3, "Object", "Friendly")] + renderer.update_entities(entities) + var node: ColorRect = renderer.entity_nodes[3] as ColorRect + assert_that(node.color).is_equal(Constants.ENTITY_COLOR_OBJECT) + renderer.queue_free() + + +# ============================================================================== +# Layer 4: Tween on Relationship Change +# Tests that color transitions use a 0.5-1s fade, not an instant flip. +# D-033: "Color shifts smoothly (0.5s fade) when relationship state changes." +# D-063: "entity D-033 color may fade" on confrontation delivery. +# ============================================================================== + +func test_color_shift_not_instant() -> void: + var renderer: Node2D = _make_entity_renderer() + var entities_before: Array = [_make_entity(2, "Npc", "Friendly")] + renderer.update_entities(entities_before) + var node: ColorRect = renderer.entity_nodes[2] as ColorRect + if not _entity_uses_relationship(renderer): + renderer.queue_free() + return + # Change relationship to Hostile + var entities_after: Array = [_make_entity(2, "Npc", "Hostile")] + renderer.update_entities(entities_after) + # Immediately after update, color should NOT yet be the target + var color_after_immediate: Color = node.color + if not _renderer_has_tween_support(renderer): + push_warning("TestColorShift: tween on relationship change not implemented yet — awaiting #521") + renderer.queue_free() + return + # The color should NOT be exactly the target yet (tween in progress) + assert_that(color_after_immediate != Constants.ENTITY_COLOR_HOSTILE).is_true() + renderer.queue_free() + +func test_color_shift_reaches_target() -> void: + var renderer: Node2D = _make_entity_renderer() + if not _entity_uses_relationship(renderer): + renderer.queue_free() + return + if not _renderer_has_tween_support(renderer): + renderer.queue_free() + return + renderer.update_entities([_make_entity(2, "Npc", "Friendly")]) + renderer.update_entities([_make_entity(2, "Npc", "Hostile")]) + # Simulate time passing: ~1.5 seconds of frames + var elapsed: float = 0.0 + while elapsed < 1.5: + renderer._process(1.0 / 60.0) + elapsed += 1.0 / 60.0 + var node: ColorRect = renderer.entity_nodes[2] as ColorRect + assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true() + renderer.queue_free() + +func test_color_shift_same_relationship_no_tween() -> void: + var renderer: Node2D = _make_entity_renderer() + if not _entity_uses_relationship(renderer): + renderer.queue_free() + return + var entities: Array = [_make_entity(2, "Npc", "Unknown")] + renderer.update_entities(entities) + var node: ColorRect = renderer.entity_nodes[2] as ColorRect + var color_first: Color = node.color + renderer.update_entities(entities) + var color_second: Color = node.color + assert_that(color_first).is_equal(color_second) + renderer.queue_free() + + +# ============================================================================== +# Layer 5: Protocol — Relationship Field Round-Trip +# ============================================================================== + +func test_protocol_entity_relationship_decoded() -> void: + var raw: Dictionary = { + "entity_id": 5, + "x": 10.0, "y": 10.0, "z": 0, + "kind": "Npc", + "relationship": "PersonOfInterest", + "visibility": "Forward", + "observation": "Visible", + } + var decoded: Variant = Protocol._decode_entity(raw) + assert_that(decoded).is_not_null() + assert_that(decoded.relationship).is_equal("PersonOfInterest") + +func test_protocol_entity_relationship_defaults_unknown() -> void: + var raw: Dictionary = { + "entity_id": 5, + "x": 10.0, "y": 10.0, "z": 0, + "kind": "Npc", + } + var decoded: Variant = Protocol._decode_entity(raw) + assert_that(decoded).is_not_null() + assert_that(decoded.relationship).is_equal("Unknown") + +func test_protocol_entity_all_relationship_values() -> void: + var relationships: Array[String] = ["Unknown", "Friendly", "PersonOfInterest", "Hostile"] + for rel in relationships: + var raw: Dictionary = { + "entity_id": 5, + "x": 10.0, "y": 10.0, "z": 0, + "kind": "Npc", + "relationship": rel, + } + var decoded: Variant = Protocol._decode_entity(raw) + assert_that(decoded).is_not_null() + assert_that(decoded.relationship).is_equal(rel) + + +# ============================================================================== +# Layer 6: GameState — Relationship Data Passthrough +# ============================================================================== + +func test_game_state_preserves_relationship() -> void: + var snapshot: Dictionary = { + "tick": 1, + "entities": [_make_entity(2, "Npc", "Friendly")], + } + GameState.apply_snapshot(snapshot) + assert_that(GameState.visible_entities.size()).is_equal(1) + assert_that(GameState.visible_entities[0].relationship).is_equal("Friendly") + +func test_game_state_relationship_changes_between_snapshots() -> void: + GameState.apply_snapshot({"tick": 1, "entities": [_make_entity(2, "Npc", "Friendly")]}) + assert_that(GameState.visible_entities[0].relationship).is_equal("Friendly") + GameState.apply_snapshot({"tick": 2, "entities": [_make_entity(2, "Npc", "PersonOfInterest")]}) + assert_that(GameState.visible_entities[0].relationship).is_equal("PersonOfInterest") + + +# ============================================================================== +# Helpers +# ============================================================================== + +func _entity_uses_relationship(renderer: Node2D) -> bool: + var friendly: Dictionary = _make_entity(10, "Npc", "Friendly", 3.0, 3.0) + var hostile: Dictionary = _make_entity(11, "Npc", "Hostile", 5.0, 5.0) + renderer.update_entities([friendly, hostile]) + if not renderer.entity_nodes.has(10) or not renderer.entity_nodes.has(11): + return false + var f_node: ColorRect = renderer.entity_nodes[10] as ColorRect + var h_node: ColorRect = renderer.entity_nodes[11] as ColorRect + var f_color: Color = f_node.color + var h_color: Color = h_node.color + var uses_rel: bool = not f_color.is_equal_approx(h_color) + renderer.update_entities([]) + return uses_rel + +func _renderer_has_tween_support(renderer: Node2D) -> bool: + return renderer.get("_entity_tweens") != null From 7640a9ab8788e717497df2b5e5d437a66a19e158 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 09:06:42 +0100 Subject: [PATCH 3/3] =?UTF-8?q?fix(client):=20address=20PR=20#38=20review?= =?UTF-8?q?=20=E2=80=94=206=20warnings=20+=204=20suggestions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hoshe: - COLOR_FADE_DURATION 0.7 → 0.5 to match D-033 spec ("0.5s fade") - Gauntlet guard tests now exercise InputMapper._unhandled_input() with synthesized InputEventKey instead of asserting a bool - Buffer clearing tests use SimBridge pipeline instead of manual nulls - Add mid-transition re-trigger test (rapid relationship changes) - Add relationship field to test snapshot NPC Tyre: - Add _teleport_in_progress flag to defer smoothing re-enable by one frame after teleport (prevents same-_process() re-enable race) - Add _test_gauntlet_mode to SimBridge test snapshot - Extract TELEPORT_DISTANCE_THRESHOLD constant, mirror in tests - Add comments: flash preemption, modulate/color independence - Rename "hub teleport" → "Gauntlet dev teleport" in code comments to clarify this is not production fast-travel Co-Authored-By: Claude Opus 4.6 --- client/scripts/autoloads/input_mapper.gd | 2 +- client/scripts/autoloads/sim_bridge.gd | 8 +- client/scripts/main.gd | 25 +++-- client/scripts/rendering/entity_renderer.gd | 10 +- client/tests/test_color_shift.gd | 28 ++++++ client/tests/test_hub_teleport.gd | 106 +++++++++++++------- 6 files changed, 129 insertions(+), 50 deletions(-) diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index a5fcc077b..9a51eee07 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -20,7 +20,7 @@ enum Action { TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN, BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server SET_FACING, # D-054: facing octant update (no movement) - TELEPORT_HUB, # #501: Home key — teleport to hub (Gauntlet-only) + TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel) } var input_queue: Array[Dictionary] = [] diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 1b75e8e5e..4c7c8b98b 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -10,6 +10,8 @@ var _test_player_pos: Vector2i = Vector2i(10, 10) var _test_facing: String = "North" var _test_input_queue: Array = [] # Queued actions for test mode var _test_in_dialogue: bool = false # Mock dialogue state (#434) +var _test_gauntlet_mode: bool = false # #501: Gauntlet mode for dev teleport guard +var _test_npc_relationship: String = "Unknown" # #521: NPC relationship for D-033 color var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot) var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport @@ -40,6 +42,8 @@ func reset_test_state() -> void: _test_facing = "North" _test_input_queue.clear() _test_in_dialogue = false + _test_gauntlet_mode = false + _test_npc_relationship = "Unknown" # Change connection state and emit signal func _set_state(new_state: ConnectionState) -> void: @@ -270,7 +274,7 @@ static func _action_enum_to_wire(action: int) -> String: InputMapper.Action.SET_FACING: return "SetFacing" # D-054: facing octant update (no movement) InputMapper.Action.TELEPORT_HUB: - return "TeleportToHub" # #501: hub teleport (Gauntlet-only) + return "TeleportToHub" # #501: Gauntlet dev teleport (not production fast-travel) _: push_warning("SimBridge: unknown action enum %s" % action) return "" @@ -333,6 +337,7 @@ func _test_snapshot() -> Dictionary: "z": 0, "kind": { "variant": "Npc", "data": null }, "visibility": sector, + "relationship": _test_npc_relationship, }) # v4: nearby_interactions when NPC is nearby and visible (#404/#405) @@ -411,6 +416,7 @@ func _test_snapshot() -> Dictionary: "current_monologue": monologue, "current_dialogue": dialogue, "pending_recognitions": pending_recs, + "gauntlet_mode": _test_gauntlet_mode, } # Generate a small test room: 8x6 room with walls, a door, and floor diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 75d2b3766..8368bb2a5 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -20,7 +20,8 @@ var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input var _camera_anchored: bool = false var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice var _last_dialogue_tick: int = -1 -var _flash_rect: ColorRect = null # #502: ephemeral screen flash overlay +var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber) +var _teleport_in_progress: bool = false # #501: defer smoothing re-enable by one frame after teleport func _ready() -> void: print("The Settled Reach — client initialized") @@ -123,9 +124,15 @@ func _process(_delta: float) -> void: # rendered used smoothing=OFF (correct viewport from frame one). Now we # turn smoothing back on and sync its internal state so subsequent frames # get smooth camera tracking during gameplay. + # #501: Skip re-enable during teleport — _teleport_transition() disables + # smoothing for a clean camera snap. Defer by one frame to avoid the + # re-enable block in the same _process() call undoing the snap. if _camera_anchored and not camera.position_smoothing_enabled: - camera.position_smoothing_enabled = true - camera.reset_smoothing() + if _teleport_in_progress: + _teleport_in_progress = false + else: + camera.position_smoothing_enabled = true + camera.reset_smoothing() # Send queued input to simulation var inputs = InputMapper.flush_queue() @@ -236,17 +243,23 @@ func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_stat # #501: Detect large position jump indicating a teleport (not normal movement). +const TELEPORT_DISTANCE_THRESHOLD: float = 5.0 + func _detect_teleport(old_pos: Vector2, new_pos: Vector2) -> bool: - return old_pos.distance_to(new_pos) > 5.0 + return old_pos.distance_to(new_pos) > TELEPORT_DISTANCE_THRESHOLD -# #501: Hub teleport transition — snap camera + 0.3s fade-from-black. +# #501: Gauntlet dev teleport transition — snap camera + 0.3s fade-from-black. # Clears dialogue/monologue/interaction state (server clears its side too). +# Scoped to Gauntlet testing only — production fast-travel uses diegetic gates. func _teleport_transition() -> void: - # Snap camera: disable smoothing, force re-anchor + # Snap camera: disable smoothing, force re-anchor. + # _teleport_in_progress defers smoothing re-enable by one frame so the + # re-enable block at the bottom of _process() doesn't undo the snap. camera.position_smoothing_enabled = false camera.global_position = GameState.player_position * Constants.TILE_SIZE _camera_anchored = true + _teleport_in_progress = true # Clear client-side buffers GameState.current_monologue = null diff --git a/client/scripts/rendering/entity_renderer.gd b/client/scripts/rendering/entity_renderer.gd index 5425888fa..61ff8e0d2 100644 --- a/client/scripts/rendering/entity_renderer.gd +++ b/client/scripts/rendering/entity_renderer.gd @@ -26,8 +26,8 @@ var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel positi var _entity_relationships: Dictionary = {} # #521: entity_id -> String (last relationship) var _entity_tweens: Dictionary = {} # #521: entity_id -> {target: Color, elapsed: float} -# #521: Color transition duration in seconds (D-033/D-063: 0.5-1s spec, 0.7s chosen) -const COLOR_FADE_DURATION: float = 0.7 +# #521: Color transition duration in seconds (D-033: "0.5s fade") +const COLOR_FADE_DURATION: float = 0.5 func _ready() -> void: print("EntityRenderer: Initialized") @@ -133,7 +133,7 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void: floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET ) - # #521: Detect relationship change → fade D-033 color (0.7s via _process) + # #521: Detect relationship change → fade D-033 color (0.5s via _process) var new_rel: String = entity_data.get("relationship", "Unknown") var old_rel: String = _entity_relationships.get(entity_id, "Unknown") if new_rel != old_rel: @@ -145,6 +145,10 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void: "elapsed": 0.0, } + # Note: modulate.a (peripheral dimming below) and color (D-033 tint above) + # are compositionally independent — both can change simultaneously without + # interference. If alpha tweening is added later, coordinate with color tween. + # v2: Peripheral vision dimming (D-015) # null visibility (v1 backward compat) defaults to full alpha var visibility: Variant = entity_data.get("visibility") diff --git a/client/tests/test_color_shift.gd b/client/tests/test_color_shift.gd index 398fccd11..e619ab3ad 100644 --- a/client/tests/test_color_shift.gd +++ b/client/tests/test_color_shift.gd @@ -240,6 +240,34 @@ func test_color_shift_reaches_target() -> void: assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true() renderer.queue_free() +func test_color_shift_mid_transition_retrigger() -> void: + # Rapid relationship changes: Unknown → Friendly → Hostile in quick succession. + # The second change should preempt the first tween and converge to Hostile. + var renderer: Node2D = _make_entity_renderer() + if not _entity_uses_relationship(renderer): + renderer.queue_free() + return + if not _renderer_has_tween_support(renderer): + renderer.queue_free() + return + renderer.update_entities([_make_entity(2, "Npc", "Unknown")]) + # First change: Unknown → Friendly + renderer.update_entities([_make_entity(2, "Npc", "Friendly")]) + # Advance partway (0.1s of a 0.5s tween) + for i in range(6): + renderer._process(1.0 / 60.0) + # Second change mid-tween: Friendly → Hostile (preempts first) + renderer.update_entities([_make_entity(2, "Npc", "Hostile")]) + # Advance past full duration + var elapsed: float = 0.0 + while elapsed < 1.0: + renderer._process(1.0 / 60.0) + elapsed += 1.0 / 60.0 + var node: ColorRect = renderer.entity_nodes[2] as ColorRect + assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true() + renderer.queue_free() + + func test_color_shift_same_relationship_no_tween() -> void: var renderer: Node2D = _make_entity_renderer() if not _entity_uses_relationship(renderer): diff --git a/client/tests/test_hub_teleport.gd b/client/tests/test_hub_teleport.gd index 3a0d145a0..12ce5f24b 100644 --- a/client/tests/test_hub_teleport.gd +++ b/client/tests/test_hub_teleport.gd @@ -73,20 +73,34 @@ func test_teleport_hub_action_exists() -> void: # -- InputMapper: Gauntlet mode guard ----------------------------------------- -func test_teleport_hub_gated_by_gauntlet_mode() -> void: - # Non-gauntlet: Home key should NOT queue TELEPORT_HUB +func test_teleport_hub_blocked_outside_gauntlet() -> void: + # Non-gauntlet: Home key input should NOT queue TELEPORT_HUB GameState.gauntlet_mode = false - # Simulate what _unhandled_input does: check gauntlet_mode before queuing - # (We test the guard logic, not the full input event pipeline) - var should_queue := GameState.gauntlet_mode - assert_that(should_queue).is_false() + InputMapper.input_queue.clear() + var event := InputEventKey.new() + event.physical_keycode = KEY_HOME + event.pressed = true + InputMapper._unhandled_input(event) + var has_teleport := false + for entry in InputMapper.input_queue: + if entry.action == InputMapper.Action.TELEPORT_HUB: + has_teleport = true + assert_that(has_teleport).is_false() func test_teleport_hub_allowed_in_gauntlet_mode() -> void: # Gauntlet mode: Home key SHOULD queue TELEPORT_HUB GameState.gauntlet_mode = true - var should_queue := GameState.gauntlet_mode - assert_that(should_queue).is_true() + InputMapper.input_queue.clear() + var event := InputEventKey.new() + event.physical_keycode = KEY_HOME + event.pressed = true + InputMapper._unhandled_input(event) + var has_teleport := false + for entry in InputMapper.input_queue: + if entry.action == InputMapper.Action.TELEPORT_HUB: + has_teleport = true + assert_that(has_teleport).is_true() # -- GameState: gauntlet_mode from snapshot ------------------------------------ @@ -161,67 +175,70 @@ func test_test_mode_teleport_clears_dialogue() -> void: # -- Teleport detection ------------------------------------------------------- +# Threshold constant lives on the main scene node (TELEPORT_DISTANCE_THRESHOLD = 5.0). +# These tests verify the distance math against that threshold. + +const _THRESHOLD: float = 5.0 # Mirror of main.gd TELEPORT_DISTANCE_THRESHOLD func test_detect_teleport_large_jump() -> void: - # Position jump > 5 tiles should be detected as teleport - # _detect_teleport is a method on the main scene — test the math directly + # Position jump > threshold should be detected as teleport var old_pos := Vector2(10.0, 10.0) var new_pos := Vector2(50.0, 50.0) - var distance := old_pos.distance_to(new_pos) - assert_that(distance > 5.0).is_true() + assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_true() func test_detect_teleport_normal_movement() -> void: # Normal 1-tile movement should NOT be detected as teleport var old_pos := Vector2(10.0, 10.0) var new_pos := Vector2(11.0, 10.0) - var distance := old_pos.distance_to(new_pos) - assert_that(distance > 5.0).is_false() + assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_false() func test_detect_teleport_diagonal_movement() -> void: # Diagonal movement (1,1) — distance ~1.41, not a teleport var old_pos := Vector2(10.0, 10.0) var new_pos := Vector2(11.0, 11.0) - var distance := old_pos.distance_to(new_pos) - assert_that(distance > 5.0).is_false() + assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_false() -func test_detect_teleport_boundary_exactly_five() -> void: - # Exactly 5.0 tiles — should NOT trigger (threshold is > 5.0, not >=) +func test_detect_teleport_boundary_exactly_threshold() -> void: + # Exactly threshold — should NOT trigger (> not >=) var old_pos := Vector2(10.0, 10.0) var new_pos := Vector2(15.0, 10.0) - var distance := old_pos.distance_to(new_pos) - assert_that(distance > 5.0).is_false() + assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_false() -func test_detect_teleport_boundary_just_over_five() -> void: - # 5.1 tiles — should trigger +func test_detect_teleport_boundary_just_over() -> void: + # Just over threshold — should trigger var old_pos := Vector2(10.0, 10.0) var new_pos := Vector2(15.1, 10.0) - var distance := old_pos.distance_to(new_pos) - assert_that(distance > 5.0).is_true() + assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_true() # -- Buffer clearing on teleport ----------------------------------------------- -func test_teleport_clears_monologue_state() -> void: - # Teleport transition must clear current_monologue - GameState.current_monologue = {"id": "test_mono", "text": "test", "duration_seconds": 5.0} - # Simulate what _teleport_transition does - GameState.current_monologue = null - assert_that(GameState.current_monologue).is_null() +func test_teleport_clears_dialogue_in_test_mode() -> void: + # TeleportToHub in test mode should clear dialogue state via the pipeline + SimBridge.reset_test_state() + SimBridge._test_in_dialogue = true + SimBridge._test_input_queue.append("TeleportToHub") + var snap: Dictionary = SimBridge._test_snapshot() + # Dialogue should be cleared by teleport + assert_that(SimBridge._test_in_dialogue).is_false() + assert_that(snap.current_dialogue).is_null() -func test_teleport_clears_dialogue_state() -> void: - # Teleport transition must clear current_dialogue and dialogue_active - GameState.current_dialogue = {"npc_name": "Kael", "speech": "test", "options": []} - GameState.dialogue_active = true - # Simulate what _teleport_transition does - GameState.current_dialogue = null - GameState.dialogue_active = false - assert_that(GameState.current_dialogue).is_null() - assert_that(GameState.dialogue_active).is_false() +func test_teleport_resets_position_in_test_mode() -> void: + # TeleportToHub must reset to hub spawn and clear dialogue (integration) + SimBridge.reset_test_state() + SimBridge._test_player_pos = Vector2i(50, 50) + SimBridge._test_in_dialogue = true + SimBridge._test_input_queue.append("TeleportToHub") + var snap: Dictionary = SimBridge._test_snapshot() + var player: Dictionary = snap.entities[0] + assert_that(player.x).is_equal(10.0) + assert_that(player.y).is_equal(10.0) + assert_that(SimBridge._test_in_dialogue).is_false() # -- Send input integration (test mode) ---------------------------------------- @@ -248,6 +265,17 @@ func test_send_teleport_hub_queues_wire_action() -> void: assert_that(SimBridge._test_input_queue.has("TeleportToHub")).is_true() +func test_gauntlet_mode_from_test_snapshot() -> void: + # Verify test snapshot includes gauntlet_mode field + SimBridge.reset_test_state() + SimBridge._test_gauntlet_mode = true + var snap: Dictionary = SimBridge._test_snapshot() + assert_that(snap.gauntlet_mode).is_true() + SimBridge._test_gauntlet_mode = false + snap = SimBridge._test_snapshot() + assert_that(snap.gauntlet_mode).is_false() + + # -- Live mode outbound encoding ----------------------------------------------- func test_teleport_hub_outbound_entry() -> void: