Merge remote-tracking branch 'origin/client'

This commit is contained in:
2026-02-12 02:09:01 +01:00
6 changed files with 369 additions and 47 deletions
+7
View File
@@ -11,9 +11,16 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
- Team-agent mapping in sprint planning — each team has defined default agents for briefing assignment
- Sprint skills recognize all team branches (server, client, copy, audio, visual, ci)
- Copy team sprint briefing for Sprint 2 (#368 knowledge vocabulary)
- Sprint 2 proof: fog of perception E2E tests (#357) — 3 tests verifying all 7 acceptance criteria through real server pipeline (movement, tiles, fog, wall hiding, corner reveal)
- Server proof room — wall at (16,14) between player at (16,16) and NPC at (16,13) for LOS testing
- Dynamic test snapshot in SimBridge — tracks player position from queued inputs, Bresenham LOS, Manhattan-distance visibility for standalone demo mode
- 4 Bresenham LOS unit tests — clear path, wall blocked, diagonal, same position (PR #13 review)
### Fixed
- Type safety in GameState visible_tiles loop — validates Dictionary with x/y keys before access (PR #11 review)
- Consistent reset_test_state() usage across all test files (PR #13 review)
- E2E connection loop now detects server process death early (PR #13 review)
- Corner reveal test verifies NPC position at (16.5, 13.5) (PR #13 review)
- Entity renderer skips redundant modulate.a writes when alpha unchanged (PR #11 review)
### Added
+143 -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,63 @@ 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.
# NOTE: Test coordinate space (player at 10,10; NPC at 12,9; wall at 12,10)
# is intentionally decoupled from the E2E proof room (player at 16,16; NPC at
# 16,13; wall at 16,14). This ensures standalone tests don't depend on server
# map layout and can exercise the rendering pipeline independently.
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, 10)
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 +343,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"
+6 -6
View File
@@ -123,7 +123,7 @@ func test_game_state_warns_on_missing_player() -> void:
# -- SimBridge: test data completeness --
func test_sim_bridge_test_snapshot_has_tiles() -> void:
SimBridge._test_tick = 0
SimBridge.reset_test_state()
var snap = SimBridge._test_snapshot()
assert_that(snap.has("tiles")).is_true()
assert_that(snap.tiles.size()).is_greater(0)
@@ -133,7 +133,7 @@ func test_sim_bridge_test_snapshot_has_tiles() -> void:
assert_that(tile.has("type")).is_true()
func test_sim_bridge_test_snapshot_has_visible_positions() -> void:
SimBridge._test_tick = 0
SimBridge.reset_test_state()
var snap = SimBridge._test_snapshot()
assert_that(snap.has("visible_positions")).is_true()
assert_that(snap.visible_positions.size()).is_greater(0)
@@ -142,7 +142,7 @@ func test_sim_bridge_test_snapshot_has_visible_positions() -> void:
assert_that(pos.has("y")).is_true()
func test_sim_bridge_test_snapshot_has_player_entity() -> void:
SimBridge._test_tick = 0
SimBridge.reset_test_state()
var snap = SimBridge._test_snapshot()
var has_player := false
for entity in snap.entities:
@@ -152,7 +152,7 @@ func test_sim_bridge_test_snapshot_has_player_entity() -> void:
assert_that(has_player).is_true()
func test_sim_bridge_test_snapshot_has_npc() -> void:
SimBridge._test_tick = 0
SimBridge.reset_test_state()
var snap = SimBridge._test_snapshot()
var has_npc := false
for entity in snap.entities:
@@ -162,7 +162,7 @@ func test_sim_bridge_test_snapshot_has_npc() -> void:
assert_that(has_npc).is_true()
func test_sim_bridge_test_tiles_contain_all_types() -> void:
SimBridge._test_tick = 0
SimBridge.reset_test_state()
var snap = SimBridge._test_snapshot()
var types: Dictionary = {}
for tile in snap.tiles:
@@ -172,7 +172,7 @@ func test_sim_bridge_test_tiles_contain_all_types() -> void:
assert_that(types.has("door")).is_true()
func test_sim_bridge_test_snapshot_has_v2_fields() -> void:
SimBridge._test_tick = 0
SimBridge.reset_test_state()
var snap = SimBridge._test_snapshot()
assert_that(snap.has("version")).is_true()
assert_that(snap.version).is_equal(2)
+18 -1
View File
@@ -51,8 +51,25 @@ func test_missing_fields_partial_update() -> void:
assert_that(GameState.player_position).is_equal(Vector2(10, 15))
assert_that(GameState.current_tick).is_equal(2)
func test_sim_bridge_los_clear_path() -> void:
# No wall between (10,10) and (10,8) — clear LOS
assert_that(SimBridge._test_has_los(Vector2i(10, 10), Vector2i(10, 8))).is_true()
func test_sim_bridge_los_blocked_by_wall() -> void:
# Wall at (12,10) blocks LOS from (10,10) to (12,9) via east path
# Direct line from (10,10) → (12,9) passes through (11,10) then (12,10) — wall
assert_that(SimBridge._test_has_los(Vector2i(10, 10), Vector2i(14, 10))).is_false()
func test_sim_bridge_los_diagonal() -> void:
# Diagonal LOS from (10,10) to (11,9) — no wall in path
assert_that(SimBridge._test_has_los(Vector2i(10, 10), Vector2i(11, 9))).is_true()
func test_sim_bridge_los_same_position() -> void:
# LOS to self is always true
assert_that(SimBridge._test_has_los(Vector2i(10, 10), Vector2i(10, 10))).is_true()
func test_sim_bridge_test_snapshot_deterministic() -> void:
SimBridge._test_tick = 0
SimBridge.reset_test_state()
var snap1 = SimBridge._test_snapshot()
var snap2 = SimBridge._test_snapshot()
+184
View File
@@ -0,0 +1,184 @@
## 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) = NPC (16,14) = WALL (16,16) = Player start
## Player facing North → NPC blocked by wall.
## Move East+North around the wall → NPC 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 = snapshot.entities[0]
assert_float(player.x).is_equal_approx(16.5, 0.001)
assert_float(player.y).is_equal_approx(15.5, 0.001)
assert_that(player.kind.variant).is_equal("Player")
# v2 protocol fields present
assert_that(snapshot.version).is_equal(2)
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 NPC at (16,13).
var snapshot: Dictionary = await _send_and_receive("MoveNorth")
# Only player should be visible — NPC is behind wall
var npc_count := 0
for entity in snapshot.entities:
if entity.kind.variant == "Npc":
npc_count += 1
assert_that(npc_count).is_equal(0)
# -- 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. NPC at (16,13) is 2 tiles west —
# within peripheral cone, no wall between. NPC should be visible.
var npc_found := false
for entity in snapshot.entities:
if entity.kind.variant == "Npc":
assert_float(entity.x).is_equal_approx(16.5, 0.001)
assert_float(entity.y).is_equal_approx(13.5, 0.001)
npc_found = true
break
assert_that(npc_found).is_true()
+11
View File
@@ -7,6 +7,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use settled_reach_server::bridge::tcp::TcpBridge;
use settled_reach_server::bridge::{BridgePlugin, BridgeResource, ServerRunning};
use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin};
use settled_reach_server::npc::Npc;
use settled_reach_server::perception::vision_cone::Facing;
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use settled_reach_server::simulation::SimulationPlugin;
@@ -42,12 +43,22 @@ fn main() {
app.add_plugins(KnowledgePlugin);
app.insert_resource(BridgeResource::new(bridge));
app.insert_resource(WalkabilityMap::new(32, 32, 1));
// Proof room: wall at (16,14) between player and NPC
// NPC at (16,13) hidden behind wall until player moves around it
{
let mut wm = app.world_mut().resource_mut::<WalkabilityMap>();
wm.set_walkable(&TilePosition::new(16, 14, 0), false);
}
app.world_mut().spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
));
app.world_mut()
.spawn((Npc, TilePosition::new(16, 13, 0)));
tracing::info!("Simulation initialized, entering game loop");