## Sprint 2 Proof: Fog of Perception (#357) ## Verifies the original acceptance criteria through the full server pipeline. ## ## REVIVED (T-1068): disabled in sprint-36, re-enabled against the current ## protocol (handshake + StartupMessage exchange per #555/#175, streamed ## snapshots). The proof room layout below is still what main.rs ## setup_proof_room() spawns for non-test-mode servers. ## ## Server proof room layout (setup_proof_room in server/src/main.rs): ## (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 # Server move cooldown: the Gauntlet player is Walk stance (ticks_per_move = 2, # server stance.rs). A move input arriving within that window is silently # throttled away (movement.rs::apply_move -> PlayerMoveCooldown::try_move). # Space consecutive sends past it (2 ticks + 1 margin for apply/snapshot lag) # so back-to-back moves can never race the cooldown. const COOLDOWN_TICKS: int = 3 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 ## Find the player entity in a snapshot by kind. static func _find_player(snapshot: Dictionary) -> Dictionary: for entity in snapshot.entities: if entity.kind.variant == "Player": return entity return {} ## Send a batch input and receive a snapshot that reflects it. ## The server free-runs at ~20 ticks/sec and STREAMS a snapshot every tick — ## it is not request/response. Drain stale queued snapshots first, then: ## - expect_position != null (Vector2, render coords): wait until the player ## reaches that position and assert it. Deterministic under load — tick ## margins race the free-running server when the test process is descheduled ## between drain and send. ## - expect_position == null: wait for a snapshot at least 2 ticks past the ## drain point (enough for no-op actions). func _send_and_receive( action_name: String, tick: int = 0, expect_position: Variant = null, action_data: Variant = null, ) -> Variant: # Drain queued stale snapshots, remembering the newest tick seen. var last_tick: int = -1 _bridge.poll() var pending := _bridge.poll_message() while pending.size() > 0: var stale: Variant = Protocol.decode_snapshot(pending) if stale != null: last_tick = stale.tick _bridge.poll() pending = _bridge.poll_message() var inputs: Array = [{"tick": tick, "action_name": action_name}] if action_data != null: inputs[0]["action_data"] = action_data 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) # Wait for a snapshot that reflects the processed input. var min_tick: int = last_tick + 2 var snapshot: Variant = null var elapsed := 0.0 while elapsed < RESPONSE_TIMEOUT: _bridge.poll() var msg := _bridge.poll_message() if msg.size() > 0: var decoded: Variant = Protocol.decode_snapshot(msg) if decoded == null: continue # undecodable frame — keep draining if expect_position != null: snapshot = decoded # keep latest so a timeout reports actual state var player := _find_player(decoded) if ( not player.is_empty() and is_equal_approx(player.x, expect_position.x) and is_equal_approx(player.y, expect_position.y) ): break elif decoded.tick >= min_tick: snapshot = decoded break continue await get_tree().create_timer(0.05).timeout elapsed += 0.05 assert_that(snapshot).override_failure_message( "no snapshot received within %.1fs after '%s'" % [RESPONSE_TIMEOUT, action_name] ).is_not_null() if expect_position != null and snapshot != null: var player := _find_player(snapshot) var actual := Vector2(player.x, player.y) if not player.is_empty() else Vector2.INF assert_that(actual.is_equal_approx(expect_position)).override_failure_message( "player must reach %s after '%s' — last seen %s" % [expect_position, action_name, actual] ).is_true() # Space the next move past the per-stance cooldown. The server silently # drops a move that arrives within ticks_per_move ticks of the previous # one; without this wait, back-to-back sends race the cooldown and an # occasional move is throttled, leaving the player one tile short (flaky). # Wait for the server clock to advance COOLDOWN_TICKS past the landing # tick so the next send can never be throttled. The player is stationary # meanwhile, so the fresher snapshot is still valid for the caller. var landed_tick: int = int(snapshot.tick) var spacing_elapsed := 0.0 while spacing_elapsed < RESPONSE_TIMEOUT: _bridge.poll() var m := _bridge.poll_message() if m.size() > 0: var d: Variant = Protocol.decode_snapshot(m) if d != null: snapshot = d if int(d.tick) >= landed_tick + COOLDOWN_TICKS: break continue await get_tree().create_timer(0.05).timeout spacing_elapsed += 0.05 return snapshot ## Protocol handshake (#555, #175): the server sends a HandshakeMessage as the ## first framed message; the client validates it and replies with a ## StartupMessage carrying world_seed before any input is accepted. ## Mirrors sim_bridge.gd's HANDSHAKING state. func _do_handshake(world_seed: int = 42) -> bool: var msg := PackedByteArray() var elapsed := 0.0 while elapsed < CONNECT_TIMEOUT: _bridge.poll() msg = _bridge.poll_message() if msg.size() > 0: break await get_tree().create_timer(0.05).timeout elapsed += 0.05 if msg.is_empty(): push_warning("No HandshakeMessage received within %.1fs" % CONNECT_TIMEOUT) return false var MP = load("res://addons/messagepack/messagepack.gd") var decoded: Variant = MP.decode(msg) if decoded.status != null or not (decoded.value is Dictionary): push_warning("Malformed HandshakeMessage") return false var startup_bytes := Protocol.encode_startup_message(world_seed) if startup_bytes.is_empty(): return false return _bridge.send_message(startup_bytes) == OK ## Spawn the server, connect, and complete the protocol handshake. ## Binary absence is handled by per-test `_do_skip` — by the time this runs the ## binary exists, so any failure here is a real failure (asserted loudly). func _connect_to_server() -> bool: var server_path := _server_binary_path() var spawned := await _spawn_server(server_path) assert_bool(spawned).override_failure_message( "server spawn failed after %d port attempts" % MAX_PORT_ATTEMPTS ).is_true() if not spawned: return false _bridge = LocalBridge.new() var connected := false 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") break 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: connected = true break if _bridge.get_status() == StreamPeerTCP.STATUS_ERROR: _bridge.disconnect_from_server() _bridge.reset() await get_tree().create_timer(0.1).timeout elapsed += 0.1 assert_bool(connected).override_failure_message( "TCP connect to spawned server failed within %.1fs" % CONNECT_TIMEOUT ).is_true() if not connected: return false # Handshake + startup exchange (#555, #175) — required before input is accepted var handshake_ok := await _do_handshake() assert_bool(handshake_ok).override_failure_message( "protocol handshake + StartupMessage exchange must complete (#555, #175)" ).is_true() return handshake_ok # -- AC#1, AC#2, AC#3, AC#5: Movement, camera, tiles, fog ------------------------- func test_proof_player_moves_and_v2_snapshot( _do_skip := not FileAccess.file_exists(_server_binary_path()), _skip_reason := "server binary not built — run `cargo build` in server/" ) -> void: var ok := await _connect_to_server() if not ok: return var snapshot: Dictionary = await _send_and_receive("MoveNorth", 0, Vector2(16.5, 15.5)) # AC#1: Player moved from (16,16) to (16,15) var player := _find_player(snapshot) 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.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( _do_skip := not FileAccess.file_exists(_server_binary_path()), _skip_reason := "server binary not built — run `cargo build` in server/" ) -> 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", 0, Vector2(16.5, 15.5)) # 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( _do_skip := not FileAccess.file_exists(_server_binary_path()), _skip_reason := "server binary not built — run `cargo build` in server/" ) -> void: var ok := await _connect_to_server() if not ok: return # Step 1: Move East three times — column x=18 is blocked at (18,14) by # NPC3 (entities are unwalkable), so route around via x=19. # (16,16) → (17,16) → (18,16) → (19,16) await _send_and_receive("MoveEast", 0, Vector2(17.5, 16.5)) await _send_and_receive("MoveEast", 1, Vector2(18.5, 16.5)) await _send_and_receive("MoveEast", 2, Vector2(19.5, 16.5)) # Step 2: Move North past the wall line (y=14) # (19,16) → (19,15) → (19,14) → (19,13) await _send_and_receive("MoveNorth", 3, Vector2(19.5, 15.5)) await _send_and_receive("MoveNorth", 4, Vector2(19.5, 14.5)) await _send_and_receive("MoveNorth", 5, Vector2(19.5, 13.5)) # Step 3: Move West onto (18,13), then LOOK West explicitly — D-252: moves # no longer write Facing, the view changes only via SetFacing. NPC1 at # (16,13) is two tiles dead ahead with no wall between ((17,13) is open). await _send_and_receive("MoveWest", 6, Vector2(18.5, 13.5)) var snapshot: Dictionary = await _send_and_receive( "SetFacing", 7, null, {"facing": "West"} ) # The +2-tick wait can catch the snapshot where the facing field flipped but # the entity-visibility recompute hasn't landed — keep draining (bounded) # until the westward cone content arrives. var settle := 0.0 while settle < RESPONSE_TIMEOUT and not _has_npc_at(snapshot, 16.5, 13.5): _bridge.poll() var msg := _bridge.poll_message() if msg.size() > 0: var decoded: Variant = Protocol.decode_snapshot(msg) if decoded != null: snapshot = decoded continue await get_tree().create_timer(0.05).timeout settle += 0.05 # Player at (18,13) facing West. NPC1 at (16,13) is 2 tiles dead ahead — # inside the vision cone, no wall between. NPC1 should be visible. assert_that(_has_npc_at(snapshot, 16.5, 13.5)).is_true() ## True when the snapshot's visible entities include an Npc at (x, y). func _has_npc_at(snapshot: Dictionary, x: float, y: float) -> bool: for entity in snapshot.entities: if entity.kind.variant == "Npc": if is_equal_approx(entity.x, x) and is_equal_approx(entity.y, y): return true return false