## 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)