Files
Emberwild/tests/test_net_world.gd

274 lines
11 KiB
GDScript

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)