diff --git a/docs/MULTIPLAYER_PLAN.md b/docs/MULTIPLAYER_PLAN.md index 9902ed2..9d6c550 100644 --- a/docs/MULTIPLAYER_PLAN.md +++ b/docs/MULTIPLAYER_PLAN.md @@ -17,6 +17,13 @@ concrete decisions for this codebase, as it stands after M1. - **Co-op or adversarial**, chosen by server rules (§5). ## 2. Running a server +*Built in M2.1* (`scripts/net/net.gd`, `server_config.gd`, `identity.gd`). +As of M2.1 the server accepts players, checks who they are and keeps the +player list. The world itself isn't served yet (M2.3). Flags: `--server`, +`--port`, `--config`, `--server-name`, `--max-players`, `--password`, +`--world`, and `--exit-with ` (quit when that process exits; the tests +use it so a crashed test never leaves a server running). Flags apply to +that run only and are never written to `server.json`. ```bash # Dedicated server (no window, no player) godot --headless --path . -- --server --config server.json @@ -42,7 +49,9 @@ godot --headless --path . -- --server --world "Valley of Us" --port 24242 ## 3. Players: identity and names - **Identity:** on first run each game install generates a private key - (Ed25519) and keeps it in `user://identity.key`. The public key's hash is + (RSA-2048, since Godot's Crypto has no Ed25519) and keeps it in + `user://identity/key.pem`, with the name and appearance in + `user://identity/profile.json`. The public key's hash is the **player id**. On joining, the client signs a server-chosen challenge, so nobody can claim someone else's id. No passwords and no central accounts. @@ -199,7 +208,7 @@ player" or "this player": | Phase | Delivers | Done when | |---|---|---| | **M2.0** | worldlet W0 (terrain from data, no visible change), recommended first | seed 1337 generates the same height grid as today; all suites pass | -| **M2.1** | network skeleton: `--server`, ENet on 24242, Join screen (address, name), handshake with identity and challenge, name rules, player list | two clients and a headless server on one Mac connect, see the player list, and disconnect cleanly | +| **M2.1** *(done)* | network skeleton: `--server`, ENet on 24242, Join screen (address, name), handshake with identity and challenge, name rules, player list | two clients and a headless server on one Mac connect, see the player list, and disconnect cleanly | | **M2.2** | player bodies and name tags; predicted movement, interpolation for others | two players walk around together at 100 ms simulated latency without visible rubber-banding | | **M2.3** | world state replicated: resource nodes, pickups, pieces, chests, fires, doors, clock, weather | one chops a tree and the other sees it fall; one builds a wall and the other walks into it | | **M2.4** | intents as RPCs; per-player inventory, stats, diet, death and graves; per-player saves and migration | two players craft, eat, die and loot their own graves; an old single-player save loads | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d4c2155..43236e4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -44,7 +44,7 @@ Detailed plan in `docs/MULTIPLAYER_PLAN.md` (hosting on UDP 24242 via Tailscale or port forwarding, player identity and names, server rules for co-op/PvP, and phases M2.0-M2.7). - [ ] Refactor state to server-authoritative (single-player becomes loopback server) -- [ ] Headless dedicated server mode (`--headless -- --server`) +- [x] Headless dedicated server, joining, identity handshake, names, player list *(M2.1)* - [ ] ENet transport, client prediction for local movement, interpolation for others - [ ] Replicate resource nodes, inventory, building, health - [ ] Persistence: world save/load on the server diff --git a/docs/SETUP_ON_NEW_MACHINE.md b/docs/SETUP_ON_NEW_MACHINE.md index 133a758..33ebff4 100644 --- a/docs/SETUP_ON_NEW_MACHINE.md +++ b/docs/SETUP_ON_NEW_MACHINE.md @@ -36,6 +36,8 @@ godot --headless --path . --script res://tests/test_terrain_edges.gd godot --headless --path . --script res://tests/test_survival_core.gd # Save/load round trip + world slots (uses user://test_worlds, not your saves) godot --headless --path . --script res://tests/test_save_load.gd +# Multiplayer lobby: a real headless server on UDP 24343 + scripted bot players +godot --headless --path . --script res://tests/test_net_lobby.gd # Survival stats: hunger, warmth, fire fuel, cooking, foraging, death + grave godot --headless --path . --script res://tests/test_survival_stats.gd ``` diff --git a/project.godot b/project.godot index 697aaf2..64e81ba 100644 --- a/project.godot +++ b/project.godot @@ -25,6 +25,7 @@ config/icon="res://icon.svg" ItemDB="*res://scripts/core/item_db.gd" Actions="*res://scripts/core/actions.gd" Saves="*res://scripts/core/saves.gd" +Net="*res://scripts/net/net.gd" [display] diff --git a/scripts/net/identity.gd b/scripts/net/identity.gd new file mode 100644 index 0000000..96aa55e --- /dev/null +++ b/scripts/net/identity.gd @@ -0,0 +1,100 @@ +extends RefCounted +## Who you are on multiplayer servers: a private key, a display name and how +## your character looks. No accounts and no passwords. +## +## - The first time the game runs it generates an RSA-2048 key pair and keeps +## the private key in `/key.pem`. It never leaves this machine. +## - Your **player id** is a hash of the public key. Servers recognise you by +## it (your reserved name, your admin rights, later your inventory). +## - When you join, the server sends a random challenge and you sign it. +## Nobody can claim your id without your private key. +## - `/profile.json` holds your name, appearance and last server. +## +## Godot's Crypto has no Ed25519, hence RSA (docs/MULTIPLAYER_PLAN.md §3). + +## Limited appearance for v1: a tunic colour, a hair colour, a beard or not. +const TUNICS := [Color(0.55, 0.22, 0.18), Color(0.22, 0.38, 0.58), Color(0.28, 0.46, 0.24), + Color(0.62, 0.52, 0.28), Color(0.42, 0.28, 0.52), Color(0.35, 0.35, 0.36)] +const HAIR := [Color(0.18, 0.12, 0.08), Color(0.45, 0.28, 0.14), Color(0.78, 0.6, 0.32), + Color(0.62, 0.22, 0.1), Color(0.55, 0.55, 0.55), Color(0.1, 0.1, 0.1)] +const DEFAULT_DIR := "user://identity" + +var dir := DEFAULT_DIR +var key: CryptoKey +var public_pem := "" +var player_id := "" +var name := "" +var appearance := {"tunic": 0, "hair": 0, "beard": false} +var last_server := "" + +## Loads the identity in `d`, creating the key on first use. +static func open(d: String = DEFAULT_DIR) -> RefCounted: + var me = load("res://scripts/net/identity.gd").new() + me.dir = d + DirAccess.make_dir_recursive_absolute(d) + var key_path := d.path_join("key.pem") + me.key = CryptoKey.new() + if not FileAccess.file_exists(key_path) or me.key.load(key_path) != OK: + me.key = Crypto.new().generate_rsa(2048) + var err: int = me.key.save(key_path) + if err != OK: + push_error("[identity] couldn't save the key to %s (%d)" % [key_path, err]) + me.public_pem = me.key.save_to_string(true) + me.player_id = id_for(me.public_pem) + var p: Variant = JSON.parse_string(FileAccess.get_file_as_string(d.path_join("profile.json"))) \ + if FileAccess.file_exists(d.path_join("profile.json")) else null + if p is Dictionary: + me.name = String(p.get("name", "")) + me.appearance = sanitize_appearance(p.get("appearance", {})) + me.last_server = String(p.get("last_server", "")) + return me + +func save_profile() -> void: + var f := FileAccess.open(dir.path_join("profile.json"), FileAccess.WRITE) + if f == null: + push_error("[identity] couldn't write the profile") + return + f.store_string(JSON.stringify({"name": name, "appearance": appearance, "last_server": last_server}, "\t")) + +## Signs a server's challenge with the private key. +func sign(nonce: PackedByteArray) -> PackedByteArray: + return Crypto.new().sign(HashingContext.HASH_SHA256, _sha256(nonce), key) + +## A short, stable id from a public key (first 20 hex characters of SHA-256). +static func id_for(pem: String) -> String: + return pem.sha256_text().substr(0, 20) + +## Whether `sig` is the owner of `pem` signing `nonce`. +static func verify(pem: String, nonce: PackedByteArray, sig: PackedByteArray) -> bool: + var pk := CryptoKey.new() + if pem.length() > 4096 or pk.load_from_string(pem, true) != OK: + return false + return Crypto.new().verify(HashingContext.HASH_SHA256, _sha256(nonce), sig, pk) + +static func _sha256(b: PackedByteArray) -> PackedByteArray: + var h := HashingContext.new() + h.start(HashingContext.HASH_SHA256) + h.update(b) + return h.finish() + +## Clamps appearance from a file or the network to the allowed choices. +static func sanitize_appearance(d: Variant) -> Dictionary: + var a: Dictionary = d if d is Dictionary else {} + return { + "tunic": clampi(int(a.get("tunic", 0)), 0, TUNICS.size() - 1), + "hair": clampi(int(a.get("hair", 0)), 0, HAIR.size() - 1), + "beard": bool(a.get("beard", false)), + } + +## "" if `n` is an allowed display name, otherwise why not. +static func name_problem(n: String) -> String: + if n != n.strip_edges(): + return "Names can't start or end with a space" + if n.length() < 3 or n.length() > 16: + return "Names need 3-16 characters" + if n.contains(" "): + return "Names can't have double spaces" + var re := RegEx.create_from_string("^[A-Za-z0-9 _\\-]+$") + if re.search(n) == null: + return "Names can use letters, digits, spaces, _ and -" + return "" diff --git a/scripts/net/net.gd b/scripts/net/net.gd new file mode 100644 index 0000000..0cc71ce --- /dev/null +++ b/scripts/net/net.gd @@ -0,0 +1,369 @@ +extends Node +## Autoload "Net": the multiplayer layer (docs/MULTIPLAYER_PLAN.md). +## +## M2.1 covers the skeleton: a dedicated server, joining it, the identity +## handshake, name rules and the player list. Gameplay over the network +## follows in M2.2-M2.5; single-player still runs as before. +## +## Server: `godot --headless --path . -- --server [--port 24242] +## [--config user://server/server.json] [--server-name "..."] +## [--max-players 8] [--password ...]` +## Client: Net.join("host:port", password) from the title screen. +## +## Handshake (all reliable ENet over one UDP port): +## client -> server _c2s_hello {protocol, name, appearance, public_key, password} +## server -> client _s2c_challenge nonce (32 random bytes) +## client -> server _c2s_proof signature (nonce signed with the private key) +## server -> client _s2c_welcome {...} or _s2c_rejected reason +## Then the server tells everyone about joins and leaves. + +signal state_changed(state: String) ## offline | connecting | connected | server +signal joined ## the server welcomed us +signal join_failed(reason: String) +signal left(reason: String) ## disconnected after joining +signal players_changed + +const Identity = preload("res://scripts/net/identity.gd") +const ServerConfig = preload("res://scripts/net/server_config.gd") + +const PROTOCOL := 1 +const DEFAULT_PORT := 24242 +const HANDSHAKE_TIMEOUT := 10.0 +const JOIN_TIMEOUT := 12.0 + +var state := "offline" +var dedicated := false ## started with --server +var identity: RefCounted ## identity.gd (clients) +var config: RefCounted ## server_config.gd (server) +var server_info: Dictionary = {} ## the server's public settings (clients) +## Everyone on the server: peer id -> {"id", "name", "appearance", "admin"}. +var players: Dictionary = {} +var local_peer := 0 + +## Test hooks (tests/net_bot.gd): sign with the wrong key, or claim another +## protocol version. +var debug_forge := false +var debug_protocol := 0 +var debug_skip_name_check := false ## send a bad name anyway: the server must refuse it + +var _peer: ENetMultiplayerPeer +var _pending: Dictionary = {} ## server: peer -> {"t": secs, "hello": {}, "nonce": bytes} +var _reject_reason := "" +var _join_left := 0.0 +var _args: Dictionary = {} + +func _ready() -> void: + _args = parse_args(OS.get_cmdline_user_args()) + multiplayer.peer_connected.connect(_on_peer_connected) + multiplayer.peer_disconnected.connect(_on_peer_disconnected) + multiplayer.connected_to_server.connect(_on_connected) + multiplayer.connection_failed.connect(_on_connection_failed) + multiplayer.server_disconnected.connect(_on_server_disconnected) + if _args.has("server"): + dedicated = true + _start_dedicated.call_deferred() + # `--exit-with `: quit when that process is gone (test servers and + # bots, so a crashed test never leaves them running). + if _args.has("exit-with"): + var parent := int(_args["exit-with"]) + var t := Timer.new() + t.wait_time = 1.0 + t.autostart = true + t.timeout.connect(func(): + # (OS.is_process_running only works for our own children.) + if OS.get_name() != "Windows" and OS.execute("kill", ["-0", str(parent)]) != 0: + print("[net] parent process %d is gone, quitting" % parent) + stop() + get_tree().quit()) + add_child(t) + +## "--a=b", "--a b" and bare "--flag" all work. +static func parse_args(args: PackedStringArray) -> Dictionary: + var out := {} + var i := 0 + while i < args.size(): + var a: String = args[i] + if a.begins_with("--"): + var k := a.trim_prefix("--") + if k.contains("="): + out[k.get_slice("=", 0)] = k.substr(k.find("=") + 1) + elif i + 1 < args.size() and not args[i + 1].begins_with("--"): + out[k] = args[i + 1] + i += 1 + else: + out[k] = true + i += 1 + return out + +## The local identity, loaded on first use (`--identity ` overrides). +func get_identity() -> RefCounted: + if identity == null: + identity = Identity.open(String(_args.get("identity", Identity.DEFAULT_DIR))) + return identity + +func _set_state(s: String) -> void: + state = s + state_changed.emit(s) + +func is_server() -> bool: + return state == "server" + +# --- server ------------------------------------------------------------------- + +func _start_dedicated() -> void: + var err := start_server(String(_args.get("config", ServerConfig.DEFAULT_PATH)), _args) + if not err.is_empty(): + printerr("[server] ", err) + get_tree().quit(1) + +## Opens the server. Returns "" on success, otherwise what went wrong. +func start_server(config_path: String, overrides: Dictionary = {}) -> String: + if state != "offline": + return "Already %s" % state + config = ServerConfig.open(config_path) + var map := {"port": "port", "server-name": "server_name", "max-players": "max_players", + "password": "password", "world": "world"} + for flag in map: + if overrides.has(flag): + var v: Variant = overrides[flag] + config.overrides[map[flag]] = int(v) if map[flag] in ["port", "max_players"] else String(v) + _peer = ENetMultiplayerPeer.new() + var port := int(config.v("port")) + # A few spare slots so a full server can still say "server is full". + var err := _peer.create_server(port, int(config.v("max_players")) + 4) + if err != OK: + return "Couldn't open UDP port %d (error %d). Is another server already running on it?" % [port, err] + multiplayer.multiplayer_peer = _peer + multiplayer.server_relay = false # clients only ever talk to the server + players.clear() + _pending.clear() + _set_state("server") + print("[server] '%s' listening on UDP %d (max %d players%s). Config: %s" % [config.v("server_name"), port, + int(config.v("max_players")), ", password set" if not String(config.v("password")).is_empty() else "", + ProjectSettings.globalize_path(config.path)]) + return "" + +func stop() -> void: + if _peer != null: + _peer.close() + _peer = null + multiplayer.multiplayer_peer = null + players.clear() + _pending.clear() + server_info.clear() + local_peer = 0 + if state != "offline": + _set_state("offline") + +func _on_peer_connected(id: int) -> void: + if is_server(): + _pending[id] = {"t": 0.0} + +func _on_peer_disconnected(id: int) -> void: + if not is_server(): + return + _pending.erase(id) + if players.has(id): + var p: Dictionary = players[id] + players.erase(id) + print("[server] %s left (%d online)" % [p.name, players.size()]) + _s2c_player_left.rpc(id) + players_changed.emit() + +func _process(delta: float) -> void: + if is_server(): + for id in _pending.keys(): + _pending[id].t += delta + if _pending[id].t > HANDSHAKE_TIMEOUT: + _reject(id, "Took too long to say hello") + elif state == "connecting": + _join_left -= delta + if _join_left <= 0.0: + _fail("No answer from the server") + +func _reject(peer: int, reason: String) -> void: + _pending.erase(peer) + print("[server] rejected peer %d: %s" % [peer, reason]) + _s2c_rejected.rpc_id(peer, reason) + # Let the message go out before hanging up. + get_tree().create_timer(0.4).timeout.connect(func(): + if _peer != null and is_server(): + _peer.disconnect_peer(peer)) + +@rpc("any_peer", "call_remote", "reliable") +func _c2s_hello(d: Dictionary) -> void: + var peer := multiplayer.get_remote_sender_id() + if not is_server() or not _pending.has(peer) or _pending[peer].has("hello"): + return + if int(d.get("protocol", 0)) != PROTOCOL: + _reject(peer, "Different game version (server %d, you %d): update the game" % [PROTOCOL, int(d.get("protocol", 0))]) + return + var pw := String(config.v("password")) + if not pw.is_empty() and String(d.get("password", "")) != pw: + _reject(peer, "Wrong password") + return + var nonce := Crypto.new().generate_random_bytes(32) + _pending[peer].hello = d + _pending[peer].nonce = nonce + _s2c_challenge.rpc_id(peer, nonce) + +@rpc("any_peer", "call_remote", "reliable") +func _c2s_proof(sig: PackedByteArray) -> void: + var peer := multiplayer.get_remote_sender_id() + if not is_server() or not _pending.has(peer) or not _pending[peer].has("nonce"): + return + var hello: Dictionary = _pending[peer].hello + var pem := String(hello.get("public_key", "")) + if not Identity.verify(pem, _pending[peer].nonce, sig): + _reject(peer, "Identity check failed") + return + var id := Identity.id_for(pem) + var name := String(hello.get("name", "")) + var why := _admission_problem(id, name) + if not why.is_empty(): + _reject(peer, why) + return + _pending.erase(peer) + config.reserve_name(name, id) + if config.data.admins.is_empty(): + config.add_admin(id) # first player on a new server (MULTIPLAYER_PLAN.md §12) + print("[server] %s is the first player, so they're now an admin" % name) + var p := {"id": id, "name": name, "appearance": Identity.sanitize_appearance(hello.get("appearance", {})), + "admin": config.is_admin(id)} + players[peer] = p + print("[server] %s joined (id %s%s, %d/%d online)" % [name, id, ", admin" if p.admin else "", + players.size(), int(config.v("max_players"))]) + _s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players}) + for other in players: + if other != peer: + _s2c_player_joined.rpc_id(other, peer, p) + players_changed.emit() + +## Why player `id` calling themselves `name` can't come in ("" = they can). +func _admission_problem(id: String, name: String) -> String: + if id in config.data.banned: + return "You're banned from this server" + if not config.data.allowlist.is_empty() and not id in config.data.allowlist: + return "You're not on this server's allowlist" + for other in players.values(): + if other.id == id: + return "You're already connected from another game" + var bad := Identity.name_problem(name) + if not bad.is_empty(): + return bad + var owner: String = config.name_owner(name) + if not owner.is_empty() and owner != id: + return "The name '%s' is taken on this server: pick another" % name + if players.size() >= int(config.v("max_players")): + return "The server is full (%d/%d)" % [players.size(), int(config.v("max_players"))] + return "" + +# --- client ------------------------------------------------------------------- + +## Splits "host", "host:port" or "[v6]:port". Returns [host, port]. +static func parse_address(a: String) -> Array: + a = a.strip_edges() + if a.begins_with("["): + var close := a.find("]") + var host := a.substr(1, close - 1) + var rest := a.substr(close + 1) + return [host, int(rest.trim_prefix(":")) if rest.begins_with(":") else DEFAULT_PORT] + if a.count(":") == 1: + var port := a.get_slice(":", 1) + return [a.get_slice(":", 0), int(port) if port.is_valid_int() else DEFAULT_PORT] + return [a, DEFAULT_PORT] + +## Connects to a server. The outcome arrives as `joined` or `join_failed`. +func join(address: String, password: String = "") -> void: + if state != "offline": + stop() + var me := get_identity() + var bad := Identity.name_problem(me.name) + if not bad.is_empty() and not debug_skip_name_check: + join_failed.emit("Set your name first (%s)" % bad.to_lower()) + return + var hp := parse_address(address) + if String(hp[0]).is_empty(): + join_failed.emit("Enter a server address") + return + _peer = ENetMultiplayerPeer.new() + var err := _peer.create_client(hp[0], hp[1]) + if err != OK: + _peer = null + join_failed.emit("Couldn't start connecting (error %d)" % err) + return + multiplayer.multiplayer_peer = _peer + _reject_reason = "" + _pending_password = password + _join_left = JOIN_TIMEOUT + me.last_server = address.strip_edges() + me.save_profile() + _set_state("connecting") + +var _pending_password := "" + +func leave() -> void: + var was := state + stop() + if was == "connected": + left.emit("You left the server") + +func _on_connected() -> void: + var me := get_identity() + _c2s_hello.rpc_id(1, {"protocol": debug_protocol if debug_protocol != 0 else PROTOCOL, "name": me.name, + "appearance": me.appearance, "public_key": me.public_pem, "password": _pending_password}) + +@rpc("authority", "call_remote", "reliable") +func _s2c_challenge(nonce: PackedByteArray) -> void: + var sig: PackedByteArray = get_identity().sign(nonce) + if debug_forge: + var other := CryptoKey.new() + other = Crypto.new().generate_rsa(2048) + sig = Crypto.new().sign(HashingContext.HASH_SHA256, Identity._sha256(nonce), other) + _c2s_proof.rpc_id(1, sig) + +@rpc("authority", "call_remote", "reliable") +func _s2c_welcome(d: Dictionary) -> void: + local_peer = int(d.you) + server_info = d.server + players.clear() + for k in d.players: + players[int(k)] = d.players[k] + _set_state("connected") + joined.emit() + players_changed.emit() + +@rpc("authority", "call_remote", "reliable") +func _s2c_rejected(reason: String) -> void: + _reject_reason = reason + +@rpc("authority", "call_remote", "reliable") +func _s2c_player_joined(peer: int, p: Dictionary) -> void: + players[peer] = p + players_changed.emit() + +@rpc("authority", "call_remote", "reliable") +func _s2c_player_left(peer: int) -> void: + players.erase(peer) + players_changed.emit() + +func _fail(reason: String) -> void: + stop() + join_failed.emit(reason) + +func _on_connection_failed() -> void: + _fail("Couldn't reach the server") + +func _on_server_disconnected() -> void: + var was := state + var reason := _reject_reason + stop() + if was == "connecting": + join_failed.emit(reason if not reason.is_empty() else "The server hung up") + else: + left.emit(reason if not reason.is_empty() else "The server closed the connection") + +func _notification(what: int) -> void: + if what == NOTIFICATION_WM_CLOSE_REQUEST or what == NOTIFICATION_PREDELETE: + if _peer != null: + _peer.close() diff --git a/scripts/net/server_config.gd b/scripts/net/server_config.gd new file mode 100644 index 0000000..37d22fa --- /dev/null +++ b/scripts/net/server_config.gd @@ -0,0 +1,106 @@ +extends RefCounted +## A dedicated server's preferences (`server.json`) and its reserved names +## (`names.json` alongside). Created with defaults on first run and meant to +## be edited by hand; command-line flags override single values for one run. +## See docs/MULTIPLAYER_PLAN.md §4. + +const DEFAULT_PATH := "user://server/server.json" +const DEFAULTS := { + "server_name": "Valheimlike server", + "motd": "Welcome!", + "port": 24242, + "max_players": 8, + "password": "", + "world": "server-world", + "worldlet": "valley", + "seed": 1337, + "pvp": false, + "raiding": false, + "locks": "owner_and_friends", + "death_penalty": "grave", + "sleep": "all_online", + "pause_when_empty": true, + "day_length_minutes": 20, + "wolf_grace_days": 3, + "difficulty": "normal", + "autosave_minutes": 2, + "backups_kept": 10, + "admins": [], + "allowlist": [], + "banned": [], +} +## Settings sent to players when they join (never the password or the lists). +const PUBLIC := ["server_name", "motd", "max_players", "worldlet", "pvp", "raiding", "locks", + "death_penalty", "sleep", "pause_when_empty", "day_length_minutes", "wolf_grace_days", "difficulty"] + +var path := DEFAULT_PATH +var data: Dictionary = {} ## what's in server.json (saved) +var overrides: Dictionary = {} ## command-line flags for this run (never saved) +## Reserved names: lower-case name -> {"name": as typed, "id": player id}. +var names: Dictionary = {} + +static func open(p: String = DEFAULT_PATH) -> RefCounted: + var c = load("res://scripts/net/server_config.gd").new() + c.path = p + DirAccess.make_dir_recursive_absolute(p.get_base_dir()) + var loaded: Variant = JSON.parse_string(FileAccess.get_file_as_string(p)) if FileAccess.file_exists(p) else null + c.data = DEFAULTS.duplicate(true) + if loaded is Dictionary: + for k in loaded: + c.data[k] = loaded[k] + else: + c.save() # first run: write the defaults so they can be edited + var n: Variant = JSON.parse_string(FileAccess.get_file_as_string(c.names_path())) \ + if FileAccess.file_exists(c.names_path()) else null + if n is Dictionary: + c.names = n + return c + +func names_path() -> String: + return path.get_base_dir().path_join("names.json") + +func save() -> void: + _write(path, data) + +func save_names() -> void: + _write(names_path(), names) + +static func _write(p: String, d: Dictionary) -> void: + var tmp := p + ".tmp" + var f := FileAccess.open(tmp, FileAccess.WRITE) + if f == null: + push_error("[server] couldn't write %s" % p) + return + f.store_string(JSON.stringify(d, "\t")) + f.close() + DirAccess.rename_absolute(tmp, p) + +## A setting's value for this run (a flag beats the file). +func v(k: String) -> Variant: + return overrides.get(k, data.get(k)) + +func public_info() -> Dictionary: + var out := {} + for k in PUBLIC: + out[k] = v(k) + return out + +## The player id holding `n`, or "" if it's free. +func name_owner(n: String) -> String: + return String(names.get(n.to_lower(), {}).get("id", "")) + +## Gives `n` to `id`, releasing any other name that id held. +func reserve_name(n: String, id: String) -> void: + for k in names.keys(): + if names[k].id == id and k != n.to_lower(): + names.erase(k) + names[n.to_lower()] = {"name": n, "id": id} + save_names() + +func is_admin(id: String) -> bool: + return id in data.admins + +func add_admin(id: String) -> void: + if not id in data.admins: + data.admins.append(id) + save() diff --git a/scripts/ui/title_screen.gd b/scripts/ui/title_screen.gd index c06db0d..c4fb240 100644 --- a/scripts/ui/title_screen.gd +++ b/scripts/ui/title_screen.gd @@ -11,7 +11,29 @@ var _seed_edit: LineEdit var _list: VBoxContainer var _armed_delete := "" +const Identity = preload("res://scripts/net/identity.gd") +var _after_profile := "main" ## page to return to after saving the profile +var _prof_name: LineEdit +var _prof_msg: Label +var _prof_tag: Label +var _prof_beard: Button +var _prof_tunic: Array = [] +var _prof_hair: Array = [] +var _prof_fig: Dictionary = {} +var _prof_app: Dictionary = {} +var _join_addr: LineEdit +var _join_pw: LineEdit +var _join_msg: Label +var _join_btn: Button +var _lobby_title: Label +var _lobby_motd: Label +var _lobby_rules: Label +var _lobby_count: Label +var _lobby_list: VBoxContainer + func _ready() -> void: + if Net.dedicated: + return # a headless server has no menu (and doesn't need the backdrop world) Saves.current_id = "" get_tree().auto_accept_quit = true get_tree().paused = false @@ -61,8 +83,17 @@ func _ready() -> void: _pages["main"] = _main_page() _pages["new"] = _new_page() _pages["load"] = _load_page() + _pages["profile"] = _profile_page() + _pages["join"] = _join_page() + _pages["lobby"] = _lobby_page() for p in _pages.values(): col.add_child(p) + Net.joined.connect(func(): _show("lobby")) + Net.join_failed.connect(_on_join_failed) + Net.left.connect(_on_left) + Net.players_changed.connect(func(): + if _pages["lobby"].visible: + _fill_lobby()) _show("main") _maybe_capture() @@ -76,7 +107,18 @@ func _maybe_capture() -> void: return await get_tree().create_timer(1.0).timeout print("[title] mouse mode: %s" % ["visible", "hidden", "captured", "confined"][Input.mouse_mode]) - for page in ["main", "new", "load"]: + var me: RefCounted = Net.get_identity() + if me.name.is_empty(): + me.name = "Paul" # in memory only, for the pictures + for page in ["main", "new", "load", "profile", "join", "lobby"]: + if page == "lobby": + # Sample data: the lobby normally needs a live server. + Net.server_info = {"server_name": "Paul's island", "motd": "Welcome! Be nice to the wolves.", + "max_players": 8, "pvp": false, "raiding": false, "difficulty": "normal"} + Net.players = {1: {"name": "Paul", "admin": true, "appearance": {"tunic": 1}}, + 2: {"name": "Freya", "admin": false, "appearance": {"tunic": 2}}, + 3: {"name": "Old Bjorn", "admin": false, "appearance": {"tunic": 3}}} + Net.local_peer = 1 _show(page) await get_tree().create_timer(4.0 if page == "main" else 0.5).timeout await RenderingServer.frame_post_draw @@ -86,12 +128,31 @@ func _maybe_capture() -> void: func _unhandled_input(event: InputEvent) -> void: if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not _pages["main"].visible: - _show("main") + if _pages["lobby"].visible: + Net.leave() + elif Net.state == "connecting": + Net.stop() + _join_msg.text = "" + _show("main") + else: + _show("main") func _show(page: String) -> void: + # Joining needs a name: send people to their profile first. + if page == "join" and Identity.name_problem(Net.get_identity().name) != "": + _after_profile = "join" + page = "profile" for k in _pages: _pages[k].visible = k == page _armed_delete = "" + if page == "profile": + _fill_profile() + elif page == "join": + _join_addr.text = Net.get_identity().last_server + _join_btn.disabled = false + _join_addr.grab_focus() + elif page == "lobby": + _fill_lobby() if page == "load": _fill_list() elif page == "new": @@ -121,9 +182,234 @@ func _main_page() -> Control: var lb := _button("Load World", _show.bind("load")) lb.disabled = worlds.is_empty() v.add_child(lb) + v.add_child(_button("Join Game", _show.bind("join"))) + var n: String = Net.get_identity().name + v.add_child(_button("Profile: %s" % n if not n.is_empty() else "Set Up Your Profile", + func(): _after_profile = "main"; _show("profile"))) v.add_child(_button("Quit", get_tree().quit)) return v +# --- multiplayer: profile, join, lobby ---------------------------------------- + +func _profile_page() -> Control: + var p := UITheme.panel() + var h := HBoxContainer.new() + h.add_theme_constant_override("separation", 22) + p.add_child(h) + var v := VBoxContainer.new() + v.add_theme_constant_override("separation", 8) + h.add_child(v) + v.add_child(UITheme.label("Your profile", 24)) + v.add_child(UITheme.label("Name (shown over your character to other players)", 15, UITheme.TEXT_DIM)) + _prof_name = LineEdit.new() + _prof_name.custom_minimum_size = Vector2(300, 40) + _prof_name.max_length = 16 + _prof_name.text_changed.connect(func(_t): _update_profile_preview()) + _prof_name.text_submitted.connect(func(_t): _save_profile()) + v.add_child(_prof_name) + v.add_child(UITheme.label("Tunic", 15, UITheme.TEXT_DIM)) + v.add_child(_swatch_row(Identity.TUNICS, "tunic", _prof_tunic)) + v.add_child(UITheme.label("Hair", 15, UITheme.TEXT_DIM)) + v.add_child(_swatch_row(Identity.HAIR, "hair", _prof_hair)) + _prof_beard = Button.new() + _prof_beard.toggle_mode = true + _prof_beard.focus_mode = Control.FOCUS_NONE + _prof_beard.custom_minimum_size = Vector2(140, 34) + _prof_beard.add_theme_font_size_override("font_size", 16) + _prof_beard.toggled.connect(func(on): _prof_app.beard = on; _update_profile_preview()) + v.add_child(_prof_beard) + _prof_msg = UITheme.label("", 14, UITheme.TEXT_DIM) + v.add_child(_prof_msg) + var b := HBoxContainer.new() + b.add_theme_constant_override("separation", 10) + b.add_child(_button("Save", _save_profile, 140)) + b.add_child(_button("Back", func(): _show("main"), 120)) + v.add_child(b) + # A little figure: hair, face, beard, tunic. + var fig := VBoxContainer.new() + fig.add_theme_constant_override("separation", 0) + fig.alignment = BoxContainer.ALIGNMENT_CENTER + _prof_tag = UITheme.label("", 15) + _prof_tag.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + fig.add_child(_prof_tag) + _prof_fig = {} + for part in [["hair", Vector2(44, 14)], ["face", Vector2(40, 30)], ["beard", Vector2(34, 12)], ["tunic", Vector2(64, 60)]]: + var c := CenterContainer.new() + var r := ColorRect.new() + r.custom_minimum_size = part[1] + c.add_child(r) + fig.add_child(c) + _prof_fig[part[0]] = r + _prof_fig.face.color = Color(0.86, 0.68, 0.55) + h.add_child(fig) + return p + +func _swatch_row(colors: Array, key: String, store: Array) -> HBoxContainer: + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 6) + for i in range(colors.size()): + var s := Button.new() + s.custom_minimum_size = Vector2(36, 30) + s.focus_mode = Control.FOCUS_NONE + s.pressed.connect(func(): _prof_app[key] = i; _update_profile_preview()) + row.add_child(s) + store.append(s) + return row + +func _fill_profile() -> void: + var me: RefCounted = Net.get_identity() + _prof_name.text = me.name + _prof_app = me.appearance.duplicate() + _prof_beard.set_pressed_no_signal(bool(_prof_app.beard)) + _prof_msg.text = "Pick a name to join servers." if me.name.is_empty() else "" + _update_profile_preview() + _prof_name.grab_focus() + +func _update_profile_preview() -> void: + for pair in [[_prof_tunic, Identity.TUNICS, "tunic"], [_prof_hair, Identity.HAIR, "hair"]]: + for i in range(pair[0].size()): + var sel: bool = int(_prof_app[pair[2]]) == i + var st := UITheme.box(pair[1][i], UITheme.SELECTED if sel else UITheme.SLOT_BORDER, 3 if sel else 1, 4, 0) + for state in ["normal", "hover", "pressed"]: + pair[0][i].add_theme_stylebox_override(state, st) + _prof_beard.text = "Beard: %s" % ("yes" if _prof_app.beard else "no") + var bst := UITheme.box(UITheme.SLOT_BG, UITheme.SELECTED if _prof_app.beard else UITheme.SLOT_BORDER, 2 if _prof_app.beard else 1, 4, 6) + for state in ["normal", "hover", "pressed", "hover_pressed"]: + _prof_beard.add_theme_stylebox_override(state, bst) + var hair: Color = Identity.HAIR[int(_prof_app.hair)] + _prof_fig.hair.color = hair + _prof_fig.beard.color = hair if _prof_app.beard else Color(0, 0, 0, 0) + _prof_fig.tunic.color = Identity.TUNICS[int(_prof_app.tunic)] + var n := _prof_name.text + _prof_tag.text = n if not n.is_empty() else "your name" + var bad := Identity.name_problem(n) + _prof_msg.text = bad if not n.is_empty() else "Pick a name to join servers." + _prof_msg.add_theme_color_override("font_color", UITheme.BAD if not bad.is_empty() and not n.is_empty() else UITheme.TEXT_DIM) + +func _save_profile() -> void: + var bad := Identity.name_problem(_prof_name.text) + if not bad.is_empty(): + _update_profile_preview() + return + var me: RefCounted = Net.get_identity() + me.name = _prof_name.text + me.appearance = Identity.sanitize_appearance(_prof_app) + me.save_profile() + _rebuild_main() + _show(_after_profile) + +func _join_page() -> Control: + var p := UITheme.panel() + var v := VBoxContainer.new() + v.add_theme_constant_override("separation", 8) + p.add_child(v) + v.add_child(UITheme.label("Join a server", 24)) + v.add_child(UITheme.label("Address (host, or host:port; the default port is %d)" % Net.DEFAULT_PORT, 15, UITheme.TEXT_DIM)) + _join_addr = LineEdit.new() + _join_addr.placeholder_text = "e.g. 100.101.102.103 or homeserver:24242" + _join_addr.custom_minimum_size = Vector2(400, 40) + _join_addr.text_submitted.connect(func(_t): _connect()) + v.add_child(_join_addr) + v.add_child(UITheme.label("Password (if the server has one)", 15, UITheme.TEXT_DIM)) + _join_pw = LineEdit.new() + _join_pw.secret = true + _join_pw.custom_minimum_size = Vector2(400, 40) + _join_pw.text_submitted.connect(func(_t): _connect()) + v.add_child(_join_pw) + v.add_child(UITheme.label("On Tailscale, use the server's 100.x address or its machine name.", 13, UITheme.TEXT_DIM)) + _join_msg = UITheme.label("", 15, UITheme.TEXT_DIM) + _join_msg.autowrap_mode = TextServer.AUTOWRAP_WORD + _join_msg.custom_minimum_size = Vector2(400, 0) + v.add_child(_join_msg) + var h := HBoxContainer.new() + h.add_theme_constant_override("separation", 10) + _join_btn = _button("Connect", _connect, 160) + h.add_child(_join_btn) + h.add_child(_button("Back", func(): + Net.stop() + _join_msg.text = "" + _show("main"), 120)) + v.add_child(h) + return p + +func _connect() -> void: + if _join_addr.text.strip_edges().is_empty(): + _join_msg.text = "Enter the server's address" + _join_msg.add_theme_color_override("font_color", UITheme.BAD) + return + _join_msg.text = "Connecting to %s..." % _join_addr.text.strip_edges() + _join_msg.add_theme_color_override("font_color", UITheme.TEXT_DIM) + _join_btn.disabled = true + Net.join(_join_addr.text, _join_pw.text) + +func _on_join_failed(reason: String) -> void: + _join_btn.disabled = false + _join_msg.text = reason + _join_msg.add_theme_color_override("font_color", UITheme.BAD) + +func _on_left(reason: String) -> void: + _show("join") + _join_msg.text = reason + _join_msg.add_theme_color_override("font_color", UITheme.TEXT_DIM) + +func _lobby_page() -> Control: + var p := UITheme.panel() + var v := VBoxContainer.new() + v.add_theme_constant_override("separation", 8) + p.add_child(v) + _lobby_title = UITheme.label("", 24) + v.add_child(_lobby_title) + _lobby_motd = UITheme.label("", 15, UITheme.TEXT_DIM) + _lobby_motd.autowrap_mode = TextServer.AUTOWRAP_WORD + _lobby_motd.custom_minimum_size = Vector2(420, 0) + v.add_child(_lobby_motd) + _lobby_rules = UITheme.label("", 14, UITheme.TEXT_DIM) + v.add_child(_lobby_rules) + _lobby_count = UITheme.label("", 17) + v.add_child(_lobby_count) + _lobby_list = VBoxContainer.new() + _lobby_list.add_theme_constant_override("separation", 4) + v.add_child(_lobby_list) + var note := UITheme.label("Playing together in the world comes next (M2.2-M2.5). For now, this is who's here.", 13, UITheme.TEXT_DIM) + note.autowrap_mode = TextServer.AUTOWRAP_WORD + note.custom_minimum_size = Vector2(420, 0) + v.add_child(note) + v.add_child(_button("Disconnect", Net.leave, 180)) + return p + +func _fill_lobby() -> void: + var s: Dictionary = Net.server_info + _lobby_title.text = String(s.get("server_name", "Server")) + _lobby_motd.text = String(s.get("motd", "")) + var mode := "Co-op" + if s.get("pvp", false): + mode = "Adversarial" if s.get("raiding", false) else "Friendly rivals" + _lobby_rules.text = "%s · PvP %s · raiding %s · %s difficulty" % [mode, "on" if s.get("pvp", false) else "off", + "on" if s.get("raiding", false) else "off", s.get("difficulty", "normal")] + _lobby_count.text = "Players (%d/%d)" % [Net.players.size(), int(s.get("max_players", 0))] + for c in _lobby_list.get_children(): + c.queue_free() + var peers := Net.players.keys() + peers.sort_custom(func(a, b): return String(Net.players[a].name).to_lower() < String(Net.players[b].name).to_lower()) + for peer in peers: + var pl: Dictionary = Net.players[peer] + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 8) + var sw := ColorRect.new() + sw.custom_minimum_size = Vector2(16, 16) + sw.color = Identity.TUNICS[int(pl.get("appearance", {}).get("tunic", 0))] + var c := CenterContainer.new() + c.add_child(sw) + row.add_child(c) + var tags := [] + if peer == Net.local_peer: + tags.append("you") + if pl.get("admin", false): + tags.append("admin") + row.add_child(UITheme.label(String(pl.name) + (" (%s)" % ", ".join(tags) if not tags.is_empty() else ""), 16, + UITheme.SELECTED if peer == Net.local_peer else UITheme.TEXT)) + _lobby_list.add_child(row) + func _new_page() -> Control: var p := UITheme.panel() var v := VBoxContainer.new() diff --git a/tests/net_bot.gd b/tests/net_bot.gd new file mode 100644 index 0000000..eeae3fd --- /dev/null +++ b/tests/net_bot.gd @@ -0,0 +1,57 @@ +extends SceneTree +## A scripted headless player for the network tests (tests/test_net_lobby.gd +## starts several of these as separate processes). +## +## Args (after --): --identity --name --address host:port +## [--password p] [--stay seconds] [--result ] [--forge] [--protocol n] +## Joins, stays a while noting who it saw, leaves, writes the outcome to the +## result file and quits. Gives up after 20 s whatever happens. + +var _outcome := "" +var _reason := "" + +func _initialize() -> void: + _run.call_deferred() + +func _run() -> void: + var net: Node = root.get_node("Net") + var args: Dictionary = net.parse_args(OS.get_cmdline_user_args()) + var Identity = load("res://scripts/net/identity.gd") + var me = Identity.open(String(args.get("identity", "user://test_net/bot"))) + if args.has("name"): + me.name = String(args.name) + net.identity = me + net.debug_forge = args.has("forge") + net.debug_protocol = int(args.get("protocol", 0)) + net.debug_skip_name_check = args.has("raw-name") + net.joined.connect(func(): _outcome = "joined") + net.join_failed.connect(func(r): _outcome = "rejected"; _reason = r) + var res := {"joined": false, "reason": "", "saw": [], "admin": false, "server_name": "", "id": me.player_id} + net.join(String(args.get("address", "127.0.0.1")), String(args.get("password", ""))) + var start := Time.get_ticks_msec() + while _outcome.is_empty() and Time.get_ticks_msec() - start < 20000: + await process_frame + if _outcome == "joined": + res.joined = true + res.server_name = net.server_info.get("server_name", "") + res.admin = bool(net.players.get(net.local_peer, {}).get("admin", false)) + var saw := {} + var stay := float(args.get("stay", 2.0)) + var s := 0.0 + while s < stay and net.state == "connected": + for p in net.players.values(): + saw[p.name] = true + await create_timer(0.1).timeout + s += 0.1 + res.saw = saw.keys() + net.leave() + for _i in range(10): + await process_frame + else: + res.reason = _reason if not _reason.is_empty() else "timed out" + if args.has("result"): + var f := FileAccess.open(String(args.result), FileAccess.WRITE) + f.store_string(JSON.stringify(res)) + f.close() + print("[bot %s] %s" % [me.name, JSON.stringify(res)]) + quit() diff --git a/tests/run_all.sh b/tests/run_all.sh index bdf12a6..3c38909 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -4,7 +4,7 @@ # Usage: tests/run_all.sh (from the repo root; needs `godot` on PATH) cd "$(dirname "$0")/.." || exit 1 status=0 -for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_fishing_diet test_terrain_edges test_boundary; do +for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_fishing_diet test_net_lobby test_terrain_edges test_boundary; do extra="" [ "$t" = "test_boundary" ] && extra="--fixed-fps 30" # A suite that waits on a world that failed to load would hang forever, diff --git a/tests/test_net_lobby.gd b/tests/test_net_lobby.gd new file mode 100644 index 0000000..96c2458 --- /dev/null +++ b/tests/test_net_lobby.gd @@ -0,0 +1,216 @@ +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)