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, "visible_tiles": _visible_tiles(), "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, "save_result": null, } # -- 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 := 5 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, "type": _get_tile_type(x, y)}) return vtiles func _get_tile_type(x: int, y: int) -> String: var room_x := 7 var room_y := 7 var room_w := 8 var room_h := 8 var is_edge := (x == room_x or x == room_x + room_w - 1 or y == room_y or y == room_y + room_h - 1) if is_edge: if y == room_y + room_h - 1 and x == room_x + room_w / 2: return "door" return "wall" return "floor" # -- 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"