## D-059: Fog shader rebuild tests (#430). ## Validates FogState data management, fog shader integration, ## performance budget, and regression against old fog_renderer.gd API. ## ## Architecture reference: docs/architecture/fog-shader-spec.md ## Spec refs: D-059, D-049, D-033, D-060, D-066 class_name TestFogShader extends GdUnitTestSuite # -- Helpers ------------------------------------------------------------------- func _fog_state_exists() -> bool: # FogState should be an autoload once #430 lands return Engine.has_singleton("FogState") or get_node_or_null("/root/FogState") != null func _get_fog_state() -> Node: var node = get_node_or_null("/root/FogState") if node == null: push_warning("TestFogShader: FogState autoload not found — test skipped (awaiting #430)") return node func _fog_shader_script_exists() -> bool: return ResourceLoader.exists("res://scripts/rendering/fog_shader.gd") func _fog_gdshader_exists() -> bool: return ResourceLoader.exists("res://shaders/fog.gdshader") # -- FogState autoload existence ----------------------------------------------- func test_fog_state_autoload_registered() -> void: # fog-shader-spec.md: "New autoload: client/scripts/autoloads/fog_state.gd" if not _fog_state_exists(): push_warning("TestFogShader: FogState autoload not registered — test skipped") return var fog_state = _get_fog_state() assert_that(fog_state).is_not_null() # -- FogState texture management ----------------------------------------------- func test_fog_state_has_visibility_texture() -> void: var fog_state = _get_fog_state() if fog_state == null: return assert_that(fog_state.get("visibility_texture") != null).is_true() func test_fog_state_has_exploration_texture() -> void: var fog_state = _get_fog_state() if fog_state == null: return assert_that(fog_state.get("exploration_texture") != null).is_true() func test_fog_state_has_zone_tint_texture() -> void: var fog_state = _get_fog_state() if fog_state == null: return assert_that(fog_state.get("zone_tint_texture") != null).is_true() func test_fog_state_has_map_bounds() -> void: var fog_state = _get_fog_state() if fog_state == null: return assert_that(fog_state.get("map_bounds") != null).is_true() # -- FogState update from GameState ------------------------------------------- func test_fog_state_update_from_visible_positions() -> void: var fog_state = _get_fog_state() if fog_state == null: return # Set up GameState with visible positions GameState.visible_positions = {Vector2i(5, 5): true, Vector2i(6, 5): true} GameState.visibility_sectors = {Vector2i(5, 5): "Forward", Vector2i(6, 5): "Peripheral"} if fog_state.has_method("update_from_state"): fog_state.update_from_state() # Visibility texture should be updated (non-null) assert_that(fog_state.visibility_texture).is_not_null() # Reset GameState.visible_positions.clear() GameState.visibility_sectors.clear() func test_fog_state_exploration_persists() -> void: var fog_state = _get_fog_state() if fog_state == null: return if not fog_state.has_method("update_from_state"): return # Tick 1: see tile (5,5) GameState.visible_positions = {Vector2i(5, 5): true} fog_state.update_from_state() # Tick 2: no longer see tile (5,5) but it should remain explored GameState.visible_positions.clear() fog_state.update_from_state() assert_that(fog_state.exploration_texture).is_not_null() # The exploration state for (5,5) should be non-zero (explored, deep fog) # Exact value depends on implementation — test that it's not unexplored GameState.visible_positions.clear() func test_fog_state_forward_vs_peripheral() -> void: var fog_state = _get_fog_state() if fog_state == null: return if not fog_state.has_method("update_from_state"): return # Forward tiles should have higher visibility value than Peripheral GameState.visible_positions = { Vector2i(5, 5): true, Vector2i(6, 5): true, } GameState.visibility_sectors = { Vector2i(5, 5): "Forward", Vector2i(6, 5): "Peripheral", } fog_state.update_from_state() # Per spec: Forward = 255, Peripheral = 180 assert_that(fog_state.visibility_texture).is_not_null() GameState.visible_positions.clear() GameState.visibility_sectors.clear() # -- Fog shader script existence ----------------------------------------------- func test_fog_shader_script_exists() -> void: if not _fog_shader_script_exists(): push_warning("TestFogShader: fog_shader.gd not found — test skipped") return assert_that(_fog_shader_script_exists()).is_true() func test_fog_gdshader_file_exists() -> void: if not _fog_gdshader_exists(): push_warning("TestFogShader: fog.gdshader not found — test skipped") return assert_that(_fog_gdshader_exists()).is_true() # -- Old fog_renderer.gd should be deleted ------------------------------------ func test_old_fog_renderer_deleted() -> void: # fog-shader-spec.md: "Delete fog_renderer.gd" # This test will PASS once the old file is removed, FAIL if it still exists # alongside the new fog shader. if not _fog_shader_script_exists(): # New fog shader hasn't landed yet — skip this check return var old_exists = ResourceLoader.exists("res://scripts/rendering/fog_renderer.gd") assert_that(old_exists).is_false() # -- Performance: texture update budget (<1ms/frame total) -------------------- func test_visibility_texture_update_performance() -> void: var fog_state = _get_fog_state() if fog_state == null: return if not fog_state.has_method("update_from_state"): return # Simulate a realistic tile count (~400 visible tiles) var positions := {} var sectors := {} for x in range(20): for y in range(20): var pos := Vector2i(x, y) positions[pos] = true sectors[pos] = "Forward" if y < 10 else "Peripheral" GameState.visible_positions = positions GameState.visibility_sectors = sectors # Measure update time var start := Time.get_ticks_usec() fog_state.update_from_state() var elapsed_us := Time.get_ticks_usec() - start var elapsed_ms := elapsed_us / 1000.0 # D-059: Visibility texture upload budget: 0.1ms # Allow 2x margin for test environment overhead assert_that(elapsed_ms).is_less(0.5) GameState.visible_positions.clear() GameState.visibility_sectors.clear() func test_full_fog_update_under_1ms() -> void: var fog_state = _get_fog_state() if fog_state == null: return if not fog_state.has_method("update_from_state"): return # D-059: <1ms/frame total for fog system (CPU side) var positions := {} var sectors := {} for x in range(20): for y in range(20): positions[Vector2i(x, y)] = true sectors[Vector2i(x, y)] = "Forward" GameState.visible_positions = positions GameState.visibility_sectors = sectors var start := Time.get_ticks_usec() fog_state.update_from_state() var elapsed_us := Time.get_ticks_usec() - start var elapsed_ms := elapsed_us / 1000.0 # Allow 2x margin: spec says <1ms, we allow <2ms for test overhead assert_that(elapsed_ms).is_less(2.0) GameState.visible_positions.clear() GameState.visibility_sectors.clear() # -- Shader uniform constants (D-059) ----------------------------------------- func test_fog_layer_color_constants() -> void: # D-059 fog layer colors — verify constants are defined correctly # Layer 5: Unexplored no maps = #12141a var unexplored := Color("#12141a") assert_float(unexplored.r).is_equal_approx(0.071, 0.01) assert_float(unexplored.g).is_equal_approx(0.078, 0.01) assert_float(unexplored.b).is_equal_approx(0.102, 0.01) # Layer 4: Wireframe = #333340 var wireframe := Color("#333340") assert_float(wireframe.r).is_equal_approx(0.2, 0.01) assert_float(wireframe.g).is_equal_approx(0.2, 0.01) assert_float(wireframe.b).is_equal_approx(0.251, 0.01) # -- Regression: GameState visible_positions still works ---------------------- func test_game_state_visible_positions_unchanged() -> void: # Ensure the fog shader doesn't break the visible_positions Dictionary # that fog_renderer.gd used to consume GameState.apply_snapshot({ "tick": 1, "visible_tiles": [ {"x": 5, "y": 5, "z": 0, "visibility": "Forward"}, {"x": 6, "y": 5, "z": 0, "visibility": "Peripheral"}, ], }) assert_that(GameState.visible_positions.has(Vector2i(5, 5))).is_true() assert_that(GameState.visible_positions.has(Vector2i(6, 5))).is_true() assert_that(GameState.visibility_sectors[Vector2i(5, 5)]).is_equal("Forward") assert_that(GameState.visibility_sectors[Vector2i(6, 5)]).is_equal("Peripheral") func test_game_state_visible_positions_cleared_on_new_snapshot() -> void: GameState.apply_snapshot({ "tick": 1, "visible_tiles": [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}], }) assert_that(GameState.visible_positions.size()).is_equal(1) GameState.apply_snapshot({ "tick": 2, "visible_tiles": [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}], }) assert_that(GameState.visible_positions.has(Vector2i(5, 5))).is_false() assert_that(GameState.visible_positions.has(Vector2i(10, 10))).is_true() # -- #585: BoundaryWall tiles — visible in fog, not persistently explored ------ func test_boundary_wall_tiles_not_in_visible_positions() -> void: ## #585: BoundaryWall margin tiles must NOT enter visible_positions. ## They are rendered via tile_renderer (from visible_tiles) but must not ## update the player's fog exploration memory. GameState.apply_snapshot({ "tick": 1, "visible_tiles": [ {"x": 5, "y": 5, "z": 0, "visibility": "Forward"}, {"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall"}, ], }) assert_that(GameState.visible_positions.has(Vector2i(5, 5))).is_true() assert_that(GameState.visible_positions.has(Vector2i(6, 5))).is_false() assert_that(GameState.boundary_positions.has(Vector2i(6, 5))).is_true() func test_boundary_wall_tiles_in_visibility_sectors() -> void: ## BoundaryWall visibility sector is still tracked in visibility_sectors ## (for potential future use — wall coloring, etc.) GameState.apply_snapshot({ "tick": 1, "visible_tiles": [ {"x": 3, "y": 3, "z": 0, "visibility": "BoundaryWall"}, ], }) assert_that(GameState.visibility_sectors.has(Vector2i(3, 3))).is_true() assert_that(GameState.visibility_sectors[Vector2i(3, 3)]).is_equal("BoundaryWall") func test_boundary_positions_cleared_on_new_snapshot() -> void: ## BoundaryWall positions are cleared each snapshot so stale walls don't persist. GameState.apply_snapshot({ "tick": 1, "visible_tiles": [{"x": 7, "y": 7, "z": 0, "visibility": "BoundaryWall"}], }) assert_that(GameState.boundary_positions.has(Vector2i(7, 7))).is_true() GameState.apply_snapshot({ "tick": 2, "visible_tiles": [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}], }) assert_that(GameState.boundary_positions.has(Vector2i(7, 7))).is_false() assert_that(GameState.boundary_positions.size()).is_equal(0) func test_boundary_wall_fog_vis_forward() -> void: ## #585: BoundaryWall tiles must lift fog (VIS_FORWARD = 255) so wall content composites. ## visible_positions excludes boundary tiles; fog_state writes vis bytes for them separately. ## Reads _vis_bytes directly (packed byte array) to avoid ImageTexture.get_image() lag. var fog_state = _get_fog_state() if fog_state == null: return GameState.apply_snapshot({ "tick": 1, "visible_tiles": [ {"x": 0, "y": 0, "z": 0, "visibility": "Forward"}, # normal LOS tile {"x": 1, "y": 0, "z": 0, "visibility": "BoundaryWall"}, # margin tile ], }) fog_state.update_from_state() var ox: int = fog_state.map_bounds.position.x var oy: int = fog_state.map_bounds.position.y var w: int = fog_state.map_bounds.size.x var vis: PackedByteArray = fog_state._vis_bytes var normal_idx: int = (0 - oy) * w + (0 - ox) var boundary_idx: int = (0 - oy) * w + (1 - ox) assert_int(vis[normal_idx]).is_equal(FogState.VIS_FORWARD) # normal tile: VIS_FORWARD assert_int(vis[boundary_idx]).is_equal(FogState.VIS_FORWARD) # boundary also fog-lifted GameState.visible_positions.clear() GameState.boundary_positions.clear() # -- Z-layer compliance (D-049) ----------------------------------------------- func test_fog_overlay_z_layer() -> void: # fog-shader-spec.md: FogOverlay at z:10 (above world content) # Per D-049, fog entities render on Layer 5 # The FogOverlay itself renders ABOVE the FogGroup (CanvasGroup) if not _fog_shader_script_exists(): push_warning("TestFogShader: fog_shader.gd not found — z-layer test skipped") return # This test needs the scene tree to be set up # Verify via scene file inspection rather than runtime # -- Noise animation cycles (D-059) ------------------------------------------- func test_light_fog_noise_cycle() -> void: # D-059: Light fog animated Perlin noise, 8-10s cycle # This is a shader constant — verify documentation, not runtime # The shader should use TIME with a period of 8-10s pass # Manual verification required — shader inspection func test_deep_fog_noise_cycle() -> void: # D-059: Deep fog more pronounced noise, 15-20s cycle # Shader constant — manual verification pass # Manual verification required — shader inspection