## 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 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: # 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 Sprite2D assert_that(node.self_modulate).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 Sprite2D assert_that(node.self_modulate).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. # 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( "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)