4 new tests in test_fog_sprint22.gd verify BoundaryWall tiles populate boundary_positions (not visible_positions), get VIS_FORWARD without EXP_VISIBLE, stay EXP_UNEXPLORED after leaving LOS, and clear on new snapshot. Comment in tile_renderer.gd documents implicit rendering path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
653 lines
24 KiB
GDScript
653 lines
24 KiB
GDScript
## 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()
|
|
GameState.boundary_positions.clear()
|
|
|
|
|
|
func after_test() -> void:
|
|
GameState.visible_positions.clear()
|
|
GameState.visible_tiles.clear()
|
|
GameState.visibility_sectors.clear()
|
|
GameState.boundary_positions.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()
|
|
|
|
|
|
# -- Sprint 23: BoundaryWall handling (#585) ----------------------------------
|
|
|
|
func test_boundary_positions_populated_from_snapshot() -> void:
|
|
# #585: BoundaryWall tiles go to boundary_positions (not visible_positions).
|
|
# Fog lifts for boundary wall tiles so wall content composites correctly.
|
|
GameState.apply_snapshot({
|
|
"tick": 20,
|
|
"visible_tiles": [
|
|
{"x": 10, "y": 10, "z": 0, "visibility": "Forward", "type": "floor"},
|
|
{"x": 11, "y": 10, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
|
|
],
|
|
})
|
|
assert_bool(GameState.visible_positions.has(Vector2i(10, 10))).override_failure_message(
|
|
"Forward tile must be in visible_positions"
|
|
).is_true()
|
|
assert_bool(GameState.visible_positions.has(Vector2i(11, 10))).override_failure_message(
|
|
"BoundaryWall tile must NOT be in visible_positions (#585)"
|
|
).is_false()
|
|
assert_bool(GameState.boundary_positions.has(Vector2i(11, 10))).override_failure_message(
|
|
"BoundaryWall tile must be in boundary_positions (#585)"
|
|
).is_true()
|
|
|
|
|
|
func test_boundary_wall_vis_forward_not_exp_visible() -> void:
|
|
# #585: BoundaryWall tiles get VIS_FORWARD (fog lifted) but NOT EXP_VISIBLE.
|
|
# They render through fog but are not stored as exploration memory.
|
|
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.boundary_positions = {Vector2i(6, 5): true}
|
|
GameState.visible_tiles = [
|
|
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
|
|
{"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
|
|
]
|
|
fog_state.update_from_state()
|
|
|
|
var vis_bytes = fog_state.get("_vis_bytes")
|
|
var exp_bytes = fog_state.get("_exp_bytes")
|
|
if vis_bytes == null or exp_bytes == null:
|
|
push_warning("TestFogSprint22: byte arrays not accessible — skipped")
|
|
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 := 6 - ox
|
|
var py := 5 - oy
|
|
if px < 0 or py < 0 or px >= w:
|
|
push_warning("TestFogSprint22: boundary tile (6,5) out of bounds — skipped")
|
|
return
|
|
var idx := py * w + px
|
|
if idx < 0 or idx >= vis_bytes.size():
|
|
return
|
|
assert_int(vis_bytes[idx]).override_failure_message(
|
|
"BoundaryWall tile must have VIS_FORWARD — fog must lift to composite wall content (#585)"
|
|
).is_equal(fog_state.VIS_FORWARD)
|
|
assert_int(exp_bytes[idx]).override_failure_message(
|
|
"BoundaryWall tile must NOT be EXP_VISIBLE — it is not explored memory (#585)"
|
|
).is_not_equal(fog_state.EXP_VISIBLE)
|
|
|
|
|
|
func test_boundary_wall_stays_unexplored_after_leaving_los() -> void:
|
|
# #585: When BoundaryWall tile leaves LOS, it must NOT decay to EXP_EXPLORED.
|
|
# Normal LOS tiles decay to EXP_EXPLORED when they leave LOS.
|
|
# Boundary tiles must stay EXP_UNEXPLORED — they were never explored.
|
|
var fog_state = _get_fog_state()
|
|
if fog_state == null:
|
|
return
|
|
if not fog_state.has_method("update_from_state"):
|
|
return
|
|
|
|
# Frame 1: BoundaryWall at (6,5) is visible
|
|
GameState.visible_positions = {Vector2i(5, 5): true}
|
|
GameState.boundary_positions = {Vector2i(6, 5): true}
|
|
GameState.visible_tiles = [
|
|
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
|
|
{"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
|
|
]
|
|
fog_state.update_from_state()
|
|
|
|
# Frame 2: both leave LOS
|
|
GameState.visible_positions.clear()
|
|
GameState.boundary_positions.clear()
|
|
GameState.visible_tiles = []
|
|
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 := 6 - ox
|
|
var py := 5 - oy
|
|
if px >= 0 and py >= 0 and px < w:
|
|
var idx := py * w + px
|
|
if idx >= 0 and idx < exp_bytes.size():
|
|
assert_int(exp_bytes[idx]).override_failure_message(
|
|
"BoundaryWall tile must stay EXP_UNEXPLORED after leaving LOS (#585 — not explored memory)"
|
|
).is_equal(fog_state.EXP_UNEXPLORED)
|
|
|
|
|
|
func test_boundary_wall_cleared_on_new_snapshot() -> void:
|
|
# #585: boundary_positions must be cleared each tick — old walls must not persist.
|
|
# BoundaryWall positions shift as the player moves; stale positions would lift fog
|
|
# where no wall exists.
|
|
GameState.apply_snapshot({
|
|
"tick": 30,
|
|
"visible_tiles": [
|
|
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
|
|
{"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
|
|
],
|
|
})
|
|
assert_bool(GameState.boundary_positions.has(Vector2i(6, 5))).is_true()
|
|
|
|
GameState.apply_snapshot({
|
|
"tick": 31,
|
|
"visible_tiles": [
|
|
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
|
|
],
|
|
})
|
|
assert_bool(GameState.boundary_positions.has(Vector2i(6, 5))).override_failure_message(
|
|
"Stale BoundaryWall position must be cleared on next snapshot (#585)"
|
|
).is_false()
|
|
|
|
|
|
# -- 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)
|