From 4ca0a05d1ba0f4f42b1478392be0139c47346f20 Mon Sep 17 00:00:00 2001 From: algodoogle Date: Sat, 25 Jul 2026 19:42:49 +0100 Subject: [PATCH] Fix world population race and add multiplayer diagnostics The world was populated before NetworkManager.world_ready() actually called host()/join(), so owns_world() read true for every peer (including a joining client) and each one built its own local, unreplicated copy instead of the client receiving the server's spawn through the MultiplayerSpawner. Population now happens after the session is actually established. Also adds net-log coverage for spawn/despawn, station gating, item authority handoff, station snapping, avatar spawn/despawn, and scene transitions, so multiplayer behavior is visible in %TEMP%\vryhungry_net_.log instead of failing silently. Co-Authored-By: Claude Sonnet 5 --- Net/net_pickable.gd | 4 ++++ Net/network_manager.gd | 11 +++++++++++ Scenes/multiPlayer.tscn | 8 ++++---- Scenes/multiplayer_world.gd | 34 +++++++++++++++++++++++++++++----- 4 files changed, 48 insertions(+), 9 deletions(-) diff --git a/Net/net_pickable.gd b/Net/net_pickable.gd index c0e5ba1..f4089f5 100644 --- a/Net/net_pickable.gd +++ b/Net/net_pickable.gd @@ -61,6 +61,8 @@ func _on_picked_up(_p) -> void: var by := _pickable.get_picked_up_by() if not (by is XRToolsFunctionPickup): return + if NetworkManager.is_online(): + NetworkManager.log_line("Grabbed %s by hand, requesting authority" % _pickable.name) NetworkManager.request_item_authority.rpc_id(1, _pickable.get_path()) @@ -69,6 +71,8 @@ func _on_dropped(_p) -> void: # apply_held_state() losing authority (see above) must not re-report. if not is_multiplayer_authority(): return + if NetworkManager.is_online(): + NetworkManager.log_line("Dropped %s, reporting release to server" % _pickable.name) NetworkManager.release_item_authority.rpc_id( 1, _pickable.get_path(), _pickable.linear_velocity, _pickable.angular_velocity ) diff --git a/Net/network_manager.gd b/Net/network_manager.gd index 1270a19..fb2a526 100644 --- a/Net/network_manager.gd +++ b/Net/network_manager.gd @@ -101,6 +101,8 @@ func spawn_item(scene_path: String, xform: Transform3D, node_name: String = "", if is_online() and not is_server(): return null var data := {"scene": scene_path, "xform": xform, "name": node_name, "props": props} + var via := "spawner" if (is_online() and _items_spawner) else "offline" + log_line("spawn_item: %s (name=%s, via=%s)" % [scene_path.get_file(), node_name, via]) if is_online() and _items_spawner: return _items_spawner.spawn(data) # Offline: instantiate directly under the registered content root, or (for @@ -120,6 +122,7 @@ func spawn_item(scene_path: String, xform: Transform3D, node_name: String = "", ## 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) node.queue_free() @@ -157,6 +160,7 @@ func _gate_station(node: Node) -> void: child.enabled = false child.set_process(false) node.set_process(false) + log_line("gated station (non-owner peer): %s" % node.name) # --- Item grab-authority transfer ----------------------------------------- @@ -175,8 +179,10 @@ func request_item_authority(item_path: NodePath) -> void: 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. + log_line("request_item_authority: DENIED %s to peer %d (already held by %d)" % [item.name, sender, np.net_held_by]) force_release_item.rpc_id(sender, item_path) return + log_line("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) @@ -191,6 +197,7 @@ func request_item_authority(item_path: NodePath) -> void: func release_item_authority(item_path: NodePath, lin: Vector3, ang: Vector3) -> void: if not is_server(): return + log_line("release_item_authority: %s released by peer %d" % [str(item_path), multiplayer.get_remote_sender_id()]) _set_item_authority.rpc(item_path, 1) var item := get_node_or_null(item_path) if item is RigidBody3D: @@ -226,6 +233,7 @@ func _try_snap_into_station(item: Node) -> void: if is_instance_valid(zone.picked_up_object): continue if zone.global_position.distance_to(item.global_position) <= zone.grab_distance: + log_line("snapped %s into %s" % [item.name, zone.get_parent().name]) zone.pick_up_object(item) return @@ -237,6 +245,7 @@ func _set_item_authority(item_path: NodePath, peer: int) -> void: var item := get_node_or_null(item_path) if not item: return + log_line("_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: @@ -248,6 +257,7 @@ func _set_item_authority(item_path: NodePath, peer: int) -> void: ## item was already legitimately held by someone else). The client drops it. @rpc("authority", "reliable") func force_release_item(item_path: NodePath) -> void: + log_line("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() @@ -283,6 +293,7 @@ func submit_work(station_path: NodePath, amount: float) -> void: return var station := get_node_or_null(station_path) if station and station.has_method("add_work"): + log_line("submit_work: peer %d contributed %.2f to %s" % [multiplayer.get_remote_sender_id(), amount, station.name]) station.add_work(multiplayer.get_remote_sender_id(), amount) diff --git a/Scenes/multiPlayer.tscn b/Scenes/multiPlayer.tscn index be95e81..5e4e715 100644 --- a/Scenes/multiPlayer.tscn +++ b/Scenes/multiPlayer.tscn @@ -43,13 +43,13 @@ mesh = SubResource("BoxMesh_24d3s") [node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=1177077250] environment = SubResource("Environment_bvwq1") -[node name="WorldContent" type="Node3D" parent="."] +[node name="WorldContent" type="Node3D" parent="." unique_id=291550153] -[node name="Players" type="Node3D" parent="."] +[node name="Players" type="Node3D" parent="." unique_id=1595463693] -[node name="ItemsSpawner" type="MultiplayerSpawner" parent="."] +[node name="ItemsSpawner" type="MultiplayerSpawner" parent="." unique_id=627119248] spawn_path = NodePath("../WorldContent") -[node name="PlayersSpawner" type="MultiplayerSpawner" parent="."] +[node name="PlayersSpawner" type="MultiplayerSpawner" parent="." unique_id=106645565] _spawnable_scenes = PackedStringArray("res://Player/net_player.tscn") spawn_path = NodePath("../Players") diff --git a/Scenes/multiplayer_world.gd b/Scenes/multiplayer_world.gd index 2917a22..dec672b 100644 --- a/Scenes/multiplayer_world.gd +++ b/Scenes/multiplayer_world.gd @@ -31,11 +31,27 @@ func _ready() -> void: NetworkManager.session_ended.connect(_on_session_ended) NetworkManager.connection_failed.connect(_on_connection_failed) - # Offline (single player / dev run): owns_world() is already true, so - # populate immediately. Online host case is handled by session_started. + # world_ready() is what actually calls host()/join() (or the cmdline + # equivalent). Populating before this point is wrong for EVERY case, not + # just offline: is_online() is still false until host()/join() runs, so + # owns_world() would read true for a joining client too, and it would + # build its own local copy instead of receiving the server's via the + # spawner. host() emits session_started synchronously, which populates + # via _on_session_started below; the explicit call after world_ready() + # only matters for the case where neither host() nor join() ran (no + # pending session, no cmdline args) — running this scene directly offline. + NetworkManager.world_ready() _populate_world_if_owner() - NetworkManager.world_ready() + get_tree().create_timer(3.0).timeout.connect(_log_world_state) + + +# Temporary-ish sanity check: confirms WorldContent actually ended up +# populated on this peer (whether by spawning it or by receiving it via +# replication), so a silent replication failure shows up in the net log +# instead of just an empty-looking world. +func _log_world_state() -> void: + NetworkManager.log_line("World state: WorldContent=%d children, Players=%d children" % [$WorldContent.get_child_count(), $Players.get_child_count()]) func _exit_tree() -> void: @@ -53,10 +69,14 @@ func _populate_world_if_owner() -> void: return _populated = true GameManager.meals_in_play = ["hamburger"] - for d in WorldLayout.get_stations(): + var stations := WorldLayout.get_stations() + var items := WorldLayout.get_items() + NetworkManager.log_line("Populating world: %d stations, %d items" % [stations.size(), items.size()]) + for d in stations: NetworkManager.spawn_item(d["scene"], d["xform"], d["name"], d["props"]) - for d in WorldLayout.get_items(): + for d in items: NetworkManager.spawn_item(d["scene"], d["xform"], d["name"], d["props"]) + NetworkManager.log_line("World populated") ## Only the server (or the single offline machine) materialises player @@ -68,6 +88,7 @@ func _on_player_joined(peer_id: int) -> void: var p := PLAYER_SCENE.instantiate() p.name = str(peer_id) $Players.add_child(p, true) + NetworkManager.log_line("Spawned avatar for peer %d" % peer_id) func _on_player_left(peer_id: int) -> void: @@ -76,11 +97,14 @@ func _on_player_left(peer_id: int) -> void: var p := $Players.get_node_or_null(str(peer_id)) if p: p.queue_free() + NetworkManager.log_line("Despawned avatar for peer %d" % peer_id) func _on_session_ended() -> void: + NetworkManager.log_line("Session ended, returning to main menu") get_tree().change_scene_to_file("res://Scenes/mainMenu.tscn") func _on_connection_failed() -> void: + NetworkManager.log_line("Connection failed, returning to main menu") get_tree().change_scene_to_file("res://Scenes/mainMenu.tscn")