From 67978d0819c5c4796fff1c53f4fa5344c799c34e Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Feb 2026 01:46:02 +0100 Subject: [PATCH 01/10] feat(client): wire interaction prompt target+verb to server (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable the interaction data attachment in main.gd game loop. When E is pressed and the prompt is active, target_entity_id and verb are attached to the Interact action before sending to the server. Without a target, bare Interact is still sent as safe fallback. Completes the v0.1 interaction prompt data flow: server sends nearby_interactions → client shows prompt → E press attaches target+verb → SimBridge encodes and sends to server. Co-Authored-By: Claude Opus 4.6 --- client/scripts/main.gd | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) 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) From 473eda03cdf0512ca0018af619c7da4b5cfd4af3 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Feb 2026 01:46:09 +0100 Subject: [PATCH 02/10] feat(client): add live server mode with make game target Add SR_LIVE=1 environment variable to switch SimBridge from test mode to TCP connection. Default behavior unchanged (test mode). - sim_bridge.gd: read SR_LIVE env var instead of hardcoded test_mode - game_state.gd: find player entity by kind.variant == "Player" instead of hardcoded entity_id 1 (real server assigns different IDs) - Makefile: add 'make game' (builds server, starts it, launches client with SR_LIVE=1, kills server on exit) and 'make stop' helper Co-Authored-By: Claude Opus 4.6 --- Makefile | 19 +++++++++++++++++-- client/scripts/autoloads/game_state.gd | 10 ++++++---- client/scripts/autoloads/sim_bridge.gd | 2 +- 3 files changed, 24 insertions(+), 7 deletions(-) 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..5a04eea3f 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -30,16 +30,18 @@ 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()]) if snapshot.has("tiles"): visible_tiles = snapshot.tiles diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 72538f0e6..53551a2bb 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" From 721e54491c88f816353c87a211c9460367672443 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Feb 2026 01:46:27 +0100 Subject: [PATCH 03/10] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc70ff116..8aba9ad84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added +- 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 + +### 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) + ### 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 - Global enum YAML files (#387) — 9 enum definitions (situations, topics, moods, triggers, access-tiers, trust-tiers, activities, patterns, motivations) from D-035 taxonomy From 33f4f6078ca00650aba1722f1456c5ee6178518c Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Feb 2026 01:48:35 +0100 Subject: [PATCH 04/10] feat(client): add input roundtrip integration test (#411) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three tests validating the full client-server input path through a live server: movement roundtrip (MoveNorth/MoveEast with position verification), interact roundtrip (unit variant accepted as no-op), and mixed sequence (movement then interact preserves position). Catches integration seams that unit tests miss — each test spawns a real server binary on a random port, connects via LocalBridge, and exercises the TCP→deserialize→simulation→snapshot→decode pipeline. Co-Authored-By: Claude Opus 4.6 --- client/tests/test_input_roundtrip.gd | 198 +++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 client/tests/test_input_roundtrip.gd 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) From 29c500340371b45aa2c8c57a98ce5dea8c5e82b9 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Feb 2026 02:07:19 +0100 Subject: [PATCH 05/10] fix(simulation): add TileKind to VisibleTile wire protocol (#412) Server was sending visible_tiles without tile type data, so the client could not distinguish floor from wall in live mode. Add TileKind enum (Floor/Wall/Door/Object) to VisibleTile, populated from WalkabilityMap in the NaturalVision perception query. Update test fixtures to include the new field. Uses #[serde(default)] for backward compatibility. Co-Authored-By: Claude Opus 4.6 --- .../fixtures/msgpack/snapshot_v2_full.msgpack | Bin 343 -> 391 bytes server/src/bridge/types.rs | 16 +++++++++++++- server/src/perception/query.rs | 20 ++++++++++++------ server/tests/gen_fixtures.rs | 3 +++ server/tests/serialization.rs | 2 ++ 5 files changed, 34 insertions(+), 7 deletions(-) diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index b63af93c9026300b8617d4fabc51cdc278dcf82d..7fe0e2d3db416c27556f135c1262b7d9c6d8973f 100644 GIT binary patch delta 98 zcmcc4)XqHN1!L>PmokDYOEPm( Date: Fri, 13 Feb 2026 02:07:29 +0100 Subject: [PATCH 06/10] fix(client): decode tile_kind and fix entity alignment (#412) Protocol decoder now reads tile_kind from VisibleTile and maps it to the client's tile type string (floor/wall/door/object). GameState falls back to visible_tiles when the test-mode tiles array is absent, enabling live server tile rendering. Fix entity-to-tile alignment: server sends tile-center render coords (tile 16 -> 16.5) but entity renderer was using raw floats, placing entities half a tile off. Now floors the coords to get the tile index. Co-Authored-By: Claude Opus 4.6 --- client/scripts/autoloads/game_state.gd | 8 ++++++++ client/scripts/protocol/protocol.gd | 8 +++++++- client/scripts/rendering/entity_renderer.gd | 7 ++++--- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 5a04eea3f..0d9667ae2 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -43,8 +43,16 @@ func apply_snapshot(snapshot: Dictionary) -> void: 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/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) From e810abdcaf38b15612b7bc5a731341ec3a98b18a Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Feb 2026 02:07:38 +0100 Subject: [PATCH 07/10] fix(client): use server tick for movement input processing Client was sending Time.get_ticks_msec() (e.g. 12345) as the input tick, but the server's drain_for_tick only processes inputs where tick <= current_tick (a small frame counter). Inputs accumulated in the queue and were never processed, making movement keys unresponsive in live mode. Use GameState.current_tick from the latest snapshot. Co-Authored-By: Claude Opus 4.6 --- client/scripts/autoloads/sim_bridge.gd | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 53551a2bb..9d31763a3 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -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") From e6d6802c8d3a3e6d09789fad82164ce885b21ccf Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Feb 2026 02:07:43 +0100 Subject: [PATCH 08/10] fix(client): align tests with kind-based player detection Tests used entity_id matching to find the player, but game_state now finds the player by kind.variant == "Player". Update test fixture to include a Player entity and remove stale player_entity_id assignments. Co-Authored-By: Claude Opus 4.6 --- client/tests/test_snapshot_parsing.gd | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) 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)) From c68585e5f2f714fb5226fd72c65ed16524def882 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Feb 2026 02:07:47 +0100 Subject: [PATCH 09/10] chore(simulation): add debug logging for received inputs Log each received player input at DEBUG level with tick and action, useful for diagnosing input pipeline issues during live testing. Co-Authored-By: Claude Opus 4.6 --- server/src/bridge/mod.rs | 3 +++ 1 file changed, 3 insertions(+) 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); } From dd40f926005742fec427e5103b7fcca307a6b09b Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Feb 2026 02:08:12 +0100 Subject: [PATCH 10/10] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aba9ad84..e9ee32f56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,15 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ### Added - 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