Files
VRyHungry1/test/mp_snapshot.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

222 lines
8.9 KiB
GDScript

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])