From f82f97afcb2590d5f60b1a9c19e00d960981119f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 02:14:42 +0100 Subject: [PATCH] =?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)