extends SceneTree ## M2.2: playing on a server. Joining puts you on the server's island (its ## seed, spawn and clock); other players are avatars with name tags that move ## smoothly under 100 ms of simulated latency; the server refuses impossible ## moves (teleports, leaving the island) and accepts a flagged respawn. ## ## Like test_net_lobby: a real headless server and bot players ## (tests/net_bot.gd) as separate processes over UDP on localhost. This test ## is "Alice" and loads the actual world scene. ## Run: godot --headless --path . --script res://tests/test_net_world.gd const PORT := 24345 const DIR := "user://test_net" const SEED := 777 var _fails := 0 var _finished := false var _pids: Array[int] = [] var Net: Node var _n := 0 var _toasts: Array[String] = [] func check(cond: bool, what: String) -> void: print((" ok " if cond else " FAIL ") + what) if not cond: _fails += 1 func _initialize() -> void: _run.call_deferred() func _run() -> void: Engine.max_fps = 60 Net = root.get_node("Net") root.get_node("Actions").notify.connect(func(t): _toasts.append(t)) for d in ["/server3", "/results"]: _rmdir(DIR + d) DirAccess.make_dir_recursive_absolute(DIR + "/results") _spawn(["--server", "--port", str(PORT), "--config", DIR + "/server3/server.json", "--server-name", "World Test", "--seed", str(SEED)]) await _sleep(2.5) await _body() Net.debug_latency_ms = 0.0 Net.stop() for pid in _pids: if OS.is_process_running(pid): OS.kill(pid) check(_finished, "test ran to the end (no script errors)") print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails]) quit(1 if _fails > 0 else 0) func _body() -> void: var a = load("res://scripts/net/identity.gd").open(DIR + "/a") a.name = "Alice" Net.identity = a var r := await _join("127.0.0.1:%d" % PORT) check(r == "", "Alice joins (%s)" % r) check(int(Net.world_info.get("seed", 0)) == SEED and Net.world_info.has("spawn"), "the server sends its world: seed %s" % Net.world_info.get("seed")) print("the server's island") var world: Node3D = load("res://scenes/world.tscn").instantiate() root.add_child(world) for _i in range(8): await physics_frame var player: CharacterBody3D = world.get_node("Player") var terrain: Node3D = world.get_node("Terrain") check(terrain.noise_seed == SEED, "the world is built from the server's seed, not the default (%d)" % terrain.noise_seed) check(world.is_online() and world.net_sync != null, "and knows it's online") var sp: Vector3 = Net.world_info.spawn check(Vector2(player.global_position.x - sp.x, player.global_position.z - sp.z).length() < 1.0, "Alice starts where the server put her") check(not world.creatures.enabled and world.creatures.alive().is_empty() and world.wolves == null, "no animals of her game's own while online (the server runs them: test_net_life.gd)") check(world.day_night.day == int(Net.world_info.day) and absf(world.day_night.time - float(Net.world_info.time)) < 0.01, "the clock follows the server (%s)" % world.day_night.clock_text()) print("another player, 100 ms away") Net.debug_latency_ms = 100.0 Net.debug_jitter_ms = 30.0 var bob := _bot("bob", "Bob", ["--walk", "line", "--speed", "4", "--walk-time", "3", "--stay", "6"]) var av: Node3D = null var t := 0.0 while av == null and t < 20.0: av = _avatar(world, "Bob") await _sleep(0.05) t += 0.05 check(av != null, "Bob appears in Alice's world") if av == null: return check(av.tag.text == "Bob" and av.tag.billboard == BaseMaterial3D.BILLBOARD_ENABLED and not av.tag.no_depth_test, "with a name tag over him (hidden by walls)") # Watch him walk: sample every 50 ms. var samples: Array = [] var anims := {} t = 0.0 var last: Vector3 = av.global_position var last_us := Time.get_ticks_usec() while t < 6.0 and is_instance_valid(av): await _sleep(0.05) var now_us := Time.get_ticks_usec() var dt := (now_us - last_us) / 1000000.0 last_us = now_us t += dt var p: Vector3 = av.global_position samples.append([t, Vector2(p.x - last.x, p.z - last.z).length() / maxf(dt, 0.001)]) anims[av.anim] = true last = p var end_pos: Vector3 = av.global_position if is_instance_valid(av) else last var end_anim: int = av.anim if is_instance_valid(av) else -1 var br := await _result(bob) var moving := samples.filter(func(s): return s[1] > 0.5) # Top speed over 0.2 s windows (4 samples): one late frame on this machine # reads as a burst in a single 50 ms sample, while a real jump (half a # metre extra) still shows as over 6.5 m/s. var top := 0.0 for i in range(3, samples.size()): var dt_sum := 0.0 var dist := 0.0 for j in range(i - 3, i + 1): var dtj: float = samples[j][0] - (samples[j - 1][0] if j > 0 else 0.0) dt_sum += dtj dist += float(samples[j][1]) * dtj top = maxf(top, dist / maxf(dt_sum, 0.001)) var stalls := 0 if moving.size() > 2: # Count gaps inside the walking stretch where he froze. var first: float = moving[0][0] var last_t: float = moving[-1][0] for s in samples: if s[0] > first and s[0] < last_t and s[1] < 0.4: stalls += 1 check(br.get("corrections", -1) == 0, "walking normally is never corrected by the server") check(moving.size() >= 30, "Alice watched him walk for %.1f s" % (moving.size() * 0.05)) var speeds: Array = moving.map(func(s): return s[1]) speeds.sort() var median: float = speeds[speeds.size() / 2] if not speeds.is_empty() else 0.0 print(" (speeds seen: median %.2f, top %.2f m/s over %d samples)" % [median, top, speeds.size()]) check(top < 6.0, "smoothly: no jumps (top speed seen %.1f m/s for a 4 m/s walk)" % top) check(absf(median - 4.0) < 0.5, "at his real speed (median %.2f m/s)" % median) check(stalls <= 2, "and no freezing mid-walk (%d stalls in %d samples)" % [stalls, moving.size()]) check(end_anim == 0 and anims.has(1), "the walk animation played, then idle") var fe: Array = br.get("walk_end", [0, 0, 0]) var dist := Vector2(end_pos.x - fe[0], end_pos.z - fe[2]).length() check(dist < 0.3, "and he ends up exactly where he stopped (%.2f m off)" % dist) var ap: Array = br.get("alice_pos", []) check(ap.size() == 3 and Vector2(ap[0] - player.global_position.x, ap[2] - player.global_position.z).length() < 0.5, "Bob saw Alice where she really is") t = 0.0 while not world.net_sync.avatars.is_empty() and t < 8.0: await _sleep(0.1) t += 0.1 check(world.net_sync.avatars.is_empty() and _toasts.has("Bob left"), "when he leaves his avatar goes (and Alice is told)") print("cheats") var tom := _bot("tom", "Tom", ["--cheat", "teleport", "--walk-time", "3", "--stay", "1"]) var olga := _bot("olga", "Olga", ["--cheat", "outside", "--stay", "1"]) var rita := _bot("rita", "Rita", ["--cheat", "respawn", "--walk-time", "2", "--stay", "1"]) var tom_seen := false var tom_top := 0.0 var tom_last := Vector3.INF var us := Time.get_ticks_usec() t = 0.0 while t < 7.0: await _sleep(0.05) var dt := (Time.get_ticks_usec() - us) / 1000000.0 us = Time.get_ticks_usec() t += dt var tav := _avatar(world, "Tom") if tav == null: tom_last = Vector3.INF continue tom_seen = true var p: Vector3 = tav.global_position if tom_last != Vector3.INF: tom_top = maxf(tom_top, p.distance_to(tom_last) / maxf(dt, 0.001)) tom_last = p var tr := await _result(tom) var ts: Array = tr.get("walk_start", [0, 0, 0]) var tf: Array = tr.get("final_pos", [999, 0, 999]) check(tr.get("corrections", 0) >= 1 and Vector2(tf[0] - ts[0], tf[2] - ts[2]).length() < 2.0, "a 60 m teleport is refused and Tom is sent back (%d correction%s)" % [tr.get("corrections", 0), "" if tr.get("corrections", 0) == 1 else "s"]) check(tom_seen and tom_top < 10.0, "Alice never sees Tom jump (top speed seen %.1f m/s)" % tom_top) var orr := await _result(olga) var of: Array = orr.get("final_pos", [999, 0, 999]) check(orr.get("corrections", 0) >= 1 and terrain.inside_boundary(of[0], of[2], -3.0), "walking out of the island is stopped at the edge (%d correction%s)" % [orr.get("corrections", 0), "" if orr.get("corrections", 0) == 1 else "s"]) var rr := await _result(rita) check(rr.get("joined", false) and rr.get("corrections", -1) == 0, "a flagged respawn at the spawn is allowed") print("Alice's own moves") var before: Vector3 = player.global_position player.global_position = before + Vector3(40, 0, 0) player.global_position.y = terrain.sample_height(player.global_position.x, player.global_position.z) + 0.1 t = 0.0 while world.net_sync.corrections == 0 and t < 3.0: await _sleep(0.05) t += 0.05 check(world.net_sync.corrections >= 1, "if Alice's game teleports her, the server corrects it") check(Vector2(player.global_position.x - before.x, player.global_position.z - before.z).length() < 3.0, "and she's put back") var c0: int = world.net_sync.corrections world._respawn() await _sleep(1.0) check(world.net_sync.corrections == c0, "dying and respawning at the spawn isn't treated as cheating") world.queue_free() await process_frame Net.leave() _finished = true # --- helpers -------------------------------------------------------------------- func _avatar(world: Node3D, name: String) -> Node3D: if world.net_sync == null: return null for av in world.net_sync.avatars.values(): if String(av.info.get("name", "")) == name: return av return null func _join(address: String) -> String: var state := {"done": false, "reason": ""} var ok := func(): state.done = true var bad := func(why): state.done = true; state.reason = why Net.joined.connect(ok) Net.join_failed.connect(bad) Net.join(address) var t := 0.0 while not state.done and t < 25.0: await _sleep(0.05) t += 0.05 Net.joined.disconnect(ok) Net.join_failed.disconnect(bad) return state.reason if state.done else "test timed out" func _spawn(user_args: Array, script: String = "") -> int: var args := PackedStringArray(["--headless", "--path", ProjectSettings.globalize_path("res://")]) if not script.is_empty(): args.append_array(["--script", script]) args.append("--") for x in user_args: args.append(str(x)) args.append_array(["--exit-with", str(OS.get_process_id())]) var pid := OS.create_process(OS.get_executable_path(), args) _pids.append(pid) return pid func _bot(identity: String, name: String, extra: Array) -> String: _n += 1 var path := "%s/results/w_%s_%d.json" % [DIR, identity, _n] _spawn(["--identity", DIR + "/" + identity, "--name", name, "--address", "127.0.0.1:%d" % PORT, "--result", path] + extra, "res://tests/net_bot.gd") return path func _result(path: String) -> Dictionary: var t := 0.0 while not FileAccess.file_exists(path) and t < 40.0: await _sleep(0.1) t += 0.1 await _sleep(0.05) var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(path)) if FileAccess.file_exists(path) else null return d if d is Dictionary else {"joined": false, "reason": "bot gave no result"} func _sleep(s: float) -> void: await create_timer(s).timeout ## Deletes a folder and everything in it (a server's folder has its world in it). func _rmdir(d: String) -> void: var da := DirAccess.open(d) if da == null: return for sub in da.get_directories(): _rmdir(d.path_join(sub)) for f in da.get_files(): da.remove(f) DirAccess.remove_absolute(d)