Files
Cinderwild/tests/test_net_lobby.gd
Paul Bailey f62f71658f M2.1: dedicated server, joining, identity handshake, names, lobby
- Net autoload: headless dedicated server (--server) on UDP 24242 via ENet,
  server.json preferences (flags override per run, never saved), reserved
  names in names.json
- Identity: RSA-2048 key per install (Godot has no Ed25519), player id =
  key hash, challenge/response so ids can't be faked; profile with name
  and limited appearance (tunic, hair, beard)
- Server refuses bans, allowlist misses, duplicate logins, bad or taken
  names, wrong passwords, old protocol versions and a full server, each
  with a clear reason; first player on a new server becomes admin
- Title screen: Profile, Join Game, and a lobby with server info, rules
  and who's online
- test_net_lobby: real headless server + bot processes over UDP (38
  checks); --exit-with so test servers never outlive the test
2026-11-04 13:40:27 +00:00

217 lines
9.4 KiB
GDScript

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)