Split the multiplayer test harness into modules

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>
This commit is contained in:
algodoogle
2026-07-28 23:22:58 +01:00
parent 61d92052ac
commit ee835ea295
14 changed files with 1193 additions and 1054 deletions
+139
View File
@@ -0,0 +1,139 @@
extends Node
class_name MpAsserts
## The per-step checks. Every one is synchronous and side-effect free: it looks
## at the world as it currently is on THIS peer and returns a verdict.
##
## Most of these are run on both peers for the same step, which is the point —
## the server's authoritative outcome has to be what the client sees too.
var view: MpWorldView
var snapshot: MpSnapshot
var hand: XRToolsFunctionPickup
func setup(p_view: MpWorldView, p_snapshot: MpSnapshot, p_hand: XRToolsFunctionPickup) -> void:
view = p_view
snapshot = p_snapshot
hand = p_hand
## On the server "snapped" means the zone owns the object. On a client the snap
## is server-authoritative and never happens locally, so what must be true there
## is that the replicated object actually sits in the zone.
func snapped(item_name: String, station: String) -> Dictionary:
var item := view.find(item_name)
var zone := view.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 > MpSteps.SNAP_TOLERANCE:
problems.append("%s is %.3fm from %s's zone (tolerance %.2f)"
% [item_name, dist, station, MpSteps.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, view.diag(item, hand)]}
return {"ok": false, "detail": "%s | %s" % [", ".join(problems), view.diag(item, hand)]}
func dirty(plate_name: String, want_dirty: bool) -> Dictionary:
var plate := view.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, view.diag(plate, hand)]}
return {"ok": true, "detail": "%s.is_dirty == %s as expected" % [plate_name, want_dirty]}
## A consumed object must be gone on EVERY peer, not just the one that consumed it.
func gone(item_name: String) -> Dictionary:
var item := view.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 food_exists(food_id: String) -> Dictionary:
var item := view.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 plate_contains(plate_name: String, food_id: String) -> Dictionary:
var plate := view.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)]}
## Food shown on a plate is a cosmetic child of it, so it has to stay put when
## the plate is picked up and carried. If it drifts, the player sees the burger
## fly off the plate.
func plate_visuals(plate_name: String) -> Dictionary:
var plate := view.find(plate_name)
if not plate:
return {"ok": false, "detail": "'%s' does not exist on this peer" % plate_name}
var off := snapshot.max_visual_offset(plate)
if off < 0.0:
return {"ok": false, "detail": "%s is not showing any food to check" % plate_name}
if off > MpSnapshot.MAX_VISUAL_OFFSET:
return {"ok": false, "detail": "%s's food has come off the plate: %.3fm away (limit %.2f). %s"
% [plate_name, off, MpSnapshot.MAX_VISUAL_OFFSET, snapshot.visual_diag(plate)]}
return {"ok": true, "detail": "%s's food is still on it (%.3fm from centre). %s"
% [plate_name, off, snapshot.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 station_bar(station_name: String, want_visible: bool) -> Dictionary:
var station := view.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}
## A station that has had its object taken away must not still be holding it.
func zone_empty(station_name: String) -> Dictionary:
var zone := view.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}
+1
View File
@@ -0,0 +1 @@
uid://dnhqfkoir57q6
+205
View File
@@ -0,0 +1,205 @@
extends Node
class_name MpReport
## Everything the harness writes down: the ledger of checks, the run log, the
## on-screen overlay, and the per-step screenshots.
##
## Kept apart from the test logic so a step never has to think about where its
## output goes — it returns a verdict, and this decides how that is recorded.
## Lines kept in the on-screen overlay. Enough to show a whole step without the
## panel covering the kitchen underneath it.
const OVERLAY_LINES := 11
## 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
## Every check, in order: {step, side, ok, detail}.
var results: Array[Dictionary] = []
var _log_file: FileAccess
var _log_path := ""
var _overlay: Label
var _overlay_lines: Array[String] = []
var _frames_enabled := false
var _frames_dir := ""
var _frame_index := 0
var _frame_labels: Array[String] = []
func setup(is_server: bool, overlay_parent: Node) -> void:
var role := "server" if is_server else "client"
_log_path = "res://logs/mptest_%s.log" % role
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("res://logs"))
_log_file = FileAccess.open(_log_path, FileAccess.WRITE)
_build_overlay(overlay_parent)
# --- log -------------------------------------------------------------------
func log_line(s: String) -> void:
print("[MPTEST] %s" % s)
if _log_file:
_log_file.store_line(s)
_log_file.flush()
_push_overlay(s)
func banner(s: String) -> void:
log_line("")
log_line("======== %s ========" % s)
func _build_overlay(parent: Node) -> void:
if not parent:
return
var layer := CanvasLayer.new()
parent.add_child(layer)
_overlay = Label.new()
_overlay.add_theme_font_size_override("font_size", 14)
_overlay.add_theme_color_override("font_color", Color.WHITE)
_overlay.add_theme_color_override("font_outline_color", Color.BLACK)
_overlay.add_theme_constant_override("outline_size", 6)
_overlay.set_anchors_preset(Control.PRESET_TOP_WIDE)
layer.add_child(_overlay)
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 = "\n".join(_overlay_lines)
# --- ledger ----------------------------------------------------------------
func record(step: String, side: String, res: Dictionary) -> void:
var ok: bool = res.get("ok", false)
var detail: String = str(res.get("detail", ""))
results.append({"step": step, "side": side, "ok": ok, "detail": detail})
log_line("%s [%s] %s" % ["PASS" if ok else "FAIL", side, step])
log_line(" %s" % detail)
func failed_count() -> int:
var failed := 0
for r in results:
if not r["ok"]:
failed += 1
return failed
func print_summary() -> void:
banner("RESULTS")
for r in results:
log_line("%-4s %-8s %s" % ["PASS" if r["ok"] else "FAIL", r["side"], r["step"]])
var failed := failed_count()
log_line("%d/%d checks passed" % [results.size() - failed, results.size()])
for r in results:
if not r["ok"]:
log_line("FAILURE %s [%s]: %s" % [r["step"], r["side"], r["detail"]])
## A standalone report written next to the logs, so a run can be read without
## scrolling the console — and so the in-editor runner can print it back.
func write_report() -> void:
var path := "res://logs/mptest_report.txt"
var f := FileAccess.open(path, FileAccess.WRITE)
if not f:
return
var failed := failed_count()
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_line("report written to %s" % ProjectSettings.globalize_path(path))
# --- per-step screenshots --------------------------------------------------
#
# One frame per peer per step, stitched into a 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 log tells you
# something diverged, the GIF shows you what it looked like.
func setup_frames(enabled: bool, is_server: bool) -> void:
_frames_enabled = enabled
if not _frames_enabled:
return
if DisplayServer.get_name() == "headless":
_frames_enabled = false
log_line("frame capture disabled: a headless run has no rendered output to grab")
return
_frames_dir = "res://logs/mptest_frames_%s" % ("server" if is_server else "client")
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(_frames_dir))
# Clear a previous run's frames, or the GIF splices 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_line("capturing a frame per step into %s" % ProjectSettings.globalize_path(_frames_dir))
func frames_enabled() -> bool:
return _frames_enabled
func next_frame_index() -> int:
_frame_index += 1
return _frame_index
func save_frame(index: int, label: String) -> void:
if not _frames_enabled:
return
_frame_index = index
# 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 picks them up as
# frame_%04d.png; the step name is already legible in the overlay.
var err := img.save_png("%s/frame_%04d.png" % [_frames_dir, index])
if err != OK:
log_line(" could not save frame %d (%s)" % [index, error_string(err)])
else:
_frame_labels.append("%04d %s" % [index, label])
## Written alongside the frames so a frame number can be traced back to the step
## that produced it.
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()
+1
View File
@@ -0,0 +1 @@
uid://cc0fn6yfqc0ey
+221
View File
@@ -0,0 +1,221 @@
extends Node
class_name MpSnapshot
## The cross-peer sync audit: both peers describe everything they can see, and
## the two descriptions must match.
##
## This is what actually catches desyncs. 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
## perfectly but the thing on screen does not. So the description deliberately
## includes what is RENDERED, not just the replicated values behind it: a plate
## whose contained_ids arrived but whose visuals were never rebuilt looks empty
## to the player, and that has to count as a desync.
## How far apart the same object may be on two peers before it counts as a desync.
const POS_TOLERANCE := 0.08
## Physics keeps moving after a step (dropped objects fall, snaps settle), so
## give the peers this long to converge before calling a difference a failure.
const SETTLE_SEC := 3.0
## 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 ~0.12m out, so past this it has come off.
const MAX_VISUAL_OFFSET := 0.3
## Local offset at which a cosmetic item counts as having moved from its slot.
const VISUAL_DRIFT_EPSILON := 0.05
## Stations whose on-screen state has to match on every peer.
const WATCHED_STATIONS := ["Hob", "Sink", "DirtStation", "Counter", "Counter2"]
var view: MpWorldView
var report: MpReport
## Named by the driver so a drift warning says which step was running.
var current_step := "(before any step)"
var _drift_reported := {}
func setup(p_view: MpWorldView, p_report: MpReport) -> void:
view = p_view
report = p_report
func take() -> Dictionary:
var out := {}
for child in view.pickables():
out[str(child.name)] = describe(child as Node3D)
for name in WATCHED_STATIONS:
var station := view.find(name)
if station:
out["station:" + name] = describe_station(station)
return out
## Everything about an object that should look identical on every peer.
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 — 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 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 will not 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 entirely.
var off := max_visual_offset(item)
d["_visual_offset"] = off
d["visuals_attached"] = off <= MAX_VISUAL_OFFSET
return d
## 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
## still 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 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
## Largest distance from a plate's origin to any cosmetic item 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
## Compares the server's view with the client's, returning the differences.
func compare(server: Dictionary, client: Dictionary) -> Array[String]:
var problems: Array[String] = []
for name in server:
if not client.has(name):
problems.append("'%s' exists on the server but NOT on the client" % name)
for name in client:
if not server.has(name):
problems.append("'%s' exists on the client but NOT on the server (ghost copy)" % name)
for name in server:
if not client.has(name):
continue
var s: Dictionary = server[name]
var c: Dictionary = client[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 > POS_TOLERANCE:
problems.append("%s is %.3fm apart (server %s vs client %s)" % [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" % [name, key, s[key], c[key]])
# Absolute invariants, checked per peer. A cross-peer diff cannot 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 name in snap:
var d: Dictionary = snap[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, name, d.get("_visual_offset", -1.0), MAX_VISUAL_OFFSET])
return problems
# --- live watch on plate visuals -------------------------------------------
#
# The per-step checks tell us food ended up off a 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.
func _process(_delta: float) -> void:
if not view or not view.world:
return
for item in view.roots_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
report.log_line("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])
report.log_line(" plate: pos=%s freeze=%s held_by=%s xform_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 "-",
view.xform_authority(plate)])
if c is RigidBody3D:
report.log_line(" 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])
+1
View File
@@ -0,0 +1 @@
uid://bcxf7wu740dqs
+245
View File
@@ -0,0 +1,245 @@
extends Node
class_name MpSteps
## The simulated player actions: reach, grab, carry, drop.
##
## These drive the real godot-xr-tools pickup path rather than teleporting
## objects around, because the point is to test what a player actually does. XR
## gives no tracked controller headlessly, so XRToolsFunctionPickup._process
## bails out on `_controller.get_is_active()` — the grab is therefore driven by
## hand: position the hand, let the grab Area3D register what is in range,
## refresh the closest-object pick, then press grip.
##
## Every action returns {ok: bool, detail: String}.
## Distance under which an object 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
var view: MpWorldView
var report: MpReport
var hand: XRToolsFunctionPickup
var controller: Node3D
var _hand_detached := false
func setup(p_view: MpWorldView, p_report: MpReport, p_hand: XRToolsFunctionPickup, p_controller: Node3D) -> void:
view = p_view
report = p_report
hand = p_hand
controller = p_controller
# --- waiting ---------------------------------------------------------------
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
## does not produce a spurious failure and a fast one does not 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
report.log_line(" timed out after %.0fs waiting for %s" % [timeout, what])
return false
## Puts the hand at a world position.
##
## The controller is a child of the XROrigin, whose PlayerBody keeps moving under
## 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 simply running the
## scene by hand leaves a real tracked controller alone.
func move_hand_to(pos: Vector3) -> void:
if not _hand_detached:
_hand_detached = true
controller.top_level = true
report.log_line(" (detached the test hand from the XR rig so it can be driven directly)")
controller.global_position = pos
controller.force_update_transform()
# --- actions ---------------------------------------------------------------
func grab(item: Node3D, label: String) -> Dictionary:
if not is_instance_valid(item):
return {"ok": false, "detail": "'%s' does not exist on this peer" % label}
report.log_line(" 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
report.log_line(" 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" % view.diag(item, hand)}
report.log_line(" 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, view.diag(item, hand)]}
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(), view.diag(item, hand)]}
# Grabbing is predicted locally and confirmed by the server handing us the
# object's NetXform. Until that lands the server is still driving it, so
# waiting here is what makes the following carry meaningful.
report.log_line(" holding %s; waiting for the server to hand over NetXform..." % label)
if not await wait_until(func(): return view.xform_authority(item) == multiplayer.get_unique_id(),
"NetXform handoff", 5.0):
return {"ok": false, "detail": "grabbed %s, but NetXform authority stayed with peer %d. %s"
% [label, view.xform_authority(item), view.diag(item, hand)]}
report.log_line(" NetXform is ours (peer %d)" % multiplayer.get_unique_id())
return {"ok": true, "detail": "grabbed %s via %s; %s" % [label, closest.name, view.diag(item, hand)]}
func 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, view.diag(item, hand)]}
report.log_line(" 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 get_picked_up_by()'s return
# type is not known statically and := cannot infer it.
var by: Node = item.get_picked_up_by()
report.log_line(" 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 object 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 (zone enabled=%s)"
% [by.get_parent().name, label, by.enabled]}
if not await wait_until(func(): return not is_instance_valid(item) or view.xform_authority(item) == 1,
"NetXform to return to the server", 5.0):
return {"ok": false, "detail": "dropped %s, but NetXform authority stayed with peer %d"
% [label, view.xform_authority(item)]}
return {"ok": true, "detail": "dropped %s (now held by %s)" % [label, by]}
## Grab an object, carry it onto a station's snap zone, and let go there.
func place_in_zone(item: Node3D, label: String, station: String) -> Dictionary:
var zone := view.zone_of(station)
if not zone:
return {"ok": false, "detail": "station '%s' has no snap zone on this peer" % station}
var res := await grab(item, label)
if not res["ok"]:
return res
res = await carry_to(item, label, zone.global_position)
if not res["ok"]:
return res
res = await drop(item, label)
if not res["ok"]:
return res
report.log_line(" 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, view.diag(item, hand)]}
## Grab an object and carry it into another, 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 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 grab(item, label)
if not res["ok"]:
return res
res = await 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 object
# we are holding may be consumed by it.
for i in 60:
if not is_instance_valid(item):
report.log_line(" %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 consume the carried object, so still holding it means the
# reaction never fired. Let go first, so the next step is not 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, view.diag(item, hand)]}
## Carry an object somewhere clear and put it down, freeing the station it was in.
func park(item: Node3D, label: String, pos: Vector3) -> Dictionary:
var res := await grab(item, label)
if not res["ok"]:
return res
res = await carry_to(item, label, pos)
if not res["ok"]:
return res
return await drop(item, label)
## Move the held object onto a world position. Closed-loop: the hand moves by
## whatever the object's remaining error is, since the grab point offsets the
## object from the hand by an amount not worth hard-coding.
func 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}
report.log_line(" 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)
report.log_line(" %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 object with the given food id (cooking the
## burger, or combining into a hamburger). Server-side: stations only run there.
func await_food(food_id: String) -> Dictionary:
report.log_line(" waiting for a '%s' to appear..." % food_id)
var ok := await wait_until(func(): return view.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 go looking for it.
await wait_frames(30)
var made := view.find_by_food_id(food_id)
return {"ok": true, "detail": "'%s' produced: %s at %s" % [food_id, made.name, made.global_position]}
func await_clean(plate_name: String) -> Dictionary:
var plate := view.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")
report.log_line(" 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, view.diag(plate, hand)]}
return {"ok": true, "detail": "%s was washed clean" % plate_name}
+1
View File
@@ -0,0 +1 @@
uid://cnwwp8f22wwk5
+229 -1053
View File
File diff suppressed because it is too large Load Diff
+117
View File
@@ -0,0 +1,117 @@
extends Node
class_name MpWorldView
## Finding things in the world under test, and describing what they are doing.
##
## Nothing here caches a node. Items are consumed and respawned as the run goes
## on — the raw burger becomes a cooked burger, which becomes a hamburger, which
## is absorbed into a plate — so a held reference is a freed object waiting to
## happen. Everything is looked up at the moment it is used.
var world: Node3D
var report: MpReport
var _despawn_timers_seen := {}
func setup(p_world: Node3D, p_report: MpReport) -> void:
world = p_world
report = p_report
## Objects live either baked in the scene root or, once spawned at runtime, under
## WorldContent. Look in both.
func find(name: String) -> Node3D:
var n := world.get_node_or_null(name)
if not n:
n = world.get_node_or_null("WorldContent/" + 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 one is by the food id it carries.
func find_by_food_id(id: String) -> Node3D:
for child in roots_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
func roots_children() -> Array[Node]:
var out: Array[Node] = []
for root in [world, world.get_node_or_null("WorldContent")]:
if not root:
continue
for child in root.get_children():
out.append(child)
return out
func pickables() -> Array[Node]:
var out: Array[Node] = []
for child in roots_children():
if child is XRToolsPickable and not child.is_queued_for_deletion():
out.append(child)
return out
## Who is currently driving this object's transform.
##
## Under the high-level model the object's own multiplayer authority never
## changes — only its NetXform's does, and that is what "who is holding this"
## means now.
func xform_authority(item: Node) -> int:
if not is_instance_valid(item):
return 0
var xform := item.get_node_or_null(NetReplication.XFORM_NAME)
return xform.get_multiplayer_authority() if xform else 0
## The test deliberately leaves objects sitting still for minutes at a time,
## which DespawningItem treats as litter and removes — it took the raw burger
## away before the client had even connected. Hold them indefinitely instead, so
## the run tests the kitchen rather than the despawn timer.
##
## Re-checked before every step rather than only at startup, because cooking and
## combining spawn new objects as the run goes on.
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:
report.log_line("disabled %d DespawningItem timer(s) so test objects don't vanish mid-run" % stopped)
func diag(item: Node3D, hand: XRToolsFunctionPickup = null) -> String:
if not is_instance_valid(item):
return "item[freed]"
var pc := item.get_node_or_null("PlateController")
var s := "%s[pos=%s xform_authority=%d" % [item.name, item.global_position, xform_authority(item)]
if item is XRToolsPickable:
s += " enabled=%s can_pick_up=%s layer=%d held_by=%s" % [
item.enabled, item.can_pick_up(hand) if hand else "?",
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(stations: Array, hand: XRToolsFunctionPickup) -> void:
report.log_line(" stations:")
for station in stations:
var z := zone_of(station)
if z:
report.log_line(" %-12s zone holds %s (enabled=%s)" % [station, z.picked_up_object, z.enabled])
report.log_line(" items:")
for child in pickables():
report.log_line(" %s" % diag(child as Node3D, hand))
+1
View File
@@ -0,0 +1 @@
uid://dxqia06f3e4u4
+24
View File
@@ -0,0 +1,24 @@
extends Node
## Throwaway probe: prints how GDScript reports typed-array properties, so
## NetReplication's filter can exclude Array[Node3D] / Array[FoodItem] without
## guessing at the hint format.
func _ready() -> void:
for path in [
"res://Containers/container.gd",
"res://Containers/plate_controller.gd",
"res://Stations/sink.gd",
"res://Stations/item_dispenser.gd",
"res://Stations/hob.gd",
]:
var script: Script = load(path)
print("=== ", path)
for prop in script.get_script_property_list():
if not (prop["usage"] & PROPERTY_USAGE_SCRIPT_VARIABLE):
continue
print(" name=%s type=%d hint=%d hint_string=%s" % [
prop["name"], prop["type"], prop["hint"], prop["hint_string"],
])
print("TYPE_ARRAY=%d TYPE_OBJECT=%d" % [TYPE_ARRAY, TYPE_OBJECT])
get_tree().quit()
+1
View File
@@ -0,0 +1 @@
uid://ctsonlf12536k
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://bspike0probe0"]
[ext_resource type="Script" path="res://test/spike/probe.gd" id="1_probe"]
[node name="Probe" type="Node"]
script = ExtResource("1_probe")