1459 lines
59 KiB
GDScript
1459 lines
59 KiB
GDScript
extends Node
|
|
|
|
## Multiplayer debug harness for the kitchen flow, living in
|
|
## test/multiPlayerTest.tscn.
|
|
##
|
|
## Two ways to use it:
|
|
##
|
|
## AUTOMATIC — pass `--mptest` and the server runs the whole scripted 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 on-screen
|
|
## overlay, a fixed camera over the kitchen, and keyboard controls:
|
|
##
|
|
## H / J host / join 127.0.0.1
|
|
## 1 grab the plate 2 drop it
|
|
## 3 plate -> dirt station 4 plate -> sink
|
|
## 5 raw burger -> hob 6 cooked burger -> counter
|
|
## 7 buns -> cooked burger (combine)
|
|
## 8 plate -> counter 2 9 hamburger -> plate
|
|
## R run the whole automatic sequence (server only)
|
|
## 0 dump state C toggle camera
|
|
##
|
|
## 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 -> gets washed clean on both
|
|
## 6. a cook-and-plate round, run once by the CLIENT and once by the SERVER:
|
|
## raw burger -> hob (cooks into a cooked burger, 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 plate to replicate.
|
|
const SETUP_TIMEOUT_SEC := 30.0
|
|
## Distance under which an item counts as "at" a snap zone.
|
|
const SNAP_TOLERANCE := 0.12
|
|
## Cooking and washing both take ~3s of station time; allow generously for it.
|
|
const STATION_WORK_TIMEOUT := 20.0
|
|
## Lines kept in the on-screen overlay. Enough to show a whole step, without the
|
|
## panel eating the view of the kitchen underneath it.
|
|
const OVERLAY_LINES := 11
|
|
|
|
## Where the debug camera sits and what it aims at: a fixed vantage point that
|
|
## frames 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 _log_file: FileAccess
|
|
var _log_path := ""
|
|
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
|
|
|
|
# Server-side ledger of every check: {step, side, ok, detail}
|
|
var _results: Array[Dictionary] = []
|
|
# Latest reply from the client, consumed by _remote().
|
|
var _reply: Dictionary = {}
|
|
|
|
var _world: Node3D
|
|
var _controller: Node3D
|
|
var _hand: XRToolsFunctionPickup
|
|
var _hand_detached := false
|
|
|
|
var _overlay: Label
|
|
var _overlay_lines: Array[String] = []
|
|
|
|
# Per-step screenshots, later stitched into a side-by-side GIF of both peers.
|
|
var _frames_enabled := false
|
|
var _frames_dir := ""
|
|
var _frame_index := 0
|
|
|
|
|
|
func _ready() -> void:
|
|
var args := OS.get_cmdline_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
|
|
_frames_enabled = "--mptest-frames" in args
|
|
_step_pause = _arg_value(args, "--mptest-pause", 0.0)
|
|
_end_hold = _arg_value(args, "--mptest-hold", 0.0)
|
|
_world = get_parent()
|
|
_open_log()
|
|
_build_overlay()
|
|
await get_tree().process_frame
|
|
_refresh_role()
|
|
_log("=== mp test driver ready (role=%s, peer=%d, mode=%s) ==="
|
|
% [_role, multiplayer.get_unique_id(), "automatic" if _auto_mode else "manual"])
|
|
# await: _resolve_nodes now waits for the server's spawns to arrive.
|
|
if not await _resolve_nodes():
|
|
if _auto_mode:
|
|
_finish(false)
|
|
return
|
|
_build_debug_camera()
|
|
_setup_frames()
|
|
if not _auto_mode:
|
|
_log("MANUAL MODE - keys act on this window's peer:")
|
|
_log(" " + HELP)
|
|
return
|
|
if _role == "server":
|
|
await _run_server()
|
|
|
|
|
|
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, because items get consumed and respawned as the test runs (the raw
|
|
# burger becomes a cooked burger, which becomes a hamburger, which is absorbed
|
|
# into a plate) — caching node references would leave us holding freed objects.
|
|
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:
|
|
_log("FATAL: could not resolve XROrigin3D/XRControllerRightHand/FunctionPickup")
|
|
return false
|
|
# The kitchen is no longer 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 _wait_until(func(): return _find(required) != null,
|
|
"'%s' to arrive from the server" % required, SETUP_TIMEOUT_SEC):
|
|
_log("FATAL: '%s' never appeared in the world" % required)
|
|
return false
|
|
_log("resolved hand=%s; kitchen has Hob, Sink, DirtStation, Counter, Counter2" % _hand.get_path())
|
|
_disable_despawn_timers()
|
|
return true
|
|
|
|
|
|
# The test deliberately leaves items sitting still for minutes at a time, which
|
|
# DespawningItem would treat as litter and remove (it took the raw burger out
|
|
# before the client had even connected). Hold them indefinitely instead, so the
|
|
# run tests the kitchen rather than the despawn timer.
|
|
func _disable_despawn_timers() -> void:
|
|
var stopped := 0
|
|
for node in _world.find_children("*", "DespawningItem", true, false):
|
|
if _despawn_timers_seen.has(node.get_instance_id()):
|
|
continue
|
|
_despawn_timers_seen[node.get_instance_id()] = true
|
|
node.set_process(false)
|
|
stopped += 1
|
|
if stopped > 0:
|
|
_log("disabled %d DespawningItem timer(s) so test items don't vanish mid-run" % stopped)
|
|
|
|
|
|
# Items appear as the run goes on (cooking and combining spawn new ones), so
|
|
# this is re-checked before every step rather than only at startup.
|
|
var _despawn_timers_seen := {}
|
|
|
|
|
|
# Items live either baked in the scene root or, once spawned at runtime, under
|
|
# WorldContent. Look in both.
|
|
func _find(node_name: String) -> Node3D:
|
|
var n := _world.get_node_or_null(node_name)
|
|
if not n:
|
|
n = _world.get_node_or_null("WorldContent/" + node_name)
|
|
return n as Node3D
|
|
|
|
|
|
func _zone_of(station_name: String) -> XRToolsSnapZone:
|
|
var s := _find(station_name)
|
|
return s.get_node_or_null("XRToolsSnapZone") as XRToolsSnapZone if s else null
|
|
|
|
|
|
# Cooking and combining spawn their results with engine-assigned names, so the
|
|
# only stable way to find them is by the food id they carry.
|
|
func _find_by_food_id(id: String) -> Node3D:
|
|
for root in [_world, _world.get_node_or_null("WorldContent")]:
|
|
if not root:
|
|
continue
|
|
for child in root.get_children():
|
|
var f := child.get_node_or_null("FoodItem") as FoodItem
|
|
if f and f.id == id and is_instance_valid(child):
|
|
return child as Node3D
|
|
return null
|
|
|
|
|
|
# --- Server orchestration --------------------------------------------------
|
|
|
|
func _run_server() -> void:
|
|
_running = true
|
|
_results.clear()
|
|
_step_no = 0
|
|
if not await _wait_for_client():
|
|
_running = false
|
|
if _auto_mode:
|
|
_finish(false)
|
|
return
|
|
await _wait_frames(60)
|
|
_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 items
|
|
# 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 can't 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 _report()
|
|
|
|
|
|
# One full food round performed by `actor`: cook a burger, combine it with buns
|
|
# on the counter, and put the result on a plate that's 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:
|
|
_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"])
|
|
|
|
# Get it off the hob before anything else: the hob keeps cooking whatever is
|
|
# in it, and a cooked burger left there burns to charcoal in another 2s —
|
|
# which would blow up the run as soon as there's any pause between steps.
|
|
await _step(actor, "%s_cooked_to_counter" % actor, "place_food_in_zone", ["cooked_burger", "Counter"])
|
|
await _both("%s_raw_burger_removed" % actor, "verify_gone", [burger])
|
|
await _both("%s_cooked_burger_exists" % actor, "verify_food_exists", ["cooked_burger"])
|
|
await _both("%s_hob_bar_hidden_when_empty" % actor, "verify_station_bar", ["Hob", "false"])
|
|
|
|
# Now bring the buns to it to combine.
|
|
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"])
|
|
|
|
# Take the finished plate away again, the way a player would carry it off to
|
|
# be served. Without this the counter stays occupied and the next round has
|
|
# nowhere to put its plate.
|
|
await _both("%s_food_on_plate_before_lift" % actor, "verify_plate_visuals", [plate])
|
|
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 it has been carried off the
|
|
# counter and set down again.
|
|
await _both("%s_food_stayed_on_plate" % actor, "verify_plate_visuals", [plate])
|
|
|
|
|
|
# Spawn a second set of ingredients through NetworkManager for the server's
|
|
# round (the scene only bakes one of each).
|
|
func _spawn_round_items() -> void:
|
|
_banner("state between rounds (server's view)")
|
|
_dump_state()
|
|
_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])
|
|
_log(" spawned %s" % s[1])
|
|
await _wait_frames(30)
|
|
|
|
|
|
func _wait_for_client() -> bool:
|
|
var ok := await _wait_until(func(): return _find_client_id() != 0, "client to connect", SETUP_TIMEOUT_SEC)
|
|
_client_id = _find_client_id()
|
|
if ok:
|
|
_log("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's us, over RPC if it's the client)
|
|
# and record the verdict.
|
|
func _step(actor: String, label: String, step: String, args: Array) -> void:
|
|
_current_step = label
|
|
_disable_despawn_timers()
|
|
_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)
|
|
_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:
|
|
_current_step = label
|
|
_disable_despawn_timers()
|
|
_banner("STEP %d: %s (checked on BOTH peers)" % [_next_step_no(), label])
|
|
_record(label, "server", await _run_local_step(step, args))
|
|
_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 _record(step: String, side: String, res: Dictionary) -> void:
|
|
var ok: bool = res.get("ok", false)
|
|
var detail: String = res.get("detail", "")
|
|
_results.append({"step": step, "side": side, "ok": ok, "detail": detail})
|
|
_log("%s [%s] %s" % ["PASS" if ok else "FAIL", side, step])
|
|
_log(" %s" % detail)
|
|
|
|
|
|
func _report() -> void:
|
|
var failed := 0
|
|
_banner("RESULTS")
|
|
for r in _results:
|
|
if not r["ok"]:
|
|
failed += 1
|
|
_log("%-4s %-8s %s" % ["PASS" if r["ok"] else "FAIL", r["side"], r["step"]])
|
|
_log("%d/%d checks passed" % [_results.size() - failed, _results.size()])
|
|
for r in _results:
|
|
if not r["ok"]:
|
|
_log("FAILURE %s [%s]: %s" % [r["step"], r["side"], r["detail"]])
|
|
await _capture_step_frame("final")
|
|
_write_frame_index()
|
|
_write_report(failed)
|
|
if not _auto_mode:
|
|
return
|
|
_quit_client.rpc_id(_client_id)
|
|
if _end_hold > 0.0:
|
|
_log("holding for %.0fs so you can look at the final state..." % _end_hold)
|
|
await get_tree().create_timer(_end_hold).timeout
|
|
else:
|
|
await _wait_frames(30)
|
|
_finish(failed == 0)
|
|
|
|
|
|
# A standalone report of the run, written next to the logs so it can be read
|
|
# without scrolling the console — and so the in-editor runner can print it back.
|
|
func _write_report(failed: int) -> void:
|
|
var path := "res://logs/mptest_report.txt"
|
|
var f := FileAccess.open(path, FileAccess.WRITE)
|
|
if not f:
|
|
return
|
|
var passed := _results.size() - failed
|
|
f.store_line("VRyHungry multiplayer test report")
|
|
f.store_line("run at %s" % Time.get_datetime_string_from_system())
|
|
f.store_line("")
|
|
f.store_line("RESULT: %s (%d passed, %d failed, %d total)"
|
|
% ["ALL CHECKS PASSED" if failed == 0 else "FAILED", passed, failed, _results.size()])
|
|
f.store_line("")
|
|
if failed > 0:
|
|
f.store_line("--- failures ---")
|
|
for r in _results:
|
|
if not r["ok"]:
|
|
f.store_line("FAIL [%s] %s" % [r["side"], r["step"]])
|
|
f.store_line(" %s" % r["detail"])
|
|
f.store_line("")
|
|
f.store_line("--- every check, in order ---")
|
|
for r in _results:
|
|
f.store_line("%-4s %-6s %s" % ["PASS" if r["ok"] else "FAIL", r["side"], r["step"]])
|
|
f.close()
|
|
_log("report written to %s" % ProjectSettings.globalize_path(path))
|
|
|
|
|
|
# --- Client command handling ----------------------------------------------
|
|
|
|
@rpc("authority", "reliable")
|
|
func _cmd(step: String, args: Array) -> void:
|
|
_current_step = step
|
|
_disable_despawn_timers()
|
|
_log("<- server: %s%s" % [step, args])
|
|
_running = true
|
|
var res := await _run_local_step(step, args)
|
|
_running = false
|
|
_log("%s %s" % ["PASS" if res.get("ok") else "FAIL", step])
|
|
_log(" %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:
|
|
_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 _do_grab(_find(args[0]), args[0])
|
|
"drop":
|
|
return await _do_drop(_find(args[0]), args[0])
|
|
"place_in_zone":
|
|
return await _do_place_in_zone(_find(args[0]), args[0], args[1])
|
|
"place_food_in_zone":
|
|
return await _do_place_in_zone(_find_by_food_id(args[0]), args[0], args[1])
|
|
"carry_food_to_food":
|
|
return await _do_carry_item_to_item(_find(args[0]), args[0], _find_by_food_id(args[1]), args[1])
|
|
"carry_food_to_item":
|
|
return await _do_carry_item_to_item(_find_by_food_id(args[0]), args[0], _find(args[1]), args[1])
|
|
"await_food":
|
|
return await _do_await_food(args[0])
|
|
"await_clean":
|
|
return await _do_await_clean(args[0])
|
|
"park":
|
|
return await _do_park(_find(args[0]), args[0], args[1])
|
|
"verify_snapped":
|
|
return _check_snapped(args[0], args[1])
|
|
"verify_dirty":
|
|
return _check_dirty(args[0], args[1] == "true")
|
|
"verify_gone":
|
|
return _check_gone(args[0])
|
|
"verify_food_exists":
|
|
return _check_food_exists(args[0])
|
|
"verify_plate_contains":
|
|
return _check_plate_contains(args[0], args[1])
|
|
"verify_plate_visuals":
|
|
return _check_plate_visuals(args[0])
|
|
"verify_station_bar":
|
|
return _check_station_bar(args[0], args[1] == "true")
|
|
"verify_zone_empty":
|
|
return _check_zone_empty(args[0])
|
|
"verify_world_replicated":
|
|
return await _check_world_replicated()
|
|
return {"ok": false, "detail": "unknown step %s" % step}
|
|
|
|
|
|
# --- The simulated player actions -----------------------------------------
|
|
|
|
# Reach out to an item and squeeze the grip, the same way the real controller
|
|
# path does: position the hand, let the grab Area3D register what's in range,
|
|
# refresh the closest-object pick (normally done from _process, which bails out
|
|
# headlessly because the controller reports as untracked), then press grip.
|
|
func _do_grab(item: Node3D, label: String) -> Dictionary:
|
|
if not is_instance_valid(item):
|
|
return {"ok": false, "detail": "'%s' does not exist on this peer" % label}
|
|
_log(" reaching for %s at %s" % [label, item.global_position])
|
|
_move_hand_to(item.global_position)
|
|
await _wait_frames(4)
|
|
_hand._update_closest_object()
|
|
var closest := _hand.closest_object
|
|
_log(" hand at %s, closest grabbable = %s" % [_hand.global_position, closest])
|
|
if not is_instance_valid(closest):
|
|
return {"ok": false, "detail": "hand found nothing to grab. %s" % _diag(item)}
|
|
_log(" pressing grip")
|
|
_hand._on_grip_pressed()
|
|
await _wait_frames(4)
|
|
if not item.is_picked_up():
|
|
return {"ok": false, "detail": "grip pressed on %s but %s not picked up. %s"
|
|
% [closest.name, label, _diag(item)]}
|
|
if item.get_picked_up_by() != _hand:
|
|
return {"ok": false, "detail": "%s is held by %s, not our hand. %s"
|
|
% [label, item.get_picked_up_by(), _diag(item)]}
|
|
_log(" holding %s; waiting for authority..." % label)
|
|
if not await _wait_until(func(): return item.get_multiplayer_authority() == multiplayer.get_unique_id(),
|
|
"authority handoff", 5.0):
|
|
return {"ok": false, "detail": "grabbed %s, but authority stayed with peer %d. %s"
|
|
% [label, item.get_multiplayer_authority(), _diag(item)]}
|
|
_log(" authority is ours (peer %d)" % multiplayer.get_unique_id())
|
|
return {"ok": true, "detail": "grabbed %s via %s; %s" % [label, closest.name, _diag(item)]}
|
|
|
|
|
|
func _do_drop(item: Node3D, label: String) -> Dictionary:
|
|
if not is_instance_valid(item):
|
|
return {"ok": false, "detail": "'%s' does not exist on this peer" % label}
|
|
if not item.is_picked_up():
|
|
return {"ok": false, "detail": "nothing to drop: %s is not held. %s" % [label, _diag(item)]}
|
|
_log(" releasing grip (held by %s)" % item.get_picked_up_by())
|
|
_hand._on_grip_release()
|
|
await _wait_frames(8)
|
|
if not is_instance_valid(item):
|
|
return {"ok": true, "detail": "%s was consumed on release" % label}
|
|
# Explicitly typed: `item` is a Node3D here, so the return type of
|
|
# get_picked_up_by() isn't known statically and := can't infer it.
|
|
var by: Node = item.get_picked_up_by()
|
|
_log(" after release %s is held by %s" % [label, by])
|
|
if by == _hand:
|
|
return {"ok": false, "detail": "grip released but our hand still holds %s" % label}
|
|
# A station catching the item on release is intended - but only on the peer
|
|
# that owns world logic. A client's own snap zone doing it means that peer is
|
|
# running station logic it has no authority for.
|
|
if by is XRToolsSnapZone and not NetworkManager.owns_world():
|
|
return {"ok": false, "detail": "%s on this client grabbed %s out of our hand; station snap zones must only run on the world owner"
|
|
% [by.get_parent().name, label]}
|
|
if not await _wait_until(func(): return not is_instance_valid(item) or item.get_multiplayer_authority() == 1,
|
|
"authority return to server", 5.0):
|
|
return {"ok": false, "detail": "dropped %s, but authority stayed with peer %d"
|
|
% [label, item.get_multiplayer_authority()]}
|
|
return {"ok": true, "detail": "dropped %s (now held by %s)" % [label, by]}
|
|
|
|
|
|
# Grab an item, carry it onto a station's snap zone, and let go there.
|
|
func _do_place_in_zone(item: Node3D, label: String, station: String) -> Dictionary:
|
|
var zone := _zone_of(station)
|
|
if not zone:
|
|
return {"ok": false, "detail": "station '%s' has no snap zone on this peer" % station}
|
|
var res := await _do_grab(item, label)
|
|
if not res["ok"]:
|
|
return res
|
|
res = await _do_carry_to(item, label, zone.global_position)
|
|
if not res["ok"]:
|
|
return res
|
|
res = await _do_drop(item, label)
|
|
if not res["ok"]:
|
|
return res
|
|
_log(" waiting for %s to settle onto %s..." % [label, station])
|
|
await _wait_until(func(): return not is_instance_valid(item) \
|
|
or item.global_position.distance_to(zone.global_position) <= SNAP_TOLERANCE,
|
|
"%s to settle onto %s" % [label, station], 5.0)
|
|
return {"ok": true, "detail": "placed %s into %s; %s" % [label, station, _diag(item)]}
|
|
|
|
|
|
# Grab an item and carry it into another item, holding it there. Used for the
|
|
# combine (buns into the cooked burger) and for putting food on a plate, both of
|
|
# which are triggered by an Area3D overlap rather than by dropping.
|
|
func _do_carry_item_to_item(item: Node3D, label: String, target: Node3D, target_label: String) -> Dictionary:
|
|
if not is_instance_valid(target):
|
|
return {"ok": false, "detail": "target '%s' does not exist on this peer" % target_label}
|
|
var target_pos := target.global_position
|
|
var res := await _do_grab(item, label)
|
|
if not res["ok"]:
|
|
return res
|
|
res = await _do_carry_to(item, label, target_pos)
|
|
if not res["ok"] and is_instance_valid(item):
|
|
return res
|
|
# Hold it there a moment: the reaction happens on the server, and the item
|
|
# we're holding may be consumed by it.
|
|
for i in 60:
|
|
if not is_instance_valid(item):
|
|
_log(" %s was consumed (the reaction fired)" % label)
|
|
return {"ok": true, "detail": "%s consumed on contact with %s" % [label, target_label]}
|
|
await get_tree().physics_frame
|
|
# Both uses of this (combining, and putting food on a plate) consume the
|
|
# carried item, so still holding it means the reaction never fired. Let go
|
|
# first so the next step isn't left fighting our hand.
|
|
if is_instance_valid(item) and item.is_picked_up():
|
|
_hand._on_grip_release()
|
|
await _wait_frames(8)
|
|
return {"ok": false, "detail": "%s reached %s but was never consumed - the reaction did not fire; %s"
|
|
% [label, target_label, _diag(item)]}
|
|
|
|
|
|
# Carry an item somewhere clear and put it down, freeing the station it was in.
|
|
func _do_park(item: Node3D, label: String, pos: Vector3) -> Dictionary:
|
|
var res := await _do_grab(item, label)
|
|
if not res["ok"]:
|
|
return res
|
|
res = await _do_carry_to(item, label, pos)
|
|
if not res["ok"]:
|
|
return res
|
|
res = await _do_drop(item, label)
|
|
if not res["ok"]:
|
|
return res
|
|
return {"ok": true, "detail": "parked %s at %s" % [label, pos]}
|
|
|
|
|
|
# Move the held item onto a world position. Closed-loop: move the hand by
|
|
# whatever the item's remaining error is, since the grab point offsets the item
|
|
# from the hand by an amount we don't want to hard-code.
|
|
func _do_carry_to(item: Node3D, label: String, target: Vector3) -> Dictionary:
|
|
if not is_instance_valid(item) or not item.is_picked_up():
|
|
return {"ok": false, "detail": "cannot carry: %s is not held" % label}
|
|
_log(" carrying %s from %s to %s" % [label, item.global_position, target])
|
|
for i in 120:
|
|
if not is_instance_valid(item):
|
|
return {"ok": true, "detail": "%s was consumed while being carried" % label}
|
|
var err := item.global_position - target
|
|
if err.length() <= 0.01:
|
|
break
|
|
_controller.global_position -= err
|
|
await get_tree().physics_frame
|
|
await get_tree().physics_frame
|
|
if not is_instance_valid(item):
|
|
return {"ok": true, "detail": "%s was consumed while being carried" % label}
|
|
var dist := item.global_position.distance_to(target)
|
|
_log(" %s is now %.3fm from the target" % [label, dist])
|
|
if dist > 0.05:
|
|
return {"ok": false, "detail": "could not carry %s to the target: still %.3fm away" % [label, dist]}
|
|
return {"ok": true, "detail": "carried %s to within %.3fm" % [label, dist]}
|
|
|
|
|
|
# Wait for a station to produce an item with the given food id (cooking the
|
|
# burger, or combining into a hamburger). Server-side: stations only run there.
|
|
func _do_await_food(food_id: String) -> Dictionary:
|
|
_log(" waiting for a '%s' to appear..." % food_id)
|
|
var ok := await _wait_until(func(): return _find_by_food_id(food_id) != null,
|
|
"a '%s' to be produced" % food_id, STATION_WORK_TIMEOUT)
|
|
if not ok:
|
|
return {"ok": false, "detail": "no '%s' was produced within %.0fs" % [food_id, STATION_WORK_TIMEOUT]}
|
|
# Let the spawn replicate before the checks that follow look for it.
|
|
await _wait_frames(30)
|
|
var made := _find_by_food_id(food_id)
|
|
return {"ok": true, "detail": "'%s' produced: %s at %s" % [food_id, made.name, made.global_position]}
|
|
|
|
|
|
func _do_await_clean(plate_name: String) -> Dictionary:
|
|
var plate := _find(plate_name)
|
|
if not plate:
|
|
return {"ok": false, "detail": "'%s' does not exist on this peer" % plate_name}
|
|
var pc := plate.get_node_or_null("PlateController")
|
|
_log(" waiting for the sink to wash %s (dirty=%s)..." % [plate_name, pc.is_dirty if pc else "?"])
|
|
var ok := await _wait_until(func(): return pc and not pc.is_dirty,
|
|
"the sink to wash the plate", STATION_WORK_TIMEOUT)
|
|
await _wait_frames(30)
|
|
if not ok:
|
|
return {"ok": false, "detail": "%s was still dirty after %.0fs in the sink; %s"
|
|
% [plate_name, STATION_WORK_TIMEOUT, _diag(plate)]}
|
|
return {"ok": true, "detail": "%s was washed clean" % plate_name}
|
|
|
|
|
|
# --- Checks ----------------------------------------------------------------
|
|
|
|
# On the server "snapped" means the zone owns the item. On a client the snap is
|
|
# server-authoritative and never happens locally, so what must be true there is
|
|
# that the replicated item actually sits in the zone.
|
|
func _check_snapped(item_name: String, station: String) -> Dictionary:
|
|
var item := _find(item_name)
|
|
var zone := _zone_of(station)
|
|
if not item:
|
|
return {"ok": false, "detail": "'%s' does not exist on this peer" % item_name}
|
|
if not zone:
|
|
return {"ok": false, "detail": "station '%s' has no snap zone on this peer" % station}
|
|
var dist := item.global_position.distance_to(zone.global_position)
|
|
var problems: Array[String] = []
|
|
if dist > SNAP_TOLERANCE:
|
|
problems.append("%s is %.3fm from %s's zone (tolerance %.2f)" % [item_name, dist, station, SNAP_TOLERANCE])
|
|
if NetworkManager.owns_world():
|
|
if zone.picked_up_object != item:
|
|
problems.append("%s's zone holds %s, not %s" % [station, zone.picked_up_object, item_name])
|
|
elif not item.is_picked_up():
|
|
problems.append("%s's zone claims %s but it has no grab driver (half-snapped)" % [station, item_name])
|
|
if problems.is_empty():
|
|
return {"ok": true, "detail": "%s is snapped into %s; %s" % [item_name, station, _diag(item)]}
|
|
return {"ok": false, "detail": "%s | %s" % [", ".join(problems), _diag(item)]}
|
|
|
|
|
|
func _check_dirty(plate_name: String, want_dirty: bool) -> Dictionary:
|
|
var plate := _find(plate_name)
|
|
if not plate:
|
|
return {"ok": false, "detail": "'%s' does not exist on this peer" % plate_name}
|
|
var pc := plate.get_node_or_null("PlateController")
|
|
if not pc:
|
|
return {"ok": false, "detail": "'%s' has no PlateController" % plate_name}
|
|
if pc.is_dirty != want_dirty:
|
|
return {"ok": false, "detail": "%s.is_dirty is %s, expected %s; %s"
|
|
% [plate_name, pc.is_dirty, want_dirty, _diag(plate)]}
|
|
return {"ok": true, "detail": "%s.is_dirty == %s as expected" % [plate_name, want_dirty]}
|
|
|
|
|
|
# A consumed item must be gone on EVERY peer, not just the one that consumed it.
|
|
func _check_gone(item_name: String) -> Dictionary:
|
|
var item := _find(item_name)
|
|
if item and is_instance_valid(item):
|
|
return {"ok": false, "detail": "'%s' still exists on this peer at %s (it should have been consumed)"
|
|
% [item_name, item.global_position]}
|
|
return {"ok": true, "detail": "'%s' is gone, as expected" % item_name}
|
|
|
|
|
|
func _check_food_exists(food_id: String) -> Dictionary:
|
|
var item := _find_by_food_id(food_id)
|
|
if not item:
|
|
return {"ok": false, "detail": "no item with food id '%s' exists on this peer" % food_id}
|
|
return {"ok": true, "detail": "'%s' exists: %s at %s" % [food_id, item.name, item.global_position]}
|
|
|
|
|
|
func _check_plate_contains(plate_name: String, food_id: String) -> Dictionary:
|
|
var plate := _find(plate_name)
|
|
if not plate:
|
|
return {"ok": false, "detail": "'%s' does not exist on this peer" % plate_name}
|
|
var pc := plate.get_node_or_null("PlateController")
|
|
if not pc:
|
|
return {"ok": false, "detail": "'%s' has no PlateController" % plate_name}
|
|
if not (food_id in pc.contained_ids):
|
|
return {"ok": false, "detail": "%s holds %s, expected it to contain '%s'"
|
|
% [plate_name, str(pc.contained_ids), food_id]}
|
|
return {"ok": true, "detail": "%s contains %s" % [plate_name, str(pc.contained_ids)]}
|
|
|
|
|
|
# --- Per-step screenshots --------------------------------------------------
|
|
#
|
|
# One frame per peer per step, saved as PNGs and stitched into a single
|
|
# side-by-side GIF afterwards (see test/make_gif.ps1). Seeing both peers'
|
|
# viewports next to each other for the same step is the fastest way to spot a
|
|
# visual desync — the numbers in the log tell you something diverged, the GIF
|
|
# shows you what it looked like.
|
|
|
|
## Frames are captured at half the viewport's resolution: 60-odd full-size
|
|
## frames per peer is a lot of pixels to write and then re-encode.
|
|
const FRAME_SCALE := 0.5
|
|
|
|
|
|
func _setup_frames() -> void:
|
|
if not _frames_enabled:
|
|
return
|
|
if DisplayServer.get_name() == "headless":
|
|
_frames_enabled = false
|
|
_log("frame capture disabled: a headless run has no rendered output to grab")
|
|
return
|
|
var role := "server" if "--server" in OS.get_cmdline_args() else "client"
|
|
_frames_dir = "res://logs/mptest_frames_%s" % role
|
|
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(_frames_dir))
|
|
# Clear out a previous run's frames, or the GIF would splice the two together.
|
|
var dir := DirAccess.open(_frames_dir)
|
|
if dir:
|
|
for f in dir.get_files():
|
|
if f.ends_with(".png"):
|
|
dir.remove(f)
|
|
_log("capturing a frame per step into %s" % ProjectSettings.globalize_path(_frames_dir))
|
|
|
|
|
|
# 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 _frames_enabled:
|
|
return
|
|
_frame_index += 1
|
|
if NetworkManager.is_server() and _client_id != 0:
|
|
_capture_frame_rpc.rpc_id(_client_id, _frame_index, label)
|
|
await _save_frame(_frame_index, label)
|
|
|
|
|
|
@rpc("authority", "reliable")
|
|
func _capture_frame_rpc(index: int, label: String) -> void:
|
|
if not _frames_enabled:
|
|
return
|
|
_frame_index = index
|
|
await _save_frame(index, label)
|
|
|
|
|
|
func _save_frame(index: int, label: String) -> void:
|
|
# Wait for the frame to actually be drawn, or we capture whatever was in the
|
|
# buffer before this step's changes landed.
|
|
await RenderingServer.frame_post_draw
|
|
var tex := get_viewport().get_texture()
|
|
if not tex:
|
|
return
|
|
var img := tex.get_image()
|
|
if not img:
|
|
return
|
|
if FRAME_SCALE != 1.0:
|
|
img.resize(int(img.get_width() * FRAME_SCALE), int(img.get_height() * FRAME_SCALE),
|
|
Image.INTERPOLATE_BILINEAR)
|
|
# Index-only filenames so ffmpeg's image sequence reader can pick them up as
|
|
# frame_%04d.png; the step name is already legible in the on-screen overlay.
|
|
var err := img.save_png("%s/frame_%04d.png" % [_frames_dir, index])
|
|
if err != OK:
|
|
_log(" could not save frame %d (%s)" % [index, error_string(err)])
|
|
else:
|
|
_frame_labels.append("%04d %s" % [index, label])
|
|
|
|
|
|
# Written alongside the frames so a given frame number can be traced back to the
|
|
# step that produced it.
|
|
var _frame_labels: Array[String] = []
|
|
|
|
|
|
func _write_frame_index() -> void:
|
|
if not _frames_enabled or _frame_labels.is_empty():
|
|
return
|
|
var f := FileAccess.open("%s/frames.txt" % _frames_dir, FileAccess.WRITE)
|
|
if f:
|
|
for line in _frame_labels:
|
|
f.store_line(line)
|
|
f.close()
|
|
|
|
|
|
# --- Cross-peer sync audit -------------------------------------------------
|
|
#
|
|
# Runs after every step. Targeted per-step assertions only look at the one thing
|
|
# the step touched, which misses the whole class of bug where some *other*
|
|
# object quietly drifts out of sync — or where the data replicates fine but the
|
|
# thing you actually see on screen doesn't. So after each step both peers
|
|
# describe every item they can see, and the two descriptions must match.
|
|
|
|
## How far apart the same item may be on the two peers before it counts as a desync.
|
|
const SYNC_POS_TOLERANCE := 0.08
|
|
## Physics keeps moving after a step (dropped items fall, snaps settle), so give
|
|
## the peers this long to converge before calling a difference a failure.
|
|
const SYNC_SETTLE_SEC := 3.0
|
|
|
|
var _client_snapshot: Dictionary = {}
|
|
var _snapshot_pending := false
|
|
|
|
|
|
# Everything about an item that should look identical on every peer. Includes
|
|
# what is actually rendered, not just the replicated data behind it: a plate
|
|
# whose contained_ids arrived but whose visuals were never rebuilt looks empty
|
|
# to the player, and that must count as a desync.
|
|
func _describe(item: Node3D) -> Dictionary:
|
|
var d := {"pos": item.global_position, "visible": item.visible}
|
|
var pc := item.get_node_or_null("PlateController")
|
|
if pc:
|
|
d["dirty"] = pc.is_dirty
|
|
d["contents"] = ",".join(pc.contained_ids)
|
|
var dirty_node := item.get_node_or_null("Dirty")
|
|
d["dirty_shown"] = dirty_node.visible if dirty_node else false
|
|
# The cosmetic copies the container builds from contained_ids - this is
|
|
# literally what the player sees sitting on the plate.
|
|
d["meals_shown"] = _visual_count(item, "Container/MealContainer")
|
|
d["sides_shown"] = _visual_count(item, "Container/SidesContainer")
|
|
# Food sitting on a plate is a cosmetic child of the plate, so it must
|
|
# travel with it. Keys starting with "_" are per-peer diagnostics that
|
|
# _compare() skips (floats won't match exactly across peers); the
|
|
# attached flag is asserted absolutely instead, because this can — and
|
|
# did — go wrong on both peers at once, which a diff would miss.
|
|
var off := _max_visual_offset(item)
|
|
d["_visual_offset"] = off
|
|
d["visuals_attached"] = off <= MAX_VISUAL_OFFSET
|
|
return d
|
|
|
|
|
|
## How far a cosmetic item on a plate may sit from the plate's origin. The plate
|
|
## is ~0.4m across and the furthest slot is ~0.12m out, so anything past this has
|
|
## come off the plate.
|
|
const MAX_VISUAL_OFFSET := 0.3
|
|
|
|
|
|
# Largest distance from the plate's origin to any of the cosmetic items it is
|
|
# displaying. -1 when the plate is showing nothing.
|
|
func _max_visual_offset(item: Node3D) -> float:
|
|
var worst := -1.0
|
|
for path in ["Container/MealContainer", "Container/SidesContainer"]:
|
|
var root := item.get_node_or_null(path)
|
|
if not root:
|
|
continue
|
|
for c in root.get_children():
|
|
if c is Node3D and not c.is_queued_for_deletion():
|
|
worst = maxf(worst, item.global_position.distance_to((c as Node3D).global_position))
|
|
return snappedf(worst, 0.001)
|
|
|
|
|
|
# What the cosmetic children look like, for diagnosing why they moved.
|
|
func _visual_diag(item: Node3D) -> String:
|
|
var parts: Array[String] = []
|
|
for path in ["Container/MealContainer", "Container/SidesContainer"]:
|
|
var root := item.get_node_or_null(path)
|
|
if not root:
|
|
continue
|
|
for c in root.get_children():
|
|
var s := "%s global=%s local=%s parent=%s (%.3fm from plate at %s)" % [
|
|
c.name, (c as Node3D).global_position, (c as Node3D).position,
|
|
c.get_parent().name,
|
|
item.global_position.distance_to((c as Node3D).global_position),
|
|
item.global_position]
|
|
if c is RigidBody3D:
|
|
s += " [RigidBody3D freeze=%s freeze_mode=%d layer=%d top_level=%s queued=%s]" % [
|
|
c.freeze, c.freeze_mode, c.collision_layer, c.top_level,
|
|
c.is_queued_for_deletion()]
|
|
parts.append(s)
|
|
return "; ".join(parts) if parts else "(nothing on the plate)"
|
|
|
|
|
|
func _visual_count(item: Node3D, path: String) -> int:
|
|
var n := item.get_node_or_null(path)
|
|
if not n:
|
|
return -1
|
|
var count := 0
|
|
for c in n.get_children():
|
|
if not c.is_queued_for_deletion():
|
|
count += 1
|
|
return count
|
|
|
|
|
|
## Stations whose on-screen state has to match on every peer.
|
|
const WATCHED_STATIONS := ["Hob", "Sink", "DirtStation", "Counter", "Counter2"]
|
|
|
|
|
|
func _snapshot() -> Dictionary:
|
|
var out := {}
|
|
for root in [_world, _world.get_node_or_null("WorldContent")]:
|
|
if not root:
|
|
continue
|
|
for child in root.get_children():
|
|
if child is XRToolsPickable and not child.is_queued_for_deletion():
|
|
out[str(child.name)] = _describe(child)
|
|
for station_name in WATCHED_STATIONS:
|
|
var station := _find(station_name)
|
|
if station:
|
|
out["station:" + station_name] = _describe_station(station)
|
|
return out
|
|
|
|
|
|
# What a station shows the player. Only the world owner runs a station's logic
|
|
# and snap zone, so its display has to be driven from replicated state — a
|
|
# client that never updates it shows a hob that never lights up, or a sink bar
|
|
# that stays on screen after the plate came out clean.
|
|
#
|
|
# Bar *visibility* is compared across peers; the progress value is diagnostic
|
|
# only ("_" prefix), because it changes every tick and the two peers are
|
|
# legitimately a frame apart.
|
|
func _describe_station(station: Node3D) -> Dictionary:
|
|
var d := {}
|
|
var bar := station.get_node_or_null("ProgressBar3D") as ProgressBar3D
|
|
d["bar_visible"] = bar.is_bar_visible() if bar else false
|
|
d["_bar_progress"] = snappedf(bar.get_progress(), 1.0) if bar else -1.0
|
|
if "cooking_result" in station:
|
|
d["cooking"] = str(station.cooking_result)
|
|
if "is_washing" in station:
|
|
d["washing"] = bool(station.is_washing)
|
|
return d
|
|
|
|
|
|
@rpc("authority", "reliable")
|
|
func _request_snapshot() -> void:
|
|
_snapshot_reply.rpc_id(1, _snapshot())
|
|
|
|
|
|
@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
|
|
|
|
|
|
# Compare the server's view with the client's, returning a list of differences.
|
|
func _compare(server: Dictionary, client: Dictionary) -> Array[String]:
|
|
var problems: Array[String] = []
|
|
for entity_name in server:
|
|
if not client.has(entity_name):
|
|
problems.append("'%s' exists on the server but NOT on the client" % entity_name)
|
|
for entity_name in client:
|
|
if not server.has(entity_name):
|
|
problems.append("'%s' exists on the client but NOT on the server (ghost copy)" % entity_name)
|
|
for entity_name in server:
|
|
if not client.has(entity_name):
|
|
continue
|
|
var s: Dictionary = server[entity_name]
|
|
var c: Dictionary = client[entity_name]
|
|
# Stations are compared on their displayed state, not a position.
|
|
if s.has("pos") and c.has("pos"):
|
|
var dist: float = (s["pos"] as Vector3).distance_to(c["pos"])
|
|
if dist > SYNC_POS_TOLERANCE:
|
|
problems.append("%s is %.3fm apart (server %s vs client %s)" % [entity_name, dist, s["pos"], c["pos"]])
|
|
for key in s:
|
|
# "_" keys are per-peer diagnostics, not things that must match.
|
|
if key == "pos" or key.begins_with("_"):
|
|
continue
|
|
if s[key] != c[key]:
|
|
problems.append("%s.%s: server=%s client=%s" % [entity_name, key, s[key], c[key]])
|
|
# Absolute invariants, checked per peer. A cross-peer diff can't catch a
|
|
# fault that happens identically on both sides.
|
|
for peer_name in ["server", "client"]:
|
|
var snap: Dictionary = server if peer_name == "server" else client
|
|
for entity_name in snap:
|
|
var d: Dictionary = snap[entity_name]
|
|
if d.has("visuals_attached") and not d["visuals_attached"]:
|
|
problems.append("on the %s, %s's food has come off the plate (%.3fm from it, limit %.2f)"
|
|
% [peer_name, entity_name, d.get("_visual_offset", -1.0), MAX_VISUAL_OFFSET])
|
|
return problems
|
|
|
|
|
|
# Poll until the two peers agree, so ordinary physics settling isn't 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(SYNC_SETTLE_SEC * 1000.0)
|
|
while true:
|
|
var mine := _snapshot()
|
|
var theirs := await _fetch_client_snapshot()
|
|
problems = _compare(mine, theirs)
|
|
if problems.is_empty() or Time.get_ticks_msec() > deadline:
|
|
break
|
|
await _wait_frames(10)
|
|
var res := {"ok": problems.is_empty(), "detail": ""}
|
|
if problems.is_empty():
|
|
res["detail"] = "server and client agree on all %d items" % _snapshot().size()
|
|
else:
|
|
res["detail"] = "after %.0fs the peers still disagree: %s" % [SYNC_SETTLE_SEC, "; ".join(problems)]
|
|
_record("sync_after_" + label, "both", res)
|
|
|
|
|
|
# --- Live watch on plate visuals ------------------------------------------
|
|
#
|
|
# The per-step checks tell us the food ended up off the plate, but not when or
|
|
# why. This watches every frame and reports the first frame a cosmetic item
|
|
# departs from its slot, along with what was happening to the plate at the time.
|
|
|
|
## Local offset from its slot at which a cosmetic item counts as having moved.
|
|
const VISUAL_DRIFT_EPSILON := 0.05
|
|
|
|
var _current_step := "(before any step)"
|
|
var _drift_reported := {}
|
|
|
|
|
|
func _process(_delta: float) -> void:
|
|
if not _world:
|
|
return
|
|
for root in [_world, _world.get_node_or_null("WorldContent")]:
|
|
if not root:
|
|
continue
|
|
for item in root.get_children():
|
|
if not (item is Node3D) or not item.get_node_or_null("PlateController"):
|
|
continue
|
|
_watch_plate(item as Node3D)
|
|
|
|
|
|
func _watch_plate(plate: Node3D) -> void:
|
|
for path in ["Container/MealContainer", "Container/SidesContainer"]:
|
|
var holder := plate.get_node_or_null(path)
|
|
if not holder:
|
|
continue
|
|
for c in holder.get_children():
|
|
if not (c is Node3D) or c.is_queued_for_deletion():
|
|
continue
|
|
var drift: float = (c as Node3D).position.length()
|
|
var key := c.get_instance_id()
|
|
if drift <= VISUAL_DRIFT_EPSILON:
|
|
_drift_reported.erase(key)
|
|
continue
|
|
if _drift_reported.has(key):
|
|
continue
|
|
_drift_reported[key] = true
|
|
_log("VISUAL DRIFT: %s on %s moved to local %s (%.3f from its slot) during '%s'" % [
|
|
c.name, plate.name, (c as Node3D).position, drift, _current_step])
|
|
_log(" plate: pos=%s freeze=%s held_by=%s authority=%d" % [
|
|
plate.global_position, plate.freeze if plate is RigidBody3D else "-",
|
|
plate.get_picked_up_by() if plate.has_method("get_picked_up_by") else "-",
|
|
plate.get_multiplayer_authority()])
|
|
if c is RigidBody3D:
|
|
_log(" visual: freeze=%s mode=%d layer=%d top_level=%s sleeping=%s lin_vel=%s" % [
|
|
c.freeze, c.freeze_mode, c.collision_layer, c.top_level,
|
|
c.sleeping, c.linear_velocity])
|
|
|
|
|
|
# Food shown on a plate is a cosmetic child of that plate, so it has to stay put
|
|
# when the plate is picked up and carried around. If it drifts away, the player
|
|
# sees the burger fly off the plate.
|
|
func _check_plate_visuals(plate_name: String) -> Dictionary:
|
|
var plate := _find(plate_name)
|
|
if not plate:
|
|
return {"ok": false, "detail": "'%s' does not exist on this peer" % plate_name}
|
|
var off := _max_visual_offset(plate)
|
|
if off < 0.0:
|
|
return {"ok": false, "detail": "%s is not showing any food to check" % plate_name}
|
|
if off > MAX_VISUAL_OFFSET:
|
|
return {"ok": false, "detail": "%s's food has come off the plate: %.3fm away (limit %.2f). %s"
|
|
% [plate_name, off, MAX_VISUAL_OFFSET, _visual_diag(plate)]}
|
|
return {"ok": true, "detail": "%s's food is still on it (%.3fm from centre). %s"
|
|
% [plate_name, off, _visual_diag(plate)]}
|
|
|
|
|
|
# A station's progress bar must show the same thing to everyone: visible while
|
|
# the station is working, gone once it has finished. Only the world owner runs
|
|
# station logic, so a client can only get this right if the display is driven
|
|
# from replicated state.
|
|
func _check_station_bar(station_name: String, want_visible: bool) -> Dictionary:
|
|
var station := _find(station_name)
|
|
if not station:
|
|
return {"ok": false, "detail": "'%s' does not exist on this peer" % station_name}
|
|
var bar := station.get_node_or_null("ProgressBar3D") as ProgressBar3D
|
|
if not bar:
|
|
return {"ok": false, "detail": "%s has no ProgressBar3D" % station_name}
|
|
var shown := bar.is_bar_visible()
|
|
var detail := "%s bar visible=%s progress=%.0f%%" % [station_name, shown, bar.get_progress()]
|
|
if "cooking_result" in station:
|
|
detail += " cooking='%s'" % station.cooking_result
|
|
if "is_washing" in station:
|
|
detail += " washing=%s" % station.is_washing
|
|
if shown != want_visible:
|
|
return {"ok": false, "detail": "expected %s's bar to be %s, but %s"
|
|
% [station_name, "visible" if want_visible else "hidden", detail]}
|
|
return {"ok": true, "detail": detail}
|
|
|
|
|
|
# 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.
|
|
# Server-side check: it asks the client for its inventory and compares.
|
|
func _check_world_replicated() -> Dictionary:
|
|
var mine := _snapshot()
|
|
var theirs := await _fetch_client_snapshot()
|
|
if theirs.is_empty():
|
|
return {"ok": false, "detail": "the client reported nothing at all"}
|
|
var problems := _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}
|
|
|
|
|
|
# A station that has had its item taken away must not still be holding it.
|
|
func _check_zone_empty(station_name: String) -> Dictionary:
|
|
var zone := _zone_of(station_name)
|
|
if not zone:
|
|
return {"ok": false, "detail": "station '%s' has no snap zone on this peer" % station_name}
|
|
if not NetworkManager.owns_world():
|
|
# Client zones are gated off entirely; they never hold anything.
|
|
return {"ok": true, "detail": "%s: client zones are gated, nothing to check" % station_name}
|
|
if is_instance_valid(zone.picked_up_object):
|
|
return {"ok": false, "detail": "%s's zone still holds %s after it was taken away"
|
|
% [station_name, zone.picked_up_object]}
|
|
return {"ok": true, "detail": "%s's zone is empty" % station_name}
|
|
|
|
|
|
# --- Diagnostics -----------------------------------------------------------
|
|
|
|
func _diag(item: Node3D) -> String:
|
|
if not is_instance_valid(item):
|
|
return "item[freed]"
|
|
var np := item.get_node_or_null("NetPickable")
|
|
var pc := item.get_node_or_null("PlateController")
|
|
var s := "%s[pos=%s authority=%d net_held_by=%s" % [
|
|
item.name, item.global_position, item.get_multiplayer_authority(),
|
|
np.net_held_by if np else "-",
|
|
]
|
|
if item is XRToolsPickable:
|
|
s += " enabled=%s can_pick_up=%s layer=%d held_by=%s" % [
|
|
item.enabled, item.can_pick_up(_hand), item.collision_layer, item.get_picked_up_by()]
|
|
if pc:
|
|
s += " dirty=%s contains=%s" % [pc.is_dirty, str(pc.contained_ids)]
|
|
return s + "]"
|
|
|
|
|
|
func _dump_state() -> void:
|
|
_log(" stations:")
|
|
for station in ["Hob", "Sink", "DirtStation", "Counter", "Counter2"]:
|
|
var z := _zone_of(station)
|
|
if z:
|
|
_log(" %-12s zone holds %s (enabled=%s)" % [station, z.picked_up_object, z.enabled])
|
|
_log(" items:")
|
|
for root in [_world, _world.get_node_or_null("WorldContent")]:
|
|
if not root:
|
|
continue
|
|
for child in root.get_children():
|
|
if child is XRToolsPickable:
|
|
_log(" %s" % _diag(child))
|
|
|
|
|
|
# --- 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:
|
|
_log("busy running a step, ignoring that key")
|
|
return
|
|
match event.keycode:
|
|
KEY_H:
|
|
_log("KEY: host"); NetworkManager.host(); _refresh_role()
|
|
KEY_J:
|
|
_log("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":
|
|
_log("KEY: run-all is server-only (this peer is %s)" % _role)
|
|
return
|
|
_log("KEY: running the full automatic sequence")
|
|
await _run_server()
|
|
KEY_0:
|
|
_log("KEY: state dump")
|
|
_dump_state()
|
|
KEY_A:
|
|
_refresh_role()
|
|
if _role != "server":
|
|
_log("KEY: the sync audit runs from the server window")
|
|
return
|
|
_log("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()
|
|
_banner("MANUAL: %s" % what)
|
|
_running = true
|
|
var res := await _run_local_step(step, args)
|
|
_running = false
|
|
_log("%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)
|
|
_write_frame_index()
|
|
|
|
|
|
# --- Helpers ---------------------------------------------------------------
|
|
|
|
func _move_hand_to(pos: Vector3) -> void:
|
|
# The controller is a child of the XROrigin, whose PlayerBody keeps moving
|
|
# (gravity) — that drags the hand off any position we place it at. top_level
|
|
# detaches it into world space. Done lazily on first use so that simply
|
|
# running the scene by hand leaves a real tracked controller alone.
|
|
if not _hand_detached:
|
|
_hand_detached = true
|
|
_controller.top_level = true
|
|
_log(" (detached the test hand from the XR rig so it can be driven directly)")
|
|
_controller.global_position = pos
|
|
_controller.force_update_transform()
|
|
|
|
|
|
func _wait_frames(n: int) -> void:
|
|
for i in n:
|
|
await get_tree().physics_frame
|
|
|
|
|
|
# Poll a condition instead of sleeping a guessed duration, so a slow machine
|
|
# doesn't produce a spurious failure and a fast one doesn't waste seconds.
|
|
func _wait_until(cond: Callable, what: String, timeout: float) -> bool:
|
|
var deadline := Time.get_ticks_msec() + int(timeout * 1000.0)
|
|
while Time.get_ticks_msec() < deadline:
|
|
if cond.call():
|
|
return true
|
|
await get_tree().process_frame
|
|
_log(" timed out after %.0fs waiting for %s" % [timeout, what])
|
|
return false
|
|
|
|
|
|
func _finish(ok: bool) -> void:
|
|
_log("=== %s exiting (%s) ===" % [_role, "ok" if ok else "FAILED"])
|
|
if _log_file:
|
|
_log_file.flush()
|
|
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 isn't driven by anything when XR is off, and the
|
|
# rig's PlayerBody keeps falling under gravity — it 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)
|
|
# Explicitly stand the rig's camera down 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()
|
|
_log("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()
|
|
_log("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 it's 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 station_name in ["Counter2", "Counter", "Hob", "Sink", "DirtStation", "Plate"]:
|
|
var n := _find(station_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
|
|
_log(" framing: %-12s at %.2f,%.2f of frame%s" % [station_name, frac.x, frac.y, "" if on else " <-- OFF SCREEN"])
|
|
if not on:
|
|
offscreen.append(station_name)
|
|
if offscreen.is_empty():
|
|
_log(" framing: all test objects are in view")
|
|
else:
|
|
_log(" framing: WARNING - not visible: %s (adjust DEBUG_CAM_POS)" % ", ".join(offscreen))
|
|
|
|
|
|
func _toggle_debug_camera() -> void:
|
|
if not _debug_cam:
|
|
_log("KEY: no debug camera in a headless run")
|
|
return
|
|
_debug_cam.current = not _debug_cam.current
|
|
if not _debug_cam.current:
|
|
var xr_cam := _world.get_node_or_null("XROrigin3D/XRCamera3D") as Camera3D
|
|
if xr_cam:
|
|
xr_cam.current = true
|
|
_log("KEY: camera -> %s" % ("fixed debug camera" if _debug_cam.current else "XR rig camera"))
|
|
|
|
|
|
# --- Output ----------------------------------------------------------------
|
|
|
|
# A heading that's easy to find when scrolling a window full of engine spam.
|
|
func _banner(s: String) -> void:
|
|
_log("")
|
|
_log("======== %s ========" % s)
|
|
|
|
|
|
# Write the step log next to the engine's own log, under <project>/logs/, so
|
|
# everything from a run is in one place. Named per role because both instances
|
|
# would otherwise fight over one file (the engine's own godot.log has exactly
|
|
# that problem when two peers run at once — it rotates per process).
|
|
func _open_log() -> void:
|
|
var role := "server" if "--server" in OS.get_cmdline_args() else "client"
|
|
var path := "res://logs/mptest_%s.log" % role
|
|
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("res://logs"))
|
|
_log_file = FileAccess.open(path, FileAccess.WRITE)
|
|
if not _log_file:
|
|
# res:// is read-only in an exported build; fall back to somewhere writable.
|
|
path = OS.get_environment("TEMP").path_join("vryhungry_mptest_%s.log" % role)
|
|
_log_file = FileAccess.open(path, FileAccess.WRITE)
|
|
_log_path = ProjectSettings.globalize_path(path)
|
|
SweetLogger.info("[MPTEST] step log -> {0}", [_log_path])
|
|
|
|
|
|
func _log(s: String) -> void:
|
|
SweetLogger.info("[MPTEST {0}] {1}", [_role, s])
|
|
if _log_file:
|
|
_log_file.store_line(s)
|
|
_log_file.flush()
|
|
_push_overlay(s)
|
|
|
|
|
|
# On-screen mirror of the log, so a windowed run shows what's happening without
|
|
# needing the console — including which peer this window is.
|
|
func _build_overlay() -> void:
|
|
var layer := CanvasLayer.new()
|
|
layer.name = "MPTestOverlay"
|
|
var panel := PanelContainer.new()
|
|
panel.set_anchors_preset(Control.PRESET_TOP_WIDE)
|
|
panel.modulate = Color(1, 1, 1, 0.85)
|
|
_overlay = Label.new()
|
|
# Sized for a 1800x1200 canvas, and to stay legible once a captured frame has
|
|
# been scaled down into the GIF.
|
|
_overlay.add_theme_font_size_override("font_size", 22)
|
|
_overlay.autowrap_mode = TextServer.AUTOWRAP_OFF
|
|
_overlay.clip_text = true
|
|
panel.add_child(_overlay)
|
|
layer.add_child(panel)
|
|
add_child(layer)
|
|
|
|
|
|
func _push_overlay(s: String) -> void:
|
|
if not _overlay:
|
|
return
|
|
_overlay_lines.append(s)
|
|
while _overlay_lines.size() > OVERLAY_LINES:
|
|
_overlay_lines.pop_front()
|
|
_overlay.text = "[%s] %s\n%s" % [_role.to_upper(), HELP, "\n".join(_overlay_lines)]
|