Merge pull request #30 from jcreek/feat/multiplayer

Feat/multiplayer
This commit is contained in:
Josh Creek
2026-09-06 10:58:50 +01:00
committed by GitHub
367 changed files with 42196 additions and 1569 deletions
+23
View File
@@ -0,0 +1,23 @@
name: Agones Integration
on:
workflow_dispatch:
pull_request:
paths:
- Dockerfile
- Makefile
- deploy/k8s/**
- scripts/verify_kind_agones.sh
- .github/workflows/agones-integration.yml
permissions:
contents: read
jobs:
kind-agones:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Verify disposable kind/Agones lifecycle
run: make verify-kind-agones
+27
View File
@@ -0,0 +1,27 @@
name: Allocated Compose Smoke
on:
workflow_dispatch:
pull_request:
paths:
- Dockerfile
- Makefile
- compose.allocated-smoke.yml
- server/api/**
- server/store/**
- server/workload/**
- server/migrations/**
- scripts/verify_allocated_compose.sh
- .github/workflows/allocated-compose.yml
permissions:
contents: read
jobs:
allocated-compose:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Verify independent allocated Compose flow
run: make verify-allocated-compose
+26
View File
@@ -0,0 +1,26 @@
name: Multiplayer Chaos Recovery
on:
workflow_dispatch:
pull_request:
paths:
- Dockerfile
- Makefile
- compose.chaos-smoke.yml
- server/cmd/maintenance/**
- server/store/**
- server/migrations/**
- scripts/verify_chaos_recovery.sh
- .github/workflows/multiplayer-chaos.yml
permissions:
contents: read
jobs:
api-restart-recovery:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Verify API restart and stalled-allocation recovery
run: make verify-chaos-recovery
+23
View File
@@ -0,0 +1,23 @@
name: Multiplayer API Load
on:
workflow_dispatch:
pull_request:
paths:
- server/api/**
- server/domain/**
- server/matcher/**
- Makefile
- .github/workflows/multiplayer-load.yml
permissions:
contents: read
jobs:
api-load:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Verify 10,000-client API load boundary
run: make verify-multiplayer-load
+47
View File
@@ -0,0 +1,47 @@
# The Go control plane is ~24k lines, and until this workflow existed the only
# Go tests CI ever ran were the two load tests in multiplayer-load.yml. Nothing
# else — domain policy, the wire/store boundaries, the allocator, the Steam
# adapter — gated a change. The Godot unit suite is covered (verify-phase6 runs
# test_runner.tscn as its first step); this closes the equivalent gap on the
# Go side.
#
# Deliberately Docker-free and cluster-free so it stays fast enough to gate
# every push. Tests that need a real PostgreSQL or Redis are behind the
# `integration` build tag and stay with their own scripts; `go vet` is still
# run over that tag so those files cannot rot uncompiled.
name: Server Unit Tests
on:
push:
pull_request:
permissions:
contents: read
jobs:
go-tests:
runs-on: ubuntu-latest
defaults:
run:
working-directory: server
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: server/go.mod
cache-dependency-path: server/go.sum
- name: Build
run: go build ./...
- name: Vet
run: go vet ./...
# Integration-tagged files are excluded from the default build, so
# without this a signature change could leave them broken until someone
# ran the integration scripts by hand.
- name: Vet integration-tagged tests
run: go vet -tags integration ./...
- name: Test
run: go test ./...
# The control plane is concurrent by design: outbox dispatchers, the
# event hub, the matcher worker and the allocator all run in parallel.
- name: Test with race detector
run: go test -race ./...
+18
View File
@@ -0,0 +1,18 @@
name: Supply Chain Policy
on:
push:
pull_request:
permissions:
contents: read
jobs:
repository-policy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Verify immutable image references and secret hygiene
run: make verify-supply-chain
- name: Verify release process is documented
run: test -s docs/SUPPLY-CHAIN.md
+141
View File
@@ -0,0 +1,141 @@
# Investigate and fix the failing Agones Integration CI gate
## Task
`make verify-kind-agones` (workflow `.github/workflows/agones-integration.yml`,
script `scripts/verify_kind_agones.sh`) fails. Find the root cause and fix it so
the gate passes on CI. Repo: `jcreek/CosmicClash`, branch `feat/multiplayer`,
PR #30.
## What is already known — do not re-derive this
**The failure.** `helm upgrade --install agones ... --wait --timeout 5m` fails
with `Error: context deadline exceeded`. Immediately before, Helm reports:
```
resource Deployment/agones-system/agones-controller not ready. status: InProgress, message: Available: 0/1
resource Deployment/agones-system/agones-extensions not ready. status: InProgress, message: Available: 0/1
resource Deployment/agones-system/agones-allocator not ready. status: InProgress, message: Available: 0/1
```
So the cluster is created, the game-server image loads, and the Agones chart
installs — but none of its Deployments become Available inside 5 minutes. The
script never reaches the parts that exercise this repo's own manifests.
**It is pre-existing.** It fails identically at `089c127c`, the branch head
before recent work. It is not caused by the branch's changes. Do not assume a
recent commit broke it.
**It is not architecture-specific.** It fails the same way on GitHub's
`ubuntu-24.04` amd64 runners and on an arm64 macOS developer machine. Agones
1.49.0 publishes both amd64 and arm64 images.
**It is not a Helm kubeVersion rejection.** Agones charts 1.49.0, 1.50.0 and
1.51.0 declare no `kubeVersion` constraint, so Helm is not refusing the
Kubernetes version — the pods are being created and are not becoming ready.
**Ruled out as a red herring:** reproducing locally on a machine with heavy
Docker usage produced `FailedCreatePodSandBox: containerd connection reset`,
which is local resource pressure, not the CI cause. If you see that locally,
clear Docker state and retry rather than chasing it.
**There may be two distinct failures, not one.** After `docker system prune`,
a local run got *past* the Agones install cleanly (controller and allocator
both reached "condition met") and failed later, at:
```
scripts/verify_kind_agones.sh:146
kubectl wait --for=jsonpath='{.status.ready}'=2 fleet/cosmic-clash-game -n cosmic-clash --timeout=5m
error: timed out waiting for the condition on fleets/cosmic-clash-game
```
So locally the Agones install is fine and the **Fleet's game-server pods never
become Ready**; on CI the run never gets that far because the Agones install
itself times out. Treat these as potentially separate problems: fixing the CI
Agones timeout may simply expose the Fleet one underneath. Both need to pass.
The Fleet failure is the more suspicious of the two for recent work, because
`deploy/k8s/base/fleet.yaml` changed: the join-signing key material moved from
a single raw-bytes secret key (`join-signing-key`) to a JSON map
(`join-signing-keys.json`), and the mount's `items[].key` moved with it. The
script's `kubectl create secret` was updated to match and does succeed
(`secret/cosmic-clash-game-server created`), so the obvious mismatch is not
present -- but verify the pod actually mounts and starts rather than assuming.
Note the script's `sed` also strips `--allocated-mode` and the roster path and
blanks `--control-plane-url`, so the game server runs in a reduced mode here;
check whether it is failing for a reason unrelated to the key at all.
## Pinned versions (all in `scripts/verify_kind_agones.sh`)
| Thing | Value | Override |
|---|---|---|
| Agones chart | `1.49.0` | `AGONES_VERSION` |
| kind node image | `kindest/node:v1.33.1` (Kubernetes 1.33) | `KIND_NODE_IMAGE` |
| Cluster | single node, `--wait 120s` | `KIND_CLUSTER_NAME` |
| Runner | `ubuntu-latest` (ubuntu-24.04), 30 min timeout | — |
The chart is installed with `--set agones.controller.replicas=1`,
`agones.extensions.replicas=1`, `agones.allocator.replicas=1`, and
`agones.extensions.resources.{requests,limits}.ephemeral-storage` lowered to
128Mi/512Mi. That ephemeral-storage override already exists because Agones 1.49
otherwise requests 10,100 MiB and will not schedule on a default kind node —
there is a comment saying so. **A similar resource-fit problem for the other
Deployments is a strong hypothesis worth checking first.**
## Diagnostics are already in place
The script now dumps, on any failure and before the cluster is deleted: node
capacity and conditions, pods in `agones-system` and `cosmic-clash`, recent
events per namespace, and describe + current/previous logs for every not-ready
pod. Set `KIND_KEEP_ON_FAILURE=1` to retain the cluster for interactive
inspection instead of deleting it.
Its first run revealed a bug in the diagnostics themselves: a
`kubectl cluster-info` reachability guard suppressed the entire dump. That
guard has been removed, so the dump now always runs on failure.
**Start by reading that output**, either from a CI run or a local run. The most
likely candidates it will distinguish between:
1. **Resource pressure** — `FailedScheduling ... Insufficient cpu/memory/
ephemeral-storage`. Fix by lowering requests for the other Deployments the
way extensions already is, or by giving the kind cluster more capacity.
2. **Version incompatibility** — Agones 1.49 against Kubernetes 1.33. Check
Agones' release notes for its supported Kubernetes range; if 1.33 is outside
it, either raise `agones_version` or lower `kind_node_image`. Confirm the
pairing is one Agones actually tests.
3. **Probe/readiness failure** — pods Running but never Ready. The pod logs and
describe output will show the failing probe.
4. **Image pull** — `ImagePullBackOff` on an Agones image.
## Constraints
- **Do not weaken the gate to make it pass.** Removing `--wait`, extending the
timeout to hide a real failure, or `|| true` around the install are all wrong.
If the cause is genuinely a timeout on slow-but-working startup, raising it
is acceptable *only* with evidence that the pods do become Available, and the
new value should be justified in a comment.
- Keep it a disposable, isolated cluster: it must not touch an existing cluster,
and the EXIT trap must still remove the one it created.
- If you change a pinned version, pin the new one explicitly and say why in the
commit message. Do not float to `latest`.
- `CLAUDE.md` applies: never create co-authored commits, never mention Claude.
## Verification
- `make verify-kind-agones` passes locally (needs Docker, kind, kubectl, Helm).
- The `Agones Integration` workflow passes on PR #30. It is `pull_request`
triggered with path filters on `Dockerfile`, `Makefile`, `deploy/k8s/**`,
`scripts/verify_kind_agones.sh`, and its own workflow file — so a change to
the script will trigger it.
- Do not regress the other seven workflows. `Allocated Compose Smoke` was also
failing and has just been fixed; confirm it stays green.
## Useful context
- `multiplayer-next.md` §7 task 8.49 describes what this gate is meant to prove.
- `deploy/k8s/base/fleet.yaml` is the Fleet the script applies after Agones is
up, with a `sed` that swaps the release digest placeholder for the locally
built image and strips `--allocated-mode` and the roster path (there is no
control plane in this disposable cluster).
- The gate is a prerequisite for issue #17 (standing up a real cluster).
+112 -9
View File
@@ -6,7 +6,7 @@ Important rule: never create co-authored commits. Never mention Claude in commit
## Project overview
Cosmic Clash is an open-source, physics-based "vehicle soccer" game (a spiritual successor to Rocket League) built in Godot 4.7, using space ships instead of cars. It is GDScript/Godot only today — the "C# backend" in README.md was never started, and the dedicated server is an export of this same Godot project. A **separate backend service is now planned** (not started) for casual/ranked matchmaking, which is a 1.0 launch blocker; see `docs/MATCHMAKING.md`. README.md's "MVP is local-only against bots" section is historical: server-authoritative online multiplayer, a headless dedicated server, Docker/CI verification, and an optional Steam transport are all implemented (Phases 16). See `multiplayer-next.md` for what actually remains.
Cosmic Clash is an open-source, physics-based "vehicle soccer" game (a spiritual successor to Rocket League) built in Godot 4.7, using space ships instead of cars. The game and the dedicated server are GDScript/Godot only — the "C# backend" an early README described was never started, and the dedicated server is an export of this same Godot project. There is one component outside the Godot project: a **Go matchmaking control plane** in `server/` for casual/ranked queues, ranked ratings and Agones-based server allocation. It is a 1.0 launch blocker see `docs/MATCHMAKING.md` for the design, `multiplayer-next.md` §0 and §7 for what remains (the allocation-to-connect pipeline is now wired end to end; what is left is external — a Steamworks App ID, custom GodotSteam builds, a registry to publish images to, and a live Agones cluster; `TODO.md` orders them), and `docs/TECH_STACK.md` for why the control plane is Go rather than C#, Rust or C++. README.md's "MVP is local-only against bots" section is historical: server-authoritative online multiplayer, a headless dedicated server, Docker/CI verification, and an optional Steam transport are all implemented (Phases 16). See `multiplayer-next.md` for what actually remains.
Because the gameplay concept (vehicle soccer) can't be copyrighted but specific expression can, all code/art/assets must be original — this is why the project uses Godot instead of Unreal/Unity and ships instead of cars. Keep this in mind when writing code or pulling in assets: don't port or closely mirror Rocket League's actual implementation.
@@ -14,15 +14,17 @@ Because the gameplay concept (vehicle soccer) can't be copyrighted but specific
The prose docs carry far more design rationale than the code comments, and several are load-bearing:
- `multiplayer-next.md`**the current** multiplayer checklist. Short. Read this first for "what's left".
- `multiplayer-todo.md` — 250 KB of historical design decisions, per-task implementation evidence, and §9's numbered "gotchas" list. Code comments cite it constantly by section/task number (`§2.4`, `task 5.10`); when a comment does, that section is the real explanation. Don't add new work here — it's the archive.
- `multiplayer-next.md`**the multiplayer task-tracking document**: outstanding work (§0), a numbered "gotchas" list (§9), and the current task breakdown with checkboxes (§7), all in one file. Start at §0 for "what's left". Day-to-day work is Phase 7 (Steam) and Phase 8 (matchmaking), whose numbered task breakdown and acceptance criteria live in §7, because that is the format tasks are picked up from. Phases 06 are done and archival — their evidence lives in git history, not the current doc.
- `MULTIPLAYER_SPEC.md` — the architecture decisions, wire format, server-side input handling, prediction/reconciliation, latency/frame-rate budget and match lifecycle state machine, as sections 16. Code comments across `Game/scripts/` cite it constantly by section number (`§2.4`, `§4.1`); many still say `multiplayer-next.md §N` for `N` 16 from before this doc was split out — when a comment does, the content is now here, not there. `multiplayer-next.md`'s own §7+ cites `§N` the same way and disambiguates by number (16 → this doc, 7+ → itself).
- `TRAINING.md` — the full RL workflow (training, curriculum generations, export, eval, difficulty tiers).
- `SERVER.md` — dedicated-server build, config, systemd deploy, sizing.
- `STEAM.md` — optional GodotSteam custom-build setup and the transport contract.
- `FLIGHT_MANUAL.md` — the player-facing flight model.
- `docs/MATCHMAKING.md` — casual/ranked queue design. Not implemented; a 1.0 launch blocker, and the reason a backend service now exists in the plan.
- `docs/TECH_STACK.md` — what the project is built with and why.
- `TODO.md` — deferred non-multiplayer work (audio is the big one: there is none at all).
- `docs/MATCHMAKING.md` — casual/ranked queue design, and the locked constraints (Go/PostgreSQL/Redis/Agones) the `server/` module implements. Partially implemented; a 1.0 launch blocker, and the reason a backend service outside the Godot project exists at all.
- `docs/TECH_STACK.md` — what the project is built with and why, including the Go-vs-C#/Rust/C++ rationale for the matchmaking control plane.
- `TODO.md` — deferred non-multiplayer work, **and** the ordered human-actionable backlog: which GitHub issue to do first, what each one unblocks, and which items are waiting on nobody. Start there when asking "what next". Audio is no longer absent — a procedural `AudioManager` covers UI, countdown, impact, goal and engine/turbo cues; what remains is authored assets.
- `docs/THREAT-MODEL.md`, `docs/SUPPLY-CHAIN.md`, `docs/OBSERVABILITY.md`, `docs/MATCHMAKING-SLOs.md`, `docs/ADR-001-matchmaking-platform.md` — the control plane's security, release, telemetry and SLO contracts. `server/security/*.py` asserts several of them against the checked-in manifests, so changing a manifest often means changing one of these.
- `docs/REVIEW-2026-09-feat-multiplayer.md` — a point-in-time adversarial review of this branch. Every finding in it is fixed; it is kept for the reasoning, not as a status report, and its header says so.
## Godot MCP server
@@ -108,7 +110,7 @@ GODOT_BIN=/path/to/godot make verify-enet-integration # non-default Godot b
| `tests/networked_match_smoke.tscn` | see its header | Shorter attended variant of the above. |
| `tests/net_sim_smoke.tscn` | see its header | The `--net-sim-*` latency/loss decorator actually changes observed behaviour. |
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).
See `network_manager.gd`'s header comment and `multiplayer-next.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** 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.
@@ -132,7 +134,62 @@ To run one by hand, and for every config flag, see `SERVER.md`. `--smoke-force-g
### CI
`.github/workflows/` has exactly two jobs, both running the Make targets above: `dedicated-server-smoke.yml` (`make verify-phase6`) and `enet-integration.yml` (`make verify-enet-integration` inside the `enet-test` image). There is no unit-test-only workflow — `verify-phase6` runs `test_runner.tscn` as its first step.
`.github/workflows/` has eight jobs, all but one running a Make target:
| Workflow | Runs | Needs |
|---|---|---|
| `server-unit-tests.yml` | `go build`/`vet`/`vet -tags integration`/`test`/`test -race` in `server/` | nothing (no Docker) |
| `dedicated-server-smoke.yml` | `make verify-phase6` | Docker, several GB |
| `enet-integration.yml` | `make verify-enet-integration` inside the `enet-test` image | Docker |
| `allocated-compose.yml` | `make verify-allocated-compose` | Docker Compose |
| `agones-integration.yml` | `make verify-kind-agones` | kind + Helm |
| `multiplayer-chaos.yml` | `make verify-chaos-recovery` | Docker |
| `multiplayer-load.yml` | `make verify-multiplayer-load` (two `-tags load` Go tests) | — |
| `supply-chain.yml` | `make verify-supply-chain` | — |
**The Godot unit suite runs via `verify-phase6`**, which invokes `test_runner.tscn` as its first step — there is no separate Godot workflow. The Go unit suite has its own workflow because until it existed the only Go tests CI ran were `multiplayer-load`'s two load tests, so ~24k lines of control plane gated nothing.
Note what is *not* in CI: `make verify-multiplayer-local` (the combined local gate, which also runs the Python manifest/contract suites) and the `integration`-tagged Go tests, which need a real PostgreSQL/Redis and live in `scripts/run_*_integration.sh`. Run those by hand before landing server changes.
### When a gate fails, suspect the assertion first
The most expensive failures in this repo have not been broken behaviour. They
have been **assertions that cannot distinguish the two states they implicitly
claim to**, each reporting its own ambiguity as a confident verdict about the
system under test. Five in one session, several costing multiple CI round trips:
| Assertion | What it actually conflated |
|---|---|
| `kubectl wait --for=jsonpath='{.status.ready}'` on an Agones Fleet | field does not exist vs. condition unmet — it could never pass |
| `compose ps --status running \| grep -qx game-server` | not started *yet* vs. exited |
| `remote_residual_position_p99 < 0.3` | real regression vs. host scheduling noise |
| a validator reading `status.gameServer` | Agones' real response vs. an invented one, with unit tests asserting the invention |
| `docker image inspect` guarding a build | image is current vs. image merely exists, so a rerun verified stale code |
Before theorising about the code, ask: **can this check tell "broken" from
"not ready yet", "absent" from "unset", or "regressed" from "slow"?** If not,
that is the bug, whatever else is also true.
Two habits follow from it, and both repeatedly beat reading code:
- **Make the script say what it saw before diagnosing why.** Most gates here are
`curl -fsS` and bare `[[ ]]` under `set -e`, which abort silently — several CI
runs produced nothing but `make: *** Error 1`. Report the failing line and
command, print the value that failed its comparison, and dump the surrounding
state *before* any cleanup trap destroys it. Every root cause found in that
session came from doing this; essentially every confident guess made without
it was wrong.
- **Verify the diagnostics fire.** Two separate dumps were added and neither ran:
one behind a `kubectl cluster-info` guard that misjudged reachability, one
because a bare `trap ... ERR` does not fire inside functions or subshells
without `set -E`. A diagnostic that has never been seen working is not
evidence.
And when a test and the code agree but reality disagrees, suspect they were
written together. A validator and its fixtures both encoded a response shape
Agones never sends; nothing caught it because the gate had never run far enough
to see a real one.
### Other
@@ -148,7 +205,7 @@ The structure was deliberately chosen so an RL-trained AI opponent and, later, m
- **Scene flow**: `scenes/main_menu.tscn` (`main_menu.gd`, one handler per mode) → `free_play.tscn` (practice: no timer, R resets ball), `match.tscn` (150s timer, per-team score, kickoff resets), `spectate.tscn` (bot vs bot exhibition), `settings.tscn`, or — for online — `lobby.tscn``networked_match.tscn`. Esc returns to the menu. Canonical paths live in `scripts/scene_paths.gd`; use those constants rather than string literals.
- **Controller seam (do not bypass)**: `Ship` (`scripts/ship.gd`, `RigidBody3D`) never reads `Input`. Each physics tick, `_integrate_forces` pulls one `ShipAction` (`scripts/ship_action.gd`: thrust `Vector3`, rotation `Vector3`, turbo `bool`, each axis -1..1) from its `ShipController` child (`scripts/ship_controller.gd`, base returns a zero action). `PlayerShipController` reads input actions; `AIShipController` runs an RL policy; `RLShipController` is driven by the training bridge; `LocalNetShipController` wraps another controller to record inputs into the network timeline. A ship with no controller is inert but simulated. The ShipAction shape *is* the RL action space and *is* what the wire format quantises — change it deliberately and everywhere at once.
- **Arena vs game mode**: an arena (`scripts/arena.gd`, group `"arena"`) is a stateless stadium — a setting, an enclosing `Boundary` (instance of `objects/arena_boundary.tscn`), two `Goal` instances, `BallSpawn` and `SpawnsTeam0/1` Marker3Ds — queried via `get_ball_spawn()`/`get_ship_spawns(team)`/`get_goals()`. All arenas are a standard size: they instance the shared `arena_boundary.tscn`, and `scripts/arena_boundary.gd` (`ArenaBoundary`) holds the canonical play-volume constants (`INNER_HALF_X` 18, `INNER_HALF_Z` 27, `INNER_HEIGHT` 18, `GOAL_LINE_Z` = `INNER_HALF_Z`) that field-size logic must derive from instead of restating numbers. Game modes extend `GameMode` (`scripts/game_mode.gd`, group `"game"`): the mode's scene contains an Arena + HUD, and the mode spawns ball/ships/controllers/camera **in code** (`spawn_ship(team, index, controller)` etc.) so ship counts and controller mixes stay flexible.
- **Arena registry**: `scripts/arena_registry.gd` is the single source of truth for the arena list — three settings × floor/elevated goal variants. `"random": true` gates which arenas Match/Spectate/the dedicated server may pick; **elevated-goal variants are Free-Play-only** until a checkpoint trained on `training_elevated.tscn` is promoted, because the current bots cannot score on an elevated goal. `path_for_match(match_index, mode)` is deliberately pure arithmetic so "the server cycles arenas" is unit-testable. `arena_base.tscn` is the scenery-free physical layout the dedicated server loads (clients still render the variant `MatchSim` names).
- **Arena registry**: `scripts/arena_registry.gd` is the single source of truth for the arena list — three settings × floor/elevated goal variants. `"random": true` gates which arenas Match/Spectate/the dedicated server may pick; **elevated-goal variants are Free-Play-only** until a checkpoint trained on `training_elevated.tscn` is promoted, because the current bots cannot score on an elevated goal. `path_for_match(match_index, mode)` is deliberately pure arithmetic so "the server cycles arenas" is unit-testable. **The Go control plane keeps its own copy of the ranked-eligible subset** (`server/domain/ranked.go`'s `rankedArenas`), because ranked arena selection is a server-authoritative decision made before any Godot process exists. That copy is not free to drift: `server/domain/arena_registry_sync_test.go` parses this file and fails if the two disagree in either direction, or if a ranked path has no scene behind it. Editing the arena list therefore means updating `ranked.go` too — the test says so when it fails. `arena_base.tscn` is the scenery-free physical layout the dedicated server loads (clients still render the variant `MatchSim` names).
- **Goals are dumb sensors**: `scripts/goal.gd` (`Area3D`, group `"goal"`, `@export team`) only emits `goal_scored(team)` when a body in group `"ball"` enters; `GameMode` debounces it (`_handle_goal_scored`) and modes decide consequences. Never put scoring/reset logic in the goal.
- **Ship physics**: all movement is force/torque-based (`_integrate_forces`), not kinematic — inputs become world-space forces/torques relative to ship orientation, with manual drag and speed clamps per tick. Physics properties (mass, inertia, friction material) live in `objects/ship.tscn`, not in `_ready` overrides — keep the scene truthful; RL tuning and the client/server parity trace depend on it.
- **Surface pull (wall/ceiling grav-plating)**: `ArenaBoundary.get_surface_pull()` is a wall+ceiling-only proximity force field (the floor stays plain default gravity) that `Ship` and `Ball` (`scripts/ball.gd`) each apply in their own `_integrate_forces` with independently-tuned strength/range, discovered via the `"arena_boundary"` group — enabling wall-rides and ceiling shots with no collision-shape changes.
@@ -184,6 +241,52 @@ Server process: `scenes/server_boot.tscn` (`server_boot.gd`) is the shell — st
Known-insecure, and the reason public hosting is gated: **slot reclaim is keyed by display name**, so anyone who knows a disconnected player's name can take their reserved slot. Verified Steam identity (Phase 7) is the fix. Don't expose a server to strangers before then.
### Matchmaking control plane (`server/`, Go)
The only component outside the Godot project, and roughly a third of the
codebase. Layered so policy is testable without a database and persistence
without a network:
- `domain/` (~3.2k lines) — **pure policy, no I/O**: matcher formation and
rating tolerance, Glicko ratings and tiers, proposal/queue/match state
machines, casual lineup and backfill selection, probe validation, join
authorisations. Most behaviour worth asserting lives here and needs no
fixture. `ranked.go`'s arena list is checked against `arena_registry.gd` (see
Arena registry above).
- `store/` (~5.3k) — PostgreSQL boundaries. Every mutation goes through
`RunSerializable`; contention is expected rather than exceptional, so the
retry budget and jittered backoff there are load-bearing, not decoration.
- `api/` (~2.5k) — HTTP surface and the outbox dispatchers. `Service` is a
struct of optional providers, each nil-guarded into a 503, which is why a
binary can look healthy while a whole feature is unreachable — check what
`cmd/*/main.go` actually assigns before concluding a feature is broken.
- `allocator/`, `supervisor/`, `agones/` — allocation, the Go process that
wraps the exported Godot server in an Agones pod, and the Agones client.
- `matcher/`, `workload/`, `observability/`, `steam/`, `testkit/` — the matcher
worker loop, workload-token signing, metrics, the Steam Web API adapter, and
deterministic offline fakes.
`cmd/` holds seven binaries: `control-plane`, `matcher`, `allocator`,
`maintenance`, `game-server-supervisor`, `migrate`, and `testkit-api`.
**`testkit-api` is test-only** — it injects a fake Steam login that accepts any
ticket, and must never be deployed in place of `control-plane`.
Three things that are easy to get wrong:
- **Integration tests are behind `//go:build integration`** and need a real
PostgreSQL/Redis, so `go test ./...` silently skips them. Run them through
`scripts/run_*_integration.sh`, which start their own disposable containers.
`go vet -tags integration ./...` is worth running too, or those files rot
uncompiled.
- **Config is start-time.** Tier bands, the join-signing key set, Steam
credentials and the probe providers are all read once in `main()`. Changing
them is a rolling restart, not a hot reload — deliberate, and consistent with
how everything else in these binaries is supplied.
- **The wire contract is versioned.** `contracts/v1/openapi.json` and
`state-transitions.json` are asserted by `contracts/v1/test_contracts.py`;
changing a status code or operation ID without updating them breaks generated
clients silently.
### Steam transport
`net_transport.gd` (`NetTransport`) is a deliberately narrow boundary: a transport only *creates a peer*; `NetworkManager` keeps ownership of polling, RPC policy and lifecycle. `enet_transport.gd` and `steam_transport.gd` implement it. `NetworkManager.host()/join()` default to `"enet"`; passing `"steam"` **never falls back** — a missing custom build or failed init returns an error naming the missing prerequisite (`steam_bootstrap.gd` produces those messages). Discovery and server advertisement are intentionally unimplemented until a project-owned App ID exists; the local default is Valve's Spacewar App ID 480, which must never be used to advertise servers or ship.
+80 -5
View File
@@ -1,10 +1,12 @@
# Local-only dedicated-server build and verification image. Pin the Godot
# release family used by project.godot; no image is pushed by this repository.
FROM --platform=linux/amd64 barichello/godot-ci:4.7.1 AS project-imported
# barichello/godot-ci:4.7.1 (linux/amd64), resolved 2026-08-29.
FROM --platform=linux/amd64 barichello/godot-ci@sha256:622e5ca81b54cd8038ecf7de5d157b47efc800d7cf635af2eec18a6aee4bab7e AS project-imported
WORKDIR /workspace
RUN apt-get update \
&& apt-get install -y --no-install-recommends libfontconfig1 \
&& rm -rf /var/lib/apt/lists/*
# The pinned headless Godot image already runs imports without fontconfig.
# Do not refresh its old Ubuntu archive here: its historical keyring rejects
# current Noble signatures, while this source-only import stage needs no OS
# packages at all.
COPY Game /workspace/Game
# `--import` starts the editor, waits for resource import to finish, then
# exits. Do not combine it with `--quit`, which ends the editor after one
@@ -28,7 +30,11 @@ RUN sed -i 's|^run/main_scene=.*$|run/main_scene="res://scenes/server_boot.tscn"
&& mkdir -p /opt/cosmic-clash \
&& godot --headless --path Game --export-release "Linux Dedicated Server" /opt/cosmic-clash/CosmicClashServer.x86_64
FROM --platform=linux/amd64 ubuntu:24.04 AS server
# ubuntu:noble linux/amd64 manifest, resolved 2026-09-03. The prior pin
# carried an obsolete archive keyring and rejected current Noble signatures
# during apt-get update. This remains a digest pin; package verification is
# deliberately not bypassed.
FROM --platform=linux/amd64 ubuntu@sha256:1e0a86e57d247923571b75e0aaf48a1449cf8c543d51fb3e07a4a7d7bfa79316 AS server
RUN apt-get update && apt-get install -y --no-install-recommends libfontconfig1 libgl1 libstdc++6 && rm -rf /var/lib/apt/lists/*
COPY --from=exporter /opt/cosmic-clash/ /opt/cosmic-clash/
COPY deploy/cosmic-clash-server /opt/cosmic-clash/cosmic-clash-server
@@ -41,3 +47,72 @@ ENTRYPOINT ["/opt/cosmic-clash/cosmic-clash-server"]
FROM exporter AS smoke-client
WORKDIR /workspace
ENTRYPOINT ["godot", "--headless", "--path", "Game", "res://tests/export_server_smoke.tscn", "--"]
# Builds the process supervisor (server/supervisor, multiplayer-next.md task
# 8.27/8.28) that wraps the Agones-allocated dedicated server as PID 1.
# golang:1.23-alpine (matches server/go.mod's `go 1.23`), resolved 2026-09-01.
FROM --platform=linux/amd64 golang@sha256:383395b794dffa5b53012a212365d40c8e37109a626ca30d6151c8348d380b5f AS supervisor-build
WORKDIR /workspace/server
COPY server/go.mod server/go.sum ./
RUN go mod download
COPY server/ ./
RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/game-server-supervisor ./cmd/game-server-supervisor
RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/control-plane ./cmd/control-plane
RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/testkit-api ./cmd/testkit-api
RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/matcher ./cmd/matcher
RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/allocator ./cmd/allocator
RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/maintenance ./cmd/maintenance
# Agones-allocated fleet image: the same dedicated-server export as `server`
# (unchanged above; make verify-phase6 exercises that target exactly as
# before), wrapped by the Go supervisor as PID 1 instead of the direct
# launcher script -- required for process-ready/assignment-ready Agones SDK
# calls and control-plane registration (multiplayer-next.md §8.27/§8.28).
# deploy/k8s/base/fleet.yaml invokes this target with the deployment-specific
# supervisor flags and mounts the roster/signing material required by the
# allocated startup path. Workload credentials are delivered through the
# Agones allocation annotation; a projected token volume is not required.
FROM server AS game-server
COPY --from=supervisor-build /opt/cosmic-clash/game-server-supervisor /opt/cosmic-clash/game-server-supervisor
RUN chmod 0755 /opt/cosmic-clash/game-server-supervisor
ENTRYPOINT ["/opt/cosmic-clash/game-server-supervisor"]
# The production control-plane API. deploy/k8s/base/control-plane-deployment.yaml
# has always referenced this image, but nothing built it: cmd/control-plane was
# absent from the Go build stage and no target existed, so the checked-in
# Kubernetes base could not produce its own advertised topology.
#
# This must never be substituted with the testkit-api target below, which
# injects a fake login provider that accepts any ticket string.
FROM server AS control-plane
COPY --from=supervisor-build /opt/cosmic-clash/control-plane /opt/cosmic-clash/control-plane
COPY server/migrations /opt/cosmic-clash/migrations
RUN chmod 0755 /opt/cosmic-clash/control-plane
EXPOSE 8080
ENTRYPOINT ["/opt/cosmic-clash/control-plane"]
# TEST ONLY. Supplies a fake Steam login that accepts any ticket; never deploy
# this in place of the control-plane target above.
FROM server AS testkit-api
COPY --from=supervisor-build /opt/cosmic-clash/testkit-api /opt/cosmic-clash/testkit-api
COPY server/migrations /opt/cosmic-clash/migrations
RUN chmod 0755 /opt/cosmic-clash/testkit-api
ENTRYPOINT ["/opt/cosmic-clash/testkit-api"]
FROM server AS matcher
COPY --from=supervisor-build /opt/cosmic-clash/matcher /opt/cosmic-clash/matcher
COPY server/migrations /opt/cosmic-clash/migrations
RUN chmod 0755 /opt/cosmic-clash/matcher
ENTRYPOINT ["/opt/cosmic-clash/matcher"]
FROM server AS allocator
COPY --from=supervisor-build /opt/cosmic-clash/allocator /opt/cosmic-clash/allocator
COPY server/migrations /opt/cosmic-clash/migrations
RUN chmod 0755 /opt/cosmic-clash/allocator
ENTRYPOINT ["/opt/cosmic-clash/allocator"]
FROM server AS maintenance
COPY --from=supervisor-build /opt/cosmic-clash/maintenance /opt/cosmic-clash/maintenance
COPY server/migrations /opt/cosmic-clash/migrations
RUN chmod 0755 /opt/cosmic-clash/maintenance
ENTRYPOINT ["/opt/cosmic-clash/maintenance"]
+2
View File
@@ -26,6 +26,7 @@ run/main_scene.dedicated_server="res://scenes/server_boot.tscn"
[autoload]
GameSettings="*res://scripts/game_settings.gd"
ControlPlaneClient="*res://scripts/control_plane_client.gd"
VideoSettings="*res://scripts/video_settings.gd"
BackgroundFPS="*res://scripts/background_fps.gd"
PerfOverlay="*res://scripts/perf_overlay.gd"
@@ -34,6 +35,7 @@ NetworkManager="*res://scripts/network_manager.gd"
MatchNet="*res://scripts/match_net.gd"
MatchSim="*res://scripts/match_sim.gd"
NetDebugOverlay="*res://scripts/net_debug_overlay.gd"
AudioManager="*res://scripts/audio_manager.gd"
[display]
+3 -1
View File
@@ -1,6 +1,7 @@
[gd_scene load_steps=2 format=3]
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://scripts/lobby.gd" id="1_lobby"]
[ext_resource type="Theme" path="res://themes/cosmic_clash_theme.tres" id="2_theme"]
[node name="Lobby" type="Control"]
layout_mode = 3
@@ -10,6 +11,7 @@ anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_lobby")
theme = ExtResource("2_theme")
[node name="CenterContainer" type="CenterContainer" parent="."]
layout_mode = 1
+9 -1
View File
@@ -1,6 +1,7 @@
[gd_scene load_steps=2 format=3 uid="uid://bcq14356s3e2i"]
[gd_scene load_steps=3 format=3 uid="uid://bcq14356s3e2i"]
[ext_resource type="Script" path="res://scripts/main_menu.gd" id="1_menu"]
[ext_resource type="Theme" path="res://themes/cosmic_clash_theme.tres" id="2_theme"]
[node name="MainMenu" type="Control"]
layout_mode = 3
@@ -10,6 +11,7 @@ anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_menu")
theme = ExtResource("2_theme")
[node name="CenterContainer" type="CenterContainer" parent="."]
layout_mode = 1
@@ -114,6 +116,11 @@ layout_mode = 2
theme_override_font_sizes/font_size = 13
text = "LAN / direct IP — host a match or join one"
[node name="FindMatchButton" type="Button" parent="CenterContainer/VBoxContainer"]
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
text = "Find Match"
[node name="HostButton" type="Button" parent="CenterContainer/VBoxContainer"]
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
@@ -274,6 +281,7 @@ text = "Cancel"
[connection signal="pressed" from="CenterContainer/VBoxContainer/FreePlayButton" to="." method="_on_free_play_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/MatchButton" to="." method="_on_match_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/FindMatchButton" to="." method="_on_find_match_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/HostButton" to="." method="_on_host_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/JoinRow/JoinButton" to="." method="_on_join_pressed"]
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/JoinRow/JoinAddressEdit" to="." method="_on_join_address_submitted"]
+105
View File
@@ -0,0 +1,105 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://scripts/matchmaking.gd" id="1_matchmaking"]
[ext_resource type="Theme" path="res://themes/cosmic_clash_theme.tres" id="2_theme"]
[node name="Matchmaking" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_matchmaking")
theme = ExtResource("2_theme")
[node name="CenterContainer" type="CenterContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"]
custom_minimum_size = Vector2(480, 0)
layout_mode = 2
theme_override_constants/separation = 12
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_font_sizes/font_size = 40
text = "Find a Match"
horizontal_alignment = 1
[node name="PlaylistDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
[node name="StatusLabel" type="Label" parent="CenterContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_override_font_sizes/font_size = 22
text = "Ready to search"
horizontal_alignment = 1
[node name="DetailLabel" type="Label" parent="CenterContainer/VBoxContainer"]
unique_name_in_owner = true
modulate = Color(1, 1, 1, 0.65)
layout_mode = 2
autowrap_mode = 2
horizontal_alignment = 1
[node name="RankedProfileLabel" type="Label" parent="CenterContainer/VBoxContainer"]
unique_name_in_owner = true
modulate = Color(1, 1, 1, 0.65)
layout_mode = 2
text = "Ranked profile unavailable"
horizontal_alignment = 1
visible = false
[node name="QueueButton" type="Button" parent="CenterContainer/VBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 52)
layout_mode = 2
text = "Search"
[node name="CancelButton" type="Button" parent="CenterContainer/VBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
text = "Cancel Search"
visible = false
[node name="ProposalRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="AcceptButton" type="Button" parent="CenterContainer/VBoxContainer/ProposalRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
size_flags_horizontal = 3
text = "Accept"
visible = false
[node name="DeclineButton" type="Button" parent="CenterContainer/VBoxContainer/ProposalRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
size_flags_horizontal = 3
text = "Decline"
visible = false
[node name="BackButton" type="Button" parent="CenterContainer/VBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
text = "Back"
[connection signal="pressed" from="CenterContainer/VBoxContainer/QueueButton" to="." method="_on_queue_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/CancelButton" to="." method="_on_cancel_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/ProposalRow/AcceptButton" to="." method="_on_accept_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/ProposalRow/DeclineButton" to="." method="_on_decline_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/BackButton" to="." method="_on_back_pressed"]
+3 -1
View File
@@ -1,6 +1,7 @@
[gd_scene load_steps=2 format=3]
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://scripts/settings_menu.gd" id="1_settings"]
[ext_resource type="Theme" path="res://themes/cosmic_clash_theme.tres" id="2_theme"]
[node name="SettingsMenu" type="Control"]
layout_mode = 3
@@ -10,6 +11,7 @@ anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_settings")
theme = ExtResource("2_theme")
[node name="CenterContainer" type="CenterContainer" parent="."]
layout_mode = 1
+152
View File
@@ -0,0 +1,152 @@
class_name AgonesSDK
extends Node
# Dependency-free REST bridge for the Agones sidecar. The Go supervisor owns
# the process-ready probe and /ready transition; this node owns the game
# process's periodic Health pings and terminal Shutdown/annotation calls.
const HEALTH_INTERVAL_SECONDS := 2.0
const REQUEST_TIMEOUT_SECONDS := 2.0
const MAX_ANNOTATION_VALUE_LENGTH := 4096
var _base_url := ""
var _health_timer: Timer = null
var _health_in_flight := false
var _health_started_msec := 0
# Set when start_health() is called before this node is inside the tree, so
# _ready() can arm the timer at the first moment it is legal to do so.
var _health_pending := false
# Health is armed here rather than by the caller. A Timer only ticks while its
# owner is inside the SceneTree, so arming it from a caller that has not yet
# parented this node produces a node that looks configured and never pings —
# which is exactly how every allocated GameServer silently failed its Agones
# health check and was recycled.
func _ready() -> void:
if _health_pending:
_health_pending = false
_arm_health()
func configure_from_environment() -> bool:
var port := OS.get_environment("AGONES_SDK_HTTP_PORT")
if port.is_empty() or not port.is_valid_int() or int(port) < 1 or int(port) > 65535:
return false
_base_url = "http://127.0.0.1:%d" % int(port)
return true
func configure_for_testing(base_url: String) -> bool:
if not base_url.begins_with("http://127.0.0.1:") and not base_url.begins_with("http://localhost:"):
return false
_base_url = base_url.trim_suffix("/")
return true
func is_available() -> bool:
return not _base_url.is_empty()
# Returns whether health pings are running. It is a bool rather than void
# because every way this can fail used to be silent, and a game server that
# believes it is healthy while sending nothing is worse than one that refuses
# to start: Agones recycles the former every ~20 seconds forever.
func start_health() -> bool:
if not is_available():
push_error("AgonesSDK: start_health() before configuration; no health pings will be sent")
return false
if _health_timer != null:
return true
if not is_inside_tree():
# Deferred rather than fatal: the caller may legitimately configure
# before parenting. _ready() arms it. Still reported, because if the
# node is never parented this is the whole failure.
_health_pending = true
push_warning("AgonesSDK: start_health() called outside the tree; deferring until ready")
return false
_arm_health()
return true
func _arm_health() -> void:
if _health_timer != null:
return
_health_timer = Timer.new()
_health_timer.name = "AgonesHealth"
_health_timer.wait_time = HEALTH_INTERVAL_SECONDS
_health_timer.one_shot = false
_health_timer.timeout.connect(_send_health)
add_child(_health_timer)
_health_timer.start()
_send_health()
func health_is_running() -> bool:
return _health_timer != null and is_inside_tree()
func stop_health() -> void:
if _health_timer != null:
_health_timer.stop()
_health_timer.queue_free()
_health_timer = null
func health() -> int:
return await _request(HTTPClient.METHOD_POST, "/health", {})
func mark_ready() -> int:
return await _request(HTTPClient.METHOD_POST, "/ready", {})
func shutdown() -> int:
return await _request(HTTPClient.METHOD_POST, "/shutdown", {})
func set_annotation(key: String, value: String) -> int:
if not annotation_is_valid(key, value):
return 400
return await _request(HTTPClient.METHOD_PUT, "/metadata/annotation", {"key": key, "value": value})
static func annotation_is_valid(key: String, value: String) -> bool:
return not (key.is_empty() or value.is_empty() or value.length() > MAX_ANNOTATION_VALUE_LENGTH or "\n" in key or "\r" in key or "\n" in value or "\r" in value)
func _send_health() -> void:
if not is_available():
return
# The latch stops overlapping requests, but it must never become permanent.
# It is set across an await, and a request that never completes would
# otherwise silence health for the lifetime of the process. HTTPRequest's
# own timeout normally resolves this; the elapsed check is the backstop for
# the case where request_completed never fires at all.
if _health_in_flight:
var stuck_for := Time.get_ticks_msec() - _health_started_msec
if stuck_for < int(REQUEST_TIMEOUT_SECONDS * 2.0 * 1000.0):
return
push_warning("Agones health ping did not complete in %dms; sending another" % stuck_for)
_health_in_flight = true
_health_started_msec = Time.get_ticks_msec()
var status := await health()
_health_in_flight = false
if status < 200 or status >= 300:
push_warning("Agones health ping failed (%d)" % status)
func _request(method: int, path: String, payload: Dictionary) -> int:
if not is_available() or not path.begins_with("/"):
return 408
var request := HTTPRequest.new()
request.timeout = REQUEST_TIMEOUT_SECONDS
add_child(request)
var body := JSON.stringify(payload)
var err := request.request(_base_url + path, PackedStringArray(["Content-Type: application/json"]), method, body)
if err != OK:
request.queue_free()
return 599
var result = await request.request_completed
request.queue_free()
return int(result[1])
+85
View File
@@ -0,0 +1,85 @@
class_name AssignmentState
extends RefCounted
# Verified assignment-ready manifest returned by the control plane. The join
# authorisation is retained in memory only and is never written to the restart
# snapshot; transport installation belongs to the explicit ENet/Steam layer.
var available := false
var match_id := ""
var server_id := ""
var slot := -1
var expires_at := ""
var protocol_version := 0
var transport := ""
var endpoint := ""
var join_authorisation := ""
var error_message := ""
func apply(payload: Dictionary, expected_player_id: String = "") -> bool:
for key in ["match_id", "server_id", "player_id", "slot", "expires_at", "protocol_version", "transport", "endpoint", "join_authorisation"]:
if not payload.has(key):
return _reject("Assignment response is missing " + key)
if not payload["match_id"] is String or not payload["server_id"] is String or not payload["player_id"] is String or not _valid_nonnegative_integer(payload["slot"]) or not payload["expires_at"] is String or not _valid_nonnegative_integer(payload["protocol_version"]) or not payload["transport"] is String or not payload["endpoint"] is String or not payload["join_authorisation"] is String:
return _reject("Assignment response contains invalid types")
var next_match_id := String(payload["match_id"])
var next_server_id := String(payload["server_id"])
var next_transport := String(payload["transport"])
var next_endpoint := String(payload["endpoint"])
var next_player_id := String(payload["player_id"])
var next_expires_at := String(payload["expires_at"])
if not is_valid_expiry_timestamp(next_expires_at):
return _reject("Assignment response contains invalid expiry")
var expiry_unix := Time.get_unix_time_from_datetime_string(next_expires_at)
if not is_valid_opaque_id(next_match_id) or not is_valid_opaque_id(next_server_id) or not is_valid_opaque_id(next_player_id) or (not expected_player_id.is_empty() and next_player_id != expected_player_id) or int(payload["slot"]) < 0 or int(payload["slot"]) > 5 or int(payload["protocol_version"]) < 1 or (next_transport != "enet" and next_transport != "steam_sdr") or not _valid_endpoint(next_endpoint) or expiry_unix <= Time.get_unix_time_from_system() or String(payload["join_authorisation"]).is_empty():
return _reject("Assignment response contains invalid values")
match_id = next_match_id
server_id = next_server_id
slot = int(payload["slot"])
expires_at = next_expires_at
protocol_version = int(payload["protocol_version"])
transport = next_transport
endpoint = next_endpoint
join_authorisation = String(payload["join_authorisation"])
available = true
error_message = ""
return true
static func is_valid_expiry_timestamp(value: String) -> bool:
if value.is_empty():
return false
var timestamp_pattern := RegEx.create_from_string("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$")
return timestamp_pattern.search(value) != null
static func is_valid_opaque_id(value: String) -> bool:
if value.length() < 16 or value.length() > 128:
return false
var resource_pattern := RegEx.create_from_string("^[A-Za-z0-9_-]+$")
return resource_pattern.search(value) != null
static func _valid_endpoint(value: String) -> bool:
if value.is_empty() or value.contains("/") or value.contains("?") or value.contains("#"):
return false
var separator := value.rfind(":")
if separator <= 0 or separator >= value.length() - 1:
return false
var port := value.substr(separator + 1)
return port.is_valid_int() and int(port) >= 1 and int(port) <= 65535
static func _valid_nonnegative_integer(value: Variant) -> bool:
if value is int:
return int(value) >= 0
if value is float:
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
return false
func _reject(reason: String) -> bool:
available = false
error_message = reason
return false
+149
View File
@@ -0,0 +1,149 @@
extends Node
# Dependency-free audio foundation. Authored assets can replace these tones
# later without changing gameplay call sites or the multiplayer event flow.
const SAMPLE_RATE := 44100
const MAX_INTENSITY := 1.0
var enabled := true
var _engine_player: AudioStreamPlayer
var _engine_turbo := false
var _last_wall_scrape_ms := -1000
func bind_tree_buttons(root: Node) -> void:
if root == null:
return
for node in root.find_children("*", "BaseButton", true, false):
bind_button(node as BaseButton)
func bind_button(button: BaseButton) -> void:
if button == null:
return
var callback := Callable(self, "play_ui_click")
if not button.pressed.is_connected(callback):
button.pressed.connect(callback)
func play_ui_click() -> void:
_play_tone(880.0, 0.045, 0.10)
func play_countdown(count: int) -> void:
if count <= 0:
_play_tone(1046.5, 0.12, 0.18)
else:
_play_tone(countdown_frequency(count), 0.08, 0.14)
func play_impact(intensity: float) -> void:
var amount := clamp_intensity(intensity)
if amount <= 0.0:
return
_play_tone(150.0 + 180.0 * amount, 0.06 + 0.08 * amount, 0.08 + 0.18 * amount)
func play_wall_scrape(intensity: float) -> void:
var amount := clamp_intensity(intensity)
if amount <= 0.0:
return
var now_ms := Time.get_ticks_msec()
if now_ms - _last_wall_scrape_ms < 80:
return
_last_wall_scrape_ms = now_ms
_play_tone(110.0 + 90.0 * amount, 0.05 + 0.07 * amount, 0.05 + 0.10 * amount)
func play_goal() -> void:
_play_tone(523.25, 0.22, 0.22)
_play_tone(783.99, 0.30, 0.18)
func set_engine_state(thrust: float, turbo: bool) -> void:
var amount := clamp_intensity(thrust)
var rising_turbo := should_play_turbo_cue(_engine_turbo, turbo, amount)
if not enabled or amount <= 0.01:
stop_engine()
return
if rising_turbo:
_play_tone(260.0, 0.16, 0.16)
_engine_turbo = turbo
if _engine_player == null or not is_instance_valid(_engine_player):
_engine_player = AudioStreamPlayer.new()
_engine_player.stream = _engine_stream()
add_child(_engine_player)
_engine_player.play()
_engine_player.pitch_scale = engine_pitch(amount, turbo)
_engine_player.volume_db = linear_to_db(engine_volume(amount, turbo))
func stop_engine() -> void:
if _engine_player != null and is_instance_valid(_engine_player):
_engine_player.stop()
_engine_turbo = false
static func engine_pitch(thrust: float, turbo: bool) -> float:
var amount := clamp_intensity(thrust)
return 0.75 + amount * 0.55 + (0.30 if turbo and amount > 0.01 else 0.0)
static func engine_volume(thrust: float, turbo: bool) -> float:
var amount := clamp_intensity(thrust)
return clampf(0.015 + amount * 0.045 + (0.025 if turbo and amount > 0.01 else 0.0), 0.0, 0.1)
static func should_play_turbo_cue(previous_turbo: bool, turbo: bool, thrust: float) -> bool:
return turbo and not previous_turbo and clamp_intensity(thrust) > 0.01
static func clamp_intensity(value: float) -> float:
if not is_finite(value):
return 0.0
return clampf(value, 0.0, MAX_INTENSITY)
static func countdown_frequency(count: int) -> float:
return 440.0 + float(clampi(count, 1, 9)) * 55.0
func _play_tone(frequency: float, duration: float, volume: float) -> void:
if not enabled or frequency <= 0.0 or duration <= 0.0 or volume <= 0.0:
return
var stream := AudioStreamWAV.new()
stream.format = AudioStreamWAV.FORMAT_16_BITS
stream.mix_rate = SAMPLE_RATE
stream.stereo = false
stream.data = _tone_data(frequency, duration, volume)
var player := AudioStreamPlayer.new()
player.stream = stream
add_child(player)
player.finished.connect(player.queue_free)
player.play()
func _engine_stream() -> AudioStreamWAV:
var stream := AudioStreamWAV.new()
stream.format = AudioStreamWAV.FORMAT_16_BITS
stream.mix_rate = SAMPLE_RATE
stream.stereo = false
stream.loop_mode = AudioStreamWAV.LOOP_FORWARD
stream.data = _tone_data(92.0, 1.0, 0.65)
stream.loop_end = SAMPLE_RATE
return stream
func _tone_data(frequency: float, duration: float, volume: float) -> PackedByteArray:
var frames := maxi(1, int(duration * SAMPLE_RATE))
var data := PackedByteArray()
data.resize(frames * 2)
for index in frames:
var envelope := minf(1.0, float(index) / 256.0) * minf(1.0, float(frames - index) / 1024.0)
var sample := int(sin(TAU * frequency * float(index) / SAMPLE_RATE) * volume * envelope * 32767.0)
if sample < 0:
sample += 65536
data[index * 2] = sample & 0xff
data[index * 2 + 1] = (sample >> 8) & 0xff
return data
+153
View File
@@ -0,0 +1,153 @@
class_name ConnectionLeaseClient
extends Node
const AssignmentState = preload("res://scripts/assignment_state.gd")
signal reconciliation_failed(reason: String)
const CLAIMED := "claimed"
const UNAVAILABLE := "unavailable"
const REJECTED := "rejected"
var _base_url := ""
var _workload_token := ""
var _match_id := ""
var _server_id := ""
var _pending: Array[Dictionary] = []
var _processing := false
func configure(base_url: String, workload_token: String, match_id: String, server_id: String) -> bool:
base_url = base_url.strip_edges().trim_suffix("/")
workload_token = workload_token.strip_edges()
if not valid_configuration(base_url, workload_token, match_id, server_id):
return false
_base_url = base_url
_workload_token = workload_token
_match_id = match_id
_server_id = server_id
return true
# Admission awaits one bounded request only. If the control plane is down, the
# same process may continue using its local generation and this exact event is
# retained ahead of every later disconnect/reconnect for ordered reconciliation.
func claim(player_id: String, expected_generation: int) -> Dictionary:
if not AssignmentState.is_valid_opaque_id(player_id) or expected_generation < 0:
return {"status": REJECTED}
var event := _connect_event(player_id, expected_generation)
if _processing or not _pending.is_empty():
if expected_generation == 0:
return {"status": REJECTED}
_pending.append(event)
_start_processing()
return {"status": UNAVAILABLE, "generation": expected_generation + 1}
var response := await _send(event, true)
if String(response.get("status", "")) == UNAVAILABLE:
if expected_generation == 0:
return {"status": REJECTED}
_pending.append(event)
_start_processing()
return {"status": UNAVAILABLE, "generation": expected_generation + 1}
return response
func record_disconnect(player_id: String, generation: int) -> void:
if not AssignmentState.is_valid_opaque_id(player_id) or generation < 1:
return
_pending.append(_disconnect_event(player_id, generation))
_start_processing()
func _start_processing() -> void:
if _processing or _pending.is_empty() or not is_inside_tree():
return
_process_pending()
func _process_pending() -> void:
_processing = true
while not _pending.is_empty() and is_inside_tree():
var event := _pending[0]
var response := await _send(event)
var status := String(response.get("status", ""))
if status == CLAIMED:
_pending.pop_front()
continue
if status == REJECTED:
reconciliation_failed.emit("durable connection lease conflict")
_processing = false
return
await get_tree().create_timer(1.0).timeout
_processing = false
func _send(event: Dictionary, allow_recovery := false) -> Dictionary:
var request := HTTPRequest.new()
request.timeout = 1.0
add_child(request)
var operation := String(event["operation"])
var endpoint := "%s/v1/servers/%s/%s" % [_base_url, _server_id.uri_encode(), operation]
var start_error := request.request(endpoint, [
"Authorization: Bearer " + _workload_token,
"Content-Type: application/json",
"Idempotency-Key: " + String(event["key"]),
], HTTPClient.METHOD_POST, JSON.stringify(event["payload"]))
if start_error != OK:
request.queue_free()
return {"status": UNAVAILABLE}
var raw: Array = await request.request_completed
request.queue_free()
return classify_response(operation, int(event["generation"]), int(raw[0]), int(raw[1]), raw[3], allow_recovery)
func _connect_event(player_id: String, expected_generation: int) -> Dictionary:
return {
"operation": "connect",
"generation": expected_generation,
"key": event_key(_match_id, player_id, "connect", expected_generation),
"payload": {"player_id": player_id, "expected_generation": expected_generation},
}
func _disconnect_event(player_id: String, generation: int) -> Dictionary:
return {
"operation": "disconnect",
"generation": generation,
"key": event_key(_match_id, player_id, "disconnect", generation),
"payload": {"player_id": player_id, "generation": generation},
}
static func classify_response(operation: String, generation: int, request_result: int, response_code: int, body: PackedByteArray, allow_recovery := false) -> Dictionary:
if request_result != HTTPRequest.RESULT_SUCCESS or response_code == 0 or response_code == 429 or response_code >= 500:
return {"status": UNAVAILABLE}
if operation == "disconnect" and response_code == 204:
return {"status": CLAIMED, "generation": generation}
if operation == "connect" and response_code == 200:
var decoded = JSON.parse_string(body.get_string_from_utf8())
if decoded is Dictionary and _valid_generation(decoded.get("generation")):
var claimed_generation := int(decoded["generation"])
if claimed_generation == generation + 1 or (allow_recovery and generation == 0 and claimed_generation > 1):
return {"status": CLAIMED, "generation": claimed_generation}
return {"status": REJECTED}
static func _valid_generation(value: Variant) -> bool:
if value is int:
return int(value) >= 1
if value is float:
return is_finite(float(value)) and float(value) >= 1.0 and float(value) == floor(float(value)) and float(value) <= 9007199254740991.0
return false
static func event_key(match_id: String, player_id: String, operation: String, generation: int) -> String:
return "server-lease-" + (match_id + "\n" + player_id + "\n" + operation + "\n" + str(generation)).sha256_text()
static func valid_configuration(base_url: String, workload_token: String, match_id: String, server_id: String) -> bool:
if not (base_url.begins_with("http://") or base_url.begins_with("https://")) or base_url.contains("\n") or base_url.contains("\r") or base_url.contains("?") or base_url.contains("#") or base_url.contains("@"):
return false
if workload_token.is_empty() or workload_token.contains("\n") or workload_token.contains("\r"):
return false
return AssignmentState.is_valid_opaque_id(match_id) and AssignmentState.is_valid_opaque_id(server_id)
+889
View File
@@ -0,0 +1,889 @@
extends Node
# Authenticated HTTP boundary for matchmaking. ENet/Steam carries the match
# itself; this client only handles queue/proposal control-plane state.
signal request_succeeded(operation: String, payload: Dictionary)
signal request_failed(operation: String, http_code: int, detail: String)
signal session_expired()
signal probe_challenge_received(region: String, nonce_base64: String)
signal probe_recorded(region: String, server_rtt_ms: int)
signal session_changed(player_id: String)
signal websocket_event(event: Dictionary)
signal websocket_status_changed(status: String)
signal assignment_connection_started(assignment: AssignmentState)
signal assignment_connection_failed(detail: String)
const DEFAULT_BASE_URL := "http://127.0.0.1:8080"
# Release builds must point at the real control plane rather than a developer's
# loopback. The environment variable is read at startup so the same binary can
# be pointed at a staging or production endpoint without a rebuild.
const BASE_URL_ENV := "COSMIC_CLASH_CONTROL_PLANE_URL"
const PERSIST_PATH := "user://matchmaking_state.cfg"
const AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS := 5.0
var base_url := DEFAULT_BASE_URL
var access_token := ""
var auth_expired := false
var player_id := ""
var session_expires_at := ""
var state: MatchmakingState
var ranked_profile: RankedProfileState
var assignment: AssignmentState
var _request: HTTPRequest
var _operation := ""
var _last_queue_create: Dictionary = {}
var _last_mutation: Dictionary = {}
var _last_mutation_retryable := false
var _websocket: WebSocketPeer
var _websocket_status := "DISCONNECTED"
var _websocket_retry_seconds := 0.0
var _websocket_backoff := 1.0
var _authoritative_recovery_seconds := AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS
var _pending_proposal_id := ""
var _pending_assignment_match_id := ""
var _pending_resync_resource_id := ""
# The final wiring step of the matchmaking pipeline: once state.phase reaches
# ASSIGNED, the client must actually start the game transport. connect_to_assignment()
# already existed with correct validation/signal behavior, but nothing ever
# called it -- a player would sit on "Your match server is ready" forever.
# These two fields defer the connect attempt until the assignment fetch
# (triggered independently, earlier, by ASSIGNMENT_READY) has actually
# completed, and prevent a duplicate/replayed ASSIGNED update from firing a
# second connection attempt for the same match.
var _pending_connect_match_id := ""
var _connect_attempted_match_id := ""
func _ready() -> void:
state = MatchmakingState.new()
ranked_profile = RankedProfileState.new()
assignment = AssignmentState.new()
_load_persisted_state()
state.changed.connect(_persist_state)
_request = HTTPRequest.new()
_request.timeout = 10.0
add_child(_request)
_request.request_completed.connect(_on_request_completed)
state.resync_required.connect(_on_resync_required)
_websocket = WebSocketPeer.new()
assignment_connection_failed.connect(_on_assignment_connection_failed)
NetworkManager.connection_failed.connect(_on_network_connection_failed)
# Covers §8.43's "failed reconnect UX": connect_to_assignment()'s own
# synchronous failures (assignment missing/expired, invalid endpoint,
# NetworkManager.join() erroring immediately) previously only emitted
# assignment_connection_failed -- a signal nothing in the client actually
# listened to. state.phase would stay stuck at ASSIGNED, the UI would keep
# showing "Your match server is ready" forever, and there was no way back to
# a fresh search.
func _on_assignment_connection_failed(detail: String) -> void:
state.fail(detail)
# The likelier real-world failure than the synchronous one above:
# NetworkManager.join() returns OK immediately (the attempt started), but the
# actual ENet handshake fails asynchronously later -- unreachable server,
# refused connection, ENet's own ~5s connect timeout. This is exactly the gap
# main_menu.gd's own _on_connection_failed exists to cover for the direct-join
# flow (see its header comment); nothing covered it for a matchmaking-driven
# connect. Guarded to CONNECTING so this never reacts to an unrelated
# connection_failed, such as one belonging to main_menu.gd's own direct join.
func _on_network_connection_failed() -> void:
if state.phase == MatchmakingState.CONNECTING:
state.fail("Unable to connect to the match server")
func _process(_delta: float) -> void:
if not auth_expired and is_session_expired(session_expires_at):
_expire_session()
if _websocket == null:
return
_websocket.poll()
var ready_state := _websocket.get_ready_state()
if ready_state == WebSocketPeer.STATE_OPEN:
_websocket_retry_seconds = 0.0
_websocket_backoff = 1.0
_set_websocket_status("CONNECTED")
while _websocket.get_available_packet_count() > 0:
_handle_websocket_packet(_websocket.get_packet())
elif ready_state == WebSocketPeer.STATE_CONNECTING:
_set_websocket_status("CONNECTING")
elif ready_state == WebSocketPeer.STATE_CLOSED:
_set_websocket_status("DISCONNECTED")
if not auth_expired and is_valid_access_token(access_token):
_websocket_retry_seconds -= _delta
if _websocket_retry_seconds <= 0.0:
_websocket_retry_seconds = _websocket_backoff
_websocket_backoff = minf(_websocket_backoff * 2.0, 30.0)
connect_event_stream()
if not _pending_proposal_id.is_empty() and _operation.is_empty() and not player_id.is_empty():
var proposal_id := _pending_proposal_id
_pending_proposal_id = ""
recover_proposal(proposal_id)
elif not _pending_assignment_match_id.is_empty() and _operation.is_empty() and not player_id.is_empty():
var match_id := _pending_assignment_match_id
_pending_assignment_match_id = ""
fetch_assignment(match_id)
if not _pending_connect_match_id.is_empty() and _assignment_ready_for(_pending_connect_match_id):
var match_id := _pending_connect_match_id
_pending_connect_match_id = ""
_connect_attempted_match_id = match_id
connect_to_assignment()
_poll_authoritative_recovery(_delta)
# The assignment fetch (triggered independently by ASSIGNMENT_READY, which
# always precedes ASSIGNED) and the ASSIGNED transition that should start the
# transport can arrive in either order. This is the shared readiness check
# both _connect_when_assigned and the deferred _process retry above use.
func _assignment_ready_for(match_id: String) -> bool:
return assignment != null and assignment.available and assignment.match_id == match_id and _assignment_is_fresh(assignment)
# Starts (or defers, if the assignment fetch triggered by the earlier
# ASSIGNMENT_READY event hasn't completed yet) the game transport once the
# ticket-state machine reaches ASSIGNED. connect_to_assignment() itself
# already existed with full validation and failure signalling; nothing ever
# called it, so a player reaching "Your match server is ready" never actually
# connected. _connect_attempted_match_id guards against a duplicate/replayed
# ASSIGNED update firing a second connection attempt for the same match.
func _connect_when_assigned(match_id: String) -> void:
if state.phase != MatchmakingState.ASSIGNED or not is_valid_resource_id(match_id) or match_id == _connect_attempted_match_id:
return
if _assignment_ready_for(match_id):
_connect_attempted_match_id = match_id
connect_to_assignment()
else:
_pending_connect_match_id = match_id
# configured_base_url resolves the endpoint this build should use, preferring
# explicit configuration over the loopback development default.
static func configured_base_url() -> String:
var configured := OS.get_environment(BASE_URL_ENV).strip_edges()
if is_valid_base_url(configured):
return configured
return DEFAULT_BASE_URL
# has_session reports whether matchmaking requests can be made at all. Without
# it every request fails ERR_UNAUTHORIZED at the first guard in _start_request.
func has_session() -> bool:
return not access_token.is_empty() and not is_session_expired(session_expires_at)
func configure(url: String, token: String) -> bool:
var normalized := url.strip_edges().trim_suffix("/")
var normalized_token := token.strip_edges()
if not is_valid_base_url(normalized) or not is_valid_access_token(normalized_token):
return false
base_url = normalized
access_token = normalized_token
session_expires_at = ""
auth_expired = false
if _websocket != null:
connect_event_stream()
return true
func connect_event_stream() -> Error:
if not is_valid_access_token(access_token) or auth_expired or not is_valid_base_url(base_url):
return ERR_UNAUTHORIZED
var socket_url := websocket_url(base_url) + "/v1/events"
_websocket = WebSocketPeer.new()
# Godot 4.7 moved handshake headers onto WebSocketPeer; the second
# connect_to_url argument is TLSOptions, not an HTTP header array. Keep the
# bearer token in the authenticated handshake without putting it in the URL.
_websocket.handshake_headers = PackedStringArray(["Authorization: Bearer " + access_token])
var err := _websocket.connect_to_url(socket_url)
if err != OK:
_set_websocket_status("DISCONNECTED")
return err
_websocket_retry_seconds = 0.0
_set_websocket_status("CONNECTING")
return OK
func disconnect_event_stream() -> void:
if _websocket != null:
_websocket.close()
_websocket_retry_seconds = 0.0
_websocket_backoff = 1.0
_set_websocket_status("DISCONNECTED")
static func websocket_url(url: String) -> String:
if url.begins_with("https://"):
return "wss://" + url.trim_prefix("https://")
if url.begins_with("http://"):
return "ws://" + url.trim_prefix("http://")
return ""
func queue_create(ticket_id: String, playlist: String, client_build: String, protocol_version: int) -> Error:
if not is_valid_resource_id(ticket_id) or (playlist != "casual" and playlist != "ranked") or client_build.is_empty() or protocol_version < 1:
return ERR_INVALID_PARAMETER
if not state.begin_queue(ticket_id, playlist):
return ERR_INVALID_PARAMETER
var key := _idempotency_key("queue")
_last_queue_create = {"ticket_id": ticket_id, "playlist": playlist, "client_build": client_build, "protocol_version": protocol_version, "key": key}
var err := _start_request("queue_create", HTTPClient.METHOD_POST, "/v1/queue", {"ticket_id": ticket_id, "playlist": playlist, "client_build": client_build, "protocol_version": protocol_version}, key)
if err != OK:
state.fail("Could not start matchmaking: %s" % error_string(err))
return err
# Regional latency probing. The backend issues a single-use nonce, the client
# echoes it back with its opaque platform location, and the backend derives the
# round trip from its own timestamps -- no client-measured latency is accepted.
#
# Until a ticket has RTT evidence for at least one region the matcher will not
# consider it (server/domain.validCandidate requires a non-empty map), so this
# has to complete before searching is meaningful.
const PROBE_REGIONS := ["EU", "NA"]
func request_probe_challenge(region: String) -> Error:
if not is_valid_probe_region(region):
return ERR_INVALID_PARAMETER
return _start_request("probe_challenge_" + region, HTTPClient.METHOD_POST, "/v1/probes/%s/challenge" % region, {}, "")
func submit_probe_answer(region: String, nonce_base64: String, opaque_location_base64: String) -> Error:
if not is_valid_probe_region(region) or nonce_base64.is_empty() or opaque_location_base64.is_empty():
return ERR_INVALID_PARAMETER
return _start_request("probe_answer_" + region, HTTPClient.METHOD_POST, "/v1/probes/%s" % region, {
"nonce": nonce_base64,
"opaque_location": opaque_location_base64,
}, "")
static func is_valid_probe_region(region: String) -> bool:
return region == "EU" or region == "NA"
# The platform location is opaque to us by design: the backend treats it as a
# blob and never derives placement from anything the client measured. Without a
# Steam runtime there is nothing to report, so send a stable non-empty marker
# rather than failing the probe -- the RTT is what actually matters and that is
# measured by the backend either way.
static func opaque_location_payload() -> String:
if Engine.has_singleton("Steam"):
var steam := Engine.get_singleton("Steam")
if steam.has_method("getLocalPingLocation"):
var location = steam.call("getLocalPingLocation")
if location is String and not String(location).is_empty():
return Marshalls.utf8_to_base64(String(location))
return Marshalls.utf8_to_base64("no-platform-ping-location")
func login_steam(web_api_ticket: String) -> Error:
if not is_valid_web_api_ticket(web_api_ticket):
return ERR_INVALID_PARAMETER
return _start_request("steam_session", HTTPClient.METHOD_POST, "/v1/session/steam", {"web_api_ticket": web_api_ticket}, "")
func retry_queue_create() -> Error:
if _last_queue_create.is_empty() or not _last_queue_create.has("ticket_id"):
return ERR_INVALID_DATA
var ticket_id := String(_last_queue_create["ticket_id"])
var playlist := String(_last_queue_create["playlist"])
if not state.begin_queue(ticket_id, playlist):
return ERR_INVALID_PARAMETER
var err := _start_request("queue_create", HTTPClient.METHOD_POST, "/v1/queue", {"ticket_id": ticket_id, "playlist": playlist, "client_build": String(_last_queue_create["client_build"]), "protocol_version": int(_last_queue_create["protocol_version"])}, String(_last_queue_create["key"]))
if err != OK:
state.fail("Could not retry matchmaking: %s" % error_string(err))
return err
func can_retry_queue_create() -> bool:
return not _last_queue_create.is_empty() and state.phase == MatchmakingState.FAILED and String(_last_queue_create.get("ticket_id", "")) == state.ticket_id
func retry_last_mutation() -> Error:
if not can_retry_last_mutation():
return ERR_INVALID_DATA
var request := _last_mutation.duplicate(true)
return _start_request(String(request["operation"]), int(request["method"]), String(request["path"]), request["payload"], String(request["key"]), int(request["expected_revision"]))
func can_retry_last_mutation() -> bool:
return _last_mutation_retryable and not _last_mutation.is_empty() and _operation.is_empty() and not auth_expired and is_valid_access_token(access_token)
func recover_queue(ticket_id: String) -> Error:
if not is_valid_resource_id(ticket_id):
return ERR_INVALID_PARAMETER
return _start_request("queue_recover", HTTPClient.METHOD_GET, "/v1/queue/" + ticket_id, {}, "")
func recover_proposal(proposal_id: String) -> Error:
if not is_valid_resource_id(proposal_id):
return ERR_INVALID_PARAMETER
return _start_request("proposal_recover", HTTPClient.METHOD_GET, "/v1/proposals/" + proposal_id, {}, "")
static func resync_target(resource_id: String, ticket_id: String, proposal_id: String, proposal_open: bool) -> String:
if resource_id == ticket_id and not ticket_id.is_empty():
return ticket_id
if resource_id == proposal_id and not proposal_id.is_empty():
return proposal_id if proposal_open else ticket_id
return ""
func fetch_ranked_profile() -> Error:
return _start_request("ranked_profile", HTTPClient.METHOD_GET, "/v1/profile/ranked", {}, "")
func fetch_assignment(match_id: String) -> Error:
if not is_valid_resource_id(match_id) or player_id.is_empty():
return ERR_INVALID_PARAMETER
return _start_request("assignment", HTTPClient.METHOD_GET, "/v1/assignments/" + match_id, {}, "")
# Starts the assigned game transport only after AssignmentState has validated
# the complete player-scoped manifest. The signed authorisation is passed to
# MatchNet's hello RPC, never appended to the endpoint URL or logged. Server
# admission remains authoritative; this method only owns the client-side
# readiness/transport boundary.
func connect_to_assignment() -> Error:
if assignment == null or not assignment.available or not _assignment_is_fresh(assignment):
var unavailable_detail := "Match assignment is unavailable or expired"
assignment_connection_failed.emit(unavailable_detail)
return ERR_UNAUTHORIZED
var endpoint := _split_assignment_endpoint(assignment.endpoint)
if endpoint.is_empty():
var invalid_detail := "Match assignment endpoint is invalid"
assignment_connection_failed.emit(invalid_detail)
return ERR_INVALID_PARAMETER
var transport := NetworkManager.TRANSPORT_ENET if assignment.transport == "enet" else NetworkManager.TRANSPORT_STEAM
MatchNet.join_authorisation = assignment.join_authorisation
state.mark_connecting()
var err := NetworkManager.join(String(endpoint["host"]), int(endpoint["port"]), transport)
if err != OK:
MatchNet.join_authorisation = ""
assignment_connection_failed.emit("Unable to connect to match server")
return err
assignment_connection_started.emit(assignment)
return OK
static func _assignment_is_fresh(value: AssignmentState) -> bool:
if value == null or not AssignmentState.is_valid_expiry_timestamp(value.expires_at):
return false
var expiry := Time.get_unix_time_from_datetime_string(value.expires_at)
return expiry > Time.get_unix_time_from_system()
static func _split_assignment_endpoint(value: String) -> Dictionary:
if not AssignmentState._valid_endpoint(value):
return {}
var separator := value.rfind(":")
return {"host": value.substr(0, separator), "port": int(value.substr(separator + 1))}
func heartbeat(ticket_id: String, expected_revision: int) -> Error:
if not is_valid_resource_id(ticket_id) or expected_revision < 0:
return ERR_INVALID_PARAMETER
return _start_request("queue_heartbeat", HTTPClient.METHOD_POST, "/v1/queue/%s/heartbeat" % ticket_id, {}, _idempotency_key("heartbeat"), expected_revision)
func cancel_queue(ticket_id: String, expected_revision: int) -> Error:
if not is_valid_resource_id(ticket_id) or expected_revision < 0 or not state.can_cancel():
return ERR_INVALID_PARAMETER
return _start_request("queue_cancel", HTTPClient.METHOD_POST, "/v1/queue/%s/cancel" % ticket_id, {}, _idempotency_key("cancel"), expected_revision)
func respond_to_proposal(proposal_id: String, accept: bool, expected_revision: int) -> Error:
if not is_valid_resource_id(proposal_id) or expected_revision < 0:
return ERR_INVALID_PARAMETER
var action := "accept" if accept else "decline"
return _start_request("proposal_" + action, HTTPClient.METHOD_POST, "/v1/proposals/%s/%s" % [proposal_id, action], {}, _idempotency_key("proposal"), expected_revision)
static func is_valid_base_url(url: String) -> bool:
if url.is_empty() or url.contains(" ") or url.contains("\r") or url.contains("\n") or url.contains("?") or url.contains("#") or url.contains("@") or url.ends_with("/"):
return false
return url.begins_with("http://") or url.begins_with("https://")
static func is_valid_web_api_ticket(ticket: String) -> bool:
return not ticket.is_empty() and ticket.length() <= 4096 and not ticket.contains("\r") and not ticket.contains("\n")
static func is_valid_access_token(token: String) -> bool:
var separator := token.find(":")
return separator > 0 and separator < token.length() - 1 and token.length() <= 4096 and not token.contains("\r") and not token.contains("\n")
static func is_session_expired(expires_at: String, now_unix: int = -1) -> bool:
if expires_at.is_empty():
return false
if not is_valid_rfc3339_timestamp(expires_at):
return true
var expiry_unix := Time.get_unix_time_from_datetime_string(expires_at)
if expiry_unix < 0:
return true
var current_unix := now_unix
if current_unix < 0:
current_unix = int(Time.get_unix_time_from_system())
return expiry_unix <= current_unix
static func is_valid_session_response(payload: Dictionary) -> bool:
if not payload.has("expires_at") or not payload["expires_at"] is String:
return false
var expires_at := String(payload["expires_at"])
return is_valid_rfc3339_timestamp(expires_at) and not is_session_expired(expires_at)
static func is_valid_rfc3339_timestamp(value: String) -> bool:
if value.is_empty():
return false
var timestamp_pattern := RegEx.create_from_string("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$")
if timestamp_pattern.search(value) == null:
return false
var year := int(value.substr(0, 4))
var month := int(value.substr(5, 2))
var day := int(value.substr(8, 2))
var hour := int(value.substr(11, 2))
var minute := int(value.substr(14, 2))
var second := int(value.substr(17, 2))
if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59:
return false
var days_in_month := [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
var leap_year := year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
if leap_year:
days_in_month[1] = 29
if day < 1 or day > days_in_month[month - 1]:
return false
var timezone_index := value.find("+", 19)
if timezone_index < 0:
timezone_index = value.find("-", 19)
if timezone_index >= 0:
var offset_hour := int(value.substr(timezone_index + 1, 2))
var offset_minute := int(value.substr(timezone_index + 4, 2))
if offset_hour > 23 or offset_minute > 59:
return false
return true
static func is_valid_resource_id(value: String) -> bool:
if value.length() < 16 or value.length() > 128:
return false
var resource_pattern := RegEx.create_from_string("^[A-Za-z0-9_-]+$")
return resource_pattern.search(value) != null
static func is_retryable_mutation_response(response_code: int) -> bool:
return response_code == 0 or response_code == HTTPClient.RESPONSE_REQUEST_TIMEOUT or response_code == HTTPClient.RESPONSE_TOO_MANY_REQUESTS or response_code >= 500
static func should_recover_queue_after_conflict(operation: String, response_code: int, ticket_id: String) -> bool:
return response_code == HTTPClient.RESPONSE_CONFLICT and operation in ["queue_heartbeat", "queue_cancel"] and not ticket_id.is_empty()
static func normalize_ticket(payload: Dictionary) -> Dictionary:
var result := payload.duplicate(true)
for pair in [["enqueued_at", "enqueued_at_unix"], ["expires_at", "expires_at_unix"]]:
var source_key: String = pair[0]
var target_key: String = pair[1]
if not result.has(source_key):
continue
if not result[source_key] is String or not is_valid_rfc3339_timestamp(String(result[source_key])):
result[target_key] = -1
else:
result[target_key] = Time.get_unix_time_from_datetime_string(String(result[source_key]))
return result
static func normalize_proposal(payload: Dictionary) -> Dictionary:
var result := payload.duplicate(true)
if not result.has("expires_at"):
return result
if not result["expires_at"] is String or not is_valid_rfc3339_timestamp(String(result["expires_at"])):
result["expires_at_unix"] = -1
else:
result["expires_at_unix"] = int(Time.get_unix_time_from_datetime_string(String(result["expires_at"])))
return result
func _start_request(operation: String, method: HTTPClient.Method, path: String, payload: Dictionary, idempotency_key: String, expected_revision: int = -1) -> Error:
if _request == null or not _operation.is_empty() or not is_valid_base_url(base_url):
return ERR_BUSY if not _operation.is_empty() else ERR_UNAUTHORIZED
if operation != "steam_session" and access_token.is_empty():
return ERR_UNAUTHORIZED
if operation != "steam_session" and is_session_expired(session_expires_at):
_expire_session()
return ERR_UNAUTHORIZED
var headers := PackedStringArray(["Accept: application/json"])
if operation != "steam_session":
headers.append("Authorization: Bearer " + access_token)
if not idempotency_key.is_empty():
headers.append("Idempotency-Key: " + idempotency_key)
if expected_revision >= 0:
headers.append("If-Match-Revision: %d" % expected_revision)
var body := "" if payload.is_empty() else JSON.stringify(payload)
_operation = operation
var err := _request.request(base_url + path, headers, method, body)
if err != OK:
_operation = ""
return err
if not idempotency_key.is_empty():
_last_mutation = {"operation": operation, "method": method, "path": path, "payload": payload.duplicate(true), "key": idempotency_key, "expected_revision": expected_revision}
_last_mutation_retryable = false
return OK
func _expire_session() -> void:
if auth_expired:
return
access_token = ""
auth_expired = true
disconnect_event_stream()
state.fail("Session expired; sign in again")
ranked_profile.set_error("Session expired; sign in again")
session_expired.emit()
func _on_request_completed(result: HTTPRequest.Result, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
var operation := _operation
_operation = ""
if result != HTTPRequest.RESULT_SUCCESS:
_last_mutation_retryable = _last_mutation.get("operation", "") == operation
if operation == "ranked_profile":
ranked_profile.set_error("Ranked profile request failed")
elif operation == "queue_create":
state.fail("Control-plane request failed")
elif operation == "queue_recover" or operation == "proposal_recover":
state.set_notice("Could not refresh matchmaking state; retrying")
else:
state.set_notice("Control-plane request failed; retrying is safe")
request_failed.emit(operation, response_code, "network error")
return
var parsed = JSON.parse_string(body.get_string_from_utf8())
if not parsed is Dictionary:
_last_mutation_retryable = _last_mutation.get("operation", "") == operation
if operation == "ranked_profile":
ranked_profile.set_error("Ranked profile returned invalid JSON")
elif operation == "queue_create":
state.fail("Control-plane returned invalid JSON")
elif operation == "queue_recover" or operation == "proposal_recover":
state.set_notice("Could not refresh matchmaking state; retrying")
else:
state.set_notice("Control-plane returned invalid JSON; retrying is safe")
request_failed.emit(operation, response_code, "invalid JSON")
return
if response_code < 200 or response_code >= 300:
_last_mutation_retryable = _last_mutation.get("operation", "") == operation and is_retryable_mutation_response(response_code)
var detail := String(parsed.get("error", "request rejected"))
var recover_proposal_after_conflict := response_code == HTTPClient.RESPONSE_CONFLICT and (operation == "proposal_accept" or operation == "proposal_decline") and not state.proposal_id.is_empty()
var recover_queue_after_conflict := should_recover_queue_after_conflict(operation, response_code, state.ticket_id)
if response_code == HTTPClient.RESPONSE_UNAUTHORIZED:
access_token = ""
auth_expired = true
disconnect_event_stream()
state.fail("Session expired; sign in again")
ranked_profile.set_error("Session expired; sign in again")
session_expired.emit()
elif response_code == HTTPClient.RESPONSE_GONE and operation == "queue_recover":
state.expire("Queue ticket expired")
elif response_code == HTTPClient.RESPONSE_SERVICE_UNAVAILABLE:
state.set_notice("Matchmaking is temporarily unavailable; retrying is safe")
elif response_code == HTTPClient.RESPONSE_UPGRADE_REQUIRED and operation == "queue_create":
# Distinct from the generic queue_create failure below: retrying
# with the same client build can never succeed, so the retry
# offer must not be shown (can_retry_queue_create() checks
# _last_queue_create; clearing it here suppresses "Retry Search").
_last_queue_create = {}
state.fail("Your client is out of date -- please update to continue searching")
elif operation == "ranked_profile":
ranked_profile.set_error(detail)
elif response_code == HTTPClient.RESPONSE_NOT_FOUND and (operation == "queue_recover" or operation == "proposal_recover"):
state.fail("Matchmaking record is no longer available")
elif operation == "queue_create" or operation == "queue_recover" or operation == "proposal_recover":
state.fail(detail)
else:
state.set_notice(detail)
request_failed.emit(operation, response_code, detail)
if recover_proposal_after_conflict:
_pending_resync_resource_id = state.proposal_id
call_deferred("_run_pending_resync")
if recover_queue_after_conflict:
_pending_resync_resource_id = state.ticket_id
call_deferred("_run_pending_resync")
return
var payload: Dictionary = parsed
_last_mutation_retryable = false
if operation == "steam_session":
var returned_token := String(payload.get("access_token", ""))
var returned_player_id := String(payload.get("player_id", ""))
if not is_valid_resource_id(returned_player_id) or not is_valid_access_token(returned_token) or not is_valid_session_response(payload):
request_failed.emit(operation, response_code, "invalid session response")
return
player_id = returned_player_id
access_token = returned_token
auth_expired = false
session_expires_at = String(payload.get("expires_at", ""))
connect_event_stream()
session_changed.emit(player_id)
elif operation == "queue_create" or operation == "queue_recover" or operation == "queue_heartbeat" or operation == "queue_cancel":
if not _valid_queue_response(payload):
state.fail("Queue response contains invalid contract data")
request_failed.emit(operation, response_code, "invalid queue response")
return
if operation == "queue_create":
state.begin_queue(String(payload["ticket_id"]), String(payload.get("playlist", "")))
elif operation.begins_with("proposal_"):
if not _valid_proposal_response(payload):
state.fail("Proposal response contains an invalid proposal identifier")
request_failed.emit(operation, response_code, "invalid proposal identifier")
return
if operation.begins_with("queue_"):
if state.apply_ticket_update(normalize_ticket(payload), operation == "queue_recover"):
_queue_proposal_if_ready(payload)
_queue_assignment_if_ready(payload)
_connect_when_assigned(String(payload.get("match_id", "")))
elif operation.begins_with("proposal_"):
state.apply_proposal_update(normalize_proposal(payload))
elif operation == "ranked_profile":
if not ranked_profile.apply(payload):
request_failed.emit(operation, response_code, ranked_profile.error_message)
return
elif operation == "assignment":
if not assignment.apply(payload, player_id):
request_failed.emit(operation, response_code, assignment.error_message)
return
elif operation.begins_with("probe_challenge_"):
# Answer immediately: the nonce is single-use and short-lived, and the
# interval to this answer is exactly what the backend measures.
var challenge_region := operation.trim_prefix("probe_challenge_")
var nonce := String(payload.get("nonce", ""))
if nonce.is_empty():
request_failed.emit(operation, response_code, "probe challenge did not include a nonce")
return
probe_challenge_received.emit(challenge_region, nonce)
elif operation.begins_with("probe_answer_"):
probe_recorded.emit(operation.trim_prefix("probe_answer_"), int(payload.get("server_rtt_ms", -1)))
request_succeeded.emit(operation, payload)
if not _pending_resync_resource_id.is_empty():
call_deferred("_run_pending_resync")
func _handle_websocket_packet(packet: PackedByteArray) -> void:
var parsed = JSON.parse_string(packet.get_string_from_utf8())
if not parsed is Dictionary or not _valid_websocket_event(parsed):
websocket_status_changed.emit("INVALID_EVENT")
return
var event: Dictionary = parsed
websocket_event.emit(event)
var event_name := String(event["event"])
if event_name == "state_changed":
# Allocation and match lifecycle rows are keyed by match ID, not ticket
# ID. Recover the owner-scoped ticket projection instead of feeding the
# match revision/resource into the ticket reducer. ASSIGNMENT_READY also
# carries the durable lookup key, so the assignment fetch can follow the
# recovery request without depending on a circular assignment_changed
# notification from the assignment GET itself.
if event.has("match_id"):
var match_id := String(event["match_id"])
_on_resync_required(state.ticket_id)
if String(event["state"]) == "ASSIGNMENT_READY":
_pending_assignment_match_id = match_id
return
var update := event.duplicate(true)
update["ticket_id"] = String(event["resource_id"])
if not state.apply_ticket_update(update):
return
elif event_name == "proposal_changed":
var proposal_update := event.duplicate(true)
proposal_update["proposal_id"] = String(event["resource_id"])
if state.prepare_proposal_recovery(String(proposal_update["proposal_id"])):
state.apply_proposal_update(proposal_update)
elif event_name == "assignment_changed":
state.mark_assignment_ready()
_pending_assignment_match_id = String(event["match_id"])
elif event_name == "error":
state.set_notice("Control-plane error: %s" % String(event["code"]))
_on_resync_required(String(event["resource_id"]))
static func _valid_websocket_event(event: Dictionary) -> bool:
if not event.has("event") or not event["event"] is String or String(event["event"]).is_empty():
return false
if not event.has("revision") or not _valid_revision(event["revision"]):
return false
if not event.has("resource_id") or not event["resource_id"] is String or not is_valid_resource_id(String(event["resource_id"])):
return false
if not event.has("occurred_at") or not event["occurred_at"] is String or not is_valid_rfc3339_timestamp(String(event["occurred_at"])):
return false
var event_name := String(event["event"])
if event_name == "assignment_changed":
return event.has("match_id") and event["match_id"] is String and is_valid_resource_id(String(event["match_id"])) and event.has("server_id") and event["server_id"] is String and is_valid_resource_id(String(event["server_id"]))
if event_name == "error":
return event.has("code") and String(event["code"]) in ["REVISION_GAP", "NOT_AUTHORISED", "INVALID_STATE", "RATE_LIMITED"]
if event_name == "state_changed":
if not event.has("state") or String(event["state"]) not in ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"]:
return false
if event.has("match_id"):
return event["match_id"] is String and is_valid_resource_id(String(event["match_id"])) and String(event["match_id"]) == String(event["resource_id"])
return true
if event_name == "proposal_changed":
return event.has("state") and String(event["state"]) in ["OPEN", "ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"]
return false
static func _valid_revision(value: Variant) -> bool:
if value is int:
return int(value) >= 0
if value is float:
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
return false
static func _valid_response_opaque_id(payload: Dictionary, key: String) -> bool:
return payload.has(key) and payload[key] is String and is_valid_resource_id(String(payload[key]))
static func _valid_queue_response(payload: Dictionary) -> bool:
for key in ["ticket_id", "player_id", "playlist", "state", "revision", "enqueued_at", "expires_at"]:
if not payload.has(key):
return false
if not _valid_response_opaque_id(payload, "ticket_id") or not _valid_response_opaque_id(payload, "player_id"):
return false
if not payload["playlist"] is String or not String(payload["playlist"]) in ["casual", "ranked"]:
return false
if not payload["state"] is String or not String(payload["state"]) in ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"]:
return false
if payload.has("match_id"):
if not payload["match_id"] is String or not is_valid_resource_id(String(payload["match_id"])):
return false
if String(payload["state"]) not in ["ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "FAILED", "CANCELLED"]:
return false
if payload.has("proposal_id"):
if not payload["proposal_id"] is String or not is_valid_resource_id(String(payload["proposal_id"])):
return false
if String(payload["state"]) != "PROPOSED":
return false
if payload.has("match_id") and payload.has("proposal_id"):
return false
if not _valid_revision(payload["revision"]):
return false
return payload["enqueued_at"] is String and is_valid_rfc3339_timestamp(String(payload["enqueued_at"])) and payload["expires_at"] is String and is_valid_rfc3339_timestamp(String(payload["expires_at"]))
func _queue_assignment_if_ready(payload: Dictionary) -> void:
if String(payload.get("state", "")) != "ASSIGNMENT_READY":
return
var match_id := String(payload.get("match_id", ""))
if is_valid_resource_id(match_id):
_pending_assignment_match_id = match_id
func _queue_proposal_if_ready(payload: Dictionary) -> void:
if String(payload.get("state", "")) != "PROPOSED":
return
var proposal_id := String(payload.get("proposal_id", ""))
if is_valid_resource_id(proposal_id) and state.prepare_proposal_recovery(proposal_id):
_pending_proposal_id = proposal_id
func _poll_authoritative_recovery(delta: float) -> void:
if auth_expired or not is_valid_access_token(access_token) or state.ticket_id.is_empty() or state.phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.COMPLETED]:
_authoritative_recovery_seconds = AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS
return
_authoritative_recovery_seconds -= maxf(0.0, delta)
if _authoritative_recovery_seconds > 0.0 or not _operation.is_empty():
return
_authoritative_recovery_seconds = AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS
var resource_id := state.proposal_id if state.has_open_proposal() else state.ticket_id
_run_resync(resource_id)
static func _valid_proposal_response(payload: Dictionary) -> bool:
if not _valid_response_opaque_id(payload, "proposal_id") or not payload.has("expires_at") or not payload["expires_at"] is String or not is_valid_rfc3339_timestamp(String(payload["expires_at"])) or not payload.has("participants") or not payload["participants"] is Array:
return false
var participants: Array = payload["participants"]
if participants.size() < 2 or participants.size() > 6:
return false
var seen := {}
for participant in participants:
if not participant is Dictionary:
return false
if not participant.has("player_id") or not participant["player_id"] is String or not is_valid_resource_id(String(participant["player_id"])) or seen.has(String(participant["player_id"])):
return false
if not participant.has("response") or not participant["response"] is String or not String(participant["response"]) in ["PENDING", "ACCEPTED", "DECLINED", "TIMED_OUT"]:
return false
if not participant.has("team") or not participant.has("slot") or not _valid_revision(participant["team"]) or not _valid_revision(participant["slot"]):
return false
var team := int(participant["team"])
var slot := int(participant["slot"])
if team > 1 or slot > 5 or slot / 3 != team:
return false
seen[String(participant["player_id"])] = true
return true
func _on_resync_required(resource_id: String) -> void:
if not _operation.is_empty():
_pending_resync_resource_id = resource_id
return
_run_resync(resource_id)
func _run_pending_resync() -> void:
if not _operation.is_empty() or _pending_resync_resource_id.is_empty():
return
var resource_id := _pending_resync_resource_id
_pending_resync_resource_id = ""
_run_resync(resource_id)
func _run_resync(resource_id: String) -> void:
var target := resync_target(resource_id, state.ticket_id, state.proposal_id, state.has_open_proposal())
if target == state.ticket_id and not state.ticket_id.is_empty():
recover_queue(state.ticket_id)
elif target == state.proposal_id and not state.proposal_id.is_empty():
recover_proposal(state.proposal_id)
func _set_websocket_status(status: String) -> void:
if _websocket_status == status:
return
_websocket_status = status
websocket_status_changed.emit(status)
if status == "CONNECTED":
if not state.ticket_id.is_empty() and state.phase not in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE, MatchmakingState.COMPLETED]:
var resource_id := state.proposal_id if state.has_open_proposal() else state.ticket_id
if _operation.is_empty():
_run_resync(resource_id)
else:
# A reconnect must not lose its authoritative recovery merely because
# the previous mutation has not acknowledged yet. The deferred path
# runs after that request completes and avoids an ERR_BUSY drop.
_pending_resync_resource_id = resource_id
func _idempotency_key(prefix: String) -> String:
return "%s-%s-%s" % [prefix, str(Time.get_ticks_usec()), str(randi())]
func _persist_state(snapshot: Dictionary) -> void:
var config := ConfigFile.new()
config.set_value("matchmaking", "snapshot", JSON.stringify(snapshot))
config.save(PERSIST_PATH)
func _load_persisted_state() -> void:
var config := ConfigFile.new()
if config.load(PERSIST_PATH) != OK:
return
var raw = config.get_value("matchmaking", "snapshot", "")
if not raw is String or String(raw).is_empty():
return
var parsed = JSON.parse_string(String(raw))
if parsed is Dictionary and not state.restore_snapshot(parsed):
state.fail("Saved matchmaking state is invalid")
+2
View File
@@ -136,6 +136,7 @@ func _play_goal_celebration(scoring_team: int, conceding_team: int) -> void:
# real-time presentation delay between episodes.
if DisplayServer.get_name() == "headless" or not is_instance_valid(_camera_rig):
return
AudioManager.play_goal()
var goal_position := Vector3.ZERO
for goal in arena.get_goals():
if goal.team == conceding_team:
@@ -182,6 +183,7 @@ func spawn_camera_rig(target: Ship) -> ShipCameraRig:
var rig: ShipCameraRig = CAMERA_RIG_SCENE.instantiate()
add_child(rig)
_camera_rig = rig
rig.impact_feedback.connect(AudioManager.play_impact)
rig.target = target
# Also wires the scene's static HUD (if any) to the same ship, rather
# than letting it guess via the "ship" group.
+2 -2
View File
@@ -1,7 +1,7 @@
class_name InputJitterBuffer
extends RefCounted
# Per-player server-side input state (multiplayer-todo.md §3, task 3.2).
# Per-player server-side input state (MULTIPLAYER_SPEC.md §3; multiplayer-next.md task 3.2).
# Deliberately a standalone RefCounted with no scene/RPC dependency — same
# reason net_codec.gd and net_interpolator.gd are pure classes — so task
# 3.5's unit tests can drive it with scripted arrival traces with no live
@@ -18,7 +18,7 @@ extends RefCounted
# class's, since only the caller knows the current server tick.
const RING_SIZE := 32
# 500ms at 60Hz (multiplayer-todo.md §3.2's own numbers) — a duration, not a
# 500ms at 60Hz (MULTIPLAYER_SPEC.md §3.2's own numbers) — a duration, not a
# tick-rate-derived constant, so left as a literal rather than pulling in
# SimConstants for one number.
const STARVE_ZERO_TICKS := 30
+1 -1
View File
@@ -1,7 +1,7 @@
class_name InputLeadController
extends RefCounted
# Client-owned input_lead control loop (multiplayer-todo.md §3.3, task 3.3).
# Client-owned input_lead control loop (MULTIPLAYER_SPEC.md §3.3; multiplayer-next.md task 3.3).
# Standalone RefCounted, same reason as input_jitter_buffer.gd — scene-free
# so it's directly unit-testable against scripted depth traces.
#
+13 -1
View File
@@ -9,7 +9,7 @@ extends Control
# of something else: change_scene_to_file() operates on
# get_tree().current_scene, and _on_disconnected_from_server()/_leave()
# below call it themselves, which hangs if this scene isn't actually the
# tree's current_scene when that happens (see multiplayer-todo.md §9
# tree's current_scene when that happens (see multiplayer-next.md §9
# gotcha 27 — found the hard way while building tests/lobby_smoke.gd).
@onready var _status_label: Label = %StatusLabel
@@ -19,6 +19,7 @@ extends Control
@onready var _switch_team_button: Button = %SwitchTeamButton
@onready var _ready_button: CheckButton = %ReadyButton
@onready var _leave_button: Button = %LeaveButton
var _planned_server_shutdown := false
func _ready() -> void:
@@ -27,6 +28,7 @@ func _ready() -> void:
MatchNet.player_left.connect(_on_roster_changed)
MatchNet.player_state_changed.connect(_on_roster_changed)
MatchNet.rejected.connect(_on_rejected)
MatchNet.server_shutdown.connect(_on_server_shutdown)
NetworkManager.disconnected_from_server.connect(_on_disconnected_from_server)
# The server process is never a roster member (§1.1 decision 2) — it
@@ -34,6 +36,9 @@ func _ready() -> void:
_controls_row.visible = NetworkManager.is_client
_refresh()
if not MatchNet.last_server_shutdown_reason.is_empty():
_planned_server_shutdown = true
_status_label.text = "Server closed: %s" % MatchNet.last_server_shutdown_reason
func _process(_delta: float) -> void:
@@ -61,7 +66,14 @@ func _on_rejected(reason: String) -> void:
_status_label.text = "Connection rejected: %s" % reason
func _on_server_shutdown(reason: String) -> void:
_planned_server_shutdown = true
_status_label.text = "Server closed: %s" % reason
func _on_disconnected_from_server() -> void:
if _planned_server_shutdown:
return
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
+12 -1
View File
@@ -3,7 +3,7 @@ extends RefCounted
const NetBodyState = preload("res://scripts/net_body_state.gd")
# Client-owned local-ship prediction history (multiplayer-todo.md §4.3).
# Client-owned local-ship prediction history (MULTIPLAYER_SPEC.md §4.3).
# This is deliberately independent of NetworkedMatch and the scene tree so
# sequence/ring behaviour can be tested from scripted traces. Each entry is
# tagged with its full sequence number: an old value in a wrapped slot is
@@ -73,6 +73,7 @@ const RING_SIZE := 128
var _ring_seq: PackedInt32Array = PackedInt32Array()
var _ring_entry: Array = []
var _has_recorded := false
var _first_recorded_seq := -1
var newest_recorded_seq := -1
var last_acknowledged_seq := 0
@@ -94,6 +95,7 @@ func begin_epoch() -> void:
_ring_seq[i] = -1
_ring_entry[i] = null
_has_recorded = false
_first_recorded_seq = -1
newest_recorded_seq = -1
last_acknowledged_seq = 0
resync_required = false
@@ -106,6 +108,8 @@ func begin_epoch() -> void:
func record(seq: int, action: ShipAction, state: NetBodyState, contact_window: bool = false) -> bool:
var overflowed_now := false
if not _has_recorded or seq > newest_recorded_seq:
if not _has_recorded:
_first_recorded_seq = seq
if seq - last_acknowledged_seq > RING_SIZE:
# Only the LEADING edge of an episode counts: resync_required is
# still true for every subsequent tick of the same stall, and
@@ -140,6 +144,8 @@ func record(seq: int, action: ShipAction, state: NetBodyState, contact_window: b
func record_unsimulated(seq: int, action: ShipAction) -> bool:
var overflowed_now := false
if not _has_recorded or seq > newest_recorded_seq:
if not _has_recorded:
_first_recorded_seq = seq
if seq - last_acknowledged_seq > RING_SIZE:
overflowed_now = not resync_required
resync_required = true
@@ -289,6 +295,11 @@ func compare_authoritative(seq: int, authoritative: NetBodyState) -> Dictionary:
func _missing_status(seq: int) -> String:
# The server starts its acknowledgement clock at sequence 0, while the
# first local post-step prediction is normally sequence 1. This is a normal
# startup boundary, not a lost ring entry and must not trigger a hard snap.
if not _has_recorded or seq < _first_recorded_seq:
return "warmup_not_recorded"
if _has_recorded and seq <= newest_recorded_seq - RING_SIZE:
return "missing_evicted"
return "missing_not_recorded"
+5
View File
@@ -37,6 +37,7 @@ const DIFFICULTIES := [
func _ready() -> void:
AudioManager.bind_tree_buttons(self)
# An idle menu has no reason to render past the display's own refresh
# rate; gameplay scenes are uncapped again by _leave_to_gameplay below.
var refresh_rate := DisplayServer.screen_get_refresh_rate()
@@ -190,6 +191,10 @@ func _on_host_pressed() -> void:
_leave_to_lobby()
func _on_find_match_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/matchmaking.tscn")
func _on_join_pressed() -> void:
_start_join()
+2
View File
@@ -122,6 +122,7 @@ func _run_kickoff_countdown() -> void:
_set_frozen(true)
for count in range(KICKOFF_COUNTDOWN_SECONDS, 0, -1):
kickoff_countdown.emit(count)
AudioManager.play_countdown(count)
# process_always=false: if full-time fires mid-countdown (see
# _on_match_timer_timeout's get_tree().paused = true), this stalls
# harmlessly in lockstep with the pause instead of ticking a
@@ -132,6 +133,7 @@ func _run_kickoff_countdown() -> void:
if _match_over:
return
kickoff_countdown.emit(0)
AudioManager.play_countdown(0)
_set_frozen(false)
+395 -10
View File
@@ -1,7 +1,7 @@
extends Node
# Autoload (project.godot [autoload] MatchNet). Handshake + roster layer on
# top of NetworkManager's raw transport (§2.5, §1.3 of multiplayer-todo.md).
# top of NetworkManager's raw transport (§2.5, §1.3 of MULTIPLAYER_SPEC.md).
# hello/welcome, strict protocol_version and physics_ticks_per_second
# gating, player_joined/player_left, and — since lobby.tscn (task 1.5) needs
# somewhere durable to keep it across the lobby→match scene transition —
@@ -11,14 +11,19 @@ extends Node
const NetCodec = preload("res://scripts/net_codec.gd")
const SimConstants = preload("res://scripts/sim_constants.gd")
const AssignmentState = preload("res://scripts/assignment_state.gd")
signal player_joined(peer_id: int, player_name: String)
signal player_left(peer_id: int)
signal player_state_changed(peer_id: int, team: int, ready: bool)
signal rejected(reason: String) # client-side only: the server refused our hello
signal welcomed() # client-side only: our hello was accepted
signal server_shutdown(reason: String) # client-side notification before planned close
signal result_submission_accepted
signal result_submission_retrying(http_code: int)
const TEAM_COUNT := 2
const RECONNECT_GRACE_SECONDS := 60.0
# player_name is the one client-supplied value in _hello that gets broadcast
# verbatim to every other peer (protocol_version/tick_hz are checked, never
@@ -36,18 +41,40 @@ const MAX_PLAYER_NAME_LENGTH := 24
class PlayerInfo:
var peer_id: int
var player_name: String
var player_identity: String
var team: int = 0
var spawn_index: int = -1
var ready: bool = false
func _init(p_peer_id: int, p_player_name: String, p_team: int = 0, p_ready: bool = false) -> void:
func _init(p_peer_id: int, p_player_name: String, p_team: int = 0, p_ready: bool = false, p_player_identity: String = "") -> void:
peer_id = p_peer_id
player_name = p_player_name
player_identity = p_player_identity
team = p_team
ready = p_ready
var roster: Dictionary = {} # peer_id (int) -> PlayerInfo. Never contains peer 1 (the server; §1.1 decision 2 — dedicated servers are never a player).
var local_player_name := "Player"
var last_server_shutdown_reason := ""
# Set by the assignment connection path. Direct-IP/community-server joins keep
# this empty for backwards compatibility; allocated matches carry the opaque
# signed authorisation in hello rather than putting it in the endpoint URL.
var join_authorisation := ""
var require_join_authorisation := false
var admissions_open := true
var _allowed_join_authorisations: Dictionary = {}
var _active_join_peers: Dictionary = {} # opaque authorisation -> peer_id
var _join_history: Dictionary = {} # token -> {generation, lost_at}
var _join_authorisation_context: Dictionary = {}
# Key ID -> raw HMAC key. A set rather than a single key so a signing-key
# rotation does not invalidate authorisations already issued for in-flight
# matches: the allocator signs with the new key while servers still accept
# both, and the old key is dropped once no live match can reference it.
var _join_signing_keys := {}
var _connection_lease_claim := Callable()
var _connection_lease_disconnect := Callable()
var _result_submit := Callable()
# Test hook (tests/match_net_smoke.gd): set false before connecting to
# suppress the automatic real hello, so a test can send a deliberately
@@ -64,8 +91,9 @@ func _ready() -> void:
func _on_connected_to_server() -> void:
roster.clear()
last_server_shutdown_reason = ""
if _auto_hello:
_hello.rpc_id(1, NetCodec.PROTOCOL_VERSION, SimConstants.TICK_HZ, local_player_name)
_hello.rpc_id(1, NetCodec.PROTOCOL_VERSION, SimConstants.TICK_HZ, local_player_name, join_authorisation)
func _on_disconnected_from_server() -> void:
@@ -81,6 +109,76 @@ func _on_disconnected_from_server() -> void:
# the same process.
func _on_shutting_down() -> void:
roster.clear()
_allowed_join_authorisations.clear()
_active_join_peers.clear()
_join_history.clear()
_join_authorisation_context.clear()
_join_signing_keys = {}
_connection_lease_claim = Callable()
_connection_lease_disconnect = Callable()
_result_submit = Callable()
require_join_authorisation = false
admissions_open = true
# signing_keys maps key ID to raw key bytes. An empty dictionary disables
# signature verification, which is only valid for local/direct-hosted play.
func configure_join_authorisations(tokens: Array, context: Dictionary, signing_keys: Dictionary = {}) -> bool:
var allowed := {}
for token in tokens:
if not token is String or String(token).is_empty():
return false
allowed[String(token)] = true
if allowed.is_empty() or not context.has("match_id") or not context["match_id"] is String or String(context["match_id"]).is_empty() or not context.has("server_id") or not context["server_id"] is String or String(context["server_id"]).is_empty() or not context.has("protocol_version") or not _valid_integer_claim(context["protocol_version"]) or int(context["protocol_version"]) < 1:
return false
_allowed_join_authorisations = allowed
_join_authorisation_context = context.duplicate(true)
_join_signing_keys = {}
for key_id in signing_keys:
var raw = signing_keys[key_id]
if not raw is PackedByteArray or PackedByteArray(raw).is_empty():
return false
_join_signing_keys[str(key_id)] = PackedByteArray(raw).duplicate()
require_join_authorisation = true
return true
func assigned_player_slots() -> Array:
var result: Array = []
var seen_identities := {}
var seen_slots := {}
for token in _allowed_join_authorisations.keys():
var claims := _join_claims(String(token))
if claims.is_empty():
return []
var identity := str(claims.get("PlayerID", ""))
var team := int(claims.get("Team", -1))
var slot := int(claims.get("Slot", -1))
if identity.is_empty() or team < 0 or team >= TEAM_COUNT or slot < 0 or slot > 5 or slot / 3 != team or seen_identities.has(identity) or seen_slots.has(slot):
return []
seen_identities[identity] = true
seen_slots[slot] = true
result.append({
"player_identity": identity,
"team": team,
"slot": slot,
})
result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return int(a["slot"]) < int(b["slot"]))
return result
func player_identity(peer_id: int) -> String:
if not roster.has(peer_id):
return ""
return String((roster[peer_id] as PlayerInfo).player_identity)
static func reservation_identity_matches(slot_identity: String, incoming_identity: String, slot_name: String, incoming_name: String) -> bool:
# Authenticated allocations must never fall back to a client-chosen display
# name. The name fallback exists only for direct, unauthenticated servers.
if not slot_identity.is_empty() or not incoming_identity.is_empty():
return not slot_identity.is_empty() and slot_identity == incoming_identity
return slot_name == incoming_name
# Server only: a raw ENet disconnect (crash, timeout) that never sent a
@@ -90,10 +188,24 @@ func _on_shutting_down() -> void:
func _on_peer_disconnected(peer_id: int) -> void:
if not multiplayer.is_server():
return
_cleanup_disconnected_peer(peer_id)
func _cleanup_disconnected_peer(peer_id: int) -> void:
NetworkManager.invalidate_peer(peer_id)
_remove_player(peer_id)
func _remove_player(peer_id: int) -> void:
for token in _active_join_peers.keys():
if int(_active_join_peers[token]) == peer_id:
_active_join_peers.erase(token)
var history: Dictionary = _join_history.get(token, {})
history["lost_at"] = Time.get_unix_time_from_system()
_join_history[token] = history
if _connection_lease_disconnect.is_valid():
_connection_lease_disconnect.call(_join_identity(token), int(history.get("generation", 0)))
break
if not roster.has(peer_id):
return
roster.erase(peer_id)
@@ -120,6 +232,7 @@ func _remove_player(peer_id: int) -> void:
# from inside signal-handling: by then poll() has fully returned, every
# disconnect event in this batch has been dispatched, and get_peers()
# reflects the settled, genuinely-still-connected set.
if is_inside_tree():
call_deferred("_broadcast_player_left", peer_id)
@@ -129,6 +242,30 @@ func _broadcast_player_left(peer_id: int) -> void:
_player_left.rpc_id(other_peer_id, peer_id)
func broadcast_server_shutdown(reason: String) -> void:
if not multiplayer.is_server():
return
var safe_reason := _sanitize_shutdown_reason(reason)
for peer_id in multiplayer.get_peers():
_server_shutdown.rpc_id(peer_id, safe_reason)
static func _sanitize_shutdown_reason(raw: String) -> String:
var clean := ""
for c in raw:
var code := c.unicode_at(0)
if code >= 0x20 and code != 0x7F:
clean += c
clean = clean.strip_edges()
if clean.length() > 96:
clean = clean.substr(0, 96)
return clean if not clean.is_empty() else "server_shutdown"
static func admission_rejection(is_open: bool) -> String:
return "" if is_open else "server is draining"
# Balances a new joiner onto whichever team currently has fewer players
# (ties go to team 0). Server only.
func _pick_balanced_team() -> int:
@@ -145,12 +282,16 @@ func _pick_balanced_team() -> int:
@rpc("any_peer", "call_remote", "reliable")
func _hello(protocol_version: int, tick_hz: int, player_name: String) -> void:
func _hello(protocol_version: int, tick_hz: int, player_name: String, supplied_join_authorisation: String = "") -> void:
if not multiplayer.is_server():
return
var peer_id := multiplayer.get_remote_sender_id()
if roster.has(peer_id):
return # duplicate hello from an already-accepted peer; ignore
var admission_error := admission_rejection(admissions_open)
if not admission_error.is_empty():
await _reject(peer_id, admission_error)
return
if protocol_version != NetCodec.PROTOCOL_VERSION:
await _reject(peer_id, "protocol version mismatch: server=%d client=%d" % [NetCodec.PROTOCOL_VERSION, protocol_version])
@@ -158,10 +299,26 @@ func _hello(protocol_version: int, tick_hz: int, player_name: String) -> void:
if tick_hz != SimConstants.TICK_HZ:
await _reject(peer_id, "physics tick rate mismatch: server=%d client=%d" % [SimConstants.TICK_HZ, tick_hz])
return
if require_join_authorisation and not _valid_join_authorisation(supplied_join_authorisation):
await _reject(peer_id, "join authorisation rejected")
return
if require_join_authorisation and _active_join_peers.has(supplied_join_authorisation):
await _reject(peer_id, "join authorisation already in use")
return
var join_generation := 1
if require_join_authorisation:
join_generation = await _claim_join_authorisation(supplied_join_authorisation, peer_id)
if join_generation < 0:
await _reject(peer_id, "join authorisation lease rejected")
return
if player_name.length() > MAX_INPUT_LENGTH:
await _reject(peer_id, "player name too long")
return
var clean_name := _sanitize_player_name(player_name)
var identity := _join_identity(supplied_join_authorisation) if require_join_authorisation else clean_name
if identity.is_empty():
await _reject(peer_id, "join authorisation rejected")
return
# Tell the new peer about everyone already here before anyone is told
# about them, so no client ever observes an unknown peer_id in a
@@ -171,12 +328,222 @@ func _hello(protocol_version: int, tick_hz: int, player_name: String) -> void:
_player_joined.rpc_id(peer_id, existing_id, existing.player_name, existing.team, existing.ready)
var team := _pick_balanced_team()
roster[peer_id] = PlayerInfo.new(peer_id, clean_name, team, false)
var spawn_index := -1
if require_join_authorisation:
var claims := _join_claims(supplied_join_authorisation)
var assigned_slot := int(claims.get("Slot", -1))
var assigned_team := int(claims.get("Team", -1))
if assigned_slot < 0 or assigned_slot > 5 or assigned_team < 0 or assigned_team >= TEAM_COUNT or assigned_slot / 3 != assigned_team:
await _reject(peer_id, "join authorisation rejected")
return
team = assigned_team
spawn_index = assigned_slot % 3
var info := PlayerInfo.new(peer_id, clean_name, team, false, identity)
info.spawn_index = spawn_index
roster[peer_id] = info
if require_join_authorisation:
# _reserve_join_authorisation already owns the active peer reservation;
# keeping the generation in the history makes fencing auditable without
# exposing it to the client.
_join_history[supplied_join_authorisation]["generation"] = join_generation
player_joined.emit(peer_id, clean_name) # local: the broadcast below is call_remote, never loops back to the server itself
_welcome.rpc_id(peer_id)
_player_joined.rpc(peer_id, clean_name, team, false) # broadcast, includes the new peer itself
func _valid_join_authorisation(token: String) -> bool:
if token.is_empty() or not _allowed_join_authorisations.has(token):
return false
var standard_token := token.replace("-", "+").replace("_", "/")
while standard_token.length() % 4 != 0:
standard_token += "="
var decoded := Marshalls.base64_to_raw(standard_token)
if decoded.is_empty():
return false
var envelope = JSON.parse_string(decoded.get_string_from_utf8())
if not envelope is Dictionary or not envelope.has("Authorisation") or not envelope.has("Signature") or str(envelope["Signature"]).is_empty():
return false
var claims = envelope["Authorisation"]
if not claims is Dictionary:
return false
for string_claim in ["MatchID", "ServerID", "PlayerID", "SteamID", "Protocol", "ExpiresAt"]:
if not claims.has(string_claim) or not claims[string_claim] is String or String(claims[string_claim]).is_empty():
return false
for integer_claim in ["Slot", "Team", "Generation"]:
if not claims.has(integer_claim) or not _valid_integer_claim(claims[integer_claim]):
return false
if not envelope["Signature"] is String or String(envelope["Signature"]).is_empty():
return false
var claimed_team := int(claims.get("Team", -1))
var claimed_slot := int(claims.get("Slot", -1))
if claimed_team < 0 or claimed_team >= TEAM_COUNT or claimed_slot < 0 or claimed_slot > 5 or claimed_slot / 3 != claimed_team:
return false
var protocol := str(claims.get("Protocol", ""))
var expires_at := str(claims.get("ExpiresAt", ""))
if not AssignmentState.is_valid_expiry_timestamp(expires_at):
return false
var expiry := Time.get_unix_time_from_datetime_string(expires_at)
if not _join_signing_keys.is_empty():
var signature_token := str(envelope["Signature"])
var signature := Marshalls.base64_to_raw(signature_token)
if signature.size() != 32:
return false
# The key ID selects which of the currently-valid keys signed this
# authorisation, so the allocator can rotate without invalidating
# authorisations already issued for in-flight matches. It is part of
# the signed bytes below, so pointing it at a different key simply
# fails verification rather than choosing a weaker key.
var key_id := str(claims.get("KeyID", ""))
if not _join_signing_keys.has(key_id):
return false
var signing_key: PackedByteArray = _join_signing_keys[key_id]
if signing_key.is_empty():
return false
var canonical := PackedByteArray()
# Must stay byte-identical to server/domain/join_auth.go's
# JoinAuthorisationBytes; the two change together or every join fails.
var fields := [
str(claims.get("MatchID", "")), str(claims.get("ServerID", "")),
str(claims.get("PlayerID", "")), str(claims.get("SteamID", "")),
str(int(claims.get("Slot", -1))), str(int(claims.get("Team", -1))), protocol,
str(int(claims.get("Generation", 0))), expires_at, key_id,
]
for index in fields.size():
canonical.append_array(String(fields[index]).to_utf8_buffer())
if index < fields.size() - 1:
canonical.append(0)
var hmac := HMACContext.new()
hmac.start(HashingContext.HASH_SHA256, signing_key)
hmac.update(canonical)
if hmac.finish() != signature:
return false
return str(claims.get("MatchID", "")) == str(_join_authorisation_context.get("match_id", "")) \
and str(claims.get("ServerID", "")) == str(_join_authorisation_context.get("server_id", "")) \
and protocol == str(_join_authorisation_context.get("protocol", "")) \
and int(claims.get("Slot", -1)) >= 0 and int(claims.get("Slot", -1)) <= 5 \
and expiry > Time.get_unix_time_from_system()
static func _valid_integer_claim(value: Variant) -> bool:
if value is int:
return int(value) >= 0
if value is float:
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
return false
func _join_identity(token: String) -> String:
if token.is_empty():
return ""
var standard_token := token.replace("-", "+").replace("_", "/")
while standard_token.length() % 4 != 0:
standard_token += "="
var decoded := Marshalls.base64_to_raw(standard_token)
if decoded.is_empty():
return ""
var envelope = JSON.parse_string(decoded.get_string_from_utf8())
if not envelope is Dictionary or not envelope.has("Authorisation") or not envelope["Authorisation"] is Dictionary:
return ""
return str(envelope["Authorisation"].get("PlayerID", ""))
func _join_claims(token: String) -> Dictionary:
if token.is_empty():
return {}
var standard_token := token.replace("-", "+").replace("_", "/")
while standard_token.length() % 4 != 0:
standard_token += "="
var decoded := Marshalls.base64_to_raw(standard_token)
if decoded.is_empty():
return {}
var envelope = JSON.parse_string(decoded.get_string_from_utf8())
if not envelope is Dictionary or not envelope.has("Authorisation") or not envelope["Authorisation"] is Dictionary:
return {}
return envelope["Authorisation"]
func is_join_authorisation_active(token: String) -> bool:
return not token.is_empty() and _active_join_peers.has(token)
func configure_connection_lease_callbacks(claim: Callable, disconnect: Callable) -> void:
_connection_lease_claim = claim
_connection_lease_disconnect = disconnect
func configure_result_submission(callback: Callable) -> void:
_result_submit = callback
func submit_authoritative_result(score: Dictionary, integrity_state := "CERTIFIED") -> bool:
if not _result_submit.is_valid() or not score.has(0) or not score.has(1):
return false
_result_submit.call(int(score[0]), int(score[1]), integrity_state)
return true
func _claim_join_authorisation(token: String, peer_id: int) -> int:
var expected_generation := _available_join_generation(token)
if expected_generation < 0:
return -1
var generation := expected_generation + 1
if _connection_lease_claim.is_valid():
var response = await _connection_lease_claim.call(_join_identity(token), expected_generation)
generation = lease_claim_generation(response, expected_generation)
if generation < 0:
return -1
# The await above deliberately allows one bounded control-plane request.
# Re-evaluate every local fact that can change during that suspension before
# publishing the reservation. If a durable claim succeeded, close it again.
# A concurrent same-token hello can receive the same idempotent claim; its
# loser must not close the generation now owned by the local winner.
if _active_join_peers.has(token):
return -1
if not admissions_open or not _valid_join_authorisation(token) or peer_id not in multiplayer.get_peers():
if _connection_lease_disconnect.is_valid():
_connection_lease_disconnect.call(_join_identity(token), generation)
return -1
_join_history[token] = {"generation": generation, "lost_at": 0.0}
_active_join_peers[token] = peer_id
return generation
func _available_join_generation(token: String) -> int:
if token.is_empty() or _active_join_peers.has(token):
return -1
var now := Time.get_unix_time_from_system()
var history: Dictionary = _join_history.get(token, {})
var lost_at := float(history.get("lost_at", 0.0))
if lost_at > 0.0 and (now < lost_at or now - lost_at > RECONNECT_GRACE_SECONDS):
return -1
return int(history.get("generation", 0))
static func lease_claim_generation(response, expected_generation: int) -> int:
if not response is Dictionary or expected_generation < 0:
return -1
var status := String(response.get("status", ""))
if status not in ["claimed", "unavailable"] or not response.get("generation") is int:
return -1
var generation := int(response["generation"])
if status == "unavailable":
return generation if expected_generation > 0 and generation == expected_generation + 1 else -1
# A durable backend may return a later generation only to a fresh process
# recovering an already-disconnected lease. Locally known generations never
# skip, and outage fallback never invents a jump.
return generation if generation == expected_generation + 1 or (expected_generation == 0 and generation > 1) else -1
func _reserve_join_authorisation(token: String, peer_id: int) -> int:
var expected_generation := _available_join_generation(token)
if expected_generation < 0:
return -1
var generation := expected_generation + 1
_join_history[token] = {"generation": generation, "lost_at": 0.0}
_active_join_peers[token] = peer_id
return generation
# Strips control/formatting characters (so a name can't corrupt a log line
# or blow out UI layout with e.g. embedded newlines) and clamps to display
# length. Input is already bounded to MAX_INPUT_LENGTH by the caller before
@@ -223,17 +590,29 @@ func _set_team(team: int) -> void:
if not multiplayer.is_server():
return
var peer_id := multiplayer.get_remote_sender_id()
if not roster.has(peer_id) or team < 0 or team >= TEAM_COUNT:
if not _apply_team_change(peer_id, team):
return
var info: PlayerInfo = roster[peer_id]
if info.team == team:
return
info.team = team
info.ready = false # switching teams un-readies — the roster you were ready against just changed
player_state_changed.emit(peer_id, info.team, info.ready)
_state_changed.rpc(peer_id, info.team, info.ready)
func _apply_team_change(peer_id: int, team: int) -> bool:
# In allocated matches team and global slot are signed together. Changing
# only team would produce a roster that disagrees with the assignment and
# leave spawn_index anchored to the old team.
if require_join_authorisation:
return false
if not roster.has(peer_id) or team < 0 or team >= TEAM_COUNT:
return false
var info: PlayerInfo = roster[peer_id]
if info.team == team:
return false
info.team = team
info.ready = false # switching teams un-readies — the roster you were ready against just changed
return true
@rpc("any_peer", "call_remote", "reliable")
func _set_ready(ready: bool) -> void:
if not multiplayer.is_server():
@@ -269,6 +648,12 @@ func _rejected(reason: String) -> void:
rejected.emit(reason)
@rpc("authority", "call_remote", "reliable")
func _server_shutdown(reason: String) -> void:
last_server_shutdown_reason = _sanitize_shutdown_reason(reason)
server_shutdown.emit(last_server_shutdown_reason)
@rpc("authority", "call_remote", "reliable")
func _player_joined(peer_id: int, player_name: String, team: int, ready: bool) -> void:
roster[peer_id] = PlayerInfo.new(peer_id, player_name, team, ready)
+13 -2
View File
@@ -41,7 +41,7 @@ signal match_bootstrap_received(state: int, at_tick: int, score: Dictionary, end
# §6.3 task 5.8: a spectator has been given a vacated slot at a kickoff.
signal slot_assigned_received(peer_id: int, slot_index: int)
# Input validation (multiplayer-todo.md §3.1 steps 2-3, task 3.4). Deliberately
# Input validation (MULTIPLAYER_SPEC.md §3.1 steps 2-3; multiplayer-next.md task 3.4). Deliberately
# lives here rather than in NetworkedMatch: framing/rate abuse is a protocol-
# level concern independent of any particular match's roster/slot state, and
# this autoload already owns the RPC that receives the raw bytes.
@@ -192,7 +192,12 @@ func _physics_process(_delta: float) -> void:
var now := Time.get_ticks_msec()
var gap := now - _last_physics_ms
_last_physics_ms = now
if not multiplayer.is_server() or _peer_input_state.is_empty():
# NetworkManager.shutdown() swaps in an OfflineMultiplayerPeer before the
# smoke harness's deferred quit runs. Querying MultiplayerAPI.is_server()
# during that hand-off can call get_unique_id() on an inactive ENet peer and
# emit errors every physics frame; the NetworkManager role flag is the safe
# lifecycle guard at this boundary.
if not NetworkManager.is_server or _peer_input_state.is_empty():
return
if gap < STALL_DETECT_MS:
return
@@ -264,6 +269,11 @@ func send_input(bytes: PackedByteArray) -> void:
func send_snapshot(peer_id: int, bytes: PackedByteArray) -> void:
# A server-side disconnect can leave peer_id in get_peers() until the
# current poll batch settles. Do not enter Godot's RPC path for that stale
# target; NetSim repeats this check at fire time for delayed sends.
if not NetworkManager.can_send_to_peer(peer_id):
return
_track_sent(bytes.size())
NetSim.send(func() -> void: _snapshot.rpc_id(peer_id, bytes), peer_id)
@@ -477,6 +487,7 @@ func _disconnect_abusive_peer(peer_id: int, reason: String) -> void:
# which does not carry the peer, the reason or a timestamp into the log
# stream a container actually captures.
ServerLog.warn("peer_kicked", {"peer_id": peer_id, "reason": reason})
NetworkManager.invalidate_peer(peer_id)
_peer_input_state.erase(peer_id)
if multiplayer.multiplayer_peer is ENetMultiplayerPeer:
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
+1 -1
View File
@@ -1,6 +1,6 @@
class_name MatchState
# Match lifecycle states (multiplayer-todo.md §6.1, task 5.1).
# Match lifecycle states (MULTIPLAYER_SPEC.md §6.1; multiplayer-next.md task 5.1).
#
# Pure data + a transition table, deliberately with no scene, RPC or
# NetworkedMatch dependency — same reason net_codec.gd and
+388
View File
@@ -0,0 +1,388 @@
extends Control
const CLIENT_BUILD := "dev"
const PROTOCOL_VERSION := 1
const HEARTBEAT_SECONDS := 10.0
const RECOVERY_POLL_SECONDS := 2.0
@onready var playlist_dropdown: OptionButton = %PlaylistDropdown
@onready var status_label: Label = %StatusLabel
@onready var detail_label: Label = %DetailLabel
@onready var ranked_profile_label: Label = %RankedProfileLabel
@onready var queue_button: Button = %QueueButton
@onready var cancel_button: Button = %CancelButton
@onready var accept_button: Button = %AcceptButton
@onready var decline_button: Button = %DeclineButton
@onready var back_button: Button = %BackButton
var _elapsed_seconds := 0.0
var _heartbeat_seconds := 0.0
var _recovery_poll_seconds := 0.0
# Regions still awaiting RTT evidence, and the queue request deferred until at
# least one lands. The matcher ignores a ticket with no predicted RTT, so
# queueing before probing produces a search that can never match.
var _pending_probe_regions: Array[String] = []
var _probed_regions: Array[String] = []
var _deferred_queue := {}
var _web_api_ticket_handle := 0
func _ready() -> void:
AudioManager.bind_tree_buttons(self)
playlist_dropdown.add_item("Casual")
playlist_dropdown.set_item_metadata(0, "casual")
playlist_dropdown.add_item("Ranked")
playlist_dropdown.set_item_metadata(1, "ranked")
playlist_dropdown.item_selected.connect(_on_playlist_selected)
ControlPlaneClient.state.changed.connect(_on_state_changed)
ControlPlaneClient.request_failed.connect(_on_request_failed)
ControlPlaneClient.request_succeeded.connect(_on_request_succeeded)
ControlPlaneClient.session_expired.connect(_on_session_expired)
ControlPlaneClient.probe_challenge_received.connect(_on_probe_challenge_received)
ControlPlaneClient.probe_recorded.connect(_on_probe_recorded)
_ensure_signed_in()
_refresh_ranked_profile()
_render(ControlPlaneClient.state.snapshot())
# Matchmaking previously opened with an empty token against a loopback default,
# so every request failed ERR_UNAUTHORIZED before reaching the network. Point
# the client at its configured endpoint and complete Steam sign-in first.
func _ensure_signed_in() -> void:
if ControlPlaneClient.has_session():
return
if not ControlPlaneClient.configure(ControlPlaneClient.configured_base_url(), ""):
_on_local_error("Matchmaking endpoint is not configured")
return
if not SteamBootstrap.supports_web_api_ticket():
# Deliberately explicit rather than silently presenting a search that
# can never start: online matchmaking requires a verified identity.
_on_local_error("Sign-in requires the Steam build: %s" % SteamBootstrap.unavailable_reason())
return
var steam := Engine.get_singleton("Steam")
if not steam.get_auth_ticket_for_web_api.is_connected(_on_web_api_ticket):
steam.get_auth_ticket_for_web_api.connect(_on_web_api_ticket)
_web_api_ticket_handle = SteamBootstrap.request_web_api_ticket()
if _web_api_ticket_handle <= 0:
_on_local_error("Could not request a Steam authentication ticket")
return
ControlPlaneClient.state.set_notice("Signing in...")
func _on_web_api_ticket(_handle: int, result: int, ticket: PackedByteArray) -> void:
# Steam reports k_EResultOK as 1; anything else means no usable ticket.
if result != 1 or ticket.is_empty():
_on_local_error("Steam declined to issue an authentication ticket")
return
var encoded := SteamBootstrap.encode_web_api_ticket(ticket)
if encoded.is_empty():
_on_local_error("Steam returned an unusable authentication ticket")
return
var err := ControlPlaneClient.login_steam(encoded)
if err != OK:
_on_local_error("Could not sign in: %s" % error_string(err))
func _exit_tree() -> void:
# The ticket handle is a Steam resource; releasing it avoids leaking one
# per visit to this screen.
if _web_api_ticket_handle > 0:
SteamBootstrap.cancel_web_api_ticket(_web_api_ticket_handle)
_web_api_ticket_handle = 0
func _process(delta: float) -> void:
if ControlPlaneClient.state.phase in [MatchmakingState.QUEUED, MatchmakingState.PROPOSED, MatchmakingState.ACCEPTED, MatchmakingState.ALLOCATING]:
_elapsed_seconds += delta
_heartbeat_seconds += delta
_recovery_poll_seconds += delta
if _recovery_poll_seconds >= RECOVERY_POLL_SECONDS:
_recovery_poll_seconds = 0.0
var recovery_err := ControlPlaneClient.recover_proposal(ControlPlaneClient.state.proposal_id) if ControlPlaneClient.state.has_open_proposal() else ControlPlaneClient.recover_queue(ControlPlaneClient.state.ticket_id)
if recovery_err != OK and recovery_err != ERR_BUSY:
_on_local_error("State recovery unavailable: %s" % error_string(recovery_err))
if ControlPlaneClient.state.phase == MatchmakingState.QUEUED and _heartbeat_seconds >= HEARTBEAT_SECONDS:
_heartbeat_seconds = 0.0
var err := ControlPlaneClient.heartbeat(ControlPlaneClient.state.ticket_id, ControlPlaneClient.state.revision)
if err != OK:
_on_local_error("Heartbeat unavailable: %s" % error_string(err))
_render(ControlPlaneClient.state.snapshot())
func _on_queue_pressed() -> void:
if not ControlPlaneClient.has_session():
# Queueing without a session would fail at the first request guard.
_ensure_signed_in()
return
if ControlPlaneClient.can_retry_queue_create():
var retry_err := ControlPlaneClient.retry_queue_create()
if retry_err != OK:
_on_local_error("Could not retry matchmaking: %s" % error_string(retry_err))
return
if ControlPlaneClient.can_retry_last_mutation():
var mutation_err := ControlPlaneClient.retry_last_mutation()
if mutation_err != OK:
_on_local_error("Could not retry matchmaking action: %s" % error_string(mutation_err))
return
if not _can_start_new_search(ControlPlaneClient.state.phase):
return
_elapsed_seconds = 0.0
_heartbeat_seconds = 0.0
_recovery_poll_seconds = 0.0
var playlist := String(playlist_dropdown.get_selected_metadata())
var ticket_id := "ticket-%s-%s" % [str(Time.get_ticks_usec()), str(randi())]
# A ticket with no regional RTT evidence is invisible to the matcher, so
# collect it first and queue once the first region reports.
if _probed_regions.is_empty():
_deferred_queue = {"ticket_id": ticket_id, "playlist": playlist}
_start_probe_collection()
return
var err := ControlPlaneClient.queue_create(ticket_id, playlist, CLIENT_BUILD, PROTOCOL_VERSION)
if err != OK:
_on_local_error("Could not start matchmaking: %s" % error_string(err))
func _start_probe_collection() -> void:
_pending_probe_regions = []
for region in ControlPlaneClient.PROBE_REGIONS:
_pending_probe_regions.append(String(region))
ControlPlaneClient.state.set_notice("Measuring connection quality...")
_request_next_probe()
# One request at a time: the client serialises HTTP through a single
# HTTPRequest, so a second call would return ERR_BUSY.
func _request_next_probe() -> void:
if _pending_probe_regions.is_empty():
_finish_probe_collection()
return
var region := _pending_probe_regions[0]
var err := ControlPlaneClient.request_probe_challenge(region)
if err != OK and err != ERR_BUSY:
# A region we cannot probe is not fatal; placement just uses the
# regions that did respond.
_pending_probe_regions.remove_at(0)
_request_next_probe()
func _on_probe_challenge_received(region: String, nonce_base64: String) -> void:
var err := ControlPlaneClient.submit_probe_answer(region, nonce_base64, ControlPlaneClient.opaque_location_payload())
if err != OK:
_drop_pending_probe(region)
func _on_probe_recorded(region: String, _server_rtt_ms: int) -> void:
if not _probed_regions.has(region):
_probed_regions.append(region)
_drop_pending_probe(region)
func _drop_pending_probe(region: String) -> void:
var index := _pending_probe_regions.find(region)
if index >= 0:
_pending_probe_regions.remove_at(index)
_request_next_probe()
func _finish_probe_collection() -> void:
if _deferred_queue.is_empty():
return
var queued := _deferred_queue
_deferred_queue = {}
if _probed_regions.is_empty():
# Queueing now would create a ticket the matcher can never select.
_on_local_error("Could not measure connection quality to any region; matchmaking is unavailable")
return
var err := ControlPlaneClient.queue_create(String(queued["ticket_id"]), String(queued["playlist"]), CLIENT_BUILD, PROTOCOL_VERSION)
if err != OK:
_on_local_error("Could not start matchmaking: %s" % error_string(err))
func _on_cancel_pressed() -> void:
if not ControlPlaneClient.state.can_cancel():
return
var err := ControlPlaneClient.cancel_queue(ControlPlaneClient.state.ticket_id, ControlPlaneClient.state.revision)
if err != OK:
_on_local_error("Could not cancel matchmaking: %s" % error_string(err))
func _on_accept_pressed() -> void:
var err := ControlPlaneClient.respond_to_proposal(ControlPlaneClient.state.proposal_id, true, ControlPlaneClient.state.proposal_revision)
if err != OK:
_on_local_error("Could not accept proposal: %s" % error_string(err))
func _on_decline_pressed() -> void:
var err := ControlPlaneClient.respond_to_proposal(ControlPlaneClient.state.proposal_id, false, ControlPlaneClient.state.proposal_revision)
if err != OK:
_on_local_error("Could not decline proposal: %s" % error_string(err))
func _on_playlist_selected(_index: int) -> void:
_refresh_ranked_profile()
func _refresh_ranked_profile() -> void:
var ranked := String(playlist_dropdown.get_selected_metadata()) == "ranked"
ranked_profile_label.visible = ranked
if not ranked:
return
var err := ControlPlaneClient.fetch_ranked_profile()
if err != OK and err != ERR_BUSY:
ranked_profile_label.text = "Ranked profile unavailable: %s" % error_string(err)
func _on_back_pressed() -> void:
if ControlPlaneClient.state.can_cancel():
status_label.text = "Cancel the active search before leaving"
return
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
func _on_state_changed(snapshot: Dictionary) -> void:
_render(snapshot)
func _on_request_succeeded(_operation: String, _payload: Dictionary) -> void:
ranked_profile_label.text = ControlPlaneClient.ranked_profile.display_text()
_render(ControlPlaneClient.state.snapshot())
func _on_request_failed(_operation: String, _http_code: int, detail: String) -> void:
detail_label.text = detail
ranked_profile_label.text = ControlPlaneClient.ranked_profile.display_text()
_render(ControlPlaneClient.state.snapshot())
func _on_session_expired() -> void:
status_label.text = "Session expired"
detail_label.text = "Sign in again before searching for a match"
queue_button.disabled = true
func _on_local_error(detail: String) -> void:
detail_label.text = detail
static func phase_label(phase: String) -> String:
match phase:
MatchmakingState.IDLE:
return "Ready to search"
MatchmakingState.QUEUED:
return "Searching for players"
MatchmakingState.PROPOSED:
return "Match found — confirm"
MatchmakingState.ACCEPTED:
return "Match accepted — preparing server"
MatchmakingState.ALLOCATING:
return "Preparing match server"
MatchmakingState.PROCESS_READY:
return "Match server started"
MatchmakingState.ASSIGNMENT_READY:
return "Match assigned"
MatchmakingState.CONNECTING:
return "Connecting to match"
MatchmakingState.LIVE:
return "Match in progress"
MatchmakingState.RESULT_PENDING:
return "Recording match result"
MatchmakingState.COMPLETED:
return "Match complete"
MatchmakingState.ASSIGNED:
return "Match assigned"
MatchmakingState.CANCELLED:
return "Search cancelled"
MatchmakingState.EXPIRED:
return "Search expired"
MatchmakingState.FAILED:
return "Matchmaking unavailable"
_:
return "Recovering matchmaking state"
func _render(snapshot: Dictionary) -> void:
var phase := String(snapshot.get("phase", MatchmakingState.IDLE))
status_label.text = phase_label(phase)
if String(snapshot.get("message", "")) != "":
detail_label.text = String(snapshot["message"])
elif phase == MatchmakingState.QUEUED:
var waited := _elapsed_seconds
if int(snapshot.get("enqueued_at_unix", 0)) > 0:
waited = float(ControlPlaneClient.state.waited_seconds(int(Time.get_unix_time_from_system())))
detail_label.text = queue_wait_detail_text(int(waited), int(snapshot.get("revision", 0)))
elif phase == MatchmakingState.PROPOSED:
detail_label.text = proposal_countdown_text(int(snapshot.get("expires_at_unix", 0)), int(Time.get_unix_time_from_system()))
elif phase == MatchmakingState.ACCEPTED:
detail_label.text = phase_detail_label(phase)
elif phase in [MatchmakingState.ALLOCATING, MatchmakingState.PROCESS_READY, MatchmakingState.ASSIGNMENT_READY, MatchmakingState.ASSIGNED]:
detail_label.text = phase_detail_label(phase)
elif phase in [MatchmakingState.CONNECTING, MatchmakingState.LIVE]:
detail_label.text = "%s · %s" % [phase_detail_label(phase), latency_detail_text(NetworkManager.rtt_ms)]
elif phase == MatchmakingState.RESULT_PENDING:
detail_label.text = "The server is confirming the final result"
elif phase == MatchmakingState.COMPLETED:
detail_label.text = "The match result has been recorded"
elif phase == MatchmakingState.IDLE:
detail_label.text = "Choose a playlist to begin"
cancel_button.visible = ControlPlaneClient.state.can_cancel()
accept_button.visible = phase == MatchmakingState.PROPOSED
decline_button.visible = phase == MatchmakingState.PROPOSED
var retry_search := ControlPlaneClient.can_retry_queue_create()
var retry_mutation := ControlPlaneClient.can_retry_last_mutation()
queue_button.disabled = ControlPlaneClient.auth_expired or not (_can_start_new_search(phase) or retry_search or retry_mutation)
queue_button.text = "Retry Search" if retry_search else ("Retry Request" if retry_mutation else "Search")
static func _is_terminal(phase: String) -> bool:
return phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE, MatchmakingState.COMPLETED]
static func phase_detail_label(phase: String) -> String:
match phase:
MatchmakingState.ACCEPTED:
return "All players accepted; preparing the match server"
MatchmakingState.ALLOCATING:
return "Finding a dedicated match server"
MatchmakingState.PROCESS_READY:
return "Match server started; preparing player assignments"
MatchmakingState.ASSIGNMENT_READY:
return "Player assignments are ready"
MatchmakingState.ASSIGNED:
return "Your match server is ready"
MatchmakingState.CONNECTING:
return "Connecting to the match server"
MatchmakingState.LIVE:
return "Match in progress"
_:
return ""
static func proposal_countdown_text(expires_at_unix: int, now_unix: int) -> String:
if expires_at_unix <= 0:
return "Review the proposal before the countdown expires"
return "Review proposal · %ds remaining" % maxi(0, expires_at_unix - now_unix)
static func queue_wait_detail_text(waited_seconds: int, revision: int) -> String:
var waited := maxi(0, waited_seconds)
var suffix := "looking for compatible players"
if waited >= 30:
suffix = "widening skill range while keeping latency limits"
elif waited >= 10:
suffix = "matching nearby skill and latency"
return "Waiting %ds · %s · revision %d" % [waited, suffix, maxi(0, revision)]
static func latency_detail_text(rtt_ms: float) -> String:
if not is_finite(rtt_ms) or rtt_ms < 0.0:
return "Latency: measuring"
var rounded := int(round(rtt_ms))
if rtt_ms <= 50.0:
return "Latency: %dms · excellent" % rounded
if rtt_ms <= 100.0:
return "Latency: %dms · good" % rounded
return "Latency: %dms · high" % rounded
static func _can_start_new_search(phase: String) -> bool:
return phase == MatchmakingState.IDLE or phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.COMPLETED]
+392
View File
@@ -0,0 +1,392 @@
class_name MatchmakingState
extends RefCounted
# Client-side projection of the authenticated control-plane lifecycle. The
# server remains authoritative; this object only decides what the UI may show
# and refuses stale, gapped, or conflicting revisions instead of guessing.
signal changed(snapshot: Dictionary)
signal resync_required(resource_id: String)
const IDLE := "IDLE"
const QUEUED := "QUEUED"
const PROPOSED := "PROPOSED"
const ACCEPTED := "ACCEPTED"
const ALLOCATING := "ALLOCATING"
const PROCESS_READY := "PROCESS_READY"
const ASSIGNMENT_READY := "ASSIGNMENT_READY"
const CONNECTING := "CONNECTING"
const LIVE := "LIVE"
const ASSIGNED := "ASSIGNED"
const RESULT_PENDING := "RESULT_PENDING"
const COMPLETED := "COMPLETED"
const CANCELLED := "CANCELLED"
const EXPIRED := "EXPIRED"
const FAILED := "FAILED"
var phase := IDLE
var ticket_id := ""
var playlist := ""
var revision := 0
var enqueued_at_unix := 0
var expires_at_unix := 0
var proposal_id := ""
var proposal_revision := 0
var proposal_state := ""
var message := ""
var needs_resync := false
func begin_queue(new_ticket_id: String, new_playlist: String) -> bool:
if new_ticket_id.is_empty() or (new_playlist != "casual" and new_playlist != "ranked"):
return false
_reset()
ticket_id = new_ticket_id
playlist = new_playlist
phase = QUEUED
_emit_changed()
return true
func apply_ticket_update(update: Dictionary, authoritative_snapshot: bool = false) -> bool:
if not _has_string(update, "ticket_id") or not update.has("revision") or not _valid_revision(update["revision"]) or not update.has("state"):
return _request_resync(self.ticket_id)
if update.has("playlist") and not _valid_playlist(String(update["playlist"])):
return _request_resync(self.ticket_id)
if update.has("enqueued_at_unix") and not _valid_epoch(update["enqueued_at_unix"]):
return _request_resync(self.ticket_id)
if update.has("expires_at_unix") and not _valid_epoch(update["expires_at_unix"]):
return _request_resync(self.ticket_id)
if ticket_id.is_empty() or String(update["ticket_id"]) != ticket_id:
return _request_resync(self.ticket_id)
var incoming_revision := int(update["revision"])
if incoming_revision < revision:
return false
if incoming_revision == revision:
if _ticket_differs(update):
return _request_resync(self.ticket_id)
# expires_at_unix is deliberately not part of _ticket_differs' conflict
# check (see its own comment) but is still adopted here: begin_queue()
# has no way to know the server-assigned expiry in advance, so the
# very first same-revision confirmation is the only place a freshly
# queued ticket's expiry is ever set at all.
if update.has("expires_at_unix"):
expires_at_unix = int(update["expires_at_unix"])
if update.has("enqueued_at_unix"):
enqueued_at_unix = maxi(0, int(update["enqueued_at_unix"]))
return true
if incoming_revision > revision + 1 and not authoritative_snapshot:
return _request_resync(self.ticket_id)
var incoming_state := String(update["state"])
if not _is_ticket_state(incoming_state):
return _request_resync(self.ticket_id)
if authoritative_snapshot:
if not _can_reach_ticket_state(phase, incoming_state):
return _request_resync(self.ticket_id)
elif not _is_legal_ticket_transition(phase, incoming_state):
return _request_resync(self.ticket_id)
revision = incoming_revision
phase = incoming_state
if update.has("playlist"):
playlist = String(update["playlist"])
if update.has("expires_at_unix"):
expires_at_unix = int(update["expires_at_unix"])
if update.has("enqueued_at_unix"):
enqueued_at_unix = maxi(0, int(update["enqueued_at_unix"]))
if update.has("message"):
message = String(update["message"])
else:
message = ""
needs_resync = false
_emit_changed()
return true
func apply_proposal_update(update: Dictionary) -> bool:
if not _has_string(update, "proposal_id") or not update.has("revision") or not _valid_revision(update["revision"]) or not update.has("state"):
return _request_resync(proposal_id)
if update.has("expires_at_unix") and not _valid_epoch(update["expires_at_unix"]):
return _request_resync(proposal_id)
var incoming_id := String(update["proposal_id"])
if proposal_id.is_empty():
proposal_id = incoming_id
elif proposal_id != incoming_id:
return _request_resync(proposal_id)
var incoming_revision := int(update["revision"])
if incoming_revision < proposal_revision:
return false
if incoming_revision == proposal_revision and not proposal_state.is_empty():
if String(update["state"]) != proposal_state:
return _request_resync(proposal_id)
return true
if not proposal_state.is_empty() and incoming_revision > proposal_revision + 1:
return _request_resync(proposal_id)
var incoming_proposal_state := String(update["state"])
if incoming_proposal_state == "OPEN":
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
return _request_resync(proposal_id)
phase = PROPOSED
elif incoming_proposal_state == "ACCEPTED":
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
return _request_resync(proposal_id)
phase = ALLOCATING
elif incoming_proposal_state == "DECLINED":
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
return _request_resync(proposal_id)
if phase == PROPOSED:
phase = QUEUED
message = "A player declined the match proposal"
elif incoming_proposal_state == "EXPIRED":
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
return _request_resync(proposal_id)
if phase == PROPOSED:
phase = QUEUED
message = "The match proposal expired"
elif incoming_proposal_state == "CANCELLED":
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
return _request_resync(proposal_id)
if phase == PROPOSED:
phase = QUEUED
message = "The match proposal was cancelled"
else:
return _request_resync(proposal_id)
proposal_revision = incoming_revision
proposal_state = incoming_proposal_state
needs_resync = false
if incoming_proposal_state == "OPEN" or incoming_proposal_state == "ACCEPTED":
message = ""
if update.has("expires_at_unix"):
expires_at_unix = int(update["expires_at_unix"])
_emit_changed()
return true
func prepare_proposal_recovery(new_proposal_id: String) -> bool:
if not _valid_opaque_id(new_proposal_id):
return false
if proposal_id == new_proposal_id:
return true
if proposal_state not in ["", "DECLINED", "EXPIRED", "CANCELLED"]:
return false
proposal_id = new_proposal_id
proposal_revision = 0
proposal_state = ""
return true
func mark_assignment_ready() -> void:
phase = ASSIGNMENT_READY
message = "Match server is ready"
_emit_changed()
func mark_connecting() -> void:
phase = CONNECTING
message = "Connecting to match server"
_emit_changed()
func mark_live() -> void:
phase = LIVE
message = "Match in progress"
_emit_changed()
func fail(reason: String) -> void:
phase = FAILED
message = reason if not reason.is_empty() else "Matchmaking failed"
_emit_changed()
func expire(reason: String = "Matchmaking expired") -> void:
phase = EXPIRED
message = reason
_emit_changed()
func set_notice(notice: String) -> void:
message = notice
_emit_changed()
func restore_snapshot(saved: Dictionary) -> bool:
_reset()
if saved.is_empty():
return true
if saved.has("phase") and not saved["phase"] is String:
return false
if saved.has("ticket_id") and not saved["ticket_id"] is String:
return false
if saved.has("playlist") and not saved["playlist"] is String:
return false
if saved.has("proposal_id") and not saved["proposal_id"] is String:
return false
if saved.has("proposal_state") and not saved["proposal_state"] is String:
return false
if saved.has("message") and not saved["message"] is String:
return false
if saved.has("revision") and not _valid_revision(saved["revision"]):
return false
for epoch_key in ["enqueued_at_unix", "expires_at_unix"]:
if saved.has(epoch_key) and not _valid_epoch(saved[epoch_key]):
return false
if saved.has("proposal_revision") and not _valid_revision(saved["proposal_revision"]):
return false
var saved_phase := String(saved.get("phase", IDLE))
var saved_ticket_id := String(saved.get("ticket_id", ""))
if not _valid_opaque_id(saved_ticket_id) or not _is_ticket_state(saved_phase):
return false
var saved_playlist := String(saved.get("playlist", ""))
if saved_playlist != "casual" and saved_playlist != "ranked":
return false
var saved_proposal_id := String(saved.get("proposal_id", ""))
var saved_proposal_state := String(saved.get("proposal_state", ""))
if saved_proposal_state not in ["", "OPEN", "ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"] or (not saved_proposal_state.is_empty() and not _valid_opaque_id(saved_proposal_id)) or (saved_proposal_state.is_empty() and not saved_proposal_id.is_empty() and not _valid_opaque_id(saved_proposal_id)):
return false
ticket_id = saved_ticket_id
playlist = saved_playlist
phase = saved_phase
revision = maxi(0, int(saved.get("revision", 0)))
enqueued_at_unix = maxi(0, int(saved.get("enqueued_at_unix", 0)))
expires_at_unix = maxi(0, int(saved.get("expires_at_unix", 0)))
proposal_id = saved_proposal_id
proposal_revision = maxi(0, int(saved.get("proposal_revision", 0)))
proposal_state = saved_proposal_state
message = "Recovering authoritative matchmaking state"
needs_resync = phase != CANCELLED and phase != EXPIRED and phase != FAILED and phase != COMPLETED
_emit_changed()
return true
func can_cancel() -> bool:
return phase == QUEUED or phase == PROPOSED or phase == ALLOCATING
func has_open_proposal() -> bool:
return not proposal_id.is_empty() and proposal_state == "OPEN"
func waited_seconds(now_unix: int) -> int:
if enqueued_at_unix <= 0:
return 0
return maxi(0, now_unix - enqueued_at_unix)
func snapshot() -> Dictionary:
return {"phase": phase, "ticket_id": ticket_id, "playlist": playlist, "revision": revision, "enqueued_at_unix": enqueued_at_unix, "expires_at_unix": expires_at_unix, "proposal_id": proposal_id, "proposal_revision": proposal_revision, "proposal_state": proposal_state, "message": message, "needs_resync": needs_resync}
func _ticket_differs(update: Dictionary) -> bool:
# expires_at_unix is excluded on purpose: begin_queue()'s optimistic local
# state has no way to know the server-assigned expiry before the first
# real response arrives, so comparing it here made the very first
# same-revision confirmation after every begin_queue() look like a
# conflict, unconditionally -- found by an actual client hitting a real
# server: apply_ticket_update() kept requesting a resync, whose own
# response hit exactly the same false mismatch, forever, which
# control_plane_smoke.gd (a live end-to-end test, not a mock) surfaced as
# a request that legitimately never terminates. It's still kept current
# via the direct assignment below, just not treated as a conflict signal.
return String(update["state"]) != phase or (update.has("playlist") and String(update["playlist"]) != playlist)
func _request_resync(resource_id: String) -> bool:
needs_resync = true
resync_required.emit(resource_id)
return false
func _emit_changed() -> void:
changed.emit(snapshot())
func _reset() -> void:
phase = IDLE
playlist = ""
revision = 0
enqueued_at_unix = 0
expires_at_unix = 0
proposal_id = ""
proposal_revision = 0
proposal_state = ""
message = ""
needs_resync = false
func _is_ticket_state(value: String) -> bool:
return value in [QUEUED, PROPOSED, ACCEPTED, ALLOCATING, PROCESS_READY, ASSIGNMENT_READY, ASSIGNED, CONNECTING, LIVE, RESULT_PENDING, COMPLETED, CANCELLED, EXPIRED, FAILED]
func _is_legal_ticket_transition(from: String, to: String) -> bool:
if from == to:
return true
var transitions := {
QUEUED: [PROPOSED, CANCELLED, EXPIRED],
PROPOSED: [QUEUED, ACCEPTED, CANCELLED, EXPIRED],
ACCEPTED: [QUEUED, ALLOCATING, CANCELLED, FAILED],
ALLOCATING: [PROCESS_READY, FAILED, CANCELLED],
PROCESS_READY: [ASSIGNMENT_READY, FAILED, CANCELLED],
ASSIGNMENT_READY: [ASSIGNED, FAILED, CANCELLED],
ASSIGNED: [CONNECTING, FAILED, CANCELLED],
CONNECTING: [LIVE, FAILED, EXPIRED],
LIVE: [RESULT_PENDING, FAILED],
RESULT_PENDING: [COMPLETED, FAILED],
}
return transitions.has(from) and to in transitions[from]
func _can_reach_ticket_state(from: String, to: String) -> bool:
if from == to:
return true
var pending: Array[String] = [from]
var visited := {}
visited[from] = true
while not pending.is_empty():
var current: String = pending.pop_front()
for candidate in [QUEUED, PROPOSED, ACCEPTED, ALLOCATING, PROCESS_READY, ASSIGNMENT_READY, ASSIGNED, CONNECTING, LIVE, RESULT_PENDING, COMPLETED, CANCELLED, EXPIRED, FAILED]:
if visited.has(candidate) or not _is_legal_ticket_transition(current, candidate):
continue
if candidate == to:
return true
visited[candidate] = true
pending.append(candidate)
return false
func _is_legal_proposal_transition(from: String, to: String) -> bool:
if from.is_empty():
return to == "OPEN"
if from == to:
return true
return from == "OPEN" and to in ["ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"]
func _has_string(value: Dictionary, key: String) -> bool:
return value.has(key) and value[key] is String and not String(value[key]).is_empty()
func _valid_revision(value: Variant) -> bool:
if value is int:
return int(value) >= 0
if value is float:
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
return false
func _valid_playlist(value: String) -> bool:
return value == "casual" or value == "ranked"
func _valid_epoch(value: Variant) -> bool:
if value is int:
return int(value) >= 0
if value is float:
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
return false
func _valid_opaque_id(value: String) -> bool:
if value.length() < 16 or value.length() > 128:
return false
var resource_pattern := RegEx.create_from_string("^[A-Za-z0-9_-]+$")
return resource_pattern.search(value) != null
+1 -1
View File
@@ -1,6 +1,6 @@
extends RefCounted
# Plain data holder for one body's snapshot state (§2.4 of multiplayer-todo.md).
# Plain data holder for one body's snapshot state (§2.4 of MULTIPLAYER_SPEC.md).
# Deliberately not Ship/Ball themselves, and deliberately not a scene-tree
# node — NetCodec's pack/unpack must stay callable from pure-function tests
# with no live scene. Phase 2's snapshot writer fills one of these per body
+2 -2
View File
@@ -1,7 +1,7 @@
class_name NetCodec
# Wire-format constants, quantisers, and pack/unpack for the two hot-path
# packets (§2 of multiplayer-todo.md). Pure functions only — no networking,
# packets (§2 of MULTIPLAYER_SPEC.md). Pure functions only — no networking,
# no autoload state — so they're testable head-on by tests/test_runner.tscn
# without a live connection.
#
@@ -48,7 +48,7 @@ const BODY_FLAG_STALLED := 1 << 5
const BODY_FLAG_QUAT_W_SIGN := 1 << 6
# --- Quantisation ranges (§2.4 — derived from arena/gameplay constants, not
# restated prose; see multiplayer-todo.md for the ArenaBoundary/Ship/Ball
# restated prose; see MULTIPLAYER_SPEC.md for the ArenaBoundary/Ship/Ball
# constants these are sized against) ---
const POS_RANGE := 64.0 # metres, ±
const VEL_RANGE := 64.0 # m/s, ±
+1 -1
View File
@@ -3,7 +3,7 @@ extends RefCounted
# Buffers recent snapshot samples for ONE remote body and produces
# interpolated states at any requested (possibly fractional) server tick —
# used twice per body (multiplayer-todo.md §4.1/§4.6, "dual-time remote
# used twice per body (MULTIPLAYER_SPEC.md §4.1/§4.6, "dual-time remote
# entities"): once at the present-time estimate for the collider, once
# further back at present-minus-INTERP_DELAY for $Visual.
#
+6 -1
View File
@@ -1,6 +1,6 @@
extends RefCounted
# Local-ship reconciliation policy (multiplayer-todo.md §4.4). Kept out of
# Local-ship reconciliation policy (MULTIPLAYER_SPEC.md §4.4). Kept out of
# NetworkedMatch so the decision table is pure-testable; the imperative half
# only writes Ship's existing Jolt-safe queued correction hooks.
@@ -44,6 +44,11 @@ static func decide(comparison: Dictionary, local_frozen: bool, reset_changed: bo
# normally against real data.
if comparison.get("status", "") == "unsimulated_gap":
return {"mode": "skip", "reason": "unsimulated_gap"}
if comparison.get("status", "") == "warmup_not_recorded":
# Sequence acknowledgements that predate the first local post-step state
# are expected during startup. The initial snapshot already placed the
# body, so there is no correction to apply and no resync to arm.
return {"mode": "skip", "reason": "warmup_not_recorded"}
if comparison.get("status", "missing_not_recorded") != "matched":
return {"mode": "hard", "reason": comparison.get("status", "missing")}
if authoritative == null or authoritative.frozen != local_frozen:
+1 -1
View File
@@ -89,7 +89,7 @@ func _schedule(dispatch: Callable, target_peer_id: int, delay_sec: float) -> voi
return
# process_always = true: a simulated wire delay must keep counting down
# even if the local SceneTree pauses (match_mode.gd's goal-pause does
# this today; multiplayer-todo.md §8 already flags get_tree().paused
# this today; multiplayer-next.md §8 already flags get_tree().paused
# stopping the client's own send/receive loop as a separate refactor
# item). Pausing this timer too would let a paused client's in-flight
# packets pile up and arrive in a burst on unpause instead of on their
+25 -2
View File
@@ -3,7 +3,7 @@ extends Node
# Autoload (project.godot [autoload] NetworkManager). Owns transport-neutral
# hosting, joining, shutdown, and connection-state signals. Lives
# at a fixed autoload path so RPC NodePaths never depend on which scene is
# loaded (§1.3 of multiplayer-todo.md's derived decisions).
# loaded (§1.3 of MULTIPLAYER_SPEC.md's derived decisions).
#
# server_relay = false is set the moment a peer exists: the default `true`
# lets any client rpc() any other client *through the server*, which this
@@ -25,7 +25,7 @@ extends Node
# pays the same tax again. set_multiplayer_poll_enabled(false) below turns
# that off; every caller that sends or expects to receive on a tight cadence
# must now call NetworkManager.poll() itself. The intended placement per
# multiplayer-todo.md §7 task 1.3 (client: end of _physics_process after
# multiplayer-next.md §7 task 1.3 (client: end of _physics_process after
# sending input, plus top of both _process and _physics_process for receive;
# server: tick start to drain, tick end to flush) has no real per-tick caller
# yet — that lands with the input/snapshot pipeline (tasks 1.4+, Phase 2-3).
@@ -71,6 +71,7 @@ var is_server := false
var is_client := false
var _peer: MultiplayerPeer # keep a strong ref alongside multiplayer.multiplayer_peer
var active_transport := ""
var _invalidated_peer_ids: Dictionary = {}
var rtt_ms := -1.0 # min-RTT sample currently in the window; -1 = no sample yet
var clock_offset_ms := 0.0 # add to a local Time.get_ticks_msec() reading to estimate the server's clock
@@ -127,6 +128,27 @@ func poll() -> void:
multiplayer.poll()
# A peer can be removed from the transport while Godot is still draining the
# same poll batch. During that interval get_peers() may still contain it, but
# an RPC send already fails because ENet has torn down its channels.
func invalidate_peer(peer_id: int) -> void:
_invalidated_peer_ids[peer_id] = true
func can_send_to_peer(peer_id: int) -> bool:
if _invalidated_peer_ids.has(peer_id):
return false
if _peer == null or _peer is OfflineMultiplayerPeer:
return false
# A listening server's peer status is transport/version-specific; the
# authoritative server is valid as soon as it owns a peer and the target
# appears in get_peers(). Clients, however, must not dispatch while their
# connection is still handshaking.
if not is_server and _peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED:
return false
return peer_id in multiplayer.get_peers()
func available_transports() -> PackedStringArray:
var transports := PackedStringArray([TRANSPORT_ENET])
if SteamTransportScript.new().is_available():
@@ -185,6 +207,7 @@ func shutdown() -> void:
peer.close()
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
_peer = null
_invalidated_peer_ids.clear()
active_transport = ""
is_server = false
is_client = false
+94 -12
View File
@@ -124,7 +124,8 @@ class SlotInfo:
# §6.4 (tasks 5.6/5.7). A ship is NEVER despawned on disconnect — the slot
# keeps its ship and swaps the controller, so body order (and therefore
# every snapshot index) stays stable for the whole match.
var player_name := "" # identity key for reconnect; peer_id changes across a reconnect
var player_name := "" # display name only; never authoritative for allocated reclaim
var player_identity := "" # signed allocation identity; peer_id changes across a reconnect
var disconnected := false
var reserved_until_tick := -1 # server only: slot held for this player until here
var interpolator := NetInterpolator.new() # client only
@@ -310,12 +311,20 @@ var _late_joiners: Array[Dictionary] = []
# to this scene; static because the loop cannot hold a reference to a node that
# does not exist yet, and consumed on read so it cannot leak into a later match.
static var server_arena_override := ""
# Set only by ServerMatchLoop after an allocated casual match passes the
# initial-connect policy. It is consumed once while building the authoritative
# six-slot lineup, so direct servers and ranked allocations cannot add bots.
static var server_bot_fill_override := false
# §6.3's "cap with --max-spectators". Server only; 0 disables spectating
# entirely, negative means unlimited.
var _max_spectators := -1
var _last_emitted_countdown := -1
var _in_overtime := false
var _max_overtime_seconds := 900.0
var _overtime_deadline_tick := -1
var _match_over := false
var _planned_server_shutdown := false
var _awaiting_result_submission := false
# Dedicated-export smoke hook (task 6.2). It is parsed only by the authoritative
# server, cannot be triggered by an RPC, and defaults to disabled.
var _smoke_force_goal_tick := -1
@@ -342,6 +351,7 @@ func _ready() -> void:
# FULL_TIME/OVERTIME/RESULTS, so allow an override. Server-side only —
# a client cannot shorten anyone's match.
match_length_seconds = maxf(1.0, float(config.get_value("match-length")))
_max_overtime_seconds = maxf(1.0, float(config.get_value("max-overtime-seconds")))
var smoke_after := float(config.get_value("smoke-force-goal-after"))
if smoke_after >= 0.0:
_smoke_force_goal_tick = -2 # arm when PLAYING begins; -1 remains disabled
@@ -356,6 +366,10 @@ func _ready() -> void:
_replay_log = null
else:
print("NetworkedMatch: recording replay log to %s" % replay_path)
# Result acknowledgement is relevant only to the authority. Clients move
# to their lobby on the replicated RESULTS -> LOBBY transition.
MatchNet.result_submission_accepted.connect(_on_result_submission_accepted)
MatchNet.result_submission_retrying.connect(_on_result_submission_retrying)
_start_server()
else:
for arg: String in OS.get_cmdline_user_args():
@@ -386,6 +400,7 @@ func _ready() -> void:
# is not connected" errors per run — it only ever left because a test
# timer happened to fire.
NetworkManager.disconnected_from_server.connect(_on_disconnected_from_server)
MatchNet.server_shutdown.connect(_on_server_shutdown)
_request_match_config_until_received()
@@ -450,22 +465,47 @@ func _start_server() -> void:
var teams := PackedInt32Array()
var spawn_indices := PackedInt32Array()
var team_counts := {0: 0, 1: 0}
var config := ServerConfig.parse(OS.get_cmdline_user_args(), false)
var use_assigned_bot_fill := server_bot_fill_override and bool(config.get_value("allocated-mode"))
server_bot_fill_override = false
var spawn_entries: Array[Dictionary] = []
if use_assigned_bot_fill:
var by_identity := {}
for peer_id in MatchNet.roster.keys():
var roster_info: MatchNet.PlayerInfo = MatchNet.roster[peer_id]
by_identity[roster_info.player_identity] = {"peer_id": int(peer_id), "info": roster_info}
for assigned: Dictionary in MatchNet.assigned_player_slots():
var identity := String(assigned["player_identity"])
if by_identity.has(identity):
var human: Dictionary = by_identity[identity]
spawn_entries.append({"peer_id": human["peer_id"], "info": human["info"], "team": int(assigned["team"]), "spawn_index": int(assigned["slot"]) % 3, "bot": false})
else:
spawn_entries.append({"peer_id": -1, "info": null, "team": int(assigned["team"]), "spawn_index": int(assigned["slot"]) % 3, "bot": true})
else:
var sorted_peer_ids: Array = MatchNet.roster.keys()
sorted_peer_ids.sort()
for peer_id in sorted_peer_ids:
var info: MatchNet.PlayerInfo = MatchNet.roster[peer_id]
var spawn_index: int = team_counts.get(info.team, 0)
var spawn_index: int = info.spawn_index if info.spawn_index >= 0 else team_counts.get(info.team, 0)
if info.spawn_index < 0:
team_counts[info.team] = spawn_index + 1
spawn_entries.append({"peer_id": peer_id, "info": info, "team": info.team, "spawn_index": spawn_index, "bot": false})
for entry: Dictionary in spawn_entries:
var peer_id: int = int(entry["peer_id"])
var info: MatchNet.PlayerInfo = entry["info"]
var team: int = int(entry["team"])
var spawn_index: int = int(entry["spawn_index"])
var slot := SlotInfo.new()
slot.peer_id = peer_id
slot.team = info.team
slot.team = team
slot.spawn_index = spawn_index
slot.player_name = info.player_name
slot.controller = RLShipController.new()
slot.ship = spawn_ship(info.team, spawn_index, slot.controller)
slot.player_name = "Bot %d" % spawn_index if bool(entry["bot"]) else info.player_name
slot.player_identity = "" if bool(entry["bot"]) else MatchNet.player_identity(peer_id)
slot.controller = _build_opponent(bot_model_path, bot_reaction_ticks, bot_action_noise, "NetworkedMatch") if bool(entry["bot"]) else RLShipController.new()
slot.ship = spawn_ship(team, spawn_index, slot.controller)
_slots.append(slot)
peer_ids.append(peer_id)
teams.append(info.team)
teams.append(team)
spawn_indices.append(spawn_index)
MatchSim.send_match_config(arena_path, peer_ids, teams, spawn_indices)
@@ -648,6 +688,8 @@ func _apply_match_state(new_state: int, at_tick: int) -> void:
# Kickoff is over: bodies move again, and the clock resumes.
_pending_freeze_tick = -1
_set_bodies_frozen(false)
if new_state == MatchState.State.OVERTIME:
_overtime_deadline_tick = at_tick + int(_max_overtime_seconds * SimConstants.TICK_HZ)
# The clock only advances during live play (§6.2 step 9). Derived here
# rather than tracked separately so it cannot disagree with the state.
var was_running := _clock_running
@@ -964,12 +1006,23 @@ func _broadcast_clock_state() -> void:
func _on_disconnected_from_server() -> void:
if _planned_server_shutdown:
return
# Deferred: this arrives from inside NetworkManager's poll, and gotcha 27
# requires change_scene_to_file never run synchronously from a callback
# mid-traversal.
get_tree().change_scene_to_file.call_deferred(ScenePaths.MAIN_MENU)
func _on_server_shutdown(reason: String) -> void:
if multiplayer.is_server() or _planned_server_shutdown:
return
_planned_server_shutdown = true
print("NetworkedMatch: server shutdown notice: %s" % reason)
NetworkManager.shutdown()
get_tree().change_scene_to_file.call_deferred(ScenePaths.LOBBY)
func _on_match_bootstrap_received(state: int, at_tick: int, new_score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int, remaining_ticks: int) -> void:
score = new_score.duplicate()
score_changed.emit(score.duplicate())
@@ -1007,11 +1060,13 @@ func _update_clock() -> void:
# --- §6.2 step 10: full time, overtime, results (task 5.5) -----------------
func _enter_results(winning_team: int) -> void:
func _enter_results(winning_team: int, integrity_state := "CERTIFIED") -> void:
_match_over = true
_clock_running = false
_set_bodies_frozen(true)
match_ended.emit(winning_team, score.duplicate())
if multiplayer.is_server() and MatchNet.submit_authoritative_result(score, integrity_state):
_awaiting_result_submission = true
ServerLog.info("match_ended", {"score_0": score.get(0, 0), "score_1": score.get(1, 0), "overtime": _in_overtime})
_set_match_state(MatchState.State.RESULTS)
@@ -1053,6 +1108,12 @@ func _update_match_state() -> void:
else:
_enter_results(_winning_team())
return
if match_state == MatchState.State.OVERTIME and _overtime_deadline_tick >= 0 and now >= _overtime_deadline_tick:
# Golden goal remains clockless to players, but an operational bound is
# necessary: a stalled draw must finish while its allocated credential is
# valid. REVIEW completes lifecycle delivery without rating either side.
_enter_results(-1, "REVIEW")
return
if _state_deadline_tick < 0 or now < _state_deadline_tick:
return
match match_state:
@@ -1064,6 +1125,8 @@ func _update_match_state() -> void:
_set_match_state(MatchState.State.WARMUP)
_begin_kickoff()
MatchState.State.RESULTS:
if _awaiting_result_submission:
return
# §6.2 step 10: clients return to the LOBBY, never the main menu —
# a community server that empties every 2.5 minutes is dead on
# arrival. The state change is what moves both sides; the server
@@ -1072,6 +1135,18 @@ func _update_match_state() -> void:
get_tree().change_scene_to_file.call_deferred(ScenePaths.LOBBY)
func _on_result_submission_accepted() -> void:
if not multiplayer.is_server() or not _awaiting_result_submission:
return
_awaiting_result_submission = false
_state_deadline_tick = Engine.get_physics_frames()
func _on_result_submission_retrying(http_code: int) -> void:
if multiplayer.is_server() and _awaiting_result_submission:
ServerLog.warn("result_submission_retrying", {"http_code": http_code})
func _on_state_change_received(state: int, at_tick: int) -> void:
# Client path. MatchSim already rejected an unknown state value, and the
# server is the only peer allowed to send this (rpc "authority").
@@ -1206,12 +1281,12 @@ func _build_takeover_controller() -> ShipController:
# Called when a peer joins while this match is already running. Returns true if
# it reclaimed a reserved slot (§6.4's 30s identity-keyed reservation).
func _try_reclaim_slot(peer_id: int, player_name: String) -> bool:
func _try_reclaim_slot(peer_id: int, player_identity: String, player_name: String) -> bool:
if not multiplayer.is_server():
return false
var now := Engine.get_physics_frames()
for slot in _slots:
if not slot.disconnected or slot.player_name == "" or slot.player_name != player_name:
if not slot.disconnected or slot.player_name == "" or not MatchNet.reservation_identity_matches(slot.player_identity, player_identity, slot.player_name, player_name):
continue
if slot.reserved_until_tick >= 0 and now > slot.reserved_until_tick:
continue # reservation lapsed; this is a fresh joiner, not a return
@@ -1246,7 +1321,7 @@ func _try_reclaim_slot(peer_id: int, player_name: String) -> bool:
func _on_player_joined_midmatch(peer_id: int, player_name: String) -> void:
if not multiplayer.is_server() or _slots.is_empty():
return
if _try_reclaim_slot(peer_id, player_name):
if _try_reclaim_slot(peer_id, MatchNet.player_identity(peer_id), player_name):
return
if _max_spectators >= 0 and _spectator_count() > _max_spectators:
print("NetworkedMatch: spectator cap (%d) reached, disconnecting peer %d" % [_max_spectators, peer_id])
@@ -1262,7 +1337,7 @@ func _on_player_joined_midmatch(peer_id: int, player_name: String) -> void:
# in _promote_late_joiners(). Queued in arrival order and consumed from the
# front, so waiting is first-come-first-served rather than whichever slot
# index happens to free up first.
_late_joiners.append({"peer_id": peer_id, "player_name": player_name})
_late_joiners.append({"peer_id": peer_id, "player_name": player_name, "player_identity": MatchNet.player_identity(peer_id)})
print("NetworkedMatch: peer %d (%s) joined mid-match; spectating until the next kickoff" % [peer_id, player_name])
@@ -1302,6 +1377,7 @@ func _promote_late_joiners() -> void:
var joiner_peer := int(joiner["peer_id"])
slot.peer_id = joiner_peer
slot.player_name = String(joiner["player_name"])
slot.player_identity = String(joiner.get("player_identity", ""))
slot.disconnected = false
slot.reserved_until_tick = -1
# Same reasoning as the reclaim path: the arriving client numbers its
@@ -2140,6 +2216,12 @@ func get_net_debug_stats() -> Dictionary:
"ball_proxy_moved_before_authority": _ball_proxy_moved_before_authority_count > 0,
"ball_proxy_moved_before_authority_count": _ball_proxy_moved_before_authority_count,
"ball_authority_changed_since_contact": _ball_authority_changed_since_contact,
# p95 alongside p99. A p99 over a few hundred samples is only its worst
# handful, so on a loaded host it reports scheduling jitter as much as
# interpolation quality. p95 is stable enough to carry a tight bar,
# leaving p99 to catch genuine tail blow-ups.
"remote_residual_position_p95": _remote_percentile(_remote_position_residuals, 0.95),
"remote_residual_rotation_p95": _remote_percentile(_remote_rotation_residuals, 0.95),
"remote_residual_position_p99": _remote_percentile(_remote_position_residuals, 0.99),
"remote_residual_rotation_p99": _remote_percentile(_remote_rotation_residuals, 0.99),
"latest_prediction_error": _last_local_prediction_comparison.get("position_error", Vector3.ZERO),
+1 -1
View File
@@ -5,7 +5,7 @@ extends CanvasLayer
# Performance monitors; never touches rendering or gameplay state. Exists so
# 0.17/0.17b's graphics presets and resolution scaling are self-diagnosing —
# TIME_PROCESS vs total frame time tells the player whether they're CPU- or
# GPU-bound. See multiplayer-todo.md task 0.20.
# GPU-bound. See multiplayer-next.md task 0.20.
# ~2s of history at 60 fps; enough to make p50/p99 meaningful without the
# history itself being a rate-dependent quantity.
+104
View File
@@ -0,0 +1,104 @@
class_name RankedProfileState
extends RefCounted
# Read-only server projection. The client deliberately stores no tier bands
# or rating formula: it displays the backend's committed view verbatim after
# validating the shape and numeric safety of the response.
var available := false
var rating := 0.0
var rd := 0.0
var volatility := 0.0
var ranked_games := 0
var tier := ""
var provisional := false
var season_id := ""
var season_ends_at_unix := 0
var error_message := ""
func apply(payload: Dictionary) -> bool:
var required := ["rating", "rd", "volatility", "ranked_games", "tier", "provisional"]
for key in required:
if not payload.has(key):
return _reject("Profile response is missing " + key)
if not (payload["rating"] is int or payload["rating"] is float) or not (payload["rd"] is int or payload["rd"] is float) or not (payload["volatility"] is int or payload["volatility"] is float) or not (payload["ranked_games"] is int or payload["ranked_games"] is float) or not payload["tier"] is String or not payload["provisional"] is bool:
return _reject("Profile response contains invalid types")
var next_rating := float(payload["rating"])
var next_rd := float(payload["rd"])
var next_volatility := float(payload["volatility"])
var next_games := int(payload["ranked_games"])
var next_tier := String(payload["tier"])
if not is_finite(next_rating) or not is_finite(next_rd) or not is_finite(next_volatility) or not _valid_nonnegative_integer(payload["ranked_games"]) or next_rating < 0.0 or next_rd < 0.0 or next_volatility < 0.0 or next_games < 0 or not _valid_tier(next_tier):
return _reject("Profile response contains invalid values")
rating = next_rating
rd = next_rd
volatility = next_volatility
ranked_games = next_games
tier = next_tier
provisional = bool(payload["provisional"])
season_id = ""
if payload.has("season_id"):
if not payload["season_id"] is String or not is_valid_opaque_id(String(payload["season_id"])):
return _reject("Profile response contains invalid season identifier")
season_id = String(payload["season_id"])
season_ends_at_unix = 0
if payload.has("season_ends_at"):
if not payload["season_ends_at"] is String or not is_valid_season_timestamp(String(payload["season_ends_at"])):
return _reject("Profile response contains invalid season expiry")
var parsed_season_end := Time.get_unix_time_from_datetime_string(String(payload["season_ends_at"]))
if parsed_season_end < 0:
return _reject("Profile response contains invalid season expiry")
season_ends_at_unix = int(parsed_season_end)
available = true
error_message = ""
return true
static func is_valid_season_timestamp(value: String) -> bool:
if value.is_empty():
return false
var timestamp_pattern := RegEx.create_from_string("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$")
return timestamp_pattern.search(value) != null
static func is_valid_opaque_id(value: String) -> bool:
if value.length() < 16 or value.length() > 128:
return false
var id_pattern := RegEx.create_from_string("^[A-Za-z0-9_-]+$")
return id_pattern.search(value) != null
static func _valid_nonnegative_integer(value: Variant) -> bool:
if value is int:
return int(value) >= 0
if value is float:
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
return false
static func _valid_tier(value: String) -> bool:
return value in ["PROVISIONAL", "BRONZE", "SILVER", "GOLD", "PLATINUM", "DIAMOND"]
func set_error(reason: String) -> void:
available = false
error_message = reason
func display_text(now_unix: int = -1) -> String:
if not available:
return error_message if not error_message.is_empty() else "Ranked profile unavailable"
var status := "Provisional" if provisional else tier
var text := "%s · %d ranked game%s" % [status, ranked_games, "" if ranked_games == 1 else "s"]
if season_ends_at_unix > 0:
var current_unix := int(Time.get_unix_time_from_system()) if now_unix < 0 else now_unix
var remaining_days := maxi(0, int(ceil(float(season_ends_at_unix - current_unix) / 86400.0)))
text += " · Season ends in %dd" % remaining_days
return text
func _reject(reason: String) -> bool:
available = false
error_message = reason
return false
+1 -1
View File
@@ -1,7 +1,7 @@
class_name ReplayLog
extends RefCounted
# Append-only binary server replay log (multiplayer-todo.md task 5.10).
# Append-only binary server replay log (multiplayer-next.md task 5.10).
#
# The highest-value debuggability investment in Phase 5, and cheap precisely
# because the packets are ALREADY flat bytes: this stores them verbatim rather
+179 -2
View File
@@ -1,5 +1,12 @@
extends Node
const NetCodec = preload("res://scripts/net_codec.gd")
const ServerControlScript = preload("res://scripts/server_control.gd")
const AgonesSDKScript = preload("res://scripts/agones_sdk.gd")
const AssignmentState = preload("res://scripts/assignment_state.gd")
const ConnectionLeaseClientScript = preload("res://scripts/connection_lease_client.gd")
const ServerResultClientScript = preload("res://scripts/server_result_client.gd")
# Headless dedicated server entry point (task 1.6). Parses CLI args, hosts
# via NetworkManager, logs structured lines, and watches for physics-tick
# overrun (§9 gotcha 9: Engine.max_physics_steps_per_frame defaults to 8;
@@ -24,6 +31,12 @@ extends Node
var _last_physics_frame := 0
var config: ServerConfig = null
var _watchdog_armed := false # skip the first _process(): engine startup scheduling can batch several physics frames before the first idle frame runs, which isn't a real overrun
var _control: ServerControl = null
var _match_loop: ServerMatchLoop = null
var _agones = null
var _connection_leases = null
var _result_client = null
var _drain_requested := false
func _ready() -> void:
@@ -51,6 +64,93 @@ func _ready() -> void:
ServerLog.configure(String(config.get_value("log-level")))
var port := int(config.get_value("port"))
var max_clients := int(config.get_value("max-clients"))
var allocated_mode := bool(config.get_value("allocated-mode"))
var assigned_transport := String(config.get_value("transport"))
# Hosted SDR is not wired into the Godot transport layer yet. Refuse the
# allocated launch rather than silently opening an ENet endpoint that does
# not match the signed assignment's transport contract.
if allocated_mode and assigned_transport != NetworkManager.TRANSPORT_ENET:
printerr("cosmic-clash-server: allocated transport '%s' is not supported by this build" % assigned_transport)
get_tree().quit(1)
return
# Agones injects its HTTP port into every managed game-server container.
# Keep lifecycle readiness and health active in the reduced kind smoke even
# though that environment intentionally omits allocation/roster semantics.
var agones_managed := not OS.get_environment("AGONES_SDK_HTTP_PORT").is_empty()
if allocated_mode or agones_managed:
_control = ServerControlScript.new()
_control.name = "ServerControl"
_control.drain_requested.connect(_on_drain_requested)
_control.initial_connect_ready.connect(_on_initial_connect_ready)
get_tree().root.add_child.call_deferred(_control)
var control_err := _control.start(int(config.get_value("readiness-port")), OS.get_environment(String(config.get_value("drain-token-env"))))
if control_err != OK:
printerr("cosmic-clash-server: refusing to start with invalid readiness control port")
get_tree().quit(1)
return
if agones_managed:
_agones = AgonesSDKScript.new()
_agones.name = "AgonesSDK"
# Configure before parenting, then request health and defer the add like
# every other node here (§9 gotcha 27: add_child() on get_tree().root
# from inside _ready() is refused because the tree is still attaching
# this very node, and the refusal is not catchable from GDScript). The
# SDK arms its own timer in _ready(), so nothing depends on the order
# these deferred calls happen to flush in.
if _agones.configure_from_environment():
_agones.start_health()
else:
# Never silent: without this the log looks identical to a healthy
# server right up until Agones recycles it.
printerr("cosmic-clash-server: AGONES_SDK_HTTP_PORT is missing or invalid; Agones health pings are disabled")
get_tree().root.add_child.call_deferred(_agones)
if allocated_mode:
var roster_file := String(config.get_value("join-authorisations-file"))
var key_file := String(config.get_value("join-authorisations-key-file"))
var roster_json := FileAccess.get_file_as_string(roster_file)
var signing_keys := _load_join_signing_keys(key_file)
var roster_tokens = JSON.parse_string(roster_json)
if not roster_tokens is Array or roster_tokens.is_empty() or signing_keys.is_empty() or not MatchNet.configure_join_authorisations(roster_tokens, {
"match_id": String(config.get_value("match-id")),
"server_id": String(config.get_value("server-id")),
"protocol": str(NetCodec.PROTOCOL_VERSION),
"protocol_version": NetCodec.PROTOCOL_VERSION,
}, signing_keys) or MatchNet.assigned_player_slots().size() != roster_tokens.size():
printerr("cosmic-clash-server: refusing to start with invalid join-authorisations-file")
get_tree().quit(1)
return
# An allocated process owns exactly the roster issued for this match.
# Never let the general-purpose direct-server default (one player) start
# an allocated match with only a partial assignment admitted.
config.values["min-players"] = required_min_players(true, roster_tokens.size(), int(config.get_value("min-players")))
_connection_leases = ConnectionLeaseClientScript.new()
_connection_leases.name = "ConnectionLeases"
var lease_url := OS.get_environment("COSMIC_CLASH_CONTROL_PLANE_URL")
var lease_token := OS.get_environment("COSMIC_CLASH_WORKLOAD_TOKEN")
if _connection_leases.configure(lease_url, lease_token, String(config.get_value("match-id")), String(config.get_value("server-id"))):
_connection_leases.reconciliation_failed.connect(_on_connection_lease_reconciliation_failed)
get_tree().root.add_child.call_deferred(_connection_leases)
MatchNet.configure_connection_lease_callbacks(_connection_leases.claim, _connection_leases.record_disconnect)
else:
_connection_leases.queue_free()
_connection_leases = null
# Allocated matches must never fall back to an in-memory connection
# generation. Doing so would admit a player without the durable fence
# that prevents a second process (or a stale peer) from owning the same
# ranked slot. Direct/community servers do not enter this branch.
printerr("cosmic-clash-server: refusing allocated startup without connection-lease configuration")
get_tree().quit(1)
return
_result_client = ServerResultClientScript.new()
_result_client.name = "ServerResults"
if not _result_client.configure(lease_url, lease_token, String(config.get_value("match-id")), String(config.get_value("server-id"))):
printerr("cosmic-clash-server: refusing allocated startup without result-submission configuration")
get_tree().quit(1)
return
_result_client.accepted.connect(func(): MatchNet.result_submission_accepted.emit())
_result_client.retrying.connect(func(http_code): MatchNet.result_submission_retrying.emit(http_code))
get_tree().root.add_child.call_deferred(_result_client)
MatchNet.configure_result_submission(_result_client.submit)
NetworkManager.client_connected.connect(_on_client_connected)
NetworkManager.client_disconnected.connect(_on_client_disconnected)
@@ -62,12 +162,19 @@ func _ready() -> void:
ServerLog.error("server_boot_failed", {"port": port, "error": error_string(err)})
get_tree().quit(1)
return
if _control != null:
_control.set_process_ready(true)
_install_match_loop()
ServerLog.info("server_started", {
"port": port, "max_clients": max_clients, "log_level": ServerLog.level_name(),
"min_players": int(config.get_value("min-players")),
"max_matches": int(config.get_value("max-matches")),
"max_matches": 1 if allocated_mode else int(config.get_value("max-matches")),
"arena_rotation": String(config.get_value("arena-rotation")),
"allocated_mode": allocated_mode,
"match_id": String(config.get_value("match-id")) if allocated_mode else "",
"server_id": String(config.get_value("server-id")) if allocated_mode else "",
"region": String(config.get_value("region")) if allocated_mode else "",
"transport": assigned_transport if allocated_mode else NetworkManager.TRANSPORT_ENET,
})
_last_physics_frame = Engine.get_physics_frames()
@@ -78,16 +185,29 @@ func _ready() -> void:
# it started. Same constraint the smoke-test hooks document.
func _install_match_loop() -> void:
var loop := ServerMatchLoop.new()
_match_loop = loop
loop.name = "ServerMatchLoop"
loop.min_players = int(config.get_value("min-players"))
loop.start_countdown_seconds = float(config.get_value("start-countdown"))
loop.max_matches = int(config.get_value("max-matches"))
loop.max_matches = 1 if bool(config.get_value("allocated-mode")) else int(config.get_value("max-matches"))
loop.rotation_mode = String(config.get_value("arena-rotation"))
loop.allocated_mode = bool(config.get_value("allocated-mode"))
loop.allocated_playlist = String(config.get_value("playlist"))
loop.allocated_roster_size = MatchNet.assigned_player_slots().size() if loop.allocated_mode else 0
loop.allocated_arena_path = String(config.get_value("arena-path"))
# The backend's fair timeout starts only after durable assignment-ready.
# When a control plane is present, the supervisor arms this loop through
# the authenticated local control endpoint after that transition commits.
loop.allocated_admission_armed = not loop.allocated_mode or OS.get_environment("COSMIC_CLASH_INITIAL_CONNECT_SIGNAL_REQUIRED") != "1"
get_tree().root.add_child.call_deferred(loop)
func _process(_delta: float) -> void:
NetworkManager.poll()
if _drain_requested:
var scene := get_tree().current_scene
if not (is_instance_valid(scene) and scene.is_in_group("game")) and MatchNet.roster.is_empty():
get_tree().quit(0)
var current := Engine.get_physics_frames()
var steps := current - _last_physics_frame
_last_physics_frame = current
@@ -119,3 +239,60 @@ func _on_player_joined(peer_id: int, player_name: String) -> void:
func _on_player_left(peer_id: int) -> void:
ServerLog.info("player_left", {"peer_id": peer_id, "roster": MatchNet.roster.size()})
func _on_drain_requested() -> void:
_drain_requested = true
MatchNet.admissions_open = false
MatchNet.broadcast_server_shutdown("server_draining")
ServerLog.info("server_draining", {"reason": "control_request"})
func _on_initial_connect_ready() -> void:
if _match_loop != null and is_instance_valid(_match_loop):
_match_loop.arm_allocated_admission()
ServerLog.info("initial_connect_window_started", {"match_id": String(config.get_value("match-id"))})
func _on_connection_lease_reconciliation_failed(reason: String) -> void:
# A durable/local divergence means this process can no longer prove that a
# future generation is globally current. Preserve the live match but close
# admission so it cannot mint additional ambiguous leases.
MatchNet.admissions_open = false
ServerLog.error("connection_lease_reconciliation_failed", {"reason": reason})
static func valid_connection_report_configuration(base_url: String, workload_token: String, match_id: String, server_id: String, player_id: String) -> bool:
return ConnectionLeaseClientScript.valid_configuration(base_url, workload_token, match_id, server_id) and AssignmentState.is_valid_opaque_id(player_id)
static func required_min_players(allocated: bool, roster_size: int, configured: int) -> int:
if allocated and roster_size > 0:
return roster_size
return configured
# The join-signing key file maps key ID -> base64 raw key, so the allocator can
# rotate the signing key without invalidating authorisations already issued for
# in-flight matches: a rotation publishes the new key alongside the old, and the
# old one is dropped only once no live match can still reference it.
#
# A file containing raw key bytes (no JSON object) is accepted as a single key
# under the empty ID, which is what an unrotated deployment and the local smoke
# fixtures use.
static func _load_join_signing_keys(key_file: String) -> Dictionary:
var raw := FileAccess.get_file_as_bytes(key_file)
if raw.is_empty():
return {}
var parsed = JSON.parse_string(raw.get_string_from_utf8())
if not parsed is Dictionary or (parsed as Dictionary).is_empty():
return {"": raw}
var keys := {}
for key_id in parsed:
var encoded = parsed[key_id]
if not encoded is String or String(encoded).is_empty():
return {}
var decoded := Marshalls.base64_to_raw(String(encoded))
if decoded.is_empty():
return {}
keys[str(key_id)] = decoded
return keys
+72 -2
View File
@@ -1,7 +1,7 @@
class_name ServerConfig
extends RefCounted
# Dedicated-server configuration (multiplayer-todo.md task 6.3): one
# Dedicated-server configuration (multiplayer-next.md task 6.3): one
# declaration of every server flag, one parser, one `--help`.
#
# Standalone RefCounted with no scene or RPC dependency — same reason as
@@ -56,14 +56,32 @@ static func specs() -> Array[Spec]:
out.append(Spec.new("log-level", Kind.STRING, "info", "logging", "One of debug, info, warn, error"))
out.append(Spec.new("replay-log", Kind.STRING, "", "logging", "Path to record a binary replay log to; empty disables (see tools/replay_dump.gd)"))
out.append(Spec.new("match-length", Kind.FLOAT, 150.0, "match", "Regulation length in seconds"))
out.append(Spec.new("max-overtime-seconds", Kind.FLOAT, 900.0, "match", "Safety cap for sudden death; expiry records a REVIEW result without rating changes"))
out.append(Spec.new("max-matches", Kind.INT, 0, "match", "Exit cleanly after this many completed matches; 0 runs forever"))
out.append(Spec.new("min-players", Kind.INT, 1, "match", "Players required before a match starts"))
out.append(Spec.new("start-countdown", Kind.FLOAT, 5.0, "match", "Seconds to wait after min-players is met before starting"))
out.append(Spec.new("arena-rotation", Kind.STRING, "sequential", "match", "How the next arena is picked: sequential or random"))
out.append(Spec.new("arena-path", Kind.STRING, "", "match", "Allocated arena scene path; empty uses rotation"))
out.append(Spec.new("smoke-force-goal-after", Kind.FLOAT, -1.0, "match", "LOCAL TEST ONLY: force one server-authoritative goal this many seconds after play starts; -1 disables"))
out.append(Spec.new("fill-bots", Kind.BOOL, false, "match", "Give a disconnected player's ship to a bot instead of leaving it inert"))
out.append(Spec.new("slot-reservation-seconds", Kind.FLOAT, 30.0, "match", "How long a departed player's slot is held for their return"))
out.append(Spec.new("config", Kind.STRING, "", "general", "Path to a config file supplying defaults for any flag above"))
# Allocated-mode fields are opt-in. Empty defaults intentionally preserve
# the direct-IP/community-server path and its existing CLI/config surface.
out.append(Spec.new("allocated-mode", Kind.BOOL, false, "allocation", "Enable match-scoped allocation admission and lifecycle"))
out.append(Spec.new("match-id", Kind.STRING, "", "allocation", "Opaque allocated match identifier"))
out.append(Spec.new("server-id", Kind.STRING, "", "allocation", "Opaque allocated server identifier"))
out.append(Spec.new("playlist-version", Kind.STRING, "", "allocation", "Matchmaking playlist contract version"))
out.append(Spec.new("playlist", Kind.STRING, "", "allocation", "Allocated playlist: casual or ranked"))
out.append(Spec.new("client-build", Kind.STRING, "", "allocation", "Expected immutable client build identifier"))
out.append(Spec.new("assignment-expiry-unix", Kind.INT, 0, "allocation", "Unix expiry for the allocated assignment; must be in the future"))
out.append(Spec.new("server-image-digest", Kind.STRING, "", "allocation", "Expected immutable server image digest (sha256:...)"))
out.append(Spec.new("transport", Kind.STRING, "", "allocation", "Assigned transport: steam_sdr or enet"))
out.append(Spec.new("region", Kind.STRING, "", "allocation", "Assigned region: EU or NA"))
out.append(Spec.new("join-authorisations-file", Kind.STRING, "", "allocation", "JSON array of control-plane signed join envelopes mounted for this match"))
out.append(Spec.new("join-authorisations-key-file", Kind.STRING, "", "allocation", "HMAC-SHA256 key file for verifying mounted join envelopes"))
out.append(Spec.new("readiness-port", Kind.INT, 7780, "allocation", "Loopback HTTP port for allocated process-ready and drain control"))
out.append(Spec.new("drain-token-env", Kind.STRING, "COSMIC_CLASH_DRAIN_TOKEN", "allocation", "Environment variable containing the allocated drain bearer token"))
return out
@@ -231,10 +249,15 @@ func _validate() -> void:
var port := int(values["port"])
if port < 1 or port > 65535:
errors.append("--port must be 1-65535, got %d" % port)
var readiness_port := int(values["readiness-port"])
if readiness_port < 1 or readiness_port > 65535:
errors.append("--readiness-port must be 1-65535, got %d" % readiness_port)
if int(values["max-clients"]) < 1:
errors.append("--max-clients must be at least 1, got %d" % int(values["max-clients"]))
if float(values["match-length"]) <= 0.0:
errors.append("--match-length must be positive, got %s" % str(values["match-length"]))
if float(values["max-overtime-seconds"]) <= 0.0:
errors.append("--max-overtime-seconds must be positive, got %s" % str(values["max-overtime-seconds"]))
if int(values["max-matches"]) < 0:
errors.append("--max-matches must be 0 or more, got %d" % int(values["max-matches"]))
if int(values["min-players"]) < 1:
@@ -249,6 +272,53 @@ func _validate() -> void:
var rotation := String(values["arena-rotation"])
if not rotation in ["sequential", "random"]:
errors.append("--arena-rotation must be sequential or random, got '%s'" % rotation)
var arena_path := String(values["arena-path"])
if not arena_path.is_empty() and not arena_path in ArenaRegistry.rotation_paths():
errors.append("--arena-path must be a ranked-eligible ArenaRegistry path, got '%s'" % arena_path)
if bool(values["allocated-mode"]):
for key in ["match-id", "server-id", "playlist-version", "playlist", "client-build", "assignment-expiry-unix", "server-image-digest", "transport", "region"]:
if str(values[key]).is_empty():
errors.append("--allocated-mode requires --%s" % key)
if not _is_opaque_id(String(values["match-id"])):
errors.append("--match-id must be an opaque ID of 16-128 safe characters")
if not _is_opaque_id(String(values["server-id"])):
errors.append("--server-id must be an opaque ID of 16-128 safe characters")
if int(values["assignment-expiry-unix"]) <= int(Time.get_unix_time_from_system()):
errors.append("--assignment-expiry-unix must be in the future")
if String(values["join-authorisations-file"]).is_empty():
errors.append("--join-authorisations-file is required in allocated mode")
if String(values["join-authorisations-key-file"]).is_empty():
errors.append("--join-authorisations-key-file is required in allocated mode")
var digest := String(values["server-image-digest"])
if not _is_sha256_digest(digest):
errors.append("--server-image-digest must be sha256:<64 hex characters>")
var transport := String(values["transport"])
if not transport in ["steam_sdr", "enet"]:
errors.append("--transport must be steam_sdr or enet, got '%s'" % transport)
var region := String(values["region"])
if not region in ["EU", "NA"]:
errors.append("--region must be EU or NA, got '%s'" % region)
var playlist := String(values["playlist"])
if not playlist in ["casual", "ranked"]:
errors.append("--playlist must be casual or ranked, got '%s'" % playlist)
if playlist == "ranked" and arena_path.is_empty():
errors.append("--allocated-mode ranked matches require --arena-path")
static func _is_sha256_digest(value: String) -> bool:
if not value.begins_with("sha256:") or value.length() != 71:
return false
for c in value.substr(7):
if not c.to_lower() in "0123456789abcdef":
return false
return true
static func _is_opaque_id(value: String) -> bool:
if value.length() < 16 or value.length() > 128:
return false
var resource_pattern := RegEx.create_from_string("^[A-Za-z0-9_-]+$")
return resource_pattern.search(value) != null
static func _kind_name(kind: int) -> String:
@@ -276,7 +346,7 @@ static func help_text() -> String:
lines.append("")
lines.append("The command line overrides the config file, which overrides the defaults")
lines.append("shown below. An unknown flag is an error, not a warning.")
var sections := ["general", "network", "match", "logging"]
var sections := ["general", "network", "match", "logging", "allocation"]
var all := specs()
for section in sections:
lines.append("")
+120
View File
@@ -0,0 +1,120 @@
class_name ServerControl
extends Node
# Small loopback HTTP control surface for lifecycle-managed servers. The Go
# supervisor uses GET /ready as the explicit process-ready probe and POST
# /drain during a controlled termination. Direct/community servers outside
# Agones do not start this node.
signal drain_requested
signal initial_connect_ready
var _listener := TCPServer.new()
var _peers: Array = []
var _ready_for_connections := false
var _draining := false
var _drain_token := ""
func start(port: int, drain_token: String = "") -> Error:
if port < 1 or port > 65535:
return ERR_INVALID_PARAMETER
_drain_token = drain_token
return _listener.listen(port, "127.0.0.1")
func stop() -> void:
_listener.stop()
for peer in _peers:
if is_instance_valid(peer):
peer.disconnect_from_host()
_peers.clear()
func set_process_ready(value: bool) -> void:
_ready_for_connections = value and not _draining
func is_draining() -> bool:
return _draining
func _exit_tree() -> void:
stop()
func _process(_delta: float) -> void:
while _listener.is_connection_available():
_peers.append(_listener.take_connection())
for i in range(_peers.size() - 1, -1, -1):
var peer: StreamPeerTCP = _peers[i]
if peer.get_status() != StreamPeerTCP.STATUS_CONNECTED:
_peers.remove_at(i)
continue
var available := peer.get_available_bytes()
if available <= 0:
continue
var request := peer.get_utf8_string(available)
if "\r\n\r\n" not in request:
continue
_respond(peer, request)
_peers.remove_at(i)
func _respond(peer: StreamPeerTCP, request: String) -> void:
var lines := request.split("\r\n")
var first := lines[0].split(" ") if not lines.is_empty() else PackedStringArray()
var method := String(first[0]) if first.size() > 0 else ""
var path := String(first[1]) if first.size() > 1 else ""
var status := 404
var reason := "Not Found"
var body := ""
if method in ["GET", "POST"] and path == "/ready":
status = 200 if _ready_for_connections else 503
reason = "OK" if status == 200 else "Service Unavailable"
elif method in ["GET", "POST"] and path == "/health":
status = 200
reason = "OK"
elif method == "POST" and path == "/drain":
var supplied := ""
for line in lines:
if line.begins_with("Authorization: Bearer "):
supplied = line.substr("Authorization: Bearer ".length())
if _drain_token.is_empty() or not _constant_time_equal(supplied, _drain_token):
status = 401
reason = "Unauthorized"
else:
_draining = true
_ready_for_connections = false
drain_requested.emit()
status = 202
reason = "Accepted"
elif method == "POST" and path == "/initial-connect-ready":
var supplied := ""
for line in lines:
if line.begins_with("Authorization: Bearer "):
supplied = line.substr("Authorization: Bearer ".length())
if _drain_token.is_empty() or not _constant_time_equal(supplied, _drain_token):
status = 401
reason = "Unauthorized"
else:
initial_connect_ready.emit()
status = 202
reason = "Accepted"
else:
status = 405 if method in ["GET", "POST"] else 400
reason = "Method Not Allowed" if status == 405 else "Bad Request"
body = "{\"status\":\"%s\"}" % ("ready" if status == 200 else "not_ready")
var response := "HTTP/1.1 %d %s\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s" % [status, reason, body.to_utf8_buffer().size(), body]
peer.put_data(response.to_utf8_buffer())
peer.disconnect_from_host()
func _constant_time_equal(a: String, b: String) -> bool:
var left := a.to_utf8_buffer()
var right := b.to_utf8_buffer()
var difference := left.size() ^ right.size()
var length := mini(left.size(), right.size())
for i in length:
difference |= left[i] ^ right[i]
return difference == 0
+1 -1
View File
@@ -1,7 +1,7 @@
class_name ServerLog
extends RefCounted
# Structured server logging (multiplayer-todo.md task 6.4).
# Structured server logging (multiplayer-next.md task 6.4).
#
# Extracted from server_boot.gd's private `_log`, which could only ever see
# what the boot scene itself observed: connects, disconnects, roster changes
+76 -2
View File
@@ -1,7 +1,7 @@
class_name ServerMatchLoop
extends Node
# The dedicated server's match loop (multiplayer-todo.md task 6.5).
# The dedicated server's match loop (multiplayer-next.md task 6.5).
#
# THIS CLOSES A GAP NO TASK OWNED. Task 6.2 asks for "the exported binary runs
# a full match headless", but nothing in the product ever started a match:
@@ -35,17 +35,27 @@ extends Node
signal match_starting(arena_path: String, match_index: int)
const POLL_INTERVAL_MS := 250
const ALLOCATED_WAIT := "WAIT"
const ALLOCATED_READY := "READY"
const ALLOCATED_CANCEL := "CANCEL"
const ALLOCATED_START_WITH_BOTS := "START_WITH_BOTS"
var min_players := 1
var start_countdown_seconds := 5.0
var max_matches := 0 # 0 = run forever
var rotation_mode := "sequential"
var allocated_mode := false
var allocated_playlist := ""
var allocated_roster_size := 0
var allocated_arena_path := ""
var allocated_admission_armed := true
var matches_completed := 0
var _countdown_started_ms := -1
var _match_active := false
var _next_poll_ms := 0
var _shutting_down := false
var _allocated_connect_started_ms := -1
func _process(_delta: float) -> void:
@@ -57,10 +67,74 @@ func _process(_delta: float) -> void:
_next_poll_ms = now + POLL_INTERVAL_MS
if _match_active:
_poll_match_end()
else:
if allocated_mode:
_poll_allocated_match_start(now)
else:
_poll_match_start(now)
func _poll_allocated_match_start(now: int) -> void:
if not allocated_admission_armed:
return
if _allocated_connect_started_ms < 0:
_allocated_connect_started_ms = now
var connected := MatchNet.roster.size()
var has_team_zero := false
var has_team_one := false
for info: MatchNet.PlayerInfo in MatchNet.roster.values():
has_team_zero = has_team_zero or info.team == 0
has_team_one = has_team_one or info.team == 1
var action := allocated_initial_connect_action(allocated_playlist, now - _allocated_connect_started_ms, connected, allocated_roster_size, has_team_zero, has_team_one)
if action == ALLOCATED_READY:
_poll_match_start(now)
return
if action == ALLOCATED_CANCEL:
var reason := "ranked_initial_connect_timeout" if allocated_playlist == "ranked" else "casual_initial_connect_ineligible"
_cancel_allocated_no_show(reason, connected)
return
if action == ALLOCATED_START_WITH_BOTS:
NetworkedMatch.server_bot_fill_override = true
_poll_match_start(now)
func arm_allocated_admission() -> void:
allocated_admission_armed = true
_allocated_connect_started_ms = -1
static func allocated_initial_connect_action(playlist: String, elapsed_ms: int, connected: int, expected: int, has_team_zero: bool, has_team_one: bool) -> String:
if elapsed_ms < 0 or connected < 0 or expected < 1:
return ALLOCATED_CANCEL
if playlist == "ranked":
if expected != 6:
return ALLOCATED_CANCEL
if connected >= expected:
return ALLOCATED_READY
return ALLOCATED_CANCEL if elapsed_ms >= 30000 else ALLOCATED_WAIT
if playlist == "casual":
if expected < 2 or expected > 6:
return ALLOCATED_CANCEL
if connected >= expected:
if expected == 6:
return ALLOCATED_READY
return ALLOCATED_START_WITH_BOTS if has_team_zero and has_team_one else ALLOCATED_CANCEL
if elapsed_ms < 60000:
return ALLOCATED_WAIT
return ALLOCATED_START_WITH_BOTS if connected >= 2 and has_team_zero and has_team_one else ALLOCATED_CANCEL
return ALLOCATED_CANCEL
func _cancel_allocated_no_show(reason: String, connected: int) -> void:
if _shutting_down:
return
_shutting_down = true
ServerLog.info("initial_connect_cancelled", {"reason": reason, "connected": connected, "expected": allocated_roster_size})
MatchNet.broadcast_server_shutdown(reason)
await get_tree().create_timer(0.3).timeout
NetworkManager.shutdown()
get_tree().quit(0)
# A match is over when the match scene is gone. NetworkedMatch returns both
# peers to the lobby itself at RESULTS (§6.2 step 10) and aborts to the lobby
# when everyone has left (§6.4), so "the scene we started is no longer the
@@ -108,7 +182,7 @@ func _poll_match_start(now: int) -> void:
func _start_match() -> void:
var arena_path := ArenaRegistry.path_for_match(matches_completed, rotation_mode)
var arena_path := allocated_arena_path if allocated_mode and not allocated_arena_path.is_empty() else ArenaRegistry.path_for_match(matches_completed, rotation_mode)
# The match scene picks its own arena at random by default. Handing it one
# explicitly is what makes rotation a rotation rather than a coincidence.
NetworkedMatch.server_arena_override = arena_path
+91
View File
@@ -0,0 +1,91 @@
class_name ServerResultClient
extends Node
# The allocated server is the sole authority able to finish a match. Keep the
# match in RESULTS until the control plane has durably acknowledged this exact,
# idempotent payload: exiting first would strand the match in LIVE forever.
signal accepted
signal retrying(http_code: int)
const RETRY_SECONDS := 1.0
var _base_url := ""
var _workload_token := ""
var _match_id := ""
var _server_id := ""
var _submitting := false
func configure(base_url: String, workload_token: String, match_id: String, server_id: String) -> bool:
base_url = base_url.strip_edges().trim_suffix("/")
workload_token = workload_token.strip_edges()
if not valid_configuration(base_url, workload_token, match_id, server_id):
return false
_base_url = base_url
_workload_token = workload_token
_match_id = match_id
_server_id = server_id
return true
func submit(team_0: int, team_1: int, integrity_state := "CERTIFIED") -> void:
if _submitting or team_0 < 0 or team_1 < 0 or not integrity_state in ["CERTIFIED", "REVIEW"]:
return
_submitting = true
var nonce := result_nonce(_match_id, _server_id, team_0, team_1, integrity_state)
var key := "server-result-" + nonce
var payload := {
"match_id": _match_id,
"result_nonce": nonce,
"score": {"team_0": team_0, "team_1": team_1},
"integrity_state": integrity_state,
}
while is_inside_tree():
var response := await _send(payload, key)
if response_is_accepted(int(response.get("code", 0))):
_submitting = false
accepted.emit()
return
retrying.emit(int(response.get("code", 0)))
await get_tree().create_timer(RETRY_SECONDS).timeout
_submitting = false
func _send(payload: Dictionary, key: String) -> Dictionary:
var request := HTTPRequest.new()
request.timeout = 5.0
add_child(request)
var err := request.request("%s/v1/servers/%s/result" % [_base_url, _server_id.uri_encode()], [
"Authorization: Bearer " + _workload_token,
"Content-Type: application/json",
"Idempotency-Key: " + key,
], HTTPClient.METHOD_POST, JSON.stringify(payload))
if err != OK:
request.queue_free()
return {"code": 0}
var raw: Array = await request.request_completed
request.queue_free()
if int(raw[0]) != HTTPRequest.RESULT_SUCCESS:
return {"code": 0}
return {"code": int(raw[1])}
static func result_nonce(match_id: String, server_id: String, team_0: int, team_1: int, integrity_state: String) -> String:
# Result score is immutable once NetworkedMatch enters RESULTS. A deterministic
# nonce makes retries after a lost response provably the same submission.
return "result-" + (match_id + "\n" + server_id + "\n" + str(team_0) + "\n" + str(team_1) + "\n" + integrity_state).sha256_text()
static func response_is_accepted(http_code: int) -> bool:
# The documented endpoint acknowledges only after its serializable result
# transaction commits. Do not treat a generic 2xx as proof of completion.
return http_code == 202
static func valid_configuration(base_url: String, workload_token: String, match_id: String, server_id: String) -> bool:
if not (base_url.begins_with("http://") or base_url.begins_with("https://")) or base_url.contains("\n") or base_url.contains("\r") or base_url.contains("?") or base_url.contains("#") or base_url.contains("@"):
return false
if workload_token.is_empty() or workload_token.contains("\n") or workload_token.contains("\r"):
return false
return match_id.length() >= 8 and server_id.length() >= 8 and not match_id.contains("/") and not server_id.contains("/")
+1
View File
@@ -48,6 +48,7 @@ var _populating := false
func _ready() -> void:
AudioManager.bind_tree_buttons(self)
# An idle settings screen has no reason to render past the display's own
# refresh rate; _on_back_pressed only returns to another capped menu, so
# no uncap is needed there (contrast main_menu.gd's _leave_to_gameplay).
+5 -2
View File
@@ -145,7 +145,7 @@ func queue_teleport_with_velocity(to: Transform3D, new_linear_velocity: Vector3,
_has_pending_teleport = true
# --- Netcode correction hooks (Phase 4; see multiplayer-todo.md §4.4) ---
# --- Netcode correction hooks (Phase 4; see MULTIPLAYER_SPEC.md §4.4) ---
# Both stay zero until Phase 4 wires a reconciliation pass in, so the guarded
# hook in _integrate_forces below is a no-op today.
# Velocity delta from a soft correction, consumed once then cleared —
@@ -197,6 +197,7 @@ signal thrust_changed(thrust_percent: float)
signal angular_velocity_changed(angular_speed: float)
signal heading_changed(heading_degrees: float)
signal ball_contact(intensity: float, world_position: Vector3)
signal wall_contact(intensity: float)
# Performance optimization - track last emitted values to avoid unnecessary signals
var _last_speed: float = -1.0
@@ -222,7 +223,7 @@ var _engine_lights: Array[OmniLight3D] = []
# All rendered geometry (hull, canopy, engine cores/flames/lights, Nose,
# TailFin) parents under this instead of the RigidBody3D directly, so a
# future prediction correction (task 0.14) can offset the visual without
# moving the collider — see multiplayer-todo.md task 0.2. CollisionShape3D
# moving the collider — see multiplayer-next.md task 0.2. CollisionShape3D
# and the controller child correctly stay on the body itself.
@onready var visual: Node3D = $Visual
@@ -415,6 +416,8 @@ func is_turbo_active() -> bool:
func _on_body_entered(body: Node) -> void:
if body is StaticBody3D:
wall_contact.emit(clampf(linear_velocity.length() / maxf(max_speed, 0.001), 0.0, 1.0))
if not body is Ball:
return
var relative_speed := (linear_velocity - (body as Ball).linear_velocity).length()
+16
View File
@@ -38,6 +38,10 @@ extends AIController3D
# stone, matching ball_touch_cooldown_ticks's existing "stepping-stone, not
# the objective" framing.
@export_range(0.0, 1.0) var ball_touch_direction_floor := 0.3
# Fraction of a touch payout shared with teammates. Zero preserves all
# existing 1v1/curriculum reward functions; in teamplay the shared amount is
# divided across teammates and never exceeds the touching ship's payout.
@export_range(0.0, 1.0) var team_touch_credit_weight := 0.0
@export var velocity_to_ball_weight := 0.02
# Dense reward for approaching the ball *nose first* near the floor. Unlike
# velocity_to_ball_weight, sideways/reverse closing velocity earns nothing:
@@ -606,6 +610,12 @@ func _on_ship_body_entered(body: Node) -> void:
if air_touch_bonus_weight > 0.0 and ball.global_position.y > AIR_TOUCH_HEIGHT:
touch_payout += air_touch_bonus_weight * alignment
reward += touch_payout
if team_touch_credit_weight > 0.0 and not teammates.is_empty():
var teammate_credit := team_touch_credit(touch_payout, team_touch_credit_weight, teammates.size())
for teammate in teammates:
var teammate_agent := teammate.get_node_or_null("ShipAIController") as ShipAIController
if is_instance_valid(teammate_agent):
teammate_agent.reward += teammate_credit
_ticks_since_ball_touch = 0
# air_touch_fraction/productive_air_touch_fraction (see get_info) share
@@ -617,3 +627,9 @@ func _on_ship_body_entered(body: Node) -> void:
_air_touches += 1
if alignment >= PRODUCTIVE_AIR_TOUCH_ALIGNMENT:
_productive_air_touches += 1
static func team_touch_credit(touch_payout: float, weight: float, teammate_count: int) -> float:
if touch_payout <= 0.0 or weight <= 0.0 or teammate_count <= 0:
return 0.0
return touch_payout * clampf(weight, 0.0, 1.0) / teammate_count
+11
View File
@@ -97,11 +97,16 @@ func _connect_target() -> void:
return
if not target.ball_contact.is_connected(_on_target_ball_contact):
target.ball_contact.connect(_on_target_ball_contact)
if not target.wall_contact.is_connected(_on_target_wall_contact):
target.wall_contact.connect(_on_target_wall_contact)
func _exit_tree() -> void:
AudioManager.stop_engine()
if is_instance_valid(target) and target.ball_contact.is_connected(_on_target_ball_contact):
target.ball_contact.disconnect(_on_target_ball_contact)
if is_instance_valid(target) and target.wall_contact.is_connected(_on_target_wall_contact):
target.wall_contact.disconnect(_on_target_wall_contact)
func _input(event):
@@ -218,6 +223,8 @@ func _smooth_look_at(point: Vector3, delta: float) -> void:
func _update_speed_feel(delta: float) -> void:
var feel_t := 1.0 - exp(-feel_smoothing * delta)
var turbo_target := 1.0 if target.is_turbo_active() else 0.0
var engine_action := target.get_current_action_copy()
AudioManager.set_engine_state(maxf(engine_action.thrust.z, 0.0), turbo_target > 0.5)
_turbo_blend = lerpf(_turbo_blend, turbo_target, feel_t)
_speed_blend = lerpf(_speed_blend, target.get_speed_ratio(), feel_t)
var target_fov := base_fov + _speed_blend * speed_fov_add + _turbo_blend * turbo_fov_kick
@@ -240,6 +247,10 @@ func _on_target_ball_contact(intensity: float, _world_position: Vector3) -> void
impact_feedback.emit(intensity)
func _on_target_wall_contact(intensity: float) -> void:
AudioManager.play_wall_scrape(intensity)
func _apply_shake(delta: float) -> void:
if _shake_strength <= 0.001:
_shake_strength = 0.0
+1 -1
View File
@@ -4,7 +4,7 @@ class_name SimConstants
# constant derived from "60 Hz" (Ship._tick_scaled's decay reference,
# reaction_ticks' export range, TrainingMode.TICKS_PER_SIM_SECOND) reads this
# instead of restating the literal, so changing it changes every derived
# constant coherently — see multiplayer-todo.md §5.6 on why a future 120 Hz
# constant coherently — see MULTIPLAYER_SPEC.md §5.6 on why a future 120 Hz
# simulation needs to be a config change plus a retrain, not a protocol
# rewrite hunting down bare 60s.
#
+50
View File
@@ -40,3 +40,53 @@ static func initialize() -> Dictionary:
if result is Dictionary and bool(result.get("status", false)):
return {"error": OK, "app_id": app_id()}
return {"error": ERR_CANT_CONNECT, "reason": "Steam initialization failed for App ID %d" % app_id()}
# Web-API auth ticket acquisition (task 7.6). The control plane exchanges this
# ticket with Valve's publisher API for a verified Steam identity; the client
# never chooses its own identity, which is what makes this the fix for slot
# reclaim being keyed on a display name.
#
# GodotSteam delivers the ticket asynchronously through the
# `get_auth_ticket_for_web_api` signal, because the ticket is not usable until
# Steam has confirmed it with its backend. Requesting one and reading the
# return value alone yields a handle, not a ticket.
#
# Everything here is called dynamically so stock Godot, which has no GodotSteam
# symbols, can still parse and run the project.
const WEB_API_IDENTITY := "cosmicclash"
static func supports_web_api_ticket() -> bool:
if not is_runtime_available():
return false
var steam := Engine.get_singleton("Steam")
return steam.has_signal("get_auth_ticket_for_web_api") and steam.has_method("getAuthTicketForWebApi")
# Returns the request handle, or 0 when unavailable. The caller must await the
# `get_auth_ticket_for_web_api` signal for the ticket itself.
static func request_web_api_ticket() -> int:
if not supports_web_api_ticket():
return 0
var steam := Engine.get_singleton("Steam")
var handle = steam.call("getAuthTicketForWebApi", WEB_API_IDENTITY)
return int(handle) if handle is int or handle is float else 0
static func cancel_web_api_ticket(handle: int) -> void:
if handle <= 0 or not is_runtime_available():
return
var steam := Engine.get_singleton("Steam")
if steam.has_method("cancelAuthTicket"):
steam.call("cancelAuthTicket", handle)
# GodotSteam hands back raw ticket bytes; the Web API expects them hex encoded.
static func encode_web_api_ticket(buffer: PackedByteArray) -> String:
if buffer.is_empty():
return ""
var encoded := ""
for byte in buffer:
encoded += "%02x" % int(byte)
return encoded
+61 -6
View File
@@ -70,6 +70,11 @@ const SimConstants = preload("res://scripts/sim_constants.gd")
# a real goal and ships start low behind/lateral to it, so a useful touch is
# naturally reinforced by the existing goal-directed ball rewards.
@export_range(0.0, 1.0) var air_intercept_chance := 0.0
# Wall-play and rebound starts are separate: wall-play begins beside a wall
# with the ball travelling inward, while rebound begins just before an
# outward wall impact. Both default off to preserve existing distributions.
@export_range(0.0, 1.0) var wall_play_chance := 0.0
@export_range(0.0, 1.0) var rebound_chance := 0.0
# Ground-start branch for the generation-5 handling stage: ships spawn level
# and resting on the floor with a low, floor-level ball. Every other branch
# samples ship Y uniformly across the full 18m volume (see _random_position),
@@ -82,8 +87,8 @@ const SimConstants = preload("res://scripts/sim_constants.gd")
# Ships per team. Default 1 preserves every existing curriculum script's 1v1
# behaviour unchanged; up to 5 matches ShipObservations.MAX_TEAMMATES/
# MAX_OPPONENTS. Plumbing only for this pass — no 2v2+ curriculum/reward
# design has been done, so a run above 1 is untested territory.
# MAX_OPPONENTS. Team-credit reward and paired 2v2 evaluation are opt-in;
# no teamplay training stage is enabled by default.
@export_range(1, 5) var team_size: int = 1
# Placement bounds for randomized episode starts, derived from the standard
@@ -100,6 +105,9 @@ const FIELD_MIN_Y := 1.5
# spawning interpenetrated with it.
const GROUND_START_Y := 0.35
const GROUND_START_BALL_Y := 0.55
const WALL_PLAY_BALL_CLEARANCE := 1.0
const REBOUND_BALL_CLEARANCE := 0.75
const WALL_PLAY_SPEED := Vector2(4.0, 9.0)
const FIELD_MAX_Y := ArenaBoundary.INNER_HEIGHT - SPAWN_INSET
# The corner curves reach at most their chord plane |x| + |z| = INNER_HALF_X
# + INNER_HALF_Z - CORNER_RADIUS; spawns keep the same SPAWN_INSET clearance
@@ -141,6 +149,7 @@ var _eval_goals := {0: 0, 1: 0}
var _eval_draws := 0
var _eval_episodes_done := 0
var _episode_ticks := 0
var _eval_team_size := 1
# Curriculum mode state (see _parse_curriculum_args). "self_play" (default)
# is today's only historical behaviour: both ships are live trainees sharing
@@ -176,11 +185,12 @@ func _start() -> void:
spawn_ball()
if _eval:
for team in [0, 1]:
for spawn_index in _eval_team_size:
var bot := AIShipController.new()
bot.model_path = _eval_models[team]
bot.allow_vertical = _eval_allow_vertical[team]
bot.allow_pitch_roll = _eval_allow_pitch_roll[team]
spawn_ship(team, 0, bot)
spawn_ship(team, spawn_index, bot)
return
var team0_ships: Array[Ship] = []
@@ -248,6 +258,7 @@ func _parse_eval_args() -> void:
_eval_models[0] = args["eval_model_a"]
_eval_models[1] = args["eval_model_b"]
_eval_episodes = int(args.get("eval_episodes", str(_eval_episodes)))
_eval_team_size = clampi(int(args.get("eval_team_size", str(_eval_team_size))), 1, 2)
_eval_allow_vertical[0] = _typed_like(args.get("eval_allow_vertical_a", "true"), true)
_eval_allow_vertical[1] = _typed_like(args.get("eval_allow_vertical_b", "true"), true)
_eval_allow_pitch_roll[0] = _typed_like(args.get("eval_allow_pitch_roll_a", "true"), true)
@@ -261,11 +272,12 @@ const TRAINING_MODE_OVERRIDES := [
"goal_reward", "draw_penalty", "kickoff_state_chance",
"ball_near_goal_chance", "attack_goal_bias", "air_drill_chance",
"air_intercept_chance", "ground_start_chance", "team_size",
"wall_play_chance", "rebound_chance",
]
# ShipAIController @export names a curriculum run may override, read as
# --ai_<name>=<value> to avoid colliding with the names above.
const SHIP_AI_OVERRIDES := [
"ball_touch_reward", "ball_touch_cooldown_ticks", "ball_touch_direction_floor",
"ball_touch_reward", "ball_touch_cooldown_ticks", "ball_touch_direction_floor", "team_touch_credit_weight",
"velocity_to_ball_weight", "ball_velocity_to_goal_weight", "ball_distance_penalty",
"forward_velocity_to_ball_weight", "air_approach_weight", "air_touch_bonus_weight", "wall_contact_penalty", "tilt_penalty",
"ground_tilt_penalty", "non_forward_penalty", "grounded_upright_reward",
@@ -289,8 +301,8 @@ func _parse_curriculum_args() -> void:
if args.has(name):
set(name, _typed_like(args[name], get(name)))
var start_probability := kickoff_state_chance + ball_near_goal_chance \
+ air_drill_chance + air_intercept_chance
start_probability += ground_start_chance
+ air_drill_chance + air_intercept_chance + ground_start_chance \
+ wall_play_chance + rebound_chance
if start_probability > 1.0:
push_error("TrainingMode: episode-start probabilities sum to %.3f (> 1.0)" % start_probability)
@@ -323,6 +335,7 @@ func _ai_default(name: String) -> Variant:
"ball_touch_reward": return 0.4
"ball_touch_cooldown_ticks": return 60
"ball_touch_direction_floor": return 0.3
"team_touch_credit_weight": return 0.0
"velocity_to_ball_weight": return 0.02
"forward_velocity_to_ball_weight": return 0.0
"air_approach_weight": return 0.0
@@ -451,6 +464,12 @@ func _reset_episode() -> void:
elif roll < kickoff_state_chance + ball_near_goal_chance + air_drill_chance \
+ air_intercept_chance + ground_start_chance:
_place_ground_start()
elif roll < kickoff_state_chance + ball_near_goal_chance + air_drill_chance \
+ air_intercept_chance + ground_start_chance + wall_play_chance:
_place_wall_state(false)
elif roll < kickoff_state_chance + ball_near_goal_chance + air_drill_chance \
+ air_intercept_chance + ground_start_chance + wall_play_chance + rebound_chance:
_place_wall_state(true)
else:
_place_ships_random()
_place_ball_random()
@@ -560,6 +579,42 @@ func _place_ground_start() -> void:
)
# Wall-play/rebound states (see wall_play_chance/rebound_chance). The ball is
# placed against a side wall, never in a corner or goal sensor. A wall-play
# state starts after the bounce and sends the ball inward; a rebound state
# starts before contact and sends it outward so the physics engine supplies
# the reflected trajectory. Ships use the ordinary randomized placement, so
# the policy has to read the wall/rebound context instead of memorising a
# fixed attacker spawn.
func _place_wall_state(rebound: bool) -> void:
_place_ships_random()
var side := -1.0 if randf() < 0.5 else 1.0
var clearance := REBOUND_BALL_CLEARANCE if rebound else WALL_PLAY_BALL_CLEARANCE
var ball_position := Vector3(
side * (ArenaBoundary.INNER_HALF_X - clearance),
randf_range(1.0, minf(FIELD_MAX_Y, 7.0)),
randf_range(-FIELD_HALF_Z, FIELD_HALF_Z)
)
var velocity := wall_state_velocity(
rebound, side, randf_range(WALL_PLAY_SPEED.x, WALL_PLAY_SPEED.y),
randf_range(-0.15, 0.15), randf_range(-0.15, 0.15)
)
_place_body(ball, Transform3D(Basis.IDENTITY, ball_position), velocity, Vector3.ZERO)
# Pure geometry seam for adversarial tests. `side` identifies the selected
# wall (+1 or -1); a wall-play vector points into the field and a rebound
# vector points into that wall. Normalize the perturbed normal before applying
# speed so random tangential components cannot accidentally change the speed
# distribution between the two state types.
static func wall_state_velocity(rebound: bool, side: float, speed: float, vertical: float, lateral: float) -> Vector3:
if speed < 0.0:
return Vector3.ZERO
var wall_side := -1.0 if side < 0.0 else 1.0
var toward_field := Vector3(-wall_side, vertical, lateral).normalized()
return (-toward_field if rebound else toward_field) * speed
# Air-intercept drill geometry. These six ranges are not free tuning knobs —
# together they decide whether the drill is solvable at all, and the original
# values made it arithmetically impossible (see the Round 9 note in
+4 -4
View File
@@ -24,7 +24,7 @@ extends Node
# independently of stretch mode, since it scales the 3D viewport's own internal
# resolution before this blit rather than the window itself. Task 0.15b also
# found an unexplained ~6% non-uniform width scaling on this project's one
# tested (Mac/Retina) machine — see multiplayer-todo.md §5.5.1 — which needs
# tested (Mac/Retina) machine — see MULTIPLAYER_SPEC.md §5.5.1 — which needs
# understanding before stretch mode is touched, not blindly carrying into a
# resolution-dependent change.
#
@@ -49,7 +49,7 @@ const SETTINGS_PATH := "user://settings.cfg"
# preset -> bundle applied to the individual fields below. CUSTOM has no
# bundle: selecting it just stops future preset changes from overwriting
# whatever the individual fields currently hold. Task 0.15b's measured
# per-effect costs (multiplayer-todo.md §5.5.1) were too noisy to rank these
# per-effect costs (MULTIPLAYER_SPEC.md §5.5.1) were too noisy to rank these
# against each other, so each rung is "meaningfully fewer full-screen passes
# than the one above it" rather than a precisely tuned ladder.
const PRESET_BUNDLES := {
@@ -78,7 +78,7 @@ var shadows_enabled: bool = true
var glow_enabled: bool = true
# FXAA alone, not MSAA_FXAA: 4x MSAA *and* FXAA stacked is redundant blur for
# most scenes and costs more than either alone (see multiplayer-todo.md 0.19).
# most scenes and costs more than either alone (see multiplayer-next.md 0.19).
var aa_mode: AAMode = AAMode.FXAA
var glow_scale: float = 1.0
var brightness: float = 1.0
@@ -252,7 +252,7 @@ func apply_fps_cap() -> void:
# Called once by each arena's _ready() (and again on settings_changed, so an
# already-loaded arena updates live) to fold the user's glow/brightness
# preference into that arena's own baked Environment tuning, and to gate the
# preset-controlled full-screen passes (§5.5 of multiplayer-todo.md).
# preset-controlled full-screen passes (§5.5 of MULTIPLAYER_SPEC.md).
func apply_to_environment(env: Environment) -> void:
if env == null:
return
+130
View File
@@ -0,0 +1,130 @@
extends SceneTree
# Headless smoke for the Agones SDK bridge. Run by
# scripts/verify_multiplayer_local.sh:
# godot --headless --path Game --script res://tests/agones_sdk_smoke.gd
#
# Phase 1 drives each REST call directly. Phase 2 covers what phase 1 cannot:
# that start_health() produces a *repeating* ping. That is the property Agones
# actually enforces -- one ping proves nothing, because the Fleet recycles any
# GameServer that stops pinging for periodSeconds * failureThreshold -- and its
# absence is what silently recycled every allocated server.
const ServerControlScript = preload("res://scripts/server_control.gd")
const AgonesSDKScript = preload("res://scripts/agones_sdk.gd")
const PORT := 18081
const HEALTH_PORT := 18082
# start_health() pings every 2s, so three seconds must contain at least two.
const HEALTH_OBSERVATION_SECONDS := 3.0
const MINIMUM_EXPECTED_PINGS := 2
# Counting stand-in for the Agones sidecar. ServerControl answers /health but
# cannot report how often it was called, and asserting repetition is the whole
# point here, so this counts rather than changing production code for a test.
class CountingSidecar extends Node:
var health_pings := 0
var _listener := TCPServer.new()
var _peers: Array = []
func start(port: int) -> Error:
return _listener.listen(port, "127.0.0.1")
func stop() -> void:
_listener.stop()
for peer in _peers:
if is_instance_valid(peer):
peer.disconnect_from_host()
_peers.clear()
func _process(_delta: float) -> void:
while _listener.is_connection_available():
_peers.append(_listener.take_connection())
for i in range(_peers.size() - 1, -1, -1):
var peer: StreamPeerTCP = _peers[i]
if peer.get_status() != StreamPeerTCP.STATUS_CONNECTED:
_peers.remove_at(i)
continue
var available := peer.get_available_bytes()
if available <= 0:
continue
var request := peer.get_utf8_string(available)
if "\r\n\r\n" not in request:
continue
if request.begins_with("POST /health"):
health_pings += 1
var body := "{}"
peer.put_data(("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s" % [body.length(), body]).to_utf8_buffer())
peer.disconnect_from_host()
_peers.remove_at(i)
func _init() -> void:
if not await _direct_calls_smoke():
quit(1)
return
if not await _repeating_health_smoke():
quit(1)
return
print("Agones SDK smoke passed")
quit(0)
func _direct_calls_smoke() -> bool:
var fake_sidecar = ServerControlScript.new()
root.add_child(fake_sidecar)
if fake_sidecar.start(PORT) != OK:
printerr("fake sidecar failed to bind")
return false
fake_sidecar.set_process_ready(true)
var sdk = AgonesSDKScript.new()
root.add_child(sdk)
if not sdk.configure_for_testing("http://127.0.0.1:%d" % PORT):
printerr("SDK test configuration failed")
return false
await process_frame
var health_status := await sdk.health()
var ready_status := await sdk.mark_ready()
var annotation_status := await sdk.set_annotation("match", "result")
var shutdown_status := await sdk.shutdown()
fake_sidecar.stop()
fake_sidecar.queue_free()
sdk.queue_free()
if health_status != 200 or ready_status != 200 or annotation_status < 400 or shutdown_status < 400:
printerr("Agones SDK smoke statuses health=%d ready=%d annotation=%d shutdown=%d" % [health_status, ready_status, annotation_status, shutdown_status])
return false
return true
func _repeating_health_smoke() -> bool:
var sidecar := CountingSidecar.new()
root.add_child(sidecar)
if sidecar.start(HEALTH_PORT) != OK:
printerr("counting sidecar failed to bind")
return false
# Configure before parenting and let the node arm its own timer on _ready(),
# which is exactly how server_boot.gd wires it in an allocated pod.
var sdk = AgonesSDKScript.new()
if not sdk.configure_for_testing("http://127.0.0.1:%d" % HEALTH_PORT):
printerr("health SDK configuration failed")
return false
if sdk.start_health():
printerr("start_health() reported success while the node was outside the tree")
return false
root.add_child(sdk)
await process_frame
if not sdk.health_is_running():
printerr("health loop did not arm once the node entered the tree")
return false
await create_timer(HEALTH_OBSERVATION_SECONDS).timeout
var observed := sidecar.health_pings
sdk.stop_health()
sidecar.stop()
sdk.queue_free()
sidecar.queue_free()
if observed < MINIMUM_EXPECTED_PINGS:
printerr("Agones health pings in %.1fs = %d, want at least %d; the health loop is not repeating" % [HEALTH_OBSERVATION_SECONDS, observed, MINIMUM_EXPECTED_PINGS])
return false
return true
+56
View File
@@ -0,0 +1,56 @@
extends "res://tests/test_case.gd"
const AgonesSDKScript = preload("res://scripts/agones_sdk.gd")
func test_sdk_requires_loopback_sidecar_url() -> void:
var sdk = AgonesSDKScript.new()
assert_true(not sdk.configure_for_testing("https://agones.example"), "remote sidecar URL is rejected")
assert_true(not sdk.is_available(), "rejected sidecar is unavailable")
assert_true(sdk.configure_for_testing("http://127.0.0.1:9358"), "loopback sidecar URL is accepted")
assert_true(sdk.is_available(), "accepted sidecar is available")
sdk.queue_free()
func test_annotation_validation_rejects_header_injection_and_oversized_values() -> void:
assert_true(AgonesSDKScript.annotation_is_valid("match", "result"), "ordinary annotation is accepted")
assert_true(not AgonesSDKScript.annotation_is_valid("bad\nkey", "value"), "annotation key newline is rejected")
assert_true(not AgonesSDKScript.annotation_is_valid("key", "bad\rvalue"), "annotation value newline is rejected")
assert_true(not AgonesSDKScript.annotation_is_valid("key", "x".repeat(4097)), "oversized annotation is rejected")
# Regression: every allocated GameServer reached Ready and was then recycled by
# Agones ~20s later, because start_health() armed a Timer on a node that was
# never parented. A Timer only ticks inside the SceneTree, so the process
# reported healthy while sending no pings at all, and nothing said so.
#
# These are deliberately synchronous: test_runner.gd calls test methods without
# awaiting, so anything needing a live tree or an HTTP round trip belongs in
# tests/agones_sdk_smoke.gd instead. What is asserted here is the contract that
# makes the silent case impossible.
func test_start_health_refuses_when_not_configured() -> void:
var sdk = AgonesSDKScript.new()
assert_true(not sdk.start_health(), "health cannot start before a sidecar URL is known")
assert_true(not sdk.health_is_running(), "no timer is armed without configuration")
sdk.queue_free()
func test_start_health_reports_failure_when_outside_the_tree() -> void:
# The exact shape of the production bug: configured, so is_available() is
# true and the node looks ready to work, but unparented.
var sdk = AgonesSDKScript.new()
assert_true(sdk.configure_for_testing("http://127.0.0.1:9358"), "fixture configures")
assert_true(sdk.is_available(), "an unparented node still reports available")
assert_true(not sdk.start_health(), "start_health() must not claim success outside the tree")
assert_true(not sdk.health_is_running(), "no health loop is running outside the tree")
sdk.queue_free()
func test_health_is_not_running_until_a_timer_exists() -> void:
# health_is_running() is what a caller should trust, rather than
# is_available(), which only says a URL was parsed.
var sdk = AgonesSDKScript.new()
assert_true(not sdk.health_is_running(), "a fresh SDK is not pinging")
sdk.configure_for_testing("http://127.0.0.1:9358")
assert_true(not sdk.health_is_running(), "configuration alone does not start pinging")
sdk.queue_free()
+36
View File
@@ -0,0 +1,36 @@
extends "res://tests/test_case.gd"
const AssignmentState = preload("res://scripts/assignment_state.gd")
func test_assignment_projection_accepts_verified_enet_manifest() -> void:
var assignment := AssignmentState.new()
assert_true(assignment.apply({"match_id": "match_1234567890", "server_id": "server_123456789", "player_id": "player_123456789", "slot": 2, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "enet", "endpoint": "127.0.0.1:30001", "join_authorisation": "signed"}, "player_123456789"), "valid assignment applies")
assert_true(assignment.available, "assignment becomes available only after validation")
assert_eq(assignment.transport, "enet", "transport is explicit")
assert_eq(assignment.slot, 2, "slot is preserved")
assert_eq(assignment.endpoint, "127.0.0.1:30001", "endpoint is preserved")
func test_assignment_projection_rejects_wrong_shape_or_unsafe_transport() -> void:
var assignment := AssignmentState.new()
var valid := {"match_id": "match_1234567890", "server_id": "server_123456789", "player_id": "player_123456789", "slot": 0, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "enet", "endpoint": "127.0.0.1:30001", "join_authorisation": "signed"}
var out_of_range := valid.duplicate()
out_of_range["slot"] = 6
assert_true(not assignment.apply(out_of_range), "out-of-range slot is rejected")
var short_id := valid.duplicate()
short_id["match_id"] = "match-1"
assert_true(not assignment.apply(short_id), "short opaque assignment id is rejected")
var fractional_slot := valid.duplicate()
fractional_slot["slot"] = 1.5
assert_true(not assignment.apply(fractional_slot), "fractional slot is rejected")
var fractional_protocol := valid.duplicate()
fractional_protocol["protocol_version"] = 1.5
assert_true(not assignment.apply(fractional_protocol), "fractional protocol version is rejected")
assert_true(not assignment.available, "invalid assignment is not exposed")
assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 0, "expires_at": "future", "protocol_version": 1, "transport": "udp", "join_authorisation": "signed"}), "unknown transport is rejected")
assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 0, "expires_at": "future", "protocol_version": 1, "transport": "steam_sdr", "join_authorisation": ""}), "empty authorisation is rejected")
assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-2", "slot": 0, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "steam_sdr", "join_authorisation": "signed"}, "player-1"), "wrong player assignment is rejected")
assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 0, "expires_at": "2000-08-31T12:00:00Z", "protocol_version": 1, "transport": "steam_sdr", "join_authorisation": "signed"}, "player-1"), "expired assignment is rejected")
assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 0, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "steam_sdr", "endpoint": "127.0.0.1", "join_authorisation": "signed"}, "player-1"), "unsafe endpoint is rejected")
assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 0, "expires_at": "not-a-timestamp", "protocol_version": 1, "transport": "enet", "endpoint": "127.0.0.1:30001", "join_authorisation": "signed"}, "player-1"), "malformed assignment expiry is rejected")
+46
View File
@@ -0,0 +1,46 @@
extends "res://tests/test_case.gd"
const AudioManager = preload("res://scripts/audio_manager.gd")
func test_audio_intensity_fails_closed_and_clamps() -> void:
assert_eq(AudioManager.clamp_intensity(-1.0), 0.0, "negative impact is silent")
assert_eq(AudioManager.clamp_intensity(INF), 0.0, "infinite impact is silent")
assert_eq(AudioManager.clamp_intensity(0.5), 0.5, "normal impact is retained")
assert_eq(AudioManager.clamp_intensity(4.0), 1.0, "oversized impact is capped")
func test_countdown_frequency_has_bounded_monotonic_mapping() -> void:
assert_eq(AudioManager.countdown_frequency(0), 495.0, "zero uses the first safe tone")
assert_eq(AudioManager.countdown_frequency(3), 605.0, "countdown tone is deterministic")
assert_eq(AudioManager.countdown_frequency(99), 935.0, "large countdown values are capped")
assert_true(AudioManager.countdown_frequency(2) < AudioManager.countdown_frequency(3), "countdown tones rise predictably")
func test_button_binding_is_idempotent() -> void:
var manager := AudioManager.new()
var button := Button.new()
manager.bind_button(button)
manager.bind_button(button)
assert_eq(button.pressed.get_connections().size(), 1, "UI click hook is not duplicated")
button.free()
manager.free()
func test_engine_mix_is_bounded_and_turbo_is_audible() -> void:
assert_eq(AudioManager.engine_pitch(-1.0, false), 0.75, "negative thrust uses the idle pitch")
assert_true(AudioManager.engine_pitch(1.0, true) > AudioManager.engine_pitch(1.0, false), "turbo raises engine pitch")
assert_true(AudioManager.engine_volume(1.0, true) > AudioManager.engine_volume(1.0, false), "turbo raises engine volume")
assert_true(AudioManager.engine_volume(100.0, true) <= 0.1, "engine volume remains bounded")
func test_turbo_state_is_only_a_rising_edge_for_the_engine_cue() -> void:
assert_true(AudioManager.should_play_turbo_cue(false, true, 0.8), "turbo engagement emits a cue")
assert_true(not AudioManager.should_play_turbo_cue(true, true, 0.8), "held turbo does not retrigger")
assert_true(not AudioManager.should_play_turbo_cue(false, true, 0.0), "turbo at idle thrust is silent")
assert_true(not AudioManager.should_play_turbo_cue(false, false, 0.8), "ordinary thrust emits no turbo cue")
func test_wall_scrape_intensity_reuses_the_same_safe_bounds() -> void:
assert_eq(AudioManager.clamp_intensity(-2.0), 0.0, "reverse wall intensity is silent")
assert_eq(AudioManager.clamp_intensity(2.0), 1.0, "wall intensity is capped")
@@ -0,0 +1,37 @@
extends "res://tests/test_case.gd"
const LeaseClient = preload("res://scripts/connection_lease_client.gd")
func test_connection_lease_response_classification_is_fail_closed() -> void:
var success_body := JSON.stringify({"generation": 2}).to_utf8_buffer()
assert_eq(LeaseClient.classify_response("connect", 1, HTTPRequest.RESULT_SUCCESS, 200, success_body), {"status": "claimed", "generation": 2}, "exact next generation is accepted")
assert_eq(LeaseClient.classify_response("connect", 1, HTTPRequest.RESULT_SUCCESS, 200, JSON.stringify({"generation": 3}).to_utf8_buffer())["status"], "rejected", "skipped generation is rejected")
assert_eq(LeaseClient.classify_response("connect", 0, HTTPRequest.RESULT_SUCCESS, 200, JSON.stringify({"generation": 3}).to_utf8_buffer(), true), {"status": "claimed", "generation": 3}, "a fresh process accepts a durable recovery generation")
assert_eq(LeaseClient.classify_response("connect", 0, HTTPRequest.RESULT_SUCCESS, 200, JSON.stringify({"generation": 3}).to_utf8_buffer())["status"], "rejected", "queued outage reconciliation cannot skip generations")
assert_eq(LeaseClient.classify_response("connect", 1, HTTPRequest.RESULT_SUCCESS, 200, JSON.stringify({"generation": "2"}).to_utf8_buffer())["status"], "rejected", "string generation is not coerced")
assert_eq(LeaseClient.classify_response("connect", 1, HTTPRequest.RESULT_CANT_CONNECT, 0, PackedByteArray())["status"], "unavailable", "transport outage permits bounded local fallback")
assert_eq(LeaseClient.classify_response("connect", 1, HTTPRequest.RESULT_SUCCESS, 503, PackedByteArray())["status"], "unavailable", "service outage permits bounded local fallback")
assert_eq(LeaseClient.classify_response("connect", 1, HTTPRequest.RESULT_SUCCESS, 409, PackedByteArray())["status"], "rejected", "durable conflict is terminal")
assert_eq(LeaseClient.classify_response("disconnect", 2, HTTPRequest.RESULT_SUCCESS, 204, PackedByteArray()), {"status": "claimed", "generation": 2}, "disconnect acknowledgement preserves exact generation")
func test_connection_lease_configuration_and_keys_are_bound() -> void:
assert_true(LeaseClient.valid_configuration("https://control.invalid", "workload-token", "match-1234567890", "server-123456789"), "valid workload configuration is accepted")
assert_true(not LeaseClient.valid_configuration("https://control.invalid?token=leak", "workload-token", "match-1234567890", "server-123456789"), "query-bearing endpoint is rejected")
assert_true(not LeaseClient.valid_configuration("https://control@evil.invalid", "workload-token", "match-1234567890", "server-123456789"), "userinfo-bearing endpoint is rejected")
assert_true(not LeaseClient.valid_configuration("https://control.invalid", "bad\ntoken", "match-1234567890", "server-123456789"), "header injection is rejected")
var initial := LeaseClient.event_key("match-123456789", "player-12345678", "connect", 0)
assert_true(initial != LeaseClient.event_key("match-123456789", "player-12345678", "disconnect", 1), "operation and generation bind the key")
assert_true(initial != LeaseClient.event_key("match-000000000", "player-12345678", "connect", 0), "match identity binds the key")
func test_match_net_rejects_malformed_or_skipped_backend_generations() -> void:
assert_eq(MatchNet.lease_claim_generation({"status": "claimed", "generation": 2}, 1), 2, "exact backend generation is accepted")
assert_eq(MatchNet.lease_claim_generation({"status": "unavailable", "generation": 2}, 1), 2, "local fallback retains the exact next generation")
assert_eq(MatchNet.lease_claim_generation({"status": "unavailable", "generation": 1}, 0), -1, "a fresh process cannot guess a generation during an outage")
assert_eq(MatchNet.lease_claim_generation({"status": "rejected", "generation": 2}, 1), -1, "backend conflict rejects admission")
assert_eq(MatchNet.lease_claim_generation({"status": "claimed", "generation": 3}, 1), -1, "generation skips are fenced")
assert_eq(MatchNet.lease_claim_generation({"status": "claimed", "generation": 3}, 0), 3, "fresh process adopts durable recovery generation")
assert_eq(MatchNet.lease_claim_generation({"status": "unavailable", "generation": 3}, 0), -1, "offline fallback cannot invent a skipped generation")
assert_eq(MatchNet.lease_claim_generation({"status": "claimed", "generation": "2"}, 1), -1, "string generation is fenced")
@@ -0,0 +1,582 @@
extends "res://tests/test_case.gd"
const ControlPlaneClient = preload("res://scripts/control_plane_client.gd")
const RankedProfileState = preload("res://scripts/ranked_profile_state.gd")
func test_base_url_validation_rejects_ambiguous_or_insecure_values() -> void:
assert_true(ControlPlaneClient.is_valid_base_url("http://127.0.0.1:8080"), "local HTTP endpoint is valid")
assert_true(ControlPlaneClient.is_valid_base_url("https://match.example"), "HTTPS endpoint is valid")
assert_true(not ControlPlaneClient.is_valid_base_url("match.example"), "scheme is required")
assert_true(not ControlPlaneClient.is_valid_base_url("http://match.example/"), "trailing slash is normalized before validation")
assert_true(not ControlPlaneClient.is_valid_base_url("http://match example"), "whitespace is rejected")
assert_true(not ControlPlaneClient.is_valid_base_url("https://user:pass@match.example"), "userinfo is rejected")
assert_true(not ControlPlaneClient.is_valid_base_url("https://match.example?token=secret"), "query strings are rejected")
var client := ControlPlaneClient.new()
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "safe access token configures")
assert_true(not client.configure("https://match.example", "token\nforged-header"), "header injection is rejected")
assert_true(ControlPlaneClient.is_valid_web_api_ticket("ticket-value"), "ordinary Steam Web API ticket is accepted")
assert_true(not ControlPlaneClient.is_valid_web_api_ticket("ticket\nforged"), "ticket header characters are rejected")
assert_true(not ControlPlaneClient.is_valid_web_api_ticket(""), "empty Steam ticket is rejected")
assert_true(ControlPlaneClient.is_valid_access_token("session-id:opaque-token"), "opaque session format is accepted")
assert_true(not ControlPlaneClient.is_valid_access_token(":opaque-token"), "missing session identifier is rejected")
assert_true(not ControlPlaneClient.is_valid_access_token("session-id:token\nforged"), "session header injection is rejected")
assert_eq(ControlPlaneClient.websocket_url("https://match.example"), "wss://match.example", "TLS control plane uses secure WebSocket")
assert_eq(ControlPlaneClient.websocket_url("http://127.0.0.1:8080"), "ws://127.0.0.1:8080", "local control plane uses WebSocket")
assert_eq(ControlPlaneClient.websocket_url("match.example"), "", "unscoped URL cannot become a WebSocket URL")
var unconfigured := ControlPlaneClient.new()
assert_eq(unconfigured.connect_event_stream(), ERR_UNAUTHORIZED, "event stream requires an authenticated session")
func test_ticket_normalization_preserves_payload_and_derives_expiry() -> void:
var payload := {"ticket_id": "ticket-1", "state": "QUEUED", "expires_at": "2026-08-31T12:00:00Z"}
var normalized := ControlPlaneClient.normalize_ticket(payload)
assert_eq(normalized["ticket_id"], "ticket-1", "normalization preserves ticket identity")
assert_true(normalized.has("expires_at_unix"), "RFC3339 expiry is available to the projection")
assert_true(int(normalized["expires_at_unix"]) > 0, "expiry is converted to a positive epoch")
assert_true(not payload.has("expires_at_unix"), "normalization does not mutate the HTTP payload")
func test_ticket_normalization_derives_authoritative_enqueue_time() -> void:
var normalized := ControlPlaneClient.normalize_ticket({"enqueued_at": "1970-01-01T00:16:40Z"})
assert_eq(int(normalized["enqueued_at_unix"]), 1000, "RFC3339 enqueue time is converted to epoch")
assert_eq(ControlPlaneClient.normalize_ticket({"enqueued_at": "not-a-timestamp"})["enqueued_at_unix"], -1, "malformed enqueue time remains visibly invalid")
assert_eq(ControlPlaneClient.normalize_ticket({"expires_at": 123})["expires_at_unix"], -1, "non-string expiry remains visibly invalid")
func test_session_expiry_is_checked_at_the_boundary_and_fails_closed() -> void:
assert_true(not ControlPlaneClient.is_session_expired("", 1000), "legacy sessions without an expiry remain compatible")
assert_true(not ControlPlaneClient.is_session_expired("1970-01-01T00:16:40Z", 999), "session remains valid before expiry")
assert_true(ControlPlaneClient.is_session_expired("1970-01-01T00:16:40Z", 1000), "session expires at the exact boundary")
assert_true(ControlPlaneClient.is_session_expired("not-a-timestamp", 1000), "malformed non-empty expiry fails closed")
assert_true(ControlPlaneClient.is_valid_rfc3339_timestamp("2026-08-31T12:00:00.123Z"), "fractional RFC3339 timestamp is accepted")
assert_true(not ControlPlaneClient.is_valid_rfc3339_timestamp("2026-02-30T12:00:00Z"), "impossible calendar date is rejected")
assert_true(not ControlPlaneClient.is_valid_rfc3339_timestamp("2026-13-01T12:00:00Z"), "impossible month is rejected")
assert_true(not ControlPlaneClient.is_valid_rfc3339_timestamp("2026-08-31 12:00:00Z"), "space-separated timestamp is rejected")
assert_true(not ControlPlaneClient.is_valid_rfc3339_timestamp("2026-08-31T12:00:00"), "timezone-less timestamp is rejected")
var valid_session := {"player_id": "player_1234567890", "access_token": "session-id:opaque-token", "expires_at": "2099-08-31T12:00:00Z"}
assert_true(ControlPlaneClient.is_valid_session_response(valid_session), "future session response is accepted")
var missing_expiry := valid_session.duplicate()
missing_expiry.erase("expires_at")
assert_true(not ControlPlaneClient.is_valid_session_response(missing_expiry), "session without expiry is rejected")
var malformed_expiry := valid_session.duplicate()
malformed_expiry["expires_at"] = "tomorrow"
assert_true(not ControlPlaneClient.is_valid_session_response(malformed_expiry), "malformed session expiry is rejected")
var expired_session := valid_session.duplicate()
expired_session["expires_at"] = "2000-01-01T00:00:00Z"
assert_true(not ControlPlaneClient.is_valid_session_response(expired_session), "expired session response is rejected")
func test_reconfiguration_discards_the_previous_session_expiry() -> void:
var client := ControlPlaneClient.new()
client.session_expires_at = "1970-01-01T00:00:01Z"
assert_true(client.configure("https://match.example", "new-session:opaque-token"), "new session configures successfully")
assert_eq(client.session_expires_at, "", "new credentials do not inherit the old expiry")
func test_websocket_event_validation_requires_contract_specific_fields() -> void:
var envelope := {"event": "state_changed", "revision": 1, "resource_id": "ticket_123456789", "occurred_at": "2026-08-31T12:00:00Z", "state": "QUEUED"}
assert_true(ControlPlaneClient._valid_websocket_event(envelope), "valid state event is accepted")
var accepted := envelope.duplicate()
accepted["state"] = "ACCEPTED"
assert_true(ControlPlaneClient._valid_websocket_event(accepted), "authoritative accepted queue event is accepted")
for phase in ["ASSIGNED", "RESULT_PENDING", "COMPLETED"]:
var lifecycle := envelope.duplicate()
lifecycle["state"] = phase
assert_true(ControlPlaneClient._valid_websocket_event(lifecycle), "post-match queue event is accepted: " + phase)
var bad_state := envelope.duplicate()
bad_state["state"] = "SECRET"
assert_true(not ControlPlaneClient._valid_websocket_event(bad_state), "unknown state event is rejected")
var assignment := {"event": "assignment_changed", "revision": 0, "resource_id": "match_1234567890", "occurred_at": "2026-08-31T12:00:00Z", "match_id": "match_1234567890", "server_id": "server_123456789"}
assert_true(ControlPlaneClient._valid_websocket_event(assignment), "complete assignment event is accepted")
var short_assignment_id := assignment.duplicate()
short_assignment_id["server_id"] = "server-1"
assert_true(not ControlPlaneClient._valid_websocket_event(short_assignment_id), "short assignment server id is rejected")
assignment.erase("server_id")
assert_true(not ControlPlaneClient._valid_websocket_event(assignment), "incomplete assignment event is rejected")
var fractional := envelope.duplicate()
fractional["revision"] = 1.5
assert_true(not ControlPlaneClient._valid_websocket_event(fractional), "fractional event revision is rejected")
var negative := envelope.duplicate()
negative["revision"] = -1
assert_true(not ControlPlaneClient._valid_websocket_event(negative), "negative event revision is rejected")
var malformed_time := envelope.duplicate()
malformed_time["occurred_at"] = "yesterday"
assert_true(not ControlPlaneClient._valid_websocket_event(malformed_time), "malformed event timestamp is rejected")
var short_resource := envelope.duplicate()
short_resource["resource_id"] = "short"
assert_true(not ControlPlaneClient._valid_websocket_event(short_resource), "short resource identifier is rejected")
var unsafe_resource := envelope.duplicate()
unsafe_resource["resource_id"] = "ticket_123456789/secret"
assert_true(not ControlPlaneClient._valid_websocket_event(unsafe_resource), "resource identifier with separators is rejected")
var match_state := {"event": "state_changed", "revision": 4, "resource_id": "match_1234567890", "occurred_at": "2026-08-31T12:00:00Z", "state": "ASSIGNMENT_READY", "match_id": "match_1234567890"}
assert_true(ControlPlaneClient._valid_websocket_event(match_state), "match-scoped lifecycle event is accepted")
match_state["match_id"] = "different_match_123"
assert_true(not ControlPlaneClient._valid_websocket_event(match_state), "match lifecycle identity must equal its resource identity")
func test_match_assignment_ready_event_recovers_ticket_and_schedules_assignment_fetch() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.state.begin_queue("ticket_assignment_1", "casual"), "queue setup succeeds")
client._operation = "queue_heartbeat"
var event := {"event": "state_changed", "revision": 4, "resource_id": "match_assignment_1", "occurred_at": "2026-08-31T12:00:00Z", "state": "ASSIGNMENT_READY", "match_id": "match_assignment_1"}
client._handle_websocket_packet(JSON.stringify(event).to_utf8_buffer())
assert_eq(client._pending_resync_resource_id, "ticket_assignment_1", "match event requests authoritative ticket recovery")
assert_eq(client._pending_assignment_match_id, "match_assignment_1", "assignment lookup no longer depends on a prior assignment GET")
assert_eq(client.state.ticket_id, "ticket_assignment_1", "match resource is never projected as a ticket identity")
client.free()
func test_recovered_assignment_ready_ticket_schedules_fetch_after_missed_revisions() -> void:
var client := ControlPlaneClient.new()
client._ready()
client.player_id = "player_1234567890"
client.state.begin_queue("ticket_assignment_1", "casual")
assert_true(client.state.apply_ticket_update({"ticket_id": "ticket_assignment_1", "revision": 1, "state": "PROPOSED", "playlist": "casual"}), "proposal setup applies")
client._operation = "queue_recover"
var recovered := {"ticket_id": "ticket_assignment_1", "player_id": "player_1234567890", "match_id": "match_assignment_1", "playlist": "casual", "state": "ASSIGNMENT_READY", "revision": 5, "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2026-08-31T12:01:00Z"}
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(recovered).to_utf8_buffer())
assert_eq(client.state.phase, MatchmakingState.ASSIGNMENT_READY, "REST recovery applies a forward authoritative snapshot")
assert_eq(client._pending_assignment_match_id, "match_assignment_1", "recovered snapshot supplies the assignment lookup key")
client.free()
func test_websocket_reconnect_defers_recovery_while_http_mutation_is_in_flight() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.state.begin_queue("ticket-reconnect", "casual"), "queue setup succeeds")
client._operation = "queue_heartbeat"
client._set_websocket_status("CONNECTED")
assert_eq(client._pending_resync_resource_id, "ticket-reconnect", "reconnect recovery is retained until the mutation completes")
client.free()
func test_transient_rest_recovery_failure_does_not_end_matchmaking() -> void:
var client := ControlPlaneClient.new()
client._ready()
client.state.begin_queue("ticket_recovery_123", "casual")
client._operation = "queue_recover"
client._on_request_completed(HTTPRequest.RESULT_CANT_CONNECT, 0, PackedStringArray(), PackedByteArray())
assert_eq(client.state.phase, MatchmakingState.QUEUED, "network failure during recovery keeps the active search")
assert_true(client.state.message.contains("retrying"), "recovery failure remains visible and retryable")
client._operation = "proposal_recover"
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), "[]".to_utf8_buffer())
assert_eq(client.state.phase, MatchmakingState.QUEUED, "malformed transient recovery response does not become terminal")
client.free()
func test_resync_of_terminal_proposal_recovers_the_ticket() -> void:
assert_eq(ControlPlaneClient.resync_target("proposal-terminal-resync", "ticket-terminal-resync", "proposal-terminal-resync", false), "ticket-terminal-resync", "terminal proposal resync targets the requeued ticket")
assert_eq(ControlPlaneClient.resync_target("proposal-terminal-resync", "ticket-terminal-resync", "proposal-terminal-resync", true), "proposal-terminal-resync", "open proposal resync retains the proposal target")
func test_retryable_mutation_policy_only_retries_safe_failures() -> void:
assert_true(ControlPlaneClient.is_retryable_mutation_response(0), "transport failure is retryable")
assert_true(ControlPlaneClient.is_retryable_mutation_response(408), "request timeout is retryable")
assert_true(ControlPlaneClient.is_retryable_mutation_response(429), "rate limit is retryable")
assert_true(ControlPlaneClient.is_retryable_mutation_response(503), "server failure is retryable")
assert_true(not ControlPlaneClient.is_retryable_mutation_response(401), "authentication failure is not blindly replayed")
assert_true(not ControlPlaneClient.is_retryable_mutation_response(409), "revision/idempotency conflict is not blindly replayed")
# multiplayer-next.md 8.43 named "duplicate-action recovery beyond proposals"
# and "regional outage retry UI" as remaining. Both mechanisms (can_retry_last_mutation /
# retry_last_mutation, and matchmaking.gd's queue button falling back to them)
# already existed in the client, but had no test coverage proving the
# generic (non-proposal) mutation path actually recovers end to end -- only
# is_retryable_mutation_response's pure classification was covered above.
func test_generic_mutation_retry_recovers_after_a_transient_failure() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
assert_true(client.state.begin_queue("ticket-retry-generic", "casual"), "queue setup succeeds")
# Simulate what _start_request itself would already have recorded before
# a real network call was in flight, the same way the pre-existing
# conflict-handler tests above set _operation directly.
client._operation = "queue_heartbeat"
client._last_mutation = {"operation": "queue_heartbeat", "method": HTTPClient.METHOD_POST, "path": "/v1/queue/ticket-retry-generic/heartbeat", "payload": {"revision": 0}, "key": "heartbeat-retry-key-123456", "expected_revision": 0}
assert_true(not client.can_retry_last_mutation(), "a mutation still in flight is never offered as retryable")
# A regional outage: the transport itself failed rather than returning a
# decoded HTTP status -- exactly the "regional outage retry" case. This
# transition is the actual previously-uncovered boundary: nothing tested
# that a generic (non-proposal) mutation ever becomes retryable at all,
# only is_retryable_mutation_response's pure classification above.
# retry_last_mutation's own dispatch is not exercised here: it reaches
# HTTPRequest.request(), which needs the node inside a live SceneTree,
# and test_runner.tscn runs every test method from within its own
# _ready() while the tree is still being built, so that is out of reach
# for this harness -- the "not offered at all" boundary below covers the
# part of retry_last_mutation this environment can exercise safely.
client._on_request_completed(HTTPRequest.RESULT_CANT_CONNECT, 0, PackedStringArray(), PackedByteArray())
assert_true(client.can_retry_last_mutation(), "a transport failure on a non-proposal mutation is offered as retryable")
client.free()
func test_generic_mutation_retry_is_not_offered_for_unsafe_failures() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
assert_true(client.state.begin_queue("ticket-retry-unsafe", "casual"), "queue setup succeeds")
client._operation = "queue_cancel"
client._last_mutation = {"operation": "queue_cancel", "method": HTTPClient.METHOD_POST, "path": "/v1/queue/ticket-retry-unsafe/cancel", "payload": {}, "key": "cancel-retry-key-123456", "expected_revision": 0}
# A 409 is a revision/idempotency conflict, not a transient failure --
# should_recover_queue_after_conflict owns recovering it instead, and a
# blind resend would replay a mutation whose precondition already failed.
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 409, PackedStringArray(), JSON.stringify({"error": "revision conflict"}).to_utf8_buffer())
assert_true(not client.can_retry_last_mutation(), "a conflict response is never offered as a blind retry")
assert_eq(client.retry_last_mutation(), ERR_INVALID_DATA, "retrying when not offered fails closed rather than resending a stale mutation")
client.free()
# connect_to_assignment() already existed, fully validated, with its own
# assignment_connection_started/assignment_connection_failed signals -- but
# nothing anywhere in the client ever called it. A player reaching the
# ASSIGNED phase (server confirms the complete roster) with a fetched, fresh
# assignment would simply sit on "Your match server is ready" forever,
# because the transport was never actually started. This is the wiring fix,
# not just new test coverage for existing behavior.
func test_client_starts_the_transport_once_the_ticket_reaches_assigned() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
client.player_id = "player_1234567890"
assert_true(client.state.begin_queue("ticket-connect-ready", "casual"), "queue setup succeeds")
# The assignment fetch (triggered independently, earlier, by
# ASSIGNMENT_READY) has already completed by the time ASSIGNED arrives --
# the common case.
client._operation = "assignment"
var assignment_payload := {"match_id": "match_connect_1234567890", "server_id": "server_connect_1234567890", "player_id": "player_1234567890", "slot": 0, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "enet", "endpoint": "127.0.0.1:65500", "join_authorisation": "opaque-join-token"}
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(assignment_payload).to_utf8_buffer())
assert_true(client.assignment.available, "assignment fetch applies")
var connect_started := [false]
var connect_failed := [false]
client.assignment_connection_started.connect(func(_a): connect_started[0] = true)
client.assignment_connection_failed.connect(func(_d): connect_failed[0] = true)
client._operation = "queue_recover"
var ticket_payload := {"ticket_id": "ticket-connect-ready", "player_id": "player_1234567890", "playlist": "casual", "revision": 5, "state": "ASSIGNED", "match_id": "match_connect_1234567890", "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2099-08-31T12:00:00Z"}
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(ticket_payload).to_utf8_buffer())
# connect_to_assignment() itself calls state.mark_connecting() as part of a
# successful attempt, so by the time control returns here phase has
# already advanced past ASSIGNED to CONNECTING -- that advancement is
# itself the proof the connect was actually attempted.
assert_eq(client.state.phase, MatchmakingState.CONNECTING, "reaching ASSIGNED with a ready assignment actually started the transport, rather than sitting idle")
assert_true(connect_started[0] or connect_failed[0], "connect_to_assignment's own signal fired")
assert_true(client._pending_connect_match_id.is_empty(), "an attempted connect is not left pending")
# A duplicate/replayed ASSIGNED event for the same match (e.g. an
# at-least-once outbox redelivery) must not fire a second connection
# attempt. Called directly against the guarded function rather than
# through another full _on_request_completed round-trip: phase has
# already moved on to CONNECTING, so both of _connect_when_assigned's own
# guards (phase != ASSIGNED, and the _connect_attempted_match_id match)
# now independently refuse a second attempt for this match.
connect_started[0] = false
connect_failed[0] = false
client._connect_when_assigned("match_connect_1234567890")
assert_true(not connect_started[0] and not connect_failed[0], "a duplicate connect attempt for an already-attempted match is not reattempted")
NetworkManager.shutdown()
client.free()
func test_client_defers_the_connect_until_the_assignment_fetch_completes() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
assert_true(client.state.begin_queue("ticket-connect-deferred", "casual"), "queue setup succeeds")
var connect_started := [false]
var connect_failed := [false]
client.assignment_connection_started.connect(func(_a): connect_started[0] = true)
client.assignment_connection_failed.connect(func(_d): connect_failed[0] = true)
# ASSIGNED arrives before the assignment fetch (triggered earlier by
# ASSIGNMENT_READY) has actually completed -- the ordering the deferred
# path exists for. client.assignment is still the default, unavailable one.
client._operation = "queue_recover"
var ticket_payload := {"ticket_id": "ticket-connect-deferred", "player_id": "player_1234567890", "playlist": "casual", "revision": 5, "state": "ASSIGNED", "match_id": "match_deferred_1234567890", "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2099-08-31T12:00:00Z"}
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(ticket_payload).to_utf8_buffer())
assert_eq(client.state.phase, MatchmakingState.ASSIGNED, "ticket state machine still reaches ASSIGNED")
assert_eq(client._pending_connect_match_id, "match_deferred_1234567890", "the connect attempt is deferred until the assignment is actually available")
assert_true(not connect_started[0] and not connect_failed[0], "no connection attempt is made before the assignment is ready -- nothing to connect to yet")
client.free()
# Covers §8.43's "failed reconnect UX": connect_to_assignment()'s own
# synchronous failures previously only emitted assignment_connection_failed,
# a signal nothing in the client listened to -- state.phase stayed stuck at
# ASSIGNED, the UI kept showing "Your match server is ready" forever, and
# there was no way back to a fresh search.
func test_synchronous_assignment_connection_failure_surfaces_as_a_failed_search() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.state.begin_queue("ticket-connect-unavailable", "casual"), "queue setup succeeds")
# client.assignment is still the default, unavailable one.
var err := client.connect_to_assignment()
assert_eq(err, ERR_UNAUTHORIZED, "connect fails closed when the assignment isn't ready")
assert_eq(client.state.phase, MatchmakingState.FAILED, "the failure is surfaced as a failed search rather than leaving the UI stuck at ASSIGNED")
assert_true(client.state.message.to_lower().contains("unavailable") or client.state.message.to_lower().contains("expired"), "the failure detail is retained: %s" % client.state.message)
client.free()
# The likelier real-world failure than the synchronous one above:
# NetworkManager.join() returns OK immediately (the attempt started), but the
# actual ENet handshake fails asynchronously later -- unreachable server,
# refused connection, ENet's own ~5s connect timeout. This is exactly the gap
# main_menu.gd's own _on_connection_failed exists to cover for the
# direct-join flow; nothing covered it for a matchmaking-driven connect.
func test_async_network_connection_failure_after_assignment_ready_surfaces_as_a_failed_search() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
client.player_id = "player_1234567890"
assert_true(client.state.begin_queue("ticket-connect-asyncfail", "casual"), "queue setup succeeds")
client._operation = "assignment"
var assignment_payload := {"match_id": "match_asyncfail_1234567890", "server_id": "server_asyncfail_1234567890", "player_id": "player_1234567890", "slot": 0, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "enet", "endpoint": "127.0.0.1:65501", "join_authorisation": "opaque-join-token"}
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(assignment_payload).to_utf8_buffer())
client._operation = "queue_recover"
var ticket_payload := {"ticket_id": "ticket-connect-asyncfail", "player_id": "player_1234567890", "playlist": "casual", "revision": 5, "state": "ASSIGNED", "match_id": "match_asyncfail_1234567890", "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2099-08-31T12:00:00Z"}
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(ticket_payload).to_utf8_buffer())
assert_eq(client.state.phase, MatchmakingState.CONNECTING, "the transport attempt started")
NetworkManager.connection_failed.emit()
assert_eq(client.state.phase, MatchmakingState.FAILED, "the async handshake failure is surfaced rather than leaving CONNECTING stuck forever")
NetworkManager.shutdown()
client.free()
func test_network_connection_failure_is_ignored_outside_a_matchmaking_driven_connect() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.state.begin_queue("ticket-unrelated-failure", "casual"), "queue setup succeeds")
# state.phase is QUEUED, not CONNECTING -- this connection_failed belongs
# to something else (e.g. main_menu.gd's own direct-join flow) and must
# not be misattributed to matchmaking.
NetworkManager.connection_failed.emit()
assert_eq(client.state.phase, MatchmakingState.QUEUED, "an unrelated connection_failed does not fail an active queue search")
client.free()
func test_rest_resource_identifiers_use_the_opaque_contract_shape() -> void:
assert_true(ControlPlaneClient.is_valid_resource_id("ticket_1234567890"), "contract-sized resource id is accepted")
assert_true(not ControlPlaneClient.is_valid_resource_id("ticket-1"), "short resource id is rejected")
assert_true(not ControlPlaneClient.is_valid_resource_id("ticket_1234567890/path"), "path separator is rejected")
func test_queue_revision_conflicts_schedule_authoritative_recovery() -> void:
assert_true(ControlPlaneClient.should_recover_queue_after_conflict("queue_heartbeat", 409, "ticket-1"), "stale heartbeat recovers the queue ticket")
assert_true(ControlPlaneClient.should_recover_queue_after_conflict("queue_cancel", 409, "ticket-1"), "stale cancellation recovers the queue ticket")
assert_true(not ControlPlaneClient.should_recover_queue_after_conflict("queue_create", 409, "ticket-1"), "create conflict uses its own idempotency path")
assert_true(not ControlPlaneClient.should_recover_queue_after_conflict("queue_heartbeat", 503, "ticket-1"), "transient outage remains retryable instead of being treated as a revision conflict")
assert_true(not ControlPlaneClient.should_recover_queue_after_conflict("queue_cancel", 409, ""), "missing ticket cannot trigger recovery")
func test_queue_conflict_response_handler_defers_ticket_recovery() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.state.begin_queue("ticket-handler", "casual"), "queue setup succeeds")
client._operation = "queue_heartbeat"
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 409, PackedStringArray(), JSON.stringify({"error": "revision conflict"}).to_utf8_buffer())
assert_eq(client._pending_resync_resource_id, "ticket-handler", "heartbeat conflict queues ticket recovery")
client._operation = "queue_cancel"
client._pending_resync_resource_id = ""
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 409, PackedStringArray(), JSON.stringify({"error": "revision conflict"}).to_utf8_buffer())
assert_eq(client._pending_resync_resource_id, "ticket-handler", "cancel conflict queues ticket recovery")
client.free()
# Covers §8.43's "version-mismatch-specific client messaging": a 426 Upgrade
# Required on queue_create (the server-side floor added alongside this test)
# must surface a distinct, actionable message rather than the server's raw
# generic error string, and must not offer a futile "Retry Search" -- the
# same client build will fail again identically every time.
func test_outdated_client_receives_a_distinct_message_and_no_retry_offer() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
client._operation = "queue_create"
client._last_queue_create = {"ticket_id": "ticket-outdated", "playlist": "casual", "client_build": "build-1", "protocol_version": 4, "key": "outdated-key-123456"}
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, HTTPClient.RESPONSE_UPGRADE_REQUIRED, PackedStringArray(), JSON.stringify({"error": "client_outdated"}).to_utf8_buffer())
assert_eq(client.state.phase, MatchmakingState.FAILED, "outdated client fails the search")
assert_true(client.state.message.to_lower().contains("update"), "message tells the player to update rather than repeating the raw server error: %s" % client.state.message)
assert_true(not client.can_retry_queue_create(), "retrying with the same outdated client build is never offered")
client.free()
func test_rest_responses_reject_malformed_resource_identifiers() -> void:
var client := ControlPlaneClient.new()
client._ready()
client._operation = "queue_recover"
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify({"ticket_id": "short", "playlist": "casual", "revision": 0, "state": "QUEUED"}).to_utf8_buffer())
assert_eq(client.state.phase, MatchmakingState.FAILED, "malformed queue response is not projected")
client._operation = "proposal_recover"
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify({"proposal_id": "proposal/unsafe", "revision": 0, "state": "OPEN"}).to_utf8_buffer())
assert_eq(client.state.phase, MatchmakingState.FAILED, "malformed proposal response is not projected")
client.free()
func test_queue_response_requires_the_complete_contract_shape() -> void:
var valid := {"ticket_id": "ticket_1234567890", "player_id": "player_1234567890", "playlist": "casual", "state": "QUEUED", "revision": 0, "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2026-08-31T12:01:00Z"}
assert_true(ControlPlaneClient._valid_queue_response(valid), "complete queue response is accepted")
var missing_expiry := valid.duplicate()
missing_expiry.erase("expires_at")
assert_true(not ControlPlaneClient._valid_queue_response(missing_expiry), "queue response without expiry is rejected")
var fractional_revision := valid.duplicate()
fractional_revision["revision"] = 1.5
assert_true(not ControlPlaneClient._valid_queue_response(fractional_revision), "fractional queue revision is rejected")
var malformed_player := valid.duplicate()
malformed_player["player_id"] = "player/unsafe"
assert_true(not ControlPlaneClient._valid_queue_response(malformed_player), "unsafe queue player id is rejected")
var assigned := valid.duplicate()
assigned["state"] = "ASSIGNMENT_READY"
assigned["match_id"] = "match_1234567890"
assert_true(ControlPlaneClient._valid_queue_response(assigned), "recovered assignment-ready ticket carries its match lookup identity")
var premature_match := valid.duplicate()
premature_match["match_id"] = "match_1234567890"
assert_true(not ControlPlaneClient._valid_queue_response(premature_match), "pre-match ticket cannot smuggle a match identity")
assigned["match_id"] = "match/unsafe"
assert_true(not ControlPlaneClient._valid_queue_response(assigned), "unsafe recovered match identity is rejected")
var proposed := valid.duplicate()
proposed["state"] = "PROPOSED"
proposed["proposal_id"] = "proposal_12345678"
assert_true(ControlPlaneClient._valid_queue_response(proposed), "recovered proposed ticket carries its proposal lookup identity")
var client := ControlPlaneClient.new()
client._ready()
client.state.begin_queue("ticket_1234567890", "casual")
client._queue_proposal_if_ready(proposed)
assert_eq(client._pending_proposal_id, "proposal_12345678", "recovered proposal is queued for authoritative fetch")
assert_eq(client.state.proposal_id, "proposal_12345678", "recovered proposal identity becomes the active projection")
client.free()
func test_proposal_response_requires_structured_unique_participants() -> void:
var base := {"proposal_id": "proposal_1234567890", "expires_at": "2099-08-31T12:00:00Z", "participants": [
{"player_id": "player_1234567890", "response": "PENDING", "team": 0, "slot": 0},
{"player_id": "player_1234567891", "response": "PENDING", "team": 1, "slot": 3}
]}
assert_true(ControlPlaneClient._valid_proposal_response(base), "structured proposal participants are accepted")
var duplicate := base.duplicate(true)
duplicate["participants"][1]["player_id"] = "player_1234567890"
assert_true(not ControlPlaneClient._valid_proposal_response(duplicate), "duplicate participant identity is rejected")
var fractional_slot := base.duplicate(true)
fractional_slot["participants"][0]["slot"] = 0.5
assert_true(not ControlPlaneClient._valid_proposal_response(fractional_slot), "fractional participant slot is rejected")
var malformed_expiry := base.duplicate(true)
malformed_expiry["expires_at"] = "tomorrow"
assert_true(not ControlPlaneClient._valid_proposal_response(malformed_expiry), "malformed proposal expiry is rejected")
var missing_expiry := base.duplicate(true)
missing_expiry.erase("expires_at")
assert_true(not ControlPlaneClient._valid_proposal_response(missing_expiry), "missing proposal expiry is rejected")
assert_true(int(ControlPlaneClient.normalize_proposal(base)["expires_at_unix"]) > 0, "proposal expiry is normalized")
func test_assignment_endpoint_split_never_accepts_url_or_bad_port() -> void:
var endpoint := ControlPlaneClient._split_assignment_endpoint("127.0.0.1:31001")
assert_eq(endpoint["host"], "127.0.0.1", "assignment host is separated from the port")
assert_eq(endpoint["port"], 31001, "assignment port is parsed as an integer")
for unsafe in ["127.0.0.1", "127.0.0.1:0", "127.0.0.1:65536", "127.0.0.1:31001/path", "https://127.0.0.1:31001"]:
assert_true(ControlPlaneClient._split_assignment_endpoint(unsafe).is_empty(), "unsafe endpoint is rejected: %s" % unsafe)
func test_ranked_profile_is_backend_display_data_and_rejects_unsafe_values() -> void:
var profile := RankedProfileState.new()
assert_true(profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "GOLD", "provisional": true, "season_id": "season_1234567890"}), "valid profile applies")
assert_eq(profile.display_text(), "Provisional · 3 ranked games", "provisional status overrides tier presentation")
assert_true(not profile.apply({"rating": -1.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "GOLD", "provisional": false}), "negative rating is rejected")
assert_true(not profile.available, "unsafe response is not displayed")
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "", "provisional": false}), "empty tier is rejected")
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "MASTER", "provisional": false}), "unknown tier is rejected")
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "GOLD", "provisional": "false"}), "string boolean is rejected")
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3.5, "tier": "GOLD", "provisional": false}), "fractional ranked games is rejected")
func test_ranked_profile_projects_and_bounds_season_countdown() -> void:
var profile := RankedProfileState.new()
assert_true(profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_id": "season_1234567890", "season_ends_at": "1970-01-03T00:00:00Z"}), "season end applies")
assert_true(profile.display_text(1000).contains("Season ends in 2d"), "countdown rounds up remaining season time")
assert_true(profile.display_text(300000).contains("Season ends in 0d"), "expired season countdown is clamped")
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_ends_at": "not-a-timestamp"}), "malformed season expiry is rejected")
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_ends_at": 123}), "non-string season expiry is rejected")
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_id": "short"}), "short season identifier is rejected")
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_id": 123}), "non-string season identifier is rejected")
# The client had no probe support at all, so even with the backend wired a real
# player could never acquire the RTT evidence the matcher requires.
func test_probe_region_validation_rejects_unknown_regions() -> void:
assert_true(ControlPlaneClient.is_valid_probe_region("EU"), "EU is a placement region")
assert_true(ControlPlaneClient.is_valid_probe_region("NA"), "NA is a placement region")
for region in ["", "eu", "APAC", "EU/NA", "../EU"]:
assert_true(not ControlPlaneClient.is_valid_probe_region(region), "rejects %s" % region)
func test_probe_requests_require_a_session() -> void:
var client = ControlPlaneClient.new()
client.base_url = "http://127.0.0.1:8080"
client.access_token = ""
assert_eq(client.request_probe_challenge("EU"), ERR_UNAUTHORIZED, "probing without a session is refused")
assert_eq(client.submit_probe_answer("EU", "bm9uY2U=", "bG9j"), ERR_UNAUTHORIZED, "answering without a session is refused")
client.free()
func test_probe_answer_rejects_empty_nonce_or_location() -> void:
var client = ControlPlaneClient.new()
client.base_url = "http://127.0.0.1:8080"
client.access_token = "session-1234567890:token-1234567890"
assert_eq(client.submit_probe_answer("EU", "", "bG9j"), ERR_INVALID_PARAMETER, "an empty nonce is refused")
assert_eq(client.submit_probe_answer("EU", "bm9uY2U=", ""), ERR_INVALID_PARAMETER, "an empty location is refused")
assert_eq(client.request_probe_challenge("APAC"), ERR_INVALID_PARAMETER, "an unknown region is refused")
client.free()
func test_opaque_location_payload_is_never_empty() -> void:
# The backend rejects an empty opaque location, and without a Steam runtime
# there is nothing real to report -- but the RTT the backend measures is
# what actually drives placement, so the probe must still be answerable.
var payload := ControlPlaneClient.opaque_location_payload()
assert_true(not payload.is_empty(), "a probe answer always carries a location blob")
assert_true(not Marshalls.base64_to_raw(payload).is_empty(), "the location blob is valid base64")
func test_probe_challenge_response_without_a_nonce_is_a_failure() -> void:
var client = ControlPlaneClient.new()
client.base_url = "http://127.0.0.1:8080"
client.access_token = "session-1234567890:token-1234567890"
var failures: Array = []
client.request_failed.connect(func(operation: String, _code: int, detail: String): failures.append([operation, detail]))
client._operation = "probe_challenge_EU"
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 201, PackedStringArray(), JSON.stringify({"region": "EU"}).to_utf8_buffer())
assert_eq(failures.size(), 1, "a challenge with no nonce is reported as a failure")
client.free()
# The game started with an empty token against a loopback default and no
# production code ever called configure() or login_steam(), so every
# matchmaking request failed ERR_UNAUTHORIZED before reaching the network.
func test_has_session_reflects_token_and_expiry() -> void:
var client = ControlPlaneClient.new()
assert_true(not client.has_session(), "a fresh client has no session")
client.access_token = "session-1234567890:token-1234567890"
client.session_expires_at = "2099-01-01T00:00:00Z"
assert_true(client.has_session(), "a valid unexpired token is a session")
client.session_expires_at = "2000-01-01T00:00:00Z"
assert_true(not client.has_session(), "an expired token is not a session")
client.free()
func test_configured_base_url_falls_back_to_the_development_default() -> void:
# Release builds set COSMIC_CLASH_CONTROL_PLANE_URL; without it the
# loopback default keeps local development working.
var resolved := ControlPlaneClient.configured_base_url()
assert_true(ControlPlaneClient.is_valid_base_url(resolved), "the resolved endpoint is always usable")
if OS.get_environment(ControlPlaneClient.BASE_URL_ENV).strip_edges().is_empty():
assert_eq(resolved, ControlPlaneClient.DEFAULT_BASE_URL, "falls back to the development default")
+190
View File
@@ -33,7 +33,197 @@ func test_empty_or_whitespace_only_falls_back_to_default() -> void:
assert_eq(MatchNet._sanitize_player_name(""), "Player", "empty string falls back")
assert_eq(MatchNet._sanitize_player_name(" "), "Player", "whitespace-only falls back")
assert_eq(MatchNet._sanitize_player_name("\n\t\r"), "Player", "control-characters-only falls back")
assert_eq(MatchNet._sanitize_shutdown_reason("\n maintenance \t"), "maintenance", "shutdown reason strips controls")
assert_eq(MatchNet._sanitize_shutdown_reason(""), "server_shutdown", "empty shutdown reason gets a safe fallback")
func test_leading_trailing_whitespace_trimmed() -> void:
assert_eq(MatchNet._sanitize_player_name(" Bob "), "Bob", "surrounding whitespace trimmed")
func test_server_shutdown_message_is_bounded_and_emitted() -> void:
var instance = MatchNet.new()
var received := [""]
var callback := func(reason: String) -> void: received[0] = reason
instance.server_shutdown.connect(callback)
instance._server_shutdown(" planned maintenance " + "x".repeat(200))
instance.server_shutdown.disconnect(callback)
assert_eq(received[0].length(), 96, "shutdown reason is bounded before presentation")
assert_eq(instance.last_server_shutdown_reason.length(), 96, "bounded shutdown reason is retained for UI")
func test_drain_fences_new_hello_admissions() -> void:
assert_eq(MatchNet.admission_rejection(true), "", "an active server accepts new hello requests")
assert_eq(MatchNet.admission_rejection(false), "server is draining", "a draining server rejects new hello requests")
func test_draining_disconnect_still_releases_roster_and_join_token() -> void:
var match_net := MatchNet.new()
var token := "opaque-join-token"
match_net.admissions_open = false
match_net.roster[42] = MatchNet.PlayerInfo.new(42, "Alice", 0, false, "player-1")
match_net._active_join_peers[token] = 42
match_net._join_history[token] = {"generation": 1}
match_net._cleanup_disconnected_peer(42)
assert_true(not match_net.roster.has(42), "drain does not retain a disconnected roster entry")
assert_true(not match_net._active_join_peers.has(token), "drain releases the disconnected peer's join token")
assert_true(float(match_net._join_history[token].get("lost_at", 0.0)) > 0.0, "disconnect records the reclaim boundary during drain")
func test_signed_assignment_locks_team_and_spawn_slot_together() -> void:
var match_net := MatchNet.new()
var info := MatchNet.PlayerInfo.new(42, "Alice", 0, true, "player-1")
info.spawn_index = 2
match_net.roster[42] = info
match_net.require_join_authorisation = true
assert_true(not match_net._apply_team_change(42, 1), "allocated clients cannot override their signed team")
assert_eq(info.team, 0, "signed team is unchanged")
assert_eq(info.spawn_index, 2, "signed spawn index remains paired with its team")
assert_true(info.ready, "rejected mutation does not alter readiness")
match_net.require_join_authorisation = false
assert_true(match_net._apply_team_change(42, 1), "direct lobbies retain team switching")
assert_eq(info.team, 1, "direct team switch applies")
assert_true(not info.ready, "direct team switch still clears readiness")
func test_reservation_reclaim_requires_stable_identity() -> void:
assert_true(MatchNet.reservation_identity_matches("player-a", "player-a", "Alice", "Impostor"), "the verified identity can reclaim despite a changed display name")
assert_true(not MatchNet.reservation_identity_matches("player-a", "player-b", "Alice", "Alice"), "a same-name peer cannot reclaim another identity's slot")
assert_true(not MatchNet.reservation_identity_matches("player-a", "", "Alice", "Alice"), "an unauthenticated peer cannot reclaim an allocated slot")
assert_true(MatchNet.reservation_identity_matches("", "", "Alice", "Alice"), "direct servers retain the legacy display-name fallback")
func test_allocated_join_authorisation_is_allowlisted_and_bound_to_server() -> void:
var claims := {
"MatchID": "match-1", "ServerID": "server-1", "PlayerID": "player-1",
"SteamID": "steam-1", "Slot": 5, "Team": 1, "Protocol": "1",
"Generation": 1, "ExpiresAt": "2099-08-31T12:00:00Z",
}
var token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": claims, "Signature": "trusted-signature"}).to_utf8_buffer())
var match_net := MatchNet.new()
assert_true(match_net.configure_join_authorisations([token], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}), "valid roster configures")
var assigned := match_net.assigned_player_slots()
assert_eq(assigned.size(), 1, "configured roster exposes one assigned player")
assert_eq(assigned[0]["player_identity"], "player-1", "assigned roster preserves player identity")
assert_eq(assigned[0]["team"], 1, "assigned roster preserves team")
assert_eq(assigned[0]["slot"], 5, "assigned roster preserves slot")
assert_true(match_net._valid_join_authorisation(token), "allowlisted matching token is accepted")
var malformed_claims := claims.duplicate()
malformed_claims["ExpiresAt"] = "tomorrow"
var malformed_token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": malformed_claims, "Signature": "trusted-signature"}).to_utf8_buffer())
assert_true(not match_net._valid_join_authorisation(malformed_token), "malformed expiry claim is rejected before admission")
var string_slot_claims := claims.duplicate()
string_slot_claims["Slot"] = "5"
var string_slot_token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": string_slot_claims, "Signature": "trusted-signature"}).to_utf8_buffer())
assert_true(not match_net._valid_join_authorisation(string_slot_token), "string slot claim is rejected instead of coerced")
assert_true(not match_net._valid_join_authorisation(token + "tampered"), "token mutation is rejected")
var wrong_claims := claims.duplicate()
wrong_claims["ServerID"] = "other-server"
var wrong_token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": wrong_claims, "Signature": "trusted-signature"}).to_utf8_buffer())
assert_true(not match_net._valid_join_authorisation(wrong_token), "wrong server claim is rejected")
assert_eq(match_net._reserve_join_authorisation(token, 42), 1, "first admission receives generation one")
assert_true(match_net.is_join_authorisation_active(token), "admitted token is active")
assert_eq(match_net._reserve_join_authorisation(token, 43), -1, "active token cannot be admitted concurrently")
match_net._remove_player(42)
assert_true(not match_net.is_join_authorisation_active(token), "disconnect releases active token")
assert_eq(match_net._reserve_join_authorisation(token, 43), 2, "reclaim receives the next server-owned generation")
match_net._remove_player(43)
match_net._join_history[token]["lost_at"] = Time.get_unix_time_from_system() - MatchNet.RECONNECT_GRACE_SECONDS - 1.0
assert_eq(match_net._reserve_join_authorisation(token, 44), -1, "reclaim after the grace window is fenced")
match_net._join_history[token]["lost_at"] = Time.get_unix_time_from_system() + 60.0
assert_eq(match_net._reserve_join_authorisation(token, 44), -1, "clock-reversed reclaim is fenced")
var malformed_context := {"match_id": 123, "server_id": "server-1", "protocol": "1", "protocol_version": 1}
assert_true(not match_net.configure_join_authorisations([token], malformed_context), "numeric context identity is rejected")
malformed_context = {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1.5}
assert_true(not match_net.configure_join_authorisations([token], malformed_context), "fractional context protocol is rejected")
func test_allocated_join_authorisation_rejects_inconsistent_team_and_slot() -> void:
var claims := {
"MatchID": "match-1", "ServerID": "server-1", "PlayerID": "player-1",
"SteamID": "steam-1", "Slot": 3, "Team": 0, "Protocol": "1",
"Generation": 1, "ExpiresAt": "2099-08-31T12:00:00Z",
}
var token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": claims, "Signature": "trusted-signature"}).to_utf8_buffer())
var match_net := MatchNet.new()
assert_true(match_net.configure_join_authorisations([token], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}), "fixture configures")
assert_true(not match_net._valid_join_authorisation(token), "a slot assigned to team 1 cannot claim team 0")
func test_assigned_roster_rejects_duplicate_identity_or_slot_shape() -> void:
var claims := {
"MatchID": "match-1", "ServerID": "server-1", "PlayerID": "player-1",
"SteamID": "steam-1", "Slot": 0, "Team": 0, "Protocol": "1",
"Generation": 1, "ExpiresAt": "2099-08-31T12:00:00Z",
}
var first := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": claims, "Signature": "one"}).to_utf8_buffer())
var duplicate := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": claims, "Signature": "two"}).to_utf8_buffer())
var match_net := MatchNet.new()
assert_true(match_net.configure_join_authorisations([first, duplicate], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}), "duplicate fixture configures for structural inspection")
assert_eq(match_net.assigned_player_slots().size(), 0, "duplicate identity/slot roster fails closed")
func test_allocated_join_authorisation_verifies_canonical_hmac() -> void:
# This envelope is generated from server/domain.JoinAuthorisationBytes with
# HMAC-SHA256(test-key), proving the Godot verifier agrees with the Go
# canonical representation rather than merely checking token membership.
# Regenerate it whenever JoinAuthorisationBytes changes; a stale token here
# is exactly how a silent cross-language format drift would be caught.
var token := "eyJBdXRob3Jpc2F0aW9uIjp7Ik1hdGNoSUQiOiJtYXRjaC0xIiwiU2VydmVySUQiOiJzZXJ2ZXItMSIsIlBsYXllcklEIjoicGxheWVyLTEiLCJTdGVhbUlEIjoic3RlYW0tMSIsIlNsb3QiOjUsIlRlYW0iOjEsIlByb3RvY29sIjoiMSIsIkdlbmVyYXRpb24iOjEsIkV4cGlyZXNBdCI6IjIwOTktMDgtMzFUMTI6MDA6MDBaIiwiS2V5SUQiOiJrZXktMjAyNi0wOSJ9LCJTaWduYXR1cmUiOiJ4bWw1a09qdjltVURab256bHVFcitCM2wyQ0c4THBOUisxd0tpV1VkMjFrPSJ9"
var match_net := MatchNet.new()
assert_true(match_net.configure_join_authorisations([token], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}, {"key-2026-09": "test-key".to_utf8_buffer()}), "HMAC roster configures")
assert_true(match_net._valid_join_authorisation(token), "Go-compatible canonical HMAC is accepted")
var tampered_payload: Dictionary = JSON.parse_string(Marshalls.base64_to_raw(token).get_string_from_utf8())
tampered_payload["Signature"] = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
var tampered_token := Marshalls.raw_to_base64(JSON.stringify(tampered_payload).to_utf8_buffer())
var tampered_match_net := MatchNet.new()
assert_true(tampered_match_net.configure_join_authorisations([tampered_token], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}, {"key-2026-09": "test-key".to_utf8_buffer()}), "tampered roster fixture configures")
assert_true(not tampered_match_net._valid_join_authorisation(tampered_token), "allowlisted but forged signature is rejected")
# Rotation contract: the allocator signs with one key while allocated servers
# accept the set of currently-valid keys, so rotating does not invalidate
# authorisations already issued for in-flight matches. All three envelopes are
# generated from server/domain.JoinAuthorisationBytes.
const ROTATION_CONTEXT := {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}
const TOKEN_SIGNED_WITH_OLD_KEY := "eyJBdXRob3Jpc2F0aW9uIjp7Ik1hdGNoSUQiOiJtYXRjaC0xIiwiU2VydmVySUQiOiJzZXJ2ZXItMSIsIlBsYXllcklEIjoicGxheWVyLTEiLCJTdGVhbUlEIjoic3RlYW0tMSIsIlNsb3QiOjUsIlRlYW0iOjEsIlByb3RvY29sIjoiMSIsIkdlbmVyYXRpb24iOjEsIkV4cGlyZXNBdCI6IjIwOTktMDgtMzFUMTI6MDA6MDBaIiwiS2V5SUQiOiJrZXktMjAyNi0wOCJ9LCJTaWduYXR1cmUiOiI5TW42eldERGNwR1pmblY2NXdreXNCYTduUnk3OG1QQkZPT29JN2F1UkdJPSJ9"
const TOKEN_SIGNED_WITH_NEW_KEY := "eyJBdXRob3Jpc2F0aW9uIjp7Ik1hdGNoSUQiOiJtYXRjaC0xIiwiU2VydmVySUQiOiJzZXJ2ZXItMSIsIlBsYXllcklEIjoicGxheWVyLTEiLCJTdGVhbUlEIjoic3RlYW0tMSIsIlNsb3QiOjUsIlRlYW0iOjEsIlByb3RvY29sIjoiMSIsIkdlbmVyYXRpb24iOjEsIkV4cGlyZXNBdCI6IjIwOTktMDgtMzFUMTI6MDA6MDBaIiwiS2V5SUQiOiJrZXktMjAyNi0wOSJ9LCJTaWduYXR1cmUiOiJ4bWw1a09qdjltVURab256bHVFcitCM2wyQ0c4THBOUisxd0tpV1VkMjFrPSJ9"
const TOKEN_SIGNED_WITH_RETIRED_KEY := "eyJBdXRob3Jpc2F0aW9uIjp7Ik1hdGNoSUQiOiJtYXRjaC0xIiwiU2VydmVySUQiOiJzZXJ2ZXItMSIsIlBsYXllcklEIjoicGxheWVyLTEiLCJTdGVhbUlEIjoic3RlYW0tMSIsIlNsb3QiOjUsIlRlYW0iOjEsIlByb3RvY29sIjoiMSIsIkdlbmVyYXRpb24iOjEsIkV4cGlyZXNBdCI6IjIwOTktMDgtMzFUMTI6MDA6MDBaIiwiS2V5SUQiOiJrZXktMjAyNy0wMSJ9LCJTaWduYXR1cmUiOiJRemFYLzB5T0pObE1oRXRPZ1BBcUpRNGJueHZRb1BVU09CR0p2Mm9nQVdnPSJ9"
func test_join_authorisation_accepts_every_key_in_the_rotation_set() -> void:
# Mid-rotation: both keys are published, so authorisations issued before
# and after the switch must both still admit their player.
var keys := {
"key-2026-08": "old-key".to_utf8_buffer(),
"key-2026-09": "test-key".to_utf8_buffer(),
}
for token in [TOKEN_SIGNED_WITH_OLD_KEY, TOKEN_SIGNED_WITH_NEW_KEY]:
var match_net := MatchNet.new()
assert_true(match_net.configure_join_authorisations([token], ROTATION_CONTEXT, keys), "rotation fixture configures")
assert_true(match_net._valid_join_authorisation(token), "a token signed by any currently-valid key is accepted")
func test_join_authorisation_rejects_a_key_id_outside_the_set() -> void:
# Rotation completed: the retired key is dropped, so anything still signed
# with it must stop being admitted.
var keys := {"key-2026-09": "test-key".to_utf8_buffer()}
var match_net := MatchNet.new()
assert_true(match_net.configure_join_authorisations([TOKEN_SIGNED_WITH_RETIRED_KEY], ROTATION_CONTEXT, keys), "retired-key fixture configures")
assert_true(not match_net._valid_join_authorisation(TOKEN_SIGNED_WITH_RETIRED_KEY), "a token naming a key outside the set is rejected")
func test_join_authorisation_key_id_cannot_be_repointed_at_another_key() -> void:
# KeyID is inside the signed bytes, so swapping it to name a key the server
# does hold must fail verification rather than selecting that key.
var payload: Dictionary = JSON.parse_string(Marshalls.base64_to_raw(TOKEN_SIGNED_WITH_OLD_KEY).get_string_from_utf8())
payload["Authorisation"]["KeyID"] = "key-2026-09"
var repointed := Marshalls.raw_to_base64(JSON.stringify(payload).to_utf8_buffer())
var keys := {
"key-2026-08": "old-key".to_utf8_buffer(),
"key-2026-09": "test-key".to_utf8_buffer(),
}
var match_net := MatchNet.new()
assert_true(match_net.configure_join_authorisations([repointed], ROTATION_CONTEXT, keys), "repointed fixture configures")
assert_true(not match_net._valid_join_authorisation(repointed), "the key ID is covered by the signature")
+207
View File
@@ -0,0 +1,207 @@
extends "res://tests/test_case.gd"
const MatchmakingState = preload("res://scripts/matchmaking_state.gd")
func test_ticket_projection_accepts_ordered_updates_and_exposes_cancel() -> void:
var state := MatchmakingState.new()
assert_true(state.begin_queue("ticket-1", "ranked"), "valid queue starts in QUEUED")
assert_true(state.apply_ticket_update({"ticket_id": "ticket-1", "revision": 1, "state": "PROPOSED", "playlist": "ranked"}), "next revision applies")
assert_eq(state.phase, MatchmakingState.PROPOSED, "proposal is visible")
assert_true(state.can_cancel(), "authoritative cancel remains available before allocation")
func test_ticket_projection_accepts_authoritative_accepted_phase() -> void:
var state := MatchmakingState.new()
assert_true(state.begin_queue("ticket-accepted", "ranked"), "queue setup succeeds")
assert_true(state.apply_ticket_update({"ticket_id": "ticket-accepted", "revision": 1, "state": "PROPOSED", "playlist": "ranked"}), "proposal phase applies")
assert_true(state.apply_ticket_update({"ticket_id": "ticket-accepted", "revision": 2, "state": "ACCEPTED", "playlist": "ranked"}), "accepted queue phase is valid")
assert_eq(state.phase, MatchmakingState.ACCEPTED, "accepted phase remains visible instead of forcing resync")
assert_true(not state.can_cancel(), "accepted match cannot be cancelled as a queue ticket")
func test_ticket_projection_accepts_post_match_lifecycle_states() -> void:
for phase in ["ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED"]:
var state := MatchmakingState.new()
assert_true(state.begin_queue("ticket-" + phase, "casual"), "queue setup succeeds for " + phase)
var revision := 1
for next_phase in ["PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED"]:
assert_true(state.apply_ticket_update({"ticket_id": "ticket-" + phase, "revision": revision, "state": next_phase, "playlist": "casual"}), "lifecycle phase applies: " + next_phase)
revision += 1
if next_phase == phase:
break
assert_eq(state.phase, phase, "post-match phase remains visible: " + phase)
assert_true(not state.can_cancel(), "post-match phase cannot cancel: " + phase)
func test_ticket_projection_rejects_gap_and_wrong_ticket_without_mutation() -> void:
var state := MatchmakingState.new()
assert_true(state.begin_queue("ticket-1", "casual"), "queue setup succeeds")
assert_eq(state.ticket_id, "ticket-1", "queue setup retains ticket identity")
var resync_ids: Array[String] = [""]
state.resync_required.connect(func(id: String) -> void: resync_ids[0] = id)
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-2", "revision": 1, "state": "PROPOSED"}), "another player's ticket is rejected")
assert_eq(resync_ids[0], "ticket-1", "wrong resource requests recovery for current ticket")
assert_eq(state.phase, MatchmakingState.QUEUED, "invalid update cannot mutate phase")
assert_true(state.needs_resync, "invalid identity is visible to recovery")
state.needs_resync = false
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-1", "revision": 3, "state": "ALLOCATING"}), "revision gap is rejected")
assert_eq(state.phase, MatchmakingState.QUEUED, "gap cannot skip authoritative state")
func test_duplicate_conflict_and_stale_updates_are_safe() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-1", "casual")
var update := {"ticket_id": "ticket-1", "revision": 1, "state": "PROPOSED", "playlist": "casual", "expires_at_unix": 100}
assert_true(state.apply_ticket_update(update), "first update applies")
assert_true(state.apply_ticket_update(update), "identical duplicate is idempotent")
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-1", "revision": 1, "state": "QUEUED", "playlist": "casual", "expires_at_unix": 100}), "same-revision conflict requests recovery")
assert_eq(state.phase, MatchmakingState.PROPOSED, "conflicting replay cannot rewind state")
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-1", "revision": 0, "state": "QUEUED"}), "stale update is ignored")
assert_eq(state.phase, MatchmakingState.PROPOSED, "stale update cannot mutate state")
func test_higher_revision_cannot_jump_or_rewind_the_authoritative_lifecycle() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-transition", "casual")
assert_true(state.apply_ticket_update({"ticket_id": "ticket-transition", "revision": 1, "state": "PROPOSED", "playlist": "casual"}), "legal transition applies")
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-transition", "revision": 2, "state": "LIVE", "playlist": "casual"}), "higher revision cannot jump phases")
assert_eq(state.phase, MatchmakingState.PROPOSED, "illegal jump cannot mutate phase")
state.needs_resync = false
assert_true(state.apply_ticket_update({"ticket_id": "ticket-transition", "revision": 2, "state": "ACCEPTED", "playlist": "casual"}), "legal next transition applies")
state.needs_resync = false
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-transition", "revision": 3, "state": "PROPOSED", "playlist": "casual"}), "higher revision cannot rewind after acceptance")
assert_eq(state.phase, MatchmakingState.ACCEPTED, "illegal rewind cannot mutate phase")
func test_authoritative_ticket_snapshot_can_cross_missed_forward_revisions_but_not_rewind() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-snapshot", "casual")
assert_true(state.apply_ticket_update({"ticket_id": "ticket-snapshot", "revision": 1, "state": "PROPOSED", "playlist": "casual"}), "incremental proposal applies")
assert_true(state.apply_ticket_update({"ticket_id": "ticket-snapshot", "revision": 5, "state": "ASSIGNMENT_READY", "playlist": "casual"}, true), "owner-scoped REST snapshot crosses missed forward states")
assert_eq(state.phase, MatchmakingState.ASSIGNMENT_READY, "authoritative recovery reaches assignment readiness")
state.needs_resync = false
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-snapshot", "revision": 6, "state": "QUEUED", "playlist": "casual"}, true), "authoritative snapshot cannot rewind an assigned match")
assert_eq(state.phase, MatchmakingState.ASSIGNMENT_READY, "rejected snapshot cannot mutate phase")
func test_ticket_and_proposal_revisions_must_be_nonnegative_integers() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-revision", "casual")
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-revision", "revision": 1.5, "state": "PROPOSED"}), "fractional ticket revision is rejected")
assert_true(not state.apply_proposal_update({"proposal_id": "proposal-revision", "revision": -1, "state": "OPEN"}), "negative proposal revision is rejected")
func test_ticket_update_rejects_invalid_playlist_without_mutation() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-playlist", "casual")
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-playlist", "revision": 1, "state": "PROPOSED", "playlist": "admin"}), "unknown playlist is rejected")
assert_eq(state.phase, MatchmakingState.QUEUED, "invalid playlist cannot change phase")
assert_eq(state.playlist, "casual", "invalid playlist cannot change playlist")
func test_ticket_and_proposal_epoch_metadata_rejects_malformed_values() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-epoch", "casual")
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-epoch", "revision": 1, "state": "PROPOSED", "expires_at_unix": "not-a-time"}), "malformed ticket expiry is rejected")
assert_eq(state.phase, MatchmakingState.QUEUED, "malformed ticket expiry cannot change phase")
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-epoch", "revision": 1, "state": "PROPOSED", "enqueued_at_unix": -1}), "negative enqueue time is rejected")
assert_true(not state.apply_proposal_update({"proposal_id": "proposal-epoch", "revision": 1, "state": "OPEN", "expires_at_unix": 1.25}), "fractional proposal expiry is rejected")
func test_proposal_terminal_states_are_visible_and_not_cancellable() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-1", "casual")
assert_true(state.apply_proposal_update({"proposal_id": "proposal-1", "revision": 1, "state": "OPEN"}), "open proposal applies")
assert_eq(state.phase, MatchmakingState.PROPOSED, "open proposal is visible")
assert_true(state.apply_proposal_update({"proposal_id": "proposal-1", "revision": 2, "state": "DECLINED"}), "declined proposal applies")
assert_eq(state.phase, MatchmakingState.QUEUED, "decline returns the requeued ticket to search")
assert_true(state.can_cancel(), "requeued ticket can be cancelled")
var expired := MatchmakingState.new()
expired.begin_queue("ticket-2", "casual")
assert_true(expired.apply_proposal_update({"proposal_id": "proposal-2", "revision": 1, "state": "OPEN"}), "second proposal opens")
assert_true(expired.apply_proposal_update({"proposal_id": "proposal-2", "revision": 2, "state": "EXPIRED"}), "expired proposal applies")
assert_eq(expired.phase, MatchmakingState.QUEUED, "expiry returns the requeued ticket to search")
assert_true(expired.can_cancel(), "requeued ticket can be cancelled")
var cancelled := MatchmakingState.new()
cancelled.begin_queue("ticket-3", "casual")
assert_true(cancelled.apply_proposal_update({"proposal_id": "proposal-3", "revision": 1, "state": "OPEN"}), "third proposal opens")
cancelled.phase = MatchmakingState.CANCELLED
assert_true(cancelled.apply_proposal_update({"proposal_id": "proposal-3", "revision": 2, "state": "DECLINED"}), "decline after ticket cancellation is accepted")
assert_eq(cancelled.phase, MatchmakingState.CANCELLED, "proposal decline cannot resurrect a cancelled ticket")
func test_proposal_projection_rejects_illegal_higher_revision_transitions() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-proposal-transition", "casual")
assert_true(state.apply_proposal_update({"proposal_id": "proposal-transition", "revision": 1, "state": "OPEN"}), "proposal opens")
assert_true(state.apply_proposal_update({"proposal_id": "proposal-transition", "revision": 2, "state": "ACCEPTED"}), "proposal accepts")
assert_true(not state.apply_proposal_update({"proposal_id": "proposal-transition", "revision": 3, "state": "OPEN"}), "accepted proposal cannot reopen")
assert_eq(state.proposal_state, "ACCEPTED", "illegal proposal transition cannot mutate state")
state.needs_resync = false
var declined := MatchmakingState.new()
declined.begin_queue("ticket-proposal-declined", "casual")
assert_true(declined.apply_proposal_update({"proposal_id": "proposal-declined", "revision": 1, "state": "OPEN"}), "second proposal opens")
assert_true(declined.apply_proposal_update({"proposal_id": "proposal-declined", "revision": 2, "state": "DECLINED"}), "second proposal declines")
assert_true(not declined.apply_proposal_update({"proposal_id": "proposal-declined", "revision": 3, "state": "ACCEPTED"}), "declined proposal cannot accept")
func test_terminal_proposal_is_not_an_active_recovery_target() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-terminal-proposal", "casual")
assert_true(state.apply_proposal_update({"proposal_id": "proposal-terminal", "revision": 1, "state": "OPEN"}), "proposal opens")
assert_true(state.has_open_proposal(), "open proposal is an active recovery target")
assert_true(state.apply_proposal_update({"proposal_id": "proposal-terminal", "revision": 2, "state": "EXPIRED"}), "proposal expires")
assert_true(not state.has_open_proposal(), "terminal proposal uses ticket recovery instead")
assert_true(state.prepare_proposal_recovery("proposal_second_123"), "a later proposal can replace a terminal proposal identity")
assert_true(state.apply_proposal_update({"proposal_id": "proposal_second_123", "revision": 4, "state": "OPEN"}), "recovered later proposal accepts its authoritative revision")
assert_true(state.has_open_proposal(), "later proposal becomes the active recovery target")
assert_true(not state.prepare_proposal_recovery("proposal_third_1234"), "an open proposal cannot be replaced by another identity")
func test_assignment_lifecycle_has_explicit_connecting_and_live_states() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-1", "ranked")
state.mark_assignment_ready()
assert_eq(state.phase, MatchmakingState.ASSIGNMENT_READY, "assignment readiness is visible")
state.mark_connecting()
assert_eq(state.phase, MatchmakingState.CONNECTING, "transport connection is visible")
state.mark_live()
assert_eq(state.phase, MatchmakingState.LIVE, "live match is visible")
func test_expiry_is_distinct_from_generic_failure_and_remains_visible() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-1", "casual")
state.expire("Queue ticket expired")
assert_eq(state.phase, MatchmakingState.EXPIRED, "expired ticket has a terminal expiry state")
assert_eq(state.message, "Queue ticket expired", "expiry reason is visible")
assert_true(not state.can_cancel(), "expired ticket cannot be cancelled")
func test_restart_restore_requires_valid_identity_and_requests_authoritative_recovery() -> void:
var state := MatchmakingState.new()
assert_true(state.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket_1234567890", "playlist": "casual", "revision": 2}), "valid active snapshot restores")
assert_true(state.needs_resync, "restored active state must recover from the server")
assert_eq(state.revision, 2, "revision is retained for diagnostics")
assert_true(not state.restore_snapshot({"phase": "QUEUED", "ticket_id": "", "playlist": "casual"}), "missing ticket identity is rejected")
assert_eq(state.phase, MatchmakingState.IDLE, "invalid restore cannot leave stale active state")
assert_true(not state.restore_snapshot({"phase": "NOT_A_STATE", "ticket_id": "ticket-1", "playlist": "casual"}), "unknown state is rejected")
assert_true(not state.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket-1", "playlist": "casual", "revision": "2"}), "string revision is rejected")
assert_true(not state.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket-1", "playlist": "casual", "enqueued_at_unix": 1.5}), "fractional epoch is rejected")
assert_true(not state.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket-1", "playlist": "casual", "proposal_state": "OPEN"}), "proposal state without identity is rejected")
func test_authoritative_enqueue_time_survives_wait_projection_and_restore() -> void:
var state := MatchmakingState.new()
assert_true(state.begin_queue("ticket_wait_123456", "casual"), "queue setup succeeds")
assert_true(state.apply_ticket_update({"ticket_id": "ticket_wait_123456", "revision": 0, "state": "QUEUED", "playlist": "casual", "enqueued_at_unix": 1000}), "server enqueue timestamp applies")
assert_eq(state.waited_seconds(1065), 65, "wait uses server enqueue time")
var restored := MatchmakingState.new()
assert_true(restored.restore_snapshot(state.snapshot()), "snapshot restores")
assert_eq(restored.waited_seconds(1065), 65, "authoritative wait survives restore")
assert_true(not restored.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket-short", "playlist": "casual"}), "short snapshot ticket id is rejected")
assert_true(not restored.restore_snapshot({"phase": "PROPOSED", "ticket_id": "ticket_wait_123456", "playlist": "casual", "proposal_id": "proposal/unsafe", "proposal_state": "OPEN"}), "unsafe snapshot proposal id is rejected")
+46
View File
@@ -0,0 +1,46 @@
extends "res://tests/test_case.gd"
const Matchmaking = preload("res://scripts/matchmaking.gd")
const MatchmakingState = preload("res://scripts/matchmaking_state.gd")
func test_every_backend_phase_has_a_nonempty_user_message() -> void:
for phase in [MatchmakingState.IDLE, MatchmakingState.QUEUED, MatchmakingState.PROPOSED, MatchmakingState.ACCEPTED, MatchmakingState.ALLOCATING, MatchmakingState.PROCESS_READY, MatchmakingState.ASSIGNMENT_READY, MatchmakingState.ASSIGNED, MatchmakingState.CONNECTING, MatchmakingState.LIVE, MatchmakingState.RESULT_PENDING, MatchmakingState.COMPLETED, MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED]:
assert_true(not Matchmaking.phase_label(phase).is_empty(), "phase %s has visible copy" % phase)
func test_terminal_state_policy_does_not_leave_cancel_or_proposal_actions_enabled() -> void:
for phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE, MatchmakingState.COMPLETED]:
assert_true(Matchmaking._is_terminal(phase), "phase %s is terminal" % phase)
assert_true(not Matchmaking._is_terminal(MatchmakingState.QUEUED), "queued search remains active")
assert_true(not Matchmaking._is_terminal(MatchmakingState.PROPOSED), "proposal remains actionable")
assert_true(Matchmaking._can_start_new_search(MatchmakingState.FAILED), "failed search can be retried")
assert_true(not Matchmaking._can_start_new_search(MatchmakingState.LIVE), "live match cannot start a second search")
assert_true(Matchmaking._can_start_new_search(MatchmakingState.COMPLETED), "completed match can start a new search")
func test_proposal_countdown_uses_authoritative_expiry_and_clamps_after_expiry() -> void:
assert_eq(Matchmaking.proposal_countdown_text(1100, 1000), "Review proposal · 100s remaining", "proposal countdown uses server expiry")
assert_eq(Matchmaking.proposal_countdown_text(1000, 1001), "Review proposal · 0s remaining", "expired proposal countdown clamps to zero")
assert_eq(Matchmaking.proposal_countdown_text(0, 1000), "Review the proposal before the countdown expires", "missing expiry retains compatible copy")
func test_allocation_lifecycle_phases_have_specific_detail_copy() -> void:
for phase in [MatchmakingState.ACCEPTED, MatchmakingState.ALLOCATING, MatchmakingState.PROCESS_READY, MatchmakingState.ASSIGNMENT_READY, MatchmakingState.ASSIGNED, MatchmakingState.CONNECTING, MatchmakingState.LIVE]:
assert_true(not Matchmaking.phase_detail_label(phase).is_empty(), "phase %s has lifecycle detail copy" % phase)
assert_true(Matchmaking.phase_detail_label(MatchmakingState.ALLOCATING).contains("dedicated"), "allocation explains dedicated server provisioning")
assert_true(Matchmaking.phase_detail_label(MatchmakingState.CONNECTING).contains("Connecting"), "connecting explains the active transport step")
func test_queue_wait_copy_explains_progress_without_trusting_negative_input() -> void:
assert_eq(Matchmaking.queue_wait_detail_text(-4, -2), "Waiting 0s · looking for compatible players · revision 0", "negative metadata is clamped")
assert_true(Matchmaking.queue_wait_detail_text(10, 3).contains("skill and latency"), "mid-wait explains the compatibility search")
assert_true(Matchmaking.queue_wait_detail_text(30, 4).contains("keeping latency limits"), "long waits explain bounded widening")
func test_latency_copy_fails_closed_and_explains_quality_boundaries() -> void:
assert_eq(Matchmaking.latency_detail_text(-1.0), "Latency: measuring", "missing latency remains honest")
assert_eq(Matchmaking.latency_detail_text(INF), "Latency: measuring", "infinite latency fails closed")
assert_eq(Matchmaking.latency_detail_text(50.0), "Latency: 50ms · excellent", "excellent boundary is inclusive")
assert_eq(Matchmaking.latency_detail_text(100.0), "Latency: 100ms · good", "good boundary is inclusive")
assert_eq(Matchmaking.latency_detail_text(100.1), "Latency: 100ms · high", "high latency is surfaced")
+1 -1
View File
@@ -97,7 +97,7 @@ func test_snapshot_roundtrip_seven_bodies() -> void:
var packet := NetCodec.pack_snapshot(555, -2, 1234, segment)
assert_eq(packet.size(), NetCodec.SNAPSHOT_CLIENT_HEADER_SIZE + segment.size(), "full packet size")
assert_eq(packet.size(), 169, "matches multiplayer-todo.md §2.4's 169 B payload figure for 7 bodies")
assert_eq(packet.size(), 169, "matches MULTIPLAYER_SPEC.md §2.4's 169 B payload figure for 7 bodies")
var decoded := NetCodec.unpack_snapshot(packet)
assert_eq(decoded["last_input_seq"], 555, "last_input_seq")
@@ -99,6 +99,14 @@ func test_genuine_missing_history_is_still_a_hard_snap() -> void:
assert_eq(decision["mode"], "hard", "%s must still hard-correct" % status)
func test_warmup_ack_before_first_prediction_is_skipped() -> void:
var history := LocalPredictionHistory.new()
var authority := _authoritative()
var comparison := history.compare_authoritative(0, authority)
assert_eq(comparison["status"], "warmup_not_recorded", "pre-history acknowledgement is startup, not loss")
assert_eq(NetShipPredictor.decide(comparison, false, false)["mode"], "skip", "startup acknowledgement must not hard-snap")
func test_a_reset_still_wins_over_an_unsimulated_gap() -> void:
# Ordering guard: reset_gen is an epoch boundary and outranks everything,
# including the new skip path — otherwise a gap landing on the reset
+7 -1
View File
@@ -71,13 +71,19 @@ func test_physics_engine_is_jolt() -> void:
func test_required_autoloads_are_registered() -> void:
# NetworkManager in particular is reached by name from many scripts; losing
# it from [autoload] fails only at the point of use, deep in a smoke test.
for autoload_name in ["GameSettings", "VideoSettings", "NetworkManager", "MatchNet", "MatchSim"]:
for autoload_name in ["GameSettings", "ControlPlaneClient", "VideoSettings", "NetworkManager", "MatchNet", "MatchSim"]:
assert_true(
ProjectSettings.has_setting("autoload/" + autoload_name),
"autoload/%s registered" % autoload_name
)
func test_matchmaking_scene_is_the_control_plane_entry_point() -> void:
var scene := load("res://scenes/matchmaking.tscn")
assert_true(scene != null, "matchmaking scene exists")
assert_true(FileAccess.file_exists("res://scripts/matchmaking.gd"), "matchmaking controller exists")
func test_test_hook_autoloads_are_not_shipped() -> void:
# main_menu_test_hooks / lobby_test_hooks are added to [autoload] by hand
# when running those scene-level smoke tests, and must be removed again —
+60
View File
@@ -26,6 +26,7 @@ func test_defaults_apply_when_nothing_is_given() -> void:
assert_true(config.is_valid(), "an empty command line is valid")
assert_eq(config.get_value("port"), 7777, "default port")
assert_eq(config.get_value("max-matches"), 0, "0 means run forever")
assert_eq(config.get_value("max-overtime-seconds"), 900.0, "allocated sudden death has a finite safety cap")
assert_eq(config.get_value("log-level"), "info", "default log level")
@@ -98,8 +99,11 @@ func test_out_of_range_values_are_rejected_with_their_own_message() -> void:
assert_true(not _parse(["--port=70000"]).is_valid(), "port 70000 is out of range")
assert_true(not _parse(["--max-clients=0"]).is_valid(), "a server for nobody is rejected")
assert_true(not _parse(["--match-length=0"]).is_valid(), "a zero-length match is rejected")
assert_true(not _parse(["--max-overtime-seconds=0"]).is_valid(), "an unbounded allocated overtime cap is rejected")
assert_true(not _parse(["--log-level=chatty"]).is_valid(), "an undefined log level is rejected")
assert_true(not _parse(["--arena-rotation=spiral"]).is_valid(), "an undefined rotation mode is rejected")
assert_true(not _parse(["--arena-path=res://scenes/arena_01_elevated.tscn"]).is_valid(), "an elevated arena cannot be selected for allocated ranked play")
assert_true(_parse(["--arena-path=res://scenes/arena_01.tscn"]).is_valid(), "a ranked-eligible arena path is accepted")
assert_true(not _parse(["--smoke-force-goal-after=-2"]).is_valid(), "only -1 disables the deterministic smoke goal")
# Control: the same flags at legal values all pass together.
var ok = _parse(["--port=7000", "--max-clients=6", "--match-length=90", "--log-level=warn", "--arena-rotation=random"])
@@ -126,3 +130,59 @@ func test_help_is_requested_without_needing_a_valid_command_line() -> void:
assert_true(config.help_requested, "--help is recognised")
var short = _parse(["-h"])
assert_true(short.help_requested, "-h too")
func test_allocated_mode_is_opt_in_and_requires_compatibility_manifest() -> void:
var community = _parse([])
assert_true(community.is_valid(), "community defaults remain valid")
assert_eq(community.get_value("allocated-mode"), false, "allocation is opt-in")
var incomplete = _parse(["--allocated-mode", "--transport=enet"])
assert_true(not incomplete.is_valid(), "allocated mode cannot start without its manifest")
var valid = _parse([
"--allocated-mode", "--match-id=match_1234567890123456", "--server-id=server_1234567890123456",
"--playlist-version=2026-08-31", "--client-build=client-2026-08-31", "--assignment-expiry-unix=%d" % (Time.get_unix_time_from_system() + 3600), "--server-image-digest=sha256:" + "a".repeat(64),
"--playlist=casual", "--transport=enet", "--region=EU", "--join-authorisations-file=/run/secrets/join-authorisations.json", "--join-authorisations-key-file=/run/secrets/join-authorisations.key"
])
assert_true(valid.is_valid(), "a complete allocated compatibility manifest is accepted: %s" % str(valid.errors))
func test_allocated_mode_rejects_invalid_transport_region_or_digest() -> void:
var args := [
"--allocated-mode", "--match-id=m", "--server-id=s", "--playlist-version=v",
"--client-build=client", "--assignment-expiry-unix=%d" % (Time.get_unix_time_from_system() + 3600),
"--server-image-digest=sha256:" + "g".repeat(64), "--transport=udp", "--region=AP"
]
var config = _parse(args)
assert_true(not config.is_valid(), "invalid compatibility values are rejected")
var unsafe_id = _parse([
"--allocated-mode", "--match-id=short", "--server-id=server/unsafe", "--playlist-version=v",
"--client-build=client", "--assignment-expiry-unix=%d" % (Time.get_unix_time_from_system() + 3600),
"--server-image-digest=sha256:" + "a".repeat(64), "--playlist=casual", "--transport=enet", "--region=EU",
"--join-authorisations-file=/run/secrets/join-authorisations.json", "--join-authorisations-key-file=/run/secrets/join-authorisations.key"
])
assert_true(not unsafe_id.is_valid(), "short or unsafe allocated identifiers are rejected")
func test_allocated_mode_rejects_missing_or_expired_assignment_manifest_fields() -> void:
var missing = _parse(["--allocated-mode", "--match-id=m", "--server-id=s", "--playlist-version=v", "--server-image-digest=sha256:" + "a".repeat(64), "--transport=enet", "--region=EU"])
assert_true(not missing.is_valid(), "client build and expiry are required")
var expired = _parse(["--allocated-mode", "--match-id=m", "--server-id=s", "--playlist-version=v", "--client-build=client", "--assignment-expiry-unix=1", "--server-image-digest=sha256:" + "a".repeat(64), "--transport=enet", "--region=EU"])
assert_true(not expired.is_valid(), "expired assignment is rejected")
func test_allocated_start_floor_is_the_verified_roster_size() -> void:
var boot = preload("res://scripts/server_boot.gd")
assert_eq(boot.required_min_players(true, 6, 1), 6, "allocated six-player roster cannot start with one player")
assert_eq(boot.required_min_players(true, 2, 6), 2, "allocated casual roster uses its complete size")
assert_eq(boot.required_min_players(false, 1, 1), 1, "direct server keeps its configured floor")
func test_connection_reporting_requires_safe_workload_configuration() -> void:
var boot = preload("res://scripts/server_boot.gd")
assert_true(boot.valid_connection_report_configuration("http://control-plane:8080", "signed-token", "match-1234567890", "server-123456789", "player-123456789"), "allocated workload configuration is accepted")
assert_true(not boot.valid_connection_report_configuration("", "signed-token", "match-1234567890", "server-123456789", "player-123456789"), "allocated startup fails closed without a control-plane lease URL")
assert_true(not boot.valid_connection_report_configuration("http://control-plane:8080", "", "match-1234567890", "server-123456789", "player-123456789"), "allocated startup fails closed without a workload lease credential")
assert_true(not boot.valid_connection_report_configuration("http://control-plane:8080?token=leak", "signed-token", "match-1234567890", "server-123456789", "player-123456789"), "query-bearing control-plane URL is rejected")
assert_true(not boot.valid_connection_report_configuration("http://control-plane:8080", "token\nforged", "match-1234567890", "server-123456789", "player-123456789"), "header injection token is rejected")
assert_true(not boot.valid_connection_report_configuration("http://control-plane:8080", "signed-token", "short", "server-123456789", "player-123456789"), "non-opaque match identity is rejected")
assert_true(not boot.valid_connection_report_configuration("http://control-plane:8080", "signed-token", "match-1234567890", "server-123456789", "short"), "non-opaque player identity is rejected")
+21
View File
@@ -0,0 +1,21 @@
extends "res://tests/test_case.gd"
const ServerControlScript = preload("res://scripts/server_control.gd")
func test_control_rejects_invalid_port_and_starts_loopback_listener() -> void:
var control = ServerControlScript.new()
assert_eq(control.start(0), ERR_INVALID_PARAMETER, "control rejects port zero")
var port := 18000 + (Time.get_ticks_usec() % 1000)
assert_eq(control.start(port, "drain-secret"), OK, "control starts on a valid loopback port")
control.stop()
control.queue_free()
func test_process_ready_and_drain_state_are_monotonic() -> void:
var control = ServerControlScript.new()
assert_true(not control.is_draining(), "control starts non-draining")
control.set_process_ready(true)
assert_true(not control.is_draining(), "process readiness does not imply draining")
control.stop()
control.queue_free()
@@ -0,0 +1,19 @@
extends "res://tests/test_case.gd"
func test_allocated_initial_connect_policy_has_explicit_boundaries() -> void:
var loop = preload("res://scripts/server_match_loop.gd")
assert_eq(loop.allocated_initial_connect_action("ranked", 29999, 5, 6, true, true), loop.ALLOCATED_WAIT, "ranked waits before 30 seconds")
assert_eq(loop.allocated_initial_connect_action("ranked", 30000, 5, 6, true, true), loop.ALLOCATED_CANCEL, "ranked cancels at 30 seconds")
assert_eq(loop.allocated_initial_connect_action("casual", 59999, 2, 6, true, true), loop.ALLOCATED_WAIT, "casual waits before 60 seconds")
assert_eq(loop.allocated_initial_connect_action("casual", 60000, 2, 6, true, true), loop.ALLOCATED_START_WITH_BOTS, "casual starts with bots when both teams are represented")
assert_eq(loop.allocated_initial_connect_action("casual", 1000, 2, 2, true, true), loop.ALLOCATED_START_WITH_BOTS, "complete relaxed casual roster starts with disclosed bots immediately")
assert_eq(loop.allocated_initial_connect_action("casual", 1000, 2, 2, true, false), loop.ALLOCATED_CANCEL, "malformed relaxed casual roster fails closed")
assert_eq(loop.allocated_initial_connect_action("casual", 60000, 2, 6, true, false), loop.ALLOCATED_CANCEL, "casual cancels when one team is empty")
assert_eq(loop.allocated_initial_connect_action("casual", 1000, 6, 6, true, true), loop.ALLOCATED_READY, "complete roster is ready immediately")
assert_eq(loop.allocated_initial_connect_action("ranked", 1000, 5, 5, true, true), loop.ALLOCATED_CANCEL, "ranked cannot shrink its expected roster")
assert_eq(loop.allocated_initial_connect_action("other", 0, 1, 6, true, true), loop.ALLOCATED_CANCEL, "unknown allocated playlist fails closed")
var instance = loop.new()
instance.allocated_admission_armed = false
instance.arm_allocated_admission()
assert_true(instance.allocated_admission_armed, "durable readiness signal arms the local timeout")
instance.free()
@@ -0,0 +1,41 @@
extends "res://tests/test_case.gd"
const Client = preload("res://scripts/server_result_client.gd")
func test_result_nonce_is_deterministic_and_score_bound() -> void:
var first := Client.result_nonce("match-123456789", "server-123456789", 3, 2, "CERTIFIED")
assert_eq(first, Client.result_nonce("match-123456789", "server-123456789", 3, 2, "CERTIFIED"), "retry keeps the exact nonce")
assert_true(first != Client.result_nonce("match-123456789", "server-123456789", 2, 3, "CERTIFIED"), "a conflicting score cannot reuse the nonce")
assert_true(first.length() >= 16, "nonce satisfies the control-plane minimum")
func test_result_configuration_fails_closed() -> void:
assert_true(Client.valid_configuration("https://control.invalid", "token", "match-123456789", "server-123456789"), "valid result reporter configuration is accepted")
assert_true(not Client.valid_configuration("https://control.invalid?token=leak", "token", "match-123456789", "server-123456789"), "query-bearing endpoint is rejected")
assert_true(not Client.valid_configuration("https://control@evil.invalid", "token", "match-123456789", "server-123456789"), "userinfo-bearing endpoint is rejected")
assert_true(not Client.valid_configuration("https://control.invalid", "", "match-123456789", "server-123456789"), "empty bearer is rejected")
func test_only_a_committed_result_acknowledgement_releases_the_match() -> void:
assert_true(Client.response_is_accepted(202), "the endpoint's accepted response releases RESULTS")
assert_true(not Client.response_is_accepted(200), "an unexpected generic success cannot lose the result")
assert_true(not Client.response_is_accepted(422), "validation failure remains held for operator-visible retry")
assert_true(not Client.response_is_accepted(503), "outage remains held for retry")
func test_review_results_are_permitted_but_forged_states_are_not() -> void:
var client := Client.new()
assert_true(client.configure("https://control.invalid", "token", "match-123456789", "server-123456789"), "test client configures")
# submit itself is asynchronous; the pure configuration boundary proves the
# reporter can carry the REVIEW state selected by bounded overtime.
assert_true(Client.result_nonce("match-123456789", "server-123456789", 1, 1, "REVIEW") != Client.result_nonce("match-123456789", "server-123456789", 1, 1, "CERTIFIED"), "integrity state binds the receipt identity")
func test_match_net_forwards_review_integrity_to_the_reporter() -> void:
var received: Array = []
MatchNet.configure_result_submission(func(team_0: int, team_1: int, integrity: String): received.append_array([team_0, team_1, integrity]))
assert_true(MatchNet.submit_authoritative_result({0: 1, 1: 1}, "REVIEW"), "configured reporter accepts the bounded-overtime outcome")
assert_eq(received, [1, 1, "REVIEW"], "review state reaches the reporter and cannot become a rated result")
MatchNet.configure_result_submission(Callable())
assert_true(not MatchNet.submit_authoritative_result({0: 1, 1: 1}, "CERTIFIED"), "cleared reporter cannot silently claim result delivery")
+29
View File
@@ -0,0 +1,29 @@
extends "res://tests/test_case.gd"
const SteamBootstrap = preload("res://scripts/steam_bootstrap.gd")
# Web-API ticket acquisition (task 7.6). Nothing in the project could obtain a
# ticket before, so ControlPlaneClient.login_steam() had no production caller.
# These run on stock Godot, which has no GodotSteam symbols, so they cover the
# pure encoding and the unavailable path rather than a live Steam session.
func test_web_api_ticket_is_unsupported_without_the_steam_runtime() -> void:
if SteamBootstrap.is_runtime_available():
return
assert_true(not SteamBootstrap.supports_web_api_ticket(), "no ticket support without the custom build")
assert_eq(SteamBootstrap.request_web_api_ticket(), 0, "requesting a ticket yields no handle")
# Must not throw on stock Godot; cancelling a handle we never got is a no-op.
SteamBootstrap.cancel_web_api_ticket(0)
SteamBootstrap.cancel_web_api_ticket(17)
func test_web_api_ticket_encoding_is_lowercase_hex() -> void:
# The publisher Web API expects the raw ticket bytes hex encoded; the
# backend rejects anything non-hex before it forwards a ticket to Valve.
assert_eq(SteamBootstrap.encode_web_api_ticket(PackedByteArray()), "", "an empty ticket encodes to nothing")
assert_eq(SteamBootstrap.encode_web_api_ticket(PackedByteArray([0x00, 0x0f, 0xa5, 0xff])), "000fa5ff", "bytes are zero-padded lowercase hex")
var encoded := SteamBootstrap.encode_web_api_ticket(PackedByteArray([1, 2, 3, 4, 250]))
assert_eq(encoded.length(), 10, "each byte becomes exactly two characters")
assert_eq(encoded, encoded.to_lower(), "encoding is lowercase")
+18
View File
@@ -0,0 +1,18 @@
extends "res://tests/test_case.gd"
const ShipAIControllerScript = preload("res://scripts/ship_ai_controller.gd")
func test_team_touch_credit_is_split_across_teammates() -> void:
assert_eq(ShipAIControllerScript.team_touch_credit(0.8, 0.5, 2), 0.2, "half-weight touch is split between two teammates")
func test_team_touch_credit_never_exceeds_touch_payout() -> void:
var credit := ShipAIControllerScript.team_touch_credit(0.8, 1.0, 1)
assert_eq(credit, 0.8, "one teammate receives at most the touch payout")
func test_team_touch_credit_rejects_invalid_inputs() -> void:
assert_eq(ShipAIControllerScript.team_touch_credit(0.8, 0.0, 2), 0.0, "zero weight is disabled")
assert_eq(ShipAIControllerScript.team_touch_credit(0.8, 0.5, 0), 0.0, "no teammates receive no credit")
assert_eq(ShipAIControllerScript.team_touch_credit(-1.0, 0.5, 2), 0.0, "negative payout cannot mint reward")
func test_team_touch_credit_clamps_weight() -> void:
assert_eq(ShipAIControllerScript.team_touch_credit(0.8, 2.0, 2), 0.4, "weight above one is clamped")
+21
View File
@@ -0,0 +1,21 @@
extends "res://tests/test_case.gd"
const TrainingModeScript = preload("res://scripts/training_mode.gd")
func test_wall_play_points_into_the_field() -> void:
var velocity: Vector3 = TrainingModeScript.wall_state_velocity(false, 1.0, 8.0, 0.1, -0.2)
assert_true(velocity.x < 0.0, "positive-side wall play travels inward")
assert_almost_eq(velocity.length(), 8.0, 0.0001, "wall-play speed is preserved")
func test_rebound_points_into_the_selected_wall() -> void:
var velocity: Vector3 = TrainingModeScript.wall_state_velocity(true, 1.0, 8.0, 0.1, -0.2)
assert_true(velocity.x > 0.0, "positive-side rebound travels toward the wall")
assert_almost_eq(velocity.length(), 8.0, 0.0001, "rebound speed is preserved")
func test_opposite_walls_mirror_the_normal_component() -> void:
var positive: Vector3 = TrainingModeScript.wall_state_velocity(false, 1.0, 6.0, 0.0, 0.0)
var negative: Vector3 = TrainingModeScript.wall_state_velocity(false, -1.0, 6.0, 0.0, 0.0)
assert_eq(positive.x, -negative.x, "opposite wall starts mirror the x direction")
func test_negative_speed_fails_closed() -> void:
assert_eq(TrainingModeScript.wall_state_velocity(false, 1.0, -1.0, 0.0, 0.0), Vector3.ZERO, "negative speed cannot create velocity")
+137
View File
@@ -0,0 +1,137 @@
extends Node
# Two-process real end-to-end proposal smoke test: extends
# control_plane_smoke.gd's single-player login/queue/heartbeat/cancel
# coverage to the matcher path -- two real Godot clients, two real queued
# tickets, a real running server/cmd/matcher pairing them, both clients
# observing the resulting proposal over the real WebSocket event stream and
# accepting it for real. multiplayer-next.md 8.40 names this "a two-player
# proposal round trip" as the next scoped extension to this harness.
#
# ControlPlaneClient is a singleton autoload, so one process can only ever be
# one player -- this mirrors net_smoke.gd's own host/client two-process
# pattern rather than trying to simulate two players in one process:
#
# godot --headless --path Game res://tests/control_plane_proposal_smoke.tscn -- \
# --control-plane-url=http://127.0.0.1:PORT --role=player-a
# godot --headless --path Game res://tests/control_plane_proposal_smoke.tscn -- \
# --control-plane-url=http://127.0.0.1:PORT --role=player-b
#
# Prints one "SMOKE PASS/FAIL: ..." line and exits 0/1.
const TIMEOUT_SECONDS := 20.0
var _role := ""
var _finished := false
var _ticket_id := ""
var _accept_sent := false
func _ready() -> void:
var control_plane_url := ""
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--control-plane-url="):
control_plane_url = arg.substr("--control-plane-url=".length())
elif arg.begins_with("--role="):
_role = arg.substr("--role=".length())
if control_plane_url.is_empty() or (_role != "player-a" and _role != "player-b"):
_finish(false, "missing --control-plane-url or --role=player-a|player-b")
return
if not ControlPlaneClient.configure(control_plane_url, "0:0"):
_finish(false, "configure() rejected a valid-looking base URL")
return
_ticket_id = "proposal-smoke-%s-%d" % [_role, Time.get_unix_time_from_system()]
ControlPlaneClient.request_succeeded.connect(_on_request_succeeded)
ControlPlaneClient.request_failed.connect(_on_request_failed)
ControlPlaneClient.state.changed.connect(_on_state_changed)
var web_api_ticket := "proposal-smoke-web-api-ticket-%s-%d" % [_role, Time.get_ticks_usec()]
var err := ControlPlaneClient.login_steam(web_api_ticket)
if err != OK:
_finish(false, "login_steam() failed to start: %s" % error_string(err))
return
print("SMOKE[%s]: logging in against %s..." % [_role, control_plane_url])
var timer := Timer.new()
timer.wait_time = TIMEOUT_SECONDS
timer.one_shot = true
timer.timeout.connect(func(): _finish(false, "timed out after %.1fs waiting for a proposal" % TIMEOUT_SECONDS))
add_child(timer)
timer.start()
func _on_request_succeeded(operation: String, payload: Dictionary) -> void:
if _finished:
return
if operation == "steam_session":
print("SMOKE[%s]: logged in as %s, queueing for a 2-player casual match..." % [_role, ControlPlaneClient.player_id])
var err := ControlPlaneClient.queue_create(_ticket_id, "casual", "smoke-build", 1)
if err != OK:
_finish(false, "queue_create() failed to start: %s" % error_string(err))
return
if operation == "queue_create":
print("SMOKE[%s]: ticket %s QUEUED, waiting for the matcher to propose a match..." % [_role, _ticket_id])
return
if operation.begins_with("proposal_"):
if payload.get("state", "") != "ACCEPTED" and payload.get("state", "") != "OPEN":
_finish(false, "unexpected proposal response after accept: %s" % payload)
return
if payload.get("state", "") == "ACCEPTED":
_finish(true, "both players queued, the real matcher formed a proposal, and this client's accept was recorded")
else:
print("SMOKE[%s]: accepted; waiting for the other player..." % _role)
var recovery_timer := get_tree().create_timer(1.0)
recovery_timer.timeout.connect(_recover_after_accept)
func _recover_after_accept() -> void:
if _finished or ControlPlaneClient._operation != "" or ControlPlaneClient.state.proposal_id.is_empty():
return
var err := ControlPlaneClient.recover_proposal(ControlPlaneClient.state.proposal_id)
if err != OK and err != ERR_BUSY:
_finish(false, "proposal recovery after accept failed to start: %s" % error_string(err))
func _on_state_changed(snapshot: Dictionary) -> void:
if _finished or _accept_sent or String(snapshot.get("proposal_state", "")) != "OPEN":
return
if ControlPlaneClient._operation != "":
return # Already mid-request (e.g. the accept itself); avoid double-sending.
print("SMOKE[%s]: proposal %s is OPEN at revision %d, accepting..." % [_role, ControlPlaneClient.state.proposal_id, ControlPlaneClient.state.proposal_revision])
_accept_sent = true
if _role == "player-b":
var delay := get_tree().create_timer(0.5)
delay.timeout.connect(_send_accept)
return
_send_accept()
func _send_accept() -> void:
if _finished:
return
var err := ControlPlaneClient.respond_to_proposal(ControlPlaneClient.state.proposal_id, true, ControlPlaneClient.state.proposal_revision)
if err != OK and err != ERR_BUSY:
_accept_sent = false
_finish(false, "respond_to_proposal() failed to start: %s" % error_string(err))
func _on_request_failed(operation: String, http_code: int, detail: String) -> void:
if _finished:
return
if operation == "proposal_accept" and http_code == HTTPClient.RESPONSE_CONFLICT:
print("SMOKE[%s]: concurrent accept conflicted; ControlPlaneClient is recovering the authoritative proposal..." % _role)
return
_finish(false, "%s failed: http=%d detail=%s" % [operation, http_code, detail])
func _finish(passed: bool, detail: String) -> void:
if _finished:
return
_finished = true
if passed:
print("SMOKE PASS: [%s] %s" % [_role, detail])
get_tree().quit(0)
else:
print("SMOKE FAIL: [%s] %s" % [_role, detail])
get_tree().quit(1)
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/control_plane_proposal_smoke.gd" id="1_cpps"]
[node name="ControlPlaneProposalSmoke" type="Node"]
script = ExtResource("1_cpps")
+184
View File
@@ -0,0 +1,184 @@
extends Node
# Real end-to-end smoke test for ControlPlaneClient against a REAL running
# control-plane HTTP server backed by REAL PostgreSQL -- proving the actual
# wire format (GDScript's HTTPRequest/JSON on one side, the real compiled Go
# api.Service on the other) is compatible, not just that each side's own unit
# tests pass in isolation. Every other ControlPlaneClient test in this repo
# is either pure parsing/validation logic or drives the client against a
# mock; nothing before this exercised a real network round trip end to end
# (multiplayer-next.md 8.40's own evidence names this "live... verification"
# as remaining).
#
# Run against scripts/verify_control_plane_integration.sh's server/cmd/testkit-api
# instance -- see that script's own header for why a separate, clearly-marked
# test-only binary exists rather than a flag on the real cmd/control-plane:
#
# godot --headless --path Game res://tests/control_plane_smoke.tscn -- \
# --control-plane-url=http://127.0.0.1:PORT
#
# Prints one "SMOKE PASS/FAIL: ..." line and exits 0/1.
const TIMEOUT_SECONDS := 10.0
var _finished := false
var _ticket_id := ""
var _assignment_match_id := ""
var _steam_ticket := ""
var _ranked_profile_smoke := false
func _ready() -> void:
var control_plane_url := ""
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--control-plane-url="):
control_plane_url = arg.substr("--control-plane-url=".length())
elif arg.begins_with("--assignment-match-id="):
_assignment_match_id = arg.substr("--assignment-match-id=".length())
elif arg.begins_with("--steam-ticket="):
_steam_ticket = arg.substr("--steam-ticket=".length())
elif arg == "--ranked-profile-smoke":
_ranked_profile_smoke = true
if control_plane_url.is_empty():
_finish(false, "missing --control-plane-url")
return
# A syntactically valid but semantically meaningless placeholder token:
# configure() validates format eagerly, but real auth doesn't exist until
# login_steam()'s response overwrites it below. There is no other way to
# set base_url alone.
if not ControlPlaneClient.configure(control_plane_url, "0:0"):
_finish(false, "configure() rejected a valid-looking base URL")
return
_ticket_id = "smoke-ticket-%d" % Time.get_unix_time_from_system()
ControlPlaneClient.request_succeeded.connect(_on_request_succeeded)
ControlPlaneClient.request_failed.connect(_on_request_failed)
var web_api_ticket := _steam_ticket if not _steam_ticket.is_empty() else "smoke-web-api-ticket-%d" % Time.get_ticks_usec()
var err := ControlPlaneClient.login_steam(web_api_ticket)
if err != OK:
_finish(false, "login_steam() failed to start: %s" % error_string(err))
return
print("SMOKE: logging in against %s..." % control_plane_url)
var timer := Timer.new()
timer.wait_time = TIMEOUT_SECONDS
timer.one_shot = true
timer.timeout.connect(func(): _finish(false, "timed out after %.1fs" % TIMEOUT_SECONDS))
add_child(timer)
timer.start()
func _on_request_succeeded(operation: String, payload: Dictionary) -> void:
if _finished:
return
match operation:
"steam_session":
if not _assignment_match_id.is_empty():
print("SMOKE: logged in as %s, fetching player-scoped assignment..." % ControlPlaneClient.player_id)
var err := ControlPlaneClient.fetch_assignment(_assignment_match_id)
if err != OK:
_finish(false, "fetch_assignment() failed to start: %s" % error_string(err))
return
if _ranked_profile_smoke:
print("SMOKE: logged in as %s, fetching populated ranked profile..." % ControlPlaneClient.player_id)
var ranked_err := ControlPlaneClient.fetch_ranked_profile()
if ranked_err != OK:
_finish(false, "fetch_ranked_profile() failed to start: %s" % error_string(ranked_err))
return
print("SMOKE: logged in as %s, fetching ranked profile (expect none yet)..." % ControlPlaneClient.player_id)
var err := ControlPlaneClient.fetch_ranked_profile()
if err != OK:
_finish(false, "fetch_ranked_profile() failed to start: %s" % error_string(err))
"assignment":
if payload.get("match_id", "") != _assignment_match_id or payload.get("player_id", "") != ControlPlaneClient.player_id or not ControlPlaneClient.assignment.available:
_finish(false, "unexpected assignment payload: %s" % payload)
return
_finish(true, "authenticated assignment fetch returned the player-scoped endpoint and join authorisation")
"ranked_profile":
if not _ranked_profile_smoke:
_finish(false, "a brand-new testkit identity unexpectedly already has a ranked profile: %s" % payload)
return
if int(payload.get("rating", -1)) != 1600 or int(payload.get("ranked_games", -1)) != 12 or payload.get("provisional", true) != false or String(payload.get("tier", "")) != "GOLD":
_finish(false, "unexpected populated ranked profile: %s" % payload)
return
_finish(true, "authenticated ranked profile returned the durable rating, games, tier, and provisional state")
"queue_create":
if payload.get("ticket_id", "") != _ticket_id or payload.get("state", "") != "QUEUED":
_finish(false, "unexpected queue_create payload: %s" % payload)
return
# apply_ticket_update (called for every "queue_"-prefixed response,
# including this one) can itself decide the ticket needs a resync
# and fire off a recover_queue() call -- a real, existing part of
# MatchmakingState's own state machine, not something this test
# controls. Wait for ControlPlaneClient to go idle before sending
# the next request rather than assuming queue_create was the only
# thing in flight.
print("SMOKE: ticket %s QUEUED at revision %d, heartbeating once idle..." % [_ticket_id, ControlPlaneClient.state.revision])
_send_heartbeat_once_idle()
"queue_recover":
pass # Expected background resync; the idle-wait above handles it.
"queue_heartbeat":
if int(payload.get("revision", -1)) <= 0:
_finish(false, "heartbeat did not advance the revision: %s" % payload)
return
print("SMOKE: heartbeat advanced to revision %d, cancelling..." % ControlPlaneClient.state.revision)
_send_cancel_once_idle()
"queue_cancel":
if payload.get("state", "") != "CANCELLED":
_finish(false, "unexpected queue_cancel payload: %s" % payload)
return
_finish(true, "login -> queue_create -> heartbeat -> cancel all round-tripped against a real server")
func _send_heartbeat_once_idle() -> void:
if not ControlPlaneClient._operation.is_empty():
# call_deferred alone floods the message queue without ever letting a
# real frame (and therefore the in-flight HTTP request) actually
# process -- a real Timer yields to the engine between checks.
var poll := get_tree().create_timer(0.05)
poll.timeout.connect(_send_heartbeat_once_idle)
return
var err := ControlPlaneClient.heartbeat(_ticket_id, ControlPlaneClient.state.revision)
if err != OK:
_finish(false, "heartbeat() failed to start: %s" % error_string(err))
func _send_cancel_once_idle() -> void:
if not ControlPlaneClient._operation.is_empty():
var poll := get_tree().create_timer(0.05)
poll.timeout.connect(_send_cancel_once_idle)
return
var err := ControlPlaneClient.cancel_queue(_ticket_id, ControlPlaneClient.state.revision)
if err != OK:
_finish(false, "cancel_queue() failed to start: %s" % error_string(err))
func _on_request_failed(operation: String, http_code: int, detail: String) -> void:
if _finished:
return
if operation == "ranked_profile" and http_code == HTTPClient.RESPONSE_NOT_FOUND:
# Expected: a brand-new testkit identity has no ratings row yet, and
# the real store adapter reports that the same way the in-memory
# fallback it replaced always did -- not found, not an error.
print("SMOKE: ranked profile correctly reports not found, creating a queue ticket...")
var err := ControlPlaneClient.queue_create(_ticket_id, "casual", "smoke-build", 1)
if err != OK:
_finish(false, "queue_create() failed to start: %s" % error_string(err))
return
if operation == "assignment":
_finish(false, "assignment fetch failed: http=%d detail=%s" % [http_code, detail])
return
_finish(false, "%s failed: http=%d detail=%s" % [operation, http_code, detail])
func _finish(passed: bool, detail: String) -> void:
if _finished:
return
_finished = true
if passed:
print("SMOKE PASS: %s" % detail)
get_tree().quit(0)
else:
print("SMOKE FAIL: %s" % detail)
get_tree().quit(1)
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/control_plane_smoke.gd" id="1_cps"]
[node name="ControlPlaneSmoke" type="Node"]
script = ExtResource("1_cps")
+1 -1
View File
@@ -8,7 +8,7 @@ extends Node
# get_tree().current_scene, and calling it from a node that ISN'T an
# ancestor-chain match for current_scene (as an earlier draft of this test
# did, by add_child()-ing lobby.tscn under this driver) hung completely
# on disconnect — see multiplayer-todo.md §9 gotcha 27.
# on disconnect — see multiplayer-next.md §9 gotcha 27.
#
# The host role loading lobby.tscn is deliberate, not an oversight: a
# *dedicated* server (server_boot.tscn) never loads it, but a self-hosting
+1 -1
View File
@@ -97,7 +97,7 @@ func _on_player_joined(peer_id: int, player_name: String) -> void:
_finish(true, "host saw player_joined (peer_id=%d, name=%s)" % [peer_id, player_name])
# Adversarial-review regression (multiplayer-todo.md §9): MatchNet.roster
# Adversarial-review regression (multiplayer-next.md §9): MatchNet.roster
# used to have no path that cleared it when a HOST itself called
# NetworkManager.shutdown() — only the client-side disconnect signal did.
# Host -> client joins -> host leaves (shutdown) -> host again used to
+21 -3
View File
@@ -1378,9 +1378,25 @@ func run_ci_client_check(run_seconds: float) -> void:
var snapshot_count_ok: bool = snapshot_count[0] >= min_expected
var net_stats: Dictionary = match_scene.get_net_debug_stats()
var present_time := bool(match_scene.remote_visual_present_time_enabled)
var remote_position_p95 := float(net_stats.get("remote_residual_position_p95", INF))
var remote_rotation_p95 := float(net_stats.get("remote_residual_rotation_p95", INF))
var remote_position_p99 := float(net_stats.get("remote_residual_position_p99", INF))
var remote_rotation_p99 := float(net_stats.get("remote_residual_rotation_p99", INF))
var remote_quality_ok := not present_time or (remote_position_p99 < 0.3 and remote_rotation_p99 < 5.0)
# Two bars rather than one loose one. The tight bar moved to p95, which is
# stable across runs; p99 over a few hundred samples is its worst handful,
# so on a shared CI host it measures scheduling jitter as much as
# interpolation. The p99 bar is the product's own tolerance: beyond
# REMOTE_VISUAL_MAX_OFFSET the visual smoother stops absorbing a correction
# in one step, so exceeding it is a real defect rather than a slow runner.
#
# The single p99 < 0.3 bar produced false failures: two clients in one run
# reported 0.324 and 0.187 with everything else identical, and the same
# commit passed and failed in the same minute.
var remote_quality_ok := not present_time or (
remote_position_p95 < 0.3 and remote_rotation_p95 < 5.0
and remote_position_p99 < NetworkedMatch.REMOTE_VISUAL_MAX_OFFSET
and remote_rotation_p99 < NetworkedMatch.REMOTE_VISUAL_MAX_ROTATION_DEGREES
)
var my_id := multiplayer.get_unique_id()
var score_path := "/tmp/cosmicclash_ci_score_%d.txt" % my_id
@@ -1388,8 +1404,10 @@ func run_ci_client_check(run_seconds: float) -> void:
f.store_string(JSON.stringify(match_scene.score))
f.close()
print("SMOKE INFO: client-bot snapshot_count=%d (want >= %d) slots_ok=%s final_score=%s remote_present_time=%s residual_p99=%.3fm/%.3fdeg" % [
snapshot_count[0], min_expected, str(slots_ok), str(match_scene.score), str(present_time), remote_position_p99, remote_rotation_p99,
print("SMOKE INFO: client-bot snapshot_count=%d (want >= %d) slots_ok=%s final_score=%s remote_present_time=%s residual_p95=%.3fm/%.3fdeg residual_p99=%.3fm/%.3fdeg (p95 bar %.2fm/%.1fdeg, p99 bar %.2fm/%.1fdeg)" % [
snapshot_count[0], min_expected, str(slots_ok), str(match_scene.score), str(present_time),
remote_position_p95, remote_rotation_p95, remote_position_p99, remote_rotation_p99,
0.3, 5.0, NetworkedMatch.REMOTE_VISUAL_MAX_OFFSET, NetworkedMatch.REMOTE_VISUAL_MAX_ROTATION_DEGREES,
])
var success: bool = slots_ok and snapshot_count_ok and remote_quality_ok
print("SMOKE %s: CI client-bot run" % ("PASS" if success else "FAIL"))
+63
View File
@@ -0,0 +1,63 @@
extends SceneTree
const ServerControlScript = preload("res://scripts/server_control.gd")
const PORT := 18080
func _init() -> void:
var control = ServerControlScript.new()
root.add_child(control)
if control.start(PORT, "drain-secret") != OK:
printerr("server control failed to bind")
quit(1)
return
control.set_process_ready(true)
control.set_meta("admission_armed", false)
control.initial_connect_ready.connect(func() -> void: control.set_meta("admission_armed", true))
await process_frame
var ready_response := await _request("GET", "/ready", [])
if ready_response != 200:
printerr("ready response was %d" % ready_response)
quit(1)
return
var unauthorized := await _request("POST", "/drain", ["Authorization: Bearer wrong"])
if unauthorized != 401:
printerr("unauthorized drain response was %d" % unauthorized)
quit(1)
return
var unauthorized_admission := await _request("POST", "/initial-connect-ready", ["Authorization: Bearer wrong"])
if unauthorized_admission != 401 or bool(control.get_meta("admission_armed")):
printerr("unauthorized initial-connect response/state was %d/%s" % [unauthorized_admission, control.get_meta("admission_armed")])
quit(1)
return
var admitted := await _request("POST", "/initial-connect-ready", ["Authorization: Bearer drain-secret"])
if admitted != 202 or not bool(control.get_meta("admission_armed")):
printerr("authorized initial-connect response/state was %d/%s" % [admitted, control.get_meta("admission_armed")])
quit(1)
return
var drained := await _request("POST", "/drain", ["Authorization: Bearer drain-secret"])
if drained != 202 or not control.is_draining():
printerr("authorized drain response/state was %d/%s" % [drained, control.is_draining()])
quit(1)
return
var not_ready := await _request("GET", "/ready", [])
if not_ready != 503:
printerr("draining ready response was %d" % not_ready)
quit(1)
return
control.stop()
print("server control smoke passed")
quit(0)
func _request(method: String, path: String, headers: PackedStringArray) -> int:
var request := HTTPRequest.new()
root.add_child(request)
var http_method := HTTPClient.METHOD_GET if method == "GET" else HTTPClient.METHOD_POST
var err := request.request("http://127.0.0.1:%d%s" % [PORT, path], headers, http_method)
if err != OK:
request.queue_free()
return -1
var result = await request.request_completed
request.queue_free()
return int(result[1])
+66
View File
@@ -0,0 +1,66 @@
[gd_resource type="Theme" load_steps=5 format=3]
[sub_resource type="StyleBoxFlat" id="StyleBox_button_normal"]
bg_color = Color(0.08, 0.12, 0.2, 0.94)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.18, 0.42, 0.68, 0.9)
corner_radius_top_left = 6
corner_radius_top_right = 6
corner_radius_bottom_right = 6
corner_radius_bottom_left = 6
content_margin_left = 16.0
content_margin_top = 9.0
content_margin_right = 16.0
content_margin_bottom = 9.0
[sub_resource type="StyleBoxFlat" id="StyleBox_button_hover"]
bg_color = Color(0.12, 0.3, 0.48, 0.98)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.3, 0.72, 1, 1)
corner_radius_top_left = 6
corner_radius_top_right = 6
corner_radius_bottom_right = 6
corner_radius_bottom_left = 6
content_margin_left = 16.0
content_margin_top = 9.0
content_margin_right = 16.0
content_margin_bottom = 9.0
[sub_resource type="StyleBoxFlat" id="StyleBox_line_edit"]
bg_color = Color(0.035, 0.055, 0.1, 0.96)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.14, 0.3, 0.48, 1)
corner_radius_top_left = 5
corner_radius_top_right = 5
corner_radius_bottom_right = 5
corner_radius_bottom_left = 5
content_margin_left = 10.0
content_margin_top = 7.0
content_margin_right = 10.0
content_margin_bottom = 7.0
[resource]
default_font_size = 16
Button/colors/font_color = Color(0.86, 0.94, 1, 1)
Button/colors/font_hover_color = Color(1, 1, 1, 1)
Button/colors/font_pressed_color = Color(1, 1, 1, 1)
Button/colors/font_disabled_color = Color(0.45, 0.52, 0.62, 1)
Button/styles/normal = SubResource("StyleBox_button_normal")
Button/styles/hover = SubResource("StyleBox_button_hover")
Button/styles/pressed = SubResource("StyleBox_button_hover")
Button/styles/focus = SubResource("StyleBox_button_hover")
LineEdit/colors/font_color = Color(0.9, 0.96, 1, 1)
LineEdit/colors/caret_color = Color(0.3, 0.72, 1, 1)
LineEdit/styles/normal = SubResource("StyleBox_line_edit")
OptionButton/colors/font_color = Color(0.86, 0.94, 1, 1)
Label/colors/font_color = Color(0.82, 0.9, 0.98, 1)
+2 -2
View File
@@ -1,7 +1,7 @@
extends Node
# One-off GPU frame-time profiling harness for task 0.15b's real-hardware
# follow-up (multiplayer-todo.md §5.5.1) — the automated Mac passes gave
# follow-up (MULTIPLAYER_SPEC.md §5.5.1) — the automated Mac passes gave
# inconsistent, sometimes implausible numbers (stale-process contention,
# and Apple Silicon's tile-based GPU architecture is a poor stand-in for the
# target reference hardware). Run this directly on a machine with a real
@@ -47,7 +47,7 @@ func _ready() -> void:
var match_scene := load("res://scenes/match.tscn") as PackedScene
_match = match_scene.instantiate()
# 3v3 = 6 ships, matching the scenario multiplayer-todo.md §5.5 measures.
# 3v3 = 6 ships, matching the scenario MULTIPLAYER_SPEC.md §5.5 measures.
_match.team_size = 3
# Direct-scene-run fallback path (see match_mode.gd:_make_opponent_controller)
# — gives every AI ship a real trained policy so thruster VFX/movement
+582
View File
@@ -0,0 +1,582 @@
# Online multiplayer — architecture and wire-format spec
The standing design reference for the online multiplayer effort: locked
architecture decisions, the wire format, server-side input handling,
prediction/reconciliation, the latency/frame-rate budget, and the match
lifecycle state machine. This describes *how the system works* — it is not
task-tracked and does not distinguish implemented from not-yet-implemented;
for that, and for the outstanding task list, see
[`multiplayer-next.md`](multiplayer-next.md), which cites sections here by
number (`§2.4`, `§4.1`, …) and assumes them as background before picking up
Phase 2 or later work.
---
## 1. Architecture decisions
### 1.1 Locked decisions
| # | Decision | Why |
|---|---|---|
| 1 | **Server-authoritative simulation, with client-side prediction of the local ship and ball. No world rollback / resimulation.** | Jolt is not bit-deterministic across platforms or across differing contact orderings, and Godot exposes no world snapshot/restore API. Rollback netcode would be a research project. |
| 2 | **Dedicated servers only.** Headless Godot export; the server is never a player. | Fair for every player, no host advantage. Self-hostable community servers first, so nothing is blocked on paid infrastructure. |
| 3 | **ENet first**, GodotSteam later, behind a boundary. | ENet works in-editor, headless, on LAN, and in CI with no Steam client. Direct-IP connect stays permanently supported and **must never become the degraded path**. |
| 4 | **Community discovery uses no custom backend; superseded for queued play by Phase 8.** | Steam's server APIs remain enough for the community browser. Casual/ranked queues, durable ratings, allocation and authoritative results require the project-owned Go control plane specified in `docs/MATCHMAKING.md`; it does not replace the browser or direct-IP path. |
### 1.2 Rejected alternatives
- **Peer-authoritative ships** (each client owns its own transform). Easiest to build, feels perfect locally, and is trivially cheatable — it directly contradicts `README.md`'s stated anti-cheat position. Ship-vs-ship collisions also become ambiguous with no arbiter.
- **Deterministic lockstep / rollback.** See decision 1.
- **`MultiplayerSynchronizer` / `MultiplayerSpawner`.** The decisive objection is not bandwidth. It is that `last_processed_input_seq` **must** arrive in the same packet as the state it describes, or reconciliation is off by a snapshot — and a synchroniser gives you nowhere to put it. It also writes replicated properties directly onto the node, which is exactly wrong for a `RigidBody3D` under prediction: incoming state has to enter a compare-against-history pipeline, not be stamped onto `global_transform`. You would end up building the correction pipeline anyway, with the synchroniser as pure overhead. Secondary objections: one packet per body (7 bodies × ~50 B of UDP/IP/ENet framing versus one coalesced snapshot), no per-field quantisation, and no client-side interpolation.
`MultiplayerSpawner` is unnecessary for a separate reason: the roster is fixed at match start and fully described by the `match_config` message, and **no ship is ever despawned** (§6.4).
- **Seeded RNG for kickoff jitter.** Shared-seed determinism requires both sides to consume the RNG stream in exactly the same order forever. The first `randf()` anyone later adds anywhere in the reset path — a spawn VFX variation, a cosmetic, a commentary line — silently desyncs kickoff positions with no error message. The server broadcasts the resulting transforms instead: 336 bytes, once per kickoff, cannot rot.
### 1.3 Derived decisions
**All hot-path RPCs live on autoloads.** `/root/NetworkManager` and `/root/MatchNet` exist at identical paths on every peer regardless of which scene is loaded, which side is headless, or whether a client is mid-scene-transition. This deletes the entire "NodePaths must match across peers" class of bugs, kills a family of late-join races where an RPC arrives before its target node exists, and warms Godot's RPC path cache once at connect so it never re-sends a full path on scene change.
**Entities are addressed by integer slot, never by path.** The snapshot is `[slot 0..N-1]` in a fixed order established by `match_config`. `MatchNet` holds an `Array[Node] _slots` populated at spawn.
**One server process hosts exactly one match.** This is forced, not chosen: `ship.gd:162` resolves the arena boundary via `get_tree().get_first_node_in_group("arena_boundary")` and `ai_ship_controller.gd` discovers its roster via `get_tree().get_nodes_in_group("ship")`. Both are tree-global, so two matches in one scene tree would cross-wire instantly. It is recorded here because it determines the RAM figure in §1.4.
### 1.4 Server sizing — bandwidth and CPU are not the constraint
Worth establishing up front, because §2 and §3 repeatedly trade bandwidth for latency and somebody will eventually want to trade back.
`ArenaBoundary.bake_colliders()` generates roughly 168 box colliders (corner fillets, base wrap, ceiling, end walls) plus the scene's own slabs, 2 goal backstops, 2 `Area3D` sensors, and 7 dynamic bodies (the ball with `continuous_cd`). Estimated per-tick cost:
| Component | ms/tick |
|---|---:|
| Jolt step | 0.15 0.4 |
| Godot headless main loop | 0.1 0.3 |
| Bot inference, amortised | ~0.3 |
| **Total, of a 16.7 ms budget** | **0.6 1.1** |
**~610 concurrent matches per modern core**, ~150250 MB RSS per process. 100 concurrent matches ≈ 1216 cores and ~20 GB — a single mid-tier VPS. Upstream bandwidth for a full 6-player match is ~630 kbit/s (§2.4).
**Neither CPU nor bandwidth is scarce. Latency is.** Optimise accordingly.
---
## 2. Wire format
Two peers must agree byte-for-byte, so this is specified rather than sketched.
### 2.1 Channels
| Channel | Transfer mode | Contents |
|---|---|---|
| 0 | reliable | handshake, `match_config`, kickoff, goal, clock, state changes, chat, admin |
| 1 | unreliable-ordered | client → server input |
| 2 | unreliable-ordered | server → client snapshots |
Unreliable-**ordered** (ENet sequenced-unreliable, drops stale) rather than plain unreliable for both hot paths: we carry explicit sequence numbers, and a reordered late packet is worthless work. Separating them stops a large reliable `match_config` from head-of-line-blocking state on a lossy link.
**Set `ENetMultiplayerPeer.server_relay = false`.** It defaults to `true`, which lets any client `rpc()` any other client *through your server*. With it off, clients can only talk to peer 1. Single highest-value one-line security change in this document.
### 2.2 Packet header
**Every hot-path packet opens with a 1-byte type + version.** A capture then decodes standalone, and a mismatched build fails loudly instead of decoding garbage straight into `state.transform`.
Hot paths carry a single `PackedByteArray` RPC argument (≈14 B of Godot RPC framing once the path cache is warm). Control messages on channel 0 use normal typed arguments — they are rare and readability beats bytes.
### 2.3 Input packet — client → server, channel 1, 60 Hz
```
u8 type_version
u32 seq server-tick-space sequence of the NEWEST action
u8 count 1..4 (MAX_REDUNDANCY)
u32 ack_snapshot_tick newest snapshot tick this client has processed
u16 client_send_ms wrapping ms clock, echoed back for RTT
--- repeated `count` times, newest first ---
i8 thrust_x, thrust_y, thrust_z value = clamp(round(v*127), -127, 127)
i8 rot_x, rot_y, rot_z
u8 flags bit0 = turbo
```
**12 + 7×4 = 40 B payload**, ~90 B on the wire with UDP/IP/ENet framing → **~43 kbit/s up per client**.
- **Redundancy 4** is what makes an unreliable input channel safe: starvation requires four consecutive losses (~66 ms).
- **`i8` per axis, not 3-bit bins.** Bins matching `ShipActionCodec.HEADS` would cut an action to 3 bytes, but they permanently foreclose analog gamepad sticks, which this game will want. `round(v*127)/127` round-trips `-1/0/+1` exactly, so today's digital input (`player_ship_controller.gd` is `is_action_pressed`-only) is lossless.
- **The encoding is itself a validator.** `i8/127` cannot express NaN, Inf, or a value outside `[-1.008, 1.008]`. Half of "sanitise untrusted client input" is solved by not using Variant encoding.
### 2.4 Snapshot — server → client, channel 2, 60 Hz default
Per-client header built per peer; body buffer built once per tick and reused across peers.
```
--- per-client header (7 B) ---
u32 last_input_seq newest input from THIS client the server has applied
i8 input_buffer_depth jitter-buffer occupancy; negative = starved
u16 echo_client_send_ms from that input packet, for RTT
--- shared body header (8 B) ---
u8 type_version
u32 server_tick Engine.get_physics_frames() on the server
u8 match_state see §6.1
u8 reset_gen increments on every authoritative teleport
u8 body_count
--- repeated body_count times, slot order fixed by match_config (22 B each) ---
i16 pos_x, pos_y, pos_z range ±64 m -> 1.95 mm
i16 quat_x, quat_y, quat_z w = ±sqrt(1-x²-y²-z²), sign in flags
i16 vel_x, vel_y, vel_z range ±64 m/s -> 1.95 mm/s
i8 avel_x, avel_y, avel_z ships ±4 rad/s; ball ±32 rad/s
u8 flags bit0 frozen, bit1 turbo, bits2-4 thrust_z bin,
bit5 stalled, bit6 quat_w sign
```
7 bodies → **8 + 7 + 7×22 = 169 B payload**, ~219 B on the wire.
| | per client down | server up, 6 clients | + 10 spectators |
|---|---:|---:|---:|
| 60 Hz | 105 kbit/s | 631 kbit/s | 1.68 Mbit/s |
MTU headroom is ~6× (ENet fragments above ~1400 B); a hypothetical 10v10 at 21 bodies is 477 B and still fits. **This format does not need delta compression.**
**Plain `i16` quaternion components, not smallest-three.** Smallest-three saves 4 B/body and is the textbook answer. It is also exactly where a hand-rolled codec goes subtly wrong — off-by-one in the 2-bit index, sign of the dropped component, renormalisation drift. Three `i16`s plus a sign bit give ~3e-5 rad with no bit-shifting, for 2 B/body (≈3 kbit/s). Take the bytes.
**Quantisation ranges derive from constants, not from prose.** `ArenaBoundary.INNER_HALF_X = 18.0`, `INNER_HALF_Z = 27.0`, `INNER_HEIGHT = 18.0` (`arena_boundary.gd:8-10`) plus `GameMode.ESCAPE_MARGIN = 15.0`; `Ship.max_speed = 35.0` (`ship.gd:16`); `Ball.MAX_SPEED = 32.0` (`ball.gd:17`).
**The flags byte must carry `turbo` and a 3-bit `thrust_z` bin.** `_integrate_forces` is not called on frozen bodies, so remote ships on a client never pull `get_action()`, and `Ship._update_movement_vfx()` (`ship.gd:293`) reads `_current_action.thrust.z` and `turbo`. Without those bits, every remote ship flies with dead engines.
### 2.5 Reliable control messages, channel 0
`hello` · `welcome` · `player_joined` · `player_left` · `ready_state` · `match_config` · `scene_ready` · `kickoff` · `state_change` · `goal_scored` · `clock_state` · `match_ended` · `chat` · `server_shutdown`.
---
## 3. Server-side input handling
Per-player server state:
```gdscript
class PlayerSlot:
var peer_id: int
var slot: int # snapshot index
var ring: Array[ShipAction] # FIXED 32 entries, indexed seq % 32
var ring_seq: PackedInt32Array # 32 entries, seq stored at each index (-1 = empty)
var last_applied_seq: int
var last_action: ShipAction
var starved_ticks: int
var packets_this_second: int
var remote_controller: RLShipController
```
### 3.1 Ingestion
`@rpc("any_peer", "unreliable_ordered", channel = 1)`, in order:
1. `multiplayer.get_remote_sender_id()` → look up slot. Unknown sender → drop and count.
2. **Rate limit.** `packets_this_second > 110` (60 Hz × 1.5 + 20) → drop. Three consecutive seconds over budget → disconnect with `RATE_LIMIT`. Same for a byte budget.
3. **Framing.** `count > 4` or `payload_size != 12 + count*7` → drop, count malformed. 20 malformed → disconnect.
4. **Sequence range.** `seq > server_tick + 20` → drop. (Not 120: `input_lead` is clamped to 12, so anything above ~20 is broken or hostile.) This is why the ring is fixed-size and indexed `seq % 32` — **a client can never make the server allocate.**
5. For each action, newest first at descending seq: `seq <= last_applied_seq` → discard (already consumed); else write `ring[seq % 32]`.
6. **Decode with per-axis clamp only:**
```gdscript
action.thrust = Vector3(b[0]/127.0, b[1]/127.0, b[2]/127.0).clampf(-1.0, 1.0)
```
> **Never normalise the thrust vector.** A player holding W+A+E legitimately produces `thrust = (1,1,1)`, length 1.73, and each axis uses a different power constant — `thrust_power 150`, `maneuvering_thrust 75`, `vertical_thrust 120` (`ship.gd:12-14`). Normalising would silently change the flight model for honest players. Per-axis clamp combined with the `i8` encoding is complete validation: the reachable value space is exactly what a legitimate client can produce.
### 3.2 Consumption — once per server physics tick, before the step
```
expected = last_applied_seq + 1
if ring holds expected:
action = ring[expected % 32]; starved_ticks = 0
else:
action = last_action # REPEAT — do not zero
starved_ticks += 1
if starved_ticks > 30: # 500 ms
action = ZERO_ACTION; flags.stalled = true
last_applied_seq = expected
last_action = action
remote_controller.action = action
```
**Repeat-last, not zero.** Player inputs are heavily autocorrelated at 60 Hz — the odds that a held thrust was released on exactly the dropped tick are low, and the client predicted with the real input either way, so repeating minimises expected divergence. It is also consistent with `AIShipController`, which already holds its action between decisions. Zeroing after 500 ms stops a disconnecting player's ship flying into a wall at full throttle forever.
### 3.3 Jitter buffer — one control loop, not three
An earlier draft had the server adapting `target_depth`, the server fast-forward-dropping queued actions, **and** the client slewing `input_lead`. Three integrators acting on one plant (buffer occupancy) with different time constants is a textbook oscillation; on a jittery link it hunts, and it presents to the player as intermittent sticky controls that are nearly impossible to attribute.
**The server reports `input_buffer_depth` in every snapshot and does nothing else adaptive. The client owns `input_lead` exclusively.**
- `target_depth = 1` (16.7 ms), not 2. With redundancy-4 you have already bought the insurance depth 2 provides; depth 2 is 16.7 ms of pure input latency for nothing.
- Client `input_lead` clamp `[1, 12]`, **fast attack / slow release**: on any starve, increase by up to 3 **immediately**; decrease by 1 per 60 ticks only after 2 s of clean surplus. A symmetric ±1-per-500 ms slew takes two seconds to absorb a wifi spike, during which the player steers and the ship does not turn — the most rage-inducing failure mode in any netcode.
- Changing `input_lead` means skipping or duplicating one tick's sequence number. Never change it more than once per 30 ticks.
**Enforce `input_lead` server-side from observed arrival times.** A client that fakes starvation to drive `input_lead` to 1 gets its inputs applied with less server-side buffering than honest players — a small but real responsiveness edge. The `i8` encoding does nothing about this; only observing actual arrival timing does.
---
## 4. Prediction and reconciliation
### 4.1 Two clocks for remote entities — the load-bearing correction
The obvious design runs remote ships and the ball as frozen kinematic proxies at `server_time_est - INTERP_DELAY` while predicting the local ship to *now*. **That is wrong**, and it is wrong in a way that only shows up over real latency:
- Two ships closing at 50 m/s put the opponent's collider **3.5 m** from truth. The hull is a `BoxShape3D` of `(1.6, 0.6, 4)` (`ship.tscn:12`) — that is most of a ship length of positional lie.
- A fast ball is **2.2 m** off against a 0.5 m radius — four ball diameters.
- `ship.tscn:16` has `collision_mask = 7`: ships collide with ships, the ball, and the arena. Ship-vs-ship contact is *constant* in vehicle soccer, not incidental.
So prediction would not diverge occasionally due to timing noise. It would diverge **deterministically and in the same direction on essentially every contact**, and the hard-snap threshold would become the steady state rather than a backstop.
**Fix: separate the collider clock from the render clock.**
| | runs at | why |
|---|---|---|
| remote body **collider** | `server_time_est`, extrapolated forward from the newest snapshot by ~one-way + half a snapshot interval | Extrapolation error over ~45 ms at real accelerations (`thrust_power 150 / mass 5` = 30 m/s², 75 m/s² on turbo — `ship.gd:12,15`, `ship.tscn:17`) is ~0.030.08 m. Two orders of magnitude better than 3.5 m. |
| remote **`$Visual`** | `server_time_est - INTERP_DELAY` | Smooth, jitter-free rendering. |
This is the same trick applied to the local ship, pointed the other way. It costs one extra transform write per remote body per tick.
### 4.2 Where each piece lives
| Concern | Location |
|---|---|
| sample + send input | `LocalNetShipController._physics_process` — runs before the physics step, guarantees exactly one sample/tick |
| record predicted state | same, at top of tick N (state = result of N1) |
| apply velocity / teleport correction | `Ship._integrate_forces`, ~15 guarded lines — the only Jolt-safe place to write `state.transform` / `state.linear_velocity` |
| visual smoothing | `Ship/$Visual.global_transform`, set in `_physics_process` |
| snap-vs-blend decision | `net_ship_predictor.gd` (child node) |
| remote bodies | `net_interpolator.gd` |
### 4.3 Per-tick, own ship
1. `predicted[current_tick - 1] = {transform, linear_velocity, angular_velocity}` — ring of 128.
2. `var a := _player.get_action().copy()`**must copy.** `player_ship_controller.gd` reuses a single `ShipAction` across ticks (its own header warns about this); buffering it aliases every history entry to the same object.
3. `_action = a`, returned by `get_action()` this tick so `Ship._integrate_forces` samples input exactly once.
4. `input_history[seq] = a`, `seq = predicted_server_tick + input_lead`.
5. Build and send the packet with the last 4 entries.
`Ship._integrate_forces` then runs completely unchanged.
### 4.4 On snapshot arrival
```
A = last_input_seq
if reset_gen changed OR predicted[A] missing OR flags.frozen != local frozen:
HARD SNAP
else if pos_err > 2.0 m OR rot_err > 60°:
HARD SNAP
else:
SOFT CORRECT
```
Comparing server state at tick `A` against **`predicted[A]`** — the client's own state at that same tick — makes the delta latency-free by construction. That is the entire reason for keeping the prediction ring, and it is why this works acceptably without resimulation: **never blend current state toward stale state.**
**SOFT CORRECT**
- **Velocity: applied in full, immediately.** `net_vel_correction += (srv.linvel - predicted[A].linvel)`, consumed once in `_integrate_forces`. Velocity error is invisible to the player but is the *cause* of future position error; blending it just prolongs divergence.
- **Position/rotation: physics moves in full, rendering does not.** Queue the body teleport, and simultaneously offset `$Visual` by the negation. Net visual movement at the instant of correction: zero. The body is where the server says; the rendered ship catches up.
- **Decay** each physics tick, reusing the existing convention at `ship.gd:450`:
```gdscript
var k := _tick_scaled(0.88, delta) # 63% gone in ~130 ms, 95% in ~280 ms
```
- **`MAX_VISUAL_OFFSET = 0.4 m`**, not 2.0. The hull is 4 m long; a 2 m offset means being rendered half a ship-length from your own collider for ~280 ms, so you clip walls you visibly cleared — a felt bug in a game built around wall-riding. Beyond 0.4 m, show the correction. A visible correction is honest; an invisible 2 m lie is not.
**HARD CORRECT**
- Apply the same sequence-matched authoritative pose and velocity delta to the current local body, reset body and `$Visual` interpolation, and clear the visual offset. It is physically the same correction as soft correction; only its presentation differs.
**Delta transport, not one-body replay.** For every matched snapshot, overwrite `predicted[A]` with authority, transport its pose and linear/angular-velocity delta through each retained state `A+1..current`, and apply that same delta once to the live local Jolt body. This keeps retained history coherent, so a later snapshot does not correct an already-corrected pre-delta trajectory a second time.
Do **not** analytically replay stored actions. That approximation cannot reproduce Jolt integration or contact manifolds (friction, restitution, walls, ships, and ball), therefore it becomes least trustworthy exactly where reconciliation is most noticeable. This is still neither whole-world rollback nor a change to server physics: it is client-only state transport around a server-authoritative simulation.
For reset generation changes, place exact authority, begin a new history epoch, and do not consume pre-reset actions. For missing or overflowed history, place authority once and suppress stale acknowledgements until a new matched sequence is recorded; never manufacture future history by filling it with one stale authority state.
> Same-sequence **pre-correction** residual remains diagnostic telemetry. With a server input jitter buffer, it is not by itself a presentation-quality gate: the server may have integrated an action at a different physical instant from the client. Acceptance must report it separately by free-flight/contact/reset/resync cohort, while gating post-correction/presentation error and hard-snap behaviour.
>
> That the two sides integrate the **same action** for a given sequence is a separate claim, and a checkable one. Keep the two apart: "right action, different instant" is expected here; "wrong action" is a bug, and was one (`multiplayer-next.md` §9 gotcha 47).
### 4.5 Camera and visuals
**The camera must follow `$Visual`, not the body.** `ship_camera.gd:115`, `:149`, `:150` read `target.global_transform` directly. Left as-is, every soft correct makes the *camera* jump the full error while the *mesh* smoothly lags — strictly worse than snapping, because the world lurches around a player whose ship slides inside the frame.
**And it must read `$Visual.get_global_transform_interpolated()` from `_process`, not `global_transform` from `_physics_process`** (rationale in §5.4). `Node3D.get_global_transform_interpolated()` exists precisely for a camera tracking a physics-interpolated body; `global_transform` returns the last physics tick's pose, so a `_process` camera reading it would chase a 60 Hz staircase at 240 fps.
> **Ordering hazard**, straight from the engine docs: `get_global_transform_interpolated()` "creates an interpolation pump on the `Node3D` the first time it is called, which can respond to physics interpolation resets… be sure to call it at least once before resetting the `Node3D` physics interpolation." Every hard snap calls `reset_physics_interpolation()` on `$Visual`. **Prime the pump when the camera's `target` is assigned**, not lazily on the first frame, or the first snap of the match streaks the camera.
`project.godot` has `physics_interpolation=true`, and `$Visual`'s own local transform is interpolated too — so `reset_physics_interpolation()` must be called on `$Visual` as well as the body, or every snap smears the mesh for a frame.
### 4.6 Remote bodies on the client
- `freeze = true`, `freeze_mode = FREEZE_MODE_KINEMATIC`**not `STATIC`**, or Jolt cannot derive contact velocity from the per-tick transform delta and your predicted ship hits a static wall instead of a moving ship.
- `net_interpolator.gd` samples the snapshot buffer (last 8 per body); collider at `server_time_est` (§4.1), `$Visual` at `server_time_est - INTERP_DELAY`.
- **The two samples run on different clocks *and* different callbacks.** The collider is a physics concern: `_physics_process`, 60 Hz. `$Visual` is a render concern: `_process`, sampled at true render time with `physics_interpolation_mode = OFF` so Godot does not interpolate an already-per-frame transform. On a 240 Hz client this is 240 distinct remote-ship positions per second instead of 60, and one fewer tick of lag, for no extra cost — the buffer lerp is happening either way (§5.4).
- `INTERP_DELAY = one_way_ms + snapshot_interval * 1.5 + 2.5 * jitter_ewma`, clamped `[25, 200] ms`. At 60 ms RTT / 60 Hz / 5 ms jitter that is 30 + 25 + 12.5 ≈ **68 ms**.
> **The `one_way_ms` term is not optional, and omitting it is a silent architectural failure.** `server_time_est` (§4.7) estimates what the server clock reads *right now*. The newest snapshot in hand was stamped `one_way` ago. So rendering `$Visual` at `server_time_est - INTERP_DELAY` only interpolates if `INTERP_DELAY ≥ one_way`. Set it to the buffer alone (~38 ms at 60 Hz) and the render cursor lands *on or past* the newest sample: every remote entity is permanently dead-reckoned. **The 25 ms clamp floor is reachable on LAN only.**
- Past the newest snapshot, extrapolate on last known velocity for at most 150 ms, then hold. **Never extrapolate indefinitely** — a stuck ship reads better than one flying through a wall.
- **Never write `linear_velocity` to a frozen body.** Godot/Jolt zeroes and holds velocity on frozen bodies, so `ball.gd:35`'s `linear_velocity.length()` trail driver will not work that way. `Ball.set_visual_speed(speed)` mirrors the `Ship.set_visual_action(thrust_z, turbo)` pattern. Don't route presentation data through a property the physics server owns.
- Call `reset_physics_interpolation()` on remote bodies at every kickoff.
### 4.7 Clock
`server_time_est = local_ms + clock_offset`, `clock_offset` from ping/pong on channel 0 every 1 s using the **minimum-RTT sample in a rolling 5 s window** (the min-RTT sample has the least queueing error).
**Freeze `tick_offset` at match start.** Seed it exactly from the handshake (`server_tick + round(one_way / tick_ms)`) and absorb all subsequent drift into `input_lead` alone. The prediction ring is indexed in server-tick space, so slewing `tick_offset` during play silently reinterprets every historical entry and produces sporadic, unreproducible false snaps. Re-seed only across a kickoff boundary.
---
## 5. Latency and frame-rate budget
Three of the largest terms are invisible to a netcode document that only counts network hops. Record the budget so future changes are argued against a number.
Client at 60 Hz physics, 60 ms RTT, 5 ms jitter, 60 Hz snapshots. **Display at 60 Hz with vsync on** — the Godot default, and the worst case. §5.4 redoes the display-dependent rows for 120/144/165/240/360 Hz.
### 5.1 Own ship (predicted) — input to pixel
| Stage | ms | | scales with fps? |
|---|---:|---|---|
| OS input → `Input.is_action_pressed` | 10 | 0.5 × frame interval + device polling | partly |
| wait for next physics tick | 8 | avg of 016.7 | no — 60 Hz physics |
| physics step applies force | 0 | | |
| Godot physics interpolation | 8 | mean, worst case 16.7 | no — 60 Hz physics |
| render + vsync present | 25 | 1.5 refresh intervals, vsync defaults on | yes |
| **Total** | **≈52** | | |
This is the **existing single-player floor**, unchanged by netcode. A low-latency present would take it to ~35 ms (§5.4). Note: **16 of the 52 ms do not move no matter how many frames the client draws.** That is the price of a 60 Hz simulation.
### 5.2 World response — the number that decides whether this ships
| Stage | ms | |
|---|---:|---|
| input freshness | 10 | 0.5 × frame interval + ~2 ms device polling |
| wait for next physics tick | 8 | |
| manual multiplayer flush | ~0 | |
| client → server transit | 30 | RTT/2 |
| jitter buffer, `target_depth = 1` | 17 | |
| server tick + flush | 8 | |
| **server → client transit** | **30** | RTT/2 — the return leg |
| interpolation buffer beyond arrival | 38 | `interval × 1.5 + 2.5 × jitter`; the `one_way` half of `INTERP_DELAY` is the row above |
| client physics interpolation | 8 | |
| render + present | 25 | vsync on, 60 Hz display |
| **World response, opponents** | **≈174** | |
| **Ball, with local prediction** | **≈52** | same as own ship |
| Both, at 144 Hz + low-latency present | **148 / 26** | §5.4 |
For reference, Rocket League runs 120 Hz physics and predicts both car and ball locally; its equivalent at 60 ms RTT is roughly 90110 ms.
**≈174 ms as designed here is not competitive, and this document should not pretend otherwise.** §5.6 gets to ≈127 ms with two changes that touch no graphics setting and require no bot retrain, and to ≈103 ms with 120 Hz simulation — inside the reference band. Read §5.6 before treating this table as a verdict.
What *is* settled is the shape of the design: a locally-predicted ball and own ship at ≈52 ms is the difference between this being playable and not, and a 30 Hz / default-poll / interpolated-ball design would land near ≈250.
### 5.3 Why 60 Hz snapshots, not 30
- Interpolation buffer: the `interval × 1.5` term is **50 ms at 30 Hz vs 25 at 60**, on top of the one-way term both share (§4.6), plus a half-interval of cadence quantisation.
- Interpolation fidelity: at `MAX_SPEED = 32` the ball moves **1.07 m between samples at 30 Hz** — more than its own diameter, so any wall bounce landing between two samples gets lerped as a straight line *through the wall*. At 60 Hz it is 0.53 m.
- Cost: 300 kbit/s. Per §1.4, bandwidth is not the constraint.
Keep `--snapshot-hz 30` as an explicit degraded mode.
### 5.4 High-refresh-rate clients — 120 / 144 / 165 / 240 / 360 Hz
Players on high-refresh displays are the ones most sensitive to everything in this document. Three places in the code must run per rendered frame, not per physics tick, for this to be true — these are now implemented (Phase 0); the reasoning is kept here because it explains why the split exists.
#### What frame rate actually buys
| Display | present | own ship / ball (§5.1) | world response (§5.2) | with low-latency present |
|---|---:|---:|---:|---:|
| 60 Hz | 25.0 | **52** | **174** | 35 / 157 |
| 120 Hz | 12.5 | **35** | **158** | 27 / 149 |
| 144 Hz | 10.4 | **33** | **155** | 26 / 148 |
| 165 Hz | 9.1 | **31** | **153** | 25 / 147 |
| 240 Hz | 6.3 | **27** | **149** | 23 / 145 |
| 360 Hz | 4.2 | **24** | **146** | 21 / 144 |
> **This table's reachability depends on the render budget — see §5.5 for what was actually measured on reference hardware.**
Three conclusions to design around:
1. **60 → 144 Hz is worth ~19 ms on own-ship feel. 144 → 360 Hz is worth ~9.** The curve flattens hard, because 16 ms of the remaining budget is the 60 Hz physics tick plus its interpolation and does not move.
2. **A low-latency present is worth more at 60 Hz (17 ms) than the entire jump from 144 to 360 Hz.** It costs one settings dropdown.
3. **Frame rate barely moves world response** — 174 → 146 across the whole 60360 range, because that budget is dominated by RTT and the interpolation buffer. Frame rate is an *own-ship feel* lever, not a netcode one. Say this to players plainly; someone who buys a 360 Hz monitor to see opponents sooner has been mis-sold.
#### Frame-time variance, not mean frame rate, is the real target
At 240 fps the frame budget is **4.17 ms**, and physics runs at 60 Hz — so **one frame in four carries the entire physics tick** and must still fit in 4.17 ms. On that frame the client pays, in one go: the Jolt step over 7 dynamic bodies against a 172-shape compound; 7 × `Ship._integrate_forces`; 6 × `_update_movement_vfx`; and on decision ticks, bot inference — `policy_network.gd` is a pure-GDScript MLP at **31→64→64→7 ≈ 6.5k multiply-accumulates per bot**, so five bots landing together is ~33k GDScript float ops in one frame.
**The physics tick sets a floor on 1%-low frame time that no graphics setting can lower.** A game that averages 240 fps but drops one frame in four to 8 ms is not a 240 fps game. Profile p99, not mean.
#### What frame rate does *not* buy, so nobody optimises the wrong thing
**Input sampling does not improve.** `player_ship_controller.gd:15-38` reads seven `Input.is_action_pressed` calls — all digital, all held-state — and `Ship._integrate_forces` pulls them once per physics tick. The state read at the tick *is* the freshest state; sampling it 240 times a second returns the same value 4 times in a row. **Do not build a sub-tick input accumulator.** If analog stick support is added later this changes, and the right answer is then a time-weighted average over the tick, not a higher sample rate.
**Physics interpolation stays on.** It costs ~8 ms (§5.1) and is the single largest fps-independent term after the tick wait, so it will look like a target. It is not: without it a 60 Hz simulation presents 60 distinct world states per second regardless of frame rate, which is precisely the stepping a 240 Hz display was bought to avoid. Leave it on; do not expose a toggle.
#### Why physics stays at 60 Hz, and what a bump would cost
The honest answer to "our players want 240 fps responsiveness" is that **simulation rate, not frame rate, is the binding constraint** — 16 ms of own-ship latency and ~33 ms of world response sit behind it, and §5.2 shows frame rate alone cannot get world response under ~146 ms. Doubling to 120 Hz (Rocket League's rate, with snapshots raised alongside) would take world response from ≈174 to **≈141 ms** and own-ship from 52 to **≈44**, at 60 Hz display — or **≈115 ms** combined with a 144 Hz display and a low-latency present.
That is a bigger win than every tuning parameter in §3 and §4 combined. It is nonetheless **out of scope for v1**, for reasons that are about the project rather than the netcode:
- **Every policy in `Game/bots/` is invalidated.** `ship.gd:450`'s `_tick_scaled` is defined against a 60 Hz reference and `ai_ship_controller.gd`'s `reaction_ticks` counts ticks. A bump means a full retrain — and per `TODO.md` the generation-5 curriculum is still running.
- **Server density halves**, ~610 matches per core to ~35 (§1.4).
- **Bandwidth roughly doubles**: input 43 → 86 kbit/s up, snapshots 105 → 210 kbit/s per client, 631 kbit/s → 1.26 Mbit/s per 6-player match. Still not the constraint, but 100 concurrent matches becomes ~126 Mbit/s of server uplink, which is a hosting-plan question rather than a rounding error.
**The consequence for this plan is a hard rule: 60 is a constant named `NetCodec.TICK_HZ`/`SimConstants.TICK_HZ`, never a literal.** Ring sizes, `INTERP_DELAY`, `input_lead` clamps, seq-window bounds, snapshot cadence and the timeout constants all derive from it — this is already true in code. Done this way, a later bump is a config change plus a retrain — not a protocol rewrite.
#### Client display settings
`VideoSettings` implements a `Preset` system (Low/Medium/High/Custom), `vsync_mode` (Adaptive default), refresh-derived `fps_cap_divisor`, and `resolution_scale` — see §5.5 for what these bought when actually measured. The design reasoning that shaped them:
- **Adaptive vsync** (`FIFO_RELAXED`) is FIFO while the renderer keeps up and tears only on a *missed* vblank — the right default for a game that will sometimes drop below refresh, avoiding FIFO's half-rate cliff.
- **FPS cap options are derived from the display**, not a fixed list — non-divisor caps beat against scanout (cap at 100 on a 144 Hz display and `gcd(100,144) = 4`: visible micro-stutter).
- `Engine.max_fps` is a throttle, not a pacer — it has no knowledge of scanout and never phase-locks to a vblank.
- Godot cannot report the *negotiated* present mode — `DisplayServer.window_get_vsync_mode()` echoes back the mode you stored, not the driver's actual grant. A live fps readout is the honest alternative.
### 5.5 Can this build produce frames at all? — measured
§5.4's table describes a device-class question, not a code question, and the
render configuration was a showcase build, not a competitive one by default.
**Measured on real reference hardware (RTX 3090, Linux, via
`Game/tools/gpu_profile_harness.gd`)**, 6-ship 3v3 Match, 1080p:
| | p50 | p99 | fps (p50) |
|---|---:|---:|---:|
| All effects on (project defaults) | 1.85 ms | 2.98 ms | 540 |
| All effects off | 0.53 ms | 1.53 ms | 1883 |
At 540 fps p50 with every effect enabled, **this scene is nowhere near
GPU-bound on reference-class desktop hardware** — the "must hit 144 fps"
framing this section originally worried about does not hold at that
hardware tier. SDFGI and SSIL account for over half of the effects' total
cost (0.36 ms and 0.25 ms respectively), matching the original expectation
that voxel cone tracing and a full-res screen-space GI pass would be the
expensive ones.
An earlier pass on Apple Silicon (M4, Metal) measured a much lower,
undifferentiated ~55 fps ceiling with all effects clustering at 2.93.8 ms
each — this was a poor stand-in for the target platform: Apple's
tile-based-deferred GPU architecture forces a full system-memory resolve on
any pass reading neighbouring pixels (SSAO, SSIL, glow, `screen_texture`),
which is a largely constant per-pass tax rather than proportional to each
effect's real cost. Treat that number as informative about relative
ordering only, not as a stand-in for desktop-GPU behaviour.
**Still open: no low/mid-tier GPU has been profiled.** The 3090 result rules
out "the game is GPU-bound on reasonable hardware" as a near-term concern,
but says nothing about a GTX 1660 or an integrated Iris/Vega part, which is
where a real preset ladder actually earns its keep. Re-run
`gpu_profile_harness.tscn` on weaker hardware before spending more effort on
frame-time optimisation. Baking the arena GI to retire SDFGI is real
but smaller than originally assumed on a 3090-class GPU — it stays worth
doing for low-end/integrated GPUs, unmeasured; a separate-physics-thread
prototype was closed without implementation, since no
frame-time variance problem exists to fix on reference hardware.
### 5.6 Closing the gap to the reference — without lowering settings
§5.2 lands at ≈174 ms against a ~90110 ms reference band. The instinct is that reaching it means trading visual quality for frames. **It does not.** Decompose the 174:
At 60 ms RTT, 60 ms is transit and irreducible in code. That leaves **114 ms of local overhead**, of which frame rate governs only two terms — input freshness (10) and present (25) — and *quality settings* govern neither directly. Present latency is a function of vsync mode and swapchain depth, not of how many effects are enabled; a 60 fps client with a shallow present queue beats a 240 fps client with a deep one. **The entire 60 → 240 fps range is worth ~12 ms once a low-latency present is in place** (§5.4). The other ~100 ms is netcode time model and simulation rate.
Four levers, none of which touches a graphics setting:
| | Lever | Saves | Risk |
|---|---|---:|---|
| **L1** | **Extrapolate remote *visuals* to present time** instead of interpolating the past | **30** | Mis-prediction pops |
| **L2** | 120 Hz simulation | 21 | Bot retrain, ½ server density, 2× bandwidth |
| **L3** | Adaptive jitter-buffer depth, 0 on clean links | 8 | Starvation on jittery links |
| **L4** | Shallow present queue + Adaptive vsync | 17 | Throughput loss if GPU-bound |
**L1 is the big one, and it is nearly free.** §4.1 already computes remote entities' **present-time** state — that was the fatal correction that put the collider at `server_time_est`. `$Visual` is then deliberately rendered ~68 ms in the past for smoothness. **Render it at present time too and the whole 37.5 ms interpolation buffer disappears**, leaving only a residual for error smoothing.
The reason this is safe here is that ships have bounded acceleration and the hull is large. Extrapolating with known velocity, error is `½·a·t²` over the full 68 ms horizon:
| | max accel | error @ 38 ms | error @ 68 ms |
|---|---:|---:|---:|
| position, cruise | 30 m/s² | 0.022 m | **0.069 m** |
| position, turbo | 75 m/s² | 0.054 m | **0.173 m** |
| yaw | 20 rad/s² | 0.8° | **2.6°** |
| pitch / roll | 2.9 rad/s² | 0.1° | **0.4°** |
**0.17 m and 2.6° worst case, against a 4 m hull.** Feed the residual through the same soft-correct pipeline already specified for the local ship (§4.4) and remote ships are visually at present time with a sub-decimetre wobble.
Two bonuses: it **collapses §4.1's dual clock back into one** — collider and visual both at `server_time_est`, so §5.4's `_process`/`_physics_process` split and the two-regimes-for-one-node-name hazard both go away — and it applies to the ball, which is near-ballistic between contacts and therefore extrapolates better than ships do.
The cost is real but narrow: a remote ship that *reverses input* at the moment you sample it mispredicts by the numbers above and then visibly corrects. Interpolation never mispredicts; it is just always late. This is the genuine trade, and it is the one the reference class makes. **L1L4 are not yet implemented; this remains netcode work, not measurement — see `multiplayer-next.md` §11 for status.**
| Term | today | L1 + L4 (v1) | + L2 + L3 | at 144 fps |
|---|---:|---:|---:|---:|
| input freshness | 10 | 10 | 10 | 5.5 |
| wait for next tick | 8.3 | 8.3 | 4.2 | 4.2 |
| client → server | 30 | 30 | 30 | 30 |
| jitter buffer | 16.7 | 16.7 | 4.2 | 4.2 |
| server tick + flush | 8 | 8 | 4 | 4 |
| server → client | 30 | 30 | 30 | 30 |
| interp buffer → extrapolation residual | 37.5 | 8 | 8 | 8 |
| client physics interpolation | 8.3 | 8.3 | 4.2 | 4.2 |
| present | 25 | 8.3 | 8.3 | 3.5 |
| **World response** | **≈174** | **≈127** | **≈103** | **≈94** |
**≈103 ms at 60 fps with every effect enabled**, and ≈94 at 144 fps — inside the reference band, without disabling SDFGI, SSIL, SSAO or shadows. Sequencing follows ms-per-unit-of-risk: **L4 then L1 first (≈127 ms, no bot retrain, no protocol change)**; L2 and L3 after, when a retrain is affordable.
> **The largest lever is not on this list.** All of the above assumes 60 ms RTT. Regional server siting that puts most players on a 30 ms RTT takes ≈127 to ≈97 and ≈103 to ≈73 with no code at all. Server siting (Phase 6/Phase 8 in `multiplayer-next.md`) owns it, and it should be argued against these numbers.
### 5.7 The next tier — and where it stops paying
#### Frame rate: SDFGI is the wrong tool for this arena
`arena.gd`/`goal.gd` have **no `_process`, no `_physics_process`, no `AnimationPlayer` and no `Tween`** — the floor, walls, ceiling, goals and every light are static for the entire match. SDFGI exists to light *dynamic* worlds, and it pays for that by re-voxelising cascades as the camera moves — and this camera never stops moving. It is the most expensive optional effect in the frame (§5.5), doing continuous work to solve a problem this project does not have. **Replace `sdfgi_enabled` with baked GI** (`LightmapGI` or `VoxelGI`) — still open, see §5.5's "still open" note; the relative win is real but the absolute win on reference-class hardware is small.
#### Latency: what is actually left, after L1L4 and 120 Hz simulation
At 144 fps the budget would be ≈94 ms — **and 60 of that is RTT.** The remaining ~34 ms of local overhead sits at or near a floor set by physics rate or hardware. Two small code ideas remain, both trading visual stability for a few ms: forward-extrapolating the local `$Visual` instead of interpolating the last two ticks (~4 ms, risk of overshoot on collision), and tightening the extrapolation-error smoothing (~4 ms, more visible correction pops). **That is the whole remaining code budget.** Regional server siting is worth 4× that for free (§5.6).
Two limits worth keeping in mind before spending a month on the last 5 ms:
1. **Past ~100 ms, you are optimising 34 ms at a time against a 60 ms constant.** Server siting and matchmaking dominate everything else from that point on.
2. **"Lowest lag" and "best feel" diverge at the end.** Every remaining lever buys milliseconds by predicting further ahead and correcting harder. Past a point that makes the game feel *worse*. **The human playtest is the authority; the budget table is not.**
---
## 6. Match lifecycle
### 6.1 State machine
```
LOBBY -> LOADING -> WARMUP -> PLAYING -> GOAL_PAUSE -> WARMUP -> ...
-> FULL_TIME -> OVERTIME_WARMUP -> OVERTIME -> ...
-> RESULTS -> LOBBY
```
Broadcast as the `match_state` byte in every snapshot, and on transition via `state_change(state, at_tick)`.
### 6.2 Sequence
1. **Connect.** Client sends `hello(protocol_version, physics_ticks_per_second, display_name, auth_ticket)`. Server rejects a mismatch on **either** version or tick rate, with a reason string, then `disconnect_peer`. `auth_ticket` is an empty `PackedByteArray` until Phase 7 lands — the field is reserved.
2. **Welcome.** Server assigns `player_id`, balances teams, replies `welcome(player_id, server_info, roster, match_state, server_tick, score, end_tick)`, broadcasts `player_joined`.
3. **Lobby.** `ready_toggle()`; start when all ready, or `--auto-start` after `--min-players` plus a countdown.
4. **Config.** `match_config(match_id, arena_path, team_size, match_length_ticks, roster[], seed)`. `roster[i] = {slot, team, spawn_index, player_id, name, is_bot}`**slot order here is the snapshot's body order for the whole match.** The client validates `arena_path` against `ArenaRegistry.ARENAS` before `load()`; a malicious or buggy server must not be able to make a client load an arbitrary `res://` path.
5. **Load.** Both sides load `networked_match.tscn`. Each peer loads the arena and spawns the roster in slot order. Client additionally spawns a camera rig on its own ship and adds `HUD.tscn` **in code**. Client sends `scene_ready(match_id)`.
6. **Kickoff.** Server waits for all `scene_ready` (10 s timeout → proceed). Broadcasts `kickoff(reset_transforms[], countdown_start_tick, reset_gen)`. Both sides freeze bodies. HUD counts down from `server_tick`, not a local `Timer`. At `countdown_start_tick + 180` the server unfreezes and broadcasts `state_change(PLAYING)`.
7. **Play.** Inputs up, snapshots down.
8. **Goal.** Server's `Goal` sensor fires → `_handle_goal_scored` debounce → `goal_scored(scoring_team, score, goal_tick, resume_tick)`. Bodies freeze. Clients play the cinematic within `[goal_tick, resume_tick]`. At `resume_tick`: `kickoff(...)`.
9. **Clock.** Tick-derived: `remaining_ticks = end_tick - current_server_tick`. `end_tick` and a `running` flag ship in `match_config` and in `clock_state(running, end_tick, at_tick)`.
10. **Full time / overtime / results.** `RESULTS` holds, then `state_change(LOBBY)` and both sides load `lobby.tscn`. **Clients return to the lobby, not the main menu** — a community server that empties every 2.5 minutes is dead on arrival.
**Every lifecycle message carries absolute ticks**, never durations. **Specify the late-arrival case explicitly**: a `kickoff` that lands after its own `resume_tick` must apply the reset immediately and skip the countdown, not schedule it into the past.
### 6.3 Late joiners and spectators
`welcome` carries full state, so a late joiner reconstructs immediately.
- Free slot and state is `LOBBY`/`WARMUP` → join as a player now.
- Free slot mid-match → **spectate now, take the slot at the next kickoff.** Swapping a controller at a kickoff boundary is free; mid-play it is not.
- No free slot → spectator. A spectator receives identical snapshots (the snapshot is already a broadcast — zero extra server work), spawns no ship, and points a camera rig at a chosen ship or the ball. Cap with `--max-spectators`.
### 6.4 Disconnects — no ship is ever despawned
On `peer_disconnected` the server **keeps the ship and swaps its controller**:
1. `--fill-bots`: replace with an `AIShipController` on the server's configured model.
2. `--no-fill-bots` (default for public servers, see §1.4): swap to the base `ShipController` — inert but simulated.
Set `flags.stalled` so clients can grey out the nameplate. Reserve the slot for 30 s keyed by identity so a reconnect gets its ship back. If the last human leaves, abort to `LOBBY`.
**Justification is wire-format simplicity, not the bot cache.** Fixed slot order means the snapshot needs no add/remove machinery, no `MultiplayerSpawner`, and no re-indexing. That reason stands on its own.
+27 -1
View File
@@ -1,4 +1,18 @@
.PHONY: verify-phase6 verify-enet-integration verify-steam-templates
.PHONY: verify-phase6 verify-enet-integration verify-steam-templates verify-supply-chain verify-kind-agones verify-allocated-compose verify-chaos-recovery verify-multiplayer-local verify-multiplayer-load verify-release-gate verify-training
verify-multiplayer-local:
bash scripts/verify_multiplayer_local.sh
verify-multiplayer-load:
(cd server && go test -tags load ./api ./matcher -run 'Test(QueueCreateHTTPLoad|ProposalFormationLoad)' -count=1)
verify-release-gate:
@test -n "$(RELEASE_REPORT)" || (echo "RELEASE_REPORT=/path/to/report.json is required" >&2; exit 2)
python3 scripts/verify_release_gate.py "$(RELEASE_REPORT)"
verify-training:
@test -x training/.venv/bin/python || (echo "training/.venv/bin/python is required; see TRAINING.md" >&2; exit 2)
(cd training && .venv/bin/python -m unittest test_action_space.py test_evaluate.py test_generation5.py)
verify-phase6:
bash scripts/verify_phase6.sh
@@ -8,3 +22,15 @@ verify-enet-integration:
verify-steam-templates:
bash scripts/verify_steam_templates.sh
verify-supply-chain:
python3 scripts/verify_supply_chain.py
verify-kind-agones:
bash scripts/verify_kind_agones.sh
verify-allocated-compose:
bash scripts/verify_allocated_compose.sh
verify-chaos-recovery:
bash scripts/verify_chaos_recovery.sh
+6 -2
View File
@@ -18,7 +18,7 @@ The concept of 'vehicle soccer' cannot be copyrighted, but the original expressi
Cosmic Clash is a single Godot 4.7 project, written entirely in GDScript. That same project exports both the interactive game and a headless dedicated server for online multiplayer. See [`docs/TECH_STACK.md`](docs/TECH_STACK.md) for the full stack and the reasoning behind each choice.
Online play with casual and ranked queues is a 1.0 requirement, and it needs a small backend service for identity, matchmaking and ratings — separate from the Godot project, and not yet built. See [`docs/MATCHMAKING.md`](docs/MATCHMAKING.md).
Online play with casual and ranked queues is a 1.0 requirement, and it needs a backend service for identity, matchmaking and ratings — separate from the Godot project. That control plane is written in Go (with PostgreSQL, Redis and Agones on Kubernetes), chosen for the Agones/Kubernetes-native ecosystem rather than for raw speed: it never touches a simulation packet. See [`docs/MATCHMAKING.md`](docs/MATCHMAKING.md) for the design and [`docs/TECH_STACK.md`](docs/TECH_STACK.md) for why Go over C#, Rust or C++.
## Contributing
@@ -34,7 +34,11 @@ There will be default bots available, trained using reinforcement learning, and
## MVP
The first version of this game will be JUST the game, no server-side functionality at all. It will be a local only game where you can play against bots. Split-screen multiplayer could be added in a version 0.2 if demand is high enough. If there is sufficient interest then the server-side functionality can be added to enable online play, with a system in place to ensure that servers can be paid for (perhaps a cheap monthly subscription model?).
The original local-only milestone is complete; the 1.0 scope now includes
dedicated online play plus casual and ranked matchmaking. Community servers
remain self-hostable, while project-hosted match servers are allocated per
match through the provider-portable control plane described in
[`docs/MATCHMAKING.md`](docs/MATCHMAKING.md). Split-screen remains deferred.
## Monetisation
+5
View File
@@ -86,6 +86,11 @@ roughly 610 simultaneous match processes per modern core, 150250 MB RSS pe
process, and about 630 kbit/s upstream for a full six-player match; use those
as a starting point and monitor actual CPU, RSS, and egress.
Per-match autoscaling — allocating a server for one match and shutting it
down afterwards — is designed in [`docs/MATCHMAKING.md`](docs/MATCHMAKING.md)
and not yet implemented. The sizing numbers above predate that work and
should be re-measured under real concurrency before they size a bill.
This build must not be exposed to strangers yet. Slot reclaim is still keyed
by display name, so a player who knows a disconnected player's name can claim
their reserved slot. Phase 7 Steam-auth identity is the required fix. Local,
+97 -10
View File
@@ -1,29 +1,116 @@
# TODO
**Items here that need a person** — hardware, an external account, a playtest, a
design decision — are also tracked as GitHub issues under the
[`needs:human`](https://github.com/jcreek/CosmicClash/issues?q=is%3Aissue+is%3Aopen+label%3A%22needs%3Ahuman%22)
label, prioritised `P0-blocker``P3-low`. The issue is the status; this
document and `multiplayer-next.md` remain the detail. Agent-actionable code
tasks are deliberately *not* filed as issues — they live in
`multiplayer-next.md` §7.
Deferred work, in rough priority order. The current architecture (ShipAction/ShipController seam, Arena/GameMode split, code-driven spawning, group-tagged ball/goals) was chosen specifically so these bolt on without rework.
## AI opponent (reinforcement learning)
The training pipeline is built — see `TRAINING.md` (self-play PPO via the vendored godot_rl_agents bridge, JSON policy export, in-game GDScript inference, eval ladder). Remaining:
- [ ] Run the generation-5 handling/intercepts/league/teamplay curriculum described in `TRAINING.md`; promote later checkpoints as `medium`/`hard` only after they clear the match and behaviour gates.
- [ ] Extend generation 5's moving aerial-intercept states with wall plays and rebound scenarios after Stage 5 establishes a productive-air-touch baseline.
- [ ] Design team-credit rewards and paired 2v2 evaluation before enabling the deferred teamplay stage.
- [ ] ([#24](https://github.com/jcreek/CosmicClash/issues/24)) Run the generation-5 handling/intercepts/league/teamplay curriculum described in `TRAINING.md`; promote later checkpoints as `medium`/`hard` only after they clear the match and behaviour gates. The orchestrator now requires three independent paired evaluation seeds for each promotion decision; the current Stage 6 league run remains blocked on its recorded regression/telemetry results.
- [ ] ([#25](https://github.com/jcreek/CosmicClash/issues/25)) Extend generation 5's moving aerial-intercept states with wall plays and rebound scenarios after Stage 5 establishes a productive-air-touch baseline. The opt-in wall-play/rebound state generator is now implemented and enabled for the next Stage 6 league command; training evidence is still required.
- [x] Design team-credit rewards and paired 2v2 evaluation before enabling the deferred teamplay stage. `team_touch_credit_weight` is zero by default and `evaluate.py --team-size=2` provides the opt-in paired evaluator; Stage 7 remains disabled pending recorded 2v2 behaviour gates.
## Presentation / AAA polish
The largest gap between this and a AAA-feeling product is presentation, not code. Sequenced after the above for pragmatic reasons, but this is the highest impact per hour.
- [ ] **Audio — there is none.** Zero sound files, zero `AudioStreamPlayer` nodes, no bus layout. Needs: engine hum pitched to throttle, turbo whoosh, ball impacts scaled by collision impulse, wall scrapes, goal explosion, crowd bed, UI clicks, countdown beeps, music. Can be driven off `Ship`'s existing telemetry signals.
- [ ] Custom font + a real `Theme` resource for the HUD. `ThemeDB.fallback_font` at 10-13 px reads as a debug overlay.
- [ ] **Video settings menu is missing graphics presets, vsync, and resolution scaling.** `video_settings.gd` currently exposes only AA, glow, and brightness, while SDFGI, SSIL, SSAO, and five shadow-casting lights are on by default and unreachable by the player. Blocked on the same profiling gate as the multiplayer section's 0.160.28 tasks below (0.17/0.17b/0.17c/0.17d) — needs a human at the editor with real hardware, not further code changes on its own.
- [ ] ([#26](https://github.com/jcreek/CosmicClash/issues/26)) **Audio — authored sound design remains open.** A dependency-free procedural `AudioManager` now provides safe UI/countdown/impact/goal hooks, an engine tone pitched/levelled from local thrust and turbo state plus a rising-edge turbo cue, and is wired into kickoff, goal, ball-contact, and menu events; replace the placeholder tones with authored engine/turbo/impact/wall/goal/crowd/music assets after selecting distributable files and mixing them on real hardware.
- [ ] ([#27](https://github.com/jcreek/CosmicClash/issues/27)) **Custom font remains open.** A shared real `Theme` resource now styles the HUD/menu surfaces; select and bundle a distributable font so the UI no longer relies on `ThemeDB.fallback_font` at 10-13 px.
- [ ] ([#28](https://github.com/jcreek/CosmicClash/issues/28)) **Video settings are implemented; profiling/visual QA remains.** `video_settings.gd` and the settings menu expose graphics presets, AA, vsync, FPS caps, resolution scaling, glow, and brightness, with preset-gated SDFGI/SSIL/SSAO/shadows. The remaining gate is measuring the preset ladder and image quality on low/mid-tier reference hardware in the live editor; no further control wiring is implied by this TODO.
## Multiplayer (long term)
The concise current checklist is **[`multiplayer-next.md`](multiplayer-next.md)**. Historical architecture decisions, implementation evidence, and completed-task detail stay in **[`multiplayer-todo.md`](multiplayer-todo.md)**. Server-authoritative multiplayer, prediction, ENet dedicated hosting, and the Phase 6 exported-server Docker/CI verification are implemented; the remaining gates are captured in the current checklist.
The single tracking document is **[`multiplayer-next.md`](multiplayer-next.md)** architecture decisions, implementation evidence, and the current checklist all in one place. Server-authoritative multiplayer, prediction, ENet dedicated hosting, and the Phase 6 exported-server Docker/CI verification are implemented; the remaining gates are captured there.
Phase 7 begins with optional GodotSteam bootstrap and a transport boundary; direct-IP ENet remains fully supported. It also carries the **graphics/performance work** — the project has never been profiled, and `video_settings.gd` exposes only AA, glow and brightness while SDFGI, SSIL, SSAO and five shadow-casting lights are on by default and unreachable (see §5.5 there).
Phase 7 begins with optional GodotSteam bootstrap and a transport boundary; direct-IP ENet remains fully supported. Graphics controls are now implemented separately through the preset/vsync/FPS-cap/resolution-scale work described above; the remaining graphics gate is real low/mid-tier hardware profiling and visual QA (see §5.5 in the multiplayer tracker).
**Tasks 0.10.15, 0.180.25, 0.27, 0.29 are done** (see the Phase 0 table in `multiplayer-todo.md` for what each one actually changed — several deviated from the original plan for concrete GDScript/Godot reasons recorded inline). Remaining, all blocked on **0.15b (profile, on reference hardware, in the live editor — not done)**: 0.16 (camera to `_process`), 0.17/0.17b/0.17c/0.17d (graphics presets, vsync, resolution scaling), **0.26 (bake the arena GI to retire SDFGI — the largest frame-time win available, costs no image quality since the arena is fully static)**, and 0.28 (physics separate-thread prototype, flagged as the riskiest task in the phase). These need a human at the editor with real hardware to profile and eyeball, not further code changes.
**Tasks 0.10.15, 0.180.25, 0.27, 0.29 are done** (see the Phase 0 table in `multiplayer-next.md` for what each one actually changed — several deviated from the original plan for concrete GDScript/Godot reasons recorded inline). Remaining, all blocked on **0.15b (profile, on reference hardware, in the live editor — not done)**: 0.16 (camera to `_process`), 0.17/0.17b/0.17c/0.17d (graphics presets, vsync, resolution scaling), **0.26 (bake the arena GI to retire SDFGI — the largest frame-time win available, costs no image quality since the arena is fully static)**, and 0.28 (physics separate-thread prototype, flagged as the riskiest task in the phase). 0.15b is [#21](https://github.com/jcreek/CosmicClash/issues/21). These need a human at the editor with real hardware to profile and eyeball, not further code changes.
- [ ] Possible v0.2 split-screen: spawn one `ship_camera_rig` + viewport per local player (camera is already outside the ship scene to allow this). Unrelated to online play.
- [ ] ([#29](https://github.com/jcreek/CosmicClash/issues/29)) Possible v0.2 split-screen: spawn one `ship_camera_rig` + viewport per local player (camera is already outside the ship scene to allow this). Unrelated to online play.
### What's left to actually finish multiplayer (human-actionable)
Everything below needs a person — hardware, a design decision, an external account, or hands on a controller — not more code from an agent working alone. Full detail for each is linked; this list exists so nothing falls through the cracks. Ordered roughly as it blocks.
Each item is also a GitHub issue (linked inline), labelled `needs:human` plus a
`P0-blocker``P3-low` priority. Close the issue and tick the box together.
**Priority labels say how much something matters; this says what to do first.**
They differ: #33 is P2 but belongs before the P0 cluster, because standing the
cluster up first means migrating a running one afterwards.
#### Do these in order — each unblocks the next
1. **[#31](https://github.com/jcreek/CosmicClash/issues/31) — answer two
questions.** Which registry namespace (`ghcr.io/cosmic-clash/*` is in every
manifest and no such org exists), and whether packages are public (this repo
is private and no manifest declares `imagePullSecrets`). Publishing needs no
new credential. **This is the highest-leverage thing on the list**: two
answers unblock the whole of Phase 8, and the work behind them is an agent's.
2. **[#33](https://github.com/jcreek/CosmicClash/issues/33) — split the
game-server namespace.** Agent work, no decision owed. Before #17 rather than
after, so the cluster is stood up on the final topology instead of being
migrated later.
3. **[#17](https://github.com/jcreek/CosmicClash/issues/17) — stand up the
cluster.** Needs #31's images to exist. Unblocks the production halves of
most of Phase 8.
4. **[#32](https://github.com/jcreek/CosmicClash/issues/32) — casual backfill.**
Mostly agent work; the design decision is already made. Needs #17 to verify
a late roster reaching a running server.
5. **[#22](https://github.com/jcreek/CosmicClash/issues/22) — release gates.**
Last: needs the cluster and the App ID.
#### Steam, in parallel — long external lead time, start early
6. **[#15](https://github.com/jcreek/CosmicClash/issues/15) — App ID and
publisher key.** Valve coordination, so the calendar time is theirs, not
yours. The adapter is written and config-gated: sign-in returns 503 until
both values are set.
7. **[#16](https://github.com/jcreek/CosmicClash/issues/16) — GodotSteam build
templates.** The client-side ticket code is written and needs the custom
build to run.
#### Unblocked today — nothing is stopping these
- **[#19](https://github.com/jcreek/CosmicClash/issues/19)** then
**[#18](https://github.com/jcreek/CosmicClash/issues/18)**: the 3v3 gate is
the cheaper session to arrange and exercises #18's latency conditions
incidentally, so doing it first can settle both.
**[#20](https://github.com/jcreek/CosmicClash/issues/20)** needs two machines
and the internet, not a cluster.
- **[#24](https://github.com/jcreek/CosmicClash/issues/24)** then
**[#25](https://github.com/jcreek/CosmicClash/issues/25)**: training runs,
independent of everything above.
- **[#21](https://github.com/jcreek/CosmicClash/issues/21)**,
**[#26](https://github.com/jcreek/CosmicClash/issues/26)**,
**[#27](https://github.com/jcreek/CosmicClash/issues/27)**,
**[#28](https://github.com/jcreek/CosmicClash/issues/28)**: hardware, audio,
font, graphics QA. No dependencies, no ordering between them.
- **[#23](https://github.com/jcreek/CosmicClash/issues/23)**,
**[#29](https://github.com/jcreek/CosmicClash/issues/29)**: open design
questions with no deadline. Neither blocks anything.
- [x] ([#14](https://github.com/jcreek/CosmicClash/issues/14)) **Join-signing design decided and implemented.** Resolved as HMAC-SHA256 over the canonical claim bytes with a **key ID inside those bytes**: the allocator signs with one named key while allocated servers hold the set of currently-valid keys, so rotation does not invalidate authorisations already issued for in-flight matches. `allocator.Worker` now publishes the signed roster after binding, and `cmd/allocator` refuses to start without key material. Rotation procedure is in `docs/MATCHMAKING.md` §2; see `multiplayer-next.md` §8.31. Nothing human-only remains here — live verification is covered by [#17](https://github.com/jcreek/CosmicClash/issues/17).
- [ ] ([#18](https://github.com/jcreek/CosmicClash/issues/18)) **Phase 4 playtest at ~100 ms RTT** — does the ship/ball feel local, do contact corrections read as bumps or glitches? Every numeric gate is green; this is a feel judgment no metric can answer. `multiplayer-next.md` §0, gate A.
- [ ] ([#19](https://github.com/jcreek/CosmicClash/issues/19)) **Phase 5 3v3 gate** — a full 6-player match start to finish, with a mid-match disconnect and a late joiner. Only verified so far at 1v1 plus a two-bot CI match. `multiplayer-next.md` §0, gate B.
- [ ] ([#20](https://github.com/jcreek/CosmicClash/issues/20)) **Phase 6 external gate** — run the exported Docker server and clients from separate real machines over the internet, then play a full match (controlled test only, since defect C below is still open). `multiplayer-next.md` §0.
- [ ] ([#15](https://github.com/jcreek/CosmicClash/issues/15)) **Acquire a project-owned Steamworks App ID and coordinate with Valve** — hard prerequisite for Phase 7 (browser, verified tickets, bans, production credentials, ticketed Hosted Dedicated Server SDR) and therefore for Phase 8. `multiplayer-next.md` §0, Phase 7; `STEAM.md`.
- [ ] ([#16](https://github.com/jcreek/CosmicClash/issues/16)) **Supply custom GodotSteam client/server build templates** and pin them in `steam-dependencies.lock.json` (`COSMIC_CLASH_STEAM_CLIENT_GODOT` / `COSMIC_CLASH_STEAM_SERVER_GODOT`) — `make verify-steam-templates` refuses a stock Godot binary until these exist. `STEAM.md`.
- [ ] ([#21](https://github.com/jcreek/CosmicClash/issues/21)) **Reference-hardware profiling (task 0.15b)** in the live editor on real low/mid-tier hardware — blocks 0.16, 0.17/0.17b/0.17c/0.17d, 0.26 (arena GI bake), and 0.28 (physics separate-thread prototype). Covered above; listed again here because it also gates Phase 5.5's graphics QA gate for multiplayer sign-off.
- [ ] ([#17](https://github.com/jcreek/CosmicClash/issues/17)) **Stand up the live Kubernetes cluster and Agones deployment** for Phase 8 — provider-portable manifests exist, but nothing has run against a real cluster; needs the provider-specific deployment overlay (network, DNS, secrets) per `docs/MATCHMAKING.md`.
- [ ] ([#31](https://github.com/jcreek/CosmicClash/issues/31)) **Build, push and pin the container images the Kubernetes manifests reference.** Every image target builds, but no workflow publishes any of them and all manifest digests are still all-zero placeholders, so `deploy/k8s/base` cannot pull running images. **Only two things need a person**: the `ghcr.io/cosmic-clash/*` namespace in the manifests does not exist (no such org), and this repo is private while no manifest declares `imagePullSecrets`, so package visibility must be chosen. Publishing itself needs no new credential — GHCR accepts the built-in `GITHUB_TOKEN` with `packages: write` — so the workflow, digest pinning and enabling `--require-concrete` are agent work once those two are answered. Blocks [#17](https://github.com/jcreek/CosmicClash/issues/17).
- [ ] ([#33](https://github.com/jcreek/CosmicClash/issues/33)) **Move game servers to their own namespace** so `cosmic-clash` can enforce `restricted` again. Agones' Dynamic port policy needs a `hostPort`, which `baseline`/`restricted` forbid, so the whole namespace dropped to `privileged` — including the control plane, which mounts the database DSN, workload secret and Steam publisher key. Deferred until the Agones gate was green so a new failure could not be ambiguous.
- [x] (no issue — agent-actionable) **Phase 8.48 has its own Compose smoke fixture.** `compose.allocated-smoke.yml` and `scripts/verify_allocated_compose.sh` are independent of `compose.phase6-smoke.yml` — the script states so explicitly and reuses none of its ports — so the allocated-mode flow no longer inherits that fixture's hardcoded port, first-come slots or `--max-matches=2`. Exercised by `make verify-allocated-compose`.
- [ ] ([#23](https://github.com/jcreek/CosmicClash/issues/23)) **Decide the contact-cohort-only client-side shadow world** (open question F in `multiplayer-next.md` §0). A design call about whether contact pairs get a client-side shadow simulation; nothing is blocked on it, and it can stay open indefinitely without holding anything up.
- [ ] ([#32](https://github.com/jcreek/CosmicClash/issues/32)) **Implement casual backfill** — proposal, matcher pass, client offer UI and late roster delivery. Listed here because it has an issue, not because it needs you: the roster-delivery design is decided (`docs/MATCHMAKING.md` § Casual) and candidate selection has landed, so the rest is agent work. End-to-end verification needs the cluster ([#17](https://github.com/jcreek/CosmicClash/issues/17)).
- [ ] ([#22](https://github.com/jcreek/CosmicClash/issues/22)) **Release-evidence and human sign-off gates for Phase 8 production launch** — once the above are done, someone needs to actually run and sign off the production-shaped checks `multiplayer-next.md` §7 lists as infrastructure/production-dependent.
Defect **C** (slot reservation keyed on display name alone — real, demonstrated, exploitable during the 30 s disconnect window) is not its own action item: it is fixed for free by the Steam auth tickets in task 7.4 above, so nothing to do until Steam identity lands.
+46 -4
View File
@@ -66,6 +66,16 @@ with `GODOT_BIN` or `--godot_bin` if yours lives elsewhere.)
## Run a training session
Before starting a multi-hour curriculum run, execute the bounded policy and
orchestrator checks from the repository root:
```bash
make verify-training
```
The target changes into `training/` deliberately because the test modules
import sibling files such as `generation5.py` and `evaluate.py`.
```bash
cd training
.venv/bin/python train.py --experiment run01 --timesteps 20000000 --n-parallel 6 --speedup 16
@@ -129,6 +139,28 @@ appends to `training/eval_history.json` — the long-term progress record.
Evaluate each new candidate against the previous promoted bot and a fixed
early reference to see absolute progress over time.
The evaluator launches Godot with the explicit headless display driver,
Compatibility renderer, dummy audio driver, and a temporary writable log path
so evaluation is reproducible on machines where the default renderer or
`user://` log location is unavailable. On 2026-09-01, the current Stage 6
export completed the full 100-episode paired evaluation against each of its
three league references on the independent seeds 19 and 43 (seed 1 was already
recorded in `eval_history.json`):
| Seed | Stage 3 reference | Stage 4 reference | Stage 5 reference |
| --- | ---: | ---: | ---: |
| 1 | 79174 | 60319 | 325117 |
| 19 | 82135 | 652114 | 404218 |
| 43 | 74179 | 582715 | 365410 |
Cells are candidate winsreference winsdraws. The candidate clears the
documented 15% reference-regression margin against Stage 3 and Stage 4 on all
three seeds, but fails against Stage 5 on seeds 1 and 43 and is effectively
even on seed 19. The physical-team splits stayed within the 20% diagnostic
ceiling. This is independent runtime evidence, not promotion evidence: Stage 6
remains open and requires another training attempt or an explicit human
decision after reviewing the Stage 5 regression.
If a model was trained with the locomotion mask on (curriculum stages 1, 2,
and 5 — see below), pass `--grounded-a`/`--grounded-b` for whichever side it's on.
The eval otherwise runs `AIShipController` fully unmasked regardless of how a
@@ -665,9 +697,13 @@ can be based on evidence instead of a single watched match.
| 6 — `league` | Live policy against a frozen opponent sampled per episode from Stage 3, Stage 4, and Stage 5 | 100M (~10h) | Prevent a narrow self-play equilibrium and consolidate ground handling, aerial interception, attack, and defence against distinct styles. | No clear head-to-head regression against any pool member plus conservative handling/aerial telemetry floors. Promote the passing result to `medium.json` after these recorded evaluations support it. |
Stage 7 teamplay remains deliberately unconfigured. The fixed roster
observation and `team_size` plumbing can run 2v2, but there is no paired 2v2
evaluation or team-credit reward yet; spending 120M steps without those gates
would make a pass meaningless.
observation and `team_size` plumbing can run 2v2. The team-credit reward and
paired 2v2 evaluation prerequisites are implemented but remain opt-in:
`ShipAIController.team_touch_credit_weight` shares a bounded
fraction of a touch payout across same-team agents (default `0.0` preserves
all existing curricula), and `evaluate.py --team-size=2` runs the same policy
as a two-ship team with the existing paired side swap. Stage 7 stays
unconfigured until a recorded 2v2 evaluation establishes teamplay gates.
`training/generation5.py` implements Stages 46 separately from the completed
generation-4 orchestrator and state. It always begins Stage 4 from
@@ -855,7 +891,13 @@ real tail, the same way this one now has been.
Stage 6's `league` opponent mode samples a historical exported policy at each
episode reset. Each later stage preserves the preceding shaping and adds one
new difficulty.
new difficulty. Stage 6 now also reserves 10% each for wall-play and
pre-rebound states; these starts are generated by `TrainingMode` and are not
present in Stages 45. The generation-5 orchestrator evaluates every candidate
against every reference on three independent paired seeds (`1, 19, 43`) before
advancing; pass `--evaluation-seeds` only when deliberately running a
different, recorded experiment. This avoids promoting a policy from a single
side-biased starting-state sequence.
The physical-side gate is separate from the model-vs-model score. A paired
side swap can make an identical policy appear perfectly balanced overall even
+117
View File
@@ -0,0 +1,117 @@
services:
database:
image: postgres:17-alpine
environment:
POSTGRES_DB: cosmic_clash_test
POSTGRES_USER: cosmic_clash_test
POSTGRES_PASSWORD: cosmic_clash_test
healthcheck:
test: ["CMD-SHELL", "pg_isready -U cosmic_clash_test -d cosmic_clash_test"]
interval: 1s
timeout: 3s
retries: 30
control-plane:
build:
context: .
target: testkit-api
environment:
COSMIC_CLASH_POSTGRES_DSN: postgres://cosmic_clash_test:cosmic_clash_test@database:5432/cosmic_clash_test?sslmode=disable
COSMIC_CLASH_WORKLOAD_SECRET: compose-workload-secret
command: ["--listen=0.0.0.0:8080", "--migrations=/opt/cosmic-clash/migrations"]
depends_on:
database:
condition: service_healthy
ports:
- "18080:8080"
matcher:
build:
context: .
target: matcher
environment:
COSMIC_CLASH_POSTGRES_DSN: postgres://cosmic_clash_test:cosmic_clash_test@database:5432/cosmic_clash_test?sslmode=disable
command: ["--dsn=postgres://cosmic_clash_test:cosmic_clash_test@database:5432/cosmic_clash_test?sslmode=disable", "--migrations=/opt/cosmic-clash/migrations", "--playlist=casual", "--size=6", "--interval=1s"]
depends_on:
database:
condition: service_healthy
agones-provider:
image: python:3.12-alpine
command: ["python3", "/opt/fake_agones_provider.py"]
environment:
FAKE_AGONES_TLS_CERT: /run/cosmic-clash/fake-agones.crt
FAKE_AGONES_TLS_KEY: /run/cosmic-clash/fake-agones.key
volumes:
- ./scripts/fake_agones_provider.py:/opt/fake_agones_provider.py:ro
- ${COMPOSE_SMOKE_DIR:-/tmp/cosmic-clash-allocated-smoke}/fake-agones.crt:/run/cosmic-clash/fake-agones.crt:ro
- ${COMPOSE_SMOKE_DIR:-/tmp/cosmic-clash-allocated-smoke}/fake-agones.key:/run/cosmic-clash/fake-agones.key:ro
allocator:
build:
context: .
target: allocator
environment:
COSMIC_CLASH_POSTGRES_DSN: postgres://cosmic_clash_test:cosmic_clash_test@database:5432/cosmic_clash_test?sslmode=disable
COSMIC_CLASH_AGONES_URL: https://agones-provider:8443
COSMIC_CLASH_AGONES_NAMESPACE: cosmic-clash
COSMIC_CLASH_WORKLOAD_SECRET: compose-workload-secret
COSMIC_CLASH_KUBERNETES_TOKEN_PATH: /run/cosmic-clash/kubernetes-token
COSMIC_CLASH_KUBERNETES_CA_PATH: /run/cosmic-clash/fake-agones.crt
# The allocator signs one join authorisation per participant and publishes
# the assignment roster, so it needs the same key material the game server
# verifies with. It refuses to start without them rather than binding
# allocations that could never become joinable.
command: ["--dsn=postgres://cosmic_clash_test:cosmic_clash_test@database:5432/cosmic_clash_test?sslmode=disable", "--migrations=/opt/cosmic-clash/migrations", "--interval=1s", "--transport=enet", "--join-authorisations-key-file=/run/secrets/cosmic-clash/join-signing-keys.json", "--join-authorisations-key-id=compose-key-1"]
depends_on:
database:
condition: service_healthy
agones-provider:
condition: service_started
volumes:
- ${COMPOSE_SMOKE_DIR:-/tmp/cosmic-clash-allocated-smoke}/kubernetes-token:/run/cosmic-clash/kubernetes-token:ro
- ${COMPOSE_SMOKE_DIR:-/tmp/cosmic-clash-allocated-smoke}/fake-agones.crt:/run/cosmic-clash/fake-agones.crt:ro
- ${COMPOSE_SMOKE_DIR:-/tmp/cosmic-clash-allocated-smoke}/join-signing-keys.json:/run/secrets/cosmic-clash/join-signing-keys.json:ro
maintenance:
build:
context: .
target: maintenance
environment:
COSMIC_CLASH_POSTGRES_DSN: postgres://cosmic_clash_test:cosmic_clash_test@database:5432/cosmic_clash_test?sslmode=disable
command: ["--dsn=postgres://cosmic_clash_test:cosmic_clash_test@database:5432/cosmic_clash_test?sslmode=disable", "--migrations=/opt/cosmic-clash/migrations", "--interval=1h", "--initial-connect-interval=1s"]
depends_on:
database:
condition: service_healthy
game-server:
build:
context: .
target: game-server
command:
- --drain-url=http://127.0.0.1:7780/drain
- --drain-token-env=COSMIC_CLASH_DRAIN_TOKEN
- --drain-grace=10s
- --
- /opt/cosmic-clash/cosmic-clash-server
- --port=31001
- --allocated-mode
- --match-id=compose-match-0001
- --server-id=compose-server-0001
- --playlist-version=casual
- --playlist=casual
- --client-build=build-1
- --assignment-expiry-unix=4102444800
- --server-image-digest=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
- --transport=enet
- --region=EU
- --join-authorisations-file=/run/cosmic-clash/join-roster.json
- --join-authorisations-key-file=/run/secrets/cosmic-clash/join-signing-keys.json
- --readiness-port=7780
environment:
COSMIC_CLASH_DRAIN_TOKEN: compose-drain-token
COSMIC_CLASH_CONTROL_PLANE_URL: http://control-plane:8080
COSMIC_CLASH_WORKLOAD_TOKEN: ${COSMIC_CLASH_COMPOSE_WORKLOAD_TOKEN:?allocated smoke workload token is required}
volumes:
- ${COMPOSE_SMOKE_DIR:-/tmp/cosmic-clash-allocated-smoke}/join-roster.json:/run/cosmic-clash/join-roster.json:ro
- ${COMPOSE_SMOKE_DIR:-/tmp/cosmic-clash-allocated-smoke}/join-signing-keys.json:/run/secrets/cosmic-clash/join-signing-keys.json:ro
+37
View File
@@ -0,0 +1,37 @@
services:
database:
image: postgres:17-alpine
environment:
POSTGRES_DB: cosmic_clash_test
POSTGRES_USER: cosmic_clash_test
POSTGRES_PASSWORD: cosmic_clash_test
healthcheck:
test: ["CMD-SHELL", "pg_isready -U cosmic_clash_test -d cosmic_clash_test"]
interval: 1s
timeout: 3s
retries: 30
control-plane:
build:
context: .
target: testkit-api
environment:
COSMIC_CLASH_POSTGRES_DSN: postgres://cosmic_clash_test:cosmic_clash_test@database:5432/cosmic_clash_test?sslmode=disable
COSMIC_CLASH_WORKLOAD_SECRET: chaos-workload-secret
command: ["--listen=0.0.0.0:8080", "--migrations=/opt/cosmic-clash/migrations"]
depends_on:
database:
condition: service_healthy
ports:
- "18082:8080"
maintenance:
build:
context: .
target: maintenance
environment:
COSMIC_CLASH_POSTGRES_DSN: postgres://cosmic_clash_test:cosmic_clash_test@database:5432/cosmic_clash_test?sslmode=disable
command: ["--dsn=postgres://cosmic_clash_test:cosmic_clash_test@database:5432/cosmic_clash_test?sslmode=disable", "--migrations=/opt/cosmic-clash/migrations", "--interval=1s", "--stalled-allocation-deadline=1s", "--stalled-allocation-batch=10", "--initial-connect-batch=1"]
depends_on:
database:
condition: service_healthy
+17
View File
@@ -3,4 +3,21 @@
# is baked into the dedicated artifact during the Docker export stage.
set -eu
# Godot's stdout is fully (block) buffered rather than line-buffered whenever
# it isn't attached to a TTY -- true of every real deployment of this script:
# `docker run -d` (Docker's log driver presents a pipe, not a TTY), a plain
# `docker run` even without -d, and systemd's journal capture (also a pipe).
# Verified directly: a `docker run -d` container sat for 20+ seconds with
# `docker logs` showing nothing at all, including the startup line, while the
# process was confirmed alive and running; docker stop's SIGTERM (Godot has
# no SIGTERM hook, see SERVER.md) then killed it without ever flushing that
# buffered output, losing it permanently rather than merely delaying it.
# `stdbuf -oL -eL` forces line buffering via LD_PRELOAD without touching the
# binary; re-verified the same scenario then shows the startup line within
# 3s. Fall back to running unwrapped if stdbuf isn't available (e.g. a
# minimal image without GNU coreutils) rather than failing to start at all --
# a server with delayed logs is still far better than no server.
if command -v stdbuf >/dev/null 2>&1; then
exec stdbuf -oL -eL "$(dirname "$0")/CosmicClashServer.x86_64" --headless -- "$@"
fi
exec "$(dirname "$0")/CosmicClashServer.x86_64" --headless -- "$@"
+129
View File
@@ -0,0 +1,129 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: allocator
namespace: cosmic-clash
labels:
app.kubernetes.io/name: allocator
app.kubernetes.io/component: allocator
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app.kubernetes.io/name: allocator
template:
metadata:
labels:
app.kubernetes.io/name: allocator
app.kubernetes.io/component: allocator
spec:
terminationGracePeriodSeconds: 10
serviceAccountName: allocator
# This role calls Agones CRDs through the Kubernetes API. The client
# rereads the short-lived projected token on every request.
automountServiceAccountToken: true
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: allocator
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
topologyKey: kubernetes.io/hostname
labelSelector:
matchLabels:
app.kubernetes.io/name: allocator
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: allocator
image: ghcr.io/cosmic-clash/allocator@sha256:0000000000000000000000000000000000000000000000000000000000000000
args:
- --dsn=$(COSMIC_CLASH_POSTGRES_DSN)
- --agones-url=https://kubernetes.default.svc
- --agones-namespace=cosmic-clash
- --provider-timeout=10s
- --readiness-max-stale=30s
- --workload-token-ttl=2h
- --metrics-addr=:9091
# Without these the allocator binds allocations but never publishes
# an assignment roster, and no allocated match can become joinable.
# The same key material is mounted into game servers by fleet.yaml.
- --join-authorisations-key-file=/run/secrets/cosmic-clash/join-signing-keys.json
- --join-authorisations-key-id=$(COSMIC_CLASH_JOIN_SIGNING_KEY_ID)
ports:
- name: metrics
containerPort: 9091
volumeMounts:
- name: join-signing-keys
mountPath: /run/secrets/cosmic-clash
readOnly: true
readinessProbe:
httpGet:
path: /readyz
port: metrics
initialDelaySeconds: 2
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
livenessProbe:
httpGet:
path: /healthz
port: metrics
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ALL]
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 1
memory: 512Mi
env:
- name: COSMIC_CLASH_POSTGRES_DSN
valueFrom:
secretKeyRef:
name: cosmic-clash-database
key: dsn
- name: COSMIC_CLASH_WORKLOAD_SECRET
valueFrom:
secretKeyRef:
name: cosmic-clash-workload
key: secret
# Rotation: publish the new key in the Secret everywhere first,
# then move this ID to it, then drop the retired key once no live
# match can still reference it.
- name: COSMIC_CLASH_JOIN_SIGNING_KEY_ID
valueFrom:
secretKeyRef:
name: cosmic-clash-game-server
key: join-signing-key-id
volumes:
- name: join-signing-keys
secret:
secretName: cosmic-clash-game-server
defaultMode: 0400
items:
- key: join-signing-keys.json
path: join-signing-keys.json
+10
View File
@@ -0,0 +1,10 @@
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: allocator
namespace: cosmic-clash
spec:
minAvailable: 1
selector:
matchLabels:
app.kubernetes.io/name: allocator
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: allocator
namespace: cosmic-clash
labels:
app.kubernetes.io/name: allocator
app.kubernetes.io/component: allocator
spec:
selector:
app.kubernetes.io/name: allocator
ports:
- name: metrics
port: 9091
targetPort: metrics
@@ -0,0 +1,116 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: control-plane
namespace: cosmic-clash
labels:
app.kubernetes.io/name: control-plane
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app.kubernetes.io/name: control-plane
template:
metadata:
labels:
app.kubernetes.io/name: control-plane
spec:
terminationGracePeriodSeconds: 10
serviceAccountName: control-plane
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
seccompProfile:
type: RuntimeDefault
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: control-plane
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
topologyKey: kubernetes.io/hostname
labelSelector:
matchLabels:
app.kubernetes.io/name: control-plane
containers:
- name: control-plane
image: ghcr.io/cosmic-clash/control-plane@sha256:0000000000000000000000000000000000000000000000000000000000000000
args:
- --rate-limit=120
- --rate-limit-window=1m
- --rate-limit-max-keys=10000
# Ingress NetworkPolicy admits only the labelled edge gateway.
# Cover common private/CGNAT/ULA pod networks; overlays should
# narrow this to their actual gateway CIDR where available.
- --trusted-proxy-cidrs=10.0.0.0/8,100.64.0.0/10,172.16.0.0/12,192.168.0.0/16,fc00::/7
ports:
- name: http
containerPort: 8080
readinessProbe:
httpGet:
path: /readyz
port: http
periodSeconds: 5
timeoutSeconds: 2
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 2
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ALL]
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 1
memory: 512Mi
env:
- name: COSMIC_CLASH_POSTGRES_DSN
valueFrom:
secretKeyRef:
name: cosmic-clash-database
key: dsn
- name: COSMIC_CLASH_WORKLOAD_SECRET
valueFrom:
secretKeyRef:
name: cosmic-clash-workload
key: secret
# Player sign-in. The publisher key is the credential Valve issues
# to us, never to a client, so it is mounted only here -- no other
# workload and no game server ever sees it. Both values must be
# present or POST /v1/session/steam keeps returning 503: silently
# accepting an unverified ticket would be worse than refusing to
# authenticate. Optional until the App ID exists (issue #15), so the
# Deployment still rolls out without the Secret.
- name: COSMIC_CLASH_STEAM_PUBLISHER_KEY
valueFrom:
secretKeyRef:
name: cosmic-clash-steam
key: publisher-key
optional: true
- name: COSMIC_CLASH_STEAM_APP_ID
valueFrom:
secretKeyRef:
name: cosmic-clash-steam
key: app-id
optional: true
+10
View File
@@ -0,0 +1,10 @@
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: control-plane
namespace: cosmic-clash
spec:
minAvailable: 1
selector:
matchLabels:
app.kubernetes.io/name: control-plane
@@ -0,0 +1,14 @@
apiVersion: v1
kind: Service
metadata:
name: control-plane
namespace: cosmic-clash
labels:
app.kubernetes.io/name: control-plane
spec:
selector:
app.kubernetes.io/name: control-plane
ports:
- name: http
port: 8080
targetPort: http
+17
View File
@@ -0,0 +1,17 @@
apiVersion: autoscaling.agones.dev/v1
kind: FleetAutoscaler
metadata:
name: cosmic-clash-game
namespace: cosmic-clash
labels:
app.kubernetes.io/name: game-fleet-autoscaler
spec:
fleetName: cosmic-clash-game
policy:
type: Buffer
buffer:
# Ready floor is deliberately independent of Allocated capacity. Agones
# scales Allocated servers down to zero while preserving this buffer.
minReady: 2
maxReady: 6
bufferSize: 2

Some files were not shown because too many files have changed in this diff Show More