## P1 client tests: fog shader state (4), entity lifecycle (2), pending recognition blob (1). ## Information boundary + core rendering contract tests — the perception/fog system ## that makes asymmetric information work. ## Bug #5 class prevention: fog byte encoding errors. Bug #2 class: entity lifecycle. ## Spec ref: stig-round3.md Section 1 (tests #3-#9), workshop-outcomes.md. class_name TestClientP1 extends GdUnitTestSuite var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd") var FogEntitiesScript = load("res://scripts/rendering/fog_entities.gd") # -- Helpers ------------------------------------------------------------------- func _get_fog_state() -> Node: var node = get_node_or_null("/root/FogState") if node == null: push_warning("TestClientP1: FogState autoload not found — test skipped") return node 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 # -- Fog state: visibility byte values (#3-#4) -------------------------------- func test_fog_visibility_forward_tile() -> void: # P1 #3: Forward-sector tile writes VIS_FORWARD (255) to _vis_bytes. var fog = _get_fog_state() if fog == null: return # Reset to deterministic state — 64x64 map at origin, all bytes zeroed GameState.visible_tiles = [] fog._resize(Rect2i(0, 0, 64, 64)) GameState.visible_positions = {Vector2i(5, 5): true} GameState.visibility_sectors = {Vector2i(5, 5): "Forward"} fog.update_from_state() # Index: row 5 * width 64 + col 5 assert_that(fog._vis_bytes[5 * 64 + 5]).override_failure_message( "Forward tile at (5,5) should be VIS_FORWARD=%d" % FogState.VIS_FORWARD ).is_equal(FogState.VIS_FORWARD) GameState.visible_positions.clear() GameState.visibility_sectors.clear() func test_fog_visibility_peripheral_tile() -> void: # P1 #4: Server simplified to forward-only (Sprint 22, #569). All visible # tiles are now written as VIS_FORWARD regardless of visibility_sectors value. # Peripheral sector is no longer a distinct visual state. var fog = _get_fog_state() if fog == null: return GameState.visible_tiles = [] fog._resize(Rect2i(0, 0, 64, 64)) GameState.visible_positions = {Vector2i(10, 8): true} GameState.visibility_sectors = {Vector2i(10, 8): "Peripheral"} fog.update_from_state() assert_that(fog._vis_bytes[8 * 64 + 10]).override_failure_message( "All visible tiles write VIS_FORWARD after forward-only simplification" ).is_equal(FogState.VIS_FORWARD) GameState.visible_positions.clear() GameState.visibility_sectors.clear() # -- Fog state: exploration persistence (#5) ----------------------------------- func test_fog_exploration_persistence() -> void: # P1 #5: Tile visible in frame 1 (EXP_VISIBLE), not visible in frame 2 # → _exp_bytes decays to EXP_EXPLORED (128), not EXP_UNEXPLORED (0). var fog = _get_fog_state() if fog == null: return GameState.visible_tiles = [] fog._resize(Rect2i(0, 0, 64, 64)) var pos := Vector2i(5, 5) var idx: int = 5 * 64 + 5 # Frame 1: tile visible GameState.visible_positions = {pos: true} GameState.visibility_sectors = {pos: "Forward"} fog.update_from_state() assert_that(fog._exp_bytes[idx]).override_failure_message( "Visible tile should have EXP_VISIBLE=%d" % FogState.EXP_VISIBLE ).is_equal(FogState.EXP_VISIBLE) # Frame 2: tile no longer visible — should decay to EXP_EXPLORED, not EXP_UNEXPLORED GameState.visible_positions = {} GameState.visibility_sectors = {} fog.update_from_state() assert_that(fog._exp_bytes[idx]).override_failure_message( "Previously visible tile should decay to EXP_EXPLORED=%d, not EXP_UNEXPLORED" % FogState.EXP_EXPLORED ).is_equal(FogState.EXP_EXPLORED) GameState.visible_positions.clear() GameState.visibility_sectors.clear() # -- Fog state: hidden tile default (#6) -------------------------------------- func test_fog_hidden_tile_value() -> void: # P1 #6: Tile never in visible_positions → _vis_bytes == VIS_HIDDEN (0). # Verifies default state after _resize() and after update with other visible tiles. var fog = _get_fog_state() if fog == null: return GameState.visible_tiles = [] fog._resize(Rect2i(0, 0, 64, 64)) # Check a tile that was never made visible (30,30) var idx: int = 30 * 64 + 30 assert_that(fog._vis_bytes[idx]).override_failure_message( "Never-visible tile should be VIS_HIDDEN=%d after resize" % FogState.VIS_HIDDEN ).is_equal(FogState.VIS_HIDDEN) # Also verify it stays VIS_HIDDEN after an update that makes OTHER tiles visible GameState.visible_positions = {Vector2i(5, 5): true} GameState.visibility_sectors = {Vector2i(5, 5): "Forward"} fog.update_from_state() assert_that(fog._vis_bytes[idx]).override_failure_message( "Non-visible tile should remain VIS_HIDDEN=%d after update" % FogState.VIS_HIDDEN ).is_equal(FogState.VIS_HIDDEN) GameState.visible_positions.clear() GameState.visibility_sectors.clear() # -- Entity lifecycle: removal on leaving visibility (#7) ---------------------- func test_entity_removed_when_leaving_visibility() -> void: # P1 #7: Entity present in one update, absent in next → entity_nodes empty. # Verifies removal (queue_free), not just hiding. var renderer := _make_entity_renderer() var entity := [{"entity_id": 42, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}}] renderer.update_entities(entity) assert_that(renderer.entity_nodes.size()).override_failure_message( "Entity should exist after first update" ).is_equal(1) assert_that(renderer.entity_nodes.has(42)).is_true() # Entity leaves visibility — removed from entity_nodes, node queue_free'd renderer.update_entities([]) assert_that(renderer.entity_nodes.size()).override_failure_message( "Entity should be removed when absent from update" ).is_equal(0) assert_that(renderer.entity_nodes.has(42)).is_false() renderer.queue_free() # -- Entity lifecycle: creation on first appearance (#8) ----------------------- func test_entity_created_on_first_appearance() -> void: # P1 #8: Empty renderer → update with 1 entity → entity_nodes.size() == 1, # child exists in scene tree. var renderer := _make_entity_renderer() assert_that(renderer.entity_nodes.size()).override_failure_message( "Fresh renderer should have no entities" ).is_equal(0) var entity := [{"entity_id": 99, "x": 3.0, "y": 7.0, "z": 0, "kind": {"variant": "Npc", "data": null}}] renderer.update_entities(entity) assert_that(renderer.entity_nodes.size()).override_failure_message( "Renderer should have 1 entity after update" ).is_equal(1) assert_that(renderer.entity_nodes.has(99)).is_true() # Verify child actually exists in the scene tree var node = renderer.entity_nodes[99] assert_that(node.get_parent()).is_equal(renderer) renderer.queue_free() # -- Pending recognition blob rendering (#9) ---------------------------------- func test_pending_recognition_blob_rendering() -> void: # P1 #9: pending_recognitions with 1 entry → FogEntities tracks blob entity # (not a full entity sprite — drawn via _draw(), data in _entities dict). var fog_entities := _make_fog_entities() var saved_recognitions := GameState.pending_recognitions GameState.pending_recognitions = [{ "entity_id": 77, "x": 10.0, "y": 15.0, "z": 0, "remaining_ticks": 5, "total_delay_ticks": 10, }] fog_entities.update_from_state() # FogEntities uses _draw() with no child nodes — verify internal tracking assert_that(fog_entities._entities.size()).override_failure_message( "FogEntities should track 1 blob entity" ).is_equal(1) assert_that(fog_entities._entities.has(77)).is_true() # Verify position stored correctly var blob_data: Dictionary = fog_entities._entities[77] assert_that(blob_data.pos).is_equal(Vector2(10.0, 15.0)) # Recognition progress should be calculated: 1.0 - 5/10 = 0.5 assert_that(blob_data.progress).is_equal_approx(0.5, 0.01) # New entity triggers a sonar ping (D-059) assert_that(fog_entities._pings.size()).override_failure_message( "New fog entity should trigger a sonar ping" ).is_greater_equal(1) # Cleanup GameState.pending_recognitions = saved_recognitions fog_entities.queue_free()