From db1bad562d13ebd35952b8b78fdc193852d02941 Mon Sep 17 00:00:00 2001 From: JonShard Date: Wed, 29 Jul 2026 16:59:17 +0200 Subject: [PATCH] Uncomment net_pickable --- Net/net_pickable.gd | 416 ++++++++++++++++++------------------- Scenes/multiPlayer.tscn | 16 +- scripts/network_manager.gd | 183 ++++++++++++++++ 3 files changed, 398 insertions(+), 217 deletions(-) diff --git a/Net/net_pickable.gd b/Net/net_pickable.gd index c7c4936..430ccec 100644 --- a/Net/net_pickable.gd +++ b/Net/net_pickable.gd @@ -1,209 +1,209 @@ extends MultiplayerSynchronizer -# -### Networked sync component for a pickable item. Added as a child literally -### named "NetPickable" (network_manager.gd's authority RPCs already expect -### this) of every net-synced pickable, replicating its transform and held -### state. Only the current multiplayer authority (the server while loose, or -### whichever peer is holding it) actually simulates physics for the item; -### every other peer freezes their local copy and just follows the synced -### transform. -# -### 0 = loose/server-simulated; otherwise the peer id currently holding it. -### Replicated at spawn and on change so a late joiner sees the current holder. -#var net_held_by: int = 0: set = _set_net_held_by -# -#var _pickable: XRToolsPickable -# -## This item's own baked freeze_mode (e.g. plate.tscn bakes KINEMATIC, not the -## RigidBody3D default of STATIC) — captured once so it can be restored when -## this peer regains ownership, instead of getting stuck on whatever -## 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 -# -## Whether the "our own hand still holds this" guard has already been logged for -## the current grab. apply_held_state() runs every network tick, so without this -## the guard message repeats for as long as you hold the item. -#var _grab_race_logged := false -# -# -#func _ready() -> void: - #_pickable = get_parent() as XRToolsPickable - #if not _pickable: - #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/ - ## original_collision_layer via @onready, which runs AFTER this child's - ## _ready() but BEFORE the root's _ready() body. Calling apply_held_state - ## synchronously here would freeze/mask the item before that capture runs, - ## permanently corrupting the "restore on drop" values. - #apply_held_state.call_deferred() -# -# -#func _set_net_held_by(value: int) -> void: - #var old := net_held_by - #net_held_by = value - #if old != value and NetworkManager.is_online(): - #print("%s net_held_by: %d -> %d (local state: %s, authority=%d)" % [ - #_pickable.name, old, value, _holder_desc(), get_multiplayer_authority() - #]) - #apply_held_state() -# -# -### Puts the item in the right physics state for whether this peer currently -### owns it. Called locally after net_held_by changes, and directly by -### NetworkManager._set_item_authority right after an authority handoff. -### -### IMPORTANT: this runs on every network tick, not just on a real change. -### net_held_by is replicated in ALWAYS mode, so the synchronizer assigns it every -### tick on non-authority peers — unchanged value included — and that assignment -### lands in _set_net_held_by(), which calls this. So every branch here has to be -### idempotent and silent when there is nothing to do: otherwise each item logs a -### line and rewrites four physics properties every tick on every peer that -### doesn't own it. -#func apply_held_state() -> void: - #if not _pickable: - #return - #if not NetworkManager.is_online() or is_multiplayer_authority(): - #_grab_race_logged = false - ## We own this item's simulation (offline, loose+server, or currently - ## holding it). If it's not actively in our own hand right now, make - ## sure it isn't still left frozen/collision-less from a previous - ## non-authority period (e.g. right after regaining authority when a - ## client released it) — while actually held, XRToolsPickable's own - ## pick_up()/let_go() already manage these fields, so leave those be. - #if not _pickable.is_picked_up(): - #var changed := _pickable.freeze_mode != _original_freeze_mode \ - #or _pickable.collision_mask != _pickable.original_collision_mask - #if changed: - #if NetworkManager.is_online(): - #print( - #"%s: reclaiming ownership, restoring freeze_mode %d->%d collision_mask %d->%d" % [ - #_pickable.name, _pickable.freeze_mode, _original_freeze_mode, - #_pickable.collision_mask, _pickable.original_collision_mask - #] - #) - #_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 - ## optimistically (they travel on different channels with no ordering - ## guarantee). Don't let a stale sync value yank an item out of our own - ## hand mid-grab — only an explicit force_release_item rejection, or - ## actually losing authority for real, should end a grab we initiated. - #if _pickable.is_picked_up() and _pickable.get_picked_up_by() is XRToolsFunctionPickup: - ## Log once per grab, not once per tick. - #if NetworkManager.is_online() and not _grab_race_logged: - #_grab_race_logged = true - #print( - #"%s: ignoring non-authority sync (net_held_by=%d) — still actively held by our own hand (grab-race guard)" % [ - #_pickable.name, net_held_by - #] - #) - #return - #_grab_race_logged = false - ## Someone else owns it: stop simulating locally, just follow the sync. - #if _pickable.is_picked_up(): - #if NetworkManager.is_online(): - #print( - #"%s: was held by %s on this peer, but authority now says peer %d owns it — force-dropping" % [ - #_pickable.name, _holder_desc(), net_held_by - #] - #) - #_pickable.drop() - ## Bail out when we're already in the follow-the-sync state. Without this the - ## writes below (and the line logged with them) repeated every tick for every - ## item on every non-authority peer — 90% of the log, plus four redundant - ## physics-property writes per item per tick. The comparison also means we - ## still re-apply if something else perturbs the state (e.g. let_go() - ## restoring the collision mask after a force-drop). - #var want_enabled := (net_held_by == 0) - #if _pickable.freeze \ - #and _pickable.freeze_mode == RigidBody3D.FREEZE_MODE_KINEMATIC \ - #and _pickable.collision_mask == 0 \ - #and _pickable.enabled == want_enabled: - #return - #if NetworkManager.is_online(): - #print("%s: freezing (non-authority, owner=peer %d)" % [_pickable.name, net_held_by]) - #_pickable.freeze = true - #_pickable.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC - #_pickable.collision_mask = 0 - #_pickable.enabled = want_enabled -# -# -### Local hand grab (not a station snap zone, which is server-only): request -### authority immediately so the throw/drop can be reconciled, but let the grab -### happen instantly here rather than waiting on the round trip. -#func _on_picked_up(_p) -> void: - #var by := _pickable.get_picked_up_by() - #if not (by is XRToolsFunctionPickup): - ## e.g. a station snap zone grabbed it (server-side auto-snap, or the - ## addon's own "grab out of a snap zone" shortcut mid-cascade) — not a - ## player-initiated hand grab, so no authority request from here. - #if NetworkManager.is_online(): - #print("%s picked up by %s (not a hand) — no authority request" % [_pickable.name, _holder_desc()]) - #return - #if NetworkManager.is_online(): - #print("%s grabbed by hand (authority was peer %d), requesting authority" % [_pickable.name, net_held_by]) - #NetworkManager.request_item_authority_from(_pickable.get_path()) -# -# -#func _on_dropped(_p) -> void: - ## Only forward if we're actually still the authority — a drop caused by - ## apply_held_state() losing authority (see above) must not re-report. - #if not is_multiplayer_authority(): - #if NetworkManager.is_online(): - #print("%s dropped locally, but we aren't its authority (peer %d is) — not reporting" % [_pickable.name, net_held_by]) - #return - #if NetworkManager.is_online(): - #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.global_transform - #) -# -# -### Human-readable description of what's currently holding this item on THIS -### peer, for diagnosing desyncs between a hand's own "what am I holding" -### bookkeeping and the item's actual grab state (see the snap-zone-grab race -### in NetworkManager._try_snap_into_station for a real example). -#func _holder_desc() -> String: - #if not _pickable or not _pickable.is_picked_up(): - #return "loose" - #var by := _pickable.get_picked_up_by() - #if not by: - #return "held(no grabber?)" - #if by is XRToolsFunctionPickup: - #return "hand(%s)" % by.get_path() - #if by is XRToolsSnapZone: - #var station := by.get_parent() - #return "zone(%s)" % (station.name if station else str(by.get_path())) - #return "other(%s: %s)" % [by.get_class(), by.get_path()] + +## Networked sync component for a pickable item. Added as a child literally +## named "NetPickable" (network_manager.gd's authority RPCs already expect +## this) of every net-synced pickable, replicating its transform and held +## state. Only the current multiplayer authority (the server while loose, or +## whichever peer is holding it) actually simulates physics for the item; +## every other peer freezes their local copy and just follows the synced +## transform. + +## 0 = loose/server-simulated; otherwise the peer id currently holding it. +## Replicated at spawn and on change so a late joiner sees the current holder. +var net_held_by: int = 0: set = _set_net_held_by + +var _pickable: XRToolsPickable + +# This item's own baked freeze_mode (e.g. plate.tscn bakes KINEMATIC, not the +# RigidBody3D default of STATIC) — captured once so it can be restored when +# this peer regains ownership, instead of getting stuck on whatever +# 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 + +# Whether the "our own hand still holds this" guard has already been logged for +# the current grab. apply_held_state() runs every network tick, so without this +# the guard message repeats for as long as you hold the item. +var _grab_race_logged := false + + +func _ready() -> void: + _pickable = get_parent() as XRToolsPickable + if not _pickable: + 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/ + # original_collision_layer via @onready, which runs AFTER this child's + # _ready() but BEFORE the root's _ready() body. Calling apply_held_state + # synchronously here would freeze/mask the item before that capture runs, + # permanently corrupting the "restore on drop" values. + apply_held_state.call_deferred() + + +func _set_net_held_by(value: int) -> void: + var old := net_held_by + net_held_by = value + if old != value and NetworkManager.is_online(): + print("%s net_held_by: %d -> %d (local state: %s, authority=%d)" % [ + _pickable.name, old, value, _holder_desc(), get_multiplayer_authority() + ]) + apply_held_state() + + +## Puts the item in the right physics state for whether this peer currently +## owns it. Called locally after net_held_by changes, and directly by +## NetworkManager._set_item_authority right after an authority handoff. +## +## IMPORTANT: this runs on every network tick, not just on a real change. +## net_held_by is replicated in ALWAYS mode, so the synchronizer assigns it every +## tick on non-authority peers — unchanged value included — and that assignment +## lands in _set_net_held_by(), which calls this. So every branch here has to be +## idempotent and silent when there is nothing to do: otherwise each item logs a +## line and rewrites four physics properties every tick on every peer that +## doesn't own it. +func apply_held_state() -> void: + if not _pickable: + return + if not NetworkManager.is_online() or is_multiplayer_authority(): + _grab_race_logged = false + # We own this item's simulation (offline, loose+server, or currently + # holding it). If it's not actively in our own hand right now, make + # sure it isn't still left frozen/collision-less from a previous + # non-authority period (e.g. right after regaining authority when a + # client released it) — while actually held, XRToolsPickable's own + # pick_up()/let_go() already manage these fields, so leave those be. + if not _pickable.is_picked_up(): + var changed := _pickable.freeze_mode != _original_freeze_mode \ + or _pickable.collision_mask != _pickable.original_collision_mask + if changed: + if NetworkManager.is_online(): + print( + "%s: reclaiming ownership, restoring freeze_mode %d->%d collision_mask %d->%d" % [ + _pickable.name, _pickable.freeze_mode, _original_freeze_mode, + _pickable.collision_mask, _pickable.original_collision_mask + ] + ) + _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 + # optimistically (they travel on different channels with no ordering + # guarantee). Don't let a stale sync value yank an item out of our own + # hand mid-grab — only an explicit force_release_item rejection, or + # actually losing authority for real, should end a grab we initiated. + if _pickable.is_picked_up() and _pickable.get_picked_up_by() is XRToolsFunctionPickup: + # Log once per grab, not once per tick. + if NetworkManager.is_online() and not _grab_race_logged: + _grab_race_logged = true + print( + "%s: ignoring non-authority sync (net_held_by=%d) — still actively held by our own hand (grab-race guard)" % [ + _pickable.name, net_held_by + ] + ) + return + _grab_race_logged = false + # Someone else owns it: stop simulating locally, just follow the sync. + if _pickable.is_picked_up(): + if NetworkManager.is_online(): + print( + "%s: was held by %s on this peer, but authority now says peer %d owns it — force-dropping" % [ + _pickable.name, _holder_desc(), net_held_by + ] + ) + _pickable.drop() + # Bail out when we're already in the follow-the-sync state. Without this the + # writes below (and the line logged with them) repeated every tick for every + # item on every non-authority peer — 90% of the log, plus four redundant + # physics-property writes per item per tick. The comparison also means we + # still re-apply if something else perturbs the state (e.g. let_go() + # restoring the collision mask after a force-drop). + var want_enabled := (net_held_by == 0) + if _pickable.freeze \ + and _pickable.freeze_mode == RigidBody3D.FREEZE_MODE_KINEMATIC \ + and _pickable.collision_mask == 0 \ + and _pickable.enabled == want_enabled: + return + if NetworkManager.is_online(): + print("%s: freezing (non-authority, owner=peer %d)" % [_pickable.name, net_held_by]) + _pickable.freeze = true + _pickable.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC + _pickable.collision_mask = 0 + _pickable.enabled = want_enabled + + +## Local hand grab (not a station snap zone, which is server-only): request +## authority immediately so the throw/drop can be reconciled, but let the grab +## happen instantly here rather than waiting on the round trip. +func _on_picked_up(_p) -> void: + var by := _pickable.get_picked_up_by() + if not (by is XRToolsFunctionPickup): + # e.g. a station snap zone grabbed it (server-side auto-snap, or the + # addon's own "grab out of a snap zone" shortcut mid-cascade) — not a + # player-initiated hand grab, so no authority request from here. + if NetworkManager.is_online(): + print("%s picked up by %s (not a hand) — no authority request" % [_pickable.name, _holder_desc()]) + return + if NetworkManager.is_online(): + print("%s grabbed by hand (authority was peer %d), requesting authority" % [_pickable.name, net_held_by]) + NetworkManager.request_item_authority_from(_pickable.get_path()) + + +func _on_dropped(_p) -> void: + # Only forward if we're actually still the authority — a drop caused by + # apply_held_state() losing authority (see above) must not re-report. + if not is_multiplayer_authority(): + if NetworkManager.is_online(): + print("%s dropped locally, but we aren't its authority (peer %d is) — not reporting" % [_pickable.name, net_held_by]) + return + if NetworkManager.is_online(): + 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.global_transform + ) + + +## Human-readable description of what's currently holding this item on THIS +## peer, for diagnosing desyncs between a hand's own "what am I holding" +## bookkeeping and the item's actual grab state (see the snap-zone-grab race +## in NetworkManager._try_snap_into_station for a real example). +func _holder_desc() -> String: + if not _pickable or not _pickable.is_picked_up(): + return "loose" + var by := _pickable.get_picked_up_by() + if not by: + return "held(no grabber?)" + if by is XRToolsFunctionPickup: + return "hand(%s)" % by.get_path() + if by is XRToolsSnapZone: + var station := by.get_parent() + return "zone(%s)" % (station.name if station else str(by.get_path())) + return "other(%s: %s)" % [by.get_class(), by.get_path()] diff --git a/Scenes/multiPlayer.tscn b/Scenes/multiPlayer.tscn index 3be6c96..429e769 100644 --- a/Scenes/multiPlayer.tscn +++ b/Scenes/multiPlayer.tscn @@ -68,17 +68,9 @@ shape = SubResource("BoxShape3D_arao0") 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="PlayersSpawner" type="MultiplayerSpawner" parent="." unique_id=106645565] -_spawnable_scenes = PackedStringArray("res://Player/net_player.tscn") -spawn_path = NodePath("../Players") - -[node name="Players" type="Node3D" parent="." unique_id=1595463693] - [node name="ItemsSpawner" type="MultiplayerSpawner" parent="." unique_id=627119248] _spawnable_scenes = PackedStringArray("uid://dpot5qie20vf6", "uid://b3m2ag8g5rj4r", "uid://c0lknik4noobs", "uid://b3m2ag8g5rj4r", "uid://cfc4ho67u4r5e", "uid://e4i6o5oriecx") -spawn_path = NodePath("../Items") - -[node name="Items" type="Node3D" parent="." unique_id=291550153] +spawn_path = NodePath("..") [node name="StationsSpawner" type="MultiplayerSpawner" parent="." unique_id=240083159] _spawnable_scenes = PackedStringArray("uid://c6rift56ql3f8", "uid://efaec6ymgabo", "uid://bbg7dwsbxxh1t", "uid://cnjwtnhwh0i8q", "uid://j7caslh27nor", "uid://ck5tuftqmyiue", "uid://cwnwo4i28upap", "uid://dvrk268s7gkxh", "uid://caf0xanmxbshy") @@ -87,3 +79,9 @@ script = ExtResource("5_36d77") kitchen_scene = ExtResource("6_uvo4r") [node name="Stations" type="Node3D" parent="." unique_id=1991170324] + +[node name="PlayersSpawner" type="MultiplayerSpawner" parent="." unique_id=106645565] +_spawnable_scenes = PackedStringArray("res://Player/net_player.tscn") +spawn_path = NodePath("../Players") + +[node name="Players" type="Node3D" parent="." unique_id=1595463693] diff --git a/scripts/network_manager.gd b/scripts/network_manager.gd index 1fbf016..6bc6540 100644 --- a/scripts/network_manager.gd +++ b/scripts/network_manager.gd @@ -71,6 +71,189 @@ func _go_offline() -> void: unregister_world() + +# --- Item grab-authority transfer ----------------------------------------- + +## Called by NetPickable when this peer grabs an item by hand. Godot rejects +## rpc_id() targeting your own peer id ("RPC on yourself is not allowed"), so +## when we ARE the server this runs the logic directly instead of round- +## tripping an RPC to ourselves — otherwise every host-side grab/drop was +## silently failing to run its server-side half (no denial checks, and +## crucially no auto-snap-into-station on release). +func request_item_authority_from(item_path: NodePath) -> void: + if is_server(): + _grant_or_reject_item_authority(item_path, multiplayer.get_unique_id()) + else: + _request_item_authority_rpc.rpc_id(1, item_path) + + +@rpc("any_peer", "reliable") +func _request_item_authority_rpc(item_path: NodePath) -> void: + if not is_server(): + return + _grant_or_reject_item_authority(item_path, multiplayer.get_remote_sender_id()) + + +## Runs on the server (called directly if the requester IS the server, or via +## the RPC above otherwise). If the item was snapped into a station, the +## station releases it so the grabber cleanly takes ownership. +func _grant_or_reject_item_authority(item_path: NodePath, sender: int) -> void: + var item := get_node_or_null(item_path) + if item: + var np := item.get_node_or_null("NetPickable") + if np and np.net_held_by != 0 and np.net_held_by != sender: + # Already legitimately held by a different live peer: reject the + # requester's optimistic client-side grab instead of stealing it. + print("NetworkManager request_item_authority: DENIED %s to peer %d (already held by %d)" % [item.name, sender, np.net_held_by]) + _force_release_item_to(sender, item_path) + return + print("NetworkManager request_item_authority: granting %s to peer %d" % [str(item.name) if item else str(item_path), sender]) + # Assign authority + held state first (disables the item on the server so its + # snap zone won't re-grab it), then release it from any station. + _set_item_authority.rpc(item_path, sender) + if item: + _release_from_snap_zones(item) + + +## 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, xform: Transform3D) -> void: + if is_server(): + _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, xform) + + +@rpc("any_peer", "reliable") +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, 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, xform: Transform3D, sender: int) -> void: + print("NetworkManager 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 + _try_snap_into_station.call_deferred(item) + + +# All station snap zones in the world (every XRToolsSnapZone child of a node in +# the "station" group — some stations, e.g. Table, have more than one). +func _station_snap_zones() -> Array: + var zones := [] + for station in get_tree().get_nodes_in_group("station"): + for child in station.get_children(): + if child is XRToolsSnapZone: + zones.append(child) + return zones + + +# If the item is snapped into any station, drop it from that station. +func _release_from_snap_zones(item: Node) -> void: + for zone in _station_snap_zones(): + if zone.picked_up_object == item: + print("NetworkManager releasing %s from %s's snap zone (authority just granted elsewhere)" % [item.name, zone.get_parent().name]) + zone.drop_object() + # Make the zone forget the item as well. These zones are snap_mode=RANGE, + # so every frame they re-grab anything still listed in their grab area + # that can be picked up — and Jolt does not emit body_exited when let_go() + # switches the item's collision layer back out of the zone's mask, so the + # entry goes stale and never clears. The station then snatches the item + # straight back off the player who just took it, teleporting it home. + # Bringing it near again re-adds it properly (a held item is on the layer + # the zone watches), and releasing next to a station is handled + # explicitly by _try_snap_into_station. + if zone._object_in_grab_area.has(item): + zone._object_in_grab_area.erase(item) + + +# Snap the item into the nearest empty station snap zone within grab range. +# +# Called deferred from _do_release_item_authority: XRToolsFunctionPickup's own +# "grab an item out of a snap zone" path calls zone.drop_object() BEFORE it +# calls pick_up() on the hand's behalf. drop_object()'s let_go() synchronously +# fires the pickable's `dropped` signal, which (via NetPickable) lands here — +# if this ran synchronously it would immediately re-snap the item into the +# very same zone it's still physically inside, stealing it away before the +# hand's own pick_up() call (later in the same call stack) ever runs. That +# leaves XRToolsFunctionPickup.picked_up_object pointing at an item whose +# _grab_driver actually belongs to the zone — a stale reference that crashes +# (null _grab_driver) the next time a controller button is pressed. Deferring +# lets the hand's pick_up() go first; the is_picked_up() check below is a +# second guard in case the item gets grabbed for real before this runs. +func _try_snap_into_station(item: Node) -> void: + if not (item is Node3D): + return + if item.has_method("is_picked_up") and item.is_picked_up(): + var by: Node = null + if item.has_method("get_picked_up_by"): + by = item.get_picked_up_by() + print("NetworkManager skipped snapping %s: already held by %s (grab-race guard)" % [item.name, by.get_path() if by else "?"]) + return + for zone in _station_snap_zones(): + if is_instance_valid(zone.picked_up_object): + continue + if zone.global_position.distance_to(item.global_position) <= zone.grab_distance: + print("NetworkManager snapped %s into %s" % [item.name, zone.get_parent().name]) + zone.pick_up_object(item) + return + print("NetworkManager no station in range to snap %s into (or none empty)" % item.name) + + +# Server broadcasts an authority assignment so every peer agrees on who owns the +# item (set_multiplayer_authority is a local call and must run everywhere). +@rpc("authority", "call_local", "reliable") +func _set_item_authority(item_path: NodePath, peer: int) -> void: + var item := get_node_or_null(item_path) + if not item: + return + print("NetworkManager _set_item_authority: %s -> peer %d" % [item.name, peer]) + item.set_multiplayer_authority(peer) # recursive: item + synchronizer + NetPickable + var np := item.get_node_or_null("NetPickable") + if np: + np.net_held_by = 0 if peer == 1 else peer + np.apply_held_state() + + +## Rejects peer's optimistic grab (the item was already legitimately held by +## someone else). Same self-RPC concern: if the rejected peer is the server +## itself, apply it directly rather than rpc_id-ing ourselves. +func _force_release_item_to(peer: int, item_path: NodePath) -> void: + if peer == 1: + _do_force_release(item_path) + else: + force_release_item.rpc_id(peer, item_path) + + +@rpc("authority", "reliable") +func force_release_item(item_path: NodePath) -> void: + _do_force_release(item_path) + + +func _do_force_release(item_path: NodePath) -> void: + print("NetworkManager force_release_item: dropping %s (server rejected our grab)" % str(item_path)) + var item := get_node_or_null(item_path) + if item and item.has_method("drop"): + item.drop() + + + func is_server() -> bool: return is_online() and multiplayer.is_server()