diff --git a/CHANGELOG.md b/CHANGELOG.md index 3891c25f3..e0a364276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Makefile b/Makefile index eaec20fcf..f7a85f1d8 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 1ab7a0dd7..0d9667ae2 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -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() diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 72538f0e6..9d31763a3 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -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") diff --git a/client/scripts/main.gd b/client/scripts/main.gd index f76dccd20..4c579ee1d 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -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) diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index 2ee9399e6..bb69783bc 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -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) diff --git a/client/scripts/rendering/entity_renderer.gd b/client/scripts/rendering/entity_renderer.gd index cd6a439a5..489c04da1 100644 --- a/client/scripts/rendering/entity_renderer.gd +++ b/client/scripts/rendering/entity_renderer.gd @@ -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) diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index b63af93c9..7fe0e2d3d 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack and b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack differ diff --git a/client/tests/test_input_roundtrip.gd b/client/tests/test_input_roundtrip.gd new file mode 100644 index 000000000..3a99fec44 --- /dev/null +++ b/client/tests/test_input_roundtrip.gd @@ -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) diff --git a/client/tests/test_snapshot_parsing.gd b/client/tests/test_snapshot_parsing.gd index 3fd78c17e..cc414d805 100644 --- a/client/tests/test_snapshot_parsing.gd +++ b/client/tests/test_snapshot_parsing.gd @@ -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)) diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index e47532916..644fada87 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -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); } diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 06e9cb311..056bc47f0 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -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. diff --git a/server/src/perception/query.rs b/server/src/perception/query.rs index a632191b2..92114bd0e 100644 --- a/server/src/perception/query.rs +++ b/server/src/perception/query.rs @@ -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(); diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 3ba0d4f78..9cf73a5c9 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -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![], diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 4b5f2ca91..fc2dfeef2 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -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![],