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:
Paul Bailey
2026-11-04 13:40:27 +00:00
parent 89683e78fd
commit f62f71658f
11 changed files with 1152 additions and 6 deletions

100
scripts/net/identity.gd Normal file
View 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 ""