- game_state.gd: add boundary_positions Dictionary field; BoundaryWall tiles from visible_tiles go to boundary_positions instead of visible_positions — rendered by tile_renderer but not tracked as explored fog memory - fog_state.gd: update_from_state() writes VIS_FORWARD for boundary_positions so fog lifts over margin wall content; boundary tiles excluded from exploration step so they don't persist as EXP_EXPLORED when player turns away - tile_renderer.gd: no changes needed — renders all visible_tiles by type, sector-agnostic - test_fog_shader.gd: 4 new tests — boundary excluded from visible_positions, tracked in boundary_positions, cleared each snapshot, fog lifts to VIS_FORWARD Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
371 lines
13 KiB
GDScript
371 lines
13 KiB
GDScript
## 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
|
|
pass
|
|
|
|
|
|
# -- 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
|