From 33b26c1a15573be6286ec63e96f27845c5947316 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 4 Mar 2026 20:33:08 +0100 Subject: [PATCH] test(client): add BoundaryWall fog tests and document tile_renderer behavior (#585) 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 --- client/scripts/rendering/tile_renderer.gd | 8 +- client/tests/test_fog_sprint22.gd | 135 ++++++++++++++++++++++ 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/client/scripts/rendering/tile_renderer.gd b/client/scripts/rendering/tile_renderer.gd index d96050006..f9081a8e7 100644 --- a/client/scripts/rendering/tile_renderer.gd +++ b/client/scripts/rendering/tile_renderer.gd @@ -64,11 +64,17 @@ func _setup_tileset() -> void: tile_set = ts # Update tiles from snapshot data -# tiles: Array of {x: int, y: int, z: int, type: String} +# tiles: Array of {x: int, y: int, z: int, type: String, visibility: String (optional)} # z here is the server-side FLOOR LEVEL (0 = ground, 1 = first floor, etc.), # NOT the Godot scene z_index (which controls render order within a floor). # This node only renders floor-level 0. Higher floor levels will be handled # by separate TileMapLayer nodes when multi-floor rendering is implemented. +# +# BoundaryWall tiles (#585): visibility="BoundaryWall" tiles (wall tiles 1 step beyond +# LOS boundary) are rendered normally here — they have a "type" field from protocol.gd +# so they composite correctly under the fog shader. The fog/exploration exemption is +# handled in fog_state.gd (VIS_FORWARD without EXP_VISIBLE) and game_state.gd +# (boundary_positions not visible_positions). No special handling needed in this method. func update_tiles(tiles: Array) -> void: if not _initialized: return diff --git a/client/tests/test_fog_sprint22.gd b/client/tests/test_fog_sprint22.gd index fb71b84da..3f851298c 100644 --- a/client/tests/test_fog_sprint22.gd +++ b/client/tests/test_fog_sprint22.gd @@ -24,12 +24,14 @@ 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) --------------------------------------------------- @@ -489,6 +491,139 @@ func test_visible_positions_cleared_on_new_snapshot() -> void: 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: