feat(multiplayer): Phase 1 transport, connection, and lobby

Lands tasks 1.0-1.8 of multiplayer-todo.md: the pure-function test runner,
net_codec (wire format quantizers/pack-unpack), NetworkManager (ENet
transport, manual polling, min-RTT clock sync), MatchNet (handshake,
protocol/tick-rate gating, roster with team+ready state), lobby.tscn (team
columns, switch team, ready toggle), server_boot.tscn (headless dedicated
server with structured logging and an overrun watchdog), and main_menu.gd's
Host/Join-by-IP UI (connecting overlay, cancel, bounded failure path).

Followed by an adversarial review (Opus subagent) that found and fixed two
real bugs - an unvalidated player_name broadcast that let one client's
oversized name head-of-line-block the reliable channel for everyone, and a
server-side roster leak across a host/re-host cycle - plus three gaps in
the test suite itself where a claim of "verified" wasn't actually backed
by what the test checked. All five two-process smoke tests plus the
pure-function suite are green with the strengthened assertions in place.
This commit is contained in:
Josh Creek
2026-08-20 08:18:59 +01:00
parent e83bb4fa0c
commit 4533da34e0
30 changed files with 2509 additions and 12 deletions
+4 -1
View File
@@ -53,11 +53,14 @@ Upstream ships telemetry, and there are **two independent switches** — turning
## Commands
There is no build step, linter, or automated test suite for the GDScript project itself — Godot projects run directly from source.
There is no build step or linter for the GDScript project itself — Godot projects run directly from source.
- **Open the project**: open `Game/` as a project in the Godot 4.7 editor, or run `godot --path Game` from the repo root.
- **Run the game**: press Play in the editor, or `godot --path Game res://scenes/main_menu.tscn`.
- **Headless smoke test** (RL/CI precondition — the game must run without rendering): `godot --headless --path Game res://scenes/free_play.tscn`.
- **Unit tests** (pure-function assertions, see `multiplayer-todo.md` task 1.0): `godot --headless --path Game res://tests/test_runner.tscn`. Exits 0/1. Add a test by dropping a `*.gd` file under `Game/tests/cases/` that extends `res://tests/test_case.gd` (path-based `extends`, not the bare `class_name` — see that file for why) with any number of `test_*()` methods; the runner discovers it, no registration needed.
- **Networking smoke tests** (real two-process ENet connect/disconnect, see `multiplayer-todo.md` §7 Phase 1 tasks): each starts a host then a client, each in its own `godot --headless` process, printing `SMOKE PASS/FAIL: ...` and exiting 0/1. Not part of `test_runner.tscn` — a live ENet handshake needs two real processes. `res://tests/net_smoke.tscn` (task 1.2 — `--role=host|client`, now also confirms the host observes `client_disconnected`, not just that each side exits cleanly on its own), `res://tests/match_net_smoke.tscn` (task 1.4 — `--role=host|client|client-badversion|client-longname|host_recycle`; `client-longname` sends an oversized player name and expects rejection, `host_recycle` hosts, lets a client join, leaves, re-hosts, and confirms the roster is actually empty — run a plain `client` role against it), `res://tests/clock_smoke.tscn` (task 1.8 — `--role=host|client`, clock convergence cross-checked against independent OS-wall-clock ground truth, not just self-consistency), `res://tests/lobby_smoke.tscn` (task 1.5 — `--role=host|client`; **both** roles load `lobby.tscn` for real via `change_scene_to_file` now, exercising the server's read-only view as well as the client's interactive one). See `network_manager.gd`'s header comment and `multiplayer-todo.md` §9 gotchas 2530 for the non-obvious Godot/ENet failure modes these caught (`OfflineMultiplayerPeer` sentinel, premature peer teardown, `change_scene_to_file` off the real `current_scene`, unbounded `connection_failed`, the `is_client`-before-actually-connected race, `load()` not returning null on a broken script).
- **`main_menu.tscn`'s Host/Join flow** (task 1.7) is verified the same way but needs a temporary autoload since it's the real main scene, not a wrapper: add `MainMenuTestHooks="*res://tests/main_menu_test_hooks.gd"` to `project.godot [autoload]`, run `godot --headless --path Game res://scenes/main_menu.tscn -- --role=<host|join_ok|join_refused|join_cancel>` (host first, sleep ~1s, then the join role), then remove the autoload line again — it must never ship registered.
- The `mcp/godot-mcp` submodule is a separate Node/TypeScript project with its own `npm install` / `npm run build` (see above) — it is tooling, not part of the game itself.
## Architecture