ee835ea295
mp_test_driver.gd was 1458 lines doing six unrelated jobs. It is now orchestration only — the scripted sequence, the RPC plumbing between peers, and the manual keyboard controls — with the work in modules that each have one: MpWorldView finding things in the world and describing what they are doing MpSteps the simulated player actions (reach, grab, carry, drop) MpAsserts the per-step checks MpSnapshot the cross-peer sync audit MpReport the ledger, the log, the overlay, the screenshots The scenario list and the audit logic carry over unchanged. That audit compares what is actually RENDERED on both peers, not just the replicated values behind it, which is the only thing that catches a plate whose contents arrived but whose visuals were never rebuilt — so it was worth moving verbatim. Assertions that tested the old model now test the new one: "who is holding this" is the authority of the object's NetXform, not of the object itself. Suite: 146/146 passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
644 lines
25 KiB
GDScript
644 lines
25 KiB
GDScript
extends Node
|
|
|
|
## Multiplayer harness for the kitchen flow, living in test/multiPlayerTest.tscn
|
|
## (and, inert unless asked for, in Scenes/multiPlayer.tscn).
|
|
##
|
|
## This file is the orchestration only — the scripted sequence, the RPC plumbing
|
|
## between the two peers, and the manual keyboard controls. The work itself lives
|
|
## in four modules, each with one job:
|
|
##
|
|
## MpWorldView finding things in the world and describing what they are doing
|
|
## MpSteps the simulated player actions (reach, grab, carry, drop)
|
|
## MpAsserts the per-step checks
|
|
## MpSnapshot the cross-peer sync audit
|
|
## MpReport the ledger, the log, the overlay, the screenshots
|
|
##
|
|
## Two ways to use it:
|
|
##
|
|
## AUTOMATIC — pass `--mptest` and the server runs the whole sequence and exits
|
|
## non-zero if any check fails. test/run_mp_test.ps1 does this headlessly;
|
|
## test/run_mp_test_windowed.ps1 does it in two visible windows with a pause
|
|
## between steps so you can watch it happen.
|
|
##
|
|
## MANUAL — just run the scene (no `--mptest`). Every window gets an overlay, a
|
|
## fixed camera over the kitchen, and keyboard controls. Keys act on the peer
|
|
## whose window has focus, so you can do a step on the client and watch the
|
|
## server window follow (or fail to).
|
|
##
|
|
## The scripted sequence:
|
|
## 1. client grabs the plate, then drops it
|
|
## 2. server grabs the plate, then drops it
|
|
## 3. client carries it to the dirt station -> snaps + turns dirty on both
|
|
## 4. both peers confirm they can still pick it up
|
|
## 5. client carries the dirty plate to the sink -> washed clean on both
|
|
## 6. a cook-and-plate round, run once by the CLIENT and once by the SERVER:
|
|
## raw burger -> hob (cooks, raw one removed), cooked burger -> counter,
|
|
## buns -> cooked burger (combines into a hamburger), plate -> counter 2,
|
|
## hamburger -> plate.
|
|
|
|
## How long the server waits for a client step reply before calling it failed.
|
|
const STEP_TIMEOUT_SEC := 30.0
|
|
## How long to wait for the client to connect and for the world to replicate.
|
|
const SETUP_TIMEOUT_SEC := 30.0
|
|
|
|
## Where the debug camera sits and what it aims at: a fixed vantage point framing
|
|
## the whole kitchen (Counter2 at x=-2 through DirtStation at x=2).
|
|
const DEBUG_CAM_POS := Vector3(0.0, 2.1, 3.4)
|
|
const DEBUG_CAM_LOOK_AT := Vector3(0.0, 0.9, 0.0)
|
|
|
|
const HELP := "[H]ost [J]oin 1 grab 2 drop 3 dirt 4 sink 5 hob 6 counter 7 combine 8 plate>counter2 9 >plate R run-all A sync-audit 0 dump C cam"
|
|
|
|
var _view: MpWorldView
|
|
var _steps: MpSteps
|
|
var _asserts: MpAsserts
|
|
var _snapshot: MpSnapshot
|
|
var _report: MpReport
|
|
|
|
var _world: Node3D
|
|
var _controller: Node3D
|
|
var _hand: XRToolsFunctionPickup
|
|
var _debug_cam: Camera3D
|
|
|
|
var _role := "?"
|
|
var _client_id := 0
|
|
var _auto_mode := false
|
|
var _running := false
|
|
var _step_pause := 0.0
|
|
var _end_hold := 0.0
|
|
var _step_no := 0
|
|
|
|
## Latest reply from the client, consumed by _remote().
|
|
var _reply: Dictionary = {}
|
|
var _client_snapshot: Dictionary = {}
|
|
var _snapshot_pending := false
|
|
|
|
|
|
func _ready() -> void:
|
|
var args := OS.get_cmdline_user_args()
|
|
_auto_mode = "--mptest" in args
|
|
# Opt-in only. This node also sits in the real multiplayer scene (so a client
|
|
# joining a test session has a driver), and must be completely inert during an
|
|
# ordinary game — no overlay, no debug camera, no keyboard hooks.
|
|
if not _auto_mode and not ("--mptest-manual" in args):
|
|
queue_free()
|
|
return
|
|
_step_pause = _arg_value(args, "--mptest-pause", 0.0)
|
|
_end_hold = _arg_value(args, "--mptest-hold", 0.0)
|
|
_world = get_parent()
|
|
|
|
await get_tree().process_frame
|
|
_refresh_role()
|
|
_build_modules(_role == "server")
|
|
_report.setup_frames("--mptest-frames" in args, _role == "server")
|
|
_report.log_line("=== mp test driver ready (role=%s, peer=%d, mode=%s) ==="
|
|
% [_role, multiplayer.get_unique_id(), "automatic" if _auto_mode else "manual"])
|
|
|
|
if not await _resolve_nodes():
|
|
if _auto_mode:
|
|
_finish(false)
|
|
return
|
|
_build_debug_camera()
|
|
if not _auto_mode:
|
|
_report.log_line("MANUAL MODE - keys act on this window's peer:")
|
|
_report.log_line(" " + HELP)
|
|
return
|
|
if _role == "server":
|
|
await _run_server()
|
|
|
|
|
|
func _build_modules(is_server: bool) -> void:
|
|
_report = MpReport.new()
|
|
add_child(_report)
|
|
_report.setup(is_server, _world)
|
|
|
|
_view = MpWorldView.new()
|
|
add_child(_view)
|
|
_view.setup(_world, _report)
|
|
|
|
_snapshot = MpSnapshot.new()
|
|
add_child(_snapshot)
|
|
_snapshot.setup(_view, _report)
|
|
|
|
|
|
func _arg_value(args: PackedStringArray, key: String, fallback: float) -> float:
|
|
var i := args.find(key)
|
|
if i >= 0 and i + 1 < args.size():
|
|
return args[i + 1].to_float()
|
|
return fallback
|
|
|
|
|
|
func _refresh_role() -> void:
|
|
_role = "server" if NetworkManager.is_server() else ("client" if NetworkManager.is_online() else "offline")
|
|
|
|
|
|
## Only the hand is resolved up front; everything else is looked up by name at
|
|
## use time (see MpWorldView).
|
|
func _resolve_nodes() -> bool:
|
|
_controller = _world.get_node_or_null("XROrigin3D/XRControllerRightHand") as Node3D
|
|
_hand = _controller.get_node_or_null("FunctionPickup") as XRToolsFunctionPickup if _controller else null
|
|
if not _hand:
|
|
_report.log_line("FATAL: could not resolve XROrigin3D/XRControllerRightHand/FunctionPickup")
|
|
return false
|
|
|
|
_steps = MpSteps.new()
|
|
add_child(_steps)
|
|
_steps.setup(_view, _report, _hand, _controller)
|
|
_asserts = MpAsserts.new()
|
|
add_child(_asserts)
|
|
_asserts.setup(_view, _snapshot, _hand)
|
|
|
|
# The kitchen is not baked into the live scene: the server harvests the
|
|
# authored nodes and respawns them replicated, so on a client nothing exists
|
|
# until those spawns arrive. Wait for them rather than failing immediately.
|
|
for required in ["Hob", "Sink", "DirtStation", "Counter", "Counter2", "Plate"]:
|
|
if not await _steps.wait_until(func(): return _view.find(required) != null,
|
|
"'%s' to arrive from the server" % required, SETUP_TIMEOUT_SEC):
|
|
_report.log_line("FATAL: '%s' never appeared in the world" % required)
|
|
return false
|
|
_report.log_line("resolved hand=%s; kitchen has Hob, Sink, DirtStation, Counter, Counter2" % _hand.get_path())
|
|
_view.disable_despawn_timers()
|
|
return true
|
|
|
|
|
|
# --- server orchestration --------------------------------------------------
|
|
|
|
func _run_server() -> void:
|
|
_running = true
|
|
_report.results.clear()
|
|
_step_no = 0
|
|
if not await _wait_for_client():
|
|
_running = false
|
|
if _auto_mode:
|
|
_finish(false)
|
|
return
|
|
await _steps.wait_frames(60)
|
|
_report.banner("starting sequence")
|
|
# A frame of the untouched kitchen, so the GIF opens on the starting state.
|
|
await _capture_step_frame("start")
|
|
|
|
# 0. The client opened a different, empty scene, so everything it has must
|
|
# have arrived over the network. Check that before touching anything — this is
|
|
# the same path a player joining mid-session takes.
|
|
await _step("server", "world_replicated_to_client", "verify_world_replicated", [])
|
|
|
|
# 1-2. Both peers can pick the plate up and put it down.
|
|
await _step("client", "client_grab_plate", "grab", ["Plate"])
|
|
await _step("client", "client_drop_plate", "drop", ["Plate"])
|
|
await _step("server", "server_grab_plate", "grab", ["Plate"])
|
|
await _step("server", "server_drop_plate", "drop", ["Plate"])
|
|
|
|
# 3. Client carries it to the dirt station; it should snap in and go dirty.
|
|
await _step("client", "client_plate_to_dirt", "place_in_zone", ["Plate", "DirtStation"])
|
|
await _both("plate_snapped_and_dirty", "verify_snapped", ["Plate", "DirtStation"])
|
|
await _both("plate_is_dirty", "verify_dirty", ["Plate", "true"])
|
|
|
|
# 4. Both peers can still pick it back up out of the station.
|
|
await _step("client", "client_regrab_plate", "grab", ["Plate"])
|
|
await _step("client", "client_redrop_plate", "drop", ["Plate"])
|
|
await _step("server", "server_regrab_plate", "grab", ["Plate"])
|
|
await _step("server", "server_redrop_plate", "drop", ["Plate"])
|
|
|
|
# 5. Client takes the dirty plate to the sink, which should wash it clean.
|
|
await _step("client", "client_plate_to_sink", "place_in_zone", ["Plate", "Sink"])
|
|
await _both("plate_snapped_in_sink", "verify_snapped", ["Plate", "Sink"])
|
|
await _both("sink_bar_shown_while_washing", "verify_station_bar", ["Sink", "true"])
|
|
await _step("server", "wait_for_wash", "await_clean", ["Plate"])
|
|
await _both("plate_washed_clean", "verify_dirty", ["Plate", "false"])
|
|
await _both("sink_bar_hidden_when_done", "verify_station_bar", ["Sink", "false"])
|
|
|
|
# 6. The cook-and-plate round, once per peer. The first round uses the objects
|
|
# baked into the scene; the second uses freshly spawned ones, so both paths
|
|
# through the spawner get covered. Finished plates are parked well clear of
|
|
# every snap zone (all the stations sit on z=0), so they cannot get re-snapped
|
|
# on the way down.
|
|
await _cook_and_plate_round("client", "raw_burger", "BurgerBuns", "Plate", Vector3(-2.0, 1.1, 1.5))
|
|
await _spawn_round_items()
|
|
await _cook_and_plate_round("server", "RawBurger2", "BurgerBuns2", "Plate2", Vector3(-1.0, 1.1, 1.5))
|
|
|
|
_running = false
|
|
await _finish_run()
|
|
|
|
|
|
## One full food round performed by `actor`: cook a burger, combine it with buns
|
|
## on the counter, and put the result on a plate sitting on another counter.
|
|
## Every result is checked on BOTH peers, since the whole point is that the
|
|
## server's authoritative outcome reaches the client.
|
|
func _cook_and_plate_round(actor: String, burger: String, buns: String, plate: String, park_at: Vector3) -> void:
|
|
_report.banner("%s's cook-and-plate round (%s + %s -> %s)" % [actor, burger, buns, plate])
|
|
|
|
# Burger onto the hob; it should cook and the raw one should disappear.
|
|
await _step(actor, "%s_burger_to_hob" % actor, "place_in_zone", [burger, "Hob"])
|
|
await _both("%s_burger_snapped_in_hob" % actor, "verify_snapped", [burger, "Hob"])
|
|
await _both("%s_hob_bar_shown_while_cooking" % actor, "verify_station_bar", ["Hob", "true"])
|
|
await _step("server", "%s_wait_for_cook" % actor, "await_food", ["cooked_burger"])
|
|
await _both("%s_raw_burger_removed" % actor, "verify_gone", [burger])
|
|
await _both("%s_cooked_burger_exists" % actor, "verify_food_exists", ["cooked_burger"])
|
|
|
|
# Cooked burger onto the counter, then buns into it: they combine.
|
|
await _step(actor, "%s_cooked_to_counter" % actor, "place_food_in_zone", ["cooked_burger", "Counter"])
|
|
await _both("%s_hob_bar_hidden_when_empty" % actor, "verify_station_bar", ["Hob", "false"])
|
|
await _step(actor, "%s_buns_to_cooked" % actor, "carry_food_to_food", [buns, "cooked_burger"])
|
|
await _step("server", "%s_wait_for_combine" % actor, "await_food", ["hamburger"])
|
|
await _both("%s_ingredients_consumed" % actor, "verify_gone", [buns])
|
|
await _both("%s_hamburger_exists" % actor, "verify_food_exists", ["hamburger"])
|
|
|
|
# Plate onto the second counter, then the hamburger onto the plate.
|
|
await _step(actor, "%s_plate_to_counter2" % actor, "place_in_zone", [plate, "Counter2"])
|
|
await _both("%s_plate_snapped_on_counter2" % actor, "verify_snapped", [plate, "Counter2"])
|
|
await _step(actor, "%s_hamburger_to_plate" % actor, "carry_food_to_item", ["hamburger", plate])
|
|
await _both("%s_plate_holds_hamburger" % actor, "verify_plate_contains", [plate, "hamburger"])
|
|
await _both("%s_food_on_plate_before_lift" % actor, "verify_plate_visuals", [plate])
|
|
|
|
# Lift the plate off the counter and set it down clear of every station.
|
|
await _step(actor, "%s_plate_off_counter2" % actor, "park", [plate, park_at])
|
|
await _both("%s_counter2_freed" % actor, "verify_zone_empty", ["Counter2"])
|
|
# The food must still be on the plate after being carried off and set down.
|
|
await _both("%s_food_stayed_on_plate" % actor, "verify_plate_visuals", [plate])
|
|
|
|
|
|
## A second set of ingredients for the server's round (the scene bakes only one
|
|
## of each), spawned through the normal path so the runtime-spawn case is covered
|
|
## as well as the authored one.
|
|
func _spawn_round_items() -> void:
|
|
_report.banner("state between rounds (server's view)")
|
|
_view.dump_state(MpSnapshot.WATCHED_STATIONS, _hand)
|
|
_report.banner("spawning a second set of ingredients for the server's round")
|
|
var spawns := [
|
|
["res://Items/burger.tscn", "RawBurger2", Vector3(0.3, 1.05, 0.7)],
|
|
["res://Items/BurgerBuns.tscn", "BurgerBuns2", Vector3(-0.4, 1.05, 0.7)],
|
|
["res://Containers/plate.tscn", "Plate2", Vector3(1.4, 1.05, 0.7)],
|
|
]
|
|
for s in spawns:
|
|
NetworkManager.spawn_item(s[0], Transform3D(Basis(), s[2]), s[1])
|
|
_report.log_line(" spawned %s" % s[1])
|
|
await _steps.wait_frames(30)
|
|
|
|
|
|
func _wait_for_client() -> bool:
|
|
var ok := await _steps.wait_until(func(): return _find_client_id() != 0,
|
|
"client to connect", SETUP_TIMEOUT_SEC)
|
|
_client_id = _find_client_id()
|
|
if ok:
|
|
_report.log_line("client connected: peer %d" % _client_id)
|
|
return ok
|
|
|
|
|
|
func _find_client_id() -> int:
|
|
for id in multiplayer.get_peers():
|
|
if id != 1:
|
|
return id
|
|
return 0
|
|
|
|
|
|
## Run one step as `actor` (locally if that is us, over RPC if it is the client)
|
|
## and record the verdict.
|
|
func _step(actor: String, label: String, step: String, args: Array) -> void:
|
|
_snapshot.current_step = label
|
|
_view.disable_despawn_timers()
|
|
_report.banner("STEP %d: %s (on the %s)" % [_next_step_no(), label, actor.to_upper()])
|
|
var res: Dictionary
|
|
if actor == "server":
|
|
res = await _run_local_step(step, args)
|
|
else:
|
|
res = await _remote(step, args)
|
|
_report.record(label, actor, res)
|
|
await _audit_sync(label)
|
|
await _capture_step_frame(label)
|
|
await _pause()
|
|
|
|
|
|
## Run the same check on both peers — the server's state and the client's must
|
|
## agree, which is the whole point of the exercise.
|
|
func _both(label: String, step: String, args: Array) -> void:
|
|
_snapshot.current_step = label
|
|
_view.disable_despawn_timers()
|
|
_report.banner("STEP %d: %s (checked on BOTH peers)" % [_next_step_no(), label])
|
|
_report.record(label, "server", await _run_local_step(step, args))
|
|
_report.record(label, "client", await _remote(step, args))
|
|
await _audit_sync(label)
|
|
await _capture_step_frame(label)
|
|
await _pause()
|
|
|
|
|
|
func _remote(step: String, args: Array) -> Dictionary:
|
|
_reply = {}
|
|
_cmd.rpc_id(_client_id, step, args)
|
|
var deadline := Time.get_ticks_msec() + int(STEP_TIMEOUT_SEC * 1000.0)
|
|
while _reply.is_empty() and Time.get_ticks_msec() < deadline:
|
|
await get_tree().process_frame
|
|
if _reply.is_empty():
|
|
return {"ok": false, "detail": "no reply from client within %.0fs" % STEP_TIMEOUT_SEC}
|
|
return _reply.duplicate()
|
|
|
|
|
|
func _next_step_no() -> int:
|
|
_step_no += 1
|
|
return _step_no
|
|
|
|
|
|
func _pause() -> void:
|
|
if _step_pause > 0.0:
|
|
await get_tree().create_timer(_step_pause).timeout
|
|
|
|
|
|
func _finish_run() -> void:
|
|
_report.print_summary()
|
|
await _capture_step_frame("final")
|
|
_report.write_frame_index()
|
|
_report.write_report()
|
|
if not _auto_mode:
|
|
return
|
|
_quit_client.rpc_id(_client_id)
|
|
if _end_hold > 0.0:
|
|
_report.log_line("holding for %.0fs so you can look at the final state..." % _end_hold)
|
|
await get_tree().create_timer(_end_hold).timeout
|
|
else:
|
|
await _steps.wait_frames(30)
|
|
_finish(_report.failed_count() == 0)
|
|
|
|
|
|
# --- client command handling ----------------------------------------------
|
|
|
|
@rpc("authority", "reliable")
|
|
func _cmd(step: String, args: Array) -> void:
|
|
_snapshot.current_step = step
|
|
_view.disable_despawn_timers()
|
|
_report.log_line("<- server: %s%s" % [step, args])
|
|
_running = true
|
|
var res := await _run_local_step(step, args)
|
|
_running = false
|
|
_report.log_line("%s %s" % ["PASS" if res.get("ok") else "FAIL", step])
|
|
_report.log_line(" %s" % res.get("detail"))
|
|
_result.rpc_id(1, step, res.get("ok", false), str(res.get("detail", "")))
|
|
|
|
|
|
@rpc("any_peer", "reliable")
|
|
func _result(step: String, ok: bool, detail: String) -> void:
|
|
_reply = {"ok": ok, "detail": detail, "step": step}
|
|
|
|
|
|
@rpc("authority", "reliable")
|
|
func _quit_client() -> void:
|
|
_report.write_frame_index()
|
|
if _end_hold > 0.0:
|
|
await get_tree().create_timer(_end_hold).timeout
|
|
_finish(true)
|
|
|
|
|
|
func _run_local_step(step: String, args: Array) -> Dictionary:
|
|
match step:
|
|
"grab":
|
|
return await _steps.grab(_view.find(args[0]), args[0])
|
|
"drop":
|
|
return await _steps.drop(_view.find(args[0]), args[0])
|
|
"place_in_zone":
|
|
return await _steps.place_in_zone(_view.find(args[0]), args[0], args[1])
|
|
"place_food_in_zone":
|
|
return await _steps.place_in_zone(_view.find_by_food_id(args[0]), args[0], args[1])
|
|
"carry_food_to_food":
|
|
return await _steps.carry_item_to_item(
|
|
_view.find(args[0]), args[0], _view.find_by_food_id(args[1]), args[1])
|
|
"carry_food_to_item":
|
|
return await _steps.carry_item_to_item(
|
|
_view.find_by_food_id(args[0]), args[0], _view.find(args[1]), args[1])
|
|
"await_food":
|
|
return await _steps.await_food(args[0])
|
|
"await_clean":
|
|
return await _steps.await_clean(args[0])
|
|
"park":
|
|
return await _steps.park(_view.find(args[0]), args[0], args[1])
|
|
"verify_snapped":
|
|
return _asserts.snapped(args[0], args[1])
|
|
"verify_dirty":
|
|
return _asserts.dirty(args[0], args[1] == "true")
|
|
"verify_gone":
|
|
return _asserts.gone(args[0])
|
|
"verify_food_exists":
|
|
return _asserts.food_exists(args[0])
|
|
"verify_plate_contains":
|
|
return _asserts.plate_contains(args[0], args[1])
|
|
"verify_plate_visuals":
|
|
return _asserts.plate_visuals(args[0])
|
|
"verify_station_bar":
|
|
return _asserts.station_bar(args[0], args[1] == "true")
|
|
"verify_zone_empty":
|
|
return _asserts.zone_empty(args[0])
|
|
"verify_world_replicated":
|
|
return await _check_world_replicated()
|
|
return {"ok": false, "detail": "unknown step %s" % step}
|
|
|
|
|
|
# --- cross-peer sync audit -------------------------------------------------
|
|
|
|
@rpc("authority", "reliable")
|
|
func _request_snapshot() -> void:
|
|
_snapshot_reply.rpc_id(1, _snapshot.take())
|
|
|
|
|
|
@rpc("any_peer", "reliable")
|
|
func _snapshot_reply(snap: Dictionary) -> void:
|
|
_client_snapshot = snap
|
|
_snapshot_pending = false
|
|
|
|
|
|
func _fetch_client_snapshot() -> Dictionary:
|
|
_client_snapshot = {}
|
|
_snapshot_pending = true
|
|
_request_snapshot.rpc_id(_client_id)
|
|
var deadline := Time.get_ticks_msec() + 10000
|
|
while _snapshot_pending and Time.get_ticks_msec() < deadline:
|
|
await get_tree().process_frame
|
|
return _client_snapshot
|
|
|
|
|
|
## Poll until the two peers agree, so ordinary physics settling is not reported
|
|
## as a desync — a real one never converges and gets reported with its details.
|
|
func _audit_sync(label: String) -> void:
|
|
if not NetworkManager.is_server() or _client_id == 0:
|
|
return
|
|
var problems: Array[String] = []
|
|
var deadline := Time.get_ticks_msec() + int(MpSnapshot.SETTLE_SEC * 1000.0)
|
|
while true:
|
|
var mine := _snapshot.take()
|
|
var theirs := await _fetch_client_snapshot()
|
|
problems = _snapshot.compare(mine, theirs)
|
|
if problems.is_empty() or Time.get_ticks_msec() > deadline:
|
|
break
|
|
await _steps.wait_frames(10)
|
|
var res := {"ok": problems.is_empty(), "detail": ""}
|
|
if problems.is_empty():
|
|
res["detail"] = "server and client agree on all %d objects" % _snapshot.take().size()
|
|
else:
|
|
res["detail"] = "after %.0fs the peers still disagree: %s" % [MpSnapshot.SETTLE_SEC, "; ".join(problems)]
|
|
_report.record("sync_after_" + label, "both", res)
|
|
|
|
|
|
## The client loaded a bare multiplayer scene with no kitchen in it, so every
|
|
## object it can see arrived from the server. This confirms it got the whole
|
|
## layout — the same thing that has to work for a player joining mid-session.
|
|
func _check_world_replicated() -> Dictionary:
|
|
var mine := _snapshot.take()
|
|
var theirs := await _fetch_client_snapshot()
|
|
if theirs.is_empty():
|
|
return {"ok": false, "detail": "the client reported nothing at all"}
|
|
var problems := _snapshot.compare(mine, theirs)
|
|
var detail := "server has %d objects, client has %d" % [mine.size(), theirs.size()]
|
|
if not problems.is_empty():
|
|
return {"ok": false, "detail": "%s; %s" % [detail, "; ".join(problems)]}
|
|
return {"ok": true, "detail": "%s, all matching (client received the world over the network)" % detail}
|
|
|
|
|
|
# --- per-step screenshots --------------------------------------------------
|
|
|
|
## The server numbers the frames and tells the client to grab the matching one,
|
|
## so frame N is the same step on both sides and they can be stitched in pairs.
|
|
func _capture_step_frame(label: String) -> void:
|
|
if not _report.frames_enabled():
|
|
return
|
|
var index := _report.next_frame_index()
|
|
if NetworkManager.is_server() and _client_id != 0:
|
|
_capture_frame_rpc.rpc_id(_client_id, index, label)
|
|
await _report.save_frame(index, label)
|
|
|
|
|
|
@rpc("authority", "reliable")
|
|
func _capture_frame_rpc(index: int, label: String) -> void:
|
|
await _report.save_frame(index, label)
|
|
|
|
|
|
# --- manual controls -------------------------------------------------------
|
|
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
if not (event is InputEventKey) or not event.pressed or event.echo:
|
|
return
|
|
if _running and event.keycode != KEY_0:
|
|
_report.log_line("busy running a step, ignoring that key")
|
|
return
|
|
match event.keycode:
|
|
KEY_H:
|
|
_report.log_line("KEY: host"); NetworkManager.host(); _refresh_role()
|
|
KEY_J:
|
|
_report.log_line("KEY: join 127.0.0.1"); NetworkManager.join("127.0.0.1")
|
|
await get_tree().create_timer(1.0).timeout
|
|
_refresh_role()
|
|
KEY_1: await _manual("grab plate", "grab", ["Plate"])
|
|
KEY_2: await _manual("drop plate", "drop", ["Plate"])
|
|
KEY_3: await _manual("plate -> dirt station", "place_in_zone", ["Plate", "DirtStation"])
|
|
KEY_4: await _manual("plate -> sink", "place_in_zone", ["Plate", "Sink"])
|
|
KEY_5: await _manual("raw burger -> hob", "place_in_zone", ["raw_burger", "Hob"])
|
|
KEY_6: await _manual("cooked burger -> counter", "place_food_in_zone", ["cooked_burger", "Counter"])
|
|
KEY_7: await _manual("buns -> cooked burger", "carry_food_to_food", ["BurgerBuns", "cooked_burger"])
|
|
KEY_8: await _manual("plate -> counter 2", "place_in_zone", ["Plate", "Counter2"])
|
|
KEY_9: await _manual("hamburger -> plate", "carry_food_to_item", ["hamburger", "Plate"])
|
|
KEY_R:
|
|
_refresh_role()
|
|
if _role != "server":
|
|
_report.log_line("KEY: run-all is server-only (this peer is %s)" % _role)
|
|
return
|
|
_report.log_line("KEY: running the full automatic sequence")
|
|
await _run_server()
|
|
KEY_0:
|
|
_report.log_line("KEY: state dump")
|
|
_view.dump_state(MpSnapshot.WATCHED_STATIONS, _hand)
|
|
KEY_A:
|
|
_refresh_role()
|
|
if _role != "server":
|
|
_report.log_line("KEY: the sync audit runs from the server window")
|
|
return
|
|
if _client_id == 0:
|
|
_client_id = _find_client_id()
|
|
_report.log_line("KEY: comparing every object against the client")
|
|
_running = true
|
|
await _audit_sync("manual_check")
|
|
_running = false
|
|
KEY_C:
|
|
_toggle_debug_camera()
|
|
|
|
|
|
func _manual(what: String, step: String, args: Array) -> void:
|
|
_refresh_role()
|
|
_report.banner("MANUAL: %s" % what)
|
|
_running = true
|
|
var res := await _run_local_step(step, args)
|
|
_running = false
|
|
_report.log_line("%s %s :: %s" % ["PASS" if res.get("ok") else "FAIL", what, res.get("detail", "")])
|
|
# Capture manual steps too, so a hand-driven repro can be turned into a GIF.
|
|
await _capture_step_frame(step)
|
|
_report.write_frame_index()
|
|
|
|
|
|
func _finish(ok: bool) -> void:
|
|
_report.log_line("=== %s exiting (%s) ===" % [_role, "ok" if ok else "FAILED"])
|
|
get_tree().quit(0 if ok else 1)
|
|
|
|
|
|
# --- camera ----------------------------------------------------------------
|
|
|
|
## A fixed camera overlooking the kitchen.
|
|
##
|
|
## Without this a windowed run shows nothing useful: the only camera is the
|
|
## XROrigin's XRCamera3D, which nothing drives when XR is off, and the rig's
|
|
## PlayerBody keeps falling under gravity — the view ends up metres below the
|
|
## floor pointing at the void. Ours is a plain Camera3D parented to the world,
|
|
## not the rig, so it stays put.
|
|
func _build_debug_camera() -> void:
|
|
if DisplayServer.get_name() == "headless":
|
|
return
|
|
_debug_cam = Camera3D.new()
|
|
_debug_cam.name = "MPTestDebugCamera"
|
|
_world.add_child(_debug_cam)
|
|
_debug_cam.global_position = DEBUG_CAM_POS
|
|
_debug_cam.look_at(DEBUG_CAM_LOOK_AT)
|
|
# Stand the rig's camera down explicitly first: setting `current` alone lost
|
|
# the race against XRCamera3D, leaving the view stuck under the floor.
|
|
var xr_cam := _world.get_node_or_null("XROrigin3D/XRCamera3D") as Camera3D
|
|
if xr_cam:
|
|
xr_cam.current = false
|
|
_debug_cam.make_current()
|
|
_report.log_line("debug camera at %s looking at %s ([C] toggles back to the XR rig camera)"
|
|
% [DEBUG_CAM_POS, DEBUG_CAM_LOOK_AT])
|
|
_assert_debug_camera.call_deferred()
|
|
|
|
|
|
func _assert_debug_camera() -> void:
|
|
if _debug_cam and _debug_cam.current and get_viewport().get_camera_3d() != _debug_cam:
|
|
_debug_cam.make_current()
|
|
_report.log_line("rendering through camera: %s" % get_viewport().get_camera_3d())
|
|
_check_framing()
|
|
|
|
|
|
## Confirm the things the test acts on are actually on screen. Checked by
|
|
## projection rather than by eye, because that is the only way to be sure across
|
|
## window sizes and display scaling.
|
|
func _check_framing() -> void:
|
|
var size := get_viewport().get_visible_rect().size
|
|
var offscreen: Array[String] = []
|
|
for name in ["Counter2", "Counter", "Hob", "Sink", "DirtStation", "Plate"]:
|
|
var n := _view.find(name)
|
|
if not n:
|
|
continue
|
|
var p := _debug_cam.unproject_position(n.global_position)
|
|
var frac := Vector2(p.x / size.x, p.y / size.y)
|
|
var on := not _debug_cam.is_position_behind(n.global_position) \
|
|
and frac.x > 0.02 and frac.x < 0.98 and frac.y > 0.02 and frac.y < 0.98
|
|
_report.log_line(" framing: %-12s at %.2f,%.2f of frame%s"
|
|
% [name, frac.x, frac.y, "" if on else " <-- OFF SCREEN"])
|
|
if not on:
|
|
offscreen.append(name)
|
|
if offscreen.is_empty():
|
|
_report.log_line(" framing: all test objects are in view")
|
|
else:
|
|
_report.log_line(" framing: WARNING - not visible: %s (adjust DEBUG_CAM_POS)" % ", ".join(offscreen))
|
|
|
|
|
|
func _toggle_debug_camera() -> void:
|
|
if not _debug_cam:
|
|
return
|
|
var xr_cam := _world.get_node_or_null("XROrigin3D/XRCamera3D") as Camera3D
|
|
if _debug_cam.current and xr_cam:
|
|
xr_cam.make_current()
|
|
_report.log_line("switched to the XR rig camera")
|
|
else:
|
|
_debug_cam.make_current()
|
|
_report.log_line("switched to the debug camera")
|