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}