TestHarness: remove deprecated tiles/visible_positions keys, add tile type (floor/wall/door) to visible_tiles, expand radius to 5. Bug report dialog: capture viewport screenshot before showing overlay, save as screenshot.png in report bundle. time_display: use maxf() instead of max() to match float argument types. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
328 lines
9.9 KiB
GDScript
328 lines
9.9 KiB
GDScript
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"
|