feat(client): Sprint 2 proof E2E tests + dynamic test snapshot (#357)

Three E2E proof tests verify all acceptance criteria through the real
server pipeline: player movement, v2 snapshot with visible_tiles,
wall hiding (NPC behind wall invisible), corner reveal (move around
wall to see NPC). Dynamic test snapshot tracks player position from
queued inputs with simple LOS and Manhattan-distance visibility for
standalone demo mode. 88 client + 112 server tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 01:45:54 +01:00
co-authored by Claude Opus 4.6
parent a7e2c8fbf4
commit 0db120ed58
4 changed files with 327 additions and 46 deletions
+139 -40
View File
@@ -6,6 +6,9 @@ enum ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, ERROR }
var state: ConnectionState = ConnectionState.DISCONNECTED
var test_mode: bool = true # Enable test mode for development without Rust server
var _test_tick: int = 0
var _test_player_pos: Vector2i = Vector2i(10, 10)
var _test_facing: String = "North"
var _test_input_queue: Array = [] # Queued actions for test mode
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
@@ -27,7 +30,14 @@ signal snapshot_received(snapshot: Dictionary)
func _ready() -> void:
if test_mode:
print("SimBridge: Running in test mode (hardcoded snapshot)")
print("SimBridge: Running in test mode (dynamic snapshot)")
# Reset test state — call before tests that use _test_snapshot()
func reset_test_state() -> void:
_test_tick = 0
_test_player_pos = Vector2i(10, 10)
_test_facing = "North"
_test_input_queue.clear()
# Change connection state and emit signal
func _set_state(new_state: ConnectionState) -> void:
@@ -162,6 +172,10 @@ func send_input(player_input: Dictionary) -> Error:
if state != ConnectionState.CONNECTED:
return ERR_CONNECTION_ERROR
if test_mode:
var action: int = player_input.get("action", -1)
var wire_name: String = _action_enum_to_wire(action)
if not wire_name.is_empty():
_test_input_queue.append(wire_name)
return OK
var action_name := _action_enum_to_wire(player_input.get("action", -1))
if action_name.is_empty():
@@ -234,38 +248,59 @@ static func _action_enum_to_wire(action: int) -> String:
push_warning("SimBridge: unknown action enum %s" % action)
return ""
# Hardcoded test snapshot matching Protocol format (deterministic per D-010 principle 4).
# Uses the same {tick, entities, tiles} schema as Protocol.decode_snapshot() returns.
# Dynamic test snapshot — processes queued inputs to move player, generates
# visibility based on current position. Matches Protocol.decode_snapshot() format.
func _test_snapshot() -> Dictionary:
_test_tick += 1
# Process queued inputs
for action_name in _test_input_queue:
var delta := _action_to_delta(action_name)
var new_pos := _test_player_pos + delta
if _test_is_walkable(new_pos):
_test_player_pos = new_pos
if delta != Vector2i.ZERO:
_test_facing = _delta_to_facing(delta)
_test_input_queue.clear()
var px := _test_player_pos.x
var py := _test_player_pos.y
# Build entities — player always visible
var entities: Array = [{
"entity_id": 1,
"x": float(px),
"y": float(py),
"z": 0,
"kind": { "variant": "Player", "data": null },
"visibility": "Forward",
}]
# NPC at (12, 9) — visible if within range and not blocked by wall at (12, 9)
var npc_pos := Vector2i(12, 9)
var npc_dist := absi(px - npc_pos.x) + absi(py - npc_pos.y)
if npc_dist <= 4 and _test_has_los(Vector2i(px, py), npc_pos):
var sector: String = "Forward" if npc_pos.y <= py else "Peripheral"
entities.append({
"entity_id": 2,
"x": float(npc_pos.x),
"y": float(npc_pos.y),
"z": 0,
"kind": { "variant": "Npc", "data": null },
"visibility": sector,
})
return {
"tick": _test_tick,
"version": 2,
"game_time": {
"day": 0,
"time_of_day": 0,
"time_of_day": _test_tick * 10,
"day_phase": "Morning",
"paused": false,
},
"player_facing": "North",
"entities": [
{
"entity_id": 1,
"x": 10.0,
"y": 10.0,
"z": 0,
"kind": { "variant": "Player", "data": null },
"visibility": "Forward",
},
{
"entity_id": 2,
"x": 12.0,
"y": 10.0,
"z": 0,
"kind": { "variant": "Npc", "data": null },
"visibility": "Peripheral",
},
],
"player_facing": _test_facing,
"entities": entities,
"tiles": _test_tiles(),
"visible_tiles": _test_visible_tiles(),
"visible_positions": _test_visible_positions(),
@@ -304,45 +339,109 @@ func _test_tiles() -> Array:
return tiles
# Test visible tiles with visibility sectors (v2 format)
# Tiles ahead of the player (y <= player_y) are Forward, others Peripheral.
# Tiles ahead of the player are Forward, others Peripheral.
func _test_visible_tiles() -> Array:
var vtiles: Array = []
var player_x := 10
var player_y := 10
var px := _test_player_pos.x
var py := _test_player_pos.y
var radius := 4
var room_x := 7
var room_y := 7
var room_w := 8
var room_h := 8
for x in range(player_x - radius, player_x + radius + 1):
for y in range(player_y - radius, player_y + radius + 1):
var dist := absf(x - player_x) + absf(y - player_y)
for x in range(px - radius, px + radius + 1):
for y in range(py - radius, py + radius + 1):
var dist := absf(x - px) + absf(y - py)
if dist <= radius:
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
var sector: String = "Forward" if y <= player_y else "Peripheral"
var sector: String = "Forward" if y <= py else "Peripheral"
vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector})
return vtiles
# Test visibility: player at (10,10) can see tiles within radius 4, blocked by walls
# Test visibility: tiles within radius 4 of player, inside room bounds
func _test_visible_positions() -> Array:
var positions: Array = []
var player_x := 10
var player_y := 10
var px := _test_player_pos.x
var py := _test_player_pos.y
var radius := 4
# Room bounds (inner floor area)
var room_x := 7
var room_y := 7
var room_w := 8
var room_h := 8
for x in range(player_x - radius, player_x + radius + 1):
for y in range(player_y - radius, player_y + radius + 1):
var dist := absf(x - player_x) + absf(y - player_y)
for x in range(px - radius, px + radius + 1):
for y in range(py - radius, py + radius + 1):
var dist := absf(x - px) + absf(y - py)
if dist <= radius:
# Walls are visible but block further vision
# For test purposes, include all tiles within radius that are inside the room
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
positions.append({"x": x, "y": y})
return positions
# -- Test mode helpers --
const _TEST_WALLS: Array = [
# Room walls (8x8 room from (7,7) to (14,14))
Vector2i(7,7), Vector2i(8,7), Vector2i(9,7), Vector2i(10,7),
Vector2i(11,7), Vector2i(12,7), Vector2i(13,7), Vector2i(14,7),
Vector2i(7,14), Vector2i(8,14), Vector2i(9,14), Vector2i(10,14),
Vector2i(11,14), Vector2i(12,14), Vector2i(13,14), Vector2i(14,14),
Vector2i(7,8), Vector2i(7,9), Vector2i(7,10), Vector2i(7,11),
Vector2i(7,12), Vector2i(7,13),
Vector2i(14,8), Vector2i(14,9), Vector2i(14,10), Vector2i(14,11),
Vector2i(14,12), Vector2i(14,13),
# Interior wall blocking NPC
Vector2i(12, 10),
]
func _test_is_walkable(pos: Vector2i) -> bool:
return not _TEST_WALLS.has(pos)
# Simple LOS check — blocked if a wall tile sits between start and end
func _test_has_los(from: Vector2i, to: Vector2i) -> bool:
# Bresenham-lite: check tiles along the line
var dx := absi(to.x - from.x)
var dy := absi(to.y - from.y)
var sx := 1 if from.x < to.x else -1
var sy := 1 if from.y < to.y else -1
var err := dx - dy
var cx := from.x
var cy := from.y
while true:
if cx == to.x and cy == to.y:
return true
if Vector2i(cx, cy) != from and not _test_is_walkable(Vector2i(cx, cy)):
return false
var e2 := 2 * err
if e2 > -dy:
err -= dy
cx += sx
if e2 < dx:
err += dx
cy += sy
return true
static func _action_to_delta(action_name: String) -> Vector2i:
match action_name:
"MoveNorth": return Vector2i(0, -1)
"MoveNortheast": return Vector2i(1, -1)
"MoveEast": return Vector2i(1, 0)
"MoveSoutheast": return Vector2i(1, 1)
"MoveSouth": return Vector2i(0, 1)
"MoveSouthwest": return Vector2i(-1, 1)
"MoveWest": return Vector2i(-1, 0)
"MoveNorthwest": return Vector2i(-1, -1)
_: return Vector2i.ZERO
static func _delta_to_facing(delta: Vector2i) -> String:
match delta:
Vector2i(0, -1): return "North"
Vector2i(1, -1): return "Northeast"
Vector2i(1, 0): return "East"
Vector2i(1, 1): return "Southeast"
Vector2i(0, 1): return "South"
Vector2i(-1, 1): return "Southwest"
Vector2i(-1, 0): return "West"
Vector2i(-1, -1): return "Northwest"
_: return "North"