## Sprint 2 Proof: Fog of Perception (#357) ## Verifies all 7 acceptance criteria through the full server pipeline: ## AC1: Player moves, AC2: Camera follows (via player_position), ## AC3: Tiles render (visible_tiles non-empty), AC4: Entities via LOS, ## AC5: Fog (not all tiles visible), AC6: Walls hide, AC7: Corner reveal. ## Requires: server binary built (cargo build in server/) ## ## Server proof room layout: ## (16,13) = NPC1 (16,14) = WALL (16,16) = Player start ## (14,18) = NPC2 (18,14) = NPC3 ## Player facing North → NPC1 blocked by wall. ## Move East+North around the wall → NPC1 becomes visible. class_name TestSprint2Proof extends GdUnitTestSuite const CONNECT_TIMEOUT: float = 3.0 const RESPONSE_TIMEOUT: float = 5.0 const MAX_PORT_ATTEMPTS: int = 5 var _server_pid: int = -1 var _bridge: LocalBridge = null var _test_port: int = 0 func _server_binary_path() -> String: var project_dir := ProjectSettings.globalize_path("res://") return project_dir.path_join("../server/target/debug/settled-reach-server") static func _random_test_port() -> int: return 49152 + (randi() % (65535 - 49152 + 1)) func _spawn_server(server_path: String) -> bool: for attempt in range(MAX_PORT_ATTEMPTS): _test_port = _random_test_port() var addr := "127.0.0.1:%d" % _test_port _server_pid = OS.create_process(server_path, [addr]) if _server_pid <= 0: continue await get_tree().create_timer(0.15).timeout if OS.is_process_running(_server_pid): return true _server_pid = -1 return false func after_test() -> void: if _bridge != null: _bridge.disconnect_from_server() _bridge = null if _server_pid > 0 and OS.is_process_running(_server_pid): OS.kill(_server_pid) _server_pid = -1 ## Send a batch input and receive the snapshot response. func _send_and_receive(action_name: String, tick: int = 0) -> Variant: var inputs: Array = [{"tick": tick, "action_name": action_name}] var encoded := Protocol.encode_player_inputs(inputs) assert_that(encoded.size()).is_greater(0) var send_err := _bridge.send_message(encoded) assert_that(send_err).is_equal(OK) var snapshot_bytes := PackedByteArray() var elapsed := 0.0 while elapsed < RESPONSE_TIMEOUT: _bridge.poll() snapshot_bytes = _bridge.poll_message() if snapshot_bytes.size() > 0: break await get_tree().create_timer(0.05).timeout elapsed += 0.05 assert_that(snapshot_bytes.size()).is_greater(0) var snapshot: Variant = Protocol.decode_snapshot(snapshot_bytes) assert_that(snapshot).is_not_null() return snapshot ## Connect to server, returning true on success. func _connect_to_server() -> bool: var server_path := _server_binary_path() if not FileAccess.file_exists(server_path): push_warning("Sprint 2 proof skipped: server binary not found at %s" % server_path) return false var spawned := await _spawn_server(server_path) if not spawned: return false _bridge = LocalBridge.new() var elapsed := 0.0 while elapsed < CONNECT_TIMEOUT: if _server_pid > 0 and not OS.is_process_running(_server_pid): push_warning("Server process died during connection") return false if _bridge.get_status() == StreamPeerTCP.STATUS_NONE: _bridge.connect_to_server("127.0.0.1", _test_port) _bridge.poll() if _bridge.get_status() == StreamPeerTCP.STATUS_CONNECTED: return true if _bridge.get_status() == StreamPeerTCP.STATUS_ERROR: _bridge.disconnect_from_server() _bridge.reset() await get_tree().create_timer(0.1).timeout elapsed += 0.1 return false # -- AC#1, AC#2, AC#3, AC#5: Movement, camera, tiles, fog ------------------------- func test_proof_player_moves_and_v2_snapshot() -> void: var ok := await _connect_to_server() if not ok: return var snapshot: Dictionary = await _send_and_receive("MoveNorth") # AC#1: Player moved from (16,16) to (16,15) var player: Dictionary = {} for entity in snapshot.entities: if entity.kind.variant == "Player": player = entity break assert_that(player.size()).is_greater(0) assert_float(player.x).is_equal_approx(16.5, 0.001) assert_float(player.y).is_equal_approx(15.5, 0.001) # v4 protocol fields present assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION) assert_that(snapshot.player_facing).is_equal("North") assert_that(snapshot.game_time).is_not_null() # AC#3: Tiles flowing through bridge (visible_tiles non-empty) assert_that(snapshot.visible_tiles.size()).is_greater(0) # AC#5: Not all 1024 tiles visible — blind spot behind player assert_that(snapshot.visible_tiles.size()).is_less(1024) # -- AC#6: Wall hides entity ------------------------------------------------------- func test_proof_wall_hides_entity() -> void: var ok := await _connect_to_server() if not ok: return # After MoveNorth: player at (16,15) facing North. # Wall at (16,14) blocks LOS to NPC1 at (16,13). var snapshot: Dictionary = await _send_and_receive("MoveNorth") # NPC1 at (16,13) should be hidden — wall at (16,14) blocks LOS. # Other NPCs (NPC2 at (14,18), NPC3 at (18,14)) may be visible. var hidden_npc_visible := false for entity in snapshot.entities: if entity.kind.variant == "Npc": if is_equal_approx(entity.x, 16.5) and is_equal_approx(entity.y, 13.5): hidden_npc_visible = true break assert_that(hidden_npc_visible).is_false() # -- AC#4, AC#7: Entity appears via LOS / corner reveal ---------------------------- func test_proof_corner_reveal() -> void: var ok := await _connect_to_server() if not ok: return # Step 1: Move East twice to get beside the wall # (16,16) → MoveEast → (17,16) → MoveEast → (18,16) await _send_and_receive("MoveEast", 0) await _send_and_receive("MoveEast", 1) # Step 2: Move North past the wall line (y=14) # (18,16) → MoveNorth → (18,15) → MoveNorth → (18,14) → MoveNorth → (18,13) await _send_and_receive("MoveNorth", 2) await _send_and_receive("MoveNorth", 3) var snapshot: Dictionary = await _send_and_receive("MoveNorth", 4) # Player at (18,13) facing North. NPC1 at (16,13) is 2 tiles west — # within peripheral cone, no wall between. NPC1 should be visible. var npc1_found := false for entity in snapshot.entities: if entity.kind.variant == "Npc": if is_equal_approx(entity.x, 16.5) and is_equal_approx(entity.y, 13.5): npc1_found = true break assert_that(npc1_found).is_true()