Removes the version-mismatch guard from Protocol.decode_snapshot() and the PROTOCOL_VERSION constant from the client (server side done in #874). Core changes: - protocol.gd: remove const PROTOCOL_VERSION, remove version mismatch guard, remove "version" from return dict, add gauntlet_mode/room_id decode - sim_bridge.gd: remove handshake version check; relax handshake guard to require only a valid Dictionary (server no longer sends protocol_version); emit handshake_complete(0) for API compat - loading_screen.gd: drop "· protocol N" suffix from version label - test_harness.gd: replace Protocol.PROTOCOL_VERSION with literal 23 Test updates (21 files): replace "version": Protocol.PROTOCOL_VERSION with "version": 23 in all snapshot bytes dicts; remove snapshot.version == N assertions; remove version-rejection tests (test_rejects_version_6, test_decode_snapshot_rejects_missing_version, test_decode_snapshot_rejects_old_version, test_protocol_rejects_version_mismatch, test_sim_bridge_test_snapshot_uses_current_protocol_version). Also includes: #872 bookmark_catalog carry-forward regression test, and #873 merge-path flow tests (test_merge_path_flows_sprint37.gd). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
442 lines
10 KiB
GDScript
442 lines
10 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).
|
|
|
|
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),
|
|
]
|
|
|
|
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
|
|
}, # gdlint:ignore = max-line-length
|
|
{
|
|
"text": "Passing through. Know where I can find work?",
|
|
"response_id": "kael_greet_02",
|
|
"priority": 2,
|
|
"confrontation": false
|
|
}, # gdlint:ignore = max-line-length
|
|
{
|
|
"text": "I saw you near the cargo bay last night.",
|
|
"response_id": "kael_confront_01",
|
|
"priority": 3,
|
|
"confrontation": true
|
|
}, # gdlint:ignore = max-line-length
|
|
],
|
|
}
|
|
|
|
# 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."
|
|
}, # gdlint:ignore = max-line-length
|
|
{
|
|
"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": 23,
|
|
"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 -----------------------------------------------------------
|
|
|
|
|
|
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"
|