Fix five multiplayer sync bugs, add automated two-instance test

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>
This commit is contained in:
algodoogle
2026-07-26 02:00:50 +01:00
parent 33ef81306d
commit 23ec41c1d6
12 changed files with 1503 additions and 12 deletions
+14 -2
View File
@@ -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: