Adds test/multiPlayerTest.tscn plus a driver that runs the kitchen flow
across two game instances: grab/drop, dirt station, sink washing, hob
cooking, counter combining and plating. Runs headless (run_mp_test.ps1),
in two visible windows (run_mp_test_windowed.ps1), or by hand with
keyboard controls (play_mp_test.ps1). 108 checks, exits non-zero on
failure.
After every step both peers snapshot every item's position and rendered
state and the server diffs them. Targeted assertions only look at the
thing a step touched, which misses desyncs elsewhere - that audit is
what caught the last bug below.
Bugs found and fixed:
- net_pickable: apply_held_state() only wrote `enabled` in its
non-authority branch, so once a client grabbed an item every other
peer set enabled=false and regaining authority never restored it. The
server could then never pick that item up again, and a station would
"snap" it (emitting has_picked_up, so 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.
- network_manager: station gating only happened in the spawn path, so
stations baked into a scene file kept running their snap zones on
clients and grabbed items straight out of the local hand. Added
gate_existing_stations().
- network_manager: despawn_item() only freed the server's copy. Items
baked into a scene aren't tracked by the MultiplayerSpawner, so
consuming one left a ghost on every client, which then blocked the
station it sat in and got grabbed instead of its replacement.
- network_manager: the snap-into-station decision read the server's own
copy of the item position, but the reliable release RPC routinely
overtakes the synchronizer's unordered position updates - so it acted
on a stale position and teleported items back into the station they
had just been carried away from. The releasing peer now sends its
final transform and the server adopts it first.
- container: contained_ids.append()/erase() mutate the array in place,
which never fires the setter that rebuilds the plate's visuals. The
peer that put food on a plate was the only peer that never redrew it;
remote peers looked right because the synchronizer assigns there.
Also null-guards XRServer.get_tracker() in the vendored xr-tools hand
grab point, which threw on every successful grab without an XR runtime,
and adds multiplayer_world.populate_from_layout so debug scenes can bake
their own content instead of spawning the whole kitchen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pairs with the debug-log-heavy investigation workflow: Godot writes its
own engine log to logs/godot.log alongside the existing NetworkManager
temp-file log, and the directory is excluded from version control since
it's runtime output, not source.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The snap-zone crash fixed just before this was invisible in the logs
until traced through addon source by hand — the log showed grabs,
drops, and authority handoffs, but nothing about which peer/hand/zone
actually held an item at each step, or when a guard clause silently
changed behavior.
Adds a _holder_desc() helper (reports "loose", "hand(<path>)",
"zone(<station>)", or "other(...)") and logs every net_held_by
transition, every reclaim that has to restore freeze_mode/collision_mask
from a stuck state, every time the grab-race guard protects an actively-
held item from a stale sync, every force-drop triggered by a losing
authority race, and every pickup/drop event including the ones that
don't get forwarded (picked up by something other than a hand, or
dropped while not authority — exactly the silent case behind the
snap-zone crash). NetworkManager's snap/release-from-zone calls get the
same treatment: which station an item is pulled from, and whether a
snap attempt succeeded, was skipped by the race guard, or found nothing
in range.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
request_item_authority and release_item_authority were always sent as
an RPC to peer 1, even when the caller already WAS peer 1 (the host's
own player). Godot rejects rpc_id() targeting your own peer id ("RPC on
yourself is not allowed by selected mode"), so every host-side grab or
release was silently failing to run its server-side half: no denial
check on grab, and — the more damaging part — no
_try_snap_into_station()/_release_from_snap_zones() call on release,
leaving a station's snap zone holding a stale reference once the host
picked something back up. That's what made the host "no longer able to
interact" with an item after a client had handled it.
Split each entry point into a public function that runs the logic
directly when we're already the server, and a private @rpc handler used
only when an actual remote client calls it. force_release_item gets the
same treatment for the symmetric case (server rejecting its own grab
attempt).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>