- Fix autoload parse-order violations: sim_bridge.gd, input_mapper.gd, audio_manager.gd now use load() for class_name types instead of direct references (LocalBridge, ServerProcess, Constants) - Collapse redundant tween validity guard in dialogue_box.gd to is_instance_valid(panel) only - Add clarifying comments to fog test resize assertions (8-tile padding trigger, 32x32 fixture assumption) - Fix test_examine_display_sprint18 case 2: GameState.has() → "field" in GameState (Node vs Dictionary API) - Fix test_game_state_sprint20: rename before_each → before_test (GdUnit4 lifecycle hook) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
396 lines
14 KiB
GDScript
396 lines
14 KiB
GDScript
## Sprint 22 fog state behavioral tests — revived in Sprint 38 (#879).
|
|
## Original: deleted in Sprint 37 (#870 parse-error cleanup).
|
|
## Spec refs: D-059, D-066, #569, #585
|
|
##
|
|
## Coverage: EXP_EXPLORED persistence, grow-only bounds invariant,
|
|
## texture-resize copy, BoundaryWall handling.
|
|
##
|
|
## Uses FogState autoload directly via /root/FogState — byte-level assertions
|
|
## on _vis_bytes and _exp_bytes, consistent with test_fog_shader.gd approach.
|
|
class_name TestFogSprint22
|
|
extends GdUnitTestSuite
|
|
|
|
|
|
# -- Helpers -------------------------------------------------------------------
|
|
|
|
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")
|
|
return node
|
|
|
|
|
|
func _reset_fog_state(fog_state: Node) -> void:
|
|
GameState.visible_positions.clear()
|
|
GameState.boundary_positions.clear()
|
|
GameState.visible_tiles.clear()
|
|
GameState.visibility_sectors.clear()
|
|
# 32x32 is an arbitrary test fixture size — not a production assumption.
|
|
fog_state._resize(Rect2i(0, 0, 32, 32))
|
|
|
|
|
|
# -- EXP_EXPLORED persistence --------------------------------------------------
|
|
## D-059: Previously-seen tiles render as "deep fog" (EXP_EXPLORED = 128).
|
|
## Once a tile enters LOS, leaving LOS must NOT reset it to EXP_UNEXPLORED.
|
|
## This is the core "fog of war memory" invariant.
|
|
|
|
func test_exp_explored_persists_after_leaving_los() -> void:
|
|
var fog_state = _get_fog_state()
|
|
if fog_state == null:
|
|
return
|
|
|
|
_reset_fog_state(fog_state)
|
|
|
|
# Tick 1: tile (2,2) is in LOS → must become EXP_VISIBLE
|
|
GameState.visible_positions = {Vector2i(2, 2): true}
|
|
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 exp: PackedByteArray = fog_state._exp_bytes
|
|
var idx_2_2: int = (2 - oy) * w + (2 - ox)
|
|
assert_int(exp[idx_2_2]).override_failure_message(
|
|
"D-059: visible tile must have EXP_VISIBLE (255) on first sight"
|
|
).is_equal(FogState.EXP_VISIBLE)
|
|
|
|
# Tick 2: tile (2,2) leaves LOS — only (3,3) is visible now
|
|
GameState.visible_positions = {Vector2i(3, 3): true}
|
|
fog_state.update_from_state()
|
|
|
|
# After leaving LOS, (2,2) must be EXP_EXPLORED (128), not EXP_UNEXPLORED (0)
|
|
ox = fog_state.map_bounds.position.x
|
|
oy = fog_state.map_bounds.position.y
|
|
w = fog_state.map_bounds.size.x
|
|
exp = fog_state._exp_bytes
|
|
idx_2_2 = (2 - oy) * w + (2 - ox)
|
|
assert_int(exp[idx_2_2]).override_failure_message(
|
|
"D-059: tile leaving LOS must decay to EXP_EXPLORED (128), not EXP_UNEXPLORED (0)"
|
|
).is_equal(FogState.EXP_EXPLORED)
|
|
|
|
_reset_fog_state(fog_state)
|
|
|
|
|
|
func test_never_seen_tile_stays_unexplored() -> void:
|
|
## Corollary: a tile that was never in LOS stays EXP_UNEXPLORED.
|
|
var fog_state = _get_fog_state()
|
|
if fog_state == null:
|
|
return
|
|
|
|
_reset_fog_state(fog_state)
|
|
|
|
# Tile (5,5) never enters LOS
|
|
GameState.visible_positions = {Vector2i(2, 2): true}
|
|
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 exp: PackedByteArray = fog_state._exp_bytes
|
|
var idx_5_5: int = (5 - oy) * w + (5 - ox)
|
|
assert_int(exp[idx_5_5]).override_failure_message(
|
|
"D-059: tile never in LOS must remain EXP_UNEXPLORED (0)"
|
|
).is_equal(FogState.EXP_UNEXPLORED)
|
|
|
|
_reset_fog_state(fog_state)
|
|
|
|
|
|
func test_exp_explored_not_overwritten_by_subsequent_invisible_ticks() -> void:
|
|
## EXP_EXPLORED must not decay further after the player moves away.
|
|
## If the player is never in the area again, the tile stays at EXP_EXPLORED.
|
|
var fog_state = _get_fog_state()
|
|
if fog_state == null:
|
|
return
|
|
|
|
_reset_fog_state(fog_state)
|
|
|
|
# Tick 1: see tile (4,4)
|
|
GameState.visible_positions = {Vector2i(4, 4): true}
|
|
fog_state.update_from_state()
|
|
|
|
# Tick 2: player moves far away, (4,4) out of LOS
|
|
GameState.visible_positions = {Vector2i(20, 20): true}
|
|
fog_state.update_from_state()
|
|
|
|
# Tick 3: player stays far away
|
|
GameState.visible_positions = {Vector2i(20, 20): true}
|
|
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 exp: PackedByteArray = fog_state._exp_bytes
|
|
var idx_4_4: int = (4 - oy) * w + (4 - ox)
|
|
assert_int(exp[idx_4_4]).override_failure_message(
|
|
"D-059: EXP_EXPLORED must not decay further once set — tile stays at 128"
|
|
).is_equal(FogState.EXP_EXPLORED)
|
|
|
|
_reset_fog_state(fog_state)
|
|
|
|
|
|
# -- Grow-only bounds invariant ------------------------------------------------
|
|
## D-059: map_bounds only ever grows. Previously-explored tiles that leave the
|
|
## visible area must not be evicted from the texture. The bounds never shrink.
|
|
|
|
func test_bounds_grow_when_player_moves_to_new_area() -> void:
|
|
var fog_state = _get_fog_state()
|
|
if fog_state == null:
|
|
return
|
|
|
|
_reset_fog_state(fog_state)
|
|
|
|
# Tick 1: small area visible
|
|
GameState.visible_positions = {Vector2i(2, 2): true, Vector2i(3, 3): true}
|
|
fog_state.update_from_state()
|
|
var bounds_after_t1: Rect2i = fog_state.map_bounds
|
|
|
|
# Tick 2: player moves to a larger area
|
|
GameState.visible_positions = {Vector2i(20, 20): true, Vector2i(25, 25): true}
|
|
fog_state.update_from_state()
|
|
var bounds_after_t2: Rect2i = fog_state.map_bounds
|
|
|
|
# Bounds must have grown or stayed the same — never shrunk
|
|
assert_bool(bounds_after_t2.size.x >= bounds_after_t1.size.x).override_failure_message(
|
|
"D-059: map_bounds width must never shrink (grow-only invariant)"
|
|
).is_true()
|
|
assert_bool(bounds_after_t2.size.y >= bounds_after_t1.size.y).override_failure_message(
|
|
"D-059: map_bounds height must never shrink (grow-only invariant)"
|
|
).is_true()
|
|
|
|
_reset_fog_state(fog_state)
|
|
|
|
|
|
func test_bounds_contain_new_visible_positions() -> void:
|
|
## After update_from_state, all visible positions must lie within map_bounds.
|
|
var fog_state = _get_fog_state()
|
|
if fog_state == null:
|
|
return
|
|
|
|
_reset_fog_state(fog_state)
|
|
|
|
GameState.visible_positions = {Vector2i(10, 5): true, Vector2i(15, 12): true}
|
|
fog_state.update_from_state()
|
|
var bounds: Rect2i = fog_state.map_bounds
|
|
|
|
for pos in GameState.visible_positions:
|
|
assert_bool(bounds.has_point(pos)).override_failure_message(
|
|
"D-059: visible position %s must be within map_bounds %s" % [pos, bounds]
|
|
).is_true()
|
|
|
|
_reset_fog_state(fog_state)
|
|
|
|
|
|
func test_bounds_encompass_previous_area_after_player_moves() -> void:
|
|
## Old area coordinates must still be within map_bounds after player moves away.
|
|
var fog_state = _get_fog_state()
|
|
if fog_state == null:
|
|
return
|
|
|
|
_reset_fog_state(fog_state)
|
|
|
|
# Tick 1: see area around (2,2)
|
|
GameState.visible_positions = {Vector2i(2, 2): true}
|
|
fog_state.update_from_state()
|
|
|
|
# Tick 2: player moves far away
|
|
GameState.visible_positions = {Vector2i(20, 20): true}
|
|
fog_state.update_from_state()
|
|
|
|
# The original tile (2,2) must still be within map_bounds
|
|
var bounds: Rect2i = fog_state.map_bounds
|
|
assert_bool(bounds.has_point(Vector2i(2, 2))).override_failure_message(
|
|
"D-059: grow-only — previously-visited area (2,2) must remain within map_bounds"
|
|
).is_true()
|
|
|
|
_reset_fog_state(fog_state)
|
|
|
|
|
|
# -- Texture-resize copy -------------------------------------------------------
|
|
## D-059: When bounds grow (resize), exploration data from the old bounds
|
|
## must be preserved in the new texture at the correct offsets.
|
|
## This is the "texture-resize copy" invariant.
|
|
|
|
func test_exploration_data_preserved_across_resize() -> void:
|
|
var fog_state = _get_fog_state()
|
|
if fog_state == null:
|
|
return
|
|
|
|
_reset_fog_state(fog_state)
|
|
|
|
# Tick 1: mark (3,3) as explored
|
|
GameState.visible_positions = {Vector2i(3, 3): true}
|
|
fog_state.update_from_state()
|
|
|
|
# Move far enough to trigger a resize: _grow_bounds_from_positions adds 8-tile padding,
|
|
# so (25,25) expands the bounds beyond the 32x32 fixture set in _reset_fog_state.
|
|
GameState.visible_positions = {Vector2i(25, 25): true}
|
|
fog_state.update_from_state()
|
|
|
|
# After resize, (3,3) must still be EXP_EXPLORED (not reset to EXP_UNEXPLORED)
|
|
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 exp: PackedByteArray = fog_state._exp_bytes
|
|
var idx_3_3: int = (3 - oy) * w + (3 - ox)
|
|
assert_int(exp[idx_3_3]).override_failure_message(
|
|
"D-059: exploration state (EXP_EXPLORED=128) must survive texture resize"
|
|
).is_equal(FogState.EXP_EXPLORED)
|
|
|
|
_reset_fog_state(fog_state)
|
|
|
|
|
|
func test_newly_added_area_starts_unexplored_after_resize() -> void:
|
|
## When bounds grow to include a new area, those new tiles start as EXP_UNEXPLORED.
|
|
## The copy preserves old data; new tiles get the default (0).
|
|
var fog_state = _get_fog_state()
|
|
if fog_state == null:
|
|
return
|
|
|
|
_reset_fog_state(fog_state)
|
|
|
|
# Establish a small explored area
|
|
GameState.visible_positions = {Vector2i(2, 2): true}
|
|
fog_state.update_from_state()
|
|
|
|
# Move far enough to trigger a resize: _grow_bounds_from_positions adds 8-tile padding,
|
|
# so (30,30) expands the bounds beyond the 32x32 fixture set in _reset_fog_state.
|
|
GameState.visible_positions = {Vector2i(30, 30): true}
|
|
fog_state.update_from_state()
|
|
|
|
# A completely new tile (30,30) on this tick should be EXP_VISIBLE (just entered LOS)
|
|
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 exp: PackedByteArray = fog_state._exp_bytes
|
|
var idx_30_30: int = (30 - oy) * w + (30 - ox)
|
|
assert_int(exp[idx_30_30]).override_failure_message(
|
|
"D-059: tile first entering LOS after resize must be EXP_VISIBLE (255)"
|
|
).is_equal(FogState.EXP_VISIBLE)
|
|
|
|
_reset_fog_state(fog_state)
|
|
|
|
|
|
# -- BoundaryWall handling (#585) ----------------------------------------------
|
|
## BoundaryWall margin tiles: fog lifts (VIS_FORWARD) so wall content composites,
|
|
## but they do NOT persist as explored (not in visible_positions or _exp_bytes).
|
|
|
|
func test_boundary_wall_vis_bytes_are_forward() -> void:
|
|
## #585: BoundaryWall tiles must receive VIS_FORWARD in the vis texture
|
|
## so the wall sprite composites correctly (not occluded by fog).
|
|
var fog_state = _get_fog_state()
|
|
if fog_state == null:
|
|
return
|
|
|
|
_reset_fog_state(fog_state)
|
|
|
|
GameState.visible_positions = {Vector2i(5, 5): true}
|
|
GameState.boundary_positions = {Vector2i(6, 5): true}
|
|
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 boundary_idx: int = (5 - oy) * w + (6 - ox)
|
|
assert_int(vis[boundary_idx]).override_failure_message(
|
|
"#585: BoundaryWall tile must have VIS_FORWARD (255) in vis texture"
|
|
).is_equal(FogState.VIS_FORWARD)
|
|
|
|
_reset_fog_state(fog_state)
|
|
|
|
|
|
func test_boundary_wall_does_not_persist_as_explored() -> void:
|
|
## #585: BoundaryWall tiles must NOT become EXP_EXPLORED after leaving the area.
|
|
## They are rendering artifacts, not player memory.
|
|
var fog_state = _get_fog_state()
|
|
if fog_state == null:
|
|
return
|
|
|
|
_reset_fog_state(fog_state)
|
|
|
|
# Tick 1: have a boundary wall tile at (6,5)
|
|
GameState.visible_positions = {Vector2i(5, 5): true}
|
|
GameState.boundary_positions = {Vector2i(6, 5): true}
|
|
fog_state.update_from_state()
|
|
|
|
# Tick 2: player moves away; (6,5) is no longer a boundary wall
|
|
GameState.visible_positions = {Vector2i(20, 20): true}
|
|
GameState.boundary_positions.clear()
|
|
fog_state.update_from_state()
|
|
|
|
# (6,5) must not be EXP_EXPLORED — it was never a true explored tile
|
|
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 exp: PackedByteArray = fog_state._exp_bytes
|
|
var boundary_idx: int = (5 - oy) * w + (6 - ox)
|
|
assert_int(exp[boundary_idx]).override_failure_message(
|
|
"#585: BoundaryWall tile must NOT persist as EXP_EXPLORED — only true LOS tiles are explored"
|
|
).is_equal(FogState.EXP_UNEXPLORED)
|
|
|
|
_reset_fog_state(fog_state)
|
|
|
|
|
|
func test_normal_tile_adjacent_to_boundary_still_explored() -> void:
|
|
## The normal LOS tile adjacent to a BoundaryWall must still be marked explored.
|
|
## BoundaryWall exclusion must not affect neighboring tiles.
|
|
var fog_state = _get_fog_state()
|
|
if fog_state == null:
|
|
return
|
|
|
|
_reset_fog_state(fog_state)
|
|
|
|
# Tick 1: normal tile (5,5) in LOS, boundary wall at (6,5)
|
|
GameState.visible_positions = {Vector2i(5, 5): true}
|
|
GameState.boundary_positions = {Vector2i(6, 5): true}
|
|
fog_state.update_from_state()
|
|
|
|
# Tick 2: player moves away
|
|
GameState.visible_positions = {Vector2i(20, 20): true}
|
|
GameState.boundary_positions.clear()
|
|
fog_state.update_from_state()
|
|
|
|
# Normal tile (5,5) must be EXP_EXPLORED
|
|
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 exp: PackedByteArray = fog_state._exp_bytes
|
|
var normal_idx: int = (5 - oy) * w + (5 - ox)
|
|
assert_int(exp[normal_idx]).override_failure_message(
|
|
"#585: normal LOS tile adjacent to BoundaryWall must still be EXP_EXPLORED (128)"
|
|
).is_equal(FogState.EXP_EXPLORED)
|
|
|
|
_reset_fog_state(fog_state)
|
|
|
|
|
|
func test_boundary_wall_visibility_only_when_present() -> void:
|
|
## #585: A tile that is a BoundaryWall in tick 1 but absent in tick 2
|
|
## must have VIS_HIDDEN in tick 2 (fog reapplied).
|
|
var fog_state = _get_fog_state()
|
|
if fog_state == null:
|
|
return
|
|
|
|
_reset_fog_state(fog_state)
|
|
|
|
# Tick 1: boundary wall at (6,5)
|
|
GameState.visible_positions = {Vector2i(5, 5): true}
|
|
GameState.boundary_positions = {Vector2i(6, 5): true}
|
|
fog_state.update_from_state()
|
|
|
|
# Tick 2: player moves far away; (6,5) no longer visible or boundary
|
|
GameState.visible_positions = {Vector2i(20, 20): true}
|
|
GameState.boundary_positions.clear()
|
|
fog_state.update_from_state()
|
|
|
|
# (6,5) must be VIS_HIDDEN — fog returned
|
|
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 boundary_idx: int = (5 - oy) * w + (6 - ox)
|
|
assert_int(vis[boundary_idx]).override_failure_message(
|
|
"#585: BoundaryWall tile must return to VIS_HIDDEN when not in current boundary set"
|
|
).is_equal(FogState.VIS_HIDDEN)
|
|
|
|
_reset_fog_state(fog_state)
|