Files
settled-reach/client/tests/test_sprint2_proof.gd
T
jpmschweitzerandClaude Fable 5 e46aa404fc test(client): corner-reveal proof updated to D-252 semantics + deterministic cone wait
The proof encoded move-writes-facing ('Move West -> now facing West'); under
D-252 the view changes only via SetFacing, so the test now looks West
explicitly (helper gains an action_data passthrough). The +2-tick wait could
catch the facing flip before the visibility recompute — the assertion now
polls (bounded) until the westward cone content lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:15:32 +02:00

351 lines
13 KiB
GDScript

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