## 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() 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.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 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) 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 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) ---------------------------------------------------------- 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_X, floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y ) 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_X, floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y ) # 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_X, floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y ) # 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_input_mapper_angle() -> void: # P3-T02: D-054 — Facing indicator uses InputMapper.facing_angle (client-side float). # Indicator rotation = facing_angle + PI/2 (0=North basis). 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") # {facing_angle → expected indicator rotation} var angles := { -PI / 2.0: 0.0, # North 0.0: PI / 2.0, # East PI / 2.0: PI, # South PI: -PI / 2.0, # West (3PI/2 normalized to -PI/2 by Godot) } for angle in angles: InputMapper.facing_angle = angle renderer.update_entities(entity) assert_that(indicator.rotation).override_failure_message( "angle %.3f: expected rotation %.3f, got %.3f" % [angle, angles[angle], indicator.rotation] ).is_equal_approx(angles[angle], 0.001) InputMapper.facing_angle = -PI / 2.0 # Reset to default 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_X, floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y ) # 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 — 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 (entity movement feel constant)" ).is_equal(12.0)