extends SceneTree ## M2.1: dedicated server, joining, the identity handshake, name rules, ## admin bootstrap, the player list, and the server's refusals. ## ## Real processes and real UDP on localhost: this test starts a headless ## dedicated server (and later a second, password-protected one), plays ## "Alice" itself, and starts scripted bot players (tests/net_bot.gd) as ## separate processes. Everything it starts is killed at the end. ## Run: godot --headless --path . --script res://tests/test_net_lobby.gd const PORT := 24343 const PORT2 := 24344 const DIR := "user://test_net" var _fails := 0 var _finished := false var _pids: Array[int] = [] var Net: Node var Identity: GDScript var _n := 0 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: Net = root.get_node("Net") Identity = load("res://scripts/net/identity.gd") # Fresh server state every run (admins, reserved names). Identities are # kept between runs: they're just keys. for d in ["/server", "/server2", "/results"]: _rmdir(DIR + d) DirAccess.make_dir_recursive_absolute(DIR + "/results") _spawn(["--server", "--port", str(PORT), "--config", DIR + "/server/server.json", "--server-name", "Test Island", "--max-players", "3"]) await _sleep(1.5) await _body() 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: print("rules (no network)") check(Net.parse_address("example.com") == ["example.com", 24242], "an address without a port uses 24242") check(Net.parse_address("100.101.102.103:24300") == ["100.101.102.103", 24300], "host:port") check(Net.parse_address("[::1]:9000") == ["::1", 9000], "IPv6 [addr]:port") check(Identity.name_problem("Paul") == "" and Identity.name_problem("Old Bjorn_2") == "", "ordinary names are fine") check(Identity.name_problem("Al") != "" and Identity.name_problem("A".repeat(17)) != "", "3-16 characters") check(Identity.name_problem(" Paul") != "" and Identity.name_problem("Pa ul") != "" and Identity.name_problem("Paul!") != "", "no edge spaces, double spaces or symbols") var ap: Dictionary = Identity.sanitize_appearance({"tunic": 99, "hair": -3, "beard": 1}) check(ap.tunic == Identity.TUNICS.size() - 1 and ap.hair == 0 and ap.beard == true, "appearance is clamped to the palettes") print("joining") var a = Identity.open(DIR + "/a") a.name = "Alice" a.appearance = {"tunic": 2, "hair": 1, "beard": true} Net.identity = a var r := await _join("127.0.0.1:%d" % PORT) check(r == "" and Net.state == "connected", "Alice joins the dedicated server (%s)" % r) check(Net.server_info.get("server_name", "") == "Test Island" and Net.server_info.get("pvp", true) == false, "and gets its settings (%s)" % Net.server_info.get("server_name", "?")) check(not Net.server_info.has("password") and not Net.server_info.has("admins"), "but not its password or lists") var me: Dictionary = Net.players.get(Net.local_peer, {}) check(Net.players.size() == 1 and me.get("name", "") == "Alice" and me.get("id", "") == a.player_id, "she's on the player list") check(me.get("admin", false), "the first player on a new server becomes admin") check(int(me.get("appearance", {}).get("tunic", -1)) == 2 and me.appearance.beard == true, "with her appearance") print("a second player") var bob := _bot("bob", "Bob", ["--stay", "4"]) var t := 0.0 while _find("Bob").is_empty() and t < 20.0: await _sleep(0.1) t += 0.1 var b: Dictionary = _find("Bob") check(not b.is_empty(), "Alice sees Bob join") check(not b.is_empty() and not b.admin, "Bob isn't an admin") var br := await _result(bob) check(br.get("joined", false) and "Alice" in br.saw and "Bob" in br.saw, "Bob saw both of them (%s)" % [br.get("saw", [])]) check(br.get("server_name", "") == "Test Island", "Bob got the server's name too") t = 0.0 while not _find("Bob").is_empty() and t < 10.0: await _sleep(0.1) t += 0.1 check(_find("Bob").is_empty() and Net.players.size() == 1, "Alice sees Bob leave") print("the server says no") var tests := [ ["alice_lower", "alice", [], "taken"], ["a", "Alice", [], "already connected"], ["eve", "Eve", ["--forge"], "Identity check failed"], ["shorty", "x", ["--raw-name"], "Names need 3-16"], ["greta", "Greta", ["--protocol", "99"], "version"], ["bob_again", "BOB", [], "taken"], ] var paths := [] for tc in tests: paths.append(_bot(tc[0], tc[1], tc[2])) for i in range(tests.size()): var res := await _result(paths[i]) var why := String(res.get("reason", "")) check(not res.get("joined", true) and why.contains(tests[i][3]), "%s is refused: %s" % [tests[i][1], why if not why.is_empty() else "(no reason)"]) check(Net.players.size() == 1 and Net.state == "connected", "and none of that disturbed Alice") print("a full server") var p1 := _bot("pia", "Pia", ["--stay", "8"]) var p2 := _bot("quinn", "Quinn", ["--stay", "8"]) t = 0.0 while Net.players.size() < 3 and t < 20.0: await _sleep(0.1) t += 0.1 check(Net.players.size() == 3, "three players: the server's max (%d)" % Net.players.size()) var p3 := _bot("rex", "Rex", []) var rr := await _result(p3) check(not rr.get("joined", true) and String(rr.get("reason", "")).contains("full"), "a fourth is told it's full (%s)" % rr.get("reason", "")) check((await _result(p1)).get("joined", false) and (await _result(p2)).get("joined", false), "Pia and Quinn were fine") print("what the server remembers") var cfg: Variant = JSON.parse_string(FileAccess.get_file_as_string(DIR + "/server/server.json")) check(cfg is Dictionary and cfg.admins == [a.player_id], "server.json records Alice as admin") check(cfg is Dictionary and cfg.port == 24242, "flags override settings for the run without rewriting them (port %s)" % cfg.get("port")) var names: Variant = JSON.parse_string(FileAccess.get_file_as_string(DIR + "/server/names.json")) check(names is Dictionary and names.alice.id == a.player_id and names.has("bob") and names.has("pia"), "names.json reserves each name for its player") Net.leave() check(Net.state == "offline", "Alice leaves") r = await _join("127.0.0.1:%d" % PORT) check(r == "" and Net.players.get(Net.local_peer, {}).get("admin", false), "and comes back, still admin") Net.leave() print("a password-protected server") _spawn(["--server", "--port", str(PORT2), "--config", DIR + "/server2/server.json", "--password", "hunter2"]) await _sleep(1.5) var wrong := _bot("bob", "Bob", ["--password", "nope"], PORT2) var w := await _result(wrong) check(not w.get("joined", true) and String(w.get("reason", "")).contains("Wrong password"), "the wrong password is refused") var right := _bot("bob", "Bob", ["--password", "hunter2"], PORT2, "bob2") var rt := await _result(right) check(rt.get("joined", false) and rt.get("admin", false), "the right one gets in (and Bob's admin on this new server)") r = await _join("127.0.0.1:%d" % PORT2) check(r.contains("Wrong password"), "Alice, with no password, is refused too (%s)" % r) r = await _join("127.0.0.1:%d" % 24399) check(r != "" and Net.state == "offline", "nothing listening: a clear failure, not a hang (%s)" % r) _finished = true # --- helpers -------------------------------------------------------------------- func _join(address: String, password: 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, password) var t := 0.0 while not state.done and t < 20.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 _find(name: String) -> Dictionary: for p in Net.players.values(): if p.name == name: return p return {} 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 ## Starts a bot. Returns the path its result will be written to. func _bot(identity: String, name: String, extra: Array, port: int = PORT, label: String = "") -> String: _n += 1 var path := "%s/results/%s_%d.json" % [DIR, label if not label.is_empty() else 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 < 30.0: await _sleep(0.1) t += 0.1 await _sleep(0.05) # let the writer finish 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 func _rmdir(d: String) -> void: var da := DirAccess.open(d) if da == null: return for f in da.get_files(): da.remove(f) DirAccess.remove_absolute(d)