From 1435a77dccad14bc64260fdd605acfe29d13ad1d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 02:14:29 +0100 Subject: [PATCH 1/6] fix(client): fix MessagePack int_64 encoder dead code branch (#516) The int_64 branch condition `-(1 << 63) <= value` overflowed in GDScript's signed 64-bit arithmetic, making the branch unreachable. Negative values beyond int_32 range were incorrectly encoded as uint_64 (0xcf) instead of int_64 (0xd3). Replaced with `value < 0`. Updated boundary tests BV-N15 and BV-N16 to expect correct int_64 header byte. Co-Authored-By: Claude Opus 4.6 --- client/addons/messagepack/messagepack.gd | 2 +- client/tests/test_msgpack_boundaries.gd | 18 ++++++------------ 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/client/addons/messagepack/messagepack.gd b/client/addons/messagepack/messagepack.gd index 171c8a8f9..5ada09950 100755 --- a/client/addons/messagepack/messagepack.gd +++ b/client/addons/messagepack/messagepack.gd @@ -87,7 +87,7 @@ static func _encode_message(buffer: StreamPeerBuffer, value): elif 0 <= value and value <= (1 << 32) - 1: buffer.put_u8(types["uint_32"]) buffer.put_u32(value) - elif - (1 << 63) <= value and value < (1 << 63): + elif value < 0: buffer.put_u8(types["int_64"]) buffer.put_64(value) else: diff --git a/client/tests/test_msgpack_boundaries.gd b/client/tests/test_msgpack_boundaries.gd index a2b9822d1..eb1501fd0 100644 --- a/client/tests/test_msgpack_boundaries.gd +++ b/client/tests/test_msgpack_boundaries.gd @@ -85,13 +85,8 @@ func test_encode_uint32() -> void: func test_encode_int64_positive() -> void: - # BV-P24 to BV-P25: int 64 range (2^32 to 2^63-1) - # KNOWN-DEFECT: The encoder's int_64 branch condition `-(1 << 63) <= v < (1 << 63)` - # evaluates to `MIN_INT64 <= v < MIN_INT64` due to overflow, making it dead code. - # Values that should be int_64 (0xd3) are instead encoded as uint_64 (0xcf). - # Roundtrip still works because put_u64/get_u64 preserve the bit pattern. - # This test documents ACTUAL behavior. Fix tracked in backlog. - # See: messagepack.gd int_64 branch — use explicit constant instead of `1 << 63`. + # BV-P24 to BV-P25: positive values > uint_32 max → uint_64 (0xcf) + # Positive values correctly use uint_64 encoding for Rust interop. _assert_encodes_to(4294967296, PackedByteArray([ 0xcf, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00 ]), "BV-P24") @@ -138,16 +133,15 @@ func test_encode_int32_negative() -> void: func test_encode_int64_negative() -> void: # BV-N15 to BV-N16: int 64 range (< -2147483648) - # KNOWN-DEFECT: Same int_64 branch issue as positive int_64 — encoded as uint_64 (0xcf). - # Bit pattern is preserved: put_u64(negative) writes two's complement, - # get_u64() reads it back and Variant stores as int64 with same bit pattern. + # Negative values beyond int_32 now correctly encode as int_64 (0xd3). + # Fixed: encoder's int_64 branch used `-(1 << 63)` which overflowed to dead code. _assert_encodes_to(-2147483649, PackedByteArray([ - 0xcf, 0xff, 0xff, 0xff, 0xff, 0x7f, 0xff, 0xff, 0xff + 0xd3, 0xff, 0xff, 0xff, 0xff, 0x7f, 0xff, 0xff, 0xff ]), "BV-N15") # MIN_INT64: -9223372036854775808 var min_int64: int = -9223372036854775807 - 1 _assert_encodes_to(min_int64, PackedByteArray([ - 0xcf, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + 0xd3, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 ]), "BV-N16") From 582060959478196e19bbe7f9b95dcc829507e7ba Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 02:14:37 +0100 Subject: [PATCH 2/6] =?UTF-8?q?feat(client):=20add=2016=20P2=20tests=20?= =?UTF-8?q?=E2=80=94=20camera,=20entity=20alpha/color,=20UI=20(#492)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gdUnit4 tests covering: - Camera (5): zoom default, smoothing convergence, viewport tracking, follow movement, no-panning lock per D-014 - Entity alpha/color (4): null visibility default, peripheral/forward transition, terrain object color, player color constant - UI (7): monologue visible/hidden, interaction verbs, inventory grid, dialogue overlay, pause indicator, fog blob count, fog z_index Co-Authored-By: Claude Opus 4.6 --- client/tests/test_client_p2.gd | 313 +++++++++++++++++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 client/tests/test_client_p2.gd diff --git a/client/tests/test_client_p2.gd b/client/tests/test_client_p2.gd new file mode 100644 index 000000000..b64e8d81e --- /dev/null +++ b/client/tests/test_client_p2.gd @@ -0,0 +1,313 @@ +## P2 client tests: camera (5), entity alpha/color (4), UI (7). +## Validates camera tracking, entity visual state, and UI component lifecycle. +## Spec ref: sprint-9/client.md #492. +class_name TestClientP2 +extends GdUnitTestSuite + +var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd") +var FogEntitiesScript = load("res://scripts/rendering/fog_entities.gd") + +var _instance: Node = null + + +func before_test() -> void: + SimBridge.reset_test_state() + SimBridge._last_snapshot = null + GameState.current_tick = 0 + GameState.player_position = Vector2.ZERO + GameState.visible_entities = [] + GameState.visible_tiles = [] + GameState.visible_positions = {} + GameState.current_monologue = null + GameState.current_dialogue = null + GameState.game_time = {} + GameState.pending_recognitions = [] + GameState.nearby_interactions = [] + GameState.player_inventory = [] + GameState.player_stance = "Walk" + GameState.dialogue_active = false + + +func after_test() -> void: + if _instance and is_instance_valid(_instance): + _instance.queue_free() + _instance = null + + +# -- Helpers ------------------------------------------------------------------- + +func _make_scene() -> Node: + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + return _instance + + +func _make_entity_renderer() -> Node2D: + var renderer = Node2D.new() + renderer.set_script(EntityRendererScript) + add_child(renderer) + return renderer + + +func _make_fog_entities() -> Node2D: + var node = Node2D.new() + node.set_script(FogEntitiesScript) + add_child(node) + return node + + +# -- Camera (5) ---------------------------------------------------------------- + +func test_camera_zoom_default_2x() -> void: + # P2-C01: Camera zoom should be 2x as set in main.tscn. + var inst := _make_scene() + var camera: Camera2D = inst.get_node("Camera2D") + assert_that(camera.zoom).is_equal(Vector2(2, 2)) + + +func test_camera_smoothing_convergence() -> void: + # P2-C02: After first _process, smoothing re-enables for gameplay feel. + # After several frames, camera position should still match player position + # (smoothing converges because target == position when stationary). + var inst := _make_scene() + var camera: Camera2D = inst.get_node("Camera2D") + # First frame re-enables smoothing + inst._process(0.016) + assert_that(camera.position_smoothing_enabled).is_true() + # Several more frames — stationary player, camera converges + for i in 5: + inst._process(0.016) + var expected := GameState.player_position * Constants.TILE_SIZE + assert_that(camera.global_position).is_equal(expected) + + +func test_camera_viewport_tracks_player_position() -> void: + # P2-C03: Camera position always equals player_position * TILE_SIZE. + # No offset, no viewport clamping beyond player tracking. + var inst := _make_scene() + var camera: Camera2D = inst.get_node("Camera2D") + var expected := GameState.player_position * Constants.TILE_SIZE + assert_that(camera.global_position).override_failure_message( + "Camera must track player position exactly" + ).is_equal(expected) + + +func test_camera_follows_player_after_movement() -> void: + # P2-C04: After player moves, camera position updates to new player position. + var inst := _make_scene() + var camera: Camera2D = inst.get_node("Camera2D") + var initial_pos := camera.global_position + # Move player south via SimBridge test mode + SimBridge._test_input_queue.append("MoveSouth") + inst._process(0.016) + # Camera should have moved with player + assert_that(camera.global_position.y > initial_pos.y).override_failure_message( + "Camera Y should increase after moving south" + ).is_true() + assert_that(camera.global_position).is_equal( + GameState.player_position * Constants.TILE_SIZE) + + +func test_camera_no_panning_locked_to_player() -> void: + # P2-C05 (D-014): Camera is locked to player — no panning. + # Camera position equals player_position * TILE_SIZE every frame. + var inst := _make_scene() + var camera: Camera2D = inst.get_node("Camera2D") + # Process multiple frames + for i in 10: + inst._process(0.016) + var expected := GameState.player_position * Constants.TILE_SIZE + assert_that(camera.global_position).override_failure_message( + "Frame %d: camera must be locked to player" % i + ).is_equal(expected) + + +# -- Entity alpha/color (4) --------------------------------------------------- + +func test_entity_null_visibility_defaults_full_alpha() -> void: + # P2-E01: Entity with no visibility field (v1 backward compat) → alpha 1.0. + var renderer := _make_entity_renderer() + var entity := [{"entity_id": 50, "x": 3.0, "y": 3.0, "z": 0, + "kind": {"variant": "Npc", "data": null}}] + renderer.update_entities(entity) + var node = renderer.entity_nodes[50] + assert_that(node.modulate.a).override_failure_message( + "Null visibility should default to full alpha" + ).is_equal_approx(1.0, 0.01) + renderer.queue_free() + + +func test_entity_peripheral_to_forward_alpha_transition() -> void: + # P2-E02: Updating entity from Peripheral to Forward changes alpha. + var renderer := _make_entity_renderer() + # First: Peripheral + var entity_p := [{"entity_id": 60, "x": 4.0, "y": 4.0, "z": 0, + "kind": {"variant": "Npc", "data": null}, "visibility": "Peripheral"}] + renderer.update_entities(entity_p) + var node = renderer.entity_nodes[60] + assert_that(node.modulate.a).is_equal_approx(Constants.PERIPHERAL_ALPHA, 0.01) + # Update to Forward + var entity_f := [{"entity_id": 60, "x": 4.0, "y": 4.0, "z": 0, + "kind": {"variant": "Npc", "data": null}, "visibility": "Forward"}] + renderer.update_entities(entity_f) + assert_that(node.modulate.a).override_failure_message( + "Forward visibility should set alpha to 1.0" + ).is_equal_approx(1.0, 0.01) + renderer.queue_free() + + +func test_entity_terrain_uses_object_color() -> void: + # P2-E03: Terrain entity kind maps to ENTITY_COLOR_OBJECT. + var renderer := _make_entity_renderer() + var entity := [{"entity_id": 70, "x": 2.0, "y": 2.0, "z": 0, + "kind": {"variant": "Terrain", "data": null}, "visibility": "Forward"}] + renderer.update_entities(entity) + var node = renderer.entity_nodes[70] as ColorRect + assert_that(node.color).override_failure_message( + "Terrain kind should use ENTITY_COLOR_OBJECT" + ).is_equal(Constants.ENTITY_COLOR_OBJECT) + renderer.queue_free() + + +func test_entity_player_color_regardless_of_sector() -> void: + # P2-E04: Player uses ENTITY_COLOR_PLAYER even in Peripheral sector. + GameState.player_entity_id = 80 + var renderer := _make_entity_renderer() + var entity := [{"entity_id": 80, "x": 5.0, "y": 5.0, "z": 0, + "kind": {"variant": "Player", "data": null}, "visibility": "Peripheral"}] + renderer.update_entities(entity) + var node = renderer.entity_nodes[80] as ColorRect + assert_that(node.color).override_failure_message( + "Player color must be constant regardless of visibility sector" + ).is_equal(Constants.ENTITY_COLOR_PLAYER) + # Alpha should still be dimmed for Peripheral + assert_that(node.modulate.a).is_equal_approx(Constants.PERIPHERAL_ALPHA, 0.01) + renderer.queue_free() + + +# -- UI (7) -------------------------------------------------------------------- + +func test_monologue_display_visible_hidden() -> void: + # P2-U01: MonologueDisplay starts hidden, becomes visible after show_monologue. + var inst := _make_scene() + var mono = inst.get_node("UILayer/MonologueDisplay") + assert_that(mono.is_visible).override_failure_message( + "Monologue should start hidden" + ).is_false() + mono.show_monologue("Test thought.", 3.0) + assert_that(mono.is_visible).override_failure_message( + "Monologue should be visible after show_monologue" + ).is_true() + assert_that(mono.text_label.text).is_equal("Test thought.") + + +func test_interaction_list_shows_nearest_verb() -> void: + # P2-U02: When nearby_interactions are present, interaction list shows verbs. + var inst := _make_scene() + var ilist = inst.get_node("InsertOverlay/InteractionList") + GameState.nearby_interactions = [{ + "entity_id": 2, + "entity_type": "Npc", + "distance": 1, + "verbs": [ + {"kind": "Talk", "label": "Talk", "priority": 1, "available": true}, + {"kind": "Observe", "label": "Observe", "priority": 2, "available": true}, + ], + }] + ilist.update_from_state() + assert_that(ilist.get_visible_verb_count()).override_failure_message( + "Interaction list should show 2 verbs" + ).is_equal(2) + assert_that(ilist.is_showing()).is_true() + # Cleanup + GameState.nearby_interactions = [] + + +func test_inventory_grid_shows_carried_items() -> void: + # P2-U03: Inventory grid becomes visible when player has items. + var inst := _make_scene() + var inv = inst.get_node("UILayer/InventoryGrid") + # Initially empty — hidden + GameState.player_inventory = [] + inv.update_from_state() + assert_that(inv.visible).override_failure_message( + "Inventory should be hidden when empty" + ).is_false() + # Add items + GameState.player_inventory = [ + {"item_id": 1, "name": "Keycard", "slot": 0}, + {"item_id": 2, "name": "Datapad", "slot": 1}, + ] + inv.update_from_state() + assert_that(inv.visible).override_failure_message( + "Inventory should be visible with items" + ).is_true() + assert_that(inv.get_slot_count()).is_equal(2) + # Cleanup + GameState.player_inventory = [] + + +func test_dialogue_overlay_active_on_show() -> void: + # P2-U04: Dialogue box reports active after show_dialogue. + var inst := _make_scene() + var dlg = inst.get_node("InsertOverlay/DialogueBox") + assert_that(dlg.is_dialogue_active()).override_failure_message( + "Dialogue should start inactive" + ).is_false() + dlg.show_dialogue("Kael", "How's it going?", [ + {"text": "Fine.", "response_id": "r1", "priority": 1, "confrontation": false}, + ]) + assert_that(dlg.is_dialogue_active()).override_failure_message( + "Dialogue should be active after show_dialogue" + ).is_true() + + +func test_game_state_tick_rate_paused() -> void: + # P2-U05: TickRate::Paused is stored in game_time and queryable. + GameState.apply_snapshot({ + "tick": 1, + "game_time": { + "day": 0, + "time_of_day": 100, + "day_phase": "Morning", + "tick_rate": "Paused", + }, + }) + assert_that(GameState.game_time.tick_rate).override_failure_message( + "Paused tick rate must be stored in game_time" + ).is_equal("Paused") + + +func test_fog_entities_blob_count_matches_recognitions() -> void: + # P2-U06: FogEntities tracks one blob per pending recognition. + var fog_entities := _make_fog_entities() + var saved := GameState.pending_recognitions + GameState.pending_recognitions = [ + {"entity_id": 101, "x": 5.0, "y": 5.0, "z": 0, "remaining_ticks": 3, "total_delay_ticks": 6}, + {"entity_id": 102, "x": 8.0, "y": 8.0, "z": 0, "remaining_ticks": 1, "total_delay_ticks": 6}, + ] + fog_entities.update_from_state() + assert_that(fog_entities._entities.size()).override_failure_message( + "FogEntities should track 2 blob entities" + ).is_equal(2) + assert_that(fog_entities._entities.has(101)).is_true() + assert_that(fog_entities._entities.has(102)).is_true() + GameState.pending_recognitions = saved + fog_entities.queue_free() + + +func test_fog_overlay_z_index_covers_world() -> void: + # P2-U07: FogOverlay renders above world content (z:900), ensuring + # occluded tiles are visually covered. + var inst := _make_scene() + var fog_overlay = inst.get_node("World/FogOverlay") + assert_that(fog_overlay.z_index).override_failure_message( + "FogOverlay z_index must be %d (Z_FOG)" % Constants.Z_FOG + ).is_equal(Constants.Z_FOG) + # FogEntities at z:950 — above fog, below insert overlay + var fog_entities = inst.get_node("World/FogEntities") + assert_that(fog_entities.z_index).override_failure_message( + "FogEntities z_index must be %d (Z_FOG_ENTITIES)" % Constants.Z_FOG_ENTITIES + ).is_equal(Constants.Z_FOG_ENTITIES) From f82f97afcb2590d5f60b1a9c19e00d960981119f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 02:14:42 +0100 Subject: [PATCH 3/6] =?UTF-8?q?feat(client):=20add=2012=20P3=20tests=20?= =?UTF-8?q?=E2=80=94=20z-layer,=20entity=20lerp,=20Tyre=20additions=20(#49?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gdUnit4 tests covering: - Z-layer ordering (4): floor below ysort, entities in ysort at z:0, fog exact z_index values (Z_FOG=900, Z_FOG_ENTITIES=950), UI layers - Entity lerp (3): snap on first appear, moves toward target, converges within 0.3s at LERP_SPEED=12.0 - Tyre additions (5): recognition progress, facing rotation, lerp weight scaling with delta, blob removal, LERP_SPEED constant guard Co-Authored-By: Claude Opus 4.6 --- client/tests/test_client_p3.gd | 325 +++++++++++++++++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 client/tests/test_client_p3.gd diff --git a/client/tests/test_client_p3.gd b/client/tests/test_client_p3.gd new file mode 100644 index 000000000..ec6fdda02 --- /dev/null +++ b/client/tests/test_client_p3.gd @@ -0,0 +1,325 @@ +## P3 client tests: z-layer ordering (4), entity lerp (3), Tyre additions (5). +## Validates scene tree draw order, framerate-independent entity interpolation, +## and recognition transition timing. +## Spec ref: sprint-9/client.md #493. +class_name TestClientP3 +extends GdUnitTestSuite + +var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd") +var FogEntitiesScript = load("res://scripts/rendering/fog_entities.gd") + +var _instance: Node = null + + +func before_test() -> void: + SimBridge.reset_test_state() + GameState.current_tick = 0 + GameState.player_position = Vector2.ZERO + GameState.visible_entities = [] + GameState.visible_tiles = [] + GameState.visible_positions = {} + GameState.current_monologue = null + GameState.current_dialogue = null + GameState.game_time = {} + GameState.pending_recognitions = [] + GameState.nearby_interactions = [] + GameState.player_inventory = [] + GameState.player_stance = "Walk" + GameState.player_facing = "North" + GameState.dialogue_active = false + + +func after_test() -> void: + if _instance and is_instance_valid(_instance): + _instance.queue_free() + _instance = null + + +# -- Helpers ------------------------------------------------------------------- + +func _make_scene() -> Node: + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + return _instance + + +func _make_entity_renderer() -> Node2D: + var renderer = Node2D.new() + renderer.set_script(EntityRendererScript) + add_child(renderer) + return renderer + + +func _make_fog_entities() -> Node2D: + var node = Node2D.new() + node.set_script(FogEntitiesScript) + add_child(node) + return node + + +# -- Z-layer ordering (4) ----------------------------------------------------- + +func test_z_floor_below_ysort() -> void: + # P3-Z01: FloorTiles (z:0) renders below YSortGroup (z:100). + var inst := _make_scene() + var floor_tiles = inst.get_node("World/FogGroup/FloorTiles") + var ysort = inst.get_node("World/FogGroup/YSortGroup") + assert_that(floor_tiles.z_index).override_failure_message( + "FloorTiles z_index must be Z_FLOOR (%d)" % Constants.Z_FLOOR + ).is_equal(Constants.Z_FLOOR) + assert_that(ysort.z_index).override_failure_message( + "YSortGroup z_index must be Z_YSORT (%d)" % Constants.Z_YSORT + ).is_equal(Constants.Z_YSORT) + assert_that(floor_tiles.z_index < ysort.z_index).is_true() + + +func test_z_entities_inside_ysort_at_zero() -> void: + # P3-Z02: Entities node lives inside YSortGroup with z_index = 0. + # Y-sort contract: children of YSortGroup must use z_index = 0. + var inst := _make_scene() + var entities = inst.get_node("World/FogGroup/YSortGroup/Entities") + assert_that(entities.z_index).override_failure_message( + "Entities z_index must be 0 inside YSortGroup (y-sort contract)" + ).is_equal(0) + assert_that(entities.get_parent().y_sort_enabled).override_failure_message( + "Entities parent must have y_sort_enabled" + ).is_true() + + +func test_z_fog_above_world_content() -> void: + # P3-Z03: FogOverlay (z:900) renders above all world content including + # YSortGroup (z:100) and Overhead (z:300). + # Spec #34: FogOverlay z_index == Z_FOG (900). + # Spec #35: FogEntities z_index == Z_FOG_ENTITIES (950). + var inst := _make_scene() + var fog = inst.get_node("World/FogOverlay") + var fog_entities = inst.get_node("World/FogEntities") + var overhead = inst.get_node("World/FogGroup/Overhead") + assert_that(fog.z_index).override_failure_message( + "FogOverlay z_index must be Z_FOG (%d) per D-049" % Constants.Z_FOG + ).is_equal(Constants.Z_FOG) + assert_that(fog_entities.z_index).override_failure_message( + "FogEntities z_index must be Z_FOG_ENTITIES (%d) per D-049/D-059" % Constants.Z_FOG_ENTITIES + ).is_equal(Constants.Z_FOG_ENTITIES) + assert_that(fog.z_index > overhead.z_index).override_failure_message( + "FogOverlay (z:%d) must render above Overhead (z:%d)" % [fog.z_index, overhead.z_index] + ).is_true() + assert_that(fog_entities.z_index > fog.z_index).override_failure_message( + "FogEntities (z:%d) must render above FogOverlay (z:%d)" % [fog_entities.z_index, fog.z_index] + ).is_true() + + +func test_z_ui_layer_above_world() -> void: + # P3-Z04: UILayer (CanvasLayer 20) renders above InsertOverlay (CanvasLayer 10) + # and both render above world content. + var inst := _make_scene() + var ui_layer = inst.get_node("UILayer") as CanvasLayer + var insert_layer = inst.get_node("InsertOverlay") as CanvasLayer + assert_that(ui_layer.layer).override_failure_message( + "UILayer must be CanvasLayer %d" % Constants.CANVAS_UI + ).is_equal(Constants.CANVAS_UI) + assert_that(insert_layer.layer).override_failure_message( + "InsertOverlay must be CanvasLayer %d" % Constants.CANVAS_INSERT + ).is_equal(Constants.CANVAS_INSERT) + assert_that(ui_layer.layer > insert_layer.layer).override_failure_message( + "UILayer must render above InsertOverlay" + ).is_true() + + +# -- Entity lerp (3) ---------------------------------------------------------- + +func test_entity_snap_on_first_appear() -> void: + # P3-L01: Entity spawns at its position immediately — no lerp on first appear. + var renderer := _make_entity_renderer() + var entity := [{"entity_id": 10, "x": 8.0, "y": 6.0, "z": 0, + "kind": {"variant": "Npc", "data": null}}] + renderer.update_entities(entity) + var node = renderer.entity_nodes[10] + var expected := Vector2( + floorf(8.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET, + floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET + ) + assert_that(node.position).override_failure_message( + "Entity should snap to position on first appear (no lerp)" + ).is_equal(expected) + renderer.queue_free() + + +func test_entity_lerp_moves_toward_target() -> void: + # P3-L02: After updating target position, entity moves toward it over time. + var renderer := _make_entity_renderer() + # Spawn at (5, 5) + var entity := [{"entity_id": 11, "x": 5.0, "y": 5.0, "z": 0, + "kind": {"variant": "Npc", "data": null}}] + renderer.update_entities(entity) + var node: ColorRect = renderer.entity_nodes[11] + var start_pos: Vector2 = node.position + # Move target to (6, 5) + var entity_moved := [{"entity_id": 11, "x": 6.0, "y": 5.0, "z": 0, + "kind": {"variant": "Npc", "data": null}}] + renderer.update_entities(entity_moved) + # Process several frames — entity should move toward target + renderer._process(0.016) + renderer._process(0.016) + var after_pos: Vector2 = node.position + var target := Vector2( + floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET, + floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET + ) + # Position should have moved toward target (x increased) + assert_that(after_pos.x > start_pos.x).override_failure_message( + "Entity x should move toward target after _process" + ).is_true() + # But should not have snapped — still in transit + assert_that(after_pos.x < target.x).override_failure_message( + "Entity should still be in transit after 2 frames" + ).is_true() + renderer.queue_free() + + +func test_entity_lerp_converges_within_300ms() -> void: + # P3-L03: At LERP_SPEED=12.0, entity converges within ~0.3s. + # At 12.0: weight = 1.0 - exp(-12.0 * 0.3) ≈ 0.973 — 97% there. + var renderer := _make_entity_renderer() + # Spawn at (5, 5) + var entity := [{"entity_id": 12, "x": 5.0, "y": 5.0, "z": 0, + "kind": {"variant": "Npc", "data": null}}] + renderer.update_entities(entity) + # Move target to (7, 5) — 2 tiles + var entity_moved := [{"entity_id": 12, "x": 7.0, "y": 5.0, "z": 0, + "kind": {"variant": "Npc", "data": null}}] + renderer.update_entities(entity_moved) + var target := Vector2( + floorf(7.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET, + floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET + ) + # Simulate 0.3s at 60fps (18 frames × 0.016s ≈ 0.288s) + for i in 20: + renderer._process(0.016) + var final_node: ColorRect = renderer.entity_nodes[12] + var final_pos: Vector2 = final_node.position + # Should be within 5% of target (97% convergence at 0.3s) + var dist: float = final_pos.distance_to(target) + var total_dist: float = 2.0 * Constants.TILE_SIZE + assert_that(dist / total_dist < 0.05).override_failure_message( + "Entity should be within 5%% of target after 0.3s (dist: %.1f / %.1f)" % [dist, total_dist] + ).is_true() + renderer.queue_free() + + +# -- Tyre additions (5) ------------------------------------------------------- + +func test_recognition_transition_progress() -> void: + # P3-T01: Recognition progress calculated correctly from remaining/total ticks. + # Grey blob → colored entity over total_delay_ticks. + var fog_entities := _make_fog_entities() + var saved := GameState.pending_recognitions + # 3 remaining out of 10 total → progress 0.7 + GameState.pending_recognitions = [{ + "entity_id": 200, "x": 5.0, "y": 5.0, "z": 0, + "remaining_ticks": 3, "total_delay_ticks": 10, + }] + fog_entities.update_from_state() + var blob: Dictionary = fog_entities._entities[200] + assert_that(blob.progress).override_failure_message( + "Progress should be 0.7 (1.0 - 3/10)" + ).is_equal_approx(0.7, 0.01) + # 0 remaining → fully recognized (progress 1.0) + GameState.pending_recognitions = [{ + "entity_id": 200, "x": 5.0, "y": 5.0, "z": 0, + "remaining_ticks": 0, "total_delay_ticks": 10, + }] + fog_entities.update_from_state() + assert_that(fog_entities._entities[200].progress).override_failure_message( + "Zero remaining should be progress 1.0" + ).is_equal_approx(1.0, 0.01) + GameState.pending_recognitions = saved + fog_entities.queue_free() + + +func test_facing_indicator_rotation_matches_player_facing() -> void: + # P3-T02: Facing indicator rotation matches player_facing from snapshot. + GameState.player_entity_id = 1 + var renderer := _make_entity_renderer() + var entity := [{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, + "kind": {"variant": "Player", "data": null}, "visibility": "Forward"}] + renderer.update_entities(entity) + var indicator = renderer.entity_nodes[1].get_node("FacingIndicator") + # Test each cardinal + diagonal direction + var expected := { + "North": 0.0, + "East": PI / 2.0, + "South": PI, + "West": 3.0 * PI / 2.0, + } + for dir in expected: + GameState.player_facing = dir + renderer.update_entities(entity) + assert_that(indicator.rotation).override_failure_message( + "%s: expected rotation %.3f, got %.3f" % [dir, expected[dir], indicator.rotation] + ).is_equal_approx(expected[dir], 0.001) + renderer.queue_free() + + +func test_lerp_weight_increases_with_delta() -> void: + # P3-T03: Sprint snappiness — larger delta → larger lerp weight → faster arrival. + # Exponential smoothing: weight = 1.0 - exp(-LERP_SPEED * delta). + # Higher delta (or higher lerp multiplier) means more progress per frame. + var renderer := _make_entity_renderer() + # Spawn entity, then move target + var entity := [{"entity_id": 20, "x": 5.0, "y": 5.0, "z": 0, + "kind": {"variant": "Npc", "data": null}}] + renderer.update_entities(entity) + var entity_moved := [{"entity_id": 20, "x": 8.0, "y": 5.0, "z": 0, + "kind": {"variant": "Npc", "data": null}}] + renderer.update_entities(entity_moved) + # Small delta step + var small_node: ColorRect = renderer.entity_nodes[20] + var small_start: float = small_node.position.x + renderer._process(0.008) + var small_progress: float = small_node.position.x - small_start + # Reset position for large delta test + small_node.position = Vector2( + floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET, + floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET + ) + # Large delta step + var large_start: float = small_node.position.x + renderer._process(0.032) + var large_progress: float = small_node.position.x - large_start + assert_that(large_progress > small_progress).override_failure_message( + "Larger delta should produce more lerp progress (%.2f vs %.2f)" % [large_progress, small_progress] + ).is_true() + renderer.queue_free() + + +func test_recognition_blob_removed_when_absent() -> void: + # P3-T04: When a pending recognition disappears from state, FogEntities + # removes the blob. + var fog_entities := _make_fog_entities() + var saved := GameState.pending_recognitions + # Add entity + GameState.pending_recognitions = [{ + "entity_id": 300, "x": 10.0, "y": 10.0, "z": 0, + "remaining_ticks": 5, "total_delay_ticks": 10, + }] + fog_entities.update_from_state() + assert_that(fog_entities._entities.size()).is_equal(1) + # Remove from state + GameState.pending_recognitions = [] + fog_entities.update_from_state() + assert_that(fog_entities._entities.size()).override_failure_message( + "Blob should be removed when absent from pending_recognitions" + ).is_equal(0) + GameState.pending_recognitions = saved + fog_entities.queue_free() + + +func test_entity_renderer_lerp_speed_constant() -> void: + # P3-T05: LERP_SPEED is tuned at 12.0 per D-053 stance discussion. + # Ensures no accidental changes to the feel of entity movement. + assert_that(EntityRenderer.LERP_SPEED).override_failure_message( + "LERP_SPEED must be 12.0 per D-053 tuning" + ).is_equal(12.0) From 4648ce62bb8dd4ec1dceec1c9d7a7dc21ec3adce Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 02:14:46 +0100 Subject: [PATCH 4/6] feat(client): add 5 anti-tedium regression tests (#494) Regression guards for Sprint 9 QA features: - F12 bug report (2): no-crash without handler, no queued input action (stubs for when #495 WRONG button lands) - Gauntlet UI hidden (3): no gauntlet nodes visible in default mode, no room_id/gauntlet_mode in normal snapshots, stays hidden across multiple ticks Co-Authored-By: Claude Opus 4.6 --- client/tests/test_anti_tedium.gd | 262 +++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 client/tests/test_anti_tedium.gd diff --git a/client/tests/test_anti_tedium.gd b/client/tests/test_anti_tedium.gd new file mode 100644 index 000000000..fab341bec --- /dev/null +++ b/client/tests/test_anti_tedium.gd @@ -0,0 +1,262 @@ +## Anti-tedium regression tests (#494): +## Guard against UI clutter that makes testing tedious. +## +## Test 1: F12 bug report capture — pressing F12 must not crash; when #495 lands, +## the handler should pause, show capture dialog, save 3 files, unpause. +## Test 2: Gauntlet progress UI hidden — in non-Gauntlet mode (no room_id or +## gauntlet_mode flag in snapshot), timer and personal-bests must not appear. +## +## #495 blocked by #481/#490 (server). #496 blocked by #487 (server). +## Tests stub blocked features and verify anti-tedium guards. +## Spec ref: Sprint 9 briefing (client.md), #495, #496. +class_name TestAntiTedium +extends GdUnitTestSuite + + +var _instance: Node = null + + +func before_test() -> void: + SimBridge.reset_test_state() + SimBridge._last_snapshot = null + GameState.current_tick = 0 + GameState.player_position = Vector2.ZERO + GameState.visible_entities = [] + GameState.visible_tiles = [] + GameState.visible_positions = {} + GameState.current_monologue = null + GameState.current_dialogue = null + GameState.game_time = {} + GameState.pending_recognitions = [] + + +func after_test() -> void: + if _instance and is_instance_valid(_instance): + _instance.queue_free() + _instance = null + + +# -- Helpers ------------------------------------------------------------------- + +## Encode a minimal valid snapshot as MessagePack bytes (same helper as P0 tests). +func _make_snapshot_bytes(overrides: Dictionary = {}) -> PackedByteArray: + var snapshot := { + "tick": overrides.get("tick", 1), + "version": Protocol.PROTOCOL_VERSION, + "entities": overrides.get("entities", [{ + "entity_id": 1, + "x": 10.0, + "y": 10.0, + "z": 0, + "kind": "Player", + "visibility": "Forward", + }]), + "game_time": { + "day": 0, + "time_of_day": 0, + "day_phase": "Morning", + "tick_rate": overrides.get("tick_rate", "Full"), + }, + "player_facing": "North", + "player_stance": "Walk", + "player_inventory": [], + "visible_tiles": [], + "nearby_interactions": [], + "pending_recognitions": [], + } + if overrides.has("current_monologue"): + snapshot["current_monologue"] = overrides["current_monologue"] + if overrides.has("current_dialogue"): + snapshot["current_dialogue"] = overrides["current_dialogue"] + # Gauntlet fields: only include if explicitly provided (absence = non-gauntlet) + if overrides.has("room_id"): + snapshot["room_id"] = overrides["room_id"] + if overrides.has("gauntlet_mode"): + snapshot["gauntlet_mode"] = overrides["gauntlet_mode"] + var result = Messagepack.encode(snapshot) + return result.value + + +# -- Test 1: F12 Bug Report Capture ------------------------------------------- +# Regression guard: F12 press must not crash or cause unintended side effects. +# When #495 lands, this test verifies the full capture flow: +# 1. Game pauses (tick_rate → Paused) +# 2. Capture dialog appears +# 3. Three files saved to tests/bug-reports/YYYY-MM-DD_HH-MM-SS/ +# 4. Game unpauses +# +# Until #495: verifies F12 is inert — no crash, no state corruption. + +func test_f12_press_no_crash_without_handler() -> void: + # Load main scene — full game tree + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + # Process one frame to let _ready() and initial snapshot settle. + # The test snapshot at tick 1 includes a monologue that gets consumed here. + _instance._process(0.016) + + # Record state AFTER initialization — this is the stable baseline. + var tick_rate_before: String = GameState.game_time.get("tick_rate", "Full") + var mono_before: Variant = GameState.current_monologue + var dialogue_before: Variant = GameState.current_dialogue + + # Simulate F12 key press via the Godot input system. + # InputMapper._unhandled_input() checks action bindings — F12 is not bound + # to any action yet, so the event should pass through harmlessly. + var event := InputEventKey.new() + event.keycode = KEY_F12 + event.pressed = true + event.key_label = KEY_F12 + Input.parse_input_event(event) + + # Process a frame to let the event propagate + _instance._process(0.016) + + # Assert: no state corruption from unhandled F12 + assert_that(GameState.game_time.get("tick_rate", "Full")).override_failure_message( + "F12 press should not change tick_rate (no handler yet)" + ).is_equal(tick_rate_before) + assert_that(GameState.current_monologue).override_failure_message( + "F12 press should not spawn a monologue" + ).is_equal(mono_before) + assert_that(GameState.current_dialogue).override_failure_message( + "F12 press should not spawn a dialogue" + ).is_equal(dialogue_before) + + # Release the key + var release := InputEventKey.new() + release.keycode = KEY_F12 + release.pressed = false + release.key_label = KEY_F12 + Input.parse_input_event(release) + + +func test_f12_does_not_queue_input_action() -> void: + # Verify F12 does not produce any action in InputMapper's queue. + # When #495 adds the "bug_report" action, this test will be updated + # to verify the correct action IS queued. + InputMapper.input_queue.clear() + + var event := InputEventKey.new() + event.keycode = KEY_F12 + event.pressed = true + event.key_label = KEY_F12 + Input.parse_input_event(event) + + # Give InputMapper a frame to process + InputMapper._process(0.016) + + # F12 is not bound to any InputMapper.Action — queue should remain empty + assert_that(InputMapper.input_queue.size()).override_failure_message( + "F12 should not produce any input action (no binding exists yet)" + ).is_equal(0) + + # Cleanup + var release := InputEventKey.new() + release.keycode = KEY_F12 + release.pressed = false + release.key_label = KEY_F12 + Input.parse_input_event(release) + InputMapper.flush_queue() + + +# -- Test 2: Gauntlet Progress UI Hidden in Non-Gauntlet Mode ----------------- +# When #496 lands, it adds a room timer and personal-bests overlay. +# Anti-tedium guard: these must NOT be visible in normal (non-Gauntlet) play. +# +# Current state: no gauntlet UI exists in the scene tree. +# This test guards against #496 accidentally showing gauntlet UI in all modes. + +func test_no_gauntlet_ui_visible_in_default_mode() -> void: + # Load main scene — represents non-Gauntlet (default) play mode + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + # Process one frame to initialize all children + _instance._process(0.016) + + # Check that no gauntlet-specific UI nodes are visible in the scene tree. + # When #496 adds GauntletHUD/RoomTimer/PersonalBests, they must be hidden + # by default (only shown when gauntlet_mode is true in the snapshot). + var gauntlet_node_names := [ + "GauntletHUD", "RoomTimer", "PersonalBests", "GauntletOverlay", + "GauntletTimer", "GauntletProgress", + ] + for node_name in gauntlet_node_names: + var node: Node = _find_node_recursive(_instance, node_name) + if node != null and node is CanvasItem: + assert_that((node as CanvasItem).visible).override_failure_message( + "Gauntlet UI node '%s' must not be visible in non-Gauntlet mode" % node_name + ).is_false() + + +func test_snapshot_without_room_id_shows_no_gauntlet_ui() -> void: + # A snapshot that lacks room_id and gauntlet_mode fields represents + # normal play. Apply it and verify no gauntlet state leaks into GameState. + var bytes := _make_snapshot_bytes({"tick": 1}) + SimBridge.receive_bytes(bytes) + + var snapshot: Variant = SimBridge._last_snapshot + assert_that(snapshot).is_not_null() + + # Snapshot should NOT contain gauntlet fields + assert_that(snapshot.has("room_id")).override_failure_message( + "Non-gauntlet snapshot must not contain room_id" + ).is_false() + assert_that(snapshot.has("gauntlet_mode")).override_failure_message( + "Non-gauntlet snapshot must not contain gauntlet_mode" + ).is_false() + + # Apply to GameState — gauntlet-related state should not exist + GameState.apply_snapshot(snapshot) + # GameState should not have gauntlet fields set (they don't exist yet, + # and when added, they must default to null/false) + assert_that(GameState.get("room_id")).override_failure_message( + "GameState.room_id should not exist or be null in non-gauntlet mode" + ).is_null() + assert_that(GameState.get("gauntlet_mode")).override_failure_message( + "GameState.gauntlet_mode should not exist or be null in non-gauntlet mode" + ).is_null() + + +func test_gauntlet_ui_stays_hidden_after_multiple_snapshots() -> void: + # Simulate several ticks of normal play — gauntlet UI must never appear. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + # Process 5 frames with normal (non-gauntlet) snapshots + for tick in range(1, 6): + var bytes := _make_snapshot_bytes({"tick": tick}) + SimBridge.receive_bytes(bytes) + _instance._process(0.016) + + # After 5 frames, no gauntlet nodes should have appeared + var gauntlet_node_names := [ + "GauntletHUD", "RoomTimer", "PersonalBests", "GauntletOverlay", + "GauntletTimer", "GauntletProgress", + ] + for node_name in gauntlet_node_names: + var node: Node = _find_node_recursive(_instance, node_name) + if node != null and node is CanvasItem: + assert_that((node as CanvasItem).visible).override_failure_message( + "Gauntlet UI '%s' must stay hidden after %d non-gauntlet ticks" % [node_name, 5] + ).is_false() + + +# -- Helper: recursive node search -------------------------------------------- + +func _find_node_recursive(root: Node, target_name: String) -> Node: + if root.name == target_name: + return root + for child in root.get_children(): + var found := _find_node_recursive(child, target_name) + if found != null: + return found + return null From 1254fa1b280a66e84a9c4ec298b38f687b464e40 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 02:15:05 +0100 Subject: [PATCH 5/6] chore(meta): update changelog for sprint 9 client work Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a27d5620..9ebfc1f56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] ### Added +- Client P2 tests (#492) — 16 gdUnit4 tests for camera (smoothing, zoom, viewport, follow, no-pan), entity alpha/color (peripheral, forward, NPC color, player constant), UI (monologue, interaction, inventory, dialogue, pause, fog blob, fog z_index) +- Client P3 tests (#493) — 12 gdUnit4 tests for z-layer ordering (floor/ysort/fog/UI), entity lerp (snap, converge, LERP_SPEED=12.0), Tyre additions (recognition, facing, delta scaling, blob removal) +- Anti-tedium regression tests (#494) — 5 tests: F12 no-crash guard, no queued input, gauntlet UI hidden in default/normal snapshot/multi-tick modes - Protocol v8: dialogue_response field decoding (DialogueResponseEvent with line_id, text, speaker_entity_id) from server #305/D-028 - Test client binary scaffolding (#480) — standalone crate at `tooling/test-client/` with CLI (--connect, --replay, --text, --json, --quiet, --golden, --ticks), exit codes (0/1/2), golden file JSON diff, JSONL replay loader - Snapshot text renderer (#481) — `format_snapshot_text(&ObserverSnapshot)` pub-exported from server crate, entity labels as kind:entity_id sorted by distance, room name stub, 10 unit tests @@ -15,6 +18,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - Stance change audio (`sfx_stance_change.ogg`) — subtle mechanical click for stance toggle feedback (#440) ### Fixed +- MessagePack int_64 encoder dead code branch (#516) — `-(1 << 63)` overflowed making int_64 branch unreachable; negative values beyond int_32 now correctly encode as 0xd3 instead of 0xcf - Protocol version bumped from 7 to 8 to match server — fixes 5 test failures from version mismatch - Interact action encoding changed from unit variant to struct variant to match server's PlayerAction::Interact { target_entity_id, verb } - Monologue duplication test (test_monologue_not_duplicated_after_consumption) fixed — was using poll_snapshot() which doesn't consume _last_snapshot in test mode From 4f20a996aa5b30e9532372ab9068b8d3d862da21 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 09:46:08 +0100 Subject: [PATCH 6/6] =?UTF-8?q?fix(client):=20address=20PR=20#30=20review?= =?UTF-8?q?=20=E2=80=94=209=20items=20from=20Hoshe=20and=20Tyre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SimBridge._last_snapshot = null to P3 before_test() (contamination risk) - Clarify mono.is_visible is custom property, not CanvasItem builtin - Camera smoothing test uses approximate equality instead of exact - Propagate inner _encode_message errors in array/dict encoding - Add explanatory comment on int_64 encoder branch - Document unfalsifiable gauntlet guard as intentional future-proof - Remove loose D-053 citation from LERP_SPEED pin - Assert FogOverlay is sibling of FogGroup under World - Add CANVAS_MODAL=30 (ModalLayer) assertion in P3-Z04 Co-Authored-By: Claude Opus 4.6 --- client/addons/messagepack/messagepack.gd | 13 +++++++++--- client/tests/test_anti_tedium.gd | 7 +++++-- client/tests/test_client_p2.gd | 9 +++++++- client/tests/test_client_p3.gd | 26 ++++++++++++++++++------ 4 files changed, 43 insertions(+), 12 deletions(-) diff --git a/client/addons/messagepack/messagepack.gd b/client/addons/messagepack/messagepack.gd index 5ada09950..a0cfc9deb 100755 --- a/client/addons/messagepack/messagepack.gd +++ b/client/addons/messagepack/messagepack.gd @@ -88,6 +88,7 @@ static func _encode_message(buffer: StreamPeerBuffer, value): buffer.put_u8(types["uint_32"]) buffer.put_u32(value) elif value < 0: + # Negative beyond int_32 range — encode as int_64 (0xd3) buffer.put_u8(types["int_64"]) buffer.put_64(value) else: @@ -133,7 +134,9 @@ static func _encode_message(buffer: StreamPeerBuffer, value): return ERR_INVALID_DATA for obj in value: - _encode_message(buffer, obj) + var inner_err = _encode_message(buffer, obj) + if inner_err: + return inner_err TYPE_DICTIONARY: var size = value.size() @@ -150,8 +153,12 @@ static func _encode_message(buffer: StreamPeerBuffer, value): return ERR_INVALID_DATA for key in value: - _encode_message(buffer, key) - _encode_message(buffer, value[key]) + var key_err = _encode_message(buffer, key) + if key_err: + return key_err + var val_err = _encode_message(buffer, value[key]) + if val_err: + return val_err TYPE_PACKED_BYTE_ARRAY: var size = value.size() diff --git a/client/tests/test_anti_tedium.gd b/client/tests/test_anti_tedium.gd index fab341bec..a97703a32 100644 --- a/client/tests/test_anti_tedium.gd +++ b/client/tests/test_anti_tedium.gd @@ -214,8 +214,11 @@ func test_snapshot_without_room_id_shows_no_gauntlet_ui() -> void: # Apply to GameState — gauntlet-related state should not exist GameState.apply_snapshot(snapshot) - # GameState should not have gauntlet fields set (they don't exist yet, - # and when added, they must default to null/false) + # Guard: when #496 adds GameState.room_id / gauntlet_mode properties, + # these assertions become falsifiable — they'll catch any code path that + # sets gauntlet state from a non-gauntlet snapshot. Currently Object.get() + # returns null for nonexistent properties, so this passes trivially until + # the properties are defined. assert_that(GameState.get("room_id")).override_failure_message( "GameState.room_id should not exist or be null in non-gauntlet mode" ).is_null() diff --git a/client/tests/test_client_p2.gd b/client/tests/test_client_p2.gd index b64e8d81e..c0bca3a5f 100644 --- a/client/tests/test_client_p2.gd +++ b/client/tests/test_client_p2.gd @@ -80,7 +80,10 @@ func test_camera_smoothing_convergence() -> void: for i in 5: inst._process(0.016) var expected := GameState.player_position * Constants.TILE_SIZE - assert_that(camera.global_position).is_equal(expected) + var dist := camera.global_position.distance_to(expected) + assert_that(dist < 0.1).override_failure_message( + "Camera should converge to player position (dist: %.4f)" % dist + ).is_true() func test_camera_viewport_tracks_player_position() -> void: @@ -191,6 +194,10 @@ func test_entity_player_color_regardless_of_sector() -> void: func test_monologue_display_visible_hidden() -> void: # P2-U01: MonologueDisplay starts hidden, becomes visible after show_monologue. + # Note: mono.is_visible is a custom bool property on MonologueDisplay + # (monologue_display.gd:11), not the built-in CanvasItem.is_visible() method. + # The monologue uses tween alpha for visual hide/show, so the built-in + # .visible stays true — we test the script's own state tracking. var inst := _make_scene() var mono = inst.get_node("UILayer/MonologueDisplay") assert_that(mono.is_visible).override_failure_message( diff --git a/client/tests/test_client_p3.gd b/client/tests/test_client_p3.gd index ec6fdda02..1b802a599 100644 --- a/client/tests/test_client_p3.gd +++ b/client/tests/test_client_p3.gd @@ -13,6 +13,7 @@ var _instance: Node = null func before_test() -> void: SimBridge.reset_test_state() + SimBridge._last_snapshot = null GameState.current_tick = 0 GameState.player_position = Vector2.ZERO GameState.visible_entities = [] @@ -96,7 +97,13 @@ func test_z_fog_above_world_content() -> void: var inst := _make_scene() var fog = inst.get_node("World/FogOverlay") var fog_entities = inst.get_node("World/FogEntities") + var fog_group = inst.get_node("World/FogGroup") var overhead = inst.get_node("World/FogGroup/Overhead") + # FogOverlay must be a sibling of FogGroup (both children of World), + # not a child of FogGroup — fog renders OVER the composited group. + assert_that(fog.get_parent()).override_failure_message( + "FogOverlay must be sibling of FogGroup (both under World)" + ).is_equal(fog_group.get_parent()) assert_that(fog.z_index).override_failure_message( "FogOverlay z_index must be Z_FOG (%d) per D-049" % Constants.Z_FOG ).is_equal(Constants.Z_FOG) @@ -117,15 +124,22 @@ func test_z_ui_layer_above_world() -> void: var inst := _make_scene() var ui_layer = inst.get_node("UILayer") as CanvasLayer var insert_layer = inst.get_node("InsertOverlay") as CanvasLayer - assert_that(ui_layer.layer).override_failure_message( - "UILayer must be CanvasLayer %d" % Constants.CANVAS_UI - ).is_equal(Constants.CANVAS_UI) + var modal_layer = inst.get_node("ModalLayer") as CanvasLayer assert_that(insert_layer.layer).override_failure_message( "InsertOverlay must be CanvasLayer %d" % Constants.CANVAS_INSERT ).is_equal(Constants.CANVAS_INSERT) + assert_that(ui_layer.layer).override_failure_message( + "UILayer must be CanvasLayer %d" % Constants.CANVAS_UI + ).is_equal(Constants.CANVAS_UI) + assert_that(modal_layer.layer).override_failure_message( + "ModalLayer must be CanvasLayer %d" % Constants.CANVAS_MODAL + ).is_equal(Constants.CANVAS_MODAL) assert_that(ui_layer.layer > insert_layer.layer).override_failure_message( "UILayer must render above InsertOverlay" ).is_true() + assert_that(modal_layer.layer > ui_layer.layer).override_failure_message( + "ModalLayer must render above UILayer" + ).is_true() # -- Entity lerp (3) ---------------------------------------------------------- @@ -318,8 +332,8 @@ func test_recognition_blob_removed_when_absent() -> void: func test_entity_renderer_lerp_speed_constant() -> void: - # P3-T05: LERP_SPEED is tuned at 12.0 per D-053 stance discussion. - # Ensures no accidental changes to the feel of entity movement. + # P3-T05: LERP_SPEED is tuned at 12.0 — exponential smoothing constant + # for entity visual interpolation. Pin value to prevent accidental changes. assert_that(EntityRenderer.LERP_SPEED).override_failure_message( - "LERP_SPEED must be 12.0 per D-053 tuning" + "LERP_SPEED must be 12.0 (entity movement feel constant)" ).is_equal(12.0)