Files
VRyHungry1/test/mp_steps.gd
algodoogle ee835ea295 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>
2026-07-28 23:22:58 +01:00

246 lines
11 KiB
GDScript

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}