diff --git a/client/scripts/autoloads/fog_state.gd b/client/scripts/autoloads/fog_state.gd index 46f607c79..455621d14 100644 --- a/client/scripts/autoloads/fog_state.gd +++ b/client/scripts/autoloads/fog_state.gd @@ -29,6 +29,11 @@ var _width: int = 1 var _height: int = 1 var _prev_visible: Dictionary = {} # Tiles visible last frame (for incremental decay) +## Debug flag — when true, fog.gdshader renders raw exploration texture +## as colored overlay (green=visible, blue=explored, red=unexplored). +## Toggle via FogState.debug_exploration = true in the console. +var debug_exploration: bool = false + func _ready() -> void: _resize(Rect2i(0, 0, 64, 64)) @@ -84,15 +89,17 @@ func update_from_state() -> void: # Grow bounds to include newly visible tiles — never shrink, so explored # tiles behind the player stay in the texture and render as deep fog # instead of black. Exploration data is preserved across resizes. - var tiles := GameState.visible_tiles - if tiles.size() > 0: - var new_bounds := _grow_bounds(tiles) + # Use visible_positions (always populated from server snapshots) instead of + # visible_tiles, which stays empty in live server mode because the server + # sends tile_kind but game_state.gd's population check expects "type". + var positions: Dictionary = GameState.visible_positions + if positions.size() > 0: + var new_bounds := _grow_bounds_from_positions(positions) if new_bounds != map_bounds: _resize(new_bounds) var ox: int = map_bounds.position.x var oy: int = map_bounds.position.y - var positions: Dictionary = GameState.visible_positions # TODO(v0.2): gradual decay over game-time instead of immediate EXP_VISIBLE→EXP_EXPLORED @@ -130,6 +137,27 @@ func update_from_state() -> void: _prev_visible = positions.duplicate() +func _grow_bounds_from_positions(positions: Dictionary) -> Rect2i: + ## Compute bounds from visible_positions (Dictionary[Vector2i, bool]). + ## Equivalent to _grow_bounds() but reads from the always-populated + ## positions dict instead of the legacy visible_tiles Array. + var min_x := 999999 + var min_y := 999999 + var max_x := -999999 + var max_y := -999999 + for pos in positions: + min_x = mini(min_x, pos.x) + min_y = mini(min_y, pos.y) + max_x = maxi(max_x, pos.x) + max_y = maxi(max_y, pos.y) + if min_x > max_x: + return map_bounds + var tile_bounds := Rect2i(min_x - 8, min_y - 8, max_x - min_x + 17, max_y - min_y + 17) + if map_bounds.size.x <= 1 and map_bounds.size.y <= 1: + return tile_bounds + return map_bounds.merge(tile_bounds) + + func _grow_bounds(tiles: Array) -> Rect2i: ## Compute bounds that include all currently visible tiles, merged with ## existing bounds so the texture only grows — never shrinks. Explored @@ -148,8 +176,11 @@ func _grow_bounds(tiles: Array) -> Rect2i: # Guard: all tiles invalid (no x/y) — sentinels would produce negative Rect2i if min_x > max_x: return map_bounds - # Margin for fog gradient bleed at edges - var tile_bounds := Rect2i(min_x - 4, min_y - 4, max_x - min_x + 9, max_y - min_y + 9) + # Margin for fog gradient bleed at edges. + # The Gaussian blur in fog.gdshader samples at 2-texel intervals across a 7x7 kernel, + # reaching ±6 tiles from the fragment position. Margin must be >= 8 to avoid + # clamping artifacts at texture boundaries. + var tile_bounds := Rect2i(min_x - 8, min_y - 8, max_x - min_x + 17, max_y - min_y + 17) # Merge with existing bounds — grow only if map_bounds.size.x <= 1 and map_bounds.size.y <= 1: return tile_bounds diff --git a/client/scripts/rendering/fog_shader.gd b/client/scripts/rendering/fog_shader.gd index dab5b8677..97586aeb9 100644 --- a/client/scripts/rendering/fog_shader.gd +++ b/client/scripts/rendering/fog_shader.gd @@ -69,3 +69,4 @@ func update_fog() -> void: _shader_mat.set_shader_parameter("map_offset", Vector2(FogState.map_bounds.position)) _shader_mat.set_shader_parameter("map_size", Vector2(FogState.map_bounds.size)) _shader_mat.set_shader_parameter("time", Time.get_ticks_msec() / 1000.0) + _shader_mat.set_shader_parameter("debug_exploration", FogState.debug_exploration) diff --git a/client/shaders/fog.gdshader b/client/shaders/fog.gdshader index 467858a93..16c89b18d 100644 --- a/client/shaders/fog.gdshader +++ b/client/shaders/fog.gdshader @@ -2,11 +2,11 @@ shader_type canvas_item; // D-059: 3-layer fog shader. Composites over world content. // Layer 1: Clear (forward cone) — transparent, soft gradient edge -// Layer 2: Explored fog — light overlay indicating "not fresh", art preserved +// Layer 2: Explored fog — light overlay (~25-30%), art fully preserved // Layer 3: Unexplored — solid near-black #12141a uniform sampler2D visibility_tex : filter_linear, repeat_disable; -uniform sampler2D exploration_tex : filter_linear, repeat_disable; +uniform sampler2D exploration_tex : filter_nearest, repeat_disable; uniform sampler2D zone_tint_tex : filter_nearest, repeat_disable; uniform sampler2D noise_tex : filter_linear, repeat_enable; uniform vec2 rect_pos; // World-space position of the ColorRect (pixels) @@ -15,14 +15,17 @@ uniform vec2 map_offset; // map_bounds.position (tiles) uniform vec2 map_size; // map_bounds.size (tiles) uniform float tile_size; // Pixels per sim tile uniform float time; // Seconds since start +uniform bool debug_exploration = false; // When true, render raw exploration texture const vec3 UNEXPLORED_COLOR = vec3(0.071, 0.078, 0.102); // #12141a -const vec3 DARK_OVERLAY = vec3(0.02, 0.02, 0.05); +const vec3 FOG_OVERLAY_COLOR = vec3(0.06, 0.07, 0.12); // Dark blue-grey fog tint -// Soft gradient via 7x7 Gaussian blur on visibility (sigma 2.0). -// Spreads the cone boundary into a 3-4 tile radius gradient for soft edges. +// D-066: 6-8 sim tile soft gradient at cone boundary. +// 7x7 Gaussian kernel sampling at 2-texel intervals spreads across ±6 tiles. +// Effective sigma = 4 tiles in world space (sigma_kernel=2.0 * step=2.0). +// At 2-sigma (8 tiles): weight drops to 0.14, giving ~6-8 tile visible gradient. float sample_visibility(vec2 uv) { - vec2 t = 1.0 / map_size; + vec2 t = 2.0 / map_size; float sum = 0.0; float weight = 0.0; for (float dy = -3.0; dy <= 3.0; dy += 1.0) { @@ -44,39 +47,54 @@ void fragment() { // Outside known map -> unexplored if (tex_uv.x < 0.0 || tex_uv.x > 1.0 || tex_uv.y < 0.0 || tex_uv.y > 1.0) { COLOR = vec4(UNEXPLORED_COLOR, 1.0); - } else { - float vis_raw = texture(visibility_tex, tex_uv).r; - float vis = sample_visibility(tex_uv); - float explored = texture(exploration_tex, tex_uv).r; - - // Don't bleed gradient into never-explored tiles - if (explored < 0.01 && vis_raw < 0.01) { - vis = 0.0; - } - - if (explored < 0.01 && vis < 0.01) { - // Unexplored: solid near-black - COLOR = vec4(UNEXPLORED_COLOR, 1.0); - } else { - // Continuous blend: clear vision (vis=1) -> light fog (vis=0). - // The Gaussian blur creates a smooth 3-4 tile soft gradient - // at the cone edge — no hard boundary. - - // Light fog: subtle animated overlay, preserves all art/info - float noise_val = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r; - float fog_alpha = 0.28 + noise_val * 0.04; - - // Clarity ramp from the blurred visibility - float clarity = smoothstep(0.0, 0.85, vis); - float alpha = mix(fog_alpha, 0.0, clarity); - vec3 color = mix(DARK_OVERLAY, vec3(0.0), clarity); - - // Soft edge between explored and unexplored - float exp_fade = smoothstep(0.0, 0.3, explored); - alpha = mix(1.0, alpha, exp_fade); - color = mix(UNEXPLORED_COLOR, color, exp_fade); - - COLOR = vec4(color, alpha); - } + return; } + + float vis = sample_visibility(tex_uv); + float explored = texture(exploration_tex, tex_uv).r; + + // Debug mode: render raw exploration texture (bypass fog rendering). + // Green = EXP_VISIBLE (255), blue = EXP_EXPLORED (128), red = EXP_UNEXPLORED (0). + if (debug_exploration) { + if (explored > 0.9) { + COLOR = vec4(0.0, explored, 0.0, 0.8); // Green: currently visible + } else if (explored > 0.1) { + COLOR = vec4(0.0, 0.0, explored * 2.0, 0.8); // Blue: explored + } else { + COLOR = vec4(0.5, 0.0, 0.0, 0.8); // Red: unexplored + } + return; + } + + // D-066: 6-8 sim tile gradient at the cone boundary. + // smoothstep(0.02, 0.95, vis) maps the Gaussian-blurred visibility into a + // smooth clarity ramp spanning the full blur radius — no tile-stepping. + float clarity = smoothstep(0.02, 0.95, vis); + + if (explored < 0.01) { + // Unexplored: smooth fade from transparent (inside cone) to solid black (beyond). + // The Gaussian vis gradient drives alpha — same ramp as explored fog — so the + // cone edge looks seamless regardless of whether adjacent tiles are explored. + // World art is NOT preserved in this region: alpha approaches 1.0 outside the cone. + COLOR = vec4(UNEXPLORED_COLOR, 1.0 - clarity); + return; + } + + // Explored fog: light overlay preserving all art and information (D-059). + // fog_alpha ~0.27-0.31 -> world appears at ~70% brightness with blue-grey tint. + // 8-10s Perlin noise adds atmosphere without obscuring content. + float noise_val = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r; + float fog_alpha = 0.27 + noise_val * 0.04; + + // Blend from fog (clarity=0, out of cone) to clear (clarity=1, in cone) + float alpha = mix(fog_alpha, 0.0, clarity); + vec3 color = mix(FOG_OVERLAY_COLOR, vec3(0.0), clarity); + + // Soft transition at the explored/unexplored tile boundary. + // exploration_tex uses filter_nearest: explored is exactly 0.0, ~0.5, or 1.0. + float exp_fade = smoothstep(0.0, 0.2, explored); + alpha = mix(1.0, alpha, exp_fade); + color = mix(UNEXPLORED_COLOR, color, exp_fade); + + COLOR = vec4(color, alpha); } diff --git a/client/tests/test_fog_sprint22.gd b/client/tests/test_fog_sprint22.gd new file mode 100644 index 000000000..fb71b84da --- /dev/null +++ b/client/tests/test_fog_sprint22.gd @@ -0,0 +1,517 @@ +## Sprint 22 — Fog system acceptance tests (#569) +## +## Validates FogState data management against the Sprint 22 acceptance criteria: +## - Explored tiles never revert to unexplored black (EXP_EXPLORED persistence) +## - Bounds grow-only invariant (explored tiles behind player stay in texture) +## - All visible tiles written as Forward (server simplified to Forward-only) +## - Exploration data survives texture resize (grow-only bounds copy) +## - Shader file present with correct fog_alpha constant +## +## Spec: D-059 (fog shader), D-015 (vision cone), D-066 (dual-scale grid, 6-8 tile gradient) +## Ticket: #569 +class_name TestFogSprint22 +extends GdUnitTestSuite + + +func _get_fog_state() -> Node: + var node = get_node_or_null("/root/FogState") + if node == null: + push_warning("TestFogSprint22: FogState autoload not found — test skipped (awaiting #569)") + return node + + +func before_test() -> void: + GameState.visible_positions.clear() + GameState.visible_tiles.clear() + GameState.visibility_sectors.clear() + + +func after_test() -> void: + GameState.visible_positions.clear() + GameState.visible_tiles.clear() + GameState.visibility_sectors.clear() + + +# -- Spec constants (D-059) --------------------------------------------------- + +func test_exp_explored_constant_is_128() -> void: + # EXP_EXPLORED = 128 → shader reads this as ~0.502. + # smoothstep(0.0, 0.2, 0.502) = 1.0 → exp_fade fully applied. + # If EXP_EXPLORED were 0, explored tiles would render as solid unexplored black. + var fog_state = _get_fog_state() + if fog_state == null: + return + assert_int(fog_state.EXP_EXPLORED).override_failure_message( + "EXP_EXPLORED must be 128 — shader exp_fade requires explored value > 0.2 to avoid unexplored-black rendering" + ).is_equal(128) + + +func test_exp_unexplored_constant_is_0() -> void: + var fog_state = _get_fog_state() + if fog_state == null: + return + assert_int(fog_state.EXP_UNEXPLORED).is_equal(0) + + +func test_exp_visible_constant_is_255() -> void: + # EXP_VISIBLE = 255 → shader reads 1.0, full art visibility (currently in LOS) + var fog_state = _get_fog_state() + if fog_state == null: + return + assert_int(fog_state.EXP_VISIBLE).is_equal(255) + + +func test_vis_forward_constant_is_255() -> void: + # D-059: VIS_FORWARD = 255 → clear vision, nearly transparent fog overlay + var fog_state = _get_fog_state() + if fog_state == null: + return + assert_int(fog_state.VIS_FORWARD).is_equal(255) + + +func test_vis_hidden_constant_is_0() -> void: + # D-059: VIS_HIDDEN = 0 → no vision, fog fully opaque + var fog_state = _get_fog_state() + if fog_state == null: + return + assert_int(fog_state.VIS_HIDDEN).is_equal(0) + + +func test_unexplored_color_spec_value() -> void: + # D-059: Unexplored = solid near-black #12141a + # Verify the hex value decodes to the expected channel values. + var c := Color("#12141a") + assert_float(c.r).is_equal_approx(18.0 / 255.0, 0.003) + assert_float(c.g).is_equal_approx(20.0 / 255.0, 0.003) + assert_float(c.b).is_equal_approx(26.0 / 255.0, 0.003) + # Sanity: it IS very dark (all channels < 0.12) + assert_float(c.r).is_less(0.12) + assert_float(c.g).is_less(0.12) + assert_float(c.b).is_less(0.12) + + +# -- Acceptance: explored tiles persist after leaving LOS (criterion 3) ------ + +func test_explored_tile_becomes_exp_explored_after_leaving_los() -> void: + # ACCEPTANCE CRITERION: "Explored tiles never revert to unexplored black" + # When tile (5,5) was in LOS (frame 1) and then leaves LOS (frame 2), + # its exploration byte must be EXP_EXPLORED (128), not EXP_UNEXPLORED (0). + var fog_state = _get_fog_state() + if fog_state == null: + return + if not fog_state.has_method("update_from_state"): + push_warning("TestFogSprint22: update_from_state missing — skipped") + return + + # Frame 1: tile (5,5) is visible + GameState.visible_positions = {Vector2i(5, 5): true} + GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}] + fog_state.update_from_state() + + # Frame 2: tile (5,5) leaves LOS + GameState.visible_positions.clear() + GameState.visible_tiles = [] + fog_state.update_from_state() + + # Internal state check: _exp_bytes[tile(5,5)] must be EXP_EXPLORED (128) + var exp_bytes = fog_state.get("_exp_bytes") + if exp_bytes == null: + push_warning("TestFogSprint22: _exp_bytes not accessible — data path untestable headlessly") + return + var ox: int = fog_state.map_bounds.position.x + var oy: int = fog_state.map_bounds.position.y + var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1 + if w <= 0: + push_warning("TestFogSprint22: _width inaccessible — data path untestable") + return + var px := 5 - ox + var py := 5 - oy + if px < 0 or py < 0 or px >= w: + push_warning("TestFogSprint22: tile (5,5) out of bounds after update — check grow_bounds margin") + return + var idx := py * w + px + if idx < 0 or idx >= exp_bytes.size(): + push_warning("TestFogSprint22: idx %d out of exp_bytes range %d" % [idx, exp_bytes.size()]) + return + assert_int(exp_bytes[idx]).override_failure_message( + "Tile (5,5) must be EXP_EXPLORED=128 after leaving LOS — not EXP_UNEXPLORED=0 (#569 regression)" + ).is_equal(fog_state.EXP_EXPLORED) + + +func test_explored_tile_is_exp_visible_while_in_los() -> void: + # While in LOS, tile exploration byte must be EXP_VISIBLE (255) + var fog_state = _get_fog_state() + if fog_state == null: + return + if not fog_state.has_method("update_from_state"): + return + + GameState.visible_positions = {Vector2i(3, 3): true} + GameState.visible_tiles = [{"x": 3, "y": 3, "z": 0, "visibility": "Forward"}] + fog_state.update_from_state() + + var exp_bytes = fog_state.get("_exp_bytes") + if exp_bytes == null: + return + var ox: int = fog_state.map_bounds.position.x + var oy: int = fog_state.map_bounds.position.y + var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1 + if w <= 0: + return + var px := 3 - ox + var py := 3 - oy + if px < 0 or py < 0 or px >= w: + return + var idx := py * w + px + if idx >= 0 and idx < exp_bytes.size(): + assert_int(exp_bytes[idx]).is_equal(fog_state.EXP_VISIBLE) + + +func test_unexplored_tile_stays_exp_unexplored() -> void: + # Tile (7, 8) was never seen — must remain EXP_UNEXPLORED (0) + var fog_state = _get_fog_state() + if fog_state == null: + return + if not fog_state.has_method("update_from_state"): + return + + # See only (5, 5) — tile (7, 8) is not in LOS + GameState.visible_positions = {Vector2i(5, 5): true} + GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}] + fog_state.update_from_state() + + var exp_bytes = fog_state.get("_exp_bytes") + if exp_bytes == null: + return + var ox: int = fog_state.map_bounds.position.x + var oy: int = fog_state.map_bounds.position.y + var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1 + if w <= 0: + return + var px := 7 - ox + var py := 8 - oy + if px < 0 or py < 0 or px >= w: + return + var idx := py * w + px + if idx >= 0 and idx < exp_bytes.size(): + assert_int(exp_bytes[idx]).is_equal(fog_state.EXP_UNEXPLORED) + + +# -- Acceptance: bounds grow-only invariant ------------------------------------ + +func test_bounds_never_shrink() -> void: + # ACCEPTANCE CRITERION: "Explored tiles never revert to unexplored black" + # Requires grow-only bounds: once a tile is in the texture, it stays there. + var fog_state = _get_fog_state() + if fog_state == null: + return + if not fog_state.has_method("update_from_state"): + return + + # Frame 1: see (10, 10) → establishes initial bounds + GameState.visible_positions = {Vector2i(10, 10): true} + GameState.visible_tiles = [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}] + fog_state.update_from_state() + var b1: Rect2i = fog_state.map_bounds + + # Frame 2: see (30, 30) → bounds must expand to include both + GameState.visible_positions = {Vector2i(30, 30): true} + GameState.visible_tiles = [{"x": 30, "y": 30, "z": 0, "visibility": "Forward"}] + fog_state.update_from_state() + var b2: Rect2i = fog_state.map_bounds + + # Frame 3: back to (10, 10) → bounds must NOT shrink + GameState.visible_positions = {Vector2i(10, 10): true} + GameState.visible_tiles = [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}] + fog_state.update_from_state() + var b3: Rect2i = fog_state.map_bounds + + assert_bool(b2.size.x >= b1.size.x).override_failure_message( + "Bounds must grow when player moves to larger region" + ).is_true() + assert_bool(b2.size.y >= b1.size.y).is_true() + assert_bool(b3.size.x >= b2.size.x).override_failure_message( + "Bounds must not shrink when player returns to previous position (grow-only invariant)" + ).is_true() + assert_bool(b3.size.y >= b2.size.y).is_true() + + +func test_bounds_include_margin_for_gradient_bleed() -> void: + # D-066: 6-8 tile gradient at cone edge requires texture margin. + # _grow_bounds adds 8-tile margin on each side (accommodates 7x7 Gaussian + # kernel at 2-texel intervals = ±6 tile reach). After seeing (10,10), + # bounds should extend at least 4 tiles beyond the visible tile. + var fog_state = _get_fog_state() + if fog_state == null: + return + if not fog_state.has_method("update_from_state"): + return + + GameState.visible_positions = {Vector2i(10, 10): true} + GameState.visible_tiles = [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}] + fog_state.update_from_state() + + var b: Rect2i = fog_state.map_bounds + # With 4-tile margin: bounds.position.x <= 10 - 4 = 6 + assert_bool(b.position.x <= 6).override_failure_message( + "FogState bounds must include 4-tile margin for gradient bleed (D-066 gradient spec)" + ).is_true() + assert_bool(b.position.y <= 6).is_true() + + +# -- Acceptance: Forward-only visibility (Sprint 22 server simplification) ---- + +func test_visible_tiles_written_as_vis_forward() -> void: + # Sprint 22: server sends only Forward tiles (Peripheral sector removed). + # FogState writes VIS_FORWARD (255) for all tiles in visible_positions. + var fog_state = _get_fog_state() + if fog_state == null: + return + if not fog_state.has_method("update_from_state"): + return + + GameState.visible_positions = {Vector2i(5, 5): true, Vector2i(6, 5): true} + GameState.visible_tiles = [ + {"x": 5, "y": 5, "z": 0, "visibility": "Forward"}, + {"x": 6, "y": 5, "z": 0, "visibility": "Forward"}, + ] + fog_state.update_from_state() + + var vis_bytes = fog_state.get("_vis_bytes") + if vis_bytes == null: + return + var ox: int = fog_state.map_bounds.position.x + var oy: int = fog_state.map_bounds.position.y + var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1 + if w <= 0: + return + for pos in [Vector2i(5, 5), Vector2i(6, 5)]: + var px := pos.x - ox + var py := pos.y - oy + if px < 0 or py < 0 or px >= w: + continue + var idx := py * w + px + if idx >= 0 and idx < vis_bytes.size(): + assert_int(vis_bytes[idx]).override_failure_message( + "All visible tiles should be VIS_FORWARD=255 — server is Forward-only in Sprint 22" + ).is_equal(fog_state.VIS_FORWARD) + + +func test_tiles_outside_los_written_as_vis_hidden() -> void: + # Tiles in bounds but not in visible_positions must be VIS_HIDDEN (0) + var fog_state = _get_fog_state() + if fog_state == null: + return + if not fog_state.has_method("update_from_state"): + return + + GameState.visible_positions = {Vector2i(5, 5): true} + GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}] + fog_state.update_from_state() + + # (5, 7) is inside the padded bounds but not visible — must be VIS_HIDDEN + var vis_bytes = fog_state.get("_vis_bytes") + if vis_bytes == null: + return + var ox: int = fog_state.map_bounds.position.x + var oy: int = fog_state.map_bounds.position.y + var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1 + if w <= 0: + return + var px := 5 - ox + var py := 7 - oy + if px >= 0 and py >= 0 and px < w: + var idx := py * w + px + if idx >= 0 and idx < vis_bytes.size(): + assert_int(vis_bytes[idx]).is_equal(fog_state.VIS_HIDDEN) + + +# -- Acceptance: exploration survives texture resize -------------------------- + +func test_exploration_data_preserved_across_bounds_growth() -> void: + # D-059: Texture resize must copy old exploration bytes into new texture. + # Without this, tiles seen before a resize appear as EXP_UNEXPLORED (black). + var fog_state = _get_fog_state() + if fog_state == null: + return + if not fog_state.has_method("update_from_state"): + return + + # Frame 1: see (5, 5), then leave + GameState.visible_positions = {Vector2i(5, 5): true} + GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}] + fog_state.update_from_state() + GameState.visible_positions.clear() + GameState.visible_tiles = [] + fog_state.update_from_state() # (5,5) → EXP_EXPLORED + + # Frame 2: move far away — forces bounds growth (resize) + GameState.visible_positions = {Vector2i(80, 80): true} + GameState.visible_tiles = [{"x": 80, "y": 80, "z": 0, "visibility": "Forward"}] + fog_state.update_from_state() + + # (5,5) must still be EXP_EXPLORED after the resize + var exp_bytes = fog_state.get("_exp_bytes") + if exp_bytes == null: + return + var ox: int = fog_state.map_bounds.position.x + var oy: int = fog_state.map_bounds.position.y + var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1 + if w <= 0: + return + var px := 5 - ox + var py := 5 - oy + if px < 0 or py < 0 or px >= w: + push_warning("TestFogSprint22: (5,5) not in bounds after resize — is copy-on-resize working?") + return + var idx := py * w + px + if idx >= 0 and idx < exp_bytes.size(): + assert_int(exp_bytes[idx]).override_failure_message( + "Exploration data at (5,5) must survive bounds growth — EXP_EXPLORED (128) expected after resize" + ).is_greater_equal(fog_state.EXP_EXPLORED) + + +# -- Shader file checks (D-059) ----------------------------------------------- + +func test_fog_gdshader_exists() -> void: + assert_bool(ResourceLoader.exists("res://shaders/fog.gdshader")).override_failure_message( + "fog.gdshader must exist — fog rendering requires this shader file (#569)" + ).is_true() + + +func test_fog_shader_defines_fog_alpha() -> void: + # D-059: explored fog overlay must be ~25-30% opacity. + # fog_alpha constant controls this. Verify the shader defines it. + if not ResourceLoader.exists("res://shaders/fog.gdshader"): + push_warning("TestFogSprint22: fog.gdshader not found — shader check skipped") + return + var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader") + if source.is_empty(): + push_warning("TestFogSprint22: fog.gdshader is empty or unreadable") + return + assert_bool(source.contains("fog_alpha")).override_failure_message( + "fog.gdshader must define fog_alpha for the 25-30%% explored-tile overlay (D-059)" + ).is_true() + + +func test_fog_shader_defines_smoothstep_clarity_ramp() -> void: + # D-059/D-066: smooth gradient requires a clarity ramp (smoothstep). + # The blurred visibility → clarity ramp must use smoothstep for smooth gradients. + if not ResourceLoader.exists("res://shaders/fog.gdshader"): + push_warning("TestFogSprint22: fog.gdshader not found — skipped") + return + var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader") + if source.is_empty(): + return + assert_bool(source.contains("smoothstep")).override_failure_message( + "fog.gdshader must use smoothstep for the clarity ramp — hard steps violate D-066 gradient spec" + ).is_true() + + +func test_fog_shader_defines_unexplored_color() -> void: + # D-059: unexplored = solid near-black #12141a. + if not ResourceLoader.exists("res://shaders/fog.gdshader"): + push_warning("TestFogSprint22: fog.gdshader not found — skipped") + return + var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader") + if source.is_empty(): + return + assert_bool(source.contains("UNEXPLORED_COLOR")).override_failure_message( + "fog.gdshader must define UNEXPLORED_COLOR constant (D-059 #12141a spec)" + ).is_true() + + +func test_fog_shader_uses_gaussian_blur_for_gradient() -> void: + # D-066: 6-8 tile soft gradient requires Gaussian blur on visibility texture. + # Current implementation: 7x7 kernel at 2-texel intervals (±6 tiles), sigma 2.0 + # in kernel space = 4.0 tiles effective. At 2-sigma (8 tiles), weight drops to 0.14. + # This covers the D-066 "6-8 tile" gradient spec. + if not ResourceLoader.exists("res://shaders/fog.gdshader"): + push_warning("TestFogSprint22: fog.gdshader not found — skipped") + return + var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader") + if source.is_empty(): + return + # 7x7 Gaussian uses dy from -3 to 3 + assert_bool(source.contains("sample_visibility")).override_failure_message( + "fog.gdshader must call sample_visibility() for Gaussian-blurred visibility (D-066 gradient)" + ).is_true() + assert_bool(source.contains("-3.0")).override_failure_message( + "fog.gdshader sample_visibility must use 7x7 kernel (±3 tiles) for 6-tile gradient coverage (D-066)" + ).is_true() + + +# -- Regression: GameState visible_positions (existing contract) --------------- + +func test_visible_positions_derived_from_visible_tiles_in_server_mode() -> void: + # D-020: In real server mode, visible_positions derives from visible_tiles. + # Fog rendering depends on this derivation being correct. + GameState.apply_snapshot({ + "tick": 10, + "visible_tiles": [ + {"x": 7, "y": 7, "z": 0, "visibility": "Forward"}, + {"x": 8, "y": 7, "z": 0, "visibility": "Forward"}, + ], + }) + assert_bool(GameState.visible_positions.has(Vector2i(7, 7))).override_failure_message( + "visible_positions must be derived from visible_tiles when no explicit visible_positions key" + ).is_true() + assert_bool(GameState.visible_positions.has(Vector2i(8, 7))).is_true() + + +func test_visibility_sectors_populated_forward_only() -> void: + # D-015: visibility_sectors must be populated from visible_tiles. + # In Forward-only mode, all sectors are "Forward". + GameState.apply_snapshot({ + "tick": 11, + "visible_tiles": [ + {"x": 4, "y": 4, "z": 0, "visibility": "Forward"}, + ], + }) + assert_bool(GameState.visibility_sectors.has(Vector2i(4, 4))).is_true() + assert_str(GameState.visibility_sectors[Vector2i(4, 4)]).is_equal("Forward") + + +func test_visible_positions_cleared_on_new_snapshot() -> void: + # Old positions from tick N must not persist to tick N+1 + GameState.apply_snapshot({ + "tick": 1, + "visible_tiles": [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}], + }) + assert_int(GameState.visible_positions.size()).is_equal(1) + GameState.apply_snapshot({ + "tick": 2, + "visible_tiles": [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}], + }) + assert_bool(GameState.visible_positions.has(Vector2i(5, 5))).override_failure_message( + "Old visible positions must be cleared when new visible_tiles arrive" + ).is_false() + assert_bool(GameState.visible_positions.has(Vector2i(10, 10))).is_true() + + +# -- Performance (D-059) ------------------------------------------------------- + +func test_fog_state_update_under_2ms_for_400_tiles() -> void: + # D-059: <1ms/frame CPU budget for fog update. Allow 2x margin for test env. + var fog_state = _get_fog_state() + if fog_state == null: + return + if not fog_state.has_method("update_from_state"): + return + + var positions: Dictionary = {} + var tiles: Array = [] + for x in range(20): + for y in range(20): + positions[Vector2i(x, y)] = true + tiles.append({"x": x, "y": y, "z": 0, "visibility": "Forward"}) + GameState.visible_positions = positions + GameState.visible_tiles = tiles + + var start := Time.get_ticks_usec() + fog_state.update_from_state() + var elapsed_ms := (Time.get_ticks_usec() - start) / 1000.0 + + assert_float(elapsed_ms).override_failure_message( + "FogState.update_from_state() must complete in <2ms for 400 tiles (spec: <1ms D-059)" + ).is_less(2.0)