Merge remote-tracking branch 'origin/client'

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
2026-02-13 02:09:47 +01:00
15 changed files with 310 additions and 34 deletions
+11
View File
@@ -8,6 +8,17 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
### Added
- Sprint 5 "Live" team briefings — copy (6 tickets), client (2), CI (1), joint coordination for content-at-scale sprint targeting FRIEND packs, voice patterns, NPC style guide, PC-as-NPC authoring, UI microcopy, FactId validation
- Live server mode (`make game`) — single command builds server, launches client with TCP connection, auto-kills server on exit; `make stop` helper for manual cleanup
- `SR_LIVE=1` environment variable switches SimBridge from test mode to real TCP server connection
- TileKind in wire protocol (#412) — server sends Floor/Wall/Door/Object per visible tile, client renders walls and floors in live mode
- Input roundtrip integration test (#411) — spawns real server, connects via TCP, validates full movement and interact pipeline
- Debug logging for received player inputs on server (visible with `RUST_LOG=debug`)
### Fixed
- Interaction prompt target+verb data now attached to Interact action in game loop (#405) — was TODO stub, server receives `{target_entity_id, verb}` payload
- Player entity detection uses `kind.variant == "Player"` instead of hardcoded `entity_id == 1` — fixes "player not found" warnings when connected to real server (which assigns different IDs)
- Movement keys now work in live mode — client was sending millisecond timestamps as input tick, server only processes ticks <= current frame counter; now uses server tick from latest snapshot
- Entity-to-tile alignment in live mode — server sends tile-center render coords (tile 16 → 16.5), entity renderer now floors to tile index before positioning
### Added
- Content loader Phase 2 (#408) — 2-phase spawn pipeline loads real YAML content into ECS entities: enums, entity attributes, pools, templates, triangles, NPC profiles with Want/Tolerance/Contentment/Personality/Tells/Skills axes plus cross-reference resolution for Secrets, Relationships, Information, and DailyRoutine
+17 -2
View File
@@ -1,6 +1,6 @@
GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
.PHONY: help setup build client server test lint ci ci-client ci-server clean \
.PHONY: help setup build client server game stop test lint ci ci-client ci-server clean \
decisions-sync decisions-coverage decisions-active decisions-orphan \
db-backup db-install validate-content content-ron
@@ -14,7 +14,9 @@ help:
@echo ""
@echo " make setup Install dev dependencies (Rust, Godot, tooling)"
@echo " make build Build client and server"
@echo " make client Run the Godot client"
@echo " make game Build and run the full game (server + client)"
@echo " make stop Stop any running server instance"
@echo " make client Run the Godot client (test mode)"
@echo " make server Run the Rust simulation server"
@echo " make test Run all tests"
@echo " make lint Run all linters"
@@ -72,6 +74,19 @@ client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
$(GODOT) --path client
game: stop build-server
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
@echo "Starting server..."
@cd server && cargo run &
@sleep 2
@echo "Starting client..."
@SR_LIVE=1 $(GODOT) --path client
@$(MAKE) stop
stop:
@lsof -ti :9876 | xargs -r kill 2>/dev/null || true
@echo "Stopped any running server on port 9876"
# --- Test ---
test: test-server test-client
+14 -4
View File
@@ -30,19 +30,29 @@ func apply_snapshot(snapshot: Dictionary) -> void:
if snapshot.has("entities"):
visible_entities = snapshot.entities
# Derive player position from the player entity
# Derive player position from the entity with kind.variant == "Player"
var found_player := false
for entity in visible_entities:
if entity.has("entity_id") and entity.entity_id == player_entity_id:
if entity.has("kind") and entity.kind is Dictionary and entity.kind.get("variant") == "Player":
player_position = Vector2(entity.x, entity.y)
if entity.has("entity_id"):
player_entity_id = entity.entity_id
found_player = true
break
if not found_player and visible_entities.size() > 0:
push_warning("GameState: player entity_id %d not found in %d entities" % [
player_entity_id, visible_entities.size()])
push_warning("GameState: no Player entity found in %d entities" % [
visible_entities.size()])
# Tiles for rendering: test mode sends "tiles", live server sends tile data in "visible_tiles"
if snapshot.has("tiles"):
visible_tiles = snapshot.tiles
elif snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
# Live server: visible_tiles now includes type from tile_kind field
var has_type := false
if snapshot.visible_tiles.size() > 0 and snapshot.visible_tiles[0] is Dictionary:
has_type = snapshot.visible_tiles[0].has("type")
if has_type:
visible_tiles = snapshot.visible_tiles
if snapshot.has("visible_positions"):
visible_positions.clear()
+4 -2
View File
@@ -4,7 +4,7 @@ extends Node
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_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects to real server
var _test_tick: int = 0
var _test_player_pos: Vector2i = Vector2i(10, 10)
var _test_facing: String = "North"
@@ -181,7 +181,9 @@ func send_input(player_input: Dictionary) -> Error:
if action_name.is_empty():
# _action_enum_to_wire already emits push_warning for invalid actions
return ERR_INVALID_PARAMETER
var tick: int = player_input.get("timestamp_msec", 0)
# Use the server's current tick so drain_for_tick processes this input immediately.
# The client-side timestamp_msec is only useful for ordering within a frame.
var tick: int = GameState.current_tick
var entry: Dictionary = { "tick": tick, "action_name": action_name }
# Data variants (e.g. UsePerceptionMode) carry payload
var action_data: Variant = player_input.get("action_data")
+7 -8
View File
@@ -29,12 +29,11 @@ func _process(_delta: float) -> void:
# Send queued input to simulation
var inputs = InputMapper.flush_queue()
for input in inputs:
# TODO: Once server accepts Interact(InteractData), attach target + verb:
# if input.action == InputMapper.Action.INTERACT:
# var target_id: int = interaction_prompt.get_interaction_target()
# if target_id >= 0:
# input["action_data"] = {
# "target_entity_id": target_id,
# "verb": interaction_prompt.get_selected_verb(),
# }
if input.action == InputMapper.Action.INTERACT:
var target_id: int = interaction_prompt.get_interaction_target()
if target_id >= 0:
input["action_data"] = {
"target_entity_id": target_id,
"verb": interaction_prompt.get_selected_verb(),
}
SimBridge.send_input(input)
+7 -1
View File
@@ -63,7 +63,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
if raw_facing is String:
player_facing = raw_facing
# visible_tiles: Array of {x, y, z, visibility}
# visible_tiles: Array of {x, y, z, visibility, tile_kind}
var visible_tiles: Array = []
var raw_vtiles: Variant = raw.get("visible_tiles")
if raw_vtiles is Array:
@@ -77,6 +77,12 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
var vis: Variant = raw_tile.get("visibility")
if vis is String:
tile_entry["visibility"] = vis
# tile_kind: Floor/Wall/Door/Object — map to client tile type strings
var kind: Variant = raw_tile.get("tile_kind")
if kind is String:
tile_entry["type"] = kind.to_lower()
else:
tile_entry["type"] = "floor"
visible_tiles.append(tile_entry)
# v4: nearby_interactions (#404/#405)
+4 -3
View File
@@ -71,11 +71,12 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
var entity_node = entity_nodes[entity_id]
# Update position from x, y fields (Protocol format), centered within tile
# Update position from x, y fields (Protocol format), centered within tile.
# Server sends tile-center coords (tile 16 → 16.5), floor to get tile index.
if entity_data.has("x") and entity_data.has("y"):
entity_node.position = Vector2(
entity_data.x * TILE_SIZE + ENTITY_OFFSET,
entity_data.y * TILE_SIZE + ENTITY_OFFSET
floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET,
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET
)
# v2: Peripheral vision dimming (D-015)
Binary file not shown.
+198
View File
@@ -0,0 +1,198 @@
## #411: Integration test — full input roundtrip through live server.
## Validates the complete path: encode inputs → TCP wire → server deserialize →
## simulation systems → ObserverSnapshot → client decode.
## Catches integration seams that unit tests miss by exercising both movement
## and interaction actions against the real server binary.
## Requires: server binary built (cargo build in server/)
class_name TestInputRoundtrip
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
func _connect_to_server() -> bool:
var server_path := _server_binary_path()
if not FileAccess.file_exists(server_path):
push_warning("Input roundtrip test 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
## Send a batch input and receive the snapshot response.
func _send_and_receive(action_name: String, tick: int = 0, action_data: Variant = null) -> Variant:
var input_entry := {"tick": tick, "action_name": action_name}
if action_data != null:
input_entry["action_data"] = action_data
var inputs: Array = [input_entry]
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
## 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 {}
# -- Movement roundtrip: send movement, verify position changes ----------------
func test_movement_roundtrip() -> void:
var ok := await _connect_to_server()
if not ok:
return
# Use Interact (server no-op) to get a baseline snapshot without moving.
var baseline: Dictionary = await _send_and_receive("Interact", 0)
var player := _find_player(baseline)
assert_that(player.size()).is_greater(0)
# Player starts at (16,16) → render coords (16.5, 16.5)
var start_x: float = player.x
var start_y: float = player.y
assert_float(start_x).is_equal_approx(16.5, 0.001)
assert_float(start_y).is_equal_approx(16.5, 0.001)
# Send MoveNorth — player should move to (16, 15) → (16.5, 15.5)
var snap1: Dictionary = await _send_and_receive("MoveNorth", 1)
var p1 := _find_player(snap1)
assert_that(p1.size()).is_greater(0)
assert_float(p1.x).is_equal_approx(16.5, 0.001)
assert_float(p1.y).is_equal_approx(15.5, 0.001)
# Send MoveEast — player should move to (17, 15) → (17.5, 15.5)
var snap2: Dictionary = await _send_and_receive("MoveEast", 2)
var p2 := _find_player(snap2)
assert_that(p2.size()).is_greater(0)
assert_float(p2.x).is_equal_approx(17.5, 0.001)
assert_float(p2.y).is_equal_approx(15.5, 0.001)
# Verify position actually changed from baseline
assert_that(p2.x != start_x or p2.y != start_y).is_true()
# -- Interact roundtrip: server accepts without crashing -----------------------
func test_interact_roundtrip() -> void:
var ok := await _connect_to_server()
if not ok:
return
# Send Interact action — unit variant, no target data.
# Server accepts it (currently a no-op) and responds with a valid snapshot.
var snapshot: Dictionary = await _send_and_receive("Interact", 0)
# Snapshot should be valid with correct protocol version
assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION)
assert_that(snapshot.entities.size()).is_greater(0)
# Player should be at start position (Interact doesn't move)
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(16.5, 0.001)
# Send Interact again at next tick — server should still accept it
var snap2: Dictionary = await _send_and_receive("Interact", 1)
assert_that(snap2).is_not_null()
assert_that(snap2.version).is_equal(Protocol.PROTOCOL_VERSION)
# -- Mixed sequence: movement then interact in one session ---------------------
func test_move_then_interact() -> void:
var ok := await _connect_to_server()
if not ok:
return
# Move player first
var snap1: Dictionary = await _send_and_receive("MoveNorth", 0)
var p1 := _find_player(snap1)
assert_that(p1.size()).is_greater(0)
var moved_x: float = p1.x
var moved_y: float = p1.y
# Now send Interact — player should stay where they moved to
var snap2: Dictionary = await _send_and_receive("Interact", 1)
var p2 := _find_player(snap2)
assert_that(p2.size()).is_greater(0)
assert_float(p2.x).is_equal_approx(moved_x, 0.001)
assert_float(p2.y).is_equal_approx(moved_y, 0.001)
+11 -7
View File
@@ -7,13 +7,12 @@ extends GdUnitTestSuite
var _valid_snapshot: Dictionary = {
"tick": 1,
"entities": [
{"entity_id": 1, "x": 10.0, "y": 15.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
{"entity_id": 1, "x": 10.0, "y": 15.0, "z": 0, "kind": {"variant": "Player", "data": null}},
{"entity_id": 2, "x": 5.0, "y": 20.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
],
}
func test_apply_valid_snapshot() -> void:
GameState.player_entity_id = 1
GameState.apply_snapshot(_valid_snapshot)
assert_that(GameState.current_tick).is_equal(1)
@@ -32,17 +31,22 @@ func test_empty_snapshot_no_crash() -> void:
assert_that(GameState.visible_entities.size()).is_equal(0)
func test_no_player_entity_position_unchanged() -> void:
GameState.player_entity_id = 999 # No entity with this ID
GameState.player_position = Vector2(5, 5)
GameState.apply_snapshot(_valid_snapshot)
# Snapshot with only NPCs — no Player entity, so position should not change
var npc_only_snapshot := {
"tick": 1,
"entities": [
{"entity_id": 1, "x": 10.0, "y": 15.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
],
}
GameState.apply_snapshot(npc_only_snapshot)
# Position stays at previous value since no matching entity
# Position stays at previous value since no Player entity found
assert_that(GameState.player_position).is_equal(Vector2(5, 5))
func test_missing_fields_partial_update() -> void:
# First apply valid snapshot
GameState.player_entity_id = 1
# First apply valid snapshot (contains Player entity at 10,15)
GameState.apply_snapshot(_valid_snapshot)
assert_that(GameState.player_position).is_equal(Vector2(10, 15))
+3
View File
@@ -72,6 +72,9 @@ pub fn receive_bridge_inputs(
let Some(bridge) = bridge else { return };
match bridge.receive_inputs() {
Ok(inputs) => {
for input in &inputs {
tracing::debug!("Received input: tick={} action={:?}", input.tick, input.action);
}
for input in inputs {
input_queue.push(input);
}
+15 -1
View File
@@ -73,7 +73,7 @@ pub enum FacingDirection {
Northwest,
}
/// A tile visible to the observer with its visibility quality
/// A tile visible to the observer with its visibility quality and type
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VisibleTile {
pub x: i32,
@@ -81,6 +81,20 @@ pub struct VisibleTile {
pub z: i32,
/// Which vision cone sector this tile falls in (D-015)
pub visibility: VisibilitySector,
/// Tile type for client rendering (floor, wall, door, object)
#[serde(default)]
pub tile_kind: TileKind,
}
/// Tile type for rendering. Derived from WalkabilityMap on the server side.
/// v0.1: Floor (walkable) and Wall (blocked). Door and Object for future use.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub enum TileKind {
#[default]
Floor,
Wall,
Door,
Object,
}
/// Vision cone sectors per D-015.
+14 -6
View File
@@ -9,7 +9,7 @@ use std::collections::{HashMap, HashSet};
use bevy_ecs::prelude::*;
use crate::bridge::types::{FacingDirection, VisibilitySector, VisibleTile};
use crate::bridge::types::{FacingDirection, TileKind, VisibilitySector, VisibleTile};
use crate::perception::shadowcast::compute_fov;
use crate::perception::vision_cone::{apply_vision_cone, VisionConeConfig};
use crate::simulation::movement::{TilePosition, WalkabilityMap};
@@ -67,11 +67,19 @@ impl PerceptionQuery for NaturalVision {
let visible_tiles = cone_tiles
.iter()
.map(|&(x, y, sector)| VisibleTile {
x,
y,
z,
visibility: sector,
.map(|&(x, y, sector)| {
let tile_kind = if walkability.can_move_to(&TilePosition::new(x, y, z)) {
TileKind::Floor
} else {
TileKind::Wall
};
VisibleTile {
x,
y,
z,
visibility: sector,
tile_kind,
}
})
.collect();
+3
View File
@@ -176,18 +176,21 @@ fn generate_msgpack_fixtures() {
y: 10,
z: 0,
visibility: VisibilitySector::Forward,
tile_kind: TileKind::Floor,
},
VisibleTile {
x: 11,
y: 10,
z: 0,
visibility: VisibilitySector::Peripheral,
tile_kind: TileKind::Floor,
},
VisibleTile {
x: 10,
y: 9,
z: 0,
visibility: VisibilitySector::Forward,
tile_kind: TileKind::Floor,
},
],
nearby_interactions: vec![],
+2
View File
@@ -205,12 +205,14 @@ fn snapshot_v2_fields_roundtrip() {
y: 10,
z: 0,
visibility: VisibilitySector::Forward,
tile_kind: TileKind::Floor,
},
VisibleTile {
x: 6,
y: 10,
z: 0,
visibility: VisibilitySector::Peripheral,
tile_kind: TileKind::Wall,
},
],
nearby_interactions: vec![],