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
This commit is contained in:
@@ -17,6 +17,13 @@ concrete decisions for this codebase, as it stands after M1.
|
|||||||
- **Co-op or adversarial**, chosen by server rules (§5).
|
- **Co-op or adversarial**, chosen by server rules (§5).
|
||||||
|
|
||||||
## 2. Running a server
|
## 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 <pid>` (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
|
```bash
|
||||||
# Dedicated server (no window, no player)
|
# Dedicated server (no window, no player)
|
||||||
godot --headless --path . -- --server --config server.json
|
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
|
## 3. Players: identity and names
|
||||||
- **Identity:** on first run each game install generates a private key
|
- **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,
|
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
|
so nobody can claim someone else's id. No passwords and no central
|
||||||
accounts.
|
accounts.
|
||||||
@@ -199,7 +208,7 @@ player" or "this player":
|
|||||||
| Phase | Delivers | Done when |
|
| 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.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.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.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 |
|
| **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 |
|
||||||
|
|||||||
@@ -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
|
or port forwarding, player identity and names, server rules for co-op/PvP, and
|
||||||
phases M2.0-M2.7).
|
phases M2.0-M2.7).
|
||||||
- [ ] Refactor state to server-authoritative (single-player becomes loopback server)
|
- [ ] 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
|
- [ ] ENet transport, client prediction for local movement, interpolation for others
|
||||||
- [ ] Replicate resource nodes, inventory, building, health
|
- [ ] Replicate resource nodes, inventory, building, health
|
||||||
- [ ] Persistence: world save/load on the server
|
- [ ] Persistence: world save/load on the server
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ godot --headless --path . --script res://tests/test_terrain_edges.gd
|
|||||||
godot --headless --path . --script res://tests/test_survival_core.gd
|
godot --headless --path . --script res://tests/test_survival_core.gd
|
||||||
# Save/load round trip + world slots (uses user://test_worlds, not your saves)
|
# Save/load round trip + world slots (uses user://test_worlds, not your saves)
|
||||||
godot --headless --path . --script res://tests/test_save_load.gd
|
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
|
# Survival stats: hunger, warmth, fire fuel, cooking, foraging, death + grave
|
||||||
godot --headless --path . --script res://tests/test_survival_stats.gd
|
godot --headless --path . --script res://tests/test_survival_stats.gd
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ config/icon="res://icon.svg"
|
|||||||
ItemDB="*res://scripts/core/item_db.gd"
|
ItemDB="*res://scripts/core/item_db.gd"
|
||||||
Actions="*res://scripts/core/actions.gd"
|
Actions="*res://scripts/core/actions.gd"
|
||||||
Saves="*res://scripts/core/saves.gd"
|
Saves="*res://scripts/core/saves.gd"
|
||||||
|
Net="*res://scripts/net/net.gd"
|
||||||
|
|
||||||
[display]
|
[display]
|
||||||
|
|
||||||
|
|||||||
100
scripts/net/identity.gd
Normal file
100
scripts/net/identity.gd
Normal file
@@ -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 `<dir>/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.
|
||||||
|
## - `<dir>/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 ""
|
||||||
369
scripts/net/net.gd
Normal file
369
scripts/net/net.gd
Normal file
@@ -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 <pid>`: 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 <dir>` 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()
|
||||||
106
scripts/net/server_config.gd
Normal file
106
scripts/net/server_config.gd
Normal file
@@ -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()
|
||||||
@@ -11,7 +11,29 @@ var _seed_edit: LineEdit
|
|||||||
var _list: VBoxContainer
|
var _list: VBoxContainer
|
||||||
var _armed_delete := ""
|
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:
|
func _ready() -> void:
|
||||||
|
if Net.dedicated:
|
||||||
|
return # a headless server has no menu (and doesn't need the backdrop world)
|
||||||
Saves.current_id = ""
|
Saves.current_id = ""
|
||||||
get_tree().auto_accept_quit = true
|
get_tree().auto_accept_quit = true
|
||||||
get_tree().paused = false
|
get_tree().paused = false
|
||||||
@@ -61,8 +83,17 @@ func _ready() -> void:
|
|||||||
_pages["main"] = _main_page()
|
_pages["main"] = _main_page()
|
||||||
_pages["new"] = _new_page()
|
_pages["new"] = _new_page()
|
||||||
_pages["load"] = _load_page()
|
_pages["load"] = _load_page()
|
||||||
|
_pages["profile"] = _profile_page()
|
||||||
|
_pages["join"] = _join_page()
|
||||||
|
_pages["lobby"] = _lobby_page()
|
||||||
for p in _pages.values():
|
for p in _pages.values():
|
||||||
col.add_child(p)
|
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")
|
_show("main")
|
||||||
_maybe_capture()
|
_maybe_capture()
|
||||||
|
|
||||||
@@ -76,7 +107,18 @@ func _maybe_capture() -> void:
|
|||||||
return
|
return
|
||||||
await get_tree().create_timer(1.0).timeout
|
await get_tree().create_timer(1.0).timeout
|
||||||
print("[title] mouse mode: %s" % ["visible", "hidden", "captured", "confined"][Input.mouse_mode])
|
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)
|
_show(page)
|
||||||
await get_tree().create_timer(4.0 if page == "main" else 0.5).timeout
|
await get_tree().create_timer(4.0 if page == "main" else 0.5).timeout
|
||||||
await RenderingServer.frame_post_draw
|
await RenderingServer.frame_post_draw
|
||||||
@@ -86,12 +128,31 @@ func _maybe_capture() -> void:
|
|||||||
|
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not _pages["main"].visible:
|
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not _pages["main"].visible:
|
||||||
|
if _pages["lobby"].visible:
|
||||||
|
Net.leave()
|
||||||
|
elif Net.state == "connecting":
|
||||||
|
Net.stop()
|
||||||
|
_join_msg.text = ""
|
||||||
|
_show("main")
|
||||||
|
else:
|
||||||
_show("main")
|
_show("main")
|
||||||
|
|
||||||
func _show(page: String) -> void:
|
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:
|
for k in _pages:
|
||||||
_pages[k].visible = k == page
|
_pages[k].visible = k == page
|
||||||
_armed_delete = ""
|
_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":
|
if page == "load":
|
||||||
_fill_list()
|
_fill_list()
|
||||||
elif page == "new":
|
elif page == "new":
|
||||||
@@ -121,9 +182,234 @@ func _main_page() -> Control:
|
|||||||
var lb := _button("Load World", _show.bind("load"))
|
var lb := _button("Load World", _show.bind("load"))
|
||||||
lb.disabled = worlds.is_empty()
|
lb.disabled = worlds.is_empty()
|
||||||
v.add_child(lb)
|
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))
|
v.add_child(_button("Quit", get_tree().quit))
|
||||||
return v
|
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:
|
func _new_page() -> Control:
|
||||||
var p := UITheme.panel()
|
var p := UITheme.panel()
|
||||||
var v := VBoxContainer.new()
|
var v := VBoxContainer.new()
|
||||||
|
|||||||
57
tests/net_bot.gd
Normal file
57
tests/net_bot.gd
Normal file
@@ -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 <dir> --name <name> --address host:port
|
||||||
|
## [--password p] [--stay seconds] [--result <json path>] [--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()
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
# Usage: tests/run_all.sh (from the repo root; needs `godot` on PATH)
|
# Usage: tests/run_all.sh (from the repo root; needs `godot` on PATH)
|
||||||
cd "$(dirname "$0")/.." || exit 1
|
cd "$(dirname "$0")/.." || exit 1
|
||||||
status=0
|
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=""
|
extra=""
|
||||||
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
|
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
|
||||||
# A suite that waits on a world that failed to load would hang forever,
|
# A suite that waits on a world that failed to load would hang forever,
|
||||||
|
|||||||
216
tests/test_net_lobby.gd
Normal file
216
tests/test_net_lobby.gd
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user