274 lines
11 KiB
GDScript
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.75, "at his real speed (median %.2f m/s)" % median) # (0.75: a busy machine (the full suite) skews the samples a little)
|
|
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)
|