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
+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()