mp fix
This commit is contained in:
+170
-6
@@ -88,6 +88,12 @@ var _frame_index := 0
|
||||
func _ready() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
_auto_mode = "--mptest" in args
|
||||
# Opt-in only. This node also sits in the real multiplayer scene (so a client
|
||||
# joining a test session has a driver), and must be completely inert during
|
||||
# an ordinary game — no overlay, no debug camera, no keyboard hooks.
|
||||
if not _auto_mode and not ("--mptest-manual" in args):
|
||||
queue_free()
|
||||
return
|
||||
_frames_enabled = "--mptest-frames" in args
|
||||
_step_pause = _arg_value(args, "--mptest-pause", 0.0)
|
||||
_end_hold = _arg_value(args, "--mptest-hold", 0.0)
|
||||
@@ -98,7 +104,8 @@ func _ready() -> void:
|
||||
_refresh_role()
|
||||
_log("=== mp test driver ready (role=%s, peer=%d, mode=%s) ==="
|
||||
% [_role, multiplayer.get_unique_id(), "automatic" if _auto_mode else "manual"])
|
||||
if not _resolve_nodes():
|
||||
# await: _resolve_nodes now waits for the server's spawns to arrive.
|
||||
if not await _resolve_nodes():
|
||||
if _auto_mode:
|
||||
_finish(false)
|
||||
return
|
||||
@@ -133,14 +140,40 @@ func _resolve_nodes() -> bool:
|
||||
if not _hand:
|
||||
_log("FATAL: could not resolve XROrigin3D/XRControllerRightHand/FunctionPickup")
|
||||
return false
|
||||
# The kitchen is no longer baked into the live scene: the server harvests the
|
||||
# authored nodes and respawns them replicated, so on a client nothing exists
|
||||
# until those spawns arrive. Wait for them rather than failing immediately.
|
||||
for required in ["Hob", "Sink", "DirtStation", "Counter", "Counter2", "Plate"]:
|
||||
if not _find(required):
|
||||
_log("FATAL: test scene is missing '%s'" % required)
|
||||
if not await _wait_until(func(): return _find(required) != null,
|
||||
"'%s' to arrive from the server" % required, SETUP_TIMEOUT_SEC):
|
||||
_log("FATAL: '%s' never appeared in the world" % required)
|
||||
return false
|
||||
_log("resolved hand=%s; kitchen has Hob, Sink, DirtStation, Counter, Counter2" % _hand.get_path())
|
||||
_disable_despawn_timers()
|
||||
return true
|
||||
|
||||
|
||||
# The test deliberately leaves items sitting still for minutes at a time, which
|
||||
# DespawningItem would treat as litter and remove (it took the raw burger out
|
||||
# before the client had even connected). Hold them indefinitely instead, so the
|
||||
# run tests the kitchen rather than the despawn timer.
|
||||
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:
|
||||
_log("disabled %d DespawningItem timer(s) so test items don't vanish mid-run" % stopped)
|
||||
|
||||
|
||||
# Items appear as the run goes on (cooking and combining spawn new ones), so
|
||||
# this is re-checked before every step rather than only at startup.
|
||||
var _despawn_timers_seen := {}
|
||||
|
||||
|
||||
# Items live either baked in the scene root or, once spawned at runtime, under
|
||||
# WorldContent. Look in both.
|
||||
func _find(name: String) -> Node3D:
|
||||
@@ -184,6 +217,11 @@ func _run_server() -> void:
|
||||
# A frame of the untouched kitchen, so the GIF opens on the starting state.
|
||||
await _capture_step_frame("start")
|
||||
|
||||
# 0. The client opened a different, empty scene, so everything it has must
|
||||
# have arrived over the network. Check that before touching anything — this
|
||||
# is the same path a player joining mid-session takes.
|
||||
await _step("server", "world_replicated_to_client", "verify_world_replicated", [])
|
||||
|
||||
# 1-2. Both peers can pick the plate up and put it down.
|
||||
await _step("client", "client_grab_plate", "grab", ["Plate"])
|
||||
await _step("client", "client_drop_plate", "drop", ["Plate"])
|
||||
@@ -204,8 +242,10 @@ func _run_server() -> void:
|
||||
# 5. Client takes the dirty plate to the sink, which should wash it clean.
|
||||
await _step("client", "client_plate_to_sink", "place_in_zone", ["Plate", "Sink"])
|
||||
await _both("plate_snapped_in_sink", "verify_snapped", ["Plate", "Sink"])
|
||||
await _both("sink_bar_shown_while_washing", "verify_station_bar", ["Sink", "true"])
|
||||
await _step("server", "wait_for_wash", "await_clean", ["Plate"])
|
||||
await _both("plate_washed_clean", "verify_dirty", ["Plate", "false"])
|
||||
await _both("sink_bar_hidden_when_done", "verify_station_bar", ["Sink", "false"])
|
||||
|
||||
# 6. The cook-and-plate round, once per peer. The first round uses the items
|
||||
# baked into the scene; the second uses freshly spawned ones, so both paths
|
||||
@@ -230,6 +270,7 @@ func _cook_and_plate_round(actor: String, burger: String, buns: String, plate: S
|
||||
# Burger onto the hob; it should cook and the raw one should disappear.
|
||||
await _step(actor, "%s_burger_to_hob" % actor, "place_in_zone", [burger, "Hob"])
|
||||
await _both("%s_burger_snapped_in_hob" % actor, "verify_snapped", [burger, "Hob"])
|
||||
await _both("%s_hob_bar_shown_while_cooking" % actor, "verify_station_bar", ["Hob", "true"])
|
||||
await _step("server", "%s_wait_for_cook" % actor, "await_food", ["cooked_burger"])
|
||||
|
||||
# Get it off the hob before anything else: the hob keeps cooking whatever is
|
||||
@@ -238,6 +279,7 @@ func _cook_and_plate_round(actor: String, burger: String, buns: String, plate: S
|
||||
await _step(actor, "%s_cooked_to_counter" % actor, "place_food_in_zone", ["cooked_burger", "Counter"])
|
||||
await _both("%s_raw_burger_removed" % actor, "verify_gone", [burger])
|
||||
await _both("%s_cooked_burger_exists" % actor, "verify_food_exists", ["cooked_burger"])
|
||||
await _both("%s_hob_bar_hidden_when_empty" % actor, "verify_station_bar", ["Hob", "false"])
|
||||
|
||||
# Now bring the buns to it to combine.
|
||||
await _step(actor, "%s_buns_to_cooked" % actor, "carry_food_to_food", [buns, "cooked_burger"])
|
||||
@@ -256,6 +298,7 @@ func _cook_and_plate_round(actor: String, burger: String, buns: String, plate: S
|
||||
# nowhere to put its plate.
|
||||
await _both("%s_food_on_plate_before_lift" % actor, "verify_plate_visuals", [plate])
|
||||
await _step(actor, "%s_plate_off_counter2" % actor, "park", [plate, park_at])
|
||||
await _both("%s_counter2_freed" % actor, "verify_zone_empty", ["Counter2"])
|
||||
# The food must still be on the plate after it has been carried off the
|
||||
# counter and set down again.
|
||||
await _both("%s_food_stayed_on_plate" % actor, "verify_plate_visuals", [plate])
|
||||
@@ -297,6 +340,7 @@ func _find_client_id() -> int:
|
||||
# and record the verdict.
|
||||
func _step(actor: String, label: String, step: String, args: Array) -> void:
|
||||
_current_step = label
|
||||
_disable_despawn_timers()
|
||||
_banner("STEP %d: %s (on the %s)" % [_next_step_no(), label, actor.to_upper()])
|
||||
var res: Dictionary
|
||||
if actor == "server":
|
||||
@@ -313,6 +357,7 @@ func _step(actor: String, label: String, step: String, args: Array) -> void:
|
||||
# agree, which is the whole point of the exercise.
|
||||
func _both(label: String, step: String, args: Array) -> void:
|
||||
_current_step = label
|
||||
_disable_despawn_timers()
|
||||
_banner("STEP %d: %s (checked on BOTH peers)" % [_next_step_no(), label])
|
||||
_record(label, "server", await _run_local_step(step, args))
|
||||
_record(label, "client", await _remote(step, args))
|
||||
@@ -363,6 +408,7 @@ func _report() -> void:
|
||||
_log("FAILURE %s [%s]: %s" % [r["step"], r["side"], r["detail"]])
|
||||
await _capture_step_frame("final")
|
||||
_write_frame_index()
|
||||
_write_report(failed)
|
||||
if not _auto_mode:
|
||||
return
|
||||
_quit_client.rpc_id(_client_id)
|
||||
@@ -374,11 +420,40 @@ func _report() -> void:
|
||||
_finish(failed == 0)
|
||||
|
||||
|
||||
# A standalone report of the run, written next to the logs so it can be read
|
||||
# without scrolling the console — and so the in-editor runner can print it back.
|
||||
func _write_report(failed: int) -> void:
|
||||
var path := "res://logs/mptest_report.txt"
|
||||
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||
if not f:
|
||||
return
|
||||
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("report written to %s" % ProjectSettings.globalize_path(path))
|
||||
|
||||
|
||||
# --- Client command handling ----------------------------------------------
|
||||
|
||||
@rpc("authority", "reliable")
|
||||
func _cmd(step: String, args: Array) -> void:
|
||||
_current_step = step
|
||||
_disable_despawn_timers()
|
||||
_log("<- server: %s%s" % [step, args])
|
||||
_running = true
|
||||
var res := await _run_local_step(step, args)
|
||||
@@ -433,6 +508,12 @@ func _run_local_step(step: String, args: Array) -> Dictionary:
|
||||
return _check_plate_contains(args[0], args[1])
|
||||
"verify_plate_visuals":
|
||||
return _check_plate_visuals(args[0])
|
||||
"verify_station_bar":
|
||||
return _check_station_bar(args[0], args[1] == "true")
|
||||
"verify_zone_empty":
|
||||
return _check_zone_empty(args[0])
|
||||
"verify_world_replicated":
|
||||
return await _check_world_replicated()
|
||||
return {"ok": false, "detail": "unknown step %s" % step}
|
||||
|
||||
|
||||
@@ -872,6 +953,10 @@ func _visual_count(item: Node3D, path: String) -> int:
|
||||
return count
|
||||
|
||||
|
||||
## Stations whose on-screen state has to match on every peer.
|
||||
const WATCHED_STATIONS := ["Hob", "Sink", "DirtStation", "Counter", "Counter2"]
|
||||
|
||||
|
||||
func _snapshot() -> Dictionary:
|
||||
var out := {}
|
||||
for root in [_world, _world.get_node_or_null("WorldContent")]:
|
||||
@@ -880,9 +965,33 @@ func _snapshot() -> Dictionary:
|
||||
for child in root.get_children():
|
||||
if child is XRToolsPickable and not child.is_queued_for_deletion():
|
||||
out[str(child.name)] = _describe(child)
|
||||
for name in WATCHED_STATIONS:
|
||||
var station := _find(name)
|
||||
if station:
|
||||
out["station:" + name] = _describe_station(station)
|
||||
return out
|
||||
|
||||
|
||||
# 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
|
||||
# that stays 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 two 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
|
||||
|
||||
|
||||
@rpc("authority", "reliable")
|
||||
func _request_snapshot() -> void:
|
||||
_snapshot_reply.rpc_id(1, _snapshot())
|
||||
@@ -918,9 +1027,11 @@ func _compare(server: Dictionary, client: Dictionary) -> Array[String]:
|
||||
continue
|
||||
var s: Dictionary = server[name]
|
||||
var c: Dictionary = client[name]
|
||||
var dist: float = (s["pos"] as Vector3).distance_to(c["pos"])
|
||||
if dist > SYNC_POS_TOLERANCE:
|
||||
problems.append("%s is %.3fm apart (server %s vs client %s)" % [name, dist, s["pos"], c["pos"]])
|
||||
# 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 > SYNC_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("_"):
|
||||
@@ -1031,6 +1142,59 @@ func _check_plate_visuals(plate_name: String) -> Dictionary:
|
||||
% [plate_name, off, _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 _check_station_bar(station_name: String, want_visible: bool) -> Dictionary:
|
||||
var station := _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}
|
||||
|
||||
|
||||
# The client loaded a bare multiplayer scene with no kitchen in it, so every
|
||||
# object it can see arrived from the server. This confirms it got the whole
|
||||
# layout — the same thing that has to work for a player joining mid-session.
|
||||
# Server-side check: it asks the client for its inventory and compares.
|
||||
func _check_world_replicated() -> Dictionary:
|
||||
var mine := _snapshot()
|
||||
var theirs := await _fetch_client_snapshot()
|
||||
if theirs.is_empty():
|
||||
return {"ok": false, "detail": "the client reported nothing at all"}
|
||||
var problems := _compare(mine, theirs)
|
||||
var detail := "server has %d objects, client has %d" % [mine.size(), theirs.size()]
|
||||
if not problems.is_empty():
|
||||
return {"ok": false, "detail": "%s; %s" % [detail, "; ".join(problems)]}
|
||||
return {"ok": true, "detail": "%s, all matching (client received the world over the network)" % detail}
|
||||
|
||||
|
||||
# A station that has had its item taken away must not still be holding it.
|
||||
func _check_zone_empty(station_name: String) -> Dictionary:
|
||||
var zone := _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}
|
||||
|
||||
|
||||
# --- Diagnostics -----------------------------------------------------------
|
||||
|
||||
func _diag(item: Node3D) -> String:
|
||||
|
||||
@@ -34,7 +34,7 @@ size = Vector3(15, 20, 0.1)
|
||||
|
||||
[node name="Main" type="Node3D" unique_id=1312265607]
|
||||
script = ExtResource("1_6uucx")
|
||||
populate_from_layout = false
|
||||
populate_from_layout = true
|
||||
|
||||
[node name="XROrigin3D" parent="." unique_id=2055526621 groups=["local_xr_origin"] instance=ExtResource("2_j7vd1")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.36171648, 0, 0.81835127)
|
||||
@@ -100,10 +100,10 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2, 0.5, 0)
|
||||
script = ExtResource("9_mptst")
|
||||
|
||||
[node name="raw_burger" parent="." unique_id=1675596942 instance=ExtResource("10_51k0c")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.28692234, 1.0149999, 0.3505687)
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.28692234, 1.0149999, 1.1)
|
||||
|
||||
[node name="BurgerBuns" parent="." unique_id=1088240294 instance=ExtResource("11_psgbv")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.40037438, 1.0094403, 0.34120744)
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.40037438, 1.0094403, 1.1)
|
||||
|
||||
[node name="Counter" parent="." unique_id=1487893288 instance=ExtResource("12_j5uvh")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1, 0.5, 0)
|
||||
|
||||
@@ -29,27 +29,28 @@ param(
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$proj = Split-Path -Parent $PSScriptRoot
|
||||
$scene = "res://test/multiPlayerTest.tscn"
|
||||
$serverScene = "res://test/multiPlayerTest.tscn"
|
||||
$clientScene = "res://Scenes/multiPlayer.tscn"
|
||||
|
||||
. (Join-Path $PSScriptRoot "mp_window_layout.ps1")
|
||||
|
||||
function Start-Instance($extraArgs) {
|
||||
$a = "--xr-mode off --resolution 900x600 --path `"$proj`" $scene"
|
||||
if ($extraArgs) { $a += " -- $($extraArgs -join ' ')" }
|
||||
function Start-Instance($extraArgs, $sceneFor) {
|
||||
$a = "--xr-mode off --resolution 900x600 --path `"$proj`" $sceneFor -- --mptest-manual"
|
||||
if ($extraArgs) { $a += " $($extraArgs -join ' ')" }
|
||||
return Start-Process -FilePath $Godot -ArgumentList $a -PassThru
|
||||
}
|
||||
|
||||
if ($Solo) {
|
||||
Write-Host "Opening a single offline window (no networking)."
|
||||
$null = Move-GameWindow (Start-Instance $null) 300 100
|
||||
$null = Move-GameWindow (Start-Instance $null $serverScene) 300 100
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host "Opening SERVER window (left)..."
|
||||
$w = Move-GameWindow (Start-Instance @("--server")) 20 60
|
||||
$w = Move-GameWindow (Start-Instance @("--server") $serverScene) 20 60
|
||||
Start-Sleep -Seconds 5
|
||||
Write-Host "Opening CLIENT window (right)..."
|
||||
$null = Move-GameWindow (Start-Instance @("--join", "127.0.0.1")) (20 + $w + 12) 60
|
||||
$null = Move-GameWindow (Start-Instance @("--join", "127.0.0.1") $clientScene) (20 + $w + 12) 60
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Both windows are up. Click one to focus it, then press:"
|
||||
|
||||
+62
-24
@@ -1,41 +1,69 @@
|
||||
# Runs the headless two-instance multiplayer test (test/multiPlayerTest.tscn).
|
||||
# Runs the two-instance multiplayer test and ALWAYS produces a GIF of the run.
|
||||
#
|
||||
# powershell -File test\run_mp_test.ps1
|
||||
#
|
||||
# Starts a server instance and a client instance of the game with --xr-mode off
|
||||
# (SteamVR's OpenXR runtime crashes a headless process), lets test/mp_test_driver.gd
|
||||
# drive the scripted plate/dirt-station sequence, then prints both logs.
|
||||
# Starts a server instance and a client instance on test/multiPlayerTest.tscn,
|
||||
# lets test/mp_test_driver.gd drive the scripted kitchen sequence, then writes:
|
||||
# logs\mptest_report.txt - every check, pass/fail, plus failure details
|
||||
# logs\mptest_run.gif - both peers side by side, one frame per step
|
||||
# logs\mptest_{server,client}.log - full step logs
|
||||
# Exits non-zero if any check failed.
|
||||
#
|
||||
# The windows are visible because frame capture needs a real framebuffer: a
|
||||
# headless Godot renders nothing, so there would be no GIF. Use -Headless when
|
||||
# you only want the pass/fail result (faster, but no GIF).
|
||||
|
||||
param(
|
||||
[string]$Godot = "C:\Users\Aqua Aurora\Desktop\Godot_v4.7-stable_win64.exe",
|
||||
[int]$TimeoutSec = 120
|
||||
[int]$TimeoutSec = 400,
|
||||
# Seconds to pause between steps, and to hold the final state on screen.
|
||||
[double]$Pause = 0.4,
|
||||
[double]$Hold = 3,
|
||||
# Passed through to make_gif.ps1.
|
||||
[int]$HalfWidth = 900,
|
||||
[double]$SecondsPerStep = 1.2,
|
||||
# Skip the windows entirely. No frames are captured, so no GIF is produced.
|
||||
[switch]$Headless
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$proj = Split-Path -Parent $PSScriptRoot
|
||||
$scene = "res://test/multiPlayerTest.tscn"
|
||||
# The two peers deliberately load DIFFERENT scenes, mirroring the real game:
|
||||
# the host opens whatever world it is running, the client opens the bare
|
||||
# multiplayer scene and must receive the entire layout over the network. That is
|
||||
# also exactly what a client joining mid-session goes through.
|
||||
$serverScene = "res://test/multiPlayerTest.tscn"
|
||||
$clientScene = "res://Scenes/multiPlayer.tscn"
|
||||
$serverLog = Join-Path $proj "logs/mptest_server.log"
|
||||
$clientLog = Join-Path $proj "logs/mptest_client.log"
|
||||
$report = Join-Path $proj "logs/mptest_report.txt"
|
||||
$gif = Join-Path $proj "logs/mptest_run.gif"
|
||||
|
||||
foreach ($f in @($serverLog, $clientLog)) { if (Test-Path $f) { Remove-Item $f -Force } }
|
||||
foreach ($f in @($serverLog, $clientLog, $report, $gif)) { if (Test-Path $f) { Remove-Item $f -Force } }
|
||||
|
||||
function Start-Instance($extraArgs) {
|
||||
# Single argument string with the project path quoted: Start-Process does
|
||||
# not quote array elements, so the space in the path would split it.
|
||||
$a = "--headless --xr-mode off --path `"$proj`" $scene -- $($extraArgs -join ' ')"
|
||||
$p = Start-Process -FilePath $Godot -ArgumentList $a -PassThru -NoNewWindow
|
||||
# Touching .Handle caches it, which is what makes .ExitCode readable later;
|
||||
# without this it comes back empty even after the process has exited.
|
||||
. (Join-Path $PSScriptRoot "mp_window_layout.ps1")
|
||||
|
||||
function Start-Instance($extraArgs, $sceneFor) {
|
||||
# --xr-mode off keeps it on the desktop (SteamVR's OpenXR runtime otherwise
|
||||
# takes over, and two instances can't share a headset anyway).
|
||||
$a = "--xr-mode off"
|
||||
if ($Headless) { $a = "--headless " + $a }
|
||||
$a += " --resolution 900x600 --path `"$proj`" $sceneFor -- $($extraArgs -join ' ') --mptest"
|
||||
if (-not $Headless) { $a += " --mptest-frames" }
|
||||
$a += " --mptest-pause $Pause --mptest-hold $Hold"
|
||||
$p = Start-Process -FilePath $Godot -ArgumentList $a -PassThru -NoNewWindow:$Headless
|
||||
# Touching .Handle caches it, which is what makes .ExitCode readable later.
|
||||
$null = $p.Handle
|
||||
return $p
|
||||
}
|
||||
|
||||
Write-Host "Starting server..."
|
||||
$server = Start-Instance @("--server", "--mptest")
|
||||
Start-Sleep -Seconds 4
|
||||
Write-Host "Starting client..."
|
||||
$client = Start-Instance @("--join", "127.0.0.1", "--mptest")
|
||||
Write-Host "Starting SERVER..."
|
||||
$server = Start-Instance @("--server") $serverScene
|
||||
if (-not $Headless) { $w = Move-GameWindow $server 20 60 }
|
||||
Start-Sleep -Seconds 5
|
||||
Write-Host "Starting CLIENT..."
|
||||
$client = Start-Instance @("--join", "127.0.0.1") $clientScene
|
||||
if (-not $Headless) { $null = Move-GameWindow $client (20 + $w + 12) 60 }
|
||||
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSec)
|
||||
while ((Get-Date) -lt $deadline -and -not $server.HasExited) { Start-Sleep -Milliseconds 500 }
|
||||
@@ -45,16 +73,26 @@ foreach ($p in @($server, $client)) {
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
|
||||
foreach ($pair in @(@("SERVER", $serverLog), @("CLIENT", $clientLog))) {
|
||||
if (-not $Headless) {
|
||||
Write-Host ""
|
||||
Write-Host "======================== $($pair[0]) ========================"
|
||||
if (Test-Path $pair[1]) { Get-Content $pair[1] -Encoding UTF8 } else { Write-Host "(no log written)" }
|
||||
Write-Host "======================== GIF ========================"
|
||||
& powershell -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "make_gif.ps1") `
|
||||
-HalfWidth $HalfWidth -SecondsPerStep $SecondsPerStep
|
||||
} else {
|
||||
Write-Host ""
|
||||
Write-Host "(headless run: no frames captured, so no GIF - drop -Headless to get one)"
|
||||
}
|
||||
|
||||
# ExitCode is only populated on the process object after a WaitForExit() call,
|
||||
# even when HasExited is already true - without this it reads back empty.
|
||||
Write-Host ""
|
||||
Write-Host "======================== REPORT ========================"
|
||||
if (Test-Path $report) { Get-Content $report -Encoding UTF8 } else { Write-Host "(no report written - the run did not finish)" }
|
||||
|
||||
$server.WaitForExit(2000) | Out-Null
|
||||
$code = if ($server.HasExited) { $server.ExitCode } else { 1 }
|
||||
Write-Host ""
|
||||
Write-Host "report: $report"
|
||||
if (-not $Headless -and (Test-Path $gif)) { Write-Host "gif: $gif" }
|
||||
Write-Host "logs: $serverLog"
|
||||
Write-Host " $clientLog"
|
||||
Write-Host "server exit code: $code"
|
||||
exit $code
|
||||
|
||||
@@ -24,7 +24,8 @@ param(
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$proj = Split-Path -Parent $PSScriptRoot
|
||||
$scene = "res://test/multiPlayerTest.tscn"
|
||||
$serverScene = "res://test/multiPlayerTest.tscn"
|
||||
$clientScene = "res://Scenes/multiPlayer.tscn"
|
||||
$serverLog = Join-Path $proj "logs/mptest_server.log"
|
||||
$clientLog = Join-Path $proj "logs/mptest_client.log"
|
||||
|
||||
@@ -32,10 +33,10 @@ foreach ($f in @($serverLog, $clientLog)) { if (Test-Path $f) { Remove-Item $f -
|
||||
|
||||
. (Join-Path $PSScriptRoot "mp_window_layout.ps1")
|
||||
|
||||
function Start-Instance($extraArgs) {
|
||||
function Start-Instance($extraArgs, $sceneFor) {
|
||||
# --xr-mode off keeps it on the desktop (SteamVR's OpenXR runtime otherwise
|
||||
# takes over, and two instances can't share a headset anyway).
|
||||
$a = "--xr-mode off --resolution 900x600 --path `"$proj`" $scene -- " +
|
||||
$a = "--xr-mode off --resolution 900x600 --path `"$proj`" $sceneFor -- " +
|
||||
"$($extraArgs -join ' ') --mptest --mptest-frames --mptest-pause $Pause --mptest-hold $Hold"
|
||||
$p = Start-Process -FilePath $Godot -ArgumentList $a -PassThru
|
||||
# Touching .Handle caches it, which is what makes .ExitCode readable later.
|
||||
@@ -44,11 +45,11 @@ function Start-Instance($extraArgs) {
|
||||
}
|
||||
|
||||
Write-Host "Starting SERVER window (left)..."
|
||||
$server = Start-Instance @("--server")
|
||||
$server = Start-Instance @("--server") $serverScene
|
||||
$w = Move-GameWindow $server 20 60
|
||||
Start-Sleep -Seconds 5
|
||||
Write-Host "Starting CLIENT window (right)..."
|
||||
$client = Start-Instance @("--join", "127.0.0.1")
|
||||
$client = Start-Instance @("--join", "127.0.0.1") $clientScene
|
||||
$null = Move-GameWindow $client (20 + $w + 12) 60
|
||||
|
||||
Write-Host "Watch the two windows (each has a fixed camera on the test area)."
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
@tool
|
||||
extends EditorScript
|
||||
|
||||
## Runs the whole multiplayer test suite from inside the Godot editor.
|
||||
##
|
||||
## HOW TO USE
|
||||
## 1. Open this file in the editor's script panel.
|
||||
## 2. File > Run (Ctrl+Shift+X).
|
||||
##
|
||||
## It launches two copies of the game (server + client) as separate processes,
|
||||
## waits for them to finish, then prints the full pass/fail report into the
|
||||
## Output panel and builds the side-by-side GIF.
|
||||
##
|
||||
## Everything it produces lands in logs/:
|
||||
## mptest_report.txt every check, pass/fail, plus details for failures
|
||||
## mptest_run.gif both peers side by side, one frame per step
|
||||
## mptest_{server,client}.log full step logs
|
||||
##
|
||||
## NOTE: the editor is blocked while the run is in progress (a couple of
|
||||
## minutes). Watch the two game windows to follow along.
|
||||
|
||||
## Give up if the server hasn't finished in this long.
|
||||
const TIMEOUT_SEC := 400
|
||||
## Seconds each step is held, so the run is watchable and the GIF is readable.
|
||||
const STEP_PAUSE := 0.4
|
||||
## Seconds the final state stays on screen before the instances close.
|
||||
const END_HOLD := 3.0
|
||||
|
||||
## The peers load different scenes on purpose, mirroring the real game: the host
|
||||
## opens whatever world it is running, the client opens the bare multiplayer
|
||||
## scene and has to receive the whole layout over the network — the same path a
|
||||
## client joining mid-session takes.
|
||||
const SERVER_SCENE := "res://test/multiPlayerTest.tscn"
|
||||
const CLIENT_SCENE := "res://Scenes/multiPlayer.tscn"
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var exe := OS.get_executable_path()
|
||||
var project_dir := ProjectSettings.globalize_path("res://").rstrip("/")
|
||||
var logs_dir := ProjectSettings.globalize_path("res://logs")
|
||||
DirAccess.make_dir_recursive_absolute(logs_dir)
|
||||
_clear_previous_results(logs_dir)
|
||||
|
||||
print_rich("[b]Running the multiplayer test suite...[/b]")
|
||||
print(" the editor will be unresponsive until it finishes (~2 minutes)")
|
||||
|
||||
var server_pid := _launch(exe, project_dir, ["--server"], SERVER_SCENE)
|
||||
if server_pid <= 0:
|
||||
push_error("Could not start the server instance")
|
||||
return
|
||||
# Give the host time to come up and open its port before the client dials in.
|
||||
OS.delay_msec(5000)
|
||||
var client_pid := _launch(exe, project_dir, ["--join", "127.0.0.1"], CLIENT_SCENE)
|
||||
if client_pid <= 0:
|
||||
push_error("Could not start the client instance")
|
||||
OS.kill(server_pid)
|
||||
return
|
||||
|
||||
var waited := 0.0
|
||||
while OS.is_process_running(server_pid) and waited < TIMEOUT_SEC:
|
||||
OS.delay_msec(500)
|
||||
waited += 0.5
|
||||
if OS.is_process_running(server_pid):
|
||||
print(" timed out after %ds, stopping the instances" % TIMEOUT_SEC)
|
||||
OS.kill(server_pid)
|
||||
if OS.is_process_running(client_pid):
|
||||
OS.kill(client_pid)
|
||||
OS.delay_msec(500)
|
||||
|
||||
_print_report(logs_dir)
|
||||
_build_gif(project_dir, logs_dir)
|
||||
|
||||
|
||||
func _launch(exe: String, project_dir: String, extra: Array, scene: String) -> int:
|
||||
var args := PackedStringArray([
|
||||
"--xr-mode", "off", "--resolution", "900x600",
|
||||
"--path", project_dir, scene, "--",
|
||||
])
|
||||
for a in extra:
|
||||
args.append(a)
|
||||
# --mptest runs the scripted sequence; --mptest-frames captures the frame per
|
||||
# step that the GIF is stitched from (needs a real window, hence no
|
||||
# --headless here).
|
||||
args.append_array(PackedStringArray([
|
||||
"--mptest", "--mptest-frames",
|
||||
"--mptest-pause", str(STEP_PAUSE), "--mptest-hold", str(END_HOLD),
|
||||
]))
|
||||
return OS.create_process(exe, args)
|
||||
|
||||
|
||||
# Remove the previous run's output so a failed launch can't leave stale results
|
||||
# looking like this run's.
|
||||
func _clear_previous_results(logs_dir: String) -> void:
|
||||
for name in ["mptest_report.txt", "mptest_run.gif", "mptest_server.log", "mptest_client.log"]:
|
||||
DirAccess.remove_absolute(logs_dir.path_join(name))
|
||||
for role in ["server", "client"]:
|
||||
var frames := logs_dir.path_join("mptest_frames_%s" % role)
|
||||
var dir := DirAccess.open(frames)
|
||||
if dir:
|
||||
for f in dir.get_files():
|
||||
if f.ends_with(".png"):
|
||||
dir.remove(f)
|
||||
|
||||
|
||||
func _print_report(logs_dir: String) -> void:
|
||||
var path := logs_dir.path_join("mptest_report.txt")
|
||||
if not FileAccess.file_exists(path):
|
||||
push_error("No report at %s — the run did not finish. Check logs/mptest_server.log" % path)
|
||||
return
|
||||
var text := FileAccess.get_file_as_string(path)
|
||||
print("")
|
||||
# Colour the summary so a failure is obvious in the Output panel.
|
||||
for line in text.split("\n"):
|
||||
if line.begins_with("FAIL") or line.contains("RESULT: FAILED"):
|
||||
print_rich("[color=red]%s[/color]" % line)
|
||||
elif line.contains("ALL CHECKS PASSED"):
|
||||
print_rich("[color=green][b]%s[/b][/color]" % line)
|
||||
elif line.begins_with("PASS"):
|
||||
print_rich("[color=gray]%s[/color]" % line)
|
||||
else:
|
||||
print(line)
|
||||
print("report: %s" % path)
|
||||
|
||||
|
||||
func _build_gif(project_dir: String, logs_dir: String) -> void:
|
||||
var script := project_dir.path_join("test/make_gif.ps1")
|
||||
var out := []
|
||||
var code := OS.execute("powershell", [
|
||||
"-ExecutionPolicy", "Bypass", "-File", script,
|
||||
], out, true)
|
||||
for line in out:
|
||||
print(line)
|
||||
var gif := logs_dir.path_join("mptest_run.gif")
|
||||
if code == 0 and FileAccess.file_exists(gif):
|
||||
print_rich("[b]gif:[/b] %s" % gif)
|
||||
else:
|
||||
push_warning("GIF was not produced (is ffmpeg on PATH?). See the output above.")
|
||||
@@ -0,0 +1 @@
|
||||
uid://crcuhy3n8goep
|
||||
Reference in New Issue
Block a user