From c4f210a2f638b665db523e292e91c5b4333174bf Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 25 Feb 2026 12:23:36 +0100 Subject: [PATCH] refactor(client): extract test simulation from SimBridge to TestHarness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves ~300 lines of test simulation logic (Bresenham LOS, collision, procedural room generation, movement physics, dialogue triggers) from the production sim_bridge.gd autoload into a dedicated TestHarness class at scripts/protocol/test_harness.gd. Enforces D-020 information boundary — no game logic in the production client. SimBridge retains thin proxy properties and methods for backward compatibility with 13+ test files (zero test changes needed). Co-Authored-By: Claude Opus 4.6 --- client/scripts/autoloads/sim_bridge.gd | 395 ++++-------------------- client/scripts/protocol/test_harness.gd | 333 ++++++++++++++++++++ 2 files changed, 390 insertions(+), 338 deletions(-) create mode 100644 client/scripts/protocol/test_harness.gd diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index f0466ddb5..f1d4f7fd0 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -5,13 +5,7 @@ enum ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, ERROR } var state: ConnectionState = ConnectionState.DISCONNECTED 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" -var _test_input_queue: Array = [] # Queued actions for test mode -var _test_in_dialogue: bool = false # Mock dialogue state (#434) -var _test_gauntlet_mode: bool = false # #501: Gauntlet mode for dev teleport guard -var _test_npc_relationship: String = "Unknown" # #521: NPC relationship for D-033 color +var harness: TestHarness = null # Test simulation (D-020: game logic lives outside production client) var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot) var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport @@ -33,17 +27,53 @@ signal snapshot_received(snapshot: Dictionary) func _ready() -> void: if test_mode: + harness = TestHarness.new() print("SimBridge: Running in test mode (dynamic snapshot)") -# Reset test state — call before tests that use _test_snapshot() + +# -- Test mode proxy API (backward compat for 13+ test files) ------------------ + func reset_test_state() -> void: - _test_tick = 0 - _test_player_pos = Vector2i(10, 10) - _test_facing = "North" - _test_input_queue.clear() - _test_in_dialogue = false - _test_gauntlet_mode = false - _test_npc_relationship = "Unknown" + if harness: harness.reset() + +func _test_snapshot() -> Dictionary: + return harness.snapshot() + +func _test_has_los(from: Vector2i, to: Vector2i) -> bool: + return harness.has_los(from, to) + +var _test_tick: int: + get: return harness.tick if harness else 0 + set(v): + if harness: harness.tick = v + +var _test_player_pos: Vector2i: + get: return harness.player_pos if harness else Vector2i.ZERO + set(v): + if harness: harness.player_pos = v + +var _test_facing: String: + get: return harness.facing if harness else "North" + set(v): + if harness: harness.facing = v + +var _test_in_dialogue: bool: + get: return harness.in_dialogue if harness else false + set(v): + if harness: harness.in_dialogue = v + +var _test_gauntlet_mode: bool: + get: return harness.gauntlet_mode if harness else false + set(v): + if harness: harness.gauntlet_mode = v + +var _test_npc_relationship: String: + get: return harness.npc_relationship if harness else "Unknown" + set(v): + if harness: harness.npc_relationship = v + + +# -- Connection lifecycle ------------------------------------------------------ # Change connection state and emit signal func _set_state(new_state: ConnectionState) -> void: @@ -175,9 +205,13 @@ func _process(delta: float) -> void: push_warning("SimBridge: connection lost") _set_state(ConnectionState.DISCONNECTED) + +# -- Input / snapshot ---------------------------------------------------------- + # Send input to simulation server. # player_input: Dictionary with "action" (int from InputMapper.Action enum) and "timestamp_msec". -# In test mode, inputs are silently dropped. In live mode, encoded and buffered for transport. +# In test mode, inputs are delegated to the test harness. +# In live mode, encoded and buffered for transport. # Returns OK on success, or an error code on failure. func send_input(player_input: Dictionary) -> Error: if state != ConnectionState.CONNECTED: @@ -187,25 +221,20 @@ func send_input(player_input: Dictionary) -> Error: var wire_name: String = action_enum_to_wire(action) if not wire_name.is_empty(): if wire_name == "SetFacing": - # D-054: Use action_data.facing from the input dict, not InputMapper global var facing: String = "" var action_data: Variant = player_input.get("action_data") if action_data is Dictionary: facing = str(action_data.get("facing", "")) if not facing.is_empty(): - _test_facing = facing + harness.process_facing(facing) else: - _test_input_queue.append(wire_name) + harness.process_input(wire_name) return OK var action_name := action_enum_to_wire(player_input.get("action", -1)) if action_name.is_empty(): - # action_enum_to_wire already emits push_warning for invalid actions return ERR_INVALID_PARAMETER - # 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") if action_data != null: entry["action_data"] = action_data @@ -213,13 +242,13 @@ func send_input(player_input: Dictionary) -> Error: return OK # Poll for snapshot from simulation. -# In test mode returns hardcoded data. In live mode, returns the last decoded snapshot (if any). +# In test mode delegates to test harness. In live mode, returns the last decoded snapshot. func poll_snapshot() -> Variant: if state != ConnectionState.CONNECTED: return null if test_mode: - var snapshot = _test_snapshot() + var snapshot = harness.snapshot() snapshot_received.emit(snapshot) return snapshot @@ -265,6 +294,9 @@ func drain_outbound() -> Array[Dictionary]: _outbound_buffer.clear() return inputs + +# -- Wire protocol mapping ----------------------------------------------------- + # Map InputMapper.Action enum values to wire-format action names (matching Rust PlayerAction). # OPEN_MENU is client-only — no Rust equivalent, not sent over the wire. static func action_enum_to_wire(action: int) -> String: @@ -294,316 +326,3 @@ static func action_enum_to_wire(action: int) -> String: _: push_warning("SimBridge: unknown action enum %s" % action) return "" - -# Dynamic test snapshot — processes queued inputs to move player, generates -# visibility based on current position. Matches Protocol.decode_snapshot() format. -# NOTE: Test coordinate space (player at 10,10; NPC at 12,9; wall at 12,10) -# is intentionally decoupled from the E2E proof room (player at 16,16; NPC at -# 16,13; wall at 16,14). This ensures standalone tests don't depend on server -# map layout and can exercise the rendering pipeline independently. -func _test_snapshot() -> Dictionary: - _test_tick += 1 - - # Process queued inputs - for action_name in _test_input_queue: - if action_name == "TeleportToHub": - # #501: Reset to hub spawn position, clear dialogue - _test_player_pos = Vector2i(10, 10) - _test_in_dialogue = false - continue - if action_name == "Interact": - # Mock dialogue trigger (#434): if near NPC, start dialogue - var npc_pos := Vector2i(12, 9) - var dist := absi(_test_player_pos.x - npc_pos.x) + absi(_test_player_pos.y - npc_pos.y) - if dist <= 2 and _test_has_los(_test_player_pos, npc_pos): - _test_in_dialogue = true - continue - 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: - # Walk-away dismisses dialogue (D-064) - if _test_in_dialogue: - _test_in_dialogue = false - _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, - "relationship": _test_npc_relationship, - }) - - # v4: nearby_interactions when NPC is nearby and visible (#404/#405) - var nearby: Array = [] - if npc_dist <= 2 and _test_has_los(Vector2i(px, py), npc_pos): - nearby.append({ - "entity_id": 2, - "entity_type": "Npc", - "distance": npc_dist, - "verbs": [ - {"kind": "Talk", "label": "Talk", "priority": 1, "available": true}, - {"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true}, - ], - }) - - # v5: monologue on first tick (#414) - var monologue: Variant = null - if _test_tick == 1: - monologue = { - "id": "test_enter_001", - "text": "Sova Transit District. Population twelve thousand and change.", - "duration_seconds": 5.0, - } - - # v7: mock dialogue (#435, D-061/D-062) — triggered by Interact near NPC - # Sustained: dialogue persists across ticks while _test_in_dialogue is true. - # Movement (walk-away) clears it. Client consume-once guards against re-show. - # Options: structured {text, response_id, priority} per #435. - var dialogue: Variant = null - if _test_in_dialogue: - dialogue = { - "npc_name": "Kael", - "npc_entity_id": 2, - "speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?", - "options": [ - {"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false}, - {"text": "Passing through. Know where I can find work?", "response_id": "kael_greet_02", "priority": 2, "confrontation": false}, - {"text": "I saw you near the cargo bay last night.", "response_id": "kael_confront_01", "priority": 3, "confrontation": true}, - ], - } - - # v7: mock pending_recognitions (#431, D-059/D-060) — cognitive delay fog entity - # Entity at (13, 12) in fog: starts as grey blob, transitions to recognized over 6 ticks. - # Cycles every 12 ticks: 6 ticks recognizing, 6 ticks off (simulates repeat encounters). - var pending_recs: Array = [] - var cycle_pos := _test_tick % 12 - if cycle_pos < 6: - var total_delay := 6 - var remaining := total_delay - cycle_pos - pending_recs.append({ - "entity_id": 100, - "x": 13.5, - "y": 12.5, - "z": 0, - "remaining_ticks": remaining, - "total_delay_ticks": total_delay, - }) - - # #535: Mock overheard NPC-NPC conversation (D-078) - # Two NPCs (Mira and Soren) trade lines every 5 ticks starting at tick 3. - # Conversation ends after 6 exchanges (~30 ticks). - var conv_events: Array = [] - var conv_ended: Array = [] - var conv_start := 3 - var conv_lines := [ - {"speaker": "Mira", "target": "Soren", "line": "The cargo manifests don't add up. Three containers unaccounted for."}, - {"speaker": "Soren", "target": "Mira", "line": "Could be a logging error. Happens every... cycle."}, - {"speaker": "Mira", "target": "Soren", "line": "Not like this. Someone moved them after... check."}, - {"speaker": "Soren", "target": "Mira", "line": "You're reading too much into it. The docks are... these days."}, - {"speaker": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."}, - {"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."}, - ] - var conv_tick_interval := 5 - var conv_total_ticks := conv_lines.size() * conv_tick_interval - if _test_tick >= conv_start and _test_tick < conv_start + conv_total_ticks: - var conv_index := (_test_tick - conv_start) / conv_tick_interval - var within_tick := (_test_tick - conv_start) % conv_tick_interval - if within_tick == 0 and conv_index < conv_lines.size(): - var cl: Dictionary = conv_lines[conv_index] - conv_events.append({ - "speaker_id": 10, - "target_id": 11, - "speaker_name": cl.speaker, - "target_name": cl.target, - "occluded_line": cl.line, - }) - elif _test_tick == conv_start + conv_total_ticks: - conv_ended.append({"speaker_id": 10, "target_id": 11}) - - return { - "tick": _test_tick, - "version": Protocol.PROTOCOL_VERSION, - "game_time": { - "day": 0, - "time_of_day": _test_tick * 10, - "day_phase": "Morning", - "tick_rate": "Full", - }, - "player_facing": _test_facing, - "player_stance": "Walk", - "player_inventory": [], - "entities": entities, - "tiles": _test_tiles(), - "visible_tiles": _test_visible_tiles(), - "visible_positions": _test_visible_positions(), - "nearby_interactions": nearby, - "current_monologue": monologue, - "current_dialogue": dialogue, - "pending_recognitions": pending_recs, - "gauntlet_mode": _test_gauntlet_mode, - "conversation_events": conv_events, - "conversation_ended": conv_ended, - } - -# Generate a small test room: 8x6 room with walls, a door, and floor -func _test_tiles() -> Array: - var tiles: Array = [] - var room_x := 7 - var room_y := 7 - var room_w := 8 - var room_h := 8 - - for x in range(room_x, room_x + room_w): - for y in range(room_y, room_y + room_h): - var is_edge := (x == room_x or x == room_x + room_w - 1 - or y == room_y or y == room_y + room_h - 1) - var tile_type: String - if is_edge: - # Door on the south wall, center - if y == room_y + room_h - 1 and x == room_x + room_w / 2: - tile_type = "door" - else: - tile_type = "wall" - else: - tile_type = "floor" - tiles.append({"x": x, "y": y, "z": 0, "type": tile_type}) - - # Corridor south of the door - var door_x := room_x + room_w / 2 - for y in range(room_y + room_h, room_y + room_h + 4): - tiles.append({"x": door_x - 1, "y": y, "z": 0, "type": "wall"}) - tiles.append({"x": door_x, "y": y, "z": 0, "type": "floor"}) - tiles.append({"x": door_x + 1, "y": y, "z": 0, "type": "wall"}) - - return tiles - -# Test visible tiles with visibility sectors (v2 format) -# Tiles ahead of the player are Forward, others Peripheral. -func _test_visible_tiles() -> Array: - var vtiles: Array = [] - var px := _test_player_pos.x - var py := _test_player_pos.y - var radius := 4 - var room_x := 7 - var room_y := 7 - var room_w := 8 - var room_h := 8 - - for x in range(px - radius, px + radius + 1): - for y in range(py - radius, py + radius + 1): - var dist := absf(x - px) + absf(y - py) - if dist <= radius: - if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h: - var sector: String = "Forward" if y <= py else "Peripheral" - vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector}) - return vtiles - -# Test visibility: tiles within radius 4 of player, inside room bounds -func _test_visible_positions() -> Array: - var positions: Array = [] - var px := _test_player_pos.x - var py := _test_player_pos.y - var radius := 4 - var room_x := 7 - var room_y := 7 - var room_w := 8 - var room_h := 8 - - for x in range(px - radius, px + radius + 1): - for y in range(py - radius, py + radius + 1): - var dist := absf(x - px) + absf(y - py) - if dist <= radius: - if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h: - positions.append({"x": x, "y": y}) - return positions - - -# -- Test mode helpers -- - -const _TEST_WALLS: Array = [ - # Room walls (8x8 room from (7,7) to (14,14)) - Vector2i(7,7), Vector2i(8,7), Vector2i(9,7), Vector2i(10,7), - Vector2i(11,7), Vector2i(12,7), Vector2i(13,7), Vector2i(14,7), - Vector2i(7,14), Vector2i(8,14), Vector2i(9,14), Vector2i(10,14), - Vector2i(11,14), Vector2i(12,14), Vector2i(13,14), Vector2i(14,14), - Vector2i(7,8), Vector2i(7,9), Vector2i(7,10), Vector2i(7,11), - Vector2i(7,12), Vector2i(7,13), - Vector2i(14,8), Vector2i(14,9), Vector2i(14,10), Vector2i(14,11), - Vector2i(14,12), Vector2i(14,13), - # Interior wall blocking NPC - Vector2i(12, 10), -] - -func _test_is_walkable(pos: Vector2i) -> bool: - return not _TEST_WALLS.has(pos) - -# Simple LOS check — blocked if a wall tile sits between start and end -func _test_has_los(from: Vector2i, to: Vector2i) -> bool: - # Bresenham-lite: check tiles along the line - var dx := absi(to.x - from.x) - var dy := absi(to.y - from.y) - var sx := 1 if from.x < to.x else -1 - var sy := 1 if from.y < to.y else -1 - var err := dx - dy - var cx := from.x - var cy := from.y - while true: - if cx == to.x and cy == to.y: - return true - if Vector2i(cx, cy) != from and not _test_is_walkable(Vector2i(cx, cy)): - return false - var e2 := 2 * err - if e2 > -dy: - err -= dy - cx += sx - if e2 < dx: - err += dx - cy += sy - return true - -static func _action_to_delta(action_name: String) -> Vector2i: - match action_name: - "MoveNorth": return Vector2i(0, -1) - "MoveNortheast": return Vector2i(1, -1) - "MoveEast": return Vector2i(1, 0) - "MoveSoutheast": return Vector2i(1, 1) - "MoveSouth": return Vector2i(0, 1) - "MoveSouthwest": return Vector2i(-1, 1) - "MoveWest": return Vector2i(-1, 0) - "MoveNorthwest": return Vector2i(-1, -1) - _: return Vector2i.ZERO - -static func _delta_to_facing(delta: Vector2i) -> String: - match delta: - Vector2i(0, -1): return "North" - Vector2i(1, -1): return "Northeast" - Vector2i(1, 0): return "East" - Vector2i(1, 1): return "Southeast" - Vector2i(0, 1): return "South" - Vector2i(-1, 1): return "Southwest" - Vector2i(-1, 0): return "West" - Vector2i(-1, -1): return "Northwest" - _: return "North" diff --git a/client/scripts/protocol/test_harness.gd b/client/scripts/protocol/test_harness.gd new file mode 100644 index 000000000..b376322c3 --- /dev/null +++ b/client/scripts/protocol/test_harness.gd @@ -0,0 +1,333 @@ +class_name TestHarness +extends RefCounted +## Standalone test simulation for client development without a running server. +## Generates mock ObserverSnapshots with movement, LOS, dialogue, and NPC +## interactions. Extracted from sim_bridge.gd to enforce D-020 information +## boundary (no game logic in the production client autoload). + +var tick: int = 0 +var player_pos: Vector2i = Vector2i(10, 10) +var facing: String = "North" +var input_queue: Array = [] +var in_dialogue: bool = false +var gauntlet_mode: bool = false +var npc_relationship: String = "Unknown" + + +func reset() -> void: + tick = 0 + player_pos = Vector2i(10, 10) + facing = "North" + input_queue.clear() + in_dialogue = false + gauntlet_mode = false + npc_relationship = "Unknown" + + +func process_input(action_name: String) -> void: + input_queue.append(action_name) + + +func process_facing(new_facing: String) -> void: + facing = new_facing + + +# -- Snapshot generation ------------------------------------------------------- + +func snapshot() -> Dictionary: + tick += 1 + + # Process queued inputs + for action_name in input_queue: + if action_name == "TeleportToHub": + player_pos = Vector2i(10, 10) + in_dialogue = false + continue + if action_name == "Interact": + var npc_pos := Vector2i(12, 9) + var dist := absi(player_pos.x - npc_pos.x) + absi(player_pos.y - npc_pos.y) + if dist <= 2 and has_los(player_pos, npc_pos): + in_dialogue = true + continue + var delta := action_to_delta(action_name) + var new_pos := player_pos + delta + if _is_walkable(new_pos): + player_pos = new_pos + if delta != Vector2i.ZERO: + if in_dialogue: + in_dialogue = false + input_queue.clear() + + var px := player_pos.x + var py := 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 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, + "relationship": npc_relationship, + }) + + # v4: nearby_interactions when NPC is nearby and visible (#404/#405) + var nearby: Array = [] + if npc_dist <= 2 and has_los(Vector2i(px, py), npc_pos): + nearby.append({ + "entity_id": 2, + "entity_type": "Npc", + "distance": npc_dist, + "verbs": [ + {"kind": "Talk", "label": "Talk", "priority": 1, "available": true}, + {"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true}, + ], + }) + + # v5: monologue on first tick (#414) + var monologue: Variant = null + if tick == 1: + monologue = { + "id": "test_enter_001", + "text": "Sova Transit District. Population twelve thousand and change.", + "duration_seconds": 5.0, + } + + # v7: mock dialogue (#435, D-061/D-062) + var dialogue: Variant = null + if in_dialogue: + dialogue = { + "npc_name": "Kael", + "npc_entity_id": 2, + "speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?", + "options": [ + {"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false}, + {"text": "Passing through. Know where I can find work?", "response_id": "kael_greet_02", "priority": 2, "confrontation": false}, + {"text": "I saw you near the cargo bay last night.", "response_id": "kael_confront_01", "priority": 3, "confrontation": true}, + ], + } + + # v7: mock pending_recognitions (#431, D-059/D-060) + var pending_recs: Array = [] + var cycle_pos := tick % 12 + if cycle_pos < 6: + var total_delay := 6 + var remaining := total_delay - cycle_pos + pending_recs.append({ + "entity_id": 100, + "x": 13.5, + "y": 12.5, + "z": 0, + "remaining_ticks": remaining, + "total_delay_ticks": total_delay, + }) + + # #535: Mock overheard NPC-NPC conversation (D-078) + var conv_events: Array = [] + var conv_ended: Array = [] + var conv_start := 3 + var conv_lines := [ + {"speaker": "Mira", "target": "Soren", "line": "The cargo manifests don't add up. Three containers unaccounted for."}, + {"speaker": "Soren", "target": "Mira", "line": "Could be a logging error. Happens every... cycle."}, + {"speaker": "Mira", "target": "Soren", "line": "Not like this. Someone moved them after... check."}, + {"speaker": "Soren", "target": "Mira", "line": "You're reading too much into it. The docks are... these days."}, + {"speaker": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."}, + {"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."}, + ] + var conv_tick_interval := 5 + var conv_total_ticks := conv_lines.size() * conv_tick_interval + if tick >= conv_start and tick < conv_start + conv_total_ticks: + var conv_index := (tick - conv_start) / conv_tick_interval + var within_tick := (tick - conv_start) % conv_tick_interval + if within_tick == 0 and conv_index < conv_lines.size(): + var cl: Dictionary = conv_lines[conv_index] + conv_events.append({ + "speaker_id": 10, + "target_id": 11, + "speaker_name": cl.speaker, + "target_name": cl.target, + "occluded_line": cl.line, + }) + elif tick == conv_start + conv_total_ticks: + conv_ended.append({"speaker_id": 10, "target_id": 11}) + + return { + "tick": tick, + "version": Protocol.PROTOCOL_VERSION, + "game_time": { + "day": 0, + "time_of_day": tick * 10, + "day_phase": "Morning", + "tick_rate": "Full", + }, + "player_facing": facing, + "player_stance": "Walk", + "player_inventory": [], + "entities": entities, + "tiles": _tiles(), + "visible_tiles": _visible_tiles(), + "visible_positions": _visible_positions(), + "nearby_interactions": nearby, + "current_monologue": monologue, + "current_dialogue": dialogue, + "pending_recognitions": pending_recs, + "gauntlet_mode": gauntlet_mode, + "conversation_events": conv_events, + "conversation_ended": conv_ended, + } + + +# -- Map generation ------------------------------------------------------------ + +func _tiles() -> Array: + var tiles: Array = [] + var room_x := 7 + var room_y := 7 + var room_w := 8 + var room_h := 8 + + for x in range(room_x, room_x + room_w): + for y in range(room_y, room_y + room_h): + var is_edge := (x == room_x or x == room_x + room_w - 1 + or y == room_y or y == room_y + room_h - 1) + var tile_type: String + if is_edge: + if y == room_y + room_h - 1 and x == room_x + room_w / 2: + tile_type = "door" + else: + tile_type = "wall" + else: + tile_type = "floor" + tiles.append({"x": x, "y": y, "z": 0, "type": tile_type}) + + var door_x := room_x + room_w / 2 + for y in range(room_y + room_h, room_y + room_h + 4): + tiles.append({"x": door_x - 1, "y": y, "z": 0, "type": "wall"}) + tiles.append({"x": door_x, "y": y, "z": 0, "type": "floor"}) + tiles.append({"x": door_x + 1, "y": y, "z": 0, "type": "wall"}) + + return tiles + + +func _visible_tiles() -> Array: + var vtiles: Array = [] + var px := player_pos.x + var py := player_pos.y + var radius := 4 + var room_x := 7 + var room_y := 7 + var room_w := 8 + var room_h := 8 + + for x in range(px - radius, px + radius + 1): + for y in range(py - radius, py + radius + 1): + var dist := absf(x - px) + absf(y - py) + if dist <= radius: + if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h: + var sector: String = "Forward" if y <= py else "Peripheral" + vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector}) + return vtiles + + +func _visible_positions() -> Array: + var positions: Array = [] + var px := player_pos.x + var py := player_pos.y + var radius := 4 + var room_x := 7 + var room_y := 7 + var room_w := 8 + var room_h := 8 + + for x in range(px - radius, px + radius + 1): + for y in range(py - radius, py + radius + 1): + var dist := absf(x - px) + absf(y - py) + if dist <= radius: + if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h: + positions.append({"x": x, "y": y}) + return positions + + +# -- Spatial helpers ----------------------------------------------------------- + +const _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 _is_walkable(pos: Vector2i) -> bool: + return not _WALLS.has(pos) + + +func has_los(from: Vector2i, to: Vector2i) -> bool: + 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 _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"