extends Node3D ## Throwaway spike. Answers the questions about Godot's high-level replication ## that the rebuild depends on, before any of the real code is rewritten. ## ## Q1 Does default (auto) MultiplayerSpawner replication carry the spawn ## properties of synchronizers attached AT RUNTIME rather than baked into ## the .tscn? [CONFIRMED] ## Q2 Do ON_CHANGE properties reach a peer that joins AFTER the value was ## set, and do later changes propagate? [CONFIRMED] ## Q3 Can set_visibility_for(peer, false) suppress a peer's updates without ## despawning the node on it? [REFUTED — the ## node is despawned and respawned on every visibility flip, so this ## cannot be used to stop the server fighting a client's held object.] ## Q4 Does handing ONE of a node's two synchronizers (NetXform) to a client ## let that client drive the transform locally — ignoring the server's ## inbound updates — while the other (NetSync) keeps pushing server-owned ## state to it? This is the grab-prediction mechanism. ## ## Both peers run on one ABSOLUTE clock (see _at) rather than chains of relative ## timers: the first version of this spike drifted enough that the client's ## reports straddled the server's actions and read as inconclusive. const PORT := 24599 const ITEM := "res://test/spike/spike_item.tscn" var _log: FileAccess var _is_server := false var _start_ms: int = 0 func _ready() -> void: var args := OS.get_cmdline_user_args() _is_server = args.has("--server") _start_ms = Time.get_ticks_msec() _open_log() # Registered identically on both peers BEFORE connecting: auto-spawn sends an # index into this array, so a mismatch builds the wrong scene on the client. $Spawner.add_spawnable_scene(ITEM) # The hook under test: every peer attaches the generated synchronizers as the # node enters the tree. On the server that is our own add_child; on the # client it is MultiplayerSpawner's. $Content.child_entered_tree.connect(_on_content_child) if _is_server: _run_server() else: _run_client() func _on_content_child(node: Node) -> void: if node is MultiplayerSynchronizer: return NetReplication.attach(node) _say("attached synchronizers to %s" % node.name) ## Waits until `t` seconds after this scene started. Both peers start within a ## few hundred ms of each other, which is close enough to interleave their ## actions and observations deterministically. func _at(t: float) -> void: var target := _start_ms + int(t * 1000.0) while Time.get_ticks_msec() < target: await get_tree().process_frame ## set_multiplayer_authority is a local call, so it has to run on every peer for ## them to agree on who is driving the transform. @rpc("authority", "call_local", "reliable") func _set_xform_authority(path: NodePath, peer: int) -> void: var node := get_node_or_null(path) if not node: return node.get_node(NetReplication.XFORM_NAME).set_multiplayer_authority(peer) _say("NetXform authority of %s -> peer %d" % [node.name, peer]) func _item() -> Node3D: return $Content.get_node_or_null("SpikeItem") as Node3D # --- server ---------------------------------------------------------------- func _run_server() -> void: var peer := ENetMultiplayerPeer.new() var err := peer.create_server(PORT, 4) if err != OK: _say("FATAL: create_server failed: %s" % error_string(err)) _finish() return multiplayer.multiplayer_peer = peer _say("server up on %d" % PORT) # t=1: spawn BEFORE anyone joins, so the client receives it purely as a # late-joiner replay — the same path as joining a running game. await _at(1.0) var item: Node3D = load(ITEM).instantiate() item.name = "SpikeItem" item.position = Vector3(5, 6, 7) item.health = 42 item.label = "hello" $Content.add_child(item) _say("spawned SpikeItem at %s health=%d label=%s" % [item.position, item.health, item.label]) await _at(7.0) if multiplayer.get_peers().is_empty(): _say("FATAL: no client connected by t=7") _finish() return var client: int = multiplayer.get_peers()[0] _say("client connected: %d" % client) # t=8: change an ON_CHANGE value well after the client joined (Q2b). await _at(8.0) item.health = 7 item.label = "changed" _say("changed health=7 label=changed") # t=12: hand the transform to the client, as if it had just grabbed the item. await _at(12.0) _set_xform_authority.rpc(item.get_path(), client) # t=13: the server now tries to move it and also changes server-owned state. # The move must NOT reach the client (it owns the transform); the state must. await _at(13.0) item.position = Vector3(-9, -9, -9) item.health = 123 _say("server set pos=%s health=123 while the CLIENT owns NetXform" % item.position) await _at(15.5) _say("Q4 server's view of item pos (expect the CLIENT's 1,1,1) -> %s" % item.position) # t=17: take it back, as if the client had released it, and move it. await _at(17.0) _set_xform_authority.rpc(item.get_path(), 1) await _at(17.5) item.position = Vector3(-3, -3, -3) _say("server reclaimed NetXform and set pos=%s" % item.position) # Stay alive past the client's last report, so a shutdown-induced despawn # can't be mistaken for anything else. await _at(24.0) _say("server done") _finish() # --- client ---------------------------------------------------------------- func _run_client() -> void: # Deliberately late, so the spawn is replayed rather than observed live. await _at(5.0) var peer := ENetMultiplayerPeer.new() var err := peer.create_client("127.0.0.1", PORT) if err != OK: _say("FATAL: create_client failed: %s" % error_string(err)) _finish() return multiplayer.multiplayer_peer = peer _say("client connecting...") await _at(7.5) _report("Q1/Q2a late-join spawn (expect pos=5,6,7 health=42 label=hello)") await _at(10.0) _report("Q2b after change (expect health=7 label=changed)") # t=13.5: we now own NetXform. Drive the item ourselves, the way a held # object's grab driver would, and see whether the server's competing write # from t=13 gets ignored here and whether ours reaches the server. await _at(13.5) var item := _item() if item: item.position = Vector3(1, 1, 1) _say("client set pos=(1,1,1) while owning NetXform") await _at(15.0) _report("Q4 client owns NetXform (expect pos=1,1,1 — NOT -9; health=123 — server state still arrives)") await _at(20.0) _report("Q4b server reclaimed (expect pos=-3,-3,-3)") _say("client done") _finish() func _report(what: String) -> void: var item := _item() if not item: _say("%s -> NO NODE (item absent on client)" % what) return _say("%s -> pos=%s health=%d label=%s private=%d" % [ what, item.position, item.health, item.label, item._private_should_not_replicate, ]) # --- logging --------------------------------------------------------------- func _open_log() -> void: var who := "server" if _is_server else "client" var dir := ProjectSettings.globalize_path("res://logs") DirAccess.make_dir_recursive_absolute(dir) _log = FileAccess.open(dir.path_join("spike_%s.log" % who), FileAccess.WRITE) func _say(s: String) -> void: var line := "[%s t=%5.1f] %s" % [ "SERVER" if _is_server else "CLIENT", (Time.get_ticks_msec() - _start_ms) / 1000.0, s, ] print(line) if _log: _log.store_line(line) _log.flush() func _finish() -> void: if _log: _log.flush() get_tree().quit()