mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 ./...
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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 1–6). 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 1–6). 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 0–6 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 1–6. Code comments across `Game/scripts/` cite it constantly by section number (`§2.4`, `§4.1`); many still say `multiplayer-next.md §N` for `N` 1–6 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 (1–6 → 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 25–30 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 25–30 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
@@ -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"]
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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]
|
||||
@@ -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,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
|
||||
|
||||
@@ -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, ±
|
||||
|
||||
@@ -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.
|
||||
#
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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
@@ -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
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("/")
|
||||
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
#
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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])
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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** |
|
||||
|
||||
→ **~6–10 concurrent matches per modern core**, ~150–250 MB RSS per process. 100 concurrent matches ≈ 12–16 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.03–0.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 N−1) |
|
||||
| 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 0–16.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 90–110 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 60–360 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**, ~6–10 matches per core to ~3–5 (§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.9–3.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 ~90–110 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. **L1–L4 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 L1–L4 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 3–4 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -86,6 +86,11 @@ roughly 6–10 simultaneous match processes per modern core, 150–250 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,
|
||||
|
||||
@@ -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.16–0.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.1–0.15, 0.18–0.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.1–0.15, 0.18–0.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
@@ -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 | 79–17–4 | 60–31–9 | 32–51–17 |
|
||||
| 19 | 82–13–5 | 65–21–14 | 40–42–18 |
|
||||
| 43 | 74–17–9 | 58–27–15 | 36–54–10 |
|
||||
|
||||
Cells are candidate wins–reference wins–draws. 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 4–6 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 4–5. 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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 -- "$@"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user