Merge remote-tracking branch 'origin/client'
This commit is contained in:
@@ -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
|
- 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)
|
- Sprint skills recognize all team branches (server, client, copy, audio, visual, ci)
|
||||||
- Copy team sprint briefing for Sprint 2 (#368 knowledge vocabulary)
|
- 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
|
### Fixed
|
||||||
- Type safety in GameState visible_tiles loop — validates Dictionary with x/y keys before access (PR #11 review)
|
- 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)
|
- Entity renderer skips redundant modulate.a writes when alpha unchanged (PR #11 review)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ enum ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, ERROR }
|
|||||||
var state: ConnectionState = ConnectionState.DISCONNECTED
|
var state: ConnectionState = ConnectionState.DISCONNECTED
|
||||||
var test_mode: bool = true # Enable test mode for development without Rust server
|
var test_mode: bool = true # Enable test mode for development without Rust server
|
||||||
var _test_tick: int = 0
|
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 _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
|
||||||
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
|
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
|
||||||
|
|
||||||
@@ -27,7 +30,14 @@ signal snapshot_received(snapshot: Dictionary)
|
|||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
if test_mode:
|
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
|
# Change connection state and emit signal
|
||||||
func _set_state(new_state: ConnectionState) -> void:
|
func _set_state(new_state: ConnectionState) -> void:
|
||||||
@@ -162,6 +172,10 @@ func send_input(player_input: Dictionary) -> Error:
|
|||||||
if state != ConnectionState.CONNECTED:
|
if state != ConnectionState.CONNECTED:
|
||||||
return ERR_CONNECTION_ERROR
|
return ERR_CONNECTION_ERROR
|
||||||
if test_mode:
|
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
|
return OK
|
||||||
var action_name := _action_enum_to_wire(player_input.get("action", -1))
|
var action_name := _action_enum_to_wire(player_input.get("action", -1))
|
||||||
if action_name.is_empty():
|
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)
|
push_warning("SimBridge: unknown action enum %s" % action)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# Hardcoded test snapshot matching Protocol format (deterministic per D-010 principle 4).
|
# Dynamic test snapshot — processes queued inputs to move player, generates
|
||||||
# Uses the same {tick, entities, tiles} schema as Protocol.decode_snapshot() returns.
|
# 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:
|
func _test_snapshot() -> Dictionary:
|
||||||
_test_tick += 1
|
_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 {
|
return {
|
||||||
"tick": _test_tick,
|
"tick": _test_tick,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"game_time": {
|
"game_time": {
|
||||||
"day": 0,
|
"day": 0,
|
||||||
"time_of_day": 0,
|
"time_of_day": _test_tick * 10,
|
||||||
"day_phase": "Morning",
|
"day_phase": "Morning",
|
||||||
"paused": false,
|
"paused": false,
|
||||||
},
|
},
|
||||||
"player_facing": "North",
|
"player_facing": _test_facing,
|
||||||
"entities": [
|
"entities": 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",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"tiles": _test_tiles(),
|
"tiles": _test_tiles(),
|
||||||
"visible_tiles": _test_visible_tiles(),
|
"visible_tiles": _test_visible_tiles(),
|
||||||
"visible_positions": _test_visible_positions(),
|
"visible_positions": _test_visible_positions(),
|
||||||
@@ -304,45 +343,109 @@ func _test_tiles() -> Array:
|
|||||||
return tiles
|
return tiles
|
||||||
|
|
||||||
# Test visible tiles with visibility sectors (v2 format)
|
# 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:
|
func _test_visible_tiles() -> Array:
|
||||||
var vtiles: Array = []
|
var vtiles: Array = []
|
||||||
var player_x := 10
|
var px := _test_player_pos.x
|
||||||
var player_y := 10
|
var py := _test_player_pos.y
|
||||||
var radius := 4
|
var radius := 4
|
||||||
var room_x := 7
|
var room_x := 7
|
||||||
var room_y := 7
|
var room_y := 7
|
||||||
var room_w := 8
|
var room_w := 8
|
||||||
var room_h := 8
|
var room_h := 8
|
||||||
|
|
||||||
for x in range(player_x - radius, player_x + radius + 1):
|
for x in range(px - radius, px + radius + 1):
|
||||||
for y in range(player_y - radius, player_y + radius + 1):
|
for y in range(py - radius, py + radius + 1):
|
||||||
var dist := absf(x - player_x) + absf(y - player_y)
|
var dist := absf(x - px) + absf(y - py)
|
||||||
if dist <= radius:
|
if dist <= radius:
|
||||||
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
|
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})
|
vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector})
|
||||||
return vtiles
|
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:
|
func _test_visible_positions() -> Array:
|
||||||
var positions: Array = []
|
var positions: Array = []
|
||||||
var player_x := 10
|
var px := _test_player_pos.x
|
||||||
var player_y := 10
|
var py := _test_player_pos.y
|
||||||
var radius := 4
|
var radius := 4
|
||||||
|
|
||||||
# Room bounds (inner floor area)
|
|
||||||
var room_x := 7
|
var room_x := 7
|
||||||
var room_y := 7
|
var room_y := 7
|
||||||
var room_w := 8
|
var room_w := 8
|
||||||
var room_h := 8
|
var room_h := 8
|
||||||
|
|
||||||
for x in range(player_x - radius, player_x + radius + 1):
|
for x in range(px - radius, px + radius + 1):
|
||||||
for y in range(player_y - radius, player_y + radius + 1):
|
for y in range(py - radius, py + radius + 1):
|
||||||
var dist := absf(x - player_x) + absf(y - player_y)
|
var dist := absf(x - px) + absf(y - py)
|
||||||
if dist <= radius:
|
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:
|
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})
|
positions.append({"x": x, "y": y})
|
||||||
return positions
|
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"
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ func test_game_state_warns_on_missing_player() -> void:
|
|||||||
# -- SimBridge: test data completeness --
|
# -- SimBridge: test data completeness --
|
||||||
|
|
||||||
func test_sim_bridge_test_snapshot_has_tiles() -> void:
|
func test_sim_bridge_test_snapshot_has_tiles() -> void:
|
||||||
SimBridge._test_tick = 0
|
SimBridge.reset_test_state()
|
||||||
var snap = SimBridge._test_snapshot()
|
var snap = SimBridge._test_snapshot()
|
||||||
assert_that(snap.has("tiles")).is_true()
|
assert_that(snap.has("tiles")).is_true()
|
||||||
assert_that(snap.tiles.size()).is_greater(0)
|
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()
|
assert_that(tile.has("type")).is_true()
|
||||||
|
|
||||||
func test_sim_bridge_test_snapshot_has_visible_positions() -> void:
|
func test_sim_bridge_test_snapshot_has_visible_positions() -> void:
|
||||||
SimBridge._test_tick = 0
|
SimBridge.reset_test_state()
|
||||||
var snap = SimBridge._test_snapshot()
|
var snap = SimBridge._test_snapshot()
|
||||||
assert_that(snap.has("visible_positions")).is_true()
|
assert_that(snap.has("visible_positions")).is_true()
|
||||||
assert_that(snap.visible_positions.size()).is_greater(0)
|
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()
|
assert_that(pos.has("y")).is_true()
|
||||||
|
|
||||||
func test_sim_bridge_test_snapshot_has_player_entity() -> void:
|
func test_sim_bridge_test_snapshot_has_player_entity() -> void:
|
||||||
SimBridge._test_tick = 0
|
SimBridge.reset_test_state()
|
||||||
var snap = SimBridge._test_snapshot()
|
var snap = SimBridge._test_snapshot()
|
||||||
var has_player := false
|
var has_player := false
|
||||||
for entity in snap.entities:
|
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()
|
assert_that(has_player).is_true()
|
||||||
|
|
||||||
func test_sim_bridge_test_snapshot_has_npc() -> void:
|
func test_sim_bridge_test_snapshot_has_npc() -> void:
|
||||||
SimBridge._test_tick = 0
|
SimBridge.reset_test_state()
|
||||||
var snap = SimBridge._test_snapshot()
|
var snap = SimBridge._test_snapshot()
|
||||||
var has_npc := false
|
var has_npc := false
|
||||||
for entity in snap.entities:
|
for entity in snap.entities:
|
||||||
@@ -162,7 +162,7 @@ func test_sim_bridge_test_snapshot_has_npc() -> void:
|
|||||||
assert_that(has_npc).is_true()
|
assert_that(has_npc).is_true()
|
||||||
|
|
||||||
func test_sim_bridge_test_tiles_contain_all_types() -> void:
|
func test_sim_bridge_test_tiles_contain_all_types() -> void:
|
||||||
SimBridge._test_tick = 0
|
SimBridge.reset_test_state()
|
||||||
var snap = SimBridge._test_snapshot()
|
var snap = SimBridge._test_snapshot()
|
||||||
var types: Dictionary = {}
|
var types: Dictionary = {}
|
||||||
for tile in snap.tiles:
|
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()
|
assert_that(types.has("door")).is_true()
|
||||||
|
|
||||||
func test_sim_bridge_test_snapshot_has_v2_fields() -> void:
|
func test_sim_bridge_test_snapshot_has_v2_fields() -> void:
|
||||||
SimBridge._test_tick = 0
|
SimBridge.reset_test_state()
|
||||||
var snap = SimBridge._test_snapshot()
|
var snap = SimBridge._test_snapshot()
|
||||||
assert_that(snap.has("version")).is_true()
|
assert_that(snap.has("version")).is_true()
|
||||||
assert_that(snap.version).is_equal(2)
|
assert_that(snap.version).is_equal(2)
|
||||||
|
|||||||
@@ -51,8 +51,25 @@ func test_missing_fields_partial_update() -> void:
|
|||||||
assert_that(GameState.player_position).is_equal(Vector2(10, 15))
|
assert_that(GameState.player_position).is_equal(Vector2(10, 15))
|
||||||
assert_that(GameState.current_tick).is_equal(2)
|
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:
|
func test_sim_bridge_test_snapshot_deterministic() -> void:
|
||||||
SimBridge._test_tick = 0
|
SimBridge.reset_test_state()
|
||||||
var snap1 = SimBridge._test_snapshot()
|
var snap1 = SimBridge._test_snapshot()
|
||||||
var snap2 = SimBridge._test_snapshot()
|
var snap2 = SimBridge._test_snapshot()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -7,6 +7,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
|||||||
use settled_reach_server::bridge::tcp::TcpBridge;
|
use settled_reach_server::bridge::tcp::TcpBridge;
|
||||||
use settled_reach_server::bridge::{BridgePlugin, BridgeResource, ServerRunning};
|
use settled_reach_server::bridge::{BridgePlugin, BridgeResource, ServerRunning};
|
||||||
use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin};
|
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::perception::vision_cone::Facing;
|
||||||
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||||
use settled_reach_server::simulation::SimulationPlugin;
|
use settled_reach_server::simulation::SimulationPlugin;
|
||||||
@@ -42,12 +43,22 @@ fn main() {
|
|||||||
app.add_plugins(KnowledgePlugin);
|
app.add_plugins(KnowledgePlugin);
|
||||||
app.insert_resource(BridgeResource::new(bridge));
|
app.insert_resource(BridgeResource::new(bridge));
|
||||||
app.insert_resource(WalkabilityMap::new(32, 32, 1));
|
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((
|
app.world_mut().spawn((
|
||||||
PlayerCharacter,
|
PlayerCharacter,
|
||||||
TilePosition::new(16, 16, 0),
|
TilePosition::new(16, 16, 0),
|
||||||
Facing::default(),
|
Facing::default(),
|
||||||
KnowledgeGraph::new(),
|
KnowledgeGraph::new(),
|
||||||
));
|
));
|
||||||
|
app.world_mut()
|
||||||
|
.spawn((Npc, TilePosition::new(16, 13, 0)));
|
||||||
|
|
||||||
tracing::info!("Simulation initialized, entering game loop");
|
tracing::info!("Simulation initialized, entering game loop");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user