220 lines
9.5 KiB
GDScript
220 lines
9.5 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
|
|
|
|
## 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)
|