diff --git a/Containers/container.gd b/Containers/container.gd index c5308eb..d99550a 100644 --- a/Containers/container.gd +++ b/Containers/container.gd @@ -73,7 +73,16 @@ func _add_item(item: Node3D) -> void: var plate_controller := xr_pickable.get_node_or_null("PlateController") as PlateController if plate_controller: - plate_controller.contained_ids.append(food_node.id) + # Reassign rather than append in place. contained_ids has a setter that + # rebuilds the plate's visuals, and mutating the array never triggers it — + # so the peer that actually added the food was the one peer that never + # redrew the plate. Remote peers looked right (the synchronizer assigns + # the value there, which does fire the setter), and the stale peer only + # caught up if someone else took the plate and sent the value back. + # duplicate() keeps the Array[String] typing that the property requires. + var updated := plate_controller.contained_ids.duplicate() + updated.append(food_node.id) + plate_controller.contained_ids = updated NetworkManager.despawn_item(item) @@ -82,7 +91,10 @@ func erase_item(item: FoodItem) -> void: contained_items.erase(item) var plate_controller := xr_pickable.get_node_or_null("PlateController") as PlateController if plate_controller: - plate_controller.contained_ids.erase(item.id) + # Same reason as _add_item: assign so the visuals actually refresh. + var remaining := plate_controller.contained_ids.duplicate() + remaining.erase(item.id) + plate_controller.contained_ids = remaining func clear() -> void: diff --git a/Net/net_pickable.gd b/Net/net_pickable.gd index da11cd5..2aa7d30 100644 --- a/Net/net_pickable.gd +++ b/Net/net_pickable.gd @@ -20,6 +20,10 @@ var _pickable: XRToolsPickable # apply_held_state() last forced it to while non-authority. var _original_freeze_mode: int +# Same idea for the pickable's authored `enabled` flag, which the non-authority +# branch of apply_held_state() clears while someone else is holding the item. +var _original_enabled: bool + func _ready() -> void: _pickable = get_parent() as XRToolsPickable @@ -27,6 +31,7 @@ func _ready() -> void: push_error("NetPickable must be a child of an XRToolsPickable") return _original_freeze_mode = _pickable.freeze_mode + _original_enabled = _pickable.enabled _pickable.picked_up.connect(_on_picked_up) _pickable.dropped.connect(_on_dropped) # Deferred: the pickable root captures its own original_collision_mask/ @@ -72,6 +77,22 @@ func apply_held_state() -> void: ) _pickable.freeze_mode = _original_freeze_mode _pickable.collision_mask = _pickable.original_collision_mask + # Unlike freeze/collision (which XRToolsPickable manages itself while + # held), `enabled` is only ever written by the non-authority branch + # below, so it must be restored here or it stays false forever: once a + # client grabbed this item, every other peer set enabled=false, and + # regaining authority left it that way. On the server that silently + # broke everything downstream — hands couldn't pick the item up again, + # and a station snap zone would "snap" it (emitting has_picked_up, so + # e.g. a plate still got marked dirty) while pick_up() bailed out on + # the disabled item, leaving the zone holding an item with no grab + # driver that then fell out of the station. + if _pickable.enabled != _original_enabled: + if NetworkManager.is_online(): + print("%s: reclaiming ownership, restoring enabled %s->%s" % [ + _pickable.name, _pickable.enabled, _original_enabled + ]) + _pickable.enabled = _original_enabled return # A net_held_by/position sync update can race ahead of the # authority-handoff RPC that's about to confirm a grab we just made @@ -132,8 +153,11 @@ func _on_dropped(_p) -> void: print("%s dropped, reporting release to server (lin=%s ang=%s)" % [ _pickable.name, _pickable.linear_velocity, _pickable.angular_velocity ]) + # Send our own final transform too: we were the authority until now, and the + # server's copy may not have received our last position sync yet. NetworkManager.release_item_authority_from( - _pickable.get_path(), _pickable.linear_velocity, _pickable.angular_velocity + _pickable.get_path(), _pickable.linear_velocity, _pickable.angular_velocity, + _pickable.global_transform ) diff --git a/Net/network_manager.gd b/Net/network_manager.gd index ed1460f..9024d7c 100644 --- a/Net/network_manager.gd +++ b/Net/network_manager.gd @@ -121,8 +121,31 @@ func spawn_item(scene_path: String, xform: Transform3D, node_name: String = "", ## every peer when a tracked node exits the tree on the authority, so this is ## the single seam for destroying spawned items (works offline too). func despawn_item(node: Node) -> void: - if owns_world() and is_instance_valid(node): - log_line("despawn_item: %s" % node.name) + if not owns_world() or not is_instance_valid(node): + return + log_line("despawn_item: %s" % node.name) + # Items that came from the ItemsSpawner are despawned on every peer + # automatically when they leave the tree here. Items baked into a scene file + # are unknown to the spawner, so their removal has to be broadcast + # explicitly — otherwise every client keeps a ghost copy of an item the + # server has consumed, which then blocks the station it was sitting in and + # gets grabbed instead of the real item that replaced it. + if is_online() and not _is_spawner_tracked(node): + _despawn_static_item.rpc(node.get_path()) + node.queue_free() + + +# Items the ItemsSpawner replicates live under its spawn path; anything else was +# baked into the scene file and the spawner knows nothing about it. +func _is_spawner_tracked(node: Node) -> bool: + return _content_root != null and _content_root.is_ancestor_of(node) + + +@rpc("authority", "call_remote", "reliable") +func _despawn_static_item(path: NodePath) -> void: + var node := get_node_or_null(path) + if node: + log_line("despawn_static_item: freeing %s (the server consumed it)" % node.name) node.queue_free() @@ -163,6 +186,19 @@ func _gate_station(node: Node) -> void: log_line("gated station (non-owner peer): %s" % node.name) +## Gate every station already sitting in the scene tree, for peers that don't +## own world logic. Stations that arrive through spawn_item() are gated as they +## are built (see _spawn_item_from_data), but ones baked into a scene file never +## pass through there — leaving a client running its own snap zones, which then +## grab items straight out of the local hand and fight the server's +## authoritative placement. Idempotent, so it's safe on every session start. +func gate_existing_stations() -> void: + if owns_world(): + return + for station in get_tree().get_nodes_in_group("station"): + _gate_station(station) + + # --- Item grab-authority transfer ----------------------------------------- ## Called by NetPickable when this peer grabs an item by hand. Godot rejects @@ -209,27 +245,35 @@ func _grant_or_reject_item_authority(item_path: NodePath, sender: int) -> void: ## Called by NetPickable when this peer releases an item, forwarding its throw ## velocity so the server can resume simulating it. Same self-RPC issue as ## above: runs directly if we're the server. -func release_item_authority_from(item_path: NodePath, lin: Vector3, ang: Vector3) -> void: +func release_item_authority_from(item_path: NodePath, lin: Vector3, ang: Vector3, xform: Transform3D) -> void: if is_server(): - _do_release_item_authority(item_path, lin, ang, multiplayer.get_unique_id()) + _do_release_item_authority(item_path, lin, ang, xform, multiplayer.get_unique_id()) else: - _release_item_authority_rpc.rpc_id(1, item_path, lin, ang) + _release_item_authority_rpc.rpc_id(1, item_path, lin, ang, xform) @rpc("any_peer", "reliable") -func _release_item_authority_rpc(item_path: NodePath, lin: Vector3, ang: Vector3) -> void: +func _release_item_authority_rpc(item_path: NodePath, lin: Vector3, ang: Vector3, xform: Transform3D) -> void: if not is_server(): return - _do_release_item_authority(item_path, lin, ang, multiplayer.get_remote_sender_id()) + _do_release_item_authority(item_path, lin, ang, xform, multiplayer.get_remote_sender_id()) ## Runs on the server. If released next to a station, the server snaps it in ## (server-authoritative placement). -func _do_release_item_authority(item_path: NodePath, lin: Vector3, ang: Vector3, sender: int) -> void: +func _do_release_item_authority(item_path: NodePath, lin: Vector3, ang: Vector3, xform: Transform3D, sender: int) -> void: log_line("release_item_authority: %s released by peer %d" % [str(item_path), sender]) _set_item_authority.rpc(item_path, 1) var item := get_node_or_null(item_path) if item is RigidBody3D: + # Adopt the releasing peer's own final transform rather than trusting our + # copy's. That peer was the item's authority right up to this moment, and + # its position updates travel on the synchronizer's separate, unordered + # channel — this reliable RPC routinely overtakes them, leaving our copy + # still sitting where the item was BEFORE the peer carried it away. The + # snap decision below then reads that stale position and teleports the + # item straight back into the station it was just picked up from. + item.global_transform = xform item.freeze = false item.linear_velocity = lin item.angular_velocity = ang diff --git a/Scenes/multiplayer_world.gd b/Scenes/multiplayer_world.gd index dec672b..7c81967 100644 --- a/Scenes/multiplayer_world.gd +++ b/Scenes/multiplayer_world.gd @@ -14,6 +14,12 @@ extends Node3D const PLAYER_SCENE := preload("res://Player/net_player.tscn") +## Whether to spawn the full WorldLayout on the machine that owns the world. +## The real game scene wants this; focused debug scenes (test/) bake their own +## handful of stations and items instead and turn it off, so the thing under +## test isn't sharing the world with a second copy of the whole kitchen. +@export var populate_from_layout: bool = true + var xr_interface: XRInterface var _populated := false @@ -59,13 +65,14 @@ func _exit_tree() -> void: func _on_session_started(_is_server: bool) -> void: + NetworkManager.gate_existing_stations() _populate_world_if_owner() ## Spawns the world's stations/items exactly once, on the machine that owns ## world logic (server or offline). Safe to call multiple times/entry points. func _populate_world_if_owner() -> void: - if not NetworkManager.owns_world() or _populated: + if not NetworkManager.owns_world() or _populated or not populate_from_layout: return _populated = true GameManager.meals_in_play = ["hamburger"] diff --git a/addons/godot-xr-tools/objects/grab_points/grab_point_hand.gd b/addons/godot-xr-tools/objects/grab_points/grab_point_hand.gd index 19ee2e8..34799e8 100644 --- a/addons/godot-xr-tools/objects/grab_points/grab_point_hand.gd +++ b/addons/godot-xr-tools/objects/grab_points/grab_point_hand.gd @@ -186,6 +186,12 @@ func _is_correct_hand(grabber : Node3D) -> bool: # Get the positional tracker var tracker := XRServer.get_tracker(controller.tracker) as XRPositionalTracker + # Without an XR runtime (desktop/headless testing) there is no tracker, so + # we can't tell which hand this is. Treat it as "not the correct hand" — + # the same result the null deref below used to produce after erroring. + if not tracker: + return false + # If left hand then verify left controller if hand == Hand.LEFT and tracker.hand != XRPositionalTracker.TRACKER_HAND_LEFT: return false diff --git a/test/mp_test_driver.gd b/test/mp_test_driver.gd new file mode 100644 index 0000000..fb24575 --- /dev/null +++ b/test/mp_test_driver.gd @@ -0,0 +1,1048 @@ +extends Node + +## Multiplayer debug harness for the kitchen flow, living in +## test/multiPlayerTest.tscn. +## +## Two ways to use it: +## +## AUTOMATIC — pass `--mptest` and the server runs the whole scripted sequence +## and exits non-zero if any check fails. test/run_mp_test.ps1 does this +## headlessly; test/run_mp_test_windowed.ps1 does it in two visible windows with +## a pause between steps so you can watch it happen. +## +## MANUAL — just run the scene (no `--mptest`). Every window gets an on-screen +## overlay, a fixed camera over the kitchen, and keyboard controls: +## +## H / J host / join 127.0.0.1 +## 1 grab the plate 2 drop it +## 3 plate -> dirt station 4 plate -> sink +## 5 raw burger -> hob 6 cooked burger -> counter +## 7 buns -> cooked burger (combine) +## 8 plate -> counter 2 9 hamburger -> plate +## R run the whole automatic sequence (server only) +## 0 dump state C toggle camera +## +## Keys act on the peer whose window has focus, so you can do a step on the +## client and watch the server window follow (or fail to). +## +## The scripted sequence: +## 1. client grabs the plate, then drops it +## 2. server grabs the plate, then drops it +## 3. client carries it to the dirt station -> snaps + turns dirty on both +## 4. both peers confirm they can still pick it up +## 5. client carries the dirty plate to the sink -> gets washed clean on both +## 6. a cook-and-plate round, run once by the CLIENT and once by the SERVER: +## raw burger -> hob (cooks into a cooked burger, raw one removed), +## cooked burger -> counter, buns -> cooked burger (combines into a +## hamburger), plate -> counter 2, hamburger -> plate. + +## How long the server waits for a client step reply before calling it failed. +const STEP_TIMEOUT_SEC := 30.0 +## How long to wait for the client to connect and for the plate to replicate. +const SETUP_TIMEOUT_SEC := 30.0 +## Distance under which an item 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 +## Lines kept in the on-screen overlay. +const OVERLAY_LINES := 16 + +## Where the debug camera sits and what it aims at: a fixed vantage point that +## frames the whole kitchen (Counter2 at x=-2 through DirtStation at x=2). +const DEBUG_CAM_POS := Vector3(0.0, 2.1, 3.4) +const DEBUG_CAM_LOOK_AT := Vector3(0.0, 0.9, 0.0) + +const HELP := "[H]ost [J]oin 1 grab 2 drop 3 dirt 4 sink 5 hob 6 counter 7 combine 8 plate>counter2 9 >plate R run-all A sync-audit 0 dump C cam" + +var _log_file: FileAccess +var _log_path := "" +var _debug_cam: Camera3D +var _role := "?" +var _client_id := 0 +var _auto_mode := false +var _running := false +var _step_pause := 0.0 +var _end_hold := 0.0 +var _step_no := 0 + +# Server-side ledger of every check: {step, side, ok, detail} +var _results: Array[Dictionary] = [] +# Latest reply from the client, consumed by _remote(). +var _reply: Dictionary = {} + +var _world: Node3D +var _controller: Node3D +var _hand: XRToolsFunctionPickup +var _hand_detached := false + +var _overlay: Label +var _overlay_lines: Array[String] = [] + + +func _ready() -> void: + var args := OS.get_cmdline_user_args() + _auto_mode = "--mptest" in args + _step_pause = _arg_value(args, "--mptest-pause", 0.0) + _end_hold = _arg_value(args, "--mptest-hold", 0.0) + _world = get_parent() + _open_log() + _build_overlay() + await get_tree().process_frame + _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(): + if _auto_mode: + _finish(false) + return + _build_debug_camera() + if not _auto_mode: + _log("MANUAL MODE - keys act on this window's peer:") + _log(" " + HELP) + return + if _role == "server": + await _run_server() + + +func _arg_value(args: PackedStringArray, key: String, fallback: float) -> float: + var i := args.find(key) + if i >= 0 and i + 1 < args.size(): + return args[i + 1].to_float() + return fallback + + +func _refresh_role() -> void: + _role = "server" if NetworkManager.is_server() else ("client" if NetworkManager.is_online() else "offline") + + +# Only the hand is resolved up front. Everything else is looked up by name at +# use time, because items get consumed and respawned as the test runs (the raw +# burger becomes a cooked burger, which becomes a hamburger, which is absorbed +# into a plate) — caching node references would leave us holding freed objects. +func _resolve_nodes() -> bool: + _controller = _world.get_node_or_null("XROrigin3D/XRControllerRightHand") as Node3D + _hand = _controller.get_node_or_null("FunctionPickup") as XRToolsFunctionPickup if _controller else null + if not _hand: + _log("FATAL: could not resolve XROrigin3D/XRControllerRightHand/FunctionPickup") + return false + for required in ["Hob", "Sink", "DirtStation", "Counter", "Counter2", "Plate"]: + if not _find(required): + _log("FATAL: test scene is missing '%s'" % required) + return false + _log("resolved hand=%s; kitchen has Hob, Sink, DirtStation, Counter, Counter2" % _hand.get_path()) + return true + + +# Items live either baked in the scene root or, once spawned at runtime, under +# WorldContent. Look in both. +func _find(name: String) -> Node3D: + var n := _world.get_node_or_null(name) + if not n: + n = _world.get_node_or_null("WorldContent/" + name) + return n as Node3D + + +func _zone_of(station_name: String) -> XRToolsSnapZone: + var s := _find(station_name) + return s.get_node_or_null("XRToolsSnapZone") as XRToolsSnapZone if s else null + + +# Cooking and combining spawn their results with engine-assigned names, so the +# only stable way to find them is by the food id they carry. +func _find_by_food_id(id: String) -> Node3D: + for root in [_world, _world.get_node_or_null("WorldContent")]: + if not root: + continue + for child in root.get_children(): + var f := child.get_node_or_null("FoodItem") as FoodItem + if f and f.id == id and is_instance_valid(child): + return child as Node3D + return null + + +# --- Server orchestration -------------------------------------------------- + +func _run_server() -> void: + _running = true + _results.clear() + _step_no = 0 + if not await _wait_for_client(): + _running = false + if _auto_mode: + _finish(false) + return + await _wait_frames(60) + _banner("starting sequence") + + # 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"]) + await _step("server", "server_grab_plate", "grab", ["Plate"]) + await _step("server", "server_drop_plate", "drop", ["Plate"]) + + # 3. Client carries it to the dirt station; it should snap in and go dirty. + await _step("client", "client_plate_to_dirt", "place_in_zone", ["Plate", "DirtStation"]) + await _both("plate_snapped_and_dirty", "verify_snapped", ["Plate", "DirtStation"]) + await _both("plate_is_dirty", "verify_dirty", ["Plate", "true"]) + + # 4. Both peers can still pick it back up out of the station. + await _step("client", "client_regrab_plate", "grab", ["Plate"]) + await _step("client", "client_redrop_plate", "drop", ["Plate"]) + await _step("server", "server_regrab_plate", "grab", ["Plate"]) + await _step("server", "server_redrop_plate", "drop", ["Plate"]) + + # 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 _step("server", "wait_for_wash", "await_clean", ["Plate"]) + await _both("plate_washed_clean", "verify_dirty", ["Plate", "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 + # through the spawner get covered. + # Finished plates are parked well clear of every snap zone (all the stations + # sit on z=0), so they can't get re-snapped on the way down. + await _cook_and_plate_round("client", "raw_burger", "BurgerBuns", "Plate", Vector3(-2.0, 1.1, 1.5)) + await _spawn_round_items() + await _cook_and_plate_round("server", "RawBurger2", "BurgerBuns2", "Plate2", Vector3(-1.0, 1.1, 1.5)) + + _running = false + await _report() + + +# One full food round performed by `actor`: cook a burger, combine it with buns +# on the counter, and put the result on a plate that's sitting on another +# counter. Every result is checked on BOTH peers, since the whole point is that +# the server's authoritative outcome reaches the client. +func _cook_and_plate_round(actor: String, burger: String, buns: String, plate: String, park_at: Vector3) -> void: + _banner("%s's cook-and-plate round (%s + %s -> %s)" % [actor, burger, buns, plate]) + + # 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 _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 + # in it, and a cooked burger left there burns to charcoal in another 2s — + # which would blow up the run as soon as there's any pause between steps. + 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"]) + + # Now bring the buns to it to combine. + await _step(actor, "%s_buns_to_cooked" % actor, "carry_food_to_food", [buns, "cooked_burger"]) + await _step("server", "%s_wait_for_combine" % actor, "await_food", ["hamburger"]) + await _both("%s_ingredients_consumed" % actor, "verify_gone", [buns]) + await _both("%s_hamburger_exists" % actor, "verify_food_exists", ["hamburger"]) + + # Plate onto the second counter, then the hamburger onto the plate. + await _step(actor, "%s_plate_to_counter2" % actor, "place_in_zone", [plate, "Counter2"]) + await _both("%s_plate_snapped_on_counter2" % actor, "verify_snapped", [plate, "Counter2"]) + await _step(actor, "%s_hamburger_to_plate" % actor, "carry_food_to_item", ["hamburger", plate]) + await _both("%s_plate_holds_hamburger" % actor, "verify_plate_contains", [plate, "hamburger"]) + + # Take the finished plate away again, the way a player would carry it off to + # be served. Without this the counter stays occupied and the next round has + # nowhere to put its plate. + await _step(actor, "%s_plate_off_counter2" % actor, "park", [plate, park_at]) + + +# Spawn a second set of ingredients through NetworkManager for the server's +# round (the scene only bakes one of each). +func _spawn_round_items() -> void: + _banner("state between rounds (server's view)") + _dump_state() + _banner("spawning a second set of ingredients for the server's round") + var spawns := [ + ["res://Items/burger.tscn", "RawBurger2", Vector3(0.3, 1.05, 0.7)], + ["res://Items/BurgerBuns.tscn", "BurgerBuns2", Vector3(-0.4, 1.05, 0.7)], + ["res://Containers/plate.tscn", "Plate2", Vector3(1.4, 1.05, 0.7)], + ] + for s in spawns: + NetworkManager.spawn_item(s[0], Transform3D(Basis(), s[2]), s[1]) + _log(" spawned %s" % s[1]) + await _wait_frames(30) + + +func _wait_for_client() -> bool: + var ok := await _wait_until(func(): return _find_client_id() != 0, "client to connect", SETUP_TIMEOUT_SEC) + _client_id = _find_client_id() + if ok: + _log("client connected: peer %d" % _client_id) + return ok + + +func _find_client_id() -> int: + for id in multiplayer.get_peers(): + if id != 1: + return id + return 0 + + +# Run one step as `actor` (locally if that's us, over RPC if it's the client) +# and record the verdict. +func _step(actor: String, label: String, step: String, args: Array) -> void: + _banner("STEP %d: %s (on the %s)" % [_next_step_no(), label, actor.to_upper()]) + var res: Dictionary + if actor == "server": + res = await _run_local_step(step, args) + else: + res = await _remote(step, args) + _record(label, actor, res) + await _audit_sync(label) + await _pause() + + +# Run the same check on both peers - the server's state and the client's must +# agree, which is the whole point of the exercise. +func _both(label: String, step: String, args: Array) -> void: + _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)) + await _audit_sync(label) + await _pause() + + +func _remote(step: String, args: Array) -> Dictionary: + _reply = {} + _cmd.rpc_id(_client_id, step, args) + var deadline := Time.get_ticks_msec() + int(STEP_TIMEOUT_SEC * 1000.0) + while _reply.is_empty() and Time.get_ticks_msec() < deadline: + await get_tree().process_frame + if _reply.is_empty(): + return {"ok": false, "detail": "no reply from client within %.0fs" % STEP_TIMEOUT_SEC} + return _reply.duplicate() + + +func _next_step_no() -> int: + _step_no += 1 + return _step_no + + +func _pause() -> void: + if _step_pause > 0.0: + await get_tree().create_timer(_step_pause).timeout + + +func _record(step: String, side: String, res: Dictionary) -> void: + var ok: bool = res.get("ok", false) + var detail: String = res.get("detail", "") + _results.append({"step": step, "side": side, "ok": ok, "detail": detail}) + _log("%s [%s] %s" % ["PASS" if ok else "FAIL", side, step]) + _log(" %s" % detail) + + +func _report() -> void: + var failed := 0 + _banner("RESULTS") + for r in _results: + if not r["ok"]: + failed += 1 + _log("%-4s %-8s %s" % ["PASS" if r["ok"] else "FAIL", r["side"], r["step"]]) + _log("%d/%d checks passed" % [_results.size() - failed, _results.size()]) + for r in _results: + if not r["ok"]: + _log("FAILURE %s [%s]: %s" % [r["step"], r["side"], r["detail"]]) + if not _auto_mode: + return + _quit_client.rpc_id(_client_id) + if _end_hold > 0.0: + _log("holding for %.0fs so you can look at the final state..." % _end_hold) + await get_tree().create_timer(_end_hold).timeout + else: + await _wait_frames(30) + _finish(failed == 0) + + +# --- Client command handling ---------------------------------------------- + +@rpc("authority", "reliable") +func _cmd(step: String, args: Array) -> void: + _log("<- server: %s%s" % [step, args]) + _running = true + var res := await _run_local_step(step, args) + _running = false + _log("%s %s" % ["PASS" if res.get("ok") else "FAIL", step]) + _log(" %s" % res.get("detail")) + _result.rpc_id(1, step, res.get("ok", false), str(res.get("detail", ""))) + + +@rpc("any_peer", "reliable") +func _result(step: String, ok: bool, detail: String) -> void: + _reply = {"ok": ok, "detail": detail, "step": step} + + +@rpc("authority", "reliable") +func _quit_client() -> void: + if _end_hold > 0.0: + await get_tree().create_timer(_end_hold).timeout + _finish(true) + + +func _run_local_step(step: String, args: Array) -> Dictionary: + match step: + "grab": + return await _do_grab(_find(args[0]), args[0]) + "drop": + return await _do_drop(_find(args[0]), args[0]) + "place_in_zone": + return await _do_place_in_zone(_find(args[0]), args[0], args[1]) + "place_food_in_zone": + return await _do_place_in_zone(_find_by_food_id(args[0]), args[0], args[1]) + "carry_food_to_food": + return await _do_carry_item_to_item(_find(args[0]), args[0], _find_by_food_id(args[1]), args[1]) + "carry_food_to_item": + return await _do_carry_item_to_item(_find_by_food_id(args[0]), args[0], _find(args[1]), args[1]) + "await_food": + return await _do_await_food(args[0]) + "await_clean": + return await _do_await_clean(args[0]) + "park": + return await _do_park(_find(args[0]), args[0], args[1]) + "verify_snapped": + return _check_snapped(args[0], args[1]) + "verify_dirty": + return _check_dirty(args[0], args[1] == "true") + "verify_gone": + return _check_gone(args[0]) + "verify_food_exists": + return _check_food_exists(args[0]) + "verify_plate_contains": + return _check_plate_contains(args[0], args[1]) + return {"ok": false, "detail": "unknown step %s" % step} + + +# --- The simulated player actions ----------------------------------------- + +# Reach out to an item and squeeze the grip, the same way the real controller +# path does: position the hand, let the grab Area3D register what's in range, +# refresh the closest-object pick (normally done from _process, which bails out +# headlessly because the controller reports as untracked), then press grip. +func _do_grab(item: Node3D, label: String) -> Dictionary: + if not is_instance_valid(item): + return {"ok": false, "detail": "'%s' does not exist on this peer" % label} + _log(" 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 + _log(" 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" % _diag(item)} + _log(" 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, _diag(item)]} + 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(), _diag(item)]} + _log(" holding %s; waiting for authority..." % label) + if not await _wait_until(func(): return item.get_multiplayer_authority() == multiplayer.get_unique_id(), + "authority handoff", 5.0): + return {"ok": false, "detail": "grabbed %s, but authority stayed with peer %d. %s" + % [label, item.get_multiplayer_authority(), _diag(item)]} + _log(" authority is ours (peer %d)" % multiplayer.get_unique_id()) + return {"ok": true, "detail": "grabbed %s via %s; %s" % [label, closest.name, _diag(item)]} + + +func _do_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, _diag(item)]} + _log(" 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 the return type of + # get_picked_up_by() isn't known statically and := can't infer it. + var by: Node = item.get_picked_up_by() + _log(" 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 item 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" + % [by.get_parent().name, label]} + if not await _wait_until(func(): return not is_instance_valid(item) or item.get_multiplayer_authority() == 1, + "authority return to server", 5.0): + return {"ok": false, "detail": "dropped %s, but authority stayed with peer %d" + % [label, item.get_multiplayer_authority()]} + return {"ok": true, "detail": "dropped %s (now held by %s)" % [label, by]} + + +# Grab an item, carry it onto a station's snap zone, and let go there. +func _do_place_in_zone(item: Node3D, label: String, station: String) -> Dictionary: + var zone := _zone_of(station) + if not zone: + return {"ok": false, "detail": "station '%s' has no snap zone on this peer" % station} + var res := await _do_grab(item, label) + if not res["ok"]: + return res + res = await _do_carry_to(item, label, zone.global_position) + if not res["ok"]: + return res + res = await _do_drop(item, label) + if not res["ok"]: + return res + _log(" 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, _diag(item)]} + + +# Grab an item and carry it into another item, 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 _do_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 _do_grab(item, label) + if not res["ok"]: + return res + res = await _do_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 item + # we're holding may be consumed by it. + for i in 60: + if not is_instance_valid(item): + _log(" %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 (combining, and putting food on a plate) consume the + # carried item, so still holding it means the reaction never fired. Let go + # first so the next step isn't 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, _diag(item)]} + + +# Carry an item somewhere clear and put it down, freeing the station it was in. +func _do_park(item: Node3D, label: String, pos: Vector3) -> Dictionary: + var res := await _do_grab(item, label) + if not res["ok"]: + return res + res = await _do_carry_to(item, label, pos) + if not res["ok"]: + return res + res = await _do_drop(item, label) + if not res["ok"]: + return res + return {"ok": true, "detail": "parked %s at %s" % [label, pos]} + + +# Move the held item onto a world position. Closed-loop: move the hand by +# whatever the item's remaining error is, since the grab point offsets the item +# from the hand by an amount we don't want to hard-code. +func _do_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} + _log(" 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) + _log(" %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 item with the given food id (cooking the +# burger, or combining into a hamburger). Server-side: stations only run there. +func _do_await_food(food_id: String) -> Dictionary: + _log(" waiting for a '%s' to appear..." % food_id) + var ok := await _wait_until(func(): return _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 look for it. + await _wait_frames(30) + var made := _find_by_food_id(food_id) + return {"ok": true, "detail": "'%s' produced: %s at %s" % [food_id, made.name, made.global_position]} + + +func _do_await_clean(plate_name: String) -> Dictionary: + var plate := _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") + _log(" 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, _diag(plate)]} + return {"ok": true, "detail": "%s was washed clean" % plate_name} + + +# --- Checks ---------------------------------------------------------------- + +# On the server "snapped" means the zone owns the item. On a client the snap is +# server-authoritative and never happens locally, so what must be true there is +# that the replicated item actually sits in the zone. +func _check_snapped(item_name: String, station: String) -> Dictionary: + var item := _find(item_name) + var zone := _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 > SNAP_TOLERANCE: + problems.append("%s is %.3fm from %s's zone (tolerance %.2f)" % [item_name, dist, station, 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, _diag(item)]} + return {"ok": false, "detail": "%s | %s" % [", ".join(problems), _diag(item)]} + + +func _check_dirty(plate_name: String, want_dirty: bool) -> Dictionary: + var plate := _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, _diag(plate)]} + return {"ok": true, "detail": "%s.is_dirty == %s as expected" % [plate_name, want_dirty]} + + +# A consumed item must be gone on EVERY peer, not just the one that consumed it. +func _check_gone(item_name: String) -> Dictionary: + var item := _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 _check_food_exists(food_id: String) -> Dictionary: + var item := _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 _check_plate_contains(plate_name: String, food_id: String) -> Dictionary: + var plate := _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)]} + + +# --- Cross-peer sync audit ------------------------------------------------- +# +# Runs after every step. Targeted 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 fine but the +# thing you actually see on screen doesn't. So after each step both peers +# describe every item they can see, and the two descriptions must match. + +## How far apart the same item may be on the two peers before it counts as a desync. +const SYNC_POS_TOLERANCE := 0.08 +## Physics keeps moving after a step (dropped items fall, snaps settle), so give +## the peers this long to converge before calling a difference a failure. +const SYNC_SETTLE_SEC := 3.0 + +var _client_snapshot: Dictionary = {} +var _snapshot_pending := false + + +# Everything about an item that should look identical on every peer. Includes +# what is actually rendered, not just the replicated data behind it: a plate +# whose contained_ids arrived but whose visuals were never rebuilt looks empty +# to the player, and that must count as a desync. +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 - this is + # 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") + return d + + +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 + + +func _snapshot() -> Dictionary: + var out := {} + for root in [_world, _world.get_node_or_null("WorldContent")]: + if not root: + continue + for child in root.get_children(): + if child is XRToolsPickable and not child.is_queued_for_deletion(): + out[str(child.name)] = _describe(child) + return out + + +@rpc("authority", "reliable") +func _request_snapshot() -> void: + _snapshot_reply.rpc_id(1, _snapshot()) + + +@rpc("any_peer", "reliable") +func _snapshot_reply(snap: Dictionary) -> void: + _client_snapshot = snap + _snapshot_pending = false + + +func _fetch_client_snapshot() -> Dictionary: + _client_snapshot = {} + _snapshot_pending = true + _request_snapshot.rpc_id(_client_id) + var deadline := Time.get_ticks_msec() + 10000 + while _snapshot_pending and Time.get_ticks_msec() < deadline: + await get_tree().process_frame + return _client_snapshot + + +# Compare the server's view with the client's, returning a list of 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] + 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: + if key == "pos": + continue + if s[key] != c[key]: + problems.append("%s.%s: server=%s client=%s" % [name, key, s[key], c[key]]) + return problems + + +# Poll until the two peers agree, so ordinary physics settling isn't reported as +# a desync — a real one never converges and gets reported with its details. +func _audit_sync(label: String) -> void: + if not NetworkManager.is_server() or _client_id == 0: + return + var problems: Array[String] = [] + var deadline := Time.get_ticks_msec() + int(SYNC_SETTLE_SEC * 1000.0) + while true: + var mine := _snapshot() + var theirs := await _fetch_client_snapshot() + problems = _compare(mine, theirs) + if problems.is_empty() or Time.get_ticks_msec() > deadline: + break + await _wait_frames(10) + var res := {"ok": problems.is_empty(), "detail": ""} + if problems.is_empty(): + res["detail"] = "server and client agree on all %d items" % _snapshot().size() + else: + res["detail"] = "after %.0fs the peers still disagree: %s" % [SYNC_SETTLE_SEC, "; ".join(problems)] + _record("sync_after_" + label, "both", res) + + +# --- Diagnostics ----------------------------------------------------------- + +func _diag(item: Node3D) -> String: + if not is_instance_valid(item): + return "item[freed]" + var np := item.get_node_or_null("NetPickable") + var pc := item.get_node_or_null("PlateController") + var s := "%s[pos=%s authority=%d net_held_by=%s" % [ + item.name, item.global_position, item.get_multiplayer_authority(), + np.net_held_by if np else "-", + ] + if item is XRToolsPickable: + s += " enabled=%s can_pick_up=%s layer=%d held_by=%s" % [ + item.enabled, item.can_pick_up(_hand), item.collision_layer, item.get_picked_up_by()] + if pc: + s += " dirty=%s contains=%s" % [pc.is_dirty, str(pc.contained_ids)] + return s + "]" + + +func _dump_state() -> void: + _log(" stations:") + for station in ["Hob", "Sink", "DirtStation", "Counter", "Counter2"]: + var z := _zone_of(station) + if z: + _log(" %-12s zone holds %s (enabled=%s)" % [station, z.picked_up_object, z.enabled]) + _log(" items:") + for root in [_world, _world.get_node_or_null("WorldContent")]: + if not root: + continue + for child in root.get_children(): + if child is XRToolsPickable: + _log(" %s" % _diag(child)) + + +# --- Manual controls ------------------------------------------------------- + +func _unhandled_input(event: InputEvent) -> void: + if not (event is InputEventKey) or not event.pressed or event.echo: + return + if _running and event.keycode != KEY_0: + _log("busy running a step, ignoring that key") + return + match event.keycode: + KEY_H: + _log("KEY: host"); NetworkManager.host(); _refresh_role() + KEY_J: + _log("KEY: join 127.0.0.1"); NetworkManager.join("127.0.0.1") + await get_tree().create_timer(1.0).timeout + _refresh_role() + KEY_1: await _manual("grab plate", "grab", ["Plate"]) + KEY_2: await _manual("drop plate", "drop", ["Plate"]) + KEY_3: await _manual("plate -> dirt station", "place_in_zone", ["Plate", "DirtStation"]) + KEY_4: await _manual("plate -> sink", "place_in_zone", ["Plate", "Sink"]) + KEY_5: await _manual("raw burger -> hob", "place_in_zone", ["raw_burger", "Hob"]) + KEY_6: await _manual("cooked burger -> counter", "place_food_in_zone", ["cooked_burger", "Counter"]) + KEY_7: await _manual("buns -> cooked burger", "carry_food_to_food", ["BurgerBuns", "cooked_burger"]) + KEY_8: await _manual("plate -> counter 2", "place_in_zone", ["Plate", "Counter2"]) + KEY_9: await _manual("hamburger -> plate", "carry_food_to_item", ["hamburger", "Plate"]) + KEY_R: + _refresh_role() + if _role != "server": + _log("KEY: run-all is server-only (this peer is %s)" % _role) + return + _log("KEY: running the full automatic sequence") + await _run_server() + KEY_0: + _log("KEY: state dump") + _dump_state() + KEY_A: + _refresh_role() + if _role != "server": + _log("KEY: the sync audit runs from the server window") + return + _log("KEY: comparing every object against the client") + _running = true + await _audit_sync("manual_check") + _running = false + KEY_C: + _toggle_debug_camera() + + +func _manual(what: String, step: String, args: Array) -> void: + _refresh_role() + _banner("MANUAL: %s" % what) + _running = true + var res := await _run_local_step(step, args) + _running = false + _log("%s %s :: %s" % ["PASS" if res.get("ok") else "FAIL", what, res.get("detail", "")]) + + +# --- Helpers --------------------------------------------------------------- + +func _move_hand_to(pos: Vector3) -> void: + # The controller is a child of the XROrigin, whose PlayerBody keeps moving + # (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 that simply + # running the scene by hand leaves a real tracked controller alone. + if not _hand_detached: + _hand_detached = true + _controller.top_level = true + _log(" (detached the test hand from the XR rig so it can be driven directly)") + _controller.global_position = pos + _controller.force_update_transform() + + +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 +# doesn't produce a spurious failure and a fast one doesn't 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 + _log(" timed out after %.0fs waiting for %s" % [timeout, what]) + return false + + +func _finish(ok: bool) -> void: + _log("=== %s exiting (%s) ===" % [_role, "ok" if ok else "FAILED"]) + if _log_file: + _log_file.flush() + get_tree().quit(0 if ok else 1) + + +# --- Camera ---------------------------------------------------------------- + +# A fixed camera overlooking the kitchen. +# +# Without this a windowed run shows nothing useful: the only camera is the +# XROrigin's XRCamera3D, which isn't driven by anything when XR is off, and the +# rig's PlayerBody keeps falling under gravity — it ends up metres below the +# floor pointing at the void. Ours is a plain Camera3D parented to the world +# (not the rig), so it stays put. +func _build_debug_camera() -> void: + if DisplayServer.get_name() == "headless": + return + _debug_cam = Camera3D.new() + _debug_cam.name = "MPTestDebugCamera" + _world.add_child(_debug_cam) + _debug_cam.global_position = DEBUG_CAM_POS + _debug_cam.look_at(DEBUG_CAM_LOOK_AT) + # Explicitly stand the rig's camera down first: setting `current` alone lost + # the race against XRCamera3D, leaving the view stuck under the floor. + var xr_cam := _world.get_node_or_null("XROrigin3D/XRCamera3D") as Camera3D + if xr_cam: + xr_cam.current = false + _debug_cam.make_current() + _log("debug camera at %s looking at %s ([C] toggles back to the XR rig camera)" + % [DEBUG_CAM_POS, DEBUG_CAM_LOOK_AT]) + _assert_debug_camera.call_deferred() + + +func _assert_debug_camera() -> void: + if _debug_cam and _debug_cam.current and get_viewport().get_camera_3d() != _debug_cam: + _debug_cam.make_current() + _log("rendering through camera: %s" % get_viewport().get_camera_3d()) + _check_framing() + + +# Confirm the things the test acts on are actually on screen. Checked by +# projection rather than by eye, because it's the only way to be sure across +# window sizes and display scaling. +func _check_framing() -> void: + var size := get_viewport().get_visible_rect().size + var offscreen: Array[String] = [] + for name in ["Counter2", "Counter", "Hob", "Sink", "DirtStation", "Plate"]: + var n := _find(name) + if not n: + continue + var p := _debug_cam.unproject_position(n.global_position) + var frac := Vector2(p.x / size.x, p.y / size.y) + var on := not _debug_cam.is_position_behind(n.global_position) \ + and frac.x > 0.02 and frac.x < 0.98 and frac.y > 0.02 and frac.y < 0.98 + _log(" framing: %-12s at %.2f,%.2f of frame%s" % [name, frac.x, frac.y, "" if on else " <-- OFF SCREEN"]) + if not on: + offscreen.append(name) + if offscreen.is_empty(): + _log(" framing: all test objects are in view") + else: + _log(" framing: WARNING - not visible: %s (adjust DEBUG_CAM_POS)" % ", ".join(offscreen)) + + +func _toggle_debug_camera() -> void: + if not _debug_cam: + _log("KEY: no debug camera in a headless run") + return + _debug_cam.current = not _debug_cam.current + if not _debug_cam.current: + var xr_cam := _world.get_node_or_null("XROrigin3D/XRCamera3D") as Camera3D + if xr_cam: + xr_cam.current = true + _log("KEY: camera -> %s" % ("fixed debug camera" if _debug_cam.current else "XR rig camera")) + + +# --- Output ---------------------------------------------------------------- + +# A heading that's easy to find when scrolling a window full of engine spam. +func _banner(s: String) -> void: + _log("") + _log("======== %s ========" % s) + + +# Write the step log next to the engine's own log, under /logs/, so +# everything from a run is in one place. Named per role because both instances +# would otherwise fight over one file (the engine's own godot.log has exactly +# that problem when two peers run at once — it rotates per process). +func _open_log() -> void: + var role := "server" if "--server" in OS.get_cmdline_user_args() else "client" + var path := "res://logs/mptest_%s.log" % role + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("res://logs")) + _log_file = FileAccess.open(path, FileAccess.WRITE) + if not _log_file: + # res:// is read-only in an exported build; fall back to somewhere writable. + path = OS.get_environment("TEMP").path_join("vryhungry_mptest_%s.log" % role) + _log_file = FileAccess.open(path, FileAccess.WRITE) + _log_path = ProjectSettings.globalize_path(path) + print("[MPTEST] step log -> %s" % _log_path) + + +func _log(s: String) -> void: + print("[MPTEST %s] %s" % [_role, s]) + if _log_file: + _log_file.store_line(s) + _log_file.flush() + _push_overlay(s) + + +# On-screen mirror of the log, so a windowed run shows what's happening without +# needing the console — including which peer this window is. +func _build_overlay() -> void: + var layer := CanvasLayer.new() + layer.name = "MPTestOverlay" + var panel := PanelContainer.new() + panel.set_anchors_preset(Control.PRESET_TOP_WIDE) + panel.modulate = Color(1, 1, 1, 0.85) + _overlay = Label.new() + _overlay.add_theme_font_size_override("font_size", 13) + _overlay.autowrap_mode = TextServer.AUTOWRAP_OFF + _overlay.clip_text = true + panel.add_child(_overlay) + layer.add_child(panel) + add_child(layer) + + +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 = "[%s] %s\n%s" % [_role.to_upper(), HELP, "\n".join(_overlay_lines)] diff --git a/test/mp_test_driver.gd.uid b/test/mp_test_driver.gd.uid new file mode 100644 index 0000000..7a68038 --- /dev/null +++ b/test/mp_test_driver.gd.uid @@ -0,0 +1 @@ +uid://biu4qr3nr1eny diff --git a/test/mp_window_layout.ps1 b/test/mp_window_layout.ps1 new file mode 100644 index 0000000..93e57e0 --- /dev/null +++ b/test/mp_window_layout.ps1 @@ -0,0 +1,47 @@ +# Shared helper for placing the two game windows side by side. +# +# Godot's --position flag is ignored on this setup (the window lands at x=-7 +# whatever you pass), so the windows are moved with the Win32 API after they +# come up. Dot-source this file to get Move-GameWindow. + +if (-not ('MpWin' -as [type])) { + Add-Type @" +using System; +using System.Runtime.InteropServices; +public class MpWin { + [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r); + [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); + public struct RECT { public int Left, Top, Right, Bottom; } +} +"@ +} + +# Godot's windows are DPI aware; PowerShell's process is not by default, so +# GetWindowRect/SetWindowPos would otherwise be talking in virtualised +# coordinates and the windows land nowhere near where we asked. +[void][MpWin]::SetProcessDPIAware() + +# Moves a just-launched game window to (x, y) and returns its width, so the +# caller can place the next window immediately to its right. Never resizes: +# changing the window size independently of Godot's --resolution distorts the +# rendered aspect ratio. +function Move-GameWindow($proc, [int]$x, [int]$y) { + for ($i = 0; $i -lt 60 -and $proc.MainWindowHandle -eq 0; $i++) { + Start-Sleep -Milliseconds 250 + $proc.Refresh() + } + if ($proc.MainWindowHandle -eq 0) { + Write-Host " (window never appeared; leaving it where it is)" + return 620 + } + # The handle shows up before Godot has finished sizing/positioning the + # window - moving it too early gets overwritten by Godot's own setup. + Start-Sleep -Milliseconds 2500 + $h = $proc.MainWindowHandle + # SWP_NOSIZE (0x1) | SWP_NOZORDER (0x4) + [void][MpWin]::SetWindowPos($h, [IntPtr]::Zero, $x, $y, 0, 0, 0x0005) + $r = New-Object MpWin+RECT + [void][MpWin]::GetWindowRect($h, [ref]$r) + return [int]($r.Right - $r.Left) +} diff --git a/test/multiPlayerTest.tscn b/test/multiPlayerTest.tscn new file mode 100644 index 0000000..9aa2c0b --- /dev/null +++ b/test/multiPlayerTest.tscn @@ -0,0 +1,112 @@ +[gd_scene format=3 uid="uid://bodj8op527o2c"] + +[ext_resource type="Script" uid="uid://d1cefhe3yyyds" path="res://Scenes/multiplayer_world.gd" id="1_6uucx"] +[ext_resource type="PackedScene" uid="uid://j5s5wus7tuhb" path="res://XROrigin.tscn" id="2_j7vd1"] +[ext_resource type="Material" uid="uid://cmia50cfqxxo4" path="res://Textures/dev_material_3d.tres" id="3_ssbaf"] +[ext_resource type="Sky" uid="uid://c1abtpwhdm2d5" path="res://Textures/sky/NightSkyHDRI009_16K.tres" id="4_081u3"] +[ext_resource type="PackedScene" uid="uid://j7caslh27nor" path="res://Stations/Hob.tscn" id="5_t1fa7"] +[ext_resource type="PackedScene" uid="uid://dvrk268s7gkxh" path="res://Stations/sink.tscn" id="6_6jhmh"] +[ext_resource type="PackedScene" uid="uid://bp3v1jl8pctro" path="res://Containers/plate.tscn" id="7_bowes"] +[ext_resource type="PackedScene" uid="uid://cnjwtnhwh0i8q" path="res://Stations/dirt_station.tscn" id="8_pvl84"] +[ext_resource type="Script" uid="uid://biu4qr3nr1eny" path="res://test/mp_test_driver.gd" id="9_mptst"] +[ext_resource type="PackedScene" uid="uid://dpot5qie20vf6" path="res://Items/burger.tscn" id="10_51k0c"] +[ext_resource type="PackedScene" uid="uid://c0lknik4noobs" path="res://Items/BurgerBuns.tscn" id="11_psgbv"] +[ext_resource type="PackedScene" uid="uid://efaec6ymgabo" path="res://Stations/Counter.tscn" id="12_j5uvh"] + +[sub_resource type="BoxShape3D" id="BoxShape3D_vlqg6"] +size = Vector3(15, 0.1, 15) + +[sub_resource type="BoxMesh" id="BoxMesh_24d3s"] +material = ExtResource("3_ssbaf") +size = Vector3(15, 0.1, 15) + +[sub_resource type="Environment" id="Environment_bvwq1"] +background_mode = 2 +sky = ExtResource("4_081u3") +reflected_light_source = 2 +ssr_enabled = true +ssao_enabled = true +ssil_enabled = true +sdfgi_enabled = true + +[sub_resource type="BoxShape3D" id="BoxShape3D_arao0"] +size = Vector3(15, 20, 0.1) + +[node name="Main" type="Node3D" unique_id=1312265607] +script = ExtResource("1_6uucx") +populate_from_layout = false + +[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) + +[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1376644384] +transform = Transform3D(-0.8660254, -0.43301278, 0.24999999, 0.3022632, -0.05510214, 0.9516306, -0.39829266, 0.8997021, 0.17860365, 0, 0, 0) + +[node name="Floor" type="StaticBody3D" parent="." unique_id=122279514] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.05, 0) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Floor" unique_id=958841648] +shape = SubResource("BoxShape3D_vlqg6") + +[node name="MeshInstance3D" type="MeshInstance3D" parent="Floor/CollisionShape3D" unique_id=1939997056] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.00018164515, 0.0006352663, -0.002532661) +mesh = SubResource("BoxMesh_24d3s") + +[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=1177077250] +environment = SubResource("Environment_bvwq1") + +[node name="WorldContent" type="Node3D" parent="." unique_id=291550153] + +[node name="Players" type="Node3D" parent="." unique_id=1595463693] + +[node name="ItemsSpawner" type="MultiplayerSpawner" parent="." unique_id=627119248] +spawn_path = NodePath("../WorldContent") + +[node name="PlayersSpawner" type="MultiplayerSpawner" parent="." unique_id=106645565] +_spawnable_scenes = PackedStringArray("res://Player/net_player.tscn") +spawn_path = NodePath("../Players") + +[node name="StaticBody3D" type="StaticBody3D" parent="." unique_id=4404969] + +[node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D" unique_id=998476672] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 9.197384, 7.589958) +shape = SubResource("BoxShape3D_arao0") + +[node name="CollisionShape3D2" type="CollisionShape3D" parent="StaticBody3D" unique_id=340303902] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 9.197384, -7.509142) +shape = SubResource("BoxShape3D_arao0") + +[node name="CollisionShape3D3" type="CollisionShape3D" parent="StaticBody3D" unique_id=1193703037] +transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, -7.438612, 9.197384, -0.018813243) +shape = SubResource("BoxShape3D_arao0") + +[node name="CollisionShape3D4" type="CollisionShape3D" parent="StaticBody3D" unique_id=1431367494] +transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, 7.502467, 9.197384, -0.018813023) +shape = SubResource("BoxShape3D_arao0") + +[node name="Hob" parent="." unique_id=1687971542 instance=ExtResource("5_t1fa7")] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0) + +[node name="Sink" parent="." unique_id=2055277359 instance=ExtResource("6_6jhmh")] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 0.5, 0) + +[node name="Plate" parent="." unique_id=190487773 instance=ExtResource("7_bowes")] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.4050963, 1.1702834, 0.049627244) + +[node name="DirtStation" parent="." unique_id=160842153 instance=ExtResource("8_pvl84")] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2, 0.5, 0) + +[node name="TestDriver" type="Node" parent="." unique_id=1002011928] +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) + +[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) + +[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) + +[node name="Counter2" parent="." instance=ExtResource("12_j5uvh")] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2, 0.5, 0) diff --git a/test/play_mp_test.ps1 b/test/play_mp_test.ps1 new file mode 100644 index 0000000..b5eeb00 --- /dev/null +++ b/test/play_mp_test.ps1 @@ -0,0 +1,61 @@ +# Opens two windows on the multiplayer test scene in MANUAL mode, so you can +# drive the plate / dirt-station flow yourself and watch both peers. +# +# powershell -File test\play_mp_test.ps1 +# +# No scripted sequence runs. Click a window to focus it, then: +# +# 1 grab the plate 2 drop it +# 3 carry it to the dirt zone 4 drop it at the dirt zone +# 5 check it snapped + went dirty +# 6 run the whole automatic sequence (server window only) +# 0 dump the current state of everything +# C toggle between the fixed debug camera and the XR rig camera +# +# Keys act on whichever window has focus, so you can grab on the CLIENT and +# watch the SERVER window follow. Each window opens on a fixed camera looking +# at the test area, overlays its own step log, and writes it to +# logs\mptest_server.log / logs\mptest_client.log. +# +# The typical repro: on the CLIENT press 1, 2 (grab and drop), then on the +# SERVER press 1, 2. Then on the CLIENT press 1, 3, 4 and press 5 on both. + +param( + [string]$Godot = "C:\Users\Aqua Aurora\Desktop\Godot_v4.7-stable_win64.exe", + # Pass -Solo to open a single window with no networking, to compare the + # same steps against single-player behaviour. + [switch]$Solo +) + +$ErrorActionPreference = "Stop" +$proj = Split-Path -Parent $PSScriptRoot +$scene = "res://test/multiPlayerTest.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 ' ')" } + 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 + return +} + +Write-Host "Opening SERVER window (left)..." +$w = Move-GameWindow (Start-Instance @("--server")) 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 + +Write-Host "" +Write-Host "Both windows are up. Click one to focus it, then press:" +Write-Host " 1 grab 2 drop 3 carry-to-dirt 4 drop-at-dirt 5 verify 0 dump" +Write-Host " 6 runs the whole automatic sequence (server window only) C toggles the camera" +Write-Host "" +Write-Host "Step logs: $(Join-Path $proj 'logs\mptest_server.log')" +Write-Host " $(Join-Path $proj 'logs\mptest_client.log')" +Write-Host "Close the windows when you're done (Esc quits)." diff --git a/test/run_mp_test.ps1 b/test/run_mp_test.ps1 new file mode 100644 index 0000000..f48b34e --- /dev/null +++ b/test/run_mp_test.ps1 @@ -0,0 +1,60 @@ +# Runs the headless two-instance multiplayer test (test/multiPlayerTest.tscn). +# +# 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. +# Exits non-zero if any check failed. + +param( + [string]$Godot = "C:\Users\Aqua Aurora\Desktop\Godot_v4.7-stable_win64.exe", + [int]$TimeoutSec = 120 +) + +$ErrorActionPreference = "Stop" +$proj = Split-Path -Parent $PSScriptRoot +$scene = "res://test/multiPlayerTest.tscn" +$serverLog = Join-Path $proj "logs/mptest_server.log" +$clientLog = Join-Path $proj "logs/mptest_client.log" + +foreach ($f in @($serverLog, $clientLog)) { 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. + $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") + +$deadline = (Get-Date).AddSeconds($TimeoutSec) +while ((Get-Date) -lt $deadline -and -not $server.HasExited) { Start-Sleep -Milliseconds 500 } + +foreach ($p in @($server, $client)) { + if (-not $p.HasExited) { Write-Host "Killing pid $($p.Id) (still running)"; $p.Kill() } +} +Start-Sleep -Milliseconds 500 + +foreach ($pair in @(@("SERVER", $serverLog), @("CLIENT", $clientLog))) { + Write-Host "" + Write-Host "======================== $($pair[0]) ========================" + if (Test-Path $pair[1]) { Get-Content $pair[1] -Encoding UTF8 } else { Write-Host "(no log written)" } +} + +# ExitCode is only populated on the process object after a WaitForExit() call, +# even when HasExited is already true - without this it reads back empty. +$server.WaitForExit(2000) | Out-Null +$code = if ($server.HasExited) { $server.ExitCode } else { 1 } +Write-Host "" +Write-Host "server exit code: $code" +exit $code diff --git a/test/run_mp_test_windowed.ps1 b/test/run_mp_test_windowed.ps1 new file mode 100644 index 0000000..9ca0ff1 --- /dev/null +++ b/test/run_mp_test_windowed.ps1 @@ -0,0 +1,69 @@ +# Runs the two-instance multiplayer test in two VISIBLE windows, side by side, +# pausing between steps so you can watch what happens on each peer. +# +# powershell -File test\run_mp_test_windowed.ps1 +# +# Same test as run_mp_test.ps1 (headless); this one is for watching it. Each +# window shows an on-screen overlay of the step log for that peer. +# +# To drive it yourself instead, see test\play_mp_test.ps1 (manual mode). + +param( + [string]$Godot = "C:\Users\Aqua Aurora\Desktop\Godot_v4.7-stable_win64.exe", + # Seconds to pause between steps, and to hold the final state on screen. + [double]$Pause = 1.5, + [double]$Hold = 20, + [int]$TimeoutSec = 300 +) + +$ErrorActionPreference = "Stop" +$proj = Split-Path -Parent $PSScriptRoot +$scene = "res://test/multiPlayerTest.tscn" +$serverLog = Join-Path $proj "logs/mptest_server.log" +$clientLog = Join-Path $proj "logs/mptest_client.log" + +foreach ($f in @($serverLog, $clientLog)) { if (Test-Path $f) { Remove-Item $f -Force } } + +. (Join-Path $PSScriptRoot "mp_window_layout.ps1") + +function Start-Instance($extraArgs) { + # --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 -- " + + "$($extraArgs -join ' ') --mptest --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. + $null = $p.Handle + return $p +} + +Write-Host "Starting SERVER window (left)..." +$server = Start-Instance @("--server") +$w = Move-GameWindow $server 20 60 +Start-Sleep -Seconds 5 +Write-Host "Starting CLIENT window (right)..." +$client = Start-Instance @("--join", "127.0.0.1") +$null = Move-GameWindow $client (20 + $w + 12) 60 + +Write-Host "Watch the two windows (each has a fixed camera on the test area)." +Write-Host "Step logs: $serverLog" +Write-Host " $clientLog" +$deadline = (Get-Date).AddSeconds($TimeoutSec) +while ((Get-Date) -lt $deadline -and -not $server.HasExited) { Start-Sleep -Milliseconds 500 } + +foreach ($p in @($server, $client)) { + if (-not $p.HasExited) { Write-Host "Killing pid $($p.Id) (still running)"; $p.Kill() } +} +Start-Sleep -Milliseconds 500 + +foreach ($pair in @(@("SERVER", $serverLog), @("CLIENT", $clientLog))) { + Write-Host "" + Write-Host "======================== $($pair[0]) ========================" + if (Test-Path $pair[1]) { Get-Content $pair[1] -Encoding UTF8 } else { Write-Host "(no log written)" } +} + +$server.WaitForExit(2000) | Out-Null +$code = if ($server.HasExited) { $server.ExitCode } else { 1 } +Write-Host "" +Write-Host "server exit code: $code" +exit $code