mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-12 20:22:01 +00:00
Compare commits
48 Commits
089c127cc3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c02aad66a0 | |||
| 076d27a564 | |||
| 00b900d864 | |||
| fe453ab607 | |||
| 52cc478b38 | |||
| 4ea72be581 | |||
| 4560d2de8a | |||
| 4912837dd7 | |||
| aac00f148e | |||
| 52ee181042 | |||
| 4f48f0a6a8 | |||
| 0de97381b7 | |||
| ca70568fad | |||
| 8aa4af3a3a | |||
| 9ab1bec89a | |||
| 14da286e11 | |||
| fc2f5c8669 | |||
| 432e5a11e8 | |||
| 707aea5898 | |||
| 8ba045063d | |||
| 654f20e28f | |||
| 1becfb4f3f | |||
| 61a073099d | |||
| a1f30f6af9 | |||
| 8033d52db3 | |||
| 2702e53068 | |||
| 8b9ae35b43 | |||
| a4b362cb01 | |||
| 0a8f3924d0 | |||
| ccf7d0fbfe | |||
| f628ccfd35 | |||
| d40344a2c0 | |||
| 801fca7cb0 | |||
| 5765532409 | |||
| b8bcc1f3c1 | |||
| 5453e19761 | |||
| 129b0c7ef0 | |||
| 4248e51c60 | |||
| 320ec46ba2 | |||
| f6a87463c5 | |||
| 1dd05c75f1 | |||
| 2c648514ba | |||
| 6983ddd7df | |||
| 7d247bc516 | |||
| 4519f2db82 | |||
| 0f1a7403e4 | |||
| 8a55c33666 | |||
| ad6f9cc148 |
@@ -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,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. 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 partially implemented and a 1.0 launch blocker — see `docs/MATCHMAKING.md` for the design, `multiplayer-next.md` §0 and §7 for what remains (a real deployment cannot complete a match end to end today), 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.
|
||||
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.
|
||||
|
||||
@@ -22,7 +22,9 @@ The prose docs carry far more design rationale than the code comments, and sever
|
||||
- `FLIGHT_MANUAL.md` — the player-facing flight model.
|
||||
- `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 (audio is the big one: there is none at all).
|
||||
- `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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+17
@@ -57,6 +57,7 @@ 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
|
||||
@@ -76,6 +77,22 @@ COPY --from=supervisor-build /opt/cosmic-clash/game-server-supervisor /opt/cosmi
|
||||
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
|
||||
|
||||
+52
-12
@@ -27,18 +27,58 @@ Welcome to space, pilot! This guide will teach you everything you need to know a
|
||||
|
||||
### Controller Controls
|
||||
|
||||
#### Translation
|
||||
Button names are the Xbox layout; a PlayStation pad maps the same physical
|
||||
positions (A = ✕, B = ○, X = □, Y = △).
|
||||
|
||||
- Left Stick - Strafe (Left/Right) + Thrust (Forward/Back)
|
||||
- Right Trigger - Forward Thrust
|
||||
- Left Trigger - Reverse Thrust
|
||||
- Face Buttons - Up/Down Thrust
|
||||
**The left stick points the nose, the right stick moves the hull.** Your ship has
|
||||
six degrees of freedom and a pad has exactly six analog axes, so every one gets a
|
||||
real axis rather than an on/off button.
|
||||
|
||||
#### Rotation
|
||||
#### Rotation — left stick and shoulders
|
||||
|
||||
- Right Stick - Pitch/Yaw
|
||||
- Shoulder Buttons - Roll
|
||||
- `A Button` - Turbo Boost
|
||||
- Left Stick (left/right) - Yaw
|
||||
- Left Stick (up/down) - Pitch. Flight-sim polarity by default: **push the
|
||||
stick forward and the nose goes down.** Flip it with "Invert pitch" in
|
||||
Settings → Controls.
|
||||
- `LB` - Roll Left (Bank Left)
|
||||
- `RB` - Roll Right (Bank Right)
|
||||
|
||||
#### Translation — right stick and triggers
|
||||
|
||||
- `RT` - Forward Thrust (Main Engines)
|
||||
- `LT` - Reverse Thrust (Retro Engines)
|
||||
- Right Stick (left/right) - Strafe (Port/Starboard Thrusters)
|
||||
- Right Stick (up/down) - Thrust Up/Down (Dorsal/Ventral Thrusters)
|
||||
- `L3` (click the left stick) - Turbo Boost
|
||||
|
||||
`X` and `Y` are deliberately unused in flight, and `A`/`B` are menu-only, so a
|
||||
reflexive face-button press never does anything mid-match.
|
||||
|
||||
#### Menus
|
||||
|
||||
- D-Pad or Left Stick - move the highlight
|
||||
- `A` - select
|
||||
- `B` - back
|
||||
- `Start` - leave a match in progress (deliberately not `B`, which is too easy
|
||||
to press by accident mid-game)
|
||||
|
||||
#### Other
|
||||
|
||||
- `R3` (click the right stick) - Toggle ball camera
|
||||
- `D-Pad Up` - Reset the ball (Free Play only)
|
||||
|
||||
The triggers and sticks are **analog**: a half-pulled trigger gives half thrust,
|
||||
and a gentle stick lean gives a gentle turn. Keyboard keys are all-or-nothing,
|
||||
which is the main reason a pad is easier to fly precisely.
|
||||
|
||||
### Rebinding
|
||||
|
||||
Every control above — keyboard and controller alike — can be remapped in
|
||||
**Settings → Controls**. Pick the device with the Keyboard/Controller toggle,
|
||||
click the binding you want to change, and press the key or button to assign.
|
||||
Binding an input that is already in use unbinds it from the action that had it,
|
||||
and the screen tells you which. "Reset all bindings to defaults" restores this
|
||||
table.
|
||||
|
||||
## 🛸 Basic Flight Principles
|
||||
|
||||
@@ -69,7 +109,7 @@ Your ship is a **realistic space vehicle** with the following characteristics:
|
||||
|
||||
#### Camera Control
|
||||
|
||||
- **Ball Cam**: Press `Enter` to toggle ball tracking camera
|
||||
- **Ball Cam**: Press `Space` (or `R3` on a controller) to toggle ball tracking camera
|
||||
- **Ship Cam**: Normal follow camera that looks where your ship points
|
||||
|
||||
### Intermediate Maneuvers
|
||||
@@ -115,7 +155,7 @@ Your ship is a **realistic space vehicle** with the following characteristics:
|
||||
|
||||
## 🎮 Ball Cam vs Ship Cam
|
||||
|
||||
### Ball Cam Mode (`Enter` to toggle)
|
||||
### Ball Cam Mode (`Space` / `R3` to toggle)
|
||||
|
||||
- **Camera**: Always looks toward the ball
|
||||
- **Ship Control**: Based on ship orientation (NOT camera view)
|
||||
@@ -133,7 +173,7 @@ Your ship is a **realistic space vehicle** with the following characteristics:
|
||||
|
||||
### Turbo System
|
||||
|
||||
- **Activation**: Hold `Shift` (keyboard) or `A` (controller) while thrusting forward
|
||||
- **Activation**: Hold `Shift` (keyboard) or `L3` (controller) while thrusting forward
|
||||
- **Effect**: 2.5x thrust multiplier on main engines only
|
||||
- **Strategy**: Use for quick acceleration or emergency maneuvers
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+37
-3
@@ -28,6 +28,7 @@ run/main_scene.dedicated_server="res://scenes/server_boot.tscn"
|
||||
GameSettings="*res://scripts/game_settings.gd"
|
||||
ControlPlaneClient="*res://scripts/control_plane_client.gd"
|
||||
VideoSettings="*res://scripts/video_settings.gd"
|
||||
InputSettings="*res://scripts/input_settings.gd"
|
||||
BackgroundFPS="*res://scripts/background_fps.gd"
|
||||
PerfOverlay="*res://scripts/perf_overlay.gd"
|
||||
NetSim="*res://scripts/net_sim.gd"
|
||||
@@ -55,42 +56,49 @@ enabled=PackedStringArray("res://addons/godot_rl_agents/plugin.cfg")
|
||||
reset_ball={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":11,"pressure":0.0,"pressed":false,"script":null)
|
||||
]
|
||||
}
|
||||
move_forward={
|
||||
"deadzone": 0.2,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":5,"axis_value":1.0,"script":null)
|
||||
]
|
||||
}
|
||||
move_back={
|
||||
"deadzone": 0.2,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":4,"axis_value":1.0,"script":null)
|
||||
]
|
||||
}
|
||||
move_left={
|
||||
"deadzone": 0.2,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":2,"axis_value":-1.0,"script":null)
|
||||
]
|
||||
}
|
||||
move_right={
|
||||
"deadzone": 0.2,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":2,"axis_value":1.0,"script":null)
|
||||
]
|
||||
}
|
||||
move_up={
|
||||
"deadzone": 0.2,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":3,"axis_value":-1.0,"script":null)
|
||||
]
|
||||
}
|
||||
move_down={
|
||||
"deadzone": 0.2,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":81,"key_label":0,"unicode":113,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":3,"axis_value":1.0,"script":null)
|
||||
]
|
||||
}
|
||||
turbo={
|
||||
"deadzone": 0.2,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194325,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":false,"script":null)
|
||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":7,"pressure":0.0,"pressed":false,"script":null)
|
||||
]
|
||||
}
|
||||
turn_left={
|
||||
@@ -108,13 +116,13 @@ turn_right={
|
||||
pitch_up={
|
||||
"deadzone": 0.2,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":73,"key_label":0,"unicode":105,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":-1.0,"script":null)
|
||||
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":1.0,"script":null)
|
||||
]
|
||||
}
|
||||
pitch_down={
|
||||
"deadzone": 0.2,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":75,"key_label":0,"unicode":107,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":1.0,"script":null)
|
||||
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":-1.0,"script":null)
|
||||
]
|
||||
}
|
||||
roll_left={
|
||||
@@ -129,6 +137,32 @@ roll_right={
|
||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":10,"pressure":0.0,"pressed":false,"script":null)
|
||||
]
|
||||
}
|
||||
toggle_ball_cam={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":8,"pressure":0.0,"pressed":false,"script":null)
|
||||
]
|
||||
}
|
||||
ui_cancel={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":1,"pressure":0.0,"pressed":false,"script":null)
|
||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":6,"pressure":0.0,"pressed":false,"script":null)
|
||||
]
|
||||
}
|
||||
ui_accept={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194309,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194310,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":false,"script":null)
|
||||
]
|
||||
}
|
||||
leave_gameplay={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":6,"pressure":0.0,"pressed":false,"script":null)
|
||||
]
|
||||
}
|
||||
toggle_perf_overlay={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194334,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
|
||||
+35
-21
@@ -13,26 +13,40 @@ grow_vertical = 2
|
||||
script = ExtResource("1_lobby")
|
||||
theme = ExtResource("2_theme")
|
||||
|
||||
[node name="CenterContainer" type="CenterContainer" parent="."]
|
||||
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/margin_left = 24
|
||||
theme_override_constants/margin_top = 24
|
||||
theme_override_constants/margin_right = 24
|
||||
theme_override_constants/margin_bottom = 24
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"]
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
follow_focus = true
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/ScrollContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer"]
|
||||
custom_minimum_size = Vector2(520, 0)
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="TitleLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 40
|
||||
text = "Lobby"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="StatusLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
modulate = Color(1, 1, 1, 0.65)
|
||||
layout_mode = 2
|
||||
@@ -41,74 +55,74 @@ text = "Connecting..."
|
||||
horizontal_alignment = 1
|
||||
autowrap_mode = 2
|
||||
|
||||
[node name="TeamsSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="TeamsSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="TeamsRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="TeamsRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 20
|
||||
|
||||
[node name="Team0Panel" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow"]
|
||||
[node name="Team0Panel" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="Team0Header" type="Label" parent="CenterContainer/VBoxContainer/TeamsRow/Team0Panel"]
|
||||
[node name="Team0Header" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow/Team0Panel"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
text = "Team 1"
|
||||
|
||||
[node name="Team0List" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow/Team0Panel"]
|
||||
[node name="Team0List" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow/Team0Panel"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 2
|
||||
|
||||
[node name="TeamsVSeparator" type="VSeparator" parent="CenterContainer/VBoxContainer/TeamsRow"]
|
||||
[node name="TeamsVSeparator" type="VSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Team1Panel" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow"]
|
||||
[node name="Team1Panel" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="Team1Header" type="Label" parent="CenterContainer/VBoxContainer/TeamsRow/Team1Panel"]
|
||||
[node name="Team1Header" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow/Team1Panel"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
text = "Team 2"
|
||||
|
||||
[node name="Team1List" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow/Team1Panel"]
|
||||
[node name="Team1List" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow/Team1Panel"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 2
|
||||
|
||||
[node name="ControlsSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="ControlsSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="ControlsRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="ControlsRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="SwitchTeamButton" type="Button" parent="CenterContainer/VBoxContainer/ControlsRow"]
|
||||
[node name="SwitchTeamButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ControlsRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Switch Team"
|
||||
|
||||
[node name="ReadyButton" type="CheckButton" parent="CenterContainer/VBoxContainer/ControlsRow"]
|
||||
[node name="ReadyButton" type="CheckButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ControlsRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Ready"
|
||||
|
||||
[node name="LeaveButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="LeaveButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
text = "Leave"
|
||||
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/ControlsRow/SwitchTeamButton" to="." method="_on_switch_team_pressed"]
|
||||
[connection signal="toggled" from="CenterContainer/VBoxContainer/ControlsRow/ReadyButton" to="." method="_on_ready_toggled"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/LeaveButton" to="." method="_on_leave_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ControlsRow/SwitchTeamButton" to="." method="_on_switch_team_pressed"]
|
||||
[connection signal="toggled" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ControlsRow/ReadyButton" to="." method="_on_ready_toggled"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/LeaveButton" to="." method="_on_leave_pressed"]
|
||||
|
||||
+66
-51
@@ -13,124 +13,139 @@ grow_vertical = 2
|
||||
script = ExtResource("1_menu")
|
||||
theme = ExtResource("2_theme")
|
||||
|
||||
[node name="CenterContainer" type="CenterContainer" parent="."]
|
||||
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/margin_left = 24
|
||||
theme_override_constants/margin_top = 24
|
||||
theme_override_constants/margin_right = 24
|
||||
theme_override_constants/margin_bottom = 24
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"]
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
follow_focus = true
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/ScrollContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer"]
|
||||
custom_minimum_size = Vector2(420, 0)
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="TitleLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 48
|
||||
text = "Cosmic Clash"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="SubtitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="SubtitleLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
modulate = Color(1, 1, 1, 0.55)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 16
|
||||
text = "Physics-based soccer in space"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="TitleSpacer" type="Control" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="TitleSpacer" type="Control" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 14)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="FreePlayButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="FreePlayButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 56)
|
||||
layout_mode = 2
|
||||
text = "Free Play"
|
||||
|
||||
[node name="FreePlayHint" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="FreePlayHint" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
modulate = Color(1, 1, 1, 0.55)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "Solo practice — no timer, R resets the ball"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="ArenaRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="ArenaRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="ArenaLabel" type="Label" parent="CenterContainer/VBoxContainer/ArenaRow"]
|
||||
[node name="ArenaLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ArenaRow"]
|
||||
layout_mode = 2
|
||||
text = "Arena"
|
||||
|
||||
[node name="ArenaDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/ArenaRow"]
|
||||
[node name="ArenaDropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ArenaRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="MatchSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MatchSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="MatchHeader" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MatchHeader" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 22
|
||||
text = "Match"
|
||||
|
||||
[node name="MatchHint" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MatchHint" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
modulate = Color(1, 1, 1, 0.55)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "A 2:30 match — you vs a trained bot"
|
||||
|
||||
[node name="MatchRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MatchRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="DifficultyLabel" type="Label" parent="CenterContainer/VBoxContainer/MatchRow"]
|
||||
[node name="DifficultyLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/MatchRow"]
|
||||
layout_mode = 2
|
||||
text = "Difficulty"
|
||||
|
||||
[node name="DifficultyDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/MatchRow"]
|
||||
[node name="DifficultyDropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/MatchRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="MatchButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MatchButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 56)
|
||||
layout_mode = 2
|
||||
text = "Play Match"
|
||||
|
||||
[node name="MultiplayerSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MultiplayerSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="MultiplayerHeader" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MultiplayerHeader" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 22
|
||||
text = "Multiplayer"
|
||||
|
||||
[node name="MultiplayerHint" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MultiplayerHint" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
modulate = Color(1, 1, 1, 0.55)
|
||||
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"]
|
||||
[node name="FindMatchButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
text = "Find Match"
|
||||
|
||||
[node name="HostButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="HostButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
text = "Host"
|
||||
|
||||
[node name="JoinRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="JoinRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="JoinAddressEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/JoinRow"]
|
||||
[node name="JoinAddressEdit" type="LineEdit" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
@@ -138,12 +153,12 @@ size_flags_horizontal = 3
|
||||
text = "127.0.0.1"
|
||||
placeholder_text = "IP address"
|
||||
|
||||
[node name="JoinButton" type="Button" parent="CenterContainer/VBoxContainer/JoinRow"]
|
||||
[node name="JoinButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow"]
|
||||
custom_minimum_size = Vector2(96, 40)
|
||||
layout_mode = 2
|
||||
text = "Join"
|
||||
|
||||
[node name="MultiplayerErrorLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MultiplayerErrorLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
modulate = Color(1, 0.5, 0.5, 1)
|
||||
layout_mode = 2
|
||||
@@ -152,82 +167,82 @@ text = ""
|
||||
autowrap_mode = 2
|
||||
visible = false
|
||||
|
||||
[node name="SettingsSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="SettingsSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="SettingsButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="SettingsButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 56)
|
||||
layout_mode = 2
|
||||
text = "Settings"
|
||||
|
||||
[node name="DevSection" type="VBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="DevSection" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="DevSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="DevSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="DevHeader" type="Label" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="DevHeader" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 22
|
||||
text = "Developer"
|
||||
|
||||
[node name="DevHint" type="Label" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="DevHint" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
modulate = Color(1, 1, 1, 0.55)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "Dev-only — hidden in release builds"
|
||||
|
||||
[node name="DevOpponentRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="DevOpponentRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="DevOpponentLabel" type="Label" parent="CenterContainer/VBoxContainer/DevSection/DevOpponentRow"]
|
||||
[node name="DevOpponentLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/DevOpponentRow"]
|
||||
layout_mode = 2
|
||||
text = "Opponent override"
|
||||
|
||||
[node name="DevBotDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/DevSection/DevOpponentRow"]
|
||||
[node name="DevBotDropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/DevOpponentRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="SpectateSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="SpectateSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="SpectateHeader" type="Label" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="SpectateHeader" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 22
|
||||
text = "Spectate"
|
||||
|
||||
[node name="SpectateHint" type="Label" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="SpectateHint" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
modulate = Color(1, 1, 1, 0.55)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "Watch two bots play each other"
|
||||
|
||||
[node name="SpectateRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="SpectateRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="BotADropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/DevSection/SpectateRow"]
|
||||
[node name="BotADropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/SpectateRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="VsLabel" type="Label" parent="CenterContainer/VBoxContainer/DevSection/SpectateRow"]
|
||||
[node name="VsLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/SpectateRow"]
|
||||
layout_mode = 2
|
||||
text = "vs"
|
||||
|
||||
[node name="BotBDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/DevSection/SpectateRow"]
|
||||
[node name="BotBDropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/SpectateRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="SpectateButton" type="Button" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="SpectateButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
custom_minimum_size = Vector2(0, 56)
|
||||
layout_mode = 2
|
||||
text = "Watch Match"
|
||||
@@ -279,12 +294,12 @@ custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
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"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/SettingsButton" to="." method="_on_settings_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/DevSection/SpectateButton" to="." method="_on_spectate_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/FreePlayButton" to="." method="_on_free_play_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/MatchButton" to="." method="_on_match_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/FindMatchButton" to="." method="_on_find_match_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/HostButton" to="." method="_on_host_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow/JoinButton" to="." method="_on_join_pressed"]
|
||||
[connection signal="text_submitted" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow/JoinAddressEdit" to="." method="_on_join_address_submitted"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/SettingsButton" to="." method="_on_settings_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/SpectateButton" to="." method="_on_spectate_pressed"]
|
||||
[connection signal="pressed" from="ConnectingOverlay/CenterContainer/VBoxContainer/ConnectingCancelButton" to="." method="_on_connecting_cancel_pressed"]
|
||||
|
||||
@@ -13,45 +13,59 @@ grow_vertical = 2
|
||||
script = ExtResource("1_matchmaking")
|
||||
theme = ExtResource("2_theme")
|
||||
|
||||
[node name="CenterContainer" type="CenterContainer" parent="."]
|
||||
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/margin_left = 24
|
||||
theme_override_constants/margin_top = 24
|
||||
theme_override_constants/margin_right = 24
|
||||
theme_override_constants/margin_bottom = 24
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"]
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
follow_focus = true
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/ScrollContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer"]
|
||||
custom_minimum_size = Vector2(480, 0)
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="TitleLabel" type="Label" parent="MarginContainer/ScrollContainer/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"]
|
||||
[node name="PlaylistDropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="StatusLabel" type="Label" parent="MarginContainer/ScrollContainer/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"]
|
||||
[node name="DetailLabel" type="Label" parent="MarginContainer/ScrollContainer/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"]
|
||||
[node name="RankedProfileLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
modulate = Color(1, 1, 1, 0.65)
|
||||
layout_mode = 2
|
||||
@@ -59,24 +73,24 @@ text = "Ranked profile unavailable"
|
||||
horizontal_alignment = 1
|
||||
visible = false
|
||||
|
||||
[node name="QueueButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="QueueButton" type="Button" parent="MarginContainer/ScrollContainer/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"]
|
||||
[node name="CancelButton" type="Button" parent="MarginContainer/ScrollContainer/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"]
|
||||
[node name="ProposalRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="AcceptButton" type="Button" parent="CenterContainer/VBoxContainer/ProposalRow"]
|
||||
[node name="AcceptButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ProposalRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
@@ -84,7 +98,7 @@ size_flags_horizontal = 3
|
||||
text = "Accept"
|
||||
visible = false
|
||||
|
||||
[node name="DeclineButton" type="Button" parent="CenterContainer/VBoxContainer/ProposalRow"]
|
||||
[node name="DeclineButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ProposalRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
@@ -92,14 +106,14 @@ size_flags_horizontal = 3
|
||||
text = "Decline"
|
||||
visible = false
|
||||
|
||||
[node name="BackButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="BackButton" type="Button" parent="MarginContainer/ScrollContainer/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"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/QueueButton" to="." method="_on_queue_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/CancelButton" to="." method="_on_cancel_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ProposalRow/AcceptButton" to="." method="_on_accept_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ProposalRow/DeclineButton" to="." method="_on_decline_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/BackButton" to="." method="_on_back_pressed"]
|
||||
|
||||
+129
-45
@@ -1,7 +1,8 @@
|
||||
[gd_scene load_steps=3 format=3]
|
||||
[gd_scene load_steps=4 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"]
|
||||
[ext_resource type="Script" path="res://scripts/controls_settings.gd" id="3_controls"]
|
||||
|
||||
[node name="SettingsMenu" type="Control"]
|
||||
layout_mode = 3
|
||||
@@ -13,69 +14,88 @@ grow_vertical = 2
|
||||
script = ExtResource("1_settings")
|
||||
theme = ExtResource("2_theme")
|
||||
|
||||
[node name="CenterContainer" type="CenterContainer" parent="."]
|
||||
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/margin_left = 24
|
||||
theme_override_constants/margin_top = 24
|
||||
theme_override_constants/margin_right = 24
|
||||
theme_override_constants/margin_bottom = 24
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"]
|
||||
custom_minimum_size = Vector2(420, 0)
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="TitleLabel" type="Label" parent="MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 36
|
||||
text = "Settings"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="TitleSpacer" type="Control" parent="CenterContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 14)
|
||||
[node name="TabContainer" type="TabContainer" parent="MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
tab_alignment = 1
|
||||
|
||||
[node name="PresetRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="Video" type="ScrollContainer" parent="MarginContainer/VBoxContainer/TabContainer"]
|
||||
layout_mode = 2
|
||||
follow_focus = true
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer"]
|
||||
custom_minimum_size = Vector2(420, 0)
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="PresetLabel" type="Label" parent="CenterContainer/VBoxContainer/PresetRow"]
|
||||
[node name="PresetRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="PresetLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/PresetRow"]
|
||||
custom_minimum_size = Vector2(110, 0)
|
||||
layout_mode = 2
|
||||
text = "Graphics preset"
|
||||
|
||||
[node name="PresetDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/PresetRow"]
|
||||
[node name="PresetDropdown" type="OptionButton" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/PresetRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="AARow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="AARow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="AALabel" type="Label" parent="CenterContainer/VBoxContainer/AARow"]
|
||||
[node name="AALabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/AARow"]
|
||||
custom_minimum_size = Vector2(110, 0)
|
||||
layout_mode = 2
|
||||
text = "Anti-aliasing"
|
||||
|
||||
[node name="AADropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/AARow"]
|
||||
[node name="AADropdown" type="OptionButton" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/AARow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="ResolutionRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="ResolutionRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="ResolutionLabel" type="Label" parent="CenterContainer/VBoxContainer/ResolutionRow"]
|
||||
[node name="ResolutionLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/ResolutionRow"]
|
||||
custom_minimum_size = Vector2(110, 0)
|
||||
layout_mode = 2
|
||||
text = "Resolution scale"
|
||||
|
||||
[node name="ResolutionSlider" type="HSlider" parent="CenterContainer/VBoxContainer/ResolutionRow"]
|
||||
[node name="ResolutionSlider" type="HSlider" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/ResolutionRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 24)
|
||||
layout_mode = 2
|
||||
@@ -86,23 +106,23 @@ max_value = 1.0
|
||||
step = 0.05
|
||||
value = 1.0
|
||||
|
||||
[node name="ResolutionValueLabel" type="Label" parent="CenterContainer/VBoxContainer/ResolutionRow"]
|
||||
[node name="ResolutionValueLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/ResolutionRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(48, 0)
|
||||
layout_mode = 2
|
||||
text = "100%"
|
||||
horizontal_alignment = 2
|
||||
|
||||
[node name="GlowRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="GlowRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="GlowLabel" type="Label" parent="CenterContainer/VBoxContainer/GlowRow"]
|
||||
[node name="GlowLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/GlowRow"]
|
||||
custom_minimum_size = Vector2(110, 0)
|
||||
layout_mode = 2
|
||||
text = "Glow intensity"
|
||||
|
||||
[node name="GlowSlider" type="HSlider" parent="CenterContainer/VBoxContainer/GlowRow"]
|
||||
[node name="GlowSlider" type="HSlider" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/GlowRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 24)
|
||||
layout_mode = 2
|
||||
@@ -113,23 +133,23 @@ max_value = 1.5
|
||||
step = 0.05
|
||||
value = 1.0
|
||||
|
||||
[node name="GlowValueLabel" type="Label" parent="CenterContainer/VBoxContainer/GlowRow"]
|
||||
[node name="GlowValueLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/GlowRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(48, 0)
|
||||
layout_mode = 2
|
||||
text = "100%"
|
||||
horizontal_alignment = 2
|
||||
|
||||
[node name="BrightnessRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="BrightnessRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="BrightnessLabel" type="Label" parent="CenterContainer/VBoxContainer/BrightnessRow"]
|
||||
[node name="BrightnessLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/BrightnessRow"]
|
||||
custom_minimum_size = Vector2(110, 0)
|
||||
layout_mode = 2
|
||||
text = "Brightness"
|
||||
|
||||
[node name="BrightnessSlider" type="HSlider" parent="CenterContainer/VBoxContainer/BrightnessRow"]
|
||||
[node name="BrightnessSlider" type="HSlider" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/BrightnessRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 24)
|
||||
layout_mode = 2
|
||||
@@ -140,72 +160,136 @@ max_value = 1.3
|
||||
step = 0.02
|
||||
value = 1.0
|
||||
|
||||
[node name="BrightnessValueLabel" type="Label" parent="CenterContainer/VBoxContainer/BrightnessRow"]
|
||||
[node name="BrightnessValueLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/BrightnessRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(48, 0)
|
||||
layout_mode = 2
|
||||
text = "100%"
|
||||
horizontal_alignment = 2
|
||||
|
||||
[node name="VsyncRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="VsyncRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="VsyncLabel" type="Label" parent="CenterContainer/VBoxContainer/VsyncRow"]
|
||||
[node name="VsyncLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/VsyncRow"]
|
||||
custom_minimum_size = Vector2(110, 0)
|
||||
layout_mode = 2
|
||||
text = "VSync"
|
||||
|
||||
[node name="VsyncDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/VsyncRow"]
|
||||
[node name="VsyncDropdown" type="OptionButton" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/VsyncRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="FpsCapRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="FpsCapRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="FpsCapLabel" type="Label" parent="CenterContainer/VBoxContainer/FpsCapRow"]
|
||||
[node name="FpsCapLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/FpsCapRow"]
|
||||
custom_minimum_size = Vector2(110, 0)
|
||||
layout_mode = 2
|
||||
text = "FPS cap"
|
||||
|
||||
[node name="FpsCapDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/FpsCapRow"]
|
||||
[node name="FpsCapDropdown" type="OptionButton" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/FpsCapRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="FpsReadoutRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="FpsReadoutRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="FpsReadoutTitleLabel" type="Label" parent="CenterContainer/VBoxContainer/FpsReadoutRow"]
|
||||
[node name="FpsReadoutTitleLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/FpsReadoutRow"]
|
||||
custom_minimum_size = Vector2(110, 0)
|
||||
layout_mode = 2
|
||||
text = "Current"
|
||||
|
||||
[node name="FpsReadoutLabel" type="Label" parent="CenterContainer/VBoxContainer/FpsReadoutRow"]
|
||||
[node name="FpsReadoutLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/FpsReadoutRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "0 fps"
|
||||
|
||||
[node name="ButtonSpacer" type="Control" parent="CenterContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 14)
|
||||
[node name="Controls" type="ScrollContainer" parent="MarginContainer/VBoxContainer/TabContainer"]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
follow_focus = true
|
||||
horizontal_scroll_mode = 0
|
||||
script = ExtResource("3_controls")
|
||||
|
||||
[node name="BackButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/VBoxContainer/TabContainer/Controls"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer"]
|
||||
custom_minimum_size = Vector2(520, 0)
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="DeviceRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="DeviceLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer/DeviceRow"]
|
||||
custom_minimum_size = Vector2(160, 0)
|
||||
layout_mode = 2
|
||||
text = "Device"
|
||||
|
||||
[node name="KeyboardButton" type="Button" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer/DeviceRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
toggle_mode = true
|
||||
button_pressed = true
|
||||
text = "Keyboard"
|
||||
|
||||
[node name="ControllerButton" type="Button" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer/DeviceRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
toggle_mode = true
|
||||
text = "Controller"
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
modulate = Color(1, 0.85, 0.5, 1)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = ""
|
||||
autowrap_mode = 2
|
||||
|
||||
[node name="BindingList" type="VBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 6
|
||||
|
||||
[node name="InvertPitchCheck" type="CheckBox" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
text = "Invert pitch"
|
||||
|
||||
[node name="ResetButton" type="Button" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 44)
|
||||
layout_mode = 2
|
||||
text = "Reset all bindings to defaults"
|
||||
|
||||
[node name="BackButton" type="Button" parent="MarginContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 56)
|
||||
layout_mode = 2
|
||||
text = "Back"
|
||||
|
||||
[connection signal="item_selected" from="CenterContainer/VBoxContainer/PresetRow/PresetDropdown" to="." method="_on_preset_dropdown_item_selected"]
|
||||
[connection signal="item_selected" from="CenterContainer/VBoxContainer/AARow/AADropdown" to="." method="_on_aa_dropdown_item_selected"]
|
||||
[connection signal="value_changed" from="CenterContainer/VBoxContainer/ResolutionRow/ResolutionSlider" to="." method="_on_resolution_slider_value_changed"]
|
||||
[connection signal="value_changed" from="CenterContainer/VBoxContainer/GlowRow/GlowSlider" to="." method="_on_glow_slider_value_changed"]
|
||||
[connection signal="value_changed" from="CenterContainer/VBoxContainer/BrightnessRow/BrightnessSlider" to="." method="_on_brightness_slider_value_changed"]
|
||||
[connection signal="item_selected" from="CenterContainer/VBoxContainer/VsyncRow/VsyncDropdown" to="." method="_on_vsync_dropdown_item_selected"]
|
||||
[connection signal="item_selected" from="CenterContainer/VBoxContainer/FpsCapRow/FpsCapDropdown" to="." method="_on_fps_cap_dropdown_item_selected"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/BackButton" to="." method="_on_back_pressed"]
|
||||
[connection signal="item_selected" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/PresetRow/PresetDropdown" to="." method="_on_preset_dropdown_item_selected"]
|
||||
[connection signal="item_selected" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/AARow/AADropdown" to="." method="_on_aa_dropdown_item_selected"]
|
||||
[connection signal="value_changed" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/ResolutionRow/ResolutionSlider" to="." method="_on_resolution_slider_value_changed"]
|
||||
[connection signal="value_changed" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/GlowRow/GlowSlider" to="." method="_on_glow_slider_value_changed"]
|
||||
[connection signal="value_changed" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/BrightnessRow/BrightnessSlider" to="." method="_on_brightness_slider_value_changed"]
|
||||
[connection signal="item_selected" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/VsyncRow/VsyncDropdown" to="." method="_on_vsync_dropdown_item_selected"]
|
||||
[connection signal="item_selected" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/FpsCapRow/FpsCapDropdown" to="." method="_on_fps_cap_dropdown_item_selected"]
|
||||
[connection signal="pressed" from="MarginContainer/VBoxContainer/BackButton" to="." method="_on_back_pressed"]
|
||||
|
||||
@@ -12,6 +12,21 @@ 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:
|
||||
@@ -33,8 +48,29 @@ func is_available() -> bool:
|
||||
return not _base_url.is_empty()
|
||||
|
||||
|
||||
func start_health() -> void:
|
||||
if not is_available() or _health_timer != null:
|
||||
# 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"
|
||||
@@ -46,6 +82,10 @@ func start_health() -> void:
|
||||
_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()
|
||||
@@ -76,9 +116,20 @@ static func annotation_is_valid(key: String, value: String) -> bool:
|
||||
|
||||
|
||||
func _send_health() -> void:
|
||||
if _health_in_flight or not is_available():
|
||||
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:
|
||||
|
||||
@@ -6,6 +6,8 @@ extends Node
|
||||
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)
|
||||
@@ -13,6 +15,10 @@ 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
|
||||
|
||||
@@ -154,6 +160,21 @@ func _connect_when_assigned(match_id: String) -> void:
|
||||
_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()
|
||||
@@ -215,6 +236,50 @@ func queue_create(ticket_id: String, playlist: String, client_build: String, pro
|
||||
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
|
||||
@@ -592,6 +657,17 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
extends ScrollContainer
|
||||
|
||||
# Settings screen's Controls tab: rebinds every action in InputSettings.ACTIONS
|
||||
# for either device, toggles invert-pitch, and resets to the project.godot
|
||||
# defaults. InputSettings owns the bindings themselves and their persistence;
|
||||
# this script is only the editor for them, and deliberately keeps
|
||||
# settings_menu.gd video-only.
|
||||
#
|
||||
# Rows are built in code rather than laid out in settings.tscn so the list stays
|
||||
# derived from InputSettings.ACTIONS — adding a rebindable action means editing
|
||||
# that one const, not this scene as well.
|
||||
|
||||
# A joypad axis has to travel this far before a capture accepts it. Resting
|
||||
# stick drift is routinely a few percent off centre and would otherwise bind
|
||||
# itself the instant the player opened a capture.
|
||||
const AXIS_CAPTURE_THRESHOLD := 0.5
|
||||
|
||||
@onready var keyboard_button: Button = %KeyboardButton
|
||||
@onready var controller_button: Button = %ControllerButton
|
||||
@onready var status_label: Label = %StatusLabel
|
||||
@onready var binding_list: VBoxContainer = %BindingList
|
||||
@onready var invert_pitch_check: CheckBox = %InvertPitchCheck
|
||||
@onready var reset_button: Button = %ResetButton
|
||||
|
||||
var _device: String = InputSettings.DEVICE_KEYBOARD
|
||||
# The action currently awaiting an input event, or "" when not capturing.
|
||||
var _capturing: String = ""
|
||||
# action -> the row's Button, so a rebuild-free label refresh is possible and
|
||||
# so capture can restore the right button's text on cancel.
|
||||
var _row_buttons: Dictionary = {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
keyboard_button.pressed.connect(_on_device_selected.bind(InputSettings.DEVICE_KEYBOARD))
|
||||
controller_button.pressed.connect(_on_device_selected.bind(InputSettings.DEVICE_JOYPAD))
|
||||
invert_pitch_check.toggled.connect(_on_invert_pitch_toggled)
|
||||
reset_button.pressed.connect(_on_reset_pressed)
|
||||
invert_pitch_check.button_pressed = InputSettings.invert_pitch
|
||||
_update_device_buttons()
|
||||
_rebuild_rows()
|
||||
|
||||
|
||||
func _on_device_selected(device: String) -> void:
|
||||
_cancel_capture()
|
||||
_device = device
|
||||
_update_device_buttons()
|
||||
_rebuild_rows()
|
||||
|
||||
|
||||
func _update_device_buttons() -> void:
|
||||
keyboard_button.button_pressed = _device == InputSettings.DEVICE_KEYBOARD
|
||||
controller_button.button_pressed = _device == InputSettings.DEVICE_JOYPAD
|
||||
|
||||
|
||||
func _rebuild_rows() -> void:
|
||||
for child in binding_list.get_children():
|
||||
child.queue_free()
|
||||
_row_buttons.clear()
|
||||
|
||||
var last_group := ""
|
||||
for entry in InputSettings.ACTIONS:
|
||||
var group: String = entry["group"]
|
||||
if group != last_group:
|
||||
last_group = group
|
||||
binding_list.add_child(_make_group_header(group))
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 10)
|
||||
|
||||
var label := Label.new()
|
||||
label.text = entry["label"]
|
||||
label.custom_minimum_size = Vector2(200, 0)
|
||||
row.add_child(label)
|
||||
|
||||
var button := Button.new()
|
||||
var action: String = entry["action"]
|
||||
button.text = InputSettings.binding_text(action, _device)
|
||||
button.custom_minimum_size = Vector2(0, 36)
|
||||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
button.clip_text = true
|
||||
button.pressed.connect(_begin_capture.bind(action))
|
||||
row.add_child(button)
|
||||
_row_buttons[action] = button
|
||||
|
||||
binding_list.add_child(row)
|
||||
|
||||
# Re-bound after every rebuild: the rows above are new nodes each time, so
|
||||
# the buttons AudioManager was tracking no longer exist.
|
||||
AudioManager.bind_tree_buttons(self)
|
||||
|
||||
|
||||
func _make_group_header(group: String) -> Label:
|
||||
var header := Label.new()
|
||||
header.text = group
|
||||
header.add_theme_font_size_override("font_size", 18)
|
||||
header.modulate = Color(1, 1, 1, 0.7)
|
||||
return header
|
||||
|
||||
|
||||
func _begin_capture(action: String) -> void:
|
||||
_cancel_capture()
|
||||
_capturing = action
|
||||
var button: Button = _row_buttons[action]
|
||||
button.text = "Press a key…" if _device == InputSettings.DEVICE_KEYBOARD else "Press a button…"
|
||||
status_label.text = "Listening — press Escape to cancel."
|
||||
|
||||
|
||||
func _cancel_capture() -> void:
|
||||
if _capturing == "":
|
||||
return
|
||||
var action := _capturing
|
||||
_capturing = ""
|
||||
if _row_buttons.has(action) and is_instance_valid(_row_buttons[action]):
|
||||
_row_buttons[action].text = InputSettings.binding_text(action, _device)
|
||||
status_label.text = ""
|
||||
|
||||
|
||||
# _input rather than _unhandled_input: the row Button has focus while capturing,
|
||||
# and an unhandled-input handler would never see the key that Button consumes as
|
||||
# its own activation. Everything consumed here is marked handled so the pending
|
||||
# event cannot also re-press that button and re-enter capture.
|
||||
func _input(event: InputEvent) -> void:
|
||||
if _capturing == "":
|
||||
return
|
||||
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
get_viewport().set_input_as_handled()
|
||||
_cancel_capture()
|
||||
return
|
||||
|
||||
var captured := _capturable_event(event)
|
||||
if captured == null:
|
||||
return
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
var action := _capturing
|
||||
_capturing = ""
|
||||
var displaced := InputSettings.set_binding(action, captured)
|
||||
_rebuild_rows()
|
||||
if displaced.is_empty():
|
||||
status_label.text = ""
|
||||
else:
|
||||
status_label.text = "Unbound %s — it was using the same input." % ", ".join(_labels_for(displaced))
|
||||
|
||||
|
||||
# Returns the event to bind, or null if this event is not a legal binding for
|
||||
# the device kind currently being edited. Keeping the check here means a joypad
|
||||
# press can never land in the keyboard column just because that tab was open.
|
||||
func _capturable_event(event: InputEvent) -> InputEvent:
|
||||
if _device == InputSettings.DEVICE_KEYBOARD:
|
||||
if event is InputEventKey and event.pressed and not event.echo:
|
||||
var key := InputEventKey.new()
|
||||
key.physical_keycode = event.physical_keycode
|
||||
return key
|
||||
return null
|
||||
|
||||
if event is InputEventJoypadButton and event.pressed:
|
||||
var button := InputEventJoypadButton.new()
|
||||
button.button_index = event.button_index
|
||||
return button
|
||||
if event is InputEventJoypadMotion and absf(event.axis_value) >= AXIS_CAPTURE_THRESHOLD:
|
||||
var motion := InputEventJoypadMotion.new()
|
||||
motion.axis = event.axis
|
||||
motion.axis_value = signf(event.axis_value)
|
||||
return motion
|
||||
return null
|
||||
|
||||
|
||||
func _labels_for(actions: PackedStringArray) -> PackedStringArray:
|
||||
var out := PackedStringArray()
|
||||
for action in actions:
|
||||
for entry in InputSettings.ACTIONS:
|
||||
if entry["action"] == action:
|
||||
out.append(entry["label"])
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
func _on_invert_pitch_toggled(pressed: bool) -> void:
|
||||
InputSettings.invert_pitch = pressed
|
||||
|
||||
|
||||
func _on_reset_pressed() -> void:
|
||||
_cancel_capture()
|
||||
InputSettings.reset_all()
|
||||
invert_pitch_check.button_pressed = InputSettings.invert_pitch
|
||||
_rebuild_rows()
|
||||
status_label.text = "Bindings reset to defaults."
|
||||
@@ -0,0 +1 @@
|
||||
uid://dk7plirjfqvld
|
||||
@@ -276,7 +276,11 @@ func _reset_body(body: RigidBody3D, to: Transform3D) -> void:
|
||||
|
||||
|
||||
func _unhandled_input(event):
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
# leave_gameplay (Escape / Start), NOT ui_cancel. ui_cancel carries the B
|
||||
# button so menus behave the way a controller player expects, and B is far
|
||||
# too easy to hit by accident for "abandon the match you are playing".
|
||||
# Menus and the lobby still use ui_cancel; only live gameplay is guarded.
|
||||
if event.is_action_pressed("leave_gameplay"):
|
||||
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
extends Node
|
||||
|
||||
# Autoload: persisted keyboard/controller bindings on top of project.godot's
|
||||
# [input] defaults, plus the flight-feel preferences that belong with them
|
||||
# (invert pitch). The Settings screen's Controls tab (controls_settings.gd) is
|
||||
# the only writer; PlayerShipController is the only reader of pitch_sign().
|
||||
#
|
||||
# project.godot stays the single source of truth for *defaults*: _ready()
|
||||
# snapshots whatever InputMap holds at boot, before any override is applied, so
|
||||
# the default table is never duplicated in GDScript and can never drift from the
|
||||
# file. An override is only ever a delta on top of that snapshot.
|
||||
#
|
||||
# Persisted to user://input.cfg rather than user://settings.cfg, deliberately.
|
||||
# VideoSettings.save() builds a fresh ConfigFile and writes it, which would drop
|
||||
# every section it does not itself know about — so two autoloads sharing one
|
||||
# file would silently erase each other. A separate file sidesteps that entirely
|
||||
# instead of coupling the two save paths.
|
||||
|
||||
# Each action is bound at most once per device kind. That is a deliberate
|
||||
# simplification of Godot's arbitrary-length event list: it makes a rebind row
|
||||
# a single button rather than an editable list, and makes "what is X bound to?"
|
||||
# answerable. The consequence is that applying a binding replaces the whole
|
||||
# event list for that action (see apply()), so anything project.godot binds
|
||||
# beyond one keyboard + one joypad event per action would be dropped here.
|
||||
const DEVICE_KEYBOARD := "keyboard"
|
||||
const DEVICE_JOYPAD := "joypad"
|
||||
|
||||
const SETTINGS_PATH := "user://input.cfg"
|
||||
|
||||
# The rebindable action list, and the only place the Controls tab and the tests
|
||||
# read it from. Order is display order. Actions NOT listed here (ui_*, the F3/F4
|
||||
# debug overlays) are deliberately not rebindable.
|
||||
const ACTIONS := [
|
||||
{"action": "move_forward", "label": "Thrust forward", "group": "Flight"},
|
||||
{"action": "move_back", "label": "Thrust backward", "group": "Flight"},
|
||||
{"action": "move_left", "label": "Strafe left", "group": "Flight"},
|
||||
{"action": "move_right", "label": "Strafe right", "group": "Flight"},
|
||||
{"action": "move_up", "label": "Thrust up", "group": "Flight"},
|
||||
{"action": "move_down", "label": "Thrust down", "group": "Flight"},
|
||||
{"action": "turbo", "label": "Turbo", "group": "Flight"},
|
||||
{"action": "turn_left", "label": "Yaw left", "group": "Attitude"},
|
||||
{"action": "turn_right", "label": "Yaw right", "group": "Attitude"},
|
||||
{"action": "pitch_up", "label": "Pitch up", "group": "Attitude"},
|
||||
{"action": "pitch_down", "label": "Pitch down", "group": "Attitude"},
|
||||
{"action": "roll_left", "label": "Roll left", "group": "Attitude"},
|
||||
{"action": "roll_right", "label": "Roll right", "group": "Attitude"},
|
||||
{"action": "toggle_ball_cam", "label": "Ball camera", "group": "Other"},
|
||||
{"action": "reset_ball", "label": "Reset ball (Free Play)", "group": "Other"},
|
||||
]
|
||||
|
||||
# button_index -> label, using the Xbox names the default map is expressed in.
|
||||
# InputEvent.as_text() renders these as "Joypad Button 9 (Left Shoulder)", which
|
||||
# is both long and wrong-looking in a rebind row.
|
||||
const JOY_BUTTON_NAMES := {
|
||||
JOY_BUTTON_A: "A", JOY_BUTTON_B: "B", JOY_BUTTON_X: "X", JOY_BUTTON_Y: "Y",
|
||||
JOY_BUTTON_BACK: "Back", JOY_BUTTON_GUIDE: "Guide", JOY_BUTTON_START: "Start",
|
||||
JOY_BUTTON_LEFT_STICK: "L3", JOY_BUTTON_RIGHT_STICK: "R3",
|
||||
JOY_BUTTON_LEFT_SHOULDER: "LB", JOY_BUTTON_RIGHT_SHOULDER: "RB",
|
||||
JOY_BUTTON_DPAD_UP: "D-Pad Up", JOY_BUTTON_DPAD_DOWN: "D-Pad Down",
|
||||
JOY_BUTTON_DPAD_LEFT: "D-Pad Left", JOY_BUTTON_DPAD_RIGHT: "D-Pad Right",
|
||||
}
|
||||
|
||||
# axis -> [label at negative deflection, label at positive deflection]. The
|
||||
# triggers rest at 0 and only travel positive, so their negative half is never
|
||||
# a reachable binding and is labelled as such rather than as a direction.
|
||||
const JOY_AXIS_NAMES := {
|
||||
JOY_AXIS_LEFT_X: ["Left Stick Left", "Left Stick Right"],
|
||||
JOY_AXIS_LEFT_Y: ["Left Stick Up", "Left Stick Down"],
|
||||
JOY_AXIS_RIGHT_X: ["Right Stick Left", "Right Stick Right"],
|
||||
JOY_AXIS_RIGHT_Y: ["Right Stick Up", "Right Stick Down"],
|
||||
JOY_AXIS_TRIGGER_LEFT: ["LT", "LT"],
|
||||
JOY_AXIS_TRIGGER_RIGHT: ["RT", "RT"],
|
||||
}
|
||||
|
||||
signal bindings_changed
|
||||
|
||||
# Push the right stick forward and the nose goes down (flight-sim). Ticking this
|
||||
# flips it. Applied in PlayerShipController rather than by rewriting the
|
||||
# bindings, so it stays one preference instead of two swapped rows the player
|
||||
# then has to reason about.
|
||||
var invert_pitch: bool = false
|
||||
|
||||
# action -> {DEVICE_KEYBOARD: InputEvent|null, DEVICE_JOYPAD: InputEvent|null},
|
||||
# snapshotted from InputMap at boot before any override lands.
|
||||
var _defaults: Dictionary = {}
|
||||
# Same shape, but only for actions the player has actually customised. A device
|
||||
# key that is absent means "still using the default"; a device key present with
|
||||
# null means "the player deliberately unbound it".
|
||||
var _overrides: Dictionary = {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_capture_defaults()
|
||||
_load()
|
||||
apply()
|
||||
|
||||
|
||||
# Reads project.godot's [input] back out of InputMap. Anything that is neither a
|
||||
# key nor a joypad button/motion event (mouse buttons, say) is ignored rather
|
||||
# than mis-filed under a device kind it does not belong to.
|
||||
func _capture_defaults() -> void:
|
||||
_defaults.clear()
|
||||
for entry in ACTIONS:
|
||||
var action: String = entry["action"]
|
||||
var slots := {DEVICE_KEYBOARD: null, DEVICE_JOYPAD: null}
|
||||
if InputMap.has_action(action):
|
||||
for event in InputMap.action_get_events(action):
|
||||
var kind := device_kind_of(event)
|
||||
if kind != "" and slots[kind] == null:
|
||||
slots[kind] = event
|
||||
_defaults[action] = slots
|
||||
|
||||
|
||||
# "" for an event this system cannot express (mouse, gesture, MIDI), which is
|
||||
# also the signal to callers that it is not a legal binding.
|
||||
static func device_kind_of(event: InputEvent) -> String:
|
||||
if event is InputEventKey:
|
||||
return DEVICE_KEYBOARD
|
||||
if event is InputEventJoypadButton or event is InputEventJoypadMotion:
|
||||
return DEVICE_JOYPAD
|
||||
return ""
|
||||
|
||||
|
||||
func _load() -> void:
|
||||
_overrides.clear()
|
||||
var cfg := ConfigFile.new()
|
||||
if cfg.load(SETTINGS_PATH) != OK:
|
||||
return
|
||||
invert_pitch = cfg.get_value("input", "invert_pitch", invert_pitch)
|
||||
for entry in ACTIONS:
|
||||
var action: String = entry["action"]
|
||||
for device in [DEVICE_KEYBOARD, DEVICE_JOYPAD]:
|
||||
var key := "%s.%s" % [action, device]
|
||||
if not cfg.has_section_key("bindings", key):
|
||||
continue
|
||||
var stored = cfg.get_value("bindings", key)
|
||||
if not (stored is Dictionary):
|
||||
continue
|
||||
# An empty dict is the "deliberately unbound" sentinel — see save().
|
||||
if stored.is_empty():
|
||||
_set_override(action, device, null)
|
||||
continue
|
||||
var event := event_from_dict(stored)
|
||||
if event != null:
|
||||
_set_override(action, device, event)
|
||||
|
||||
|
||||
func save() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
cfg.set_value("input", "invert_pitch", invert_pitch)
|
||||
for action in _overrides:
|
||||
var slots: Dictionary = _overrides[action]
|
||||
for device in slots:
|
||||
var event: InputEvent = slots[device]
|
||||
var key := "%s.%s" % [action, device]
|
||||
# An unbound override is written as an empty dict, NOT as null:
|
||||
# ConfigFile.set_value() treats a null value as "erase this key", so
|
||||
# storing null would drop the entry and the next load would fall
|
||||
# back to the project default — silently rebinding something the
|
||||
# player had deliberately cleared.
|
||||
cfg.set_value("bindings", key, {} if event == null else event_to_dict(event))
|
||||
cfg.save(SETTINGS_PATH)
|
||||
|
||||
|
||||
# Rebuilds InputMap for every rebindable action from defaults + overrides. Runs
|
||||
# wholesale rather than incrementally so there is exactly one code path that
|
||||
# decides what an action is bound to, whatever route got us here.
|
||||
func apply() -> void:
|
||||
for entry in ACTIONS:
|
||||
var action: String = entry["action"]
|
||||
if not InputMap.has_action(action):
|
||||
continue
|
||||
InputMap.action_erase_events(action)
|
||||
for device in [DEVICE_KEYBOARD, DEVICE_JOYPAD]:
|
||||
var event := get_binding(action, device)
|
||||
if event != null:
|
||||
InputMap.action_add_event(action, event)
|
||||
bindings_changed.emit()
|
||||
|
||||
|
||||
func get_binding(action: String, device: String) -> InputEvent:
|
||||
if _overrides.has(action) and _overrides[action].has(device):
|
||||
return _overrides[action][device]
|
||||
if _defaults.has(action):
|
||||
return _defaults[action][device]
|
||||
return null
|
||||
|
||||
|
||||
func get_default_binding(action: String, device: String) -> InputEvent:
|
||||
if not _defaults.has(action):
|
||||
return null
|
||||
return _defaults[action][device]
|
||||
|
||||
|
||||
# Binds `event` to `action`, replacing whatever that action had for the event's
|
||||
# own device kind. Returns the actions that were unbound to avoid a duplicate,
|
||||
# so the caller can say so rather than leaving the player to discover it.
|
||||
func set_binding(action: String, event: InputEvent) -> PackedStringArray:
|
||||
var device := device_kind_of(event)
|
||||
if device == "":
|
||||
return PackedStringArray()
|
||||
var displaced := find_conflicts(event, action)
|
||||
for other in displaced:
|
||||
_set_override(other, device, null)
|
||||
_set_override(action, device, event)
|
||||
apply()
|
||||
return displaced
|
||||
|
||||
|
||||
func clear_binding(action: String, device: String) -> void:
|
||||
_set_override(action, device, null)
|
||||
apply()
|
||||
|
||||
|
||||
# Actions already bound to an equivalent event, excluding `except_action`.
|
||||
# Compared by value rather than by object identity — the event coming out of a
|
||||
# rebind capture is a different instance from the one in the map.
|
||||
func find_conflicts(event: InputEvent, except_action: String = "") -> PackedStringArray:
|
||||
var device := device_kind_of(event)
|
||||
var out := PackedStringArray()
|
||||
if device == "":
|
||||
return out
|
||||
for entry in ACTIONS:
|
||||
var action: String = entry["action"]
|
||||
if action == except_action:
|
||||
continue
|
||||
var bound := get_binding(action, device)
|
||||
if bound != null and events_match(bound, event):
|
||||
out.append(action)
|
||||
return out
|
||||
|
||||
|
||||
# Equality by the fields a binding is identified by. Deliberately not
|
||||
# InputEvent.is_match(): for an axis that ignores axis_value, which would make
|
||||
# "Right Stick Up" and "Right Stick Down" collide as the same binding.
|
||||
static func events_match(a: InputEvent, b: InputEvent) -> bool:
|
||||
if a is InputEventKey and b is InputEventKey:
|
||||
return a.physical_keycode == b.physical_keycode
|
||||
if a is InputEventJoypadButton and b is InputEventJoypadButton:
|
||||
return a.button_index == b.button_index
|
||||
if a is InputEventJoypadMotion and b is InputEventJoypadMotion:
|
||||
return a.axis == b.axis and signf(a.axis_value) == signf(b.axis_value)
|
||||
return false
|
||||
|
||||
|
||||
func reset_action(action: String) -> void:
|
||||
_overrides.erase(action)
|
||||
apply()
|
||||
|
||||
|
||||
func reset_all() -> void:
|
||||
_overrides.clear()
|
||||
invert_pitch = false
|
||||
apply()
|
||||
|
||||
|
||||
func has_override(action: String) -> bool:
|
||||
return _overrides.has(action)
|
||||
|
||||
|
||||
func pitch_sign() -> float:
|
||||
return -1.0 if invert_pitch else 1.0
|
||||
|
||||
|
||||
func _set_override(action: String, device: String, event: InputEvent) -> void:
|
||||
if not _overrides.has(action):
|
||||
_overrides[action] = {}
|
||||
_overrides[action][device] = event
|
||||
|
||||
|
||||
# ConfigFile stores Dictionary values natively, so bindings persist as plain
|
||||
# data. Never the Object(...) literal Godot writes into project.godot — that
|
||||
# form is only parsed by the engine's own project-file loader, and round-tripping
|
||||
# it through user:// would be storing engine-internal syntax in a save file.
|
||||
static func event_to_dict(event: InputEvent) -> Dictionary:
|
||||
if event is InputEventKey:
|
||||
return {"type": "key", "physical_keycode": int(event.physical_keycode)}
|
||||
if event is InputEventJoypadButton:
|
||||
return {"type": "joy_button", "button_index": int(event.button_index)}
|
||||
if event is InputEventJoypadMotion:
|
||||
return {"type": "joy_axis", "axis": int(event.axis), "value": float(signf(event.axis_value))}
|
||||
return {}
|
||||
|
||||
|
||||
# Returns null for anything unrecognised, so a save file from a newer build (or
|
||||
# a hand-edited one) degrades to "this action is unbound" rather than crashing
|
||||
# the game before the player can reach the Controls tab to fix it.
|
||||
static func event_from_dict(data: Dictionary) -> InputEvent:
|
||||
match data.get("type", ""):
|
||||
"key":
|
||||
var key := InputEventKey.new()
|
||||
key.physical_keycode = int(data.get("physical_keycode", 0))
|
||||
return key if key.physical_keycode != 0 else null
|
||||
"joy_button":
|
||||
var button := InputEventJoypadButton.new()
|
||||
button.button_index = int(data.get("button_index", -1))
|
||||
return button if button.button_index >= 0 else null
|
||||
"joy_axis":
|
||||
var motion := InputEventJoypadMotion.new()
|
||||
motion.axis = int(data.get("axis", -1))
|
||||
motion.axis_value = signf(float(data.get("value", 0.0)))
|
||||
return motion if motion.axis >= 0 and motion.axis_value != 0.0 else null
|
||||
return null
|
||||
|
||||
|
||||
func event_to_text(event: InputEvent) -> String:
|
||||
if event == null:
|
||||
return "Unbound"
|
||||
if event is InputEventKey:
|
||||
# Physical keycodes throughout, so the label matches the key's position
|
||||
# on a non-QWERTY layout the same way the binding itself does. The
|
||||
# headless display server has no keyboard layout to consult and pushes
|
||||
# an ERROR for the attempt — which the ENet smoke gate treats as a
|
||||
# failure on sight — so fall back to the unmapped keycode there.
|
||||
var keycode: int = event.physical_keycode
|
||||
if DisplayServer.get_name() != "headless":
|
||||
keycode = DisplayServer.keyboard_get_keycode_from_physical(keycode)
|
||||
return OS.get_keycode_string(keycode)
|
||||
if event is InputEventJoypadButton:
|
||||
return JOY_BUTTON_NAMES.get(event.button_index, "Button %d" % event.button_index)
|
||||
if event is InputEventJoypadMotion:
|
||||
if JOY_AXIS_NAMES.has(event.axis):
|
||||
return JOY_AXIS_NAMES[event.axis][0 if event.axis_value < 0.0 else 1]
|
||||
return "Axis %d%s" % [event.axis, "-" if event.axis_value < 0.0 else "+"]
|
||||
return event.as_text()
|
||||
|
||||
|
||||
func binding_text(action: String, device: String) -> String:
|
||||
return event_to_text(get_binding(action, device))
|
||||
@@ -0,0 +1 @@
|
||||
uid://bjtdsbem7kwdv
|
||||
@@ -52,7 +52,7 @@ func _ready() -> void:
|
||||
_populate_dropdown(bot_b_dropdown, bots, GameSettings.spectate_bot_b_path)
|
||||
NetworkManager.connected_to_server.connect(_on_connected_to_server)
|
||||
NetworkManager.connection_failed.connect(_on_connection_failed)
|
||||
$CenterContainer/VBoxContainer/FreePlayButton.grab_focus()
|
||||
%FreePlayButton.grab_focus()
|
||||
|
||||
|
||||
# main_menu.gd's first async flow (task 1.7): Host is synchronous
|
||||
@@ -120,6 +120,11 @@ func _list_bots() -> Array[String]:
|
||||
# disk, else the newest (last) bot.
|
||||
func _populate_dropdown(dropdown: OptionButton, bots: Array[String], preferred_path: String, include_none: bool = false) -> void:
|
||||
dropdown.clear()
|
||||
# These are filled from whatever checkpoints happen to be in res://bots, so
|
||||
# a long filename would otherwise widen the OptionButton (size_flags_h =
|
||||
# EXPAND_FILL) and drag the whole menu past its 420px minimum width.
|
||||
dropdown.clip_text = true
|
||||
dropdown.fit_to_longest_item = false
|
||||
if include_none:
|
||||
dropdown.add_item("(Use difficulty)")
|
||||
dropdown.set_item_metadata(0, "")
|
||||
@@ -173,7 +178,7 @@ func _on_match_pressed() -> void:
|
||||
|
||||
|
||||
func _on_settings_pressed() -> void:
|
||||
get_tree().change_scene_to_file("res://scenes/settings.tscn")
|
||||
get_tree().change_scene_to_file(ScenePaths.SETTINGS)
|
||||
|
||||
|
||||
func _on_spectate_pressed() -> void:
|
||||
|
||||
@@ -67,7 +67,11 @@ 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 = {}
|
||||
var _join_signing_key := PackedByteArray()
|
||||
# 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()
|
||||
@@ -109,7 +113,7 @@ func _on_shutting_down() -> void:
|
||||
_active_join_peers.clear()
|
||||
_join_history.clear()
|
||||
_join_authorisation_context.clear()
|
||||
_join_signing_key = PackedByteArray()
|
||||
_join_signing_keys = {}
|
||||
_connection_lease_claim = Callable()
|
||||
_connection_lease_disconnect = Callable()
|
||||
_result_submit = Callable()
|
||||
@@ -117,7 +121,9 @@ func _on_shutting_down() -> void:
|
||||
admissions_open = true
|
||||
|
||||
|
||||
func configure_join_authorisations(tokens: Array, context: Dictionary, signing_key: PackedByteArray = PackedByteArray()) -> bool:
|
||||
# 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():
|
||||
@@ -127,7 +133,12 @@ func configure_join_authorisations(tokens: Array, context: Dictionary, signing_k
|
||||
return false
|
||||
_allowed_join_authorisations = allowed
|
||||
_join_authorisation_context = context.duplicate(true)
|
||||
_join_signing_key = signing_key.duplicate()
|
||||
_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
|
||||
|
||||
@@ -372,24 +383,37 @@ func _valid_join_authorisation(token: String) -> bool:
|
||||
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_key.is_empty():
|
||||
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,
|
||||
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, _join_signing_key)
|
||||
hmac.start(HashingContext.HASH_SHA256, signing_key)
|
||||
hmac.update(canonical)
|
||||
if hmac.finish() != signature:
|
||||
return false
|
||||
|
||||
@@ -18,6 +18,13 @@ const RECOVERY_POLL_SECONDS := 2.0
|
||||
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:
|
||||
@@ -31,10 +38,59 @@ func _ready() -> void:
|
||||
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
|
||||
@@ -54,6 +110,10 @@ func _process(delta: float) -> void:
|
||||
|
||||
|
||||
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:
|
||||
@@ -71,11 +131,73 @@ func _on_queue_pressed() -> void:
|
||||
_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
|
||||
|
||||
@@ -2216,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),
|
||||
|
||||
@@ -10,30 +10,39 @@ var _action := ShipAction.new()
|
||||
func get_action() -> ShipAction:
|
||||
# Full overwrite per axis (not +=/-=): _action is reused across ticks, so
|
||||
# fields must not depend on starting from a fresh Vector3.ZERO each call.
|
||||
#
|
||||
# Input.get_axis(negative, positive) is strength(positive) -
|
||||
# strength(negative), so these keep the exact sign conventions the digital
|
||||
# version had while becoming proportional on a controller:
|
||||
# get_action_strength() returns a flat 1.0 for a held key but the
|
||||
# normalised past-deadzone deflection for an InputEventJoypadMotion. A
|
||||
# half-pulled trigger is therefore half thrust, and keyboard flight is
|
||||
# unchanged down to the value.
|
||||
|
||||
# Forward/Backward thrust (main engines)
|
||||
_action.thrust.z = (1.0 if Input.is_action_pressed("move_forward") else 0.0) \
|
||||
- (1.0 if Input.is_action_pressed("move_back") else 0.0)
|
||||
_action.thrust.z = Input.get_axis("move_back", "move_forward")
|
||||
|
||||
# Strafe thrusters (left/right)
|
||||
_action.thrust.x = (1.0 if Input.is_action_pressed("move_right") else 0.0) \
|
||||
- (1.0 if Input.is_action_pressed("move_left") else 0.0)
|
||||
_action.thrust.x = Input.get_axis("move_left", "move_right")
|
||||
|
||||
# Vertical thrusters (up/down)
|
||||
_action.thrust.y = (1.0 if Input.is_action_pressed("move_up") else 0.0) \
|
||||
- (1.0 if Input.is_action_pressed("move_down") else 0.0)
|
||||
_action.thrust.y = Input.get_axis("move_down", "move_up")
|
||||
|
||||
# Yaw (turn left/right around Y axis)
|
||||
_action.rotation.y = (1.0 if Input.is_action_pressed("turn_left") else 0.0) \
|
||||
- (1.0 if Input.is_action_pressed("turn_right") else 0.0)
|
||||
_action.rotation.y = Input.get_axis("turn_right", "turn_left")
|
||||
|
||||
# Pitch (nose up/down around X axis)
|
||||
_action.rotation.x = (1.0 if Input.is_action_pressed("pitch_down") else 0.0) \
|
||||
- (1.0 if Input.is_action_pressed("pitch_up") else 0.0)
|
||||
# Pitch (nose up/down around X axis). Positive rotation.x is nose-UP:
|
||||
# torque about local +X rotates the ship's up vector toward its tail by the
|
||||
# right-hand rule, which lifts the nose (measured, not assumed). The
|
||||
# argument order here used to be reversed, so "pitch_down" pitched up and
|
||||
# the I/K keys were each labelled as the opposite of what they did.
|
||||
# The default binding then gives flight-sim polarity — right stick forward
|
||||
# is pitch_down is nose down — and InputSettings holds the player's
|
||||
# preference for flipping that.
|
||||
_action.rotation.x = Input.get_axis("pitch_down", "pitch_up") * InputSettings.pitch_sign()
|
||||
|
||||
# Roll (bank left/right around Z axis)
|
||||
_action.rotation.z = (1.0 if Input.is_action_pressed("roll_left") else 0.0) \
|
||||
- (1.0 if Input.is_action_pressed("roll_right") else 0.0)
|
||||
_action.rotation.z = Input.get_axis("roll_right", "roll_left")
|
||||
|
||||
_action.turbo = Input.is_action_pressed("turbo")
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class_name ScenePaths
|
||||
|
||||
const MAIN_MENU := "res://scenes/main_menu.tscn"
|
||||
const SETTINGS := "res://scenes/settings.tscn"
|
||||
# §6.2 step 10: after RESULTS both peers return HERE, not to the main menu —
|
||||
# a community server whose players are all dumped back to their own menus
|
||||
# every 2.5 minutes has no way to keep a lobby together.
|
||||
|
||||
+63
-20
@@ -73,26 +73,12 @@ func _ready() -> void:
|
||||
printerr("cosmic-clash-server: allocated transport '%s' is not supported by this build" % assigned_transport)
|
||||
get_tree().quit(1)
|
||||
return
|
||||
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_key := FileAccess.get_file_as_bytes(key_file)
|
||||
var roster_tokens = JSON.parse_string(roster_json)
|
||||
if not roster_tokens is Array or roster_tokens.is_empty() or signing_key.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_key) 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
|
||||
# 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()
|
||||
# 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")))
|
||||
_control.name = "ServerControl"
|
||||
_control.drain_requested.connect(_on_drain_requested)
|
||||
_control.initial_connect_ready.connect(_on_initial_connect_ready)
|
||||
@@ -102,11 +88,41 @@ func _ready() -> void:
|
||||
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"
|
||||
get_tree().root.add_child.call_deferred(_agones)
|
||||
# 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")
|
||||
@@ -253,3 +269,30 @@ static func required_min_players(allocated: bool, roster_size: int, configured:
|
||||
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,9 +1,10 @@
|
||||
class_name ServerControl
|
||||
extends Node
|
||||
|
||||
# Small loopback HTTP control surface for allocated servers. The Go supervisor
|
||||
# uses GET /ready as the explicit process-ready probe and POST /drain during a
|
||||
# controlled termination. Direct/community servers do not start this 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
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
extends Control
|
||||
|
||||
# Settings screen: player-facing video knobs on top of VideoSettings (the
|
||||
# Settings screen root, owning the Video tab and the shared Back button. The
|
||||
# Controls tab has its own script (controls_settings.gd) so this file stays
|
||||
# video-only; both tabs' state is committed in _on_back_pressed below.
|
||||
#
|
||||
# Video tab: player-facing video knobs on top of VideoSettings (the
|
||||
# autoload holding + persisting them). Preset/AA/vsync/fps-cap/resolution
|
||||
# scale apply immediately since they're Viewport- or DisplayServer-wide;
|
||||
# glow/brightness/shadow/SDFGI/SSIL/SSAO apply the next time an arena loads
|
||||
@@ -205,6 +209,10 @@ func _mark_custom_if_user_driven() -> void:
|
||||
|
||||
func _on_back_pressed() -> void:
|
||||
VideoSettings.save()
|
||||
# Bindings are applied live as the player rebinds them (InputSettings.apply
|
||||
# runs on every change) but are only committed to disk here, matching how
|
||||
# the video knobs behave.
|
||||
InputSettings.save()
|
||||
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
||||
|
||||
|
||||
|
||||
+18
-9
@@ -15,7 +15,7 @@ const SimConstants = preload("res://scripts/sim_constants.gd")
|
||||
@export var vertical_thrust = 120.0 # Up/down thruster power
|
||||
@export var turbo_multiplier = 2.5 # Turbo boost multiplier
|
||||
@export var max_speed = 35.0 # Maximum velocity
|
||||
@export var rotation_power = 20.0 # Angular thrust power
|
||||
@export var rotation_acceleration = 20.0 # Angular acceleration, rad/s^2, equal on all three axes (see apply_rotation_forces)
|
||||
@export var max_angular_speed = 3.0 # Maximum rotation speed
|
||||
@export var drag_coefficient = 0.98 # Linear drag (air resistance)
|
||||
@export var angular_drag = 0.95 # Rotational drag
|
||||
@@ -557,18 +557,27 @@ func apply_rotation_forces(state: PhysicsDirectBodyState3D, rotation_input: Vect
|
||||
if rotation_input.length() < 0.01:
|
||||
return
|
||||
|
||||
# Apply torque for rotation - simple and effective
|
||||
# Physics: τ = I * α (torque = moment of inertia × angular acceleration)
|
||||
# Also: α = τ / I (angular acceleration = torque / moment of inertia)
|
||||
# Lower inertia = higher angular acceleration for same torque
|
||||
# Scaling each axis by its own inertia makes rotation_acceleration mean
|
||||
# exactly that — α, in rad/s² — so all three axes respond identically.
|
||||
# ship.tscn's inertia is Vector3(7, 1, 7): a flat torque across all three
|
||||
# axes therefore used to give yaw 7x the angular acceleration of pitch and
|
||||
# roll (172 deg/s vs 52 deg/s at steady state). That was an accident of the
|
||||
# inertia tensor rather than a design decision, and it read as "rotation is
|
||||
# sluggish except when turning".
|
||||
var torque = Vector3(
|
||||
rotation_input.x * rotation_power, # Pitch (rotation around X-axis)
|
||||
rotation_input.y * rotation_power, # Yaw (rotation around Y-axis)
|
||||
rotation_input.z * rotation_power # Roll (rotation around Z-axis)
|
||||
rotation_input.x * rotation_acceleration * inertia.x, # Pitch (local X)
|
||||
rotation_input.y * rotation_acceleration * inertia.y, # Yaw (local Y)
|
||||
rotation_input.z * rotation_acceleration * inertia.z # Roll (local Z)
|
||||
)
|
||||
|
||||
# Physics: Δω = τ * Δt / I (change in angular velocity = torque × time / inertia)
|
||||
state.apply_torque(torque)
|
||||
# apply_torque() is world-space, and the vector above is in the ship's own
|
||||
# frame, so it MUST be rotated by the hull's basis — exactly as thrust is
|
||||
# (see the -ship_basis.z term in apply_thrust_forces). Without this the
|
||||
# ship rotated about the world axes: roll input became pitch once the ship
|
||||
# had yawed 90 degrees, and both roll and pitch inverted at 180 degrees, so
|
||||
# the controls were correct flying up-field and backwards flying back.
|
||||
state.apply_torque(state.transform.basis * torque)
|
||||
|
||||
|
||||
# Scales a per-tick decay multiplier `k` (defined at a 60 Hz reference rate)
|
||||
|
||||
@@ -110,7 +110,9 @@ func _exit_tree() -> void:
|
||||
|
||||
|
||||
func _input(event):
|
||||
if event.is_action_pressed("ui_accept"): # Enter key
|
||||
# A dedicated action rather than ui_accept, so the camera toggle is
|
||||
# rebindable and A stays purely a menu-confirm button. Space / R3.
|
||||
if event.is_action_pressed("toggle_ball_cam"):
|
||||
ball_cam_enabled = !ball_cam_enabled
|
||||
camera_mode_changed.emit(ball_cam_enabled)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,33 +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")
|
||||
quit(1)
|
||||
return
|
||||
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")
|
||||
quit(1)
|
||||
return
|
||||
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])
|
||||
quit(1)
|
||||
return
|
||||
print("Agones SDK smoke passed")
|
||||
fake_sidecar.stop()
|
||||
quit(0)
|
||||
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
|
||||
|
||||
@@ -17,3 +17,40 @@ func test_annotation_validation_rejects_header_injection_and_oversized_values()
|
||||
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()
|
||||
|
||||
@@ -508,3 +508,75 @@ func test_ranked_profile_projects_and_bounds_season_countdown() -> void:
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
# Guards the input map and the InputSettings remap layer.
|
||||
#
|
||||
# The defect this file exists for: project.godot bound joypad events to only 5
|
||||
# of the 13 flight actions, so a controller could yaw/pitch/roll/turbo but could
|
||||
# not translate at all. Nothing failed, because nothing asserted that a *pair*
|
||||
# of bindings exists — the actions were all present and the game booted fine.
|
||||
# test_every_action_has_both_a_keyboard_and_a_joypad_binding is that assertion,
|
||||
# and it can tell "bound on both devices" from "bound on one", which is the
|
||||
# distinction that was actually missing.
|
||||
#
|
||||
# Several of these tests write to the global InputMap through InputSettings, so
|
||||
# each one must restore it before returning or it corrupts every later case in
|
||||
# the run (the runner shares one process). reset_all() is the restore.
|
||||
|
||||
const CONTROLLER_ONLY_ACTIONS := ["toggle_ball_cam", "reset_ball"]
|
||||
|
||||
|
||||
func _joypad_events(action: String) -> Array:
|
||||
var out := []
|
||||
for event in InputMap.action_get_events(action):
|
||||
if InputSettings.device_kind_of(event) == InputSettings.DEVICE_JOYPAD:
|
||||
out.append(event)
|
||||
return out
|
||||
|
||||
|
||||
func _keyboard_events(action: String) -> Array:
|
||||
var out := []
|
||||
for event in InputMap.action_get_events(action):
|
||||
if InputSettings.device_kind_of(event) == InputSettings.DEVICE_KEYBOARD:
|
||||
out.append(event)
|
||||
return out
|
||||
|
||||
|
||||
func test_every_rebindable_action_exists() -> void:
|
||||
for entry in InputSettings.ACTIONS:
|
||||
assert_true(InputMap.has_action(entry["action"]), "InputMap has action %s" % entry["action"])
|
||||
|
||||
|
||||
func test_every_action_has_both_a_keyboard_and_a_joypad_binding() -> void:
|
||||
# The regression itself: a controller player must be able to reach every
|
||||
# action without touching the keyboard, and vice versa.
|
||||
for entry in InputSettings.ACTIONS:
|
||||
var action: String = entry["action"]
|
||||
assert_true(not _keyboard_events(action).is_empty(), "%s has a keyboard binding" % action)
|
||||
assert_true(not _joypad_events(action).is_empty(), "%s has a joypad binding" % action)
|
||||
|
||||
|
||||
func test_apply_is_lossless_against_the_project_defaults() -> void:
|
||||
# InputSettings stores one binding per device per action, so apply()
|
||||
# rewrites each action's event list to exactly [keyboard, joypad]. If
|
||||
# project.godot ever gains a second keyboard event for a rebindable action,
|
||||
# booting the game would silently drop it — the action would still work, on
|
||||
# fewer keys than the file says. Asserting apply() is a no-op over the
|
||||
# defaults is what distinguishes "bindings intact" from "bindings quietly
|
||||
# trimmed", which counting events cannot do.
|
||||
InputSettings.reset_all()
|
||||
var before := {}
|
||||
for entry in InputSettings.ACTIONS:
|
||||
before[entry["action"]] = InputMap.action_get_events(entry["action"]).size()
|
||||
|
||||
InputSettings.apply()
|
||||
|
||||
for entry in InputSettings.ACTIONS:
|
||||
var action: String = entry["action"]
|
||||
assert_eq(
|
||||
InputMap.action_get_events(action).size(),
|
||||
before[action],
|
||||
"%s keeps every event across apply()" % action
|
||||
)
|
||||
assert_eq(before[action], 2, "%s has exactly one keyboard and one joypad event" % action)
|
||||
|
||||
|
||||
func test_no_two_actions_share_a_joypad_binding() -> void:
|
||||
# Two flight actions sharing one input is silently unplayable rather than an
|
||||
# error, and it is easy to reintroduce: an early draft of this layout had A
|
||||
# as both turbo and thrust-up, and B as both thrust-down and ui_cancel.
|
||||
for entry in InputSettings.ACTIONS:
|
||||
var action: String = entry["action"]
|
||||
var bound := InputSettings.get_binding(action, InputSettings.DEVICE_JOYPAD)
|
||||
assert_true(bound != null, "%s resolves a joypad binding" % action)
|
||||
if bound == null:
|
||||
continue
|
||||
var conflicts := InputSettings.find_conflicts(bound, action)
|
||||
assert_true(
|
||||
conflicts.is_empty(),
|
||||
"%s's joypad binding is unique (also on: %s)" % [action, ", ".join(conflicts)]
|
||||
)
|
||||
|
||||
|
||||
func test_no_two_actions_share_a_keyboard_binding() -> void:
|
||||
for entry in InputSettings.ACTIONS:
|
||||
var action: String = entry["action"]
|
||||
var bound := InputSettings.get_binding(action, InputSettings.DEVICE_KEYBOARD)
|
||||
assert_true(bound != null, "%s resolves a keyboard binding" % action)
|
||||
if bound == null:
|
||||
continue
|
||||
var conflicts := InputSettings.find_conflicts(bound, action)
|
||||
assert_true(
|
||||
conflicts.is_empty(),
|
||||
"%s's keyboard binding is unique (also on: %s)" % [action, ", ".join(conflicts)]
|
||||
)
|
||||
|
||||
|
||||
func _joypad_buttons(action: String) -> Array:
|
||||
var out := []
|
||||
for event in InputMap.action_get_events(action):
|
||||
if event is InputEventJoypadButton:
|
||||
out.append(event.button_index)
|
||||
return out
|
||||
|
||||
|
||||
func test_menus_are_usable_with_a_controller() -> void:
|
||||
# Godot 4.7 ships ui_up/down/left/right with D-pad and stick events but
|
||||
# gives ui_accept and ui_cancel NO joypad binding at all (verified against a
|
||||
# pristine project). A controller could therefore move the highlight around
|
||||
# the main menu and never press anything — the menu looked responsive, which
|
||||
# is exactly why it went unnoticed. project.godot binds them explicitly.
|
||||
assert_true(JOY_BUTTON_A in _joypad_buttons("ui_accept"), "A confirms in menus")
|
||||
assert_true(JOY_BUTTON_B in _joypad_buttons("ui_cancel"), "B goes back in menus")
|
||||
# Navigation is the engine default, but assert it so a future override of
|
||||
# these actions cannot silently strand a controller player again.
|
||||
for action in ["ui_up", "ui_down", "ui_left", "ui_right"]:
|
||||
var pad := 0
|
||||
for event in InputMap.action_get_events(action):
|
||||
if event is InputEventJoypadButton or event is InputEventJoypadMotion:
|
||||
pad += 1
|
||||
assert_true(pad > 0, "%s is reachable on a controller" % action)
|
||||
|
||||
|
||||
func test_leaving_gameplay_is_not_on_a_face_button() -> void:
|
||||
# game_mode.gd exits to the main menu on leave_gameplay, deliberately NOT on
|
||||
# ui_cancel: ui_cancel carries B so menus behave conventionally, and B is far
|
||||
# too easy to hit by accident to also mean "abandon this match". The two
|
||||
# actions being distinct is the whole point, so assert they really differ.
|
||||
assert_true(InputMap.has_action("leave_gameplay"), "leave_gameplay exists")
|
||||
var buttons := _joypad_buttons("leave_gameplay")
|
||||
assert_true(JOY_BUTTON_START in buttons, "Start leaves gameplay")
|
||||
for face in [JOY_BUTTON_A, JOY_BUTTON_B, JOY_BUTTON_X, JOY_BUTTON_Y]:
|
||||
assert_true(face not in buttons, "leave_gameplay must not use a face button")
|
||||
|
||||
|
||||
func test_ball_cam_has_its_own_action_off_ui_accept() -> void:
|
||||
# Ball-cam used to ride ui_accept, which project.godot now binds to A for
|
||||
# menu confirmation. A dedicated action keeps A purely a menu button and
|
||||
# lets the camera toggle be rebound like anything else.
|
||||
assert_true(InputMap.has_action("toggle_ball_cam"), "toggle_ball_cam exists")
|
||||
var joypad := _joypad_events("toggle_ball_cam")
|
||||
assert_true(joypad.size() == 1, "toggle_ball_cam has one joypad binding")
|
||||
if joypad.size() == 1:
|
||||
assert_true(
|
||||
joypad[0] is InputEventJoypadButton and joypad[0].button_index != JOY_BUTTON_A,
|
||||
"toggle_ball_cam is not on A (menu confirm)"
|
||||
)
|
||||
|
||||
|
||||
func test_thrust_is_on_the_triggers() -> void:
|
||||
# The requested layout, asserted where it is load-bearing: triggers are the
|
||||
# only analog inputs on the thrust axis, so binding them to buttons instead
|
||||
# would silently cost proportional throttle without failing anything.
|
||||
var forward := _joypad_events("move_forward")
|
||||
var back := _joypad_events("move_back")
|
||||
assert_true(forward.size() == 1 and forward[0] is InputEventJoypadMotion, "move_forward is an axis")
|
||||
assert_true(back.size() == 1 and back[0] is InputEventJoypadMotion, "move_back is an axis")
|
||||
if forward.size() == 1 and forward[0] is InputEventJoypadMotion:
|
||||
assert_eq(forward[0].axis, JOY_AXIS_TRIGGER_RIGHT, "move_forward axis")
|
||||
if back.size() == 1 and back[0] is InputEventJoypadMotion:
|
||||
assert_eq(back[0].axis, JOY_AXIS_TRIGGER_LEFT, "move_back axis")
|
||||
|
||||
|
||||
func test_pitch_is_on_the_left_stick_nose_down_when_pushed_forward() -> void:
|
||||
var down := _joypad_events("pitch_down")
|
||||
var up := _joypad_events("pitch_up")
|
||||
assert_true(down.size() == 1 and down[0] is InputEventJoypadMotion, "pitch_down is an axis")
|
||||
assert_true(up.size() == 1 and up[0] is InputEventJoypadMotion, "pitch_up is an axis")
|
||||
if down.size() == 1 and down[0] is InputEventJoypadMotion:
|
||||
assert_eq(down[0].axis, JOY_AXIS_LEFT_Y, "pitch_down axis")
|
||||
# Godot reports a stick pushed away from the player as negative Y.
|
||||
assert_true(down[0].axis_value < 0.0, "stick forward pitches the nose down")
|
||||
if up.size() == 1 and up[0] is InputEventJoypadMotion:
|
||||
assert_eq(up[0].axis, JOY_AXIS_LEFT_Y, "pitch_up axis")
|
||||
assert_true(up[0].axis_value > 0.0, "stick back pitches the nose up")
|
||||
|
||||
|
||||
func test_all_rotation_lives_on_the_left_stick() -> void:
|
||||
# Yaw and pitch belong on the same stick. Splitting them across two sticks
|
||||
# (yaw left, pitch right) is playable in the sense that every input works,
|
||||
# so nothing here failed when it was wrong — it just felt broken, because
|
||||
# each stick had a dead axis. Asserting both are on the right stick is what
|
||||
# pins the 6DOF convention down.
|
||||
for action in ["turn_left", "turn_right"]:
|
||||
var events := _joypad_events(action)
|
||||
assert_true(events.size() == 1 and events[0] is InputEventJoypadMotion, "%s is an axis" % action)
|
||||
if events.size() == 1 and events[0] is InputEventJoypadMotion:
|
||||
assert_eq(events[0].axis, JOY_AXIS_LEFT_X, "%s axis" % action)
|
||||
for action in ["pitch_up", "pitch_down"]:
|
||||
var events := _joypad_events(action)
|
||||
if events.size() == 1 and events[0] is InputEventJoypadMotion:
|
||||
assert_eq(events[0].axis, JOY_AXIS_LEFT_Y, "%s axis" % action)
|
||||
|
||||
|
||||
func test_translation_is_analog_on_the_right_stick_and_triggers() -> void:
|
||||
# Six degrees of freedom onto the pad's six analog axes. Strafe and
|
||||
# vertical were digital buttons at first, which cost proportional control
|
||||
# without failing anything — a button binding here still "works", it just
|
||||
# gives full power or nothing, so only checking the event type catches it.
|
||||
var expected := {
|
||||
"move_left": JOY_AXIS_RIGHT_X, "move_right": JOY_AXIS_RIGHT_X,
|
||||
"move_up": JOY_AXIS_RIGHT_Y, "move_down": JOY_AXIS_RIGHT_Y,
|
||||
"move_forward": JOY_AXIS_TRIGGER_RIGHT, "move_back": JOY_AXIS_TRIGGER_LEFT,
|
||||
}
|
||||
for action in expected:
|
||||
var events := _joypad_events(action)
|
||||
assert_true(
|
||||
events.size() == 1 and events[0] is InputEventJoypadMotion,
|
||||
"%s is analog, not a button" % action
|
||||
)
|
||||
if events.size() == 1 and events[0] is InputEventJoypadMotion:
|
||||
assert_eq(events[0].axis, expected[action], "%s axis" % action)
|
||||
|
||||
|
||||
func test_pushing_the_right_stick_up_thrusts_up() -> void:
|
||||
# Godot reports a stick pushed away from the player as negative Y, so the
|
||||
# intuitive direction needs the negative half — easy to get backwards, and
|
||||
# inverted vertical thrust is not something any other assertion notices.
|
||||
var up := _joypad_events("move_up")
|
||||
var down := _joypad_events("move_down")
|
||||
if up.size() == 1 and up[0] is InputEventJoypadMotion:
|
||||
assert_true(up[0].axis_value < 0.0, "stick up thrusts up")
|
||||
if down.size() == 1 and down[0] is InputEventJoypadMotion:
|
||||
assert_true(down[0].axis_value > 0.0, "stick down thrusts down")
|
||||
|
||||
|
||||
func test_roll_is_on_the_shoulder_buttons() -> void:
|
||||
var expected := {"roll_left": JOY_BUTTON_LEFT_SHOULDER, "roll_right": JOY_BUTTON_RIGHT_SHOULDER}
|
||||
for action in expected:
|
||||
var events := _joypad_events(action)
|
||||
assert_true(events.size() == 1 and events[0] is InputEventJoypadButton, "%s is a button" % action)
|
||||
if events.size() == 1 and events[0] is InputEventJoypadButton:
|
||||
assert_eq(events[0].button_index, expected[action], "%s button" % action)
|
||||
|
||||
|
||||
func test_the_face_buttons_are_free_for_menus() -> void:
|
||||
# A/B/X/Y carry no flight action, which is what lets ui_accept keep A and
|
||||
# keeps a stray face-button press from doing something during a match.
|
||||
for entry in InputSettings.ACTIONS:
|
||||
var bound := InputSettings.get_binding(entry["action"], InputSettings.DEVICE_JOYPAD)
|
||||
if bound is InputEventJoypadButton:
|
||||
assert_true(
|
||||
bound.button_index not in [JOY_BUTTON_A, JOY_BUTTON_B, JOY_BUTTON_X, JOY_BUTTON_Y],
|
||||
"%s must not use a face button" % entry["action"]
|
||||
)
|
||||
|
||||
|
||||
func test_event_dict_round_trip_preserves_every_default() -> void:
|
||||
for entry in InputSettings.ACTIONS:
|
||||
for device in [InputSettings.DEVICE_KEYBOARD, InputSettings.DEVICE_JOYPAD]:
|
||||
var original := InputSettings.get_default_binding(entry["action"], device)
|
||||
assert_true(original != null, "%s/%s has a default" % [entry["action"], device])
|
||||
if original == null:
|
||||
continue
|
||||
var restored := InputSettings.event_from_dict(InputSettings.event_to_dict(original))
|
||||
assert_true(restored != null, "%s/%s round-trips to an event" % [entry["action"], device])
|
||||
if restored != null:
|
||||
assert_true(
|
||||
InputSettings.events_match(original, restored),
|
||||
"%s/%s round-trips to an equal event" % [entry["action"], device]
|
||||
)
|
||||
|
||||
|
||||
func test_event_from_dict_rejects_junk() -> void:
|
||||
# A save file from a newer build, or a hand-edited one, must degrade to
|
||||
# "unbound" rather than taking the game down before the player can reach
|
||||
# the Controls tab to fix it.
|
||||
assert_true(InputSettings.event_from_dict({}) == null, "empty dict is not an event")
|
||||
assert_true(InputSettings.event_from_dict({"type": "mouse"}) == null, "unknown type is not an event")
|
||||
assert_true(InputSettings.event_from_dict({"type": "key"}) == null, "keycode-less key is not an event")
|
||||
assert_true(
|
||||
InputSettings.event_from_dict({"type": "joy_axis", "axis": 3, "value": 0.0}) == null,
|
||||
"a centred axis is not an event"
|
||||
)
|
||||
|
||||
|
||||
func test_axis_bindings_are_distinguished_by_direction() -> void:
|
||||
# events_match must NOT collapse the two halves of one axis, or binding
|
||||
# pitch-up would silently unbind pitch-down as a "conflict".
|
||||
var up := InputEventJoypadMotion.new()
|
||||
up.axis = JOY_AXIS_RIGHT_Y
|
||||
up.axis_value = 1.0
|
||||
var down := InputEventJoypadMotion.new()
|
||||
down.axis = JOY_AXIS_RIGHT_Y
|
||||
down.axis_value = -1.0
|
||||
assert_true(not InputSettings.events_match(up, down), "opposite axis halves are different bindings")
|
||||
assert_true(InputSettings.events_match(up, up), "an axis binding matches itself")
|
||||
|
||||
|
||||
func test_set_binding_changes_the_live_input_map() -> void:
|
||||
var rebound := InputEventKey.new()
|
||||
rebound.physical_keycode = KEY_F # not used by any default binding
|
||||
InputSettings.set_binding("move_forward", rebound)
|
||||
|
||||
var found := false
|
||||
for event in InputMap.action_get_events("move_forward"):
|
||||
if event is InputEventKey and event.physical_keycode == KEY_F:
|
||||
found = true
|
||||
assert_true(found, "the rebound key reaches InputMap")
|
||||
assert_true(InputSettings.has_override("move_forward"), "the rebind is recorded as an override")
|
||||
|
||||
# The joypad half must survive a keyboard-only rebind.
|
||||
assert_true(not _joypad_events("move_forward").is_empty(), "rebinding the key keeps the trigger")
|
||||
|
||||
InputSettings.reset_all()
|
||||
|
||||
|
||||
func test_set_binding_displaces_the_conflicting_action() -> void:
|
||||
# Binding X to an input already in use must report and clear the previous
|
||||
# owner, not leave both bound and let the player wonder why two things fire.
|
||||
var shared := InputSettings.get_binding("move_left", InputSettings.DEVICE_KEYBOARD)
|
||||
assert_true(shared != null, "move_left has a keyboard binding to steal")
|
||||
if shared == null:
|
||||
return
|
||||
|
||||
var displaced := InputSettings.set_binding("move_right", shared)
|
||||
assert_true(displaced.has("move_left"), "the displaced action is reported")
|
||||
assert_true(
|
||||
InputSettings.get_binding("move_left", InputSettings.DEVICE_KEYBOARD) == null,
|
||||
"the displaced action is actually unbound"
|
||||
)
|
||||
|
||||
InputSettings.reset_all()
|
||||
|
||||
|
||||
func test_reset_all_restores_the_project_defaults() -> void:
|
||||
var before := InputSettings.binding_text("move_up", InputSettings.DEVICE_JOYPAD)
|
||||
var rebound := InputEventJoypadButton.new()
|
||||
rebound.button_index = JOY_BUTTON_BACK
|
||||
InputSettings.set_binding("move_up", rebound)
|
||||
assert_true(
|
||||
InputSettings.binding_text("move_up", InputSettings.DEVICE_JOYPAD) != before,
|
||||
"the rebind took effect"
|
||||
)
|
||||
|
||||
InputSettings.reset_all()
|
||||
assert_eq(
|
||||
InputSettings.binding_text("move_up", InputSettings.DEVICE_JOYPAD),
|
||||
before,
|
||||
"reset_all restores the default binding"
|
||||
)
|
||||
assert_true(not InputSettings.has_override("move_up"), "reset_all clears the override")
|
||||
|
||||
|
||||
func test_bindings_survive_a_save_and_reload() -> void:
|
||||
# The end-to-end persistence path, which nothing else covers: a rebind that
|
||||
# does not survive a restart is the single most visible way this feature can
|
||||
# fail, and it fails silently — the game runs fine, just on the defaults.
|
||||
#
|
||||
# This writes the real user://input.cfg, so the player's own file is saved
|
||||
# and put back. Restoring it is not optional: the test suite shares a
|
||||
# user:// directory with the game.
|
||||
var had_file := FileAccess.file_exists(InputSettings.SETTINGS_PATH)
|
||||
var original := ""
|
||||
if had_file:
|
||||
original = FileAccess.get_file_as_string(InputSettings.SETTINGS_PATH)
|
||||
|
||||
var rebound := InputEventJoypadButton.new()
|
||||
rebound.button_index = JOY_BUTTON_BACK
|
||||
InputSettings.set_binding("turbo", rebound)
|
||||
InputSettings.invert_pitch = true
|
||||
InputSettings.save()
|
||||
|
||||
# Drop the in-memory state the way a fresh launch would, then reload.
|
||||
InputSettings.reset_all()
|
||||
assert_true(not InputSettings.has_override("turbo"), "state cleared before reload")
|
||||
InputSettings._load()
|
||||
InputSettings.apply()
|
||||
|
||||
assert_true(InputSettings.has_override("turbo"), "the override came back from disk")
|
||||
var loaded := InputSettings.get_binding("turbo", InputSettings.DEVICE_JOYPAD)
|
||||
assert_true(loaded != null, "the reloaded binding is an event")
|
||||
if loaded != null:
|
||||
assert_true(InputSettings.events_match(loaded, rebound), "the reloaded binding matches what was saved")
|
||||
assert_true(InputSettings.invert_pitch, "invert_pitch survives a reload")
|
||||
|
||||
# A deliberately-cleared binding must stay cleared across a restart. This is
|
||||
# the case that distinguishes a real "unbound" record from an absent one:
|
||||
# ConfigFile.set_value() erases a key whose value is null, so a naive
|
||||
# implementation silently restores the default here instead.
|
||||
InputSettings.clear_binding("roll_left", InputSettings.DEVICE_JOYPAD)
|
||||
InputSettings.save()
|
||||
InputSettings.reset_all()
|
||||
InputSettings._load()
|
||||
InputSettings.apply()
|
||||
assert_true(
|
||||
InputSettings.get_binding("roll_left", InputSettings.DEVICE_JOYPAD) == null,
|
||||
"an unbound action stays unbound across a reload"
|
||||
)
|
||||
assert_true(
|
||||
_joypad_events("roll_left").is_empty(),
|
||||
"the unbound action has no joypad event in InputMap after a reload"
|
||||
)
|
||||
|
||||
# And it must actually be live in InputMap, not merely remembered.
|
||||
var live := false
|
||||
for event in InputMap.action_get_events("turbo"):
|
||||
if event is InputEventJoypadButton and event.button_index == JOY_BUTTON_BACK:
|
||||
live = true
|
||||
assert_true(live, "the reloaded binding is applied to InputMap")
|
||||
|
||||
InputSettings.reset_all()
|
||||
if had_file:
|
||||
var restore := FileAccess.open(InputSettings.SETTINGS_PATH, FileAccess.WRITE)
|
||||
if restore != null:
|
||||
restore.store_string(original)
|
||||
restore.close()
|
||||
InputSettings._load()
|
||||
InputSettings.apply()
|
||||
else:
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(InputSettings.SETTINGS_PATH))
|
||||
|
||||
|
||||
func test_invert_pitch_drives_pitch_sign() -> void:
|
||||
var restore := InputSettings.invert_pitch
|
||||
InputSettings.invert_pitch = false
|
||||
assert_eq(InputSettings.pitch_sign(), 1.0, "default pitch sign")
|
||||
InputSettings.invert_pitch = true
|
||||
assert_eq(InputSettings.pitch_sign(), -1.0, "inverted pitch sign")
|
||||
InputSettings.invert_pitch = restore
|
||||
|
||||
|
||||
func test_every_default_binding_has_readable_text() -> void:
|
||||
# A rebind row showing "" or "Joypad Button 9 (Left Shoulder)" is a UI bug
|
||||
# that no other assertion here would catch.
|
||||
for entry in InputSettings.ACTIONS:
|
||||
for device in [InputSettings.DEVICE_KEYBOARD, InputSettings.DEVICE_JOYPAD]:
|
||||
var text := InputSettings.binding_text(entry["action"], device)
|
||||
assert_true(
|
||||
text != "" and text != "Unbound",
|
||||
"%s/%s has a readable label (got %s)" % [entry["action"], device, text]
|
||||
)
|
||||
|
||||
|
||||
func test_device_kind_of_rejects_events_it_cannot_bind() -> void:
|
||||
assert_eq(InputSettings.device_kind_of(InputEventMouseButton.new()), "", "mouse is not a bindable device")
|
||||
assert_eq(InputSettings.device_kind_of(InputEventKey.new()), InputSettings.DEVICE_KEYBOARD, "key device")
|
||||
assert_eq(
|
||||
InputSettings.device_kind_of(InputEventJoypadMotion.new()),
|
||||
InputSettings.DEVICE_JOYPAD,
|
||||
"joypad motion device"
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bwbkb6wa46yh1
|
||||
@@ -168,13 +168,62 @@ 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.
|
||||
var token := "eyJBdXRob3Jpc2F0aW9uIjp7Ik1hdGNoSUQiOiJtYXRjaC0xIiwiU2VydmVySUQiOiJzZXJ2ZXItMSIsIlBsYXllcklEIjoicGxheWVyLTEiLCJTdGVhbUlEIjoic3RlYW0tMSIsIlNsb3QiOjUsIlRlYW0iOjEsIlByb3RvY29sIjoiMSIsIkdlbmVyYXRpb24iOjEsIkV4cGlyZXNBdCI6IjIwOTktMDgtMzFUMTI6MDA6MDBaIn0sIlNpZ25hdHVyZSI6Ijk0QkFOWjJpMkJUWHNWOVdaSWQ1dnE1Q3FqUXF4eGFXNnB4c2U0SFRXSDg9In0="
|
||||
# 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}, "test-key".to_utf8_buffer()), "HMAC roster configures")
|
||||
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}, "test-key".to_utf8_buffer()), "tampered roster fixture configures")
|
||||
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,136 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
# Guards the menu screens against the overflow bug that made the main menu
|
||||
# unusable: the layout runs in a hard-fixed 1920x1080 logical viewport
|
||||
# (window/stretch/mode="viewport"), and a CenterContainer centres its child
|
||||
# rather than clipping it, so once the content's minimum height passed 1080 the
|
||||
# top and bottom spilled off-screen with no way to reach them. In a debug build
|
||||
# the main menu's DevSection pushed it to roughly 1120px, cutting off both the
|
||||
# title and the last button.
|
||||
#
|
||||
# What this file can and cannot do, stated plainly: it asserts the *structure*
|
||||
# that makes overflow reachable — the bottom-most control of each screen sits
|
||||
# inside a ScrollContainer, and that container follows focus so keyboard and
|
||||
# controller navigation cannot strand the player on an off-screen row. It does
|
||||
# not measure anything, so it cannot prove nothing visually clips; that check is
|
||||
# manual, at several window sizes. It exists to stop the wrapper being removed
|
||||
# or a new section being added outside it.
|
||||
#
|
||||
# Scenes are inspected through PackedScene.get_state() rather than instantiated.
|
||||
# main_menu.gd and lobby.gd connect NetworkManager signals and scan res://bots
|
||||
# in _ready(), so instantiating them in a unit test would be doing real work to
|
||||
# answer a question about the scene file.
|
||||
|
||||
# scene path -> the control furthest down that screen, i.e. the first thing to
|
||||
# be lost to overflow. Naming a specific leaf rather than "some ScrollContainer
|
||||
# exists" is what makes this assertion say something: a wrapper that does not
|
||||
# actually contain the content would still pass the weaker version.
|
||||
const DEEPEST_CONTROLS := {
|
||||
"res://scenes/main_menu.tscn": "SpectateButton",
|
||||
"res://scenes/settings.tscn": "ResetButton",
|
||||
"res://scenes/lobby.tscn": "LeaveButton",
|
||||
"res://scenes/matchmaking.tscn": "BackButton",
|
||||
}
|
||||
|
||||
|
||||
# Returns node index -> "Parent/Path/Name" for every node in the scene state,
|
||||
# reconstructing full paths from SceneState's parent-relative storage.
|
||||
func _node_paths(state: SceneState) -> Dictionary:
|
||||
var paths := {}
|
||||
for i in state.get_node_count():
|
||||
var parent := state.get_node_path(i, true)
|
||||
var name := String(state.get_node_name(i))
|
||||
var parent_str := String(parent)
|
||||
if parent_str == "." or parent_str == "":
|
||||
paths[i] = name
|
||||
else:
|
||||
paths[i] = "%s/%s" % [parent_str, name]
|
||||
return paths
|
||||
|
||||
|
||||
func _property(state: SceneState, index: int, wanted: String, fallback):
|
||||
for p in state.get_node_property_count(index):
|
||||
if String(state.get_node_property_name(index, p)) == wanted:
|
||||
return state.get_node_property_value(index, p)
|
||||
return fallback
|
||||
|
||||
|
||||
func test_every_menu_scene_loads() -> void:
|
||||
for scene_path in DEEPEST_CONTROLS:
|
||||
var scene := load(scene_path)
|
||||
assert_true(scene is PackedScene, "%s loads as a PackedScene" % scene_path)
|
||||
|
||||
|
||||
func test_the_bottom_of_each_menu_sits_inside_a_scroll_container() -> void:
|
||||
for scene_path in DEEPEST_CONTROLS:
|
||||
var scene: PackedScene = load(scene_path)
|
||||
if scene == null:
|
||||
assert_true(false, "%s failed to load" % scene_path)
|
||||
continue
|
||||
var state := scene.get_state()
|
||||
var paths := _node_paths(state)
|
||||
|
||||
# Collect the paths of every ScrollContainer in the scene...
|
||||
var scroll_paths := []
|
||||
for i in state.get_node_count():
|
||||
if String(state.get_node_type(i)) == "ScrollContainer":
|
||||
scroll_paths.append(paths[i])
|
||||
assert_true(not scroll_paths.is_empty(), "%s has a ScrollContainer" % scene_path)
|
||||
|
||||
# ...then require the deepest control to live under one of them.
|
||||
var wanted: String = DEEPEST_CONTROLS[scene_path]
|
||||
var found_path := ""
|
||||
for i in state.get_node_count():
|
||||
if String(state.get_node_name(i)) == wanted:
|
||||
found_path = paths[i]
|
||||
break
|
||||
assert_true(found_path != "", "%s contains %s" % [scene_path, wanted])
|
||||
if found_path == "":
|
||||
continue
|
||||
|
||||
var scrolled := false
|
||||
for scroll_path in scroll_paths:
|
||||
if found_path.begins_with(String(scroll_path) + "/"):
|
||||
scrolled = true
|
||||
break
|
||||
assert_true(scrolled, "%s's %s is inside a ScrollContainer (at %s)" % [scene_path, wanted, found_path])
|
||||
|
||||
|
||||
func test_menu_scroll_containers_follow_focus() -> void:
|
||||
# Without follow_focus, grab_focus() on a control below the fold (main_menu
|
||||
# focuses FreePlayButton on ready) leaves the view showing something else,
|
||||
# and controller navigation walks focus off-screen silently.
|
||||
for scene_path in DEEPEST_CONTROLS:
|
||||
var scene: PackedScene = load(scene_path)
|
||||
if scene == null:
|
||||
continue
|
||||
var state := scene.get_state()
|
||||
var checked := 0
|
||||
for i in state.get_node_count():
|
||||
if String(state.get_node_type(i)) != "ScrollContainer":
|
||||
continue
|
||||
checked += 1
|
||||
assert_true(
|
||||
_property(state, i, "follow_focus", false) == true,
|
||||
"%s/%s has follow_focus" % [scene_path, state.get_node_name(i)]
|
||||
)
|
||||
assert_true(checked > 0, "%s has at least one ScrollContainer to check" % scene_path)
|
||||
|
||||
|
||||
func test_menu_scroll_containers_do_not_scroll_horizontally() -> void:
|
||||
# Horizontal scrolling is disabled so content is clamped to the window
|
||||
# width instead of growing a second scrollbar — the dev bot dropdowns are
|
||||
# filled from filenames and would otherwise widen the whole menu.
|
||||
for scene_path in DEEPEST_CONTROLS:
|
||||
var scene: PackedScene = load(scene_path)
|
||||
if scene == null:
|
||||
continue
|
||||
var state := scene.get_state()
|
||||
for i in state.get_node_count():
|
||||
if String(state.get_node_type(i)) != "ScrollContainer":
|
||||
continue
|
||||
assert_eq(
|
||||
_property(state, i, "horizontal_scroll_mode", ScrollContainer.SCROLL_MODE_AUTO),
|
||||
ScrollContainer.SCROLL_MODE_DISABLED,
|
||||
"%s/%s disables horizontal scrolling" % [scene_path, state.get_node_name(i)]
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bs8c31fs0tfhe
|
||||
@@ -0,0 +1,177 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
# Covers PlayerShipController's translation of input actions into a ShipAction.
|
||||
#
|
||||
# The point of most of these is the *analog* path. The controller used to read
|
||||
# is_action_pressed(), which is a bool, so a half-pulled trigger and a fully
|
||||
# pulled one produced identical full thrust. A test that only ever pressed
|
||||
# actions at full strength could not tell the two implementations apart — so
|
||||
# these press at fractional strength, which only the get_action_strength()
|
||||
# version can reproduce.
|
||||
#
|
||||
# Input.action_press writes to the global input state, so every test must
|
||||
# release what it pressed before returning or it leaks into later cases.
|
||||
|
||||
const ACTIONS_USED := [
|
||||
"move_forward", "move_back", "move_left", "move_right", "move_up", "move_down",
|
||||
"turn_left", "turn_right", "pitch_up", "pitch_down", "roll_left", "roll_right",
|
||||
"turbo",
|
||||
]
|
||||
|
||||
|
||||
func _controller() -> PlayerShipController:
|
||||
return PlayerShipController.new()
|
||||
|
||||
|
||||
func _release_all() -> void:
|
||||
for action in ACTIONS_USED:
|
||||
Input.action_release(action)
|
||||
|
||||
|
||||
func test_full_strength_matches_the_historical_digital_values() -> void:
|
||||
# The keyboard path must be unchanged by the move to analog: a held key
|
||||
# reports strength 1.0, so every axis lands on exactly ±1.
|
||||
var controller := _controller()
|
||||
|
||||
Input.action_press("move_forward", 1.0)
|
||||
Input.action_press("move_right", 1.0)
|
||||
Input.action_press("move_up", 1.0)
|
||||
var action := controller.get_action()
|
||||
assert_almost_eq(action.thrust.z, 1.0, 0.001, "forward thrust")
|
||||
assert_almost_eq(action.thrust.x, 1.0, 0.001, "right thrust")
|
||||
assert_almost_eq(action.thrust.y, 1.0, 0.001, "up thrust")
|
||||
_release_all()
|
||||
|
||||
Input.action_press("move_back", 1.0)
|
||||
Input.action_press("move_left", 1.0)
|
||||
Input.action_press("move_down", 1.0)
|
||||
action = controller.get_action()
|
||||
assert_almost_eq(action.thrust.z, -1.0, 0.001, "backward thrust")
|
||||
assert_almost_eq(action.thrust.x, -1.0, 0.001, "left thrust")
|
||||
assert_almost_eq(action.thrust.y, -1.0, 0.001, "down thrust")
|
||||
_release_all()
|
||||
|
||||
|
||||
func test_rotation_sign_conventions_are_unchanged() -> void:
|
||||
# Each action must move the ship the way its NAME says. The physics
|
||||
# directions were measured by driving a real Ship through ship.tscn rather
|
||||
# than reasoned about, because the right-hand rule is exactly the kind of
|
||||
# thing that reads as obvious and comes out backwards:
|
||||
#
|
||||
# rotation.x > 0 -> nose UP (torque about local +X)
|
||||
# rotation.y > 0 -> nose LEFT (torque about local +Y)
|
||||
# rotation.z > 0 -> banks LEFT (torque about local +Z)
|
||||
#
|
||||
# pitch was inverted against this for a long time — get_axis's arguments
|
||||
# were the wrong way round, so "pitch_down" raised the nose and the I/K keys
|
||||
# each did the opposite of their label. Nothing caught it because the sign
|
||||
# was self-consistent everywhere it was used; only comparing against the
|
||||
# physics reveals it.
|
||||
var controller := _controller()
|
||||
var restore := InputSettings.invert_pitch
|
||||
InputSettings.invert_pitch = false
|
||||
|
||||
Input.action_press("turn_left", 1.0)
|
||||
Input.action_press("pitch_up", 1.0)
|
||||
Input.action_press("roll_left", 1.0)
|
||||
var action := controller.get_action()
|
||||
assert_almost_eq(action.rotation.y, 1.0, 0.001, "yaw left is positive")
|
||||
assert_almost_eq(action.rotation.x, 1.0, 0.001, "pitch UP is positive (nose up)")
|
||||
assert_almost_eq(action.rotation.z, 1.0, 0.001, "roll left is positive")
|
||||
_release_all()
|
||||
|
||||
Input.action_press("turn_right", 1.0)
|
||||
Input.action_press("pitch_down", 1.0)
|
||||
Input.action_press("roll_right", 1.0)
|
||||
action = controller.get_action()
|
||||
assert_almost_eq(action.rotation.y, -1.0, 0.001, "yaw right is negative")
|
||||
assert_almost_eq(action.rotation.x, -1.0, 0.001, "pitch DOWN is negative (nose down)")
|
||||
assert_almost_eq(action.rotation.z, -1.0, 0.001, "roll right is negative")
|
||||
_release_all()
|
||||
|
||||
InputSettings.invert_pitch = restore
|
||||
|
||||
|
||||
func test_partial_strength_produces_partial_thrust() -> void:
|
||||
# The analog assertion. A digital is_action_pressed() implementation would
|
||||
# return 1.0 here and fail.
|
||||
var controller := _controller()
|
||||
|
||||
Input.action_press("move_forward", 0.5)
|
||||
assert_almost_eq(controller.get_action().thrust.z, 0.5, 0.001, "half trigger is half thrust")
|
||||
_release_all()
|
||||
|
||||
Input.action_press("move_up", 0.25)
|
||||
assert_almost_eq(controller.get_action().thrust.y, 0.25, 0.001, "quarter deflection is quarter thrust")
|
||||
_release_all()
|
||||
|
||||
Input.action_press("turn_left", 0.3)
|
||||
assert_almost_eq(controller.get_action().rotation.y, 0.3, 0.001, "partial stick is partial yaw")
|
||||
_release_all()
|
||||
|
||||
|
||||
func test_opposing_inputs_subtract_rather_than_saturate() -> void:
|
||||
# Both halves of one stick axis can report a strength at once; the result
|
||||
# must be their difference, not whichever was read last.
|
||||
var controller := _controller()
|
||||
|
||||
Input.action_press("move_forward", 0.75)
|
||||
Input.action_press("move_back", 0.25)
|
||||
assert_almost_eq(controller.get_action().thrust.z, 0.5, 0.001, "opposed thrust subtracts")
|
||||
_release_all()
|
||||
|
||||
Input.action_press("move_forward", 0.4)
|
||||
Input.action_press("move_back", 0.4)
|
||||
assert_almost_eq(controller.get_action().thrust.z, 0.0, 0.001, "equal opposed thrust cancels")
|
||||
_release_all()
|
||||
|
||||
|
||||
func test_no_input_is_a_zero_action() -> void:
|
||||
var controller := _controller()
|
||||
_release_all()
|
||||
var action := controller.get_action()
|
||||
assert_eq(action.thrust, Vector3.ZERO, "idle thrust")
|
||||
assert_eq(action.rotation, Vector3.ZERO, "idle rotation")
|
||||
assert_true(not action.turbo, "idle turbo")
|
||||
|
||||
|
||||
func test_invert_pitch_flips_only_the_pitch_axis() -> void:
|
||||
var controller := _controller()
|
||||
var restore := InputSettings.invert_pitch
|
||||
|
||||
Input.action_press("pitch_down", 1.0)
|
||||
Input.action_press("turn_left", 1.0)
|
||||
|
||||
InputSettings.invert_pitch = false
|
||||
var normal := controller.get_action().copy()
|
||||
InputSettings.invert_pitch = true
|
||||
var inverted := controller.get_action().copy()
|
||||
|
||||
assert_almost_eq(inverted.rotation.x, -normal.rotation.x, 0.001, "invert flips pitch")
|
||||
assert_almost_eq(inverted.rotation.y, normal.rotation.y, 0.001, "invert leaves yaw alone")
|
||||
|
||||
InputSettings.invert_pitch = restore
|
||||
_release_all()
|
||||
|
||||
|
||||
func test_turbo_is_a_boolean() -> void:
|
||||
var controller := _controller()
|
||||
Input.action_press("turbo", 1.0)
|
||||
assert_true(controller.get_action().turbo, "turbo held")
|
||||
Input.action_release("turbo")
|
||||
assert_true(not controller.get_action().turbo, "turbo released")
|
||||
_release_all()
|
||||
|
||||
|
||||
func test_the_returned_action_is_reused_between_ticks() -> void:
|
||||
# get_action() documents that it returns a reused instance and overwrites
|
||||
# every axis. Callers that keep an action past its tick must copy() it —
|
||||
# local_input_timeline.gd and the prediction ring rely on that contract, so
|
||||
# assert both halves of it.
|
||||
var controller := _controller()
|
||||
Input.action_press("move_forward", 1.0)
|
||||
var first := controller.get_action()
|
||||
_release_all()
|
||||
var second := controller.get_action()
|
||||
assert_true(first == second, "the same ShipAction instance is returned each tick")
|
||||
assert_almost_eq(second.thrust.z, 0.0, 0.001, "releasing clears the axis rather than leaving it stale")
|
||||
@@ -0,0 +1 @@
|
||||
uid://xpr311fjhw2m
|
||||
@@ -71,7 +71,7 @@ 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", "ControlPlaneClient", "VideoSettings", "NetworkManager", "MatchNet", "MatchSim"]:
|
||||
for autoload_name in ["GameSettings", "ControlPlaneClient", "VideoSettings", "InputSettings", "NetworkManager", "MatchNet", "MatchSim"]:
|
||||
assert_true(
|
||||
ProjectSettings.has_setting("autoload/" + autoload_name),
|
||||
"autoload/%s registered" % autoload_name
|
||||
@@ -89,7 +89,12 @@ func test_test_hook_autoloads_are_not_shipped() -> void:
|
||||
# when running those scene-level smoke tests, and must be removed again —
|
||||
# see CLAUDE.md. Shipping one registered would run test code in the real
|
||||
# game, so fail here rather than discovering it in a build.
|
||||
for hook_name in ["MainMenuTestHooks", "LobbyTestHooks", "NetworkedMatchTestHooks"]:
|
||||
# McpInteractionServer is registered automatically by the vendored godot-mcp
|
||||
# tooling whenever it launches the project, and is left behind in
|
||||
# project.godot afterwards. It is a debug channel into a running game, so
|
||||
# shipping it registered is worse than a stray test hook, and it arrives
|
||||
# without anyone having typed it.
|
||||
for hook_name in ["MainMenuTestHooks", "LobbyTestHooks", "NetworkedMatchTestHooks", "McpInteractionServer"]:
|
||||
assert_true(
|
||||
not ProjectSettings.has_setting("autoload/" + hook_name),
|
||||
"test hook autoload/%s must not be registered" % hook_name
|
||||
|
||||
@@ -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")
|
||||
@@ -35,7 +35,7 @@ func _ready() -> void:
|
||||
|
||||
func _run_host() -> void:
|
||||
var menu := get_tree().current_scene
|
||||
var host_btn: Button = menu.get_node("CenterContainer/VBoxContainer/HostButton")
|
||||
var host_btn: Button = menu.get_node("MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/HostButton")
|
||||
host_btn.emit_signal("pressed")
|
||||
await get_tree().create_timer(1.0).timeout
|
||||
var scene := get_tree().current_scene
|
||||
@@ -58,7 +58,7 @@ func _run_join_ok() -> void:
|
||||
var menu := get_tree().current_scene
|
||||
var address_edit: LineEdit = menu.get_node("%JoinAddressEdit")
|
||||
address_edit.text = "127.0.0.1"
|
||||
var join_btn: Button = menu.get_node("CenterContainer/VBoxContainer/JoinRow/JoinButton")
|
||||
var join_btn: Button = menu.get_node("MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow/JoinButton")
|
||||
join_btn.emit_signal("pressed")
|
||||
var overlay: Control = menu.get_node("%ConnectingOverlay")
|
||||
print("SMOKE INFO: overlay visible right after Join press = %s" % str(overlay.visible))
|
||||
@@ -72,7 +72,7 @@ func _run_join_refused() -> void:
|
||||
var menu := get_tree().current_scene
|
||||
var address_edit: LineEdit = menu.get_node("%JoinAddressEdit")
|
||||
address_edit.text = "127.0.0.1"
|
||||
var join_btn: Button = menu.get_node("CenterContainer/VBoxContainer/JoinRow/JoinButton")
|
||||
var join_btn: Button = menu.get_node("MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow/JoinButton")
|
||||
join_btn.emit_signal("pressed")
|
||||
var overlay: Control = menu.get_node("%ConnectingOverlay")
|
||||
print("SMOKE INFO: overlay visible right after Join press (no server) = %s" % str(overlay.visible))
|
||||
@@ -92,7 +92,7 @@ func _run_join_cancel() -> void:
|
||||
var menu := get_tree().current_scene
|
||||
var address_edit: LineEdit = menu.get_node("%JoinAddressEdit")
|
||||
address_edit.text = "10.255.255.1" # non-routable; connect attempt just hangs until timeout/cancel
|
||||
var join_btn: Button = menu.get_node("CenterContainer/VBoxContainer/JoinRow/JoinButton")
|
||||
var join_btn: Button = menu.get_node("MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow/JoinButton")
|
||||
join_btn.emit_signal("pressed")
|
||||
var overlay: Control = menu.get_node("%ConnectingOverlay")
|
||||
await get_tree().create_timer(0.5).timeout
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -43,7 +43,62 @@ Everything below needs a person — hardware, a design decision, an external acc
|
||||
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.
|
||||
|
||||
- [ ] ([#14](https://github.com/jcreek/CosmicClash/issues/14)) **Decide the join-signing design for the Phase 8 root blocker.** No real deployment can advance a match past `PROCESS_READY` today because nothing calls the (fully built and tested) assignment-publishing path in production — it needs a join-signing key shared between allocator and game server, roster-digest computation, and per-player authorization construction, deliberately flagged rather than built pending this decision. See `multiplayer-next.md` §0 ("the actual current root blocker") and §8.31.
|
||||
**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.
|
||||
@@ -51,7 +106,11 @@ Each item is also a GitHub issue (linked inline), labelled `needs:human` plus a
|
||||
- [ ] ([#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`.
|
||||
- [ ] (no issue — agent-actionable, tracked in `multiplayer-next.md` §0) **Give Phase 8.48 its own Compose smoke fixture** so the allocated-mode flow stops depending on `compose.phase6-smoke.yml`'s hardcoded port, first-come slots, and `--max-matches=2`. `multiplayer-next.md` §0 task table.
|
||||
- [ ] ([#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.
|
||||
|
||||
@@ -58,7 +58,11 @@ services:
|
||||
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
|
||||
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"]
|
||||
# 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
|
||||
@@ -67,6 +71,7 @@ services:
|
||||
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:
|
||||
@@ -101,7 +106,7 @@ services:
|
||||
- --transport=enet
|
||||
- --region=EU
|
||||
- --join-authorisations-file=/run/cosmic-clash/join-roster.json
|
||||
- --join-authorisations-key-file=/run/secrets/cosmic-clash/join-signing-key
|
||||
- --join-authorisations-key-file=/run/secrets/cosmic-clash/join-signing-keys.json
|
||||
- --readiness-port=7780
|
||||
environment:
|
||||
COSMIC_CLASH_DRAIN_TOKEN: compose-drain-token
|
||||
@@ -109,4 +114,4 @@ services:
|
||||
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-key:/run/secrets/cosmic-clash/join-signing-key:ro
|
||||
- ${COMPOSE_SMOKE_DIR:-/tmp/cosmic-clash-allocated-smoke}/join-signing-keys.json:/run/secrets/cosmic-clash/join-signing-keys.json:ro
|
||||
|
||||
@@ -60,9 +60,18 @@ spec:
|
||||
- --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
|
||||
@@ -102,3 +111,19 @@ spec:
|
||||
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
|
||||
|
||||
@@ -95,3 +95,22 @@ spec:
|
||||
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
|
||||
|
||||
@@ -43,19 +43,22 @@ spec:
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: game-server
|
||||
serviceAccountName: match-server
|
||||
automountServiceAccountToken: false
|
||||
# Leave serviceAccountName unset: Agones assigns its SDK account and
|
||||
# masks that account's token from this public game-server container,
|
||||
# while retaining it in the injected SDK sidecar that needs API access.
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 10001
|
||||
runAsGroup: 10001
|
||||
fsGroup: 10001
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: game-server
|
||||
image: ghcr.io/cosmic-clash/game-server@sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
args:
|
||||
- --sdk-base-url=http://127.0.0.1:9357
|
||||
- --sdk-base-url=http://127.0.0.1:9358
|
||||
- --ready-url=http://127.0.0.1:7780/ready
|
||||
- --drain-url=http://127.0.0.1:7780/drain
|
||||
- --initial-connect-ready-url=http://127.0.0.1:7780/initial-connect-ready
|
||||
@@ -80,9 +83,16 @@ spec:
|
||||
- --transport=enet
|
||||
- --region=EU
|
||||
- --join-authorisations-file=/run/cosmic-clash/join-roster.json
|
||||
- --join-authorisations-key-file=/run/secrets/cosmic-clash/join-signing-key
|
||||
# The key SET, not one key: an allocated server must accept
|
||||
# authorisations signed with any currently-valid key so a
|
||||
# rotation does not break matches already in flight.
|
||||
- --join-authorisations-key-file=/run/secrets/cosmic-clash/join-signing-keys.json
|
||||
- --readiness-port=7780
|
||||
env:
|
||||
# Godot stores user:// beneath HOME. Point it at the writable
|
||||
# runtime volume while retaining a read-only root filesystem.
|
||||
- name: HOME
|
||||
value: /run/cosmic-clash
|
||||
- name: COSMIC_CLASH_SERVER_ID
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
@@ -121,5 +131,5 @@ spec:
|
||||
secret:
|
||||
secretName: cosmic-clash-game-server
|
||||
items:
|
||||
- key: join-signing-key
|
||||
path: join-signing-key
|
||||
- key: join-signing-keys.json
|
||||
path: join-signing-keys.json
|
||||
|
||||
@@ -11,6 +11,8 @@ resources:
|
||||
- allocator-deployment.yaml
|
||||
- allocator-service.yaml
|
||||
- allocator-pdb.yaml
|
||||
- matcher-deployment.yaml
|
||||
- matcher-pdb.yaml
|
||||
- maintenance-deployment.yaml
|
||||
- maintenance-pdb.yaml
|
||||
- fleet.yaml
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# cmd/matcher is a standalone poll loop that turns queued tickets into
|
||||
# proposals. It was built as an image but had no Deployment anywhere in this
|
||||
# base, so applying the checked-in manifests produced a cluster where tickets
|
||||
# could be created but nothing ever consumed them.
|
||||
#
|
||||
# Casual and ranked run as separate Deployments rather than one process with
|
||||
# two loops: they have different match sizes, and separating them means a
|
||||
# ranked backlog cannot delay casual formation (and vice versa). Each worker
|
||||
# reads its own playlist-scoped Redis namespace.
|
||||
#
|
||||
# Exactly one replica each. The matcher claims tickets through CreateProposal's
|
||||
# SKIP LOCKED fences so a second replica would be safe, but it would also halve
|
||||
# the candidate pool each worker sees per poll and make formation quality worse
|
||||
# for no throughput gain at this scale.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: matcher-casual
|
||||
namespace: cosmic-clash
|
||||
labels:
|
||||
app.kubernetes.io/name: matcher
|
||||
app.kubernetes.io/component: matcher
|
||||
cosmic-clash.io/playlist: casual
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: matcher
|
||||
cosmic-clash.io/playlist: casual
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: matcher
|
||||
app.kubernetes.io/component: matcher
|
||||
cosmic-clash.io/playlist: casual
|
||||
spec:
|
||||
terminationGracePeriodSeconds: 10
|
||||
serviceAccountName: matcher
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 10001
|
||||
runAsGroup: 10001
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: matcher
|
||||
image: ghcr.io/cosmic-clash/matcher@sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
args:
|
||||
- --dsn=$(COSMIC_CLASH_POSTGRES_DSN)
|
||||
- --playlist=casual
|
||||
- --size=4
|
||||
- --interval=1s
|
||||
- --redis-addr=$(COSMIC_CLASH_REDIS_ADDR)
|
||||
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_REDIS_ADDR
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: cosmic-clash-redis
|
||||
key: addr
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: matcher-ranked
|
||||
namespace: cosmic-clash
|
||||
labels:
|
||||
app.kubernetes.io/name: matcher
|
||||
app.kubernetes.io/component: matcher
|
||||
cosmic-clash.io/playlist: ranked
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: matcher
|
||||
cosmic-clash.io/playlist: ranked
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: matcher
|
||||
app.kubernetes.io/component: matcher
|
||||
cosmic-clash.io/playlist: ranked
|
||||
spec:
|
||||
terminationGracePeriodSeconds: 10
|
||||
serviceAccountName: matcher
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 10001
|
||||
runAsGroup: 10001
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: matcher
|
||||
image: ghcr.io/cosmic-clash/matcher@sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
args:
|
||||
- --dsn=$(COSMIC_CLASH_POSTGRES_DSN)
|
||||
# Ranked is strictly 3v3; domain.AllocateAcceptedProposal rejects a
|
||||
# ranked proposal that is not exactly six players.
|
||||
- --playlist=ranked
|
||||
- --size=6
|
||||
- --interval=1s
|
||||
- --redis-addr=$(COSMIC_CLASH_REDIS_ADDR)
|
||||
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_REDIS_ADDR
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: cosmic-clash-redis
|
||||
key: addr
|
||||
@@ -0,0 +1,15 @@
|
||||
# Each playlist runs a single matcher, so maxUnavailable rather than
|
||||
# minAvailable: minAvailable: 1 against a one-replica Deployment blocks every
|
||||
# voluntary eviction, including node drains. Allowing one keeps drains possible;
|
||||
# formation simply pauses for the restart, and queued tickets are unaffected
|
||||
# because the matcher holds no state of its own.
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: matcher
|
||||
namespace: cosmic-clash
|
||||
spec:
|
||||
maxUnavailable: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: matcher
|
||||
@@ -3,7 +3,10 @@ kind: Namespace
|
||||
metadata:
|
||||
name: cosmic-clash
|
||||
labels:
|
||||
pod-security.kubernetes.io/enforce: restricted
|
||||
# Agones' Dynamic port policy injects a hostPort into every GameServer
|
||||
# Pod. Kubernetes' built-in baseline and restricted policies both forbid
|
||||
# host ports, so this workload namespace must enforce privileged while
|
||||
# continuing to surface restricted-policy deviations in audit and warnings.
|
||||
pod-security.kubernetes.io/enforce: privileged
|
||||
pod-security.kubernetes.io/audit: restricted
|
||||
pod-security.kubernetes.io/warn: restricted
|
||||
|
||||
|
||||
@@ -26,6 +26,18 @@ spec:
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8080
|
||||
# Allocated game servers are control-plane clients too: roster fetch,
|
||||
# registration, connection receipts, shutdown acknowledgement and result
|
||||
# submission all target this port. Their egress was already permitted, but
|
||||
# without a matching ingress rule every one of those calls was dropped, so
|
||||
# no allocated match could complete even inside the cluster.
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: game-server
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8080
|
||||
egress:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
@@ -78,6 +90,12 @@ spec:
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8080
|
||||
# The injected Agones SDK sidecar updates its GameServer through the
|
||||
# kubernetes.default HTTPS Service. Its token is masked from the public
|
||||
# game-server container by Agones, but NetworkPolicy applies to the Pod.
|
||||
- ports:
|
||||
- protocol: TCP
|
||||
port: 443
|
||||
- ports:
|
||||
- protocol: UDP
|
||||
port: 53
|
||||
@@ -175,3 +193,75 @@ spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
k8s-app: kube-dns
|
||||
---
|
||||
# Public players connect straight to the allocated GameServer's UDP port; the
|
||||
# control plane only ever hands out its address. The namespace-wide default
|
||||
# deny blocked that ingress entirely, so an allocated server was unreachable
|
||||
# from the internet and no matchmade game could be joined.
|
||||
#
|
||||
# The source cannot be narrowed by selector: these peers are player machines
|
||||
# outside the cluster. It is narrowed instead to exactly one protocol and port
|
||||
# on exactly the game-server pods, and the game server admits a peer only with
|
||||
# a valid signed join authorisation for its own match.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: game-server-allowed-ingress
|
||||
namespace: cosmic-clash
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: game-server
|
||||
policyTypes:
|
||||
- Ingress
|
||||
ingress:
|
||||
- ports:
|
||||
- protocol: UDP
|
||||
port: 7777
|
||||
---
|
||||
# The matcher reads queued candidates and writes proposals. It exposes nothing
|
||||
# and talks to nobody but its two datastores.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: matcher-allowed-egress
|
||||
namespace: cosmic-clash
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: matcher
|
||||
policyTypes:
|
||||
- Egress
|
||||
egress:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: data
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: postgres
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 5432
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: data
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: redis
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 6379
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
k8s-app: kube-dns
|
||||
ports:
|
||||
- protocol: UDP
|
||||
port: 53
|
||||
- protocol: TCP
|
||||
port: 53
|
||||
|
||||
@@ -7,13 +7,6 @@ automountServiceAccountToken: false
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: match-server
|
||||
namespace: cosmic-clash
|
||||
automountServiceAccountToken: false
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: allocator
|
||||
namespace: cosmic-clash
|
||||
@@ -25,3 +18,10 @@ metadata:
|
||||
name: maintenance
|
||||
namespace: cosmic-clash
|
||||
automountServiceAccountToken: false
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: matcher
|
||||
namespace: cosmic-clash
|
||||
automountServiceAccountToken: false
|
||||
|
||||
+33
-1
@@ -95,6 +95,15 @@ matcher, allocator and game-server pods cannot read it. The signer accepts
|
||||
only allocator-recorded assignments, audits every signature, and supports
|
||||
overlapping-key rotation.
|
||||
|
||||
Join authorisations carry a key ID naming the key that signed them, and that
|
||||
ID is part of the signed bytes so it cannot be repointed at a different key.
|
||||
Allocated servers hold the set of currently-valid keys and select by ID, which
|
||||
is what makes rotation overlapping rather than breaking: publish the new key
|
||||
everywhere, move the allocator's active key ID to it, then drop the retired key
|
||||
once no live match can still reference it. The key set is delivered as a JSON
|
||||
map of key ID to base64 key, mounted from the same Secret by both the allocator
|
||||
Deployment and the Fleet.
|
||||
|
||||
## 3. Control-plane architecture
|
||||
|
||||
Use one repository and shared domain packages, with independently runnable
|
||||
@@ -169,7 +178,19 @@ The client submits its recent opaque Steam ping location plus nonce-bound
|
||||
active-probe responses from each regional endpoint; it does not submit the RTT
|
||||
used for placement. The backend validates a 30-second freshness window and
|
||||
nonce, then uses the Steam coordinator SDK and probe timings to compute the
|
||||
regional matrix. A predicted/observed discrepancy over 25 ms or 30% (whichever
|
||||
regional matrix.
|
||||
|
||||
The nonce comes from `POST /v1/probes/{region}/challenge`, which the client
|
||||
calls before `POST /v1/probes/{region}`. The challenge is single-use and
|
||||
durable rather than per-process, because any control-plane replica may serve
|
||||
the answer to a challenge another replica issued. The recorded RTT is the
|
||||
interval the backend measures between issuing the challenge and receiving the
|
||||
answer, which is what keeps client-reported latency out of placement entirely.
|
||||
|
||||
Probing is a precondition for matching, not an optimisation: a ticket with no
|
||||
regional RTT evidence is rejected by the matcher outright, so the client
|
||||
collects evidence before it creates a ticket. Not every region has to answer --
|
||||
placement uses whichever did -- but a ticket with none is never queued. A predicted/observed discrepancy over 25 ms or 30% (whichever
|
||||
is larger) in three matches within 24 hours quarantines the account's samples:
|
||||
it may queue only in regions whose active probe independently remains under
|
||||
the ceiling until five clean matches clear the quarantine. The matchmaker:
|
||||
@@ -238,6 +259,17 @@ only after the same transition commits.
|
||||
- An original casual participant gets 30 seconds to reconnect; leaving after
|
||||
that applies a 60-second queue cooldown. The match's ordinary hidden-rating
|
||||
result still applies, with no extra rating penalty.
|
||||
- **Late roster delivery.** A backfilled player's join authorisation is issued
|
||||
after their server started, but the supervisor fetches the roster once before
|
||||
launching the game child and the game process has no reload path. The agreed
|
||||
model is: the control plane marks the roster changed, the supervisor -- which
|
||||
already holds an authenticated channel to the control plane and already owns
|
||||
the roster file -- re-fetches and rewrites it, then signals the game process
|
||||
to reload. This deliberately adds no inbound path into the game pod and no new
|
||||
trust boundary; the roster stays an allowlist the server is told to expect,
|
||||
rather than admitting anyone holding a valid signature. Signature
|
||||
verification is unchanged and already covers match, server, slot and
|
||||
generation.
|
||||
- An accepted casual initial-connect no-show gets the same 60-second cooldown.
|
||||
The match proceeds with a bot only if at least one human connected on each
|
||||
team; otherwise it cancels and restores every innocent ticket with original
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
# Branch review findings — `feat/multiplayer`, September 2026
|
||||
|
||||
> **Point-in-time artefact, not living documentation.** This records the state
|
||||
> of the branch at `089c127c`. **All thirteen findings below have since been
|
||||
> addressed** — every one was verified against the code first, and each fix
|
||||
> carries a test confirmed to fail against the defect it covers. Do not read
|
||||
> the present tense here as describing current behaviour.
|
||||
>
|
||||
> For what is actually outstanding, see [`multiplayer-next.md`](../multiplayer-next.md)
|
||||
> §0 and §7. For the design the fixes implement, see
|
||||
> [`MATCHMAKING.md`](MATCHMAKING.md). It is kept because the reasoning about
|
||||
> *why* each defect mattered is worth preserving, and because several fixes are
|
||||
> only intelligible alongside the failure they close.
|
||||
>
|
||||
> Two things the review did not cover, found while fixing it and recorded in
|
||||
> `multiplayer-next.md` rather than here: `predicted_rtt` was persisted as a
|
||||
> JSONB scalar `null` (so `RecordProbe` could never have worked even once the
|
||||
> probe endpoint was wired), and the ranked-rating gap existed on the Redis
|
||||
> path too, via the candidate built at enqueue rather than the candidate query.
|
||||
|
||||
Review scope: `feat/multiplayer` at `089c127c`, compared with merge-base
|
||||
`3aa0f5b9` (`origin/master`). This is a second, stricter adversarial pass over
|
||||
the complete branch.
|
||||
|
||||
## [P0] Ship runnable control-plane and matcher workloads
|
||||
|
||||
**Location:** `Dockerfile:51-100`, `deploy/k8s/base/kustomization.yaml:3-18`,
|
||||
`deploy/k8s/base/control-plane-deployment.yaml:48-50`
|
||||
|
||||
The Kubernetes base deploys a `control-plane` image, but the Dockerfile neither
|
||||
builds `cmd/control-plane` nor defines a `control-plane` target. Conversely, the
|
||||
Dockerfile does build a matcher image, but the Kubernetes base contains no
|
||||
matcher Deployment at all. Applying the checked-in base therefore cannot
|
||||
produce the advertised production topology: there is no repository-defined
|
||||
artifact for one required workload, and no running process that consumes
|
||||
queued tickets for the other. Tickets can be created but can never become
|
||||
proposals.
|
||||
|
||||
Add a production control-plane image target (not the fake-login `testkit-api`
|
||||
target), add separately configured casual and ranked matcher Deployments plus
|
||||
their network policies/health checks, and make the release pipeline build and
|
||||
pin every referenced target. Add a rendered-manifest test that asserts every
|
||||
required role is present and every image maps to a real Docker target.
|
||||
|
||||
## [P0] Wire production Steam authentication and the client sign-in flow
|
||||
|
||||
**Location:** `server/cmd/control-plane/main.go:129-157`,
|
||||
`server/api/service.go:321-340`, `Game/scripts/control_plane_client.gd:15-23`,
|
||||
`Game/scripts/control_plane_client.gd:157-168`,
|
||||
`Game/scripts/control_plane_client.gd:218-221`,
|
||||
`Game/scripts/main_menu.gd:194-195`
|
||||
|
||||
`newAPIService` never supplies `SteamLogin`, so the production
|
||||
`POST /v1/session/steam` handler always returns `503 auth_unavailable`. On the
|
||||
other side, the game starts with an empty token and a localhost base URL; it
|
||||
has `configure` and `login_steam` methods, but no production code calls either
|
||||
one and the menu enters matchmaking directly. All matchmaking HTTP operations
|
||||
then fail locally with `ERR_UNAUTHORIZED`. Only `cmd/testkit-api` supplies an
|
||||
authentication provider, so the passing integration path is not a deployable
|
||||
or secure player path.
|
||||
|
||||
Implement and configure the real Steam ticket adapter, expose explicit
|
||||
control-plane endpoint configuration for release builds, obtain a Steam Web
|
||||
API ticket through the platform integration, and complete login before
|
||||
enabling Find Match. Add an end-to-end test using the production binary wiring
|
||||
(with the external Steam boundary stubbed), rather than the testkit service.
|
||||
|
||||
## [P0] Populate server-derived RTT or every queued candidate is invalid
|
||||
|
||||
**Location:** `server/cmd/control-plane/main.go:134-154`,
|
||||
`server/api/service.go:1143-1168`, `server/store/queue_sql.go:134-181`,
|
||||
`server/store/queue_sql.go:208-227`, `server/domain/matcher.go:159-168`,
|
||||
`Game/scripts/control_plane_client.gd:205-221`,
|
||||
`server/api/service.go:1175-1181`
|
||||
|
||||
Queue creation persists an empty `predicted_rtt` map, while `validCandidate`
|
||||
rejects every candidate whose map remains empty. The production control plane
|
||||
sets `ProbeRecorder` but never sets the `Probe` provider, so the probe endpoint
|
||||
always returns `503 probe_unavailable`; the Godot client also implements no
|
||||
probe request at all. As a result, even if a matcher Deployment is added, no
|
||||
real client-created ticket can participate in a formation. There is a second
|
||||
cache-coherency failure behind that blocker: a successful probe updates only
|
||||
PostgreSQL and does not refresh `CandidateIndex`, leaving a previously inserted
|
||||
Redis candidate with its empty RTT map. In a busy shared keyspace whose TTL is
|
||||
continually refreshed, that stale candidate need not repair itself.
|
||||
|
||||
Wire regional probe adapters into the production service and have the client
|
||||
complete authenticated probe collection for supported regions after queuing
|
||||
(or before making a candidate visible to the matcher), and update/invalidate
|
||||
the Redis projection after probe persistence. Add a full production-wiring
|
||||
test proving a newly logged-in client can acquire RTT evidence and be selected
|
||||
through both the PostgreSQL and Redis paths without direct database seeding.
|
||||
|
||||
## [P0] Publish signed assignment rosters before starting allocated servers
|
||||
|
||||
**Location:** `server/allocator/worker.go:34-79`,
|
||||
`server/cmd/allocator/main.go:81-93`, `server/allocator/service.go:84-91`,
|
||||
`server/store/assignment_sql.go:178-331`,
|
||||
`server/supervisor/supervisor.go:198-224`,
|
||||
`server/supervisor/supervisor.go:313-388`
|
||||
|
||||
The worker stops after binding the provider allocation. Although
|
||||
`Service.PublishRoster` and `SaveVerifiedAssignmentRoster` exist, the
|
||||
production allocator configures no roster store/signing key and never calls
|
||||
them. The allocated supervisor fetches a non-empty roster before it launches
|
||||
the game child, so every real allocation fails at that fetch and can never
|
||||
reach assignment-ready or accept a player. Existing tests seed assignments
|
||||
directly and therefore bypass the missing production hand-off.
|
||||
|
||||
Define the signing-key ownership and rotation model, build one signed join
|
||||
authorisation per participant, persist the assignment and roster atomically
|
||||
with the allocation transition, and make retries idempotent. Exercise the real
|
||||
allocator worker through supervisor startup without fixture-seeding the
|
||||
assignment tables.
|
||||
|
||||
## [P0] Allow both game traffic and workload callbacks through NetworkPolicy
|
||||
|
||||
**Location:** `deploy/k8s/base/network-policies.yaml:1-92`,
|
||||
`deploy/k8s/base/fleet.yaml:54-83`
|
||||
|
||||
The namespace-wide policy selects every pod and denies ingress and egress. No
|
||||
ingress policy allows UDP/7777 to `game-server` pods, so public players cannot
|
||||
reach an allocated ENet server. Independently, game-server egress permits TCP
|
||||
8080 to the control plane, but control-plane ingress permits only pods labelled
|
||||
`edge-gateway`; the game-server source is not allowed. Consequently roster
|
||||
fetch, registration, connection receipts, shutdown, and result submission are
|
||||
all blocked even inside the cluster.
|
||||
|
||||
Add narrowly scoped game-server UDP ingress for the chosen Agones/public relay
|
||||
source and control-plane TCP ingress from the game-server pod selector. Keep
|
||||
the default deny and add policy tests for both directions, including a real
|
||||
NetworkPolicy-enforcing cluster smoke test.
|
||||
|
||||
## [P1] Emit a valid initial-connect outbox envelope so one row cannot poison the queue
|
||||
|
||||
**Location:** `server/store/initial_connect_sql.go:155-164`,
|
||||
`server/api/outbox.go:95-106`, `server/api/outbox.go:168-195`,
|
||||
`server/store/outbox.go:46-51`
|
||||
|
||||
`ApplyInitialConnectPlan` writes `state_changed` payloads containing only
|
||||
`match_id`, `state`, and `action`. The state dispatcher requires `event`,
|
||||
`revision`, `resource_id`, `occurred_at`, and a non-empty `player_ids` list, so
|
||||
delivery always rejects that row. Dispatch stops on the first error and the row
|
||||
is never acknowledged; because reads are ordered oldest-first, the malformed
|
||||
row is retried forever and can prevent all later state events in the batch from
|
||||
being delivered.
|
||||
|
||||
Construct the same complete envelope used by the other lifecycle writers (or
|
||||
centralize envelope creation), include the authoritative participant list, and
|
||||
add a store-to-dispatch integration test for both LIVE and CANCELLED initial-
|
||||
connect outcomes. Also isolate/dead-letter permanently invalid rows so one bad
|
||||
event cannot globally head-of-line block publication.
|
||||
|
||||
## [P1] Load authoritative ratings into ranked matcher candidates
|
||||
|
||||
**Location:** `server/store/queue_sql.go:61-66`,
|
||||
`server/store/queue_sql.go:105-131`, `server/domain/matcher.go:171-186`,
|
||||
`server/domain/matcher.go:220-239`, `server/domain/teams.go:59-93`
|
||||
|
||||
The production candidate query does not join or otherwise read the `ratings`
|
||||
table, and its scan never sets `domain.Candidate.Rating`. All PostgreSQL-
|
||||
sourced ranked candidates therefore have the Go zero value. Rating tolerance,
|
||||
selection scoring, and team partitioning all consume that field, so ranked
|
||||
matchmaking treats every player as identically rated regardless of their
|
||||
authoritative profile. Unit tests mask the defect by constructing candidates
|
||||
with ratings directly.
|
||||
|
||||
Populate ranked candidates from the authoritative rating row (with an explicit
|
||||
default for a genuinely new profile), carry it through Redis, and add store-
|
||||
backed matcher tests with deliberately distant ratings and a team-balancing
|
||||
assertion. Never accept a client-supplied rating.
|
||||
|
||||
## [P1] Partition and bound Redis snapshots before filtering by playlist
|
||||
|
||||
**Location:** `server/store/redis_candidates.go:80-85`,
|
||||
`server/store/redis_candidates.go:141-188`,
|
||||
`server/cmd/matcher/main.go:67-93`
|
||||
|
||||
Both playlists share one Redis hash/sorted set. `Snapshot` performs an
|
||||
unbounded `ZRANGEBYSCORE` and `HMGET`, materializes and decodes the whole queue,
|
||||
then the matcher truncates to its candidate limit *before* filtering by
|
||||
playlist. A large casual prefix can therefore make the ranked worker see zero
|
||||
candidates indefinitely even when ranked tickets exist later in the set. A
|
||||
repair is worse: each matcher captures only its selected playlist as the
|
||||
durable source, but `Rebuild` replaces the shared keys, so a casual repair can
|
||||
erase ranked projections and vice versa. The unbounded read also makes each
|
||||
one-second poll allocate and transfer data proportional to total queue depth.
|
||||
|
||||
Use playlist-specific keys and make the snapshot API accept a hard limit that
|
||||
is applied by Redis (`LIMIT 0 N`) before transfer. Rebuild only the matching
|
||||
playlist namespace. Add mixed-playlist and large-backlog tests proving neither
|
||||
worker can erase/starve the other and that Redis never receives an unbounded
|
||||
range/HMGET.
|
||||
|
||||
## [P1] Enforce durable identity bans during session issuance and authentication
|
||||
|
||||
**Location:** `server/migrations/0001_initial.sql:5-10`,
|
||||
`server/store/session_sql.go:16-22`, `server/store/session_sql.go:49-63`,
|
||||
`server/domain/auth.go:166-197`
|
||||
|
||||
The durable schema has `banned_until` and `ban_reason`, but production session
|
||||
authentication reads only the `sessions` row and no production store code
|
||||
reads either ban column. The only ban check is an in-memory `TicketVerifier`
|
||||
used by domain tests. Once real Steam login is wired, a banned identity can
|
||||
continue using every existing session until expiry and, unless the future
|
||||
adapter independently duplicates this policy, can receive new sessions too.
|
||||
This defeats the server-authoritative anti-abuse boundary.
|
||||
|
||||
Make ban state part of the durable authentication transaction: refuse session
|
||||
issuance for an active ban and join/check identities on every authenticated
|
||||
request (or revoke all sessions atomically when applying a ban). Add tests for
|
||||
immediate enforcement across two control-plane replicas and for expiry/unban
|
||||
semantics.
|
||||
|
||||
## [P1] Fan out outbox events to every control-plane replica
|
||||
|
||||
**Location:** `deploy/k8s/base/control-plane-deployment.yaml:8-14`,
|
||||
`server/api/events.go:55-117`, `server/api/events.go:217-230`,
|
||||
`server/api/outbox.go:69-90`, `server/store/outbox.go:46-60`
|
||||
|
||||
The Deployment runs two replicas, but WebSocket subscribers live only in each
|
||||
process's in-memory hub. Every replica races to read the same global unpublished
|
||||
outbox rows, and publishing succeeds even when the winning replica has no
|
||||
matching local subscriber; that replica then sets the single global
|
||||
`published_at`. A client connected to the other replica never receives the
|
||||
event. The REST recovery polls eventually converge, but WebSocket delivery
|
||||
degrades as replicas are added and short-lived proposal transitions can be
|
||||
observed late.
|
||||
|
||||
Publish committed events through a shared fan-out transport, or maintain a
|
||||
durable per-replica/consumer-group cursor so every connection-owning replica
|
||||
sees them. Do not globally acknowledge merely because a local hub accepted an
|
||||
event for zero subscribers. Add a two-replica integration test with the client
|
||||
connected to the non-consuming replica.
|
||||
|
||||
## [P1] Add retention for high-volume idempotency and outbox records
|
||||
|
||||
**Location:** `server/migrations/0001_initial.sql:13-29`,
|
||||
`server/migrations/0001_initial.sql:147-177`,
|
||||
`Game/scripts/matchmaking.gd:38-52`,
|
||||
`Game/scripts/control_plane_client.gd:794-795`,
|
||||
`server/store/queue_sql.go:262-320`, `server/cmd/maintenance/main.go:57-104`
|
||||
|
||||
Each ten-second queue heartbeat gets a fresh idempotency key and permanently
|
||||
inserts a new row. Published outbox rows and expired/revoked sessions are also
|
||||
never purged; the maintenance role performs lifecycle reconciliation only.
|
||||
At 10,000 queued players, heartbeats alone add roughly 60,000 durable rows per
|
||||
minute, causing unbounded table/index growth, vacuum pressure, backup growth,
|
||||
and progressively slower recovery on a service intended to scale horizontally.
|
||||
|
||||
Define retention windows longer than every supported retry/recovery horizon,
|
||||
index cleanup predicates, and delete/archive in bounded `SKIP LOCKED` batches.
|
||||
Expose deletion lag/row-count metrics and load-test sustained heartbeat volume
|
||||
to verify that steady-state storage remains bounded.
|
||||
|
||||
## [P2] Make the observability verifier test reach its intended assertion
|
||||
|
||||
**Location:** `server/security/test_observability_manifests.py:20-32`,
|
||||
`scripts/verify_observability_manifests.py:16-22`
|
||||
|
||||
`test_checker_rejects_wrong_namespace_and_broad_scrape` copies only the
|
||||
control-plane ServiceMonitor and rules into its temporary directory. The
|
||||
verifier first requires `kustomization.yaml` and the allocator ServiceMonitor,
|
||||
so the test fails on a missing file before it examines the mutated namespace
|
||||
or scrape path. The security suite is red and the stated regression case is
|
||||
not covered.
|
||||
|
||||
Copy the complete minimum fixture (including kustomization and allocator
|
||||
ServiceMonitor), then assert the namespace and `/metrics` mutations separately
|
||||
so either defect produces the intended diagnostic.
|
||||
|
||||
## [P2] Synchronize the contract test with the renamed connection operation
|
||||
|
||||
**Location:** `server/contracts/v1/test_contracts.py:21-28`,
|
||||
`server/contracts/v1/openapi.json:54`
|
||||
|
||||
The OpenAPI document calls the endpoint `claimPlayerConnection`, while the
|
||||
structural test still requires `recordPlayerConnected`. The checked-in
|
||||
contract suite therefore fails despite the endpoint being present, making the
|
||||
gate noisy and capable of obscuring real compatibility regressions.
|
||||
|
||||
Choose the intended public operation ID and update the test or document. If
|
||||
the rename is intentional, document the generated-client compatibility impact
|
||||
and assert `claimPlayerConnection` consistently.
|
||||
|
||||
## Verification notes
|
||||
|
||||
- `go test ./...`: passed.
|
||||
- `go test -race ./...`: passed.
|
||||
- `go vet ./...`: passed.
|
||||
- Godot unit suite: 220 tests passed with the project-compatible headless
|
||||
renderer flags.
|
||||
- Training unit suite: 16 focused generation/evaluation tests passed in
|
||||
`training/.venv`; the reviewed training changes keep new distributions and
|
||||
team reward sharing opt-in, so no training-regression finding was raised.
|
||||
- Contract suite: one failure, recorded above.
|
||||
- Security manifest suite: one failure, recorded above.
|
||||
- Script verifier unit suite: 10 tests passed.
|
||||
+113
-3
@@ -105,6 +105,15 @@ port-forwarding requirement and to supply verified player identity —
|
||||
direct-IP ENet's slot-reclaim logic is keyed by display name today, which is
|
||||
insecure against a public server (`multiplayer-next.md` §0, known defect C).
|
||||
|
||||
There is a **second, independent** use of Steam that does not involve
|
||||
GodotSteam at all: `server/steam/` verifies session tickets server-side against
|
||||
Valve's `ISteamUserAuth/AuthenticateUserTicket` Web API over plain HTTP, which
|
||||
is what turns a claimed identity into a trusted one for matchmaking and for
|
||||
slot reclaim. It distinguishes "Valve rejected this ticket" (401) from "Valve
|
||||
is unreachable" (503) so an outage cannot be mistaken for an authentication
|
||||
failure, and refuses family-shared and banned accounts. It needs a **publisher
|
||||
Web API key**, which is a server-side secret and must never reach a client.
|
||||
|
||||
## Dedicated server hosting: Docker (primary) or native systemd
|
||||
|
||||
The dedicated server is not a separately-written service — it's the same
|
||||
@@ -206,6 +215,81 @@ file and runs **inside the game** in pure GDScript — shipped bots need no
|
||||
Python, no .NET, no network." Keeping the shipped game GDScript-only (no
|
||||
.NET Godot build) is consistent with the rest of the stack.
|
||||
|
||||
## Version inventory
|
||||
|
||||
Everything the project actually pins, in one place. The rest of this document
|
||||
explains *why* these were chosen; this is *what* is in use. Versions here are
|
||||
the source of truth's values at the time of writing — when they disagree with
|
||||
the files named, the files win.
|
||||
|
||||
### Shipped game and dedicated server
|
||||
|
||||
| Thing | Version | Pinned in |
|
||||
|---|---|---|
|
||||
| Godot | 4.7.1 | `Dockerfile` (digest-pinned `barichello/godot-ci`) |
|
||||
| Physics | Jolt | `Game/project.godot` — `3d/physics_engine="Jolt Physics"` |
|
||||
| Runtime dependencies | none | pure GDScript; no .NET, no ONNX, no native extensions in the default build |
|
||||
| GodotSteam | custom build, opt-in | `steam-dependencies.lock.json` |
|
||||
|
||||
The shipped client and server carry **no third-party runtime dependency at
|
||||
all** in the default ENet build. That is a deliberate constraint, not an
|
||||
accident of scope — see "What's deliberately absent".
|
||||
|
||||
### Matchmaking control plane (Go)
|
||||
|
||||
| Thing | Version | Notes |
|
||||
|---|---|---|
|
||||
| Go | 1.23 | `server/go.mod` |
|
||||
| `jackc/pgx/v5` | 5.7.4 | PostgreSQL driver; used through `database/sql` for pooling, and directly for `LISTEN`/`NOTIFY`, which needs a dedicated session |
|
||||
| `redis/go-redis/v9` | 9.7.0 | transient candidate index only; the durable queue is PostgreSQL |
|
||||
| `alicebob/miniredis/v2` | 2.38.0 | test-only in-process Redis |
|
||||
|
||||
Four direct dependencies, three of them drivers. There is no web framework, no
|
||||
ORM, no DI container and no code generation: HTTP is `net/http` with a hand-
|
||||
written mux (`server/api/service.go`), SQL is hand-written, and migrations are
|
||||
numbered `.sql` files under `server/migrations/` — each with a `down/`
|
||||
counterpart — applied by the `cmd/migrate` binary. That is a deliberate choice
|
||||
about a service whose whole job is a small number of carefully-fenced
|
||||
transactions.
|
||||
|
||||
Rating maths is Glicko-2, implemented in `server/domain/rating.go` rather than
|
||||
taken from a library.
|
||||
|
||||
### Datastores and platform
|
||||
|
||||
| Thing | Version | Pinned in |
|
||||
|---|---|---|
|
||||
| PostgreSQL | 17 (alpine) | `compose.*.yml`, `scripts/run_*_integration.sh` |
|
||||
| Redis | 7 (alpine) | `compose.*.yml`, `scripts/run_redis_integration.sh` |
|
||||
| Agones | 1.49.0 | `scripts/verify_kind_agones.sh` (`AGONES_VERSION`) |
|
||||
| Kubernetes | 1.33 in CI | `kindest/node:v1.33.1` |
|
||||
| Manifests | Kustomize | `deploy/k8s/base` + `overlays/{eu,na}` |
|
||||
| Metrics | Prometheus | `deploy/observability/` — ServiceMonitors and PrometheusRules |
|
||||
|
||||
Container images are referenced by digest, never by tag; `scripts/verify_supply_chain.py`
|
||||
fails the build on any mutable reference. All six digests under `deploy/` are
|
||||
currently all-zero placeholders, and the `ghcr.io/cosmic-clash/*` registry
|
||||
namespace does not exist yet — publishing the images is the open work tracked
|
||||
in issue #31, and is the last thing standing between the manifests and a real
|
||||
deployment.
|
||||
|
||||
### Training (out-of-process, not shipped)
|
||||
|
||||
| Thing | Version |
|
||||
|---|---|
|
||||
| Python | 3.12 |
|
||||
| `godot-rl` | 0.8.2 |
|
||||
| `stable-baselines3` | 2.4.0 |
|
||||
| `torch` | 2.13.0 |
|
||||
| `gymnasium` | 1.0.0 |
|
||||
| `tensorboard` | 2.21.0 |
|
||||
|
||||
Pinned exactly, and `training/requirements.txt` explains why in unusual detail:
|
||||
the curriculum depends on specific library *internals* rather than documented
|
||||
public APIs, so an unpinned reinstall could silently change behaviour partway
|
||||
through a 12-hour training stage. None of this ships — the game runs exported
|
||||
policies through a pure-GDScript MLP.
|
||||
|
||||
## Tooling (not shipped with the game)
|
||||
|
||||
- **`mcp/godot-mcp`** (git submodule, Node/TypeScript) — drives a live
|
||||
@@ -218,6 +302,29 @@ Python, no .NET, no network." Keeping the shipped game GDScript-only (no
|
||||
(Blender's embedded Python, plus texture generators) used to produce the
|
||||
project's original meshes and textures.
|
||||
|
||||
### Verification toolchain
|
||||
|
||||
Everything is driven from `Makefile` targets so that CI and a local run are the
|
||||
same command:
|
||||
|
||||
- **GNU Make** — the single entry point (`verify-phase6`,
|
||||
`verify-enet-integration`, `verify-kind-agones`, `verify-supply-chain`, …).
|
||||
- **Docker and Docker Compose** — the multi-process gates. The game gates use
|
||||
a staged `Dockerfile`; the control-plane gates use `compose.*.yml` fixtures.
|
||||
- **kind** (`kindest/node:v1.33.1`) **and Helm** — a throwaway Kubernetes
|
||||
cluster with Agones installed, for the allocation gate.
|
||||
- **Kustomize** — `deploy/k8s/base` plus `overlays/{eu,na}`, validated by
|
||||
`kubectl kustomize` in CI rather than only at deploy time.
|
||||
- **GitHub Actions** — eight workflows under `.github/workflows/`, each one a
|
||||
thin wrapper around a Make target, with path filters so a docs-only change
|
||||
doesn't spin up a Kubernetes cluster.
|
||||
- **`scripts/verify_supply_chain.py`** — fails the build on any mutable image
|
||||
reference, which is why every manifest pins by digest.
|
||||
|
||||
Godot itself has **no build step and no linter** — the project runs from
|
||||
source, so "the tests pass" is the only mechanical check that exists on the
|
||||
GDScript side.
|
||||
|
||||
## What's deliberately absent
|
||||
|
||||
- **No C# or .NET runtime anywhere in the shipped game or server.** The
|
||||
@@ -238,8 +345,11 @@ Python, no .NET, no network." Keeping the shipped game GDScript-only (no
|
||||
- **The remaining Go matchmaking control-plane deployment** — independently
|
||||
runnable matcher, allocator and maintenance roles backed by PostgreSQL and
|
||||
Redis, deployed on provider-portable Kubernetes with Agones-managed game
|
||||
fleets. The authenticated API boundary exists; durable production wiring and
|
||||
provider deployment remain. The cloud provider remains deliberately
|
||||
replaceable; the application stack is locked.
|
||||
fleets. The durable wiring now exists end to end — queue, latency probes,
|
||||
proposal, allocation, signed assignment rosters and result submission — and
|
||||
is exercised by Compose and kind/Agones gates in CI. What remains is the
|
||||
provider deployment itself: a registry to publish the images to, and a live
|
||||
cluster. The cloud provider remains deliberately replaceable; the application
|
||||
stack is locked.
|
||||
This is a 1.0 launch blocker and the single largest departure from "one
|
||||
Godot project, no backend". See [`MATCHMAKING.md`](MATCHMAKING.md).
|
||||
|
||||
+87
-37
@@ -41,21 +41,65 @@ blocker and is in progress.** It is larger than anything below and adds a
|
||||
backend service outside the Godot project. Tasks are in §7; the design is in
|
||||
[`docs/MATCHMAKING.md`](docs/MATCHMAKING.md).
|
||||
|
||||
**The current root blocker** ([#14](https://github.com/jcreek/CosmicClash/issues/14)): nothing in production ever publishes a
|
||||
player's signed match assignment. `store.SaveAssignment`/`SaveAssignments`/
|
||||
`SaveVerifiedAssignmentRoster` and `allocator.Service.PublishRoster` are
|
||||
fully built and tested in isolation, but no real code path
|
||||
(`allocator/worker.go`, `cmd/allocator`) ever calls them — only tests do, by
|
||||
seeding the table directly rather than exercising the real write path. Since
|
||||
**The former root blocker** ([#14](https://github.com/jcreek/CosmicClash/issues/14)) **is closed.** Nothing in production
|
||||
used to publish a player's signed match assignment:
|
||||
`store.SaveVerifiedAssignmentRoster` and `allocator.Service.PublishRoster`
|
||||
were fully built and tested in isolation, but no real code path called them —
|
||||
only tests did, by seeding the table directly. Since
|
||||
`AdvanceServerRegistration`'s SQL requires an `assignments` row per
|
||||
participant before a match can reach `ASSIGNMENT_READY`, **a real deployment
|
||||
cannot advance any match past `PROCESS_READY`** — no player can ever receive
|
||||
a real assignment or connect, regardless of how correct every other piece
|
||||
(including the client-side connect-wiring in task 8.41) is. See task 8.31
|
||||
for the full detail. Closing it needs new security-relevant design (a
|
||||
join-signing key shared between allocator and game server, roster-digest
|
||||
computation, per-player authorisation construction) — flagged rather than
|
||||
built, at the user's explicit direction, pending a decision on that design.
|
||||
participant before a match can reach `ASSIGNMENT_READY`, a real deployment
|
||||
could not advance any match past `PROCESS_READY`.
|
||||
|
||||
`allocator.Worker.RunOnce` now builds one signed join authorisation per
|
||||
durable participant and publishes the roster after binding the allocation, and
|
||||
`cmd/allocator` refuses to start without key material rather than stranding
|
||||
every match silently. The signing-key design that was pending a decision is
|
||||
settled: HMAC-SHA256 over the canonical claim bytes, with a **key ID** in
|
||||
those bytes so allocated servers can hold the set of currently-valid keys and
|
||||
rotation does not invalidate authorisations already issued for in-flight
|
||||
matches. See `docs/MATCHMAKING.md` §2 for the rotation procedure.
|
||||
|
||||
Two further blockers of the same shape were found and closed alongside it:
|
||||
regional RTT probing had no nonce-issuing endpoint (so no client-created
|
||||
ticket could ever be selected — the matcher requires non-empty RTT evidence),
|
||||
and the Kubernetes base deployed a control-plane image nothing built while
|
||||
building a matcher image nothing deployed. What remains for a live deployment
|
||||
is external: a Steamworks App ID and publisher key ([#15](https://github.com/jcreek/CosmicClash/issues/15)),
|
||||
custom GodotSteam builds ([#16](https://github.com/jcreek/CosmicClash/issues/16)),
|
||||
and a real cluster ([#17](https://github.com/jcreek/CosmicClash/issues/17)) and the images
|
||||
to run there ([#31](https://github.com/jcreek/CosmicClash/issues/31)).
|
||||
|
||||
**Rows were audited against the code on 2026-09-05.** Nine understated what
|
||||
was already built — 8.6, 8.8, 8.13, 8.16, 8.19, 8.30, 8.42, 8.43, 8.52 — on
|
||||
top of 7.4, 8.7, 8.20, 8.22 and 8.39 corrected while working on them. The
|
||||
drift ran one way: rows kept listing work that had since landed, which makes
|
||||
the backlog look larger than it is and invites rebuilding what exists. Twice
|
||||
during this branch a task was picked up only to find one of its named parts
|
||||
already complete (8.20's allocation wiring, 8.22's client UI). **When picking
|
||||
up a row, verify its claim against the code before planning against it** —
|
||||
and correct the row if it is stale, since an unverified row is a rumour, not
|
||||
a backlog item.
|
||||
|
||||
Every corrected claim is backed by an executable test rather than by having
|
||||
located an implementation, because locating one proves it exists, not that it
|
||||
works:
|
||||
|
||||
| Claim | Proof |
|
||||
|---|---|
|
||||
| 8.6 allocated `ServerConfig` fields | `test_server_config.gd::test_allocated_mode_is_opt_in_and_requires_compatibility_manifest` |
|
||||
| 8.6 signed-authorisation admission | `test_match_net.gd` join-authorisation cases, incl. the key-rotation set |
|
||||
| 8.6 endpoint wiring | `test_assignment_state.gd` — endpoint preserved, unsafe endpoint rejected |
|
||||
| 8.8 cross-replica revocation | `TestPostgreSQLSessionRevocationIsImmediateOnAnotherReplica` |
|
||||
| 8.19 lineup reached through formation | `TestPrepareProposalBuildsCasualLineupBeforeCreatingProposal` |
|
||||
| 8.19 all four penalty kinds durable | existing integration tests, plus `TestPostgreSQLInitialConnectNoShowWritesADurablePenalty` |
|
||||
| 8.30 signed roster metadata | `TestRealAllocatorWorkerPublishesSignedAssignmentRoster` |
|
||||
| 8.42 season countdown | `test_control_plane_client.gd` — `"Season ends in 2d"` and the clamped case |
|
||||
| 8.16/8.43 matcher deployed | `test_kubernetes_policies.py::test_every_required_workload_role_is_deployed` |
|
||||
|
||||
Two claims had no proof and needed one written: `INITIAL_CONNECT_NO_SHOW`
|
||||
penalties and cross-replica revocation. Both new tests were mutation-checked —
|
||||
disabling the behaviour makes them fail — so they assert something real. 8.13
|
||||
and 8.52 are cross-references and assert nothing.
|
||||
|
||||
### Blocking sign-off — the work exists, the verification does not
|
||||
|
||||
@@ -139,9 +183,9 @@ retrofitting one.
|
||||
| 7.1 `[D:1.2]` | GodotSteam integration and custom export templates, client *and* headless server | Awaiting the custom binaries/SDK access |
|
||||
| 7.2 `[D:7.1]` | `NetTransport` Steam implementation (`SteamMultiplayerPeer`, SDR) | Server advertising waits for `ISteamGameServer` work |
|
||||
| 7.3 `[D:7.2]` `[P]` | Server-browser UI and `ISteamMatchmakingServers` adapter | Unimplemented until real Steam SDK/API access is available; ENet direct-IP remains the supported browser-free path meanwhile |
|
||||
| 7.4 `[D:7.2]` `[P]` | Auth tickets in `hello` → `BeginAuthSession`, Steam identity in the roster, persistent ban list | Real GodotSteam auth integration, server-side VAC state, durable ban storage remain. **Fixes known defect C** for direct/community servers once landed |
|
||||
| 7.4 `[D:7.2]` `[P]` | Auth tickets in `hello` → `BeginAuthSession`, Steam identity in the roster, persistent ban list | Durable ban storage landed: `identities.banned_until`/`ban_reason` are enforced on both session issuance and every authenticated request, and `ApplyIdentityBan` revokes an identity's sessions in the same transaction. Real GodotSteam auth integration and server-side VAC state remain (VAC state is read at login by the Web API adapter, but is not yet re-checked mid-session). **Fixes known defect C** for direct/community servers once landed |
|
||||
| 7.5 `[D:7.2]` `[P]` | `SteamBootstrap` gating (stock builds keep ENet, explicit Steam selection fails closed) | Custom Steam client/server export smoke remains blocked on pinned GodotSteam binaries |
|
||||
| 7.6 `[D:7.4]` | Backend `AuthCoordinator`, session persistence, `ControlPlaneClient.login_steam()` | Real Steam `BeginAuthSession`/`EndAuthSession` adapter, login UI, live PostgreSQL/session integration remain |
|
||||
| 7.6 `[D:7.4]` | Backend `AuthCoordinator`, session persistence, `ControlPlaneClient.login_steam()`, real `ISteamUserAuth/AuthenticateUserTicket` adapter (`server/steam`), client web-API ticket acquisition, sign-in before matchmaking | Needs a real App ID and publisher key ([#15](https://github.com/jcreek/CosmicClash/issues/15)) and a custom GodotSteam build ([#16](https://github.com/jcreek/CosmicClash/issues/16)) to exercise live; sign-in is config-gated and returns 503 until both are set |
|
||||
| 7.7 `[D:7.1]` `[P]` | Obtain the production App ID, publisher key, SDR coordinator SDK/signing approval, certificates and hosted-data-centre support from Valve | Not started |
|
||||
| 7.8 `[D:7.6,7.7]` | Ticketed Hosted Dedicated Server SDR: routing registration, coordinator-issued player→server relay tickets, client ticket installation, reconnect and expiry | Not started; depends on 7.6 and 7.7 |
|
||||
|
||||
@@ -153,8 +197,13 @@ a component outside the Godot project — a Go backend service — and that is
|
||||
the largest architectural departure in the project's history; read the
|
||||
design doc before picking up any task below. The local control-plane,
|
||||
durable-store, allocated-server, and verification paths are substantially
|
||||
implemented; every row below lists only what's still open, not what's
|
||||
built. **The critical path is task 8.31 — see §0's root blocker.**
|
||||
implemented; every row below is *intended* to list only what's still open,
|
||||
not what's built — but see §0's audit note: rows drift toward understating
|
||||
what has landed, so verify a row's claim against the code before planning
|
||||
against it. **Task 8.31, formerly the critical path, is done — see §0.** What now
|
||||
gates a live deployment is external: an App ID ([#15](https://github.com/jcreek/CosmicClash/issues/15)),
|
||||
GodotSteam builds ([#16](https://github.com/jcreek/CosmicClash/issues/16)), and a
|
||||
cluster ([#17](https://github.com/jcreek/CosmicClash/issues/17)).
|
||||
|
||||
**Hard dependency on 7.6 and 7.8.** The local allocated path binds slot
|
||||
reclaim to a control-plane-signed player identity and locks its team/slot
|
||||
@@ -177,35 +226,35 @@ are done; everything below is what's left on the tasks still open.
|
||||
|
||||
| # | Task | Remaining |
|
||||
|---|---|---|
|
||||
| 8.5 `[D:8.4]` | PostgreSQL migrations 0001–0013 (idempotency, queue fencing, identities, ratings, matches, results, audits, outbox, allocator registry, proposal plans, leases, quotas) | New validations await a live database rerun — Docker storage exhausted locally |
|
||||
| 8.6 `[D:8.3,8.4]` | Allocated-mode `ServerConfig` fields | Signed-authorisation admission, dynamic endpoint wiring, full manifest/runtime tests remain |
|
||||
| 8.5 `[D:8.4]` | PostgreSQL migrations 0001–0017 (idempotency, queue fencing, identities, ratings, matches, results, audits, outbox, allocator registry, proposal plans, leases, quotas, outbox dead-letter, retention indexes, allocation endpoints, probe challenges) | Verified against a live PostgreSQL; migrations now run to 0017. The local Docker storage exhaustion is a recurring symptom, not a one-off — see §9 gotcha on the integration scripts leaking anonymous volumes |
|
||||
| 8.6 `[D:8.3,8.4]` | Allocated-mode `ServerConfig` fields | Allocated-mode fields are all present in `ServerConfig` (`allocated-mode`, `match-id`, `server-id`, `playlist`, `client-build`, `assignment-expiry-unix`, `server-image-digest`, `transport`, `region`, the join-authorisation file/key pair, `readiness-port`, `drain-token-env`). Signed-authorisation admission is implemented in `MatchNet` and was hardened with key-set rotation; dynamic endpoint wiring exists via `AssignmentState` and `connect_to_assignment()`. Only live runtime verification against a real cluster remains ([#17](https://github.com/jcreek/CosmicClash/issues/17)) |
|
||||
|
||||
#### 8B — Authentication and secure control plane
|
||||
|
||||
| # | Task | Remaining |
|
||||
|---|---|---|
|
||||
| 8.7 `[D:7.6,8.3]` | Ticket policy binding expected App ID/identity | Real `AuthenticateUserTicket` backend adapter, bans, publisher secret store, real Steam verification remain |
|
||||
| 8.8 `[D:8.7]` | Session policy (opaque tokens, digests, revocation) | Distributed revocation coordination, live Steam/session integration remain |
|
||||
| 8.7 `[D:7.6,8.3]` | Ticket policy binding expected App ID/identity | Adapter, bans and secret store landed: `server/steam` calls `ISteamUserAuth/AuthenticateUserTicket`, rejects family-shared and banned accounts, and separates a Valve outage (503) from a bad ticket (401); the publisher key is mounted into the control-plane Deployment alone from the `cosmic-clash-steam` Secret, asserted by a manifest test. Only verification against real Valve remains, which needs the App ID and key ([#15](https://github.com/jcreek/CosmicClash/issues/15)) |
|
||||
| 8.8 `[D:8.7]` | Session policy (opaque tokens, digests, revocation) | Distributed revocation coordination is done by construction: sessions are durable and `PostgresSessions.Authenticate` reads the row on every authenticated request, so a revocation takes effect immediately on every replica without any cross-replica protocol, and `ApplyIdentityBan` revokes an identity's sessions in the same transaction as the ban. Live Steam/session integration remains ([#15](https://github.com/jcreek/CosmicClash/issues/15)) |
|
||||
| 8.9 `[D:8.4,8.7]` | Join policy, durable reconnect leases | Live PostgreSQL/Godot process-restart and outage recovery verification remains |
|
||||
| 8.10 `[D:8.5,8.31]` | Workload credential policy (signed tokens, not Kubernetes JWTs), delivery channel, conflict alerting | Never run against a real Agones cluster; alert validated only statically, not against live Prometheus/Alertmanager traffic |
|
||||
| 8.12 `[D:8.11]` | Kubernetes hardening baseline, rate/quota limiting, degraded-mode gate | Private-store provisioning, distributed/global quotas, edge DDoS/WAF/origin shielding, encrypted backups, live policy/load tests remain |
|
||||
| 8.13 `[D:8.12]` | Digest-pinned images, supply-chain policy checker | Registry SBOM/scan/sign/admission execution and a concrete production overlay remain |
|
||||
| 8.12 `[D:8.11]` | Kubernetes hardening baseline, rate/quota limiting, degraded-mode gate | Private-store provisioning, distributed/global quotas, edge DDoS/WAF/origin shielding, encrypted backups, live policy/load tests remain. The workload namespace currently enforces `privileged` because Agones' Dynamic port policy injects a `hostPort` that `baseline`/`restricted` forbid; splitting game servers into their own namespace so `cosmic-clash` can enforce `restricted` again is tracked by [#33](https://github.com/jcreek/CosmicClash/issues/33) |
|
||||
| 8.13 `[D:8.12]` | Digest-pinned images, supply-chain policy checker | Registry SBOM/scan/sign/admission execution and a concrete production overlay remain — the build-and-pin half is tracked by [#31](https://github.com/jcreek/CosmicClash/issues/31) |
|
||||
|
||||
#### 8C — Queueing, matchmaking, playlists and rating
|
||||
|
||||
| # | Task | Remaining |
|
||||
|---|---|---|
|
||||
| 8.14 `[D:8.4,8.5,8.8]` | Queue policy (ownership, heartbeat/expiry, candidate projection) | Live Redis failover-under-load and worker integration remain |
|
||||
| 8.15 `[D:7.8,8.3]` | Probe validation (RTT, nonce/freshness/region, quarantine) | Steam coordinator, regional probe adapters, multi-region probe population remain |
|
||||
| 8.16 `[D:8.14,8.15]` | Candidate/team formation, matcher worker | Long-running worker integration remains |
|
||||
| 8.15 `[D:7.8,8.3]` | Probe validation (RTT, nonce/freshness/region, quarantine), `POST /v1/probes/{region}/challenge`, durable single-use nonces, client probe collection before queueing, candidate-index refresh after probe | Steam coordinator ping-location source remains (a placeholder blob is sent without a Steam runtime); multi-region endpoint deployment remains |
|
||||
| 8.16 `[D:8.14,8.15]` | Candidate/team formation, matcher worker | The matcher is a real long-running poll loop and now has casual and ranked Deployments in `deploy/k8s/base`; what remains is live soak against a cluster rather than the integration itself ([#17](https://github.com/jcreek/CosmicClash/issues/17)) |
|
||||
| 8.17 `[D:8.14,8.16]` | Proposal policy (response window, cooldowns, offender/innocent split) | Live PostgreSQL execution and allocation integration remain |
|
||||
| 8.18 `[D:8.5,8.14,8.17]` | Store layer (serializable retries, claim SQL, atomic promotion) | Allocation runtime integration remains |
|
||||
| 8.19 `[D:8.18]` | Casual lineup (2–6 humans, bot backfill) | Queue candidate selection, opt-in 10 s backfill proposals, reconnect/leave penalties, live integration remain |
|
||||
| 8.20 `[D:8.18]` | Ranked admission (six unique verified humans) | `ArenaRegistry` integration and allocation wiring remain |
|
||||
| 8.19 `[D:8.18]` | Casual lineup (2–6 humans, bot backfill) | Candidate selection landed (`domain.SelectCasualBackfillCandidate`: oldest ordinary casual ticket meeting build/region/tolerance, ties by ticket ID, deterministic across replicas). Casual lineup formation was already built and wired, and all four penalty kinds are written durably. What remains is the backfill proposal itself, the matcher pass that finds vacated kickoff slots, the client offer UI, and **late roster delivery** — a backfilled player's authorisation is issued after their server started, and the supervisor fetches the roster once before launching the game child with no reload path. That delivery design is now decided (supervisor re-fetches and signals a reload; see `docs/MATCHMAKING.md` § Casual) and the remaining work is tracked in [#32](https://github.com/jcreek/CosmicClash/issues/32). End-to-end verification needs a live cluster ([#17](https://github.com/jcreek/CosmicClash/issues/17)) |
|
||||
| 8.20 `[D:8.18]` | Ranked admission (six unique verified humans) | Done. Allocation wiring was already complete end to end (allocator sets the `cosmic-clash.io/arena-path` annotation → `supervisor.withAllocatedCompatibility` maps it to `--arena-path` → `server_boot.gd` → `ServerMatchLoop.allocated_arena_path`), with coverage at each hop. `ArenaRegistry` integration is now a cross-language guard rather than a shared list: `server/domain/ranked.go` must keep its own ranked-eligible subset (the choice is server-authoritative and made before any Godot process exists), so `arena_registry_sync_test.go` parses `arena_registry.gd` and fails if the two disagree in either direction, if rotation order diverges, or if a ranked path has no scene behind it. Verified against four drift scenarios including promoting an elevated variant, which the registry's own comment anticipates. Live ranked admission against a real cluster remains ([#17](https://github.com/jcreek/CosmicClash/issues/17)) |
|
||||
| 8.21 `[D:8.5,8.20]` | Rating core (Glicko-2, weights, transactional updates) | Live maintenance/DB execution remains |
|
||||
| 8.22 `[D:8.21]` | Ranked profile (provisional games, tiers) | Persisted tier policy, client UI, reconnect transport remain |
|
||||
| 8.22 `[D:8.21]` | Ranked profile (provisional games, tiers) | Persisted tier policy done: bands live in `tier_bands`, seeded with the exact compiled launch policy so storage changed without behaviour changing, loaded at startup with a malformed policy failing startup rather than silently mis-tiering, and an empty table falling back to the compiled default so an operator can truncate back to known-good. Retuning is now a rolling restart rather than a rebuilt image. `PROVISIONAL` is rejected as a durable band, being derived from game count rather than rating. Client UI was already built (`RankedProfileState.display_text()` renders tier, provisional status, ranked games and the season countdown). Reconnect transport is tracked by 8.42 and depends on live auth/backend events |
|
||||
| 8.23 `[D:8.21]` | Ranked season policy (compression, rollover) | Live maintenance/DB execution remains |
|
||||
| 8.24 `[D:8.9,8.20,8.21]` | Ranked connection policy, reconnect lease, abandon ladder | Live PostgreSQL/process-restart/outage execution remains, blocked by Docker storage |
|
||||
| 8.24 `[D:8.9,8.20,8.21]` | Ranked connection policy, reconnect lease, abandon ladder | Live PostgreSQL execution now verified (`make verify-phase6` and every integration script run clean). Process-restart and outage execution remain |
|
||||
| 8.25 `[D:8.10,8.24]` | Result policy (workload-bound, idempotent, transactional) | Production credentials, Agones annotation persistence/reconciliation, integrity-evidence adapters remain |
|
||||
|
||||
#### 8D — Agones, allocation and regional scaling
|
||||
@@ -216,8 +265,8 @@ are done; everything below is what's left on the tasks still open.
|
||||
| 8.27 `[D:8.26]` | Supervisor package (Agones discovery, Ready transition) | Metadata watch, real Agones annotation/shutdown confirmation, emulator integration remain |
|
||||
| 8.28 `[D:8.6,8.27]` | Process-ready/Agones-Ready separation, control-plane registration | Remaining gates are live Agones annotation/shutdown behavior and production cluster readiness — see task 8.49 |
|
||||
| 8.29 `[D:8.26,8.27]` | Dynamic port/SDR env propagation | Real Agones dynamic/passthrough mapping, POP/cert/firewall/NAT, multi-match fixture remain |
|
||||
| 8.30 `[D:8.18,8.26,8.28,8.29]` | Allocation leasing, compatibility validation | Full unknown-outcome cluster recovery and signed roster metadata remain |
|
||||
| **8.31** `[D:8.9,8.30]` | Signed assignment/roster persistence, player recovery | **This is the actual root blocker of the whole allocation-to-connect pipeline (see §0).** `store.SaveAssignment`/`SaveAssignments`/`SaveVerifiedAssignmentRoster` and `allocator.Service.PublishRoster` are built and tested but never called from `allocator/worker.go`, `cmd/allocator`, or anywhere else in production — only tests seed the table directly. A real match cannot advance past `PROCESS_READY`. Closing it needs new security-relevant design: a join-signing key shared between the allocator (to sign) and the game server (`fleet.yaml` already mounts one for verification via `--join-authorisations-key-file`, but no control-plane binary has a matching signing flag), roster-digest computation, and per-player `domain.JoinAuthorisation` construction via the already-built `domain.SignJoinAuthorisationHMAC`. Flagged rather than fixed at the user's explicit direction, pending a decision on that design |
|
||||
| 8.30 `[D:8.18,8.26,8.28,8.29]` | Allocation leasing, compatibility validation | Signed roster metadata landed with 8.31 — the allocator publishes one signed join authorisation per participant plus a manifest committing to a digest over the whole roster, and the supervisor materialises it before starting the game child. Full unknown-outcome cluster recovery remains ([#17](https://github.com/jcreek/CosmicClash/issues/17)) |
|
||||
| **8.31** `[D:8.9,8.30]` | Signed assignment/roster persistence, player recovery | **Done — this was the root blocker of the allocation-to-connect pipeline.** `allocator.Worker.RunOnce` now builds one signed join authorisation per durable participant and calls `PublishRoster` after binding; `cmd/allocator` takes `--join-authorisations-key-file`/`--join-authorisations-key-id` and refuses to start without them. The signing design is settled: HMAC-SHA256 over the canonical claim bytes with a key ID inside them, so servers hold a key *set* and rotation does not invalidate in-flight matches. The provider endpoint is now persisted on the allocation so a worker crashing between allocating and publishing can retry. Verified by an integration test that drives the real worker through the supervisor's own roster read path without seeding `assignments`. Live Agones verification remains ([#17](https://github.com/jcreek/CosmicClash/issues/17)) |
|
||||
| 8.32 `[D:8.2,8.26,8.30]` | FleetAutoscaler baseline, Ready buffer | Regional on-demand node pools/failure domains, pre-pull rollout, warm-allocation p95/p99, N+1 certification remain |
|
||||
| 8.33 `[D:8.26,8.32]` | Fleet scheduling, zone spread | Regional node pools, forced node-loss testing, measured N+1 headroom remain |
|
||||
| 8.34 `[D:8.28,8.29]` | Native x86_64 benchmark of boot-to-process-ready/assignment-ready, p99 CPU/RSS/network, node cap with 30% headroom | Not started |
|
||||
@@ -230,11 +279,11 @@ are done; everything below is what's left on the tasks still open.
|
||||
|
||||
| # | Task | Remaining |
|
||||
|---|---|---|
|
||||
| 8.39 `[D:8.3,8.14,8.17]` | `MatchmakingState`/`ControlPlaneClient`, queue/proposal UI, targeted revisioned events | Live PostgreSQL-backed dispatcher/fan-out verification remains |
|
||||
| 8.39 `[D:8.3,8.14,8.17]` | `MatchmakingState`/`ControlPlaneClient`, queue/proposal UI, targeted revisioned events | Cross-replica fan-out landed: committed outbox events are published through PostgreSQL LISTEN/NOTIFY so the replica owning a subscriber's WebSocket delivers it, rather than whichever replica happened to drain the row. Verified against real PostgreSQL with two listeners. Live multi-replica verification under load remains |
|
||||
| 8.40 `[D:8.3,8.14]` | Revisioned event stream, REST resync, outbox dispatcher | Allocator and Redis fan-out live verification remain |
|
||||
| 8.41 `[D:7.8,8.9,8.31,8.40]` | Player-scoped assignment API, `connect_to_assignment()` wiring, join-authorisation verification in `MatchNet` | SDR relay-ticket installation and live Agones cluster integration remain |
|
||||
| 8.42 `[D:8.22,8.23,8.24,8.40]` | `RankedProfileState`, backend-authoritative rating/tier display | Committed revision after reconnect, abandon status, season countdown remain dependent on live auth/backend events and Godot runtime verification |
|
||||
| 8.43 `[D:8.39,8.40,8.41]` | Error/expiry UX, generic mutation retry, version-mismatch and failed-reconnect messaging | Long-running worker integration (§8.16) remains |
|
||||
| 8.42 `[D:8.22,8.23,8.24,8.40]` | `RankedProfileState`, backend-authoritative rating/tier display | Season countdown is implemented (`RankedProfileState.display_text()` renders the remaining days alongside tier, provisional status and ranked games). Committed revision after reconnect and abandon status remain dependent on live auth/backend events and Godot runtime verification |
|
||||
| 8.43 `[D:8.39,8.40,8.41]` | Error/expiry UX, generic mutation retry, version-mismatch and failed-reconnect messaging | Long-running worker soak (§8.16) remains; the worker itself is deployed |
|
||||
|
||||
#### 8F — Observability, verification, cost and rollout
|
||||
|
||||
@@ -245,10 +294,10 @@ are done; everything below is what's left on the tasks still open.
|
||||
| 8.46 `[D:8.5,8.7,8.9,8.10,8.14,8.18,8.21,8.23,8.25]` | Go unit/race/fuzz coverage, local verification gate | Live matcher-worker-under-load-during-failover integration remains |
|
||||
| 8.47 `[D:8.7,8.30]` | Offline testkit (fake Steam, fake allocation) | Live exhaustive matrix and production Steam remain |
|
||||
| 8.48 `[D:8.10,8.14,8.17,8.18,8.27,8.31,8.35,8.47]` | Allocated Compose end-to-end (queue → proposal → allocation → assignment → result) | **Local complete; production gate open** — real Agones/kind and production evidence remain open |
|
||||
| 8.49 `[D:8.25,8.26,8.28,8.29,8.30,8.31,8.35,8.36]` | Disposable kind+Agones cluster runner | CI/live evidence for production control-plane registration, roster/no-show, both readiness stages, races, multi-match node, result-pending reconciliation, drain, rollback remains open. Blocked locally on Docker storage/kind/Helm availability |
|
||||
| 8.49 `[D:8.25,8.26,8.28,8.29,8.30,8.31,8.35,8.36]` | Disposable kind+Agones cluster runner | CI/live evidence for production control-plane registration, roster/no-show, both readiness stages, races, multi-match node, result-pending reconciliation, drain, rollback remains open. Blocked locally on kind/Helm availability |
|
||||
| 8.50 `[D:8.25,8.37,8.43,8.49]` | Chaos recovery (stale allocation, no-penalty requeue) | **Local complete; production gate open** — 100 ms RTT/jitter/loss, matcher/client restart, game-pod death, node drain, Redis failover, control-plane loss, live chaos evidence remain |
|
||||
| 8.51 `[D:8.17,8.18,8.30,8.31,8.45]` | 10,000-client API load gate | **Local complete; production gate open** — PostgreSQL saturation, durable matcher fencing under load, forecast launch concurrency ×2, replica scaling remain live infrastructure gates |
|
||||
| 8.52 `[D:8.32,8.34,8.45,8.51]` | Per-replica + shared regional allocator quota | Real image digest/secrets, measured regional cost model, threshold tuning, denial-of-wallet rehearsal remain |
|
||||
| 8.52 `[D:8.32,8.34,8.45,8.51]` | Per-replica + shared regional allocator quota | Real image digest/secrets ([#31](https://github.com/jcreek/CosmicClash/issues/31)), measured regional cost model, threshold tuning, denial-of-wallet rehearsal remain |
|
||||
| 8.53 `[D:7.8,8.13,8.38,8.45,8.46,8.48,8.49,8.50,8.51,8.52]` | Fail-closed release-gate promotion validator | Actual reports, production rollback rehearsal, regional playtests, live promotion remain open |
|
||||
|
||||
Implementation invariants for every task above:
|
||||
@@ -334,6 +383,7 @@ single-player one.
|
||||
49. **Advancing a consumer cursor past data that has not arrived is not a lossy shortcut — it is permanent, because the producer-side filter then rejects the very data being waited for.** **Only give up on an expected item once strictly newer data proves it lost**; "it hasn't arrived yet" and "it will never arrive" are different states and must not share a code path.
|
||||
50. **A metric that stops sampling during a failure will report that failure as healthy.** Every rate-shaped assertion needs a companion assertion on the **denominator**, or an outage silently becomes an absence of evidence and then evidence of absence.
|
||||
51. **An architectural blocker inherited from a previous session is a claim to verify, not a premise to build on.** Reconstruct the failing invariant from the code and reproduce it against a control before accepting a scope estimate attached to it — especially when the recommendation arrives without the cheaper alternative recorded as tested.
|
||||
52. **`docker run --rm` reclaims the container, not its anonymous volumes.** Every run of `scripts/run_*_integration.sh` leaves a throwaway PostgreSQL/Redis data volume behind. They accumulate invisibly — 64 of them, ~4 GB, after one working session — until the Docker VM disk fills and the next container silently fails to start, surfacing only as the script's own `PostgreSQL did not become ready` timeout rather than as a disk error. This is the actual cause behind the "Docker storage exhausted locally" notes elsewhere in this document. `docker system df` shows it (`Local Volumes … 100% reclaimable`); `docker volume prune` clears it. Worth checking first whenever an integration script starts timing out on a machine where it previously worked. **Fixed** by adding `-v` to each script's cleanup trap: `--rm` does reclaim anonymous volumes on a normal exit, but these scripts force-remove the container from a trap instead, and `docker rm -f` without `-v` keeps the volume. Verified as one leaked volume per run before, zero after.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -8,7 +8,14 @@ user="cosmic_clash_test"
|
||||
password="cosmic_clash_test"
|
||||
|
||||
cleanup() {
|
||||
docker rm -f "$container_name" >/dev/null 2>&1 || true
|
||||
# -v matters: the container runs with --rm, which would reclaim its
|
||||
# anonymous volume on a normal exit, but this trap force-removes it instead
|
||||
# and `docker rm -f` alone leaves the volume behind. Each run then leaks one
|
||||
# throwaway database volume, which accumulates silently until the Docker VM
|
||||
# disk fills and the next container fails to start -- surfacing only as this
|
||||
# script's own readiness timeout, never as a disk error. See
|
||||
# multiplayer-next.md §9 gotcha 52.
|
||||
docker rm -f -v "$container_name" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
|
||||
@@ -8,7 +8,14 @@ user="cosmic_clash_test"
|
||||
password="cosmic_clash_test"
|
||||
|
||||
cleanup() {
|
||||
docker rm -f "$container_name" >/dev/null 2>&1 || true
|
||||
# -v matters: the container runs with --rm, which would reclaim its
|
||||
# anonymous volume on a normal exit, but this trap force-removes it instead
|
||||
# and `docker rm -f` alone leaves the volume behind. Each run then leaks one
|
||||
# throwaway database volume, which accumulates silently until the Docker VM
|
||||
# disk fills and the next container fails to start -- surfacing only as this
|
||||
# script's own readiness timeout, never as a disk error. See
|
||||
# multiplayer-next.md §9 gotcha 52.
|
||||
docker rm -f -v "$container_name" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
|
||||
@@ -5,7 +5,14 @@ repo_root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
container_name="cosmic-clash-redis-integration"
|
||||
|
||||
cleanup() {
|
||||
docker rm -f "$container_name" >/dev/null 2>&1 || true
|
||||
# -v matters: the container runs with --rm, which would reclaim its
|
||||
# anonymous volume on a normal exit, but this trap force-removes it instead
|
||||
# and `docker rm -f` alone leaves the volume behind. Each run then leaks one
|
||||
# throwaway database volume, which accumulates silently until the Docker VM
|
||||
# disk fills and the next container fails to start -- surfacing only as this
|
||||
# script's own readiness timeout, never as a disk error. See
|
||||
# multiplayer-next.md §9 gotcha 52.
|
||||
docker rm -f -v "$container_name" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
|
||||
@@ -8,7 +8,14 @@ user="cosmic_clash_test"
|
||||
password="cosmic_clash_test"
|
||||
|
||||
cleanup() {
|
||||
docker rm -f "$container_name" >/dev/null 2>&1 || true
|
||||
# -v matters: the container runs with --rm, which would reclaim its
|
||||
# anonymous volume on a normal exit, but this trap force-removes it instead
|
||||
# and `docker rm -f` alone leaves the volume behind. Each run then leaks one
|
||||
# throwaway database volume, which accumulates silently until the Docker VM
|
||||
# disk fills and the next container fails to start -- surfacing only as this
|
||||
# script's own readiness timeout, never as a disk error. See
|
||||
# multiplayer-next.md §9 gotcha 52.
|
||||
docker rm -f -v "$container_name" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ database="cosmic_clash_test"
|
||||
user="cosmic_clash_test"
|
||||
password="cosmic_clash_test"
|
||||
|
||||
cleanup() { docker rm -f "$container_name" >/dev/null 2>&1 || true; }
|
||||
# -v matters: --rm would reclaim the anonymous volume on a normal exit, but
|
||||
# this trap force-removes the container instead and `docker rm -f` alone
|
||||
# leaves the volume behind. See multiplayer-next.md §9 gotcha 52.
|
||||
cleanup() { docker rm -f -v "$container_name" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
cleanup
|
||||
docker run --rm -d --name "$container_name" \
|
||||
|
||||
@@ -9,15 +9,15 @@ from verify_agones_allocation_response import validate_allocation
|
||||
|
||||
def response(**overrides):
|
||||
document = {
|
||||
# Mirrors Agones' real GameServerAllocationStatus, which is flat.
|
||||
# These fixtures previously encoded a nested "gameServer" object that
|
||||
# Agones never returns, so the suite agreed with the validator while
|
||||
# both disagreed with reality.
|
||||
"status": {
|
||||
"state": "Allocated",
|
||||
"gameServer": {
|
||||
"metadata": {"name": "cosmic-clash-game-abc"},
|
||||
"status": {
|
||||
"address": "10.0.0.7",
|
||||
"ports": [{"name": "game", "port": 31001}],
|
||||
},
|
||||
},
|
||||
"gameServerName": "cosmic-clash-game-abc",
|
||||
"address": "10.0.0.7",
|
||||
"ports": [{"name": "game", "port": 31001}],
|
||||
}
|
||||
}
|
||||
document["status"].update(overrides)
|
||||
@@ -34,28 +34,28 @@ class AgonesAllocationResponseTest(unittest.TestCase):
|
||||
|
||||
def test_rejects_missing_identity_or_address(self):
|
||||
missing_name = response()
|
||||
missing_name["status"]["gameServer"]["metadata"] = {}
|
||||
missing_name["status"]["gameServerName"] = ""
|
||||
with self.assertRaises(ValueError):
|
||||
validate_allocation(missing_name)
|
||||
|
||||
missing_address = response()
|
||||
missing_address["status"]["gameServer"]["status"]["address"] = "0.0.0.0"
|
||||
missing_address["status"]["address"] = "0.0.0.0"
|
||||
with self.assertRaises(ValueError):
|
||||
validate_allocation(missing_address)
|
||||
|
||||
def test_rejects_ambiguous_or_invalid_game_ports(self):
|
||||
duplicate = response()
|
||||
duplicate["status"]["gameServer"]["status"]["ports"].append({"name": "game", "port": 31002})
|
||||
duplicate["status"]["ports"].append({"name": "game", "port": 31002})
|
||||
with self.assertRaises(ValueError):
|
||||
validate_allocation(duplicate)
|
||||
|
||||
wrong_name = response()
|
||||
wrong_name["status"]["gameServer"]["status"]["ports"] = [{"name": "query", "port": 31001}]
|
||||
wrong_name["status"]["ports"] = [{"name": "query", "port": 31001}]
|
||||
with self.assertRaises(ValueError):
|
||||
validate_allocation(wrong_name)
|
||||
|
||||
invalid_port = response()
|
||||
invalid_port["status"]["gameServer"]["status"]["ports"][0]["port"] = 70000
|
||||
invalid_port["status"]["ports"][0]["port"] = 70000
|
||||
with self.assertRaises(ValueError):
|
||||
validate_allocation(invalid_port)
|
||||
|
||||
|
||||
@@ -11,26 +11,26 @@ def validate_allocation(document: dict[str, Any]) -> tuple[str, int]:
|
||||
if not isinstance(status, dict) or status.get("state") != "Allocated":
|
||||
raise ValueError(f"allocation state is {status.get('state') if isinstance(status, dict) else None!r}, expected 'Allocated'")
|
||||
|
||||
game_server = status.get("gameServer")
|
||||
if not isinstance(game_server, dict):
|
||||
raise ValueError("allocation did not return a GameServer")
|
||||
metadata = game_server.get("metadata")
|
||||
name = metadata.get("name") if isinstance(metadata, dict) else None
|
||||
# GameServerAllocationStatus is flat: state, gameServerName, address,
|
||||
# ports, nodeName. It does not embed the allocated GameServer object. This
|
||||
# validator originally read status.gameServer.metadata.name and
|
||||
# status.gameServer.status.{address,ports}, and its tests asserted that
|
||||
# same invented shape, so both agreed with each other and neither agreed
|
||||
# with Agones -- undetected because the gate never once got far enough to
|
||||
# allocate anything.
|
||||
name = status.get("gameServerName")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise ValueError("allocation GameServer has no metadata.name")
|
||||
raise ValueError("allocation did not return a gameServerName")
|
||||
|
||||
game_status = game_server.get("status")
|
||||
if not isinstance(game_status, dict):
|
||||
raise ValueError("allocation GameServer has no status")
|
||||
address = game_status.get("address")
|
||||
address = status.get("address")
|
||||
if not isinstance(address, str) or not address.strip() or any(char.isspace() for char in address):
|
||||
raise ValueError(f"allocation returned an invalid address: {address!r}")
|
||||
if address in {"0.0.0.0", "::"}:
|
||||
raise ValueError(f"allocation returned an unspecified address: {address!r}")
|
||||
|
||||
ports = game_status.get("ports")
|
||||
ports = status.get("ports")
|
||||
if not isinstance(ports, list):
|
||||
raise ValueError("allocation GameServer has no ports")
|
||||
raise ValueError("allocation returned no ports")
|
||||
game_ports = [
|
||||
entry.get("port")
|
||||
for entry in ports
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
set -Eeuo pipefail
|
||||
|
||||
# Independent allocated-flow fixture for multiplayer-next.md §8.48. This
|
||||
# intentionally does not call compose.phase6-smoke.yml or reuse its ports.
|
||||
@@ -11,8 +11,27 @@ secret="compose-workload-secret"
|
||||
smoke_dir="${COMPOSE_SMOKE_DIR:-/tmp/cosmic-clash-allocated-smoke}"
|
||||
compose=(docker compose -p "$project" -f "$compose_file")
|
||||
|
||||
# Most of this script is `curl -fsS` and bare [[ ]] assertions under `set -e`,
|
||||
# which abort with no message at all. That is fine locally, where the fixture
|
||||
# is still up to poke at, but in CI it produces a failed run whose log contains
|
||||
# nothing but "make: *** Error 1" -- undiagnosable without re-running by hand.
|
||||
# Report where it stopped, and dump the service logs, so a CI failure explains
|
||||
# itself on the first occurrence.
|
||||
failed_line=""
|
||||
on_error() {
|
||||
failed_line="$1"
|
||||
echo "allocated Compose fixture failed at ${BASH_SOURCE[0]}:${failed_line}" >&2
|
||||
echo "--- failing command: ${BASH_COMMAND}" >&2
|
||||
}
|
||||
trap 'on_error "$LINENO"' ERR
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
if [[ "$rc" != 0 ]]; then
|
||||
echo "--- allocated Compose service logs follow (exit ${rc}) ---" >&2
|
||||
"${compose[@]}" ps >&2 2>/dev/null || true
|
||||
"${compose[@]}" logs --no-color --tail=80 >&2 2>/dev/null || true
|
||||
fi
|
||||
if [[ "$rc" != 0 && "${COMPOSE_KEEP_ON_FAILURE:-}" == 1 ]]; then
|
||||
echo "allocated Compose fixture retained for inspection: ${project}" >&2
|
||||
exit "$rc"
|
||||
@@ -31,12 +50,16 @@ import base64, hashlib, hmac, json, pathlib, sys, time
|
||||
|
||||
directory = pathlib.Path(sys.argv[1])
|
||||
key = b"compose-join-signing-key"
|
||||
key_id = "compose-key-1"
|
||||
expires = "2099-12-31T00:00:00Z"
|
||||
fields = ["compose-match-0001", "compose-server-0001", "compose-player", "compose-steam", "0", "0", "v1", "1", expires]
|
||||
# Field order and the trailing key ID must match
|
||||
# server/domain.JoinAuthorisationBytes and Game/scripts/match_net.gd.
|
||||
fields = ["compose-match-0001", "compose-server-0001", "compose-player", "compose-steam", "0", "0", "v1", "1", expires, key_id]
|
||||
canonical = b"\0".join(field.encode() for field in fields)
|
||||
signature = base64.urlsafe_b64encode(hmac.new(key, canonical, hashlib.sha256).digest()).rstrip(b"=").decode()
|
||||
envelope = {"Authorisation": {"MatchID": fields[0], "ServerID": fields[1], "PlayerID": fields[2], "SteamID": fields[3], "Slot": 0, "Team": 0, "Protocol": fields[6], "Generation": 1, "ExpiresAt": expires}, "Signature": signature}
|
||||
(directory / "join-signing-key").write_bytes(key)
|
||||
envelope = {"Authorisation": {"MatchID": fields[0], "ServerID": fields[1], "PlayerID": fields[2], "SteamID": fields[3], "Slot": 0, "Team": 0, "Protocol": fields[6], "Generation": 1, "ExpiresAt": expires, "KeyID": key_id}, "Signature": signature}
|
||||
# The key file maps key ID -> base64 key so a rotation can publish several.
|
||||
(directory / "join-signing-keys.json").write_text(json.dumps({key_id: base64.b64encode(key).decode()}) + "\n")
|
||||
(directory / "join-roster.json").write_text(json.dumps([base64.urlsafe_b64encode(json.dumps(envelope, separators=(",", ":")).encode()).rstrip(b"=").decode()]) + "\n")
|
||||
PY
|
||||
|
||||
@@ -65,7 +88,12 @@ for attempt in $(seq 1 180); do
|
||||
if "${compose[@]}" logs game-server 2>/dev/null | grep -q ' server_started '; then
|
||||
break
|
||||
fi
|
||||
if ! "${compose[@]}" ps --status running --services | grep -qx game-server; then
|
||||
# Ask whether it EXITED, not whether it is absent from the running list.
|
||||
# Those differ: a container that has been created but has not started yet is
|
||||
# missing from --status running too, so the previous check called a
|
||||
# still-starting server dead on the first poll. It failed intermittently
|
||||
# against a game server whose own logs showed a clean `server_started`.
|
||||
if "${compose[@]}" ps -a --status exited --services 2>/dev/null | grep -qx game-server; then
|
||||
"${compose[@]}" logs game-server >&2
|
||||
echo "allocated Compose game server exited before becoming ready" >&2
|
||||
exit 1
|
||||
@@ -123,12 +151,23 @@ queue_revision="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["revis
|
||||
|
||||
# Reusing a queue idempotency key with different command material must not
|
||||
# silently turn into a second ticket or a successful replay.
|
||||
conflict_status="$(curl -sS -o /dev/null -w '%{http_code}' -X POST "$api_url/v1/queue" \
|
||||
conflict_body="$(mktemp)"
|
||||
conflict_status="$(curl -sS -o "$conflict_body" -w '%{http_code}' -X POST "$api_url/v1/queue" \
|
||||
-H "Authorization: Bearer $access_token" \
|
||||
-H 'Idempotency-Key: compose-queue-key-123456' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"ticket_id":"compose-other-ticket","playlist":"casual","client_build":"build-1","protocol_version":1}')"
|
||||
[[ "$conflict_status" == 409 ]]
|
||||
if [[ "$conflict_status" != 409 ]]; then
|
||||
# Report what actually came back. A bare [[ ]] here just aborts, which is
|
||||
# how this assertion failed in CI three times without ever saying what the
|
||||
# status was.
|
||||
echo "idempotency conflict returned ${conflict_status}, want 409; body:" >&2
|
||||
cat "$conflict_body" >&2 || true
|
||||
echo >&2
|
||||
rm -f "$conflict_body"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$conflict_body"
|
||||
|
||||
heartbeat_json="$(curl -fsS -X POST "$api_url/v1/queue/compose-queue-ticket/heartbeat" \
|
||||
-H "Authorization: Bearer $access_token" \
|
||||
|
||||
@@ -13,8 +13,76 @@ game_server_image="${GAME_SERVER_IMAGE:-cosmic-clash-game-server:kind}"
|
||||
kind_node_image="${KIND_NODE_IMAGE:-kindest/node:v1.33.1}"
|
||||
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/cosmic-clash-agones.XXXXXX")"
|
||||
|
||||
# This gate fails in CI with nothing but Helm's "context deadline exceeded",
|
||||
# and the EXIT trap then deletes the cluster, so there is no way to learn why
|
||||
# the pods never became Available. Dump enough cluster state on failure that a
|
||||
# CI run explains itself without needing a local reproduction -- which is not
|
||||
# equivalent anyway, since a developer machine has different resources and a
|
||||
# different container runtime.
|
||||
#
|
||||
# Set KIND_KEEP_ON_FAILURE=1 to retain the cluster for interactive inspection.
|
||||
on_error() {
|
||||
echo "kind/Agones gate failed at ${BASH_SOURCE[0]}:$1" >&2
|
||||
echo "--- failing command: ${BASH_COMMAND}" >&2
|
||||
}
|
||||
trap 'on_error "$LINENO"' ERR
|
||||
|
||||
dump_cluster_state() {
|
||||
echo "=== node capacity and conditions ===" >&2
|
||||
kubectl get nodes -o wide >&2 2>&1 || true
|
||||
kubectl describe nodes 2>&1 | grep -A 12 -E "Allocated resources|Conditions:" >&2 || true
|
||||
for ns in agones-system cosmic-clash; do
|
||||
echo "=== namespace ${ns}: pods ===" >&2
|
||||
kubectl -n "$ns" get pods -o wide >&2 2>&1 || true
|
||||
echo "=== namespace ${ns}: services ===" >&2
|
||||
kubectl -n "$ns" get services -o wide >&2 2>&1 || true
|
||||
# Events explain scheduling/image/probe failures that pod status alone
|
||||
# does not: FailedScheduling, ImagePullBackOff, readiness probe errors.
|
||||
echo "=== namespace ${ns}: recent events ===" >&2
|
||||
kubectl -n "$ns" get events --sort-by=.lastTimestamp 2>&1 | tail -40 >&2 || true
|
||||
# Log EVERY pod, not only the not-ready ones. A GameServer that reaches
|
||||
# Ready and is then recycled on a health check leaves no unready pod
|
||||
# behind: the failures are already deleted and the survivors read 2/2
|
||||
# Running, so filtering on readiness dumped nothing useful and the game
|
||||
# server's own output went unseen for several CI runs.
|
||||
for pod in $(kubectl -n "$ns" get pods -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' 2>/dev/null); do
|
||||
ready="$(kubectl -n "$ns" get pod "$pod" -o jsonpath='{.status.containerStatuses[*].ready}' 2>/dev/null || true)"
|
||||
echo "=== ${ns}/${pod} (ready=${ready:-unknown}) ===" >&2
|
||||
kubectl -n "$ns" describe pod "$pod" 2>&1 | tail -35 >&2 || true
|
||||
# Per container, not --all-containers: the Agones sidecar is far chattier
|
||||
# than the game server, so a shared tail hides exactly the output needed,
|
||||
# and --previous without -c resolves to a container that never restarted.
|
||||
for container in $(kubectl -n "$ns" get pod "$pod" -o jsonpath='{range .spec.containers[*]}{.name}{"\n"}{end}' 2>/dev/null); do
|
||||
echo "--- ${ns}/${pod}[${container}] logs (current) ---" >&2
|
||||
kubectl -n "$ns" logs "$pod" -c "$container" --tail=60 >&2 2>&1 || true
|
||||
echo "--- ${ns}/${pod}[${container}] logs (previous, if it restarted) ---" >&2
|
||||
kubectl -n "$ns" logs "$pod" -c "$container" --previous --tail=60 >&2 2>&1 || true
|
||||
done
|
||||
done
|
||||
done
|
||||
# Agones' own view: a GameServer can be Unhealthy while its Pod looks fine,
|
||||
# which is precisely the shape of a failed health check.
|
||||
echo "=== Agones GameServers and Fleets ===" >&2
|
||||
kubectl get gameservers --all-namespaces -o wide >&2 2>&1 || true
|
||||
kubectl get fleets --all-namespaces -o wide >&2 2>&1 || true
|
||||
echo "=== helm releases ===" >&2
|
||||
helm list --all-namespaces >&2 2>&1 || true
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local status=$?
|
||||
# No reachability guard here: every command inside dump_cluster_state is
|
||||
# already `|| true`, so a gone cluster costs a few harmless errors, whereas
|
||||
# a guard that misjudges reachability silently suppresses the whole dump --
|
||||
# which is exactly what happened on its first run.
|
||||
if [[ "$status" != 0 ]]; then
|
||||
dump_cluster_state
|
||||
fi
|
||||
if [[ "$status" != 0 && "${KIND_KEEP_ON_FAILURE:-}" == 1 ]]; then
|
||||
echo "kind cluster retained for inspection: kind-${cluster_name} (delete with: kind delete cluster --name ${cluster_name})" >&2
|
||||
rm -rf "$work_dir"
|
||||
exit "$status"
|
||||
fi
|
||||
kind delete cluster --name "$cluster_name" >/dev/null 2>&1 || true
|
||||
rm -rf "$work_dir"
|
||||
exit "$status"
|
||||
@@ -35,7 +103,14 @@ fi
|
||||
|
||||
kind delete cluster --name "$cluster_name" >/dev/null 2>&1 || true
|
||||
|
||||
if ! docker image inspect "$game_server_image" >/dev/null 2>&1; then
|
||||
# Build by default. Reusing whatever happens to be tagged locally silently
|
||||
# verifies stale code: a developer fixes the game server, reruns this gate, and
|
||||
# it exercises the previous build because the tag already exists. CI never hits
|
||||
# that because a fresh runner has no image, which is precisely how a local pass
|
||||
# and a CI failure can disagree about the same commit.
|
||||
if [[ "${KIND_REUSE_GAME_SERVER_IMAGE:-}" == 1 ]] && docker image inspect "$game_server_image" >/dev/null 2>&1; then
|
||||
echo "Reusing existing $game_server_image (KIND_REUSE_GAME_SERVER_IMAGE=1); it may not contain local changes"
|
||||
else
|
||||
echo "Building $game_server_image from the pinned game-server target"
|
||||
docker build --target game-server -t "$game_server_image" .
|
||||
fi
|
||||
@@ -43,21 +118,33 @@ fi
|
||||
kind create cluster --name "$cluster_name" --image "$kind_node_image" --wait 120s
|
||||
kind load docker-image "$game_server_image" --name "$cluster_name"
|
||||
|
||||
# Agones creates its SDK service account and namespaced RBAC in each configured
|
||||
# GameServer namespace. The namespace must therefore exist before Helm runs.
|
||||
kubectl apply -f deploy/k8s/base/namespace.yaml
|
||||
|
||||
helm repo add agones https://agones.dev/chart/stable >/dev/null
|
||||
helm repo update >/dev/null
|
||||
# Agones 1.49 otherwise requests 10,100 MiB of ephemeral storage for its
|
||||
# extensions pod, which exceeds a default single-node kind cluster before the
|
||||
# Fleet can be exercised. These are smoke-only bounds; production resource
|
||||
# sizing remains deployment-owned.
|
||||
# Agones 1.49 otherwise requests 10,100 MiB of ephemeral storage for both its
|
||||
# controller and extensions pods, which exceeds a default single-node kind
|
||||
# cluster before the Fleet can be exercised. Its allocator and ping Services
|
||||
# also default to LoadBalancer, whose ingress never becomes ready in plain kind.
|
||||
# These are smoke-only bounds; production sizing and exposure remain
|
||||
# deployment-owned.
|
||||
helm upgrade --install agones agones/agones \
|
||||
--namespace agones-system --create-namespace \
|
||||
--version "$agones_version" \
|
||||
--set 'gameservers.namespaces[0]=cosmic-clash' \
|
||||
--set agones.crds.cleanup.enabled=true \
|
||||
--set agones.controller.replicas=1 \
|
||||
--set agones.controller.resources.requests.ephemeral-storage=128Mi \
|
||||
--set agones.controller.resources.limits.ephemeral-storage=512Mi \
|
||||
--set agones.extensions.replicas=1 \
|
||||
--set agones.extensions.resources.requests.ephemeral-storage=128Mi \
|
||||
--set agones.extensions.resources.limits.ephemeral-storage=512Mi \
|
||||
--set agones.allocator.replicas=1 \
|
||||
--set agones.allocator.service.serviceType=ClusterIP \
|
||||
--set agones.ping.http.serviceType=ClusterIP \
|
||||
--set agones.ping.udp.serviceType=ClusterIP \
|
||||
--wait --timeout 5m
|
||||
|
||||
kubectl wait --for=condition=available deployment/agones-controller \
|
||||
@@ -65,6 +152,14 @@ kubectl wait --for=condition=available deployment/agones-controller \
|
||||
kubectl wait --for=condition=available deployment/agones-allocator \
|
||||
-n agones-system --timeout=180s
|
||||
|
||||
# The production Fleet only schedules on explicitly on-demand, zoned nodes.
|
||||
# Give the disposable node equivalent labels so this gate exercises those
|
||||
# constraints instead of rewriting them out of the rendered Fleet.
|
||||
kubectl label nodes --all \
|
||||
cosmic-clash.io/capacity-type=on-demand \
|
||||
topology.kubernetes.io/zone=kind-smoke \
|
||||
--overwrite
|
||||
|
||||
# The base Fleet intentionally carries a release-time digest placeholder. For
|
||||
# this isolated run only, replace that exact placeholder with the image loaded
|
||||
# into kind. No repository manifest is modified and no mutable image is used
|
||||
@@ -82,15 +177,21 @@ sed -e "s|ghcr.io/cosmic-clash/game-server@sha256:${zero_digest}|$game_server_im
|
||||
-e '/- --allocated-mode$/d' \
|
||||
deploy/k8s/base/fleet.yaml > "$work_dir/fleet.yaml"
|
||||
|
||||
kubectl apply -f deploy/k8s/base/namespace.yaml
|
||||
kubectl -n cosmic-clash create secret generic cosmic-clash-game-server \
|
||||
--from-literal=drain-token=kind-smoke-drain-token \
|
||||
--from-literal=join-signing-key=kind-smoke-signing-key \
|
||||
--from-literal=join-signing-keys.json='{"kind-smoke-key":"a2luZC1zbW9rZS1zaWduaW5nLWtleQ=="}' \
|
||||
--from-literal=join-signing-key-id=kind-smoke-key \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
kubectl apply -f deploy/k8s/base/service-accounts.yaml
|
||||
kubectl apply -f "$work_dir/fleet.yaml"
|
||||
|
||||
kubectl wait --for=jsonpath='{.status.ready}'=2 \
|
||||
# The field is readyReplicas, not ready: an Agones Fleet's status carries
|
||||
# replicas/readyReplicas/reservedReplicas/allocatedReplicas, and the READY
|
||||
# column printed by kubectl is readyReplicas. Waiting on `.status.ready` could
|
||||
# never match however healthy the Fleet was, which masked itself as "the Fleet
|
||||
# never became ready" and sent three separate investigations after the game
|
||||
# server instead of the assertion.
|
||||
kubectl wait --for=jsonpath='{.status.readyReplicas}'=2 \
|
||||
fleet/cosmic-clash-game -n cosmic-clash --timeout=5m
|
||||
|
||||
cat > "$work_dir/allocation.yaml" <<'EOF'
|
||||
@@ -105,4 +206,13 @@ spec:
|
||||
EOF
|
||||
kubectl create -f "$work_dir/allocation.yaml" -o json > "$work_dir/allocation.json"
|
||||
|
||||
python3 scripts/verify_agones_allocation_response.py "$work_dir/allocation.json"
|
||||
# Print the response when validation fails. work_dir is deleted by the EXIT
|
||||
# trap, so a mismatch between what Agones returns and what the validator
|
||||
# expects is otherwise unknowable from CI -- which is exactly how a validator
|
||||
# reading a field Agones never sends survived undetected.
|
||||
if ! python3 scripts/verify_agones_allocation_response.py "$work_dir/allocation.json"; then
|
||||
echo "--- allocation response as returned by Agones ---" >&2
|
||||
cat "$work_dir/allocation.json" >&2 || true
|
||||
echo >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -48,14 +48,35 @@ echo "local multiplayer gate: bounded fuzz targets"
|
||||
echo "local multiplayer gate: Godot harness"
|
||||
run_godot_harness
|
||||
|
||||
# The Agones SDK smoke needs a live SceneTree and awaits an HTTP round trip, so
|
||||
# it cannot live in test_runner.tscn -- that runner calls test methods without
|
||||
# awaiting. It covers the property the unit tests structurally cannot: that
|
||||
# start_health() produces a *repeating* ping, which is what Agones enforces and
|
||||
# whose absence silently recycled every allocated GameServer.
|
||||
echo "local multiplayer gate: Agones SDK smoke"
|
||||
if [[ -x "$godot_bin" ]]; then
|
||||
"$godot_bin" --headless --path "$root_dir/Game" --script res://tests/agones_sdk_smoke.gd
|
||||
else
|
||||
echo "local multiplayer gate: skipping Agones SDK smoke, Godot executable not found ($godot_bin)" >&2
|
||||
fi
|
||||
|
||||
echo "local multiplayer gate: contracts and manifests"
|
||||
python3 -m json.tool "$root_dir/server/contracts/v1/openapi.json" >/dev/null
|
||||
# json.tool only proves the contract parses. test_contracts.py is what actually
|
||||
# checks the operation IDs, envelopes and state vocabulary generated clients
|
||||
# bind to; it was previously not run by any target, so a real mismatch between
|
||||
# openapi.json and the suite sat undetected.
|
||||
python3 "$root_dir/server/contracts/v1/test_contracts.py"
|
||||
python3 "$root_dir/server/migrations/test_migration.py"
|
||||
python3 "$root_dir/server/security/test_fleet_manifests.py"
|
||||
python3 "$root_dir/server/security/test_compose_manifests.py"
|
||||
python3 "$root_dir/server/security/test_kubernetes_policies.py"
|
||||
python3 "$root_dir/server/security/test_supply_chain.py"
|
||||
python3 "$root_dir/server/security/test_threat_model.py"
|
||||
python3 "$root_dir/scripts/verify_observability_manifests.py"
|
||||
# The checker above validates the checked-in manifests; this validates the
|
||||
# checker itself still rejects a widened scrape scope.
|
||||
python3 "$root_dir/server/security/test_observability_manifests.py"
|
||||
python3 -m unittest "$root_dir/scripts/test_verify_agones_allocation_response.py"
|
||||
|
||||
echo "LOCAL MULTIPLAYER GATE PASS"
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/cosmic-clash/cosmic-clash/server/agones"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/migrations"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/store"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
@@ -34,7 +35,7 @@ func TestRealAllocatorWorkerReconcilesAgonesAllocationAndBindsMatch(t *testing.T
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `DROP TABLE IF EXISTS schema_migrations, assignments, audit_events, outbox, result_receipts, ranked_season_rollovers, penalties, seasons, ratings, match_participants, matches, proposal_participants, proposals, allocations, game_servers, queue_tickets, idempotency_keys, sessions, identities CASCADE`); err != nil {
|
||||
if _, err := db.ExecContext(ctx, `DROP SCHEMA public CASCADE; CREATE SCHEMA public`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrations.Apply(ctx, db, filepath.Join("..", "migrations")); err != nil {
|
||||
@@ -112,3 +113,149 @@ func TestRealAllocatorWorkerReconcilesAgonesAllocationAndBindsMatch(t *testing.T
|
||||
t.Fatalf("recorded allocations=%d err=%v", recorded, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The root blocker: the worker bound the provider allocation and stopped.
|
||||
// Service.PublishRoster and store.SaveVerifiedAssignmentRoster both existed but
|
||||
// had no non-test callers, so nothing in production ever wrote the assignments
|
||||
// table. The allocated supervisor fetches a non-empty roster before launching
|
||||
// the game child, so every real allocation died at that fetch and no match
|
||||
// could reach ASSIGNMENT_READY or accept a player.
|
||||
//
|
||||
// This drives the real worker and asserts against the durable tables. It never
|
||||
// seeds the assignments table, which is exactly how the existing tests missed
|
||||
// the missing hand-off.
|
||||
func TestRealAllocatorWorkerPublishesSignedAssignmentRoster(t *testing.T) {
|
||||
dsn := os.Getenv("COSMIC_CLASH_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("COSMIC_CLASH_POSTGRES_DSN is not set")
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
ctx := context.Background()
|
||||
if _, err := db.ExecContext(ctx, `DROP SCHEMA public CASCADE; CREATE SCHEMA public`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrations.Apply(ctx, db, filepath.Join("..", "migrations")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
players := []string{"roster-worker-a", "roster-worker-b"}
|
||||
for index, player := range players {
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO identities (player_id, steam_id) VALUES ($1, $2)`, player, "steam-"+player); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO queue_tickets (ticket_id, player_id, playlist, state, client_build, protocol_version, enqueued_at, expires_at) VALUES ($1, $2, 'casual', 'ACCEPTED', 'build-1', 1, $3, $4)`, fmt.Sprintf("roster-worker-ticket-%d", index), player, now, now.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO matches (match_id, playlist, state, region, protocol_version) VALUES ('roster-worker-match', 'casual', 'ALLOCATING', 'EU', 1)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index, player := range players {
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO match_participants (match_id, player_id, ticket_id, slot, team) VALUES ('roster-worker-match', $1, $2, $3, $4)`, player, fmt.Sprintf("roster-worker-ticket-%d", index), index*3, index); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
_, _ = w.Write([]byte(`{"items":[{"metadata":{"name":"roster-ready-1","labels":{"cosmic-clash.io/region":"EU","cosmic-clash.io/build":"build-1","cosmic-clash.io/protocol":"1","cosmic-clash.io/transport":"enet"}},"status":{"state":"Ready"}}]}`))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":{"state":"Allocated","gameServerName":"roster-ready-1","address":"127.0.0.1","ports":[{"name":"default","port":7777}]}}`))
|
||||
}))
|
||||
defer provider.Close()
|
||||
|
||||
agonesClient := agones.Client{BaseURL: provider.URL, Namespace: "games", HTTP: provider.Client()}
|
||||
ready, err := agonesClient.ListReadyServers(ctx)
|
||||
if err != nil || len(ready) != 1 {
|
||||
t.Fatalf("ready projection = %+v err=%v", ready, err)
|
||||
}
|
||||
if err := store.RegisterReadyServer(ctx, db, ready[0], now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Two keys, signing with the newer: proves the rotation set is threaded
|
||||
// through signing and the persistence boundary's re-verification.
|
||||
keys := JoinSigningKeys{
|
||||
ActiveKeyID: "key-new",
|
||||
Keys: map[string][]byte{"key-old": []byte("retired-key"), "key-new": []byte("active-key")},
|
||||
}
|
||||
worker := Worker{
|
||||
Claims: store.AllocatingMatchClaims{DB: db, Transport: "enet"},
|
||||
Service: Service{Provider: agonesClient, Durable: store.AllocationRegistry{DB: db}, Roster: store.PostgresRosterStore{DB: db}, Now: func() time.Time { return now }},
|
||||
Now: func() time.Time { return now },
|
||||
Roster: store.AssignmentRosters{DB: db},
|
||||
Keys: keys,
|
||||
}
|
||||
processed, err := worker.RunOnce(ctx)
|
||||
if err != nil || !processed {
|
||||
t.Fatalf("worker processed=%t err=%v", processed, err)
|
||||
}
|
||||
|
||||
// One assignment row per participant, which is precisely what the
|
||||
// ASSIGNMENT_READY transition and the supervisor's roster fetch require.
|
||||
var assignments int
|
||||
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM assignments WHERE match_id = 'roster-worker-match'`).Scan(&assignments); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if assignments != len(players) {
|
||||
t.Fatalf("assignments = %d, want %d; the allocator did not publish the roster", assignments, len(players))
|
||||
}
|
||||
|
||||
// The supervisor's own read path must return a usable roster.
|
||||
roster, err := store.GetAssignmentRoster(ctx, db, "roster-worker-match", "roster-ready-1", now)
|
||||
if err != nil {
|
||||
t.Fatalf("supervisor roster fetch: %v", err)
|
||||
}
|
||||
if len(roster) != len(players) {
|
||||
t.Fatalf("supervisor roster has %d entries, want %d", len(roster), len(players))
|
||||
}
|
||||
verify := domain.VerifyJoinAuthorisationHMAC(keys.Keys)
|
||||
seenSlots := map[int]bool{}
|
||||
for _, encoded := range roster {
|
||||
var signed domain.SignedJoinAuthorisation
|
||||
if err := json.Unmarshal(encoded, &signed); err != nil {
|
||||
t.Fatalf("decode roster entry: %v", err)
|
||||
}
|
||||
if signed.Authorisation.KeyID != "key-new" {
|
||||
t.Fatalf("entry signed with %q, want the active key", signed.Authorisation.KeyID)
|
||||
}
|
||||
if !verify(domain.JoinAuthorisationBytes(signed.Authorisation), signed.Signature) {
|
||||
t.Fatalf("roster entry for %s does not verify", signed.Authorisation.PlayerID)
|
||||
}
|
||||
if signed.Authorisation.MatchID != "roster-worker-match" || signed.Authorisation.ServerID != "roster-ready-1" {
|
||||
t.Fatalf("roster entry bound to the wrong match/server: %+v", signed.Authorisation)
|
||||
}
|
||||
seenSlots[signed.Authorisation.Slot] = true
|
||||
}
|
||||
if len(seenSlots) != len(players) {
|
||||
t.Fatalf("roster slots collided: %v", seenSlots)
|
||||
}
|
||||
|
||||
// Republishing must be idempotent: a worker that crashed after binding but
|
||||
// before publishing retries this same path.
|
||||
allocation, recorded, err := store.AllocatingMatchClaims{DB: db, Transport: "enet"}.FindProviderAllocation(ctx, domain.AllocationRequest{
|
||||
AllocationID: "allocation-roster-worker-match", MatchID: "roster-worker-match",
|
||||
Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet",
|
||||
})
|
||||
if err != nil || !recorded {
|
||||
t.Fatalf("recover allocation: recorded=%t err=%v", recorded, err)
|
||||
}
|
||||
if allocation.Endpoint == "" {
|
||||
t.Fatal("the recovered allocation lost its endpoint, so a crashed worker could never republish")
|
||||
}
|
||||
if err := worker.publishAssignmentRoster(ctx, allocation); err != nil {
|
||||
t.Fatalf("republish: %v", err)
|
||||
}
|
||||
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM assignments WHERE match_id = 'roster-worker-match'`).Scan(&assignments); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if assignments != len(players) {
|
||||
t.Fatalf("republish duplicated assignments: %d", assignments)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package allocator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
||||
)
|
||||
|
||||
// JoinAuthorisationLifetime bounds how long an issued authorisation may be
|
||||
// replayed. It must outlive the initial-connect window (a player still loading
|
||||
// must be able to join) without leaving a usable credential lying around after
|
||||
// the match it belongs to is over.
|
||||
const JoinAuthorisationLifetime = 30 * time.Minute
|
||||
|
||||
// AssignmentRosterSource reads the authoritative participants of an allocated
|
||||
// match. It is deliberately the same query the persistence boundary
|
||||
// re-validates against, so the allocator cannot construct a roster that
|
||||
// disagrees with the durable match_participants rows.
|
||||
type AssignmentRosterSource interface {
|
||||
LoadAssignmentParticipants(context.Context, domain.Allocation) ([]domain.AssignmentParticipant, error)
|
||||
}
|
||||
|
||||
// JoinSigningKeys is the allocator's key material. ActiveKeyID names the key
|
||||
// new authorisations are signed with; Keys holds every currently-valid key so
|
||||
// verification (including the re-check at the persistence boundary) still
|
||||
// accepts authorisations issued before a rotation.
|
||||
type JoinSigningKeys struct {
|
||||
ActiveKeyID string
|
||||
Keys map[string][]byte
|
||||
}
|
||||
|
||||
func (k JoinSigningKeys) validate() error {
|
||||
if k.ActiveKeyID == "" || len(k.Keys) == 0 {
|
||||
return fmt.Errorf("join signing keys are not configured")
|
||||
}
|
||||
if len(k.Keys[k.ActiveKeyID]) == 0 {
|
||||
return fmt.Errorf("active join signing key %q is not present in the key set", k.ActiveKeyID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildSignedRoster turns the durable participants into one signed join
|
||||
// authorisation each, plus the manifest that commits to the whole set.
|
||||
//
|
||||
// Signing each entry proves each individual claim; the manifest's roster
|
||||
// digest additionally commits to the set, so a server cannot be handed a
|
||||
// truncated roster whose surviving entries are each individually valid.
|
||||
func BuildSignedRoster(allocation domain.Allocation, participants []domain.AssignmentParticipant, keys JoinSigningKeys, now time.Time) (domain.Assignment, []domain.SignedJoinAuthorisation, error) {
|
||||
if err := keys.validate(); err != nil {
|
||||
return domain.Assignment{}, nil, err
|
||||
}
|
||||
if allocation.State != domain.ServerAllocated || allocation.Endpoint == "" || len(participants) == 0 || now.IsZero() {
|
||||
return domain.Assignment{}, nil, domain.ErrManifestRejected
|
||||
}
|
||||
active := keys.Keys[keys.ActiveKeyID]
|
||||
roster := make([]domain.SignedJoinAuthorisation, 0, len(participants))
|
||||
for _, participant := range participants {
|
||||
signed, err := domain.SignJoinAuthorisationHMAC(domain.JoinAuthorisation{
|
||||
MatchID: allocation.MatchID,
|
||||
ServerID: allocation.ServerID,
|
||||
PlayerID: participant.PlayerID,
|
||||
SteamID: participant.SteamID,
|
||||
Slot: participant.Slot,
|
||||
Team: participant.Team,
|
||||
Protocol: strconv.Itoa(allocation.Protocol),
|
||||
// Generation 1 is the first connection lease. Reconnects fence by
|
||||
// advancing the durable generation, not by reissuing this token.
|
||||
Generation: 1,
|
||||
ExpiresAt: now.Add(JoinAuthorisationLifetime).UTC(),
|
||||
KeyID: keys.ActiveKeyID,
|
||||
}, active)
|
||||
if err != nil {
|
||||
return domain.Assignment{}, nil, fmt.Errorf("sign join authorisation for %s: %w", participant.PlayerID, err)
|
||||
}
|
||||
roster = append(roster, signed)
|
||||
}
|
||||
rosterDigest, err := domain.AssignmentRosterDigest(roster)
|
||||
if err != nil {
|
||||
return domain.Assignment{}, nil, err
|
||||
}
|
||||
assignment := domain.Assignment{
|
||||
Allocation: allocation,
|
||||
Endpoint: allocation.Endpoint,
|
||||
Manifest: domain.AllocationManifest{
|
||||
AllocationID: allocation.AllocationID,
|
||||
MatchID: allocation.MatchID,
|
||||
ServerID: allocation.ServerID,
|
||||
Region: allocation.Region,
|
||||
Build: allocation.Build,
|
||||
Protocol: allocation.Protocol,
|
||||
Transport: allocation.Transport,
|
||||
RosterDigest: rosterDigest,
|
||||
},
|
||||
}
|
||||
return assignment, roster, nil
|
||||
}
|
||||
@@ -128,6 +128,11 @@ func (s Service) Allocate(ctx context.Context, request domain.AllocationRequest,
|
||||
}
|
||||
return agones.AllocatedServer{}, err
|
||||
}
|
||||
// The client-facing endpoint arrives on the provider result, not on the
|
||||
// allocation. Carry it onto the record so publishing the assignment roster
|
||||
// -- and recovering after a crash between allocating and publishing -- has
|
||||
// an endpoint to work from.
|
||||
result.Allocation.Endpoint = result.Endpoint
|
||||
recorded, err := s.Durable.RecordProviderAllocation(ctx, result.Allocation, now)
|
||||
if err != nil {
|
||||
if s.Metrics != nil {
|
||||
@@ -149,6 +154,7 @@ func (s Service) RecordProviderAllocation(ctx context.Context, result agones.All
|
||||
// Quota is consumed by Allocate before a fresh provider request. This
|
||||
// method only reconciles an already-issued provider result after an
|
||||
// ambiguous write, so consuming here would charge one allocation twice.
|
||||
result.Allocation.Endpoint = result.Endpoint
|
||||
allocation, err := s.Durable.RecordProviderAllocation(ctx, result.Allocation, now)
|
||||
if s.Metrics != nil {
|
||||
if err != nil {
|
||||
|
||||
@@ -25,6 +25,13 @@ type Worker struct {
|
||||
Claims MatchClaimSource
|
||||
Service Service
|
||||
Now func() time.Time
|
||||
// Roster and Keys wire the assignment hand-off. Without them the worker
|
||||
// binds an allocation and stops, nothing ever writes the assignments
|
||||
// table, and the allocated supervisor's roster fetch fails -- so every
|
||||
// real allocation dies before the game process launches. They are optional
|
||||
// only so existing allocation-only tests need no key material.
|
||||
Roster AssignmentRosterSource
|
||||
Keys JoinSigningKeys
|
||||
}
|
||||
|
||||
// RunOnce returns whether it found a claimed match. It never exposes an
|
||||
@@ -76,9 +83,41 @@ func (w Worker) RunOnce(ctx context.Context) (bool, error) {
|
||||
if err := w.Claims.BindAllocatedMatch(ctx, allocation); err != nil {
|
||||
return true, fmt.Errorf("bind allocated match %s: %w", request.MatchID, err)
|
||||
}
|
||||
if err := w.publishAssignmentRoster(ctx, allocation); err != nil {
|
||||
return true, fmt.Errorf("publish assignment roster for match %s: %w", request.MatchID, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// publishAssignmentRoster completes the hand-off from allocation to a joinable
|
||||
// match. The supervisor fetches a non-empty roster before it launches the game
|
||||
// child, so skipping this leaves the match stuck short of ASSIGNMENT_READY
|
||||
// forever.
|
||||
//
|
||||
// It is safe to retry: SaveVerifiedAssignmentRoster upserts by (match, player)
|
||||
// and re-validates every claim against the durable participants, so a worker
|
||||
// that crashed after binding but before publishing simply republishes on the
|
||||
// next pass.
|
||||
func (w Worker) publishAssignmentRoster(ctx context.Context, allocation domain.Allocation) error {
|
||||
if w.Roster == nil {
|
||||
// Allocation-only deployments (and the allocation-focused tests) leave
|
||||
// this unset deliberately.
|
||||
return nil
|
||||
}
|
||||
if err := w.Keys.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
participants, err := w.Roster.LoadAssignmentParticipants(ctx, allocation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
assignment, roster, err := BuildSignedRoster(allocation, participants, w.Keys, w.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return w.Service.PublishRoster(ctx, assignment, roster, domain.VerifyJoinAuthorisationHMAC(w.Keys.Keys))
|
||||
}
|
||||
|
||||
func validateProviderAllocation(request domain.AllocationRequest, result agones.AllocatedServer) error {
|
||||
allocation := result.Allocation
|
||||
if result.Endpoint == "" || allocation.State != domain.ServerAllocated || allocation.AllocationID != request.AllocationID || allocation.MatchID != request.MatchID || allocation.ServerID == "" || allocation.Region != request.Region || allocation.Build != request.Build || allocation.Protocol != request.Protocol || allocation.Transport != request.Transport || allocation.ArenaPath != request.ArenaPath {
|
||||
|
||||
+42
-2
@@ -224,12 +224,52 @@ func (s *Service) getEventHub() *eventHub {
|
||||
}
|
||||
|
||||
// PublishControlPlaneEvent routes an already-authorized event to the matching
|
||||
// authenticated player connection. Durable callers should publish from their
|
||||
// outbox after commit; this in-memory hub is deliberately non-authoritative.
|
||||
// authenticated player connection on THIS replica. Durable callers should
|
||||
// publish from their outbox after commit; this in-memory hub is deliberately
|
||||
// non-authoritative.
|
||||
func (s *Service) PublishControlPlaneEvent(event ControlPlaneEvent) error {
|
||||
return s.getEventHub().publish(event)
|
||||
}
|
||||
|
||||
// fannedOutEvent is the fan-out wire shape. It cannot reuse ControlPlaneEvent
|
||||
// directly because that type hides PlayerID from clients (json:"-"), and the
|
||||
// recipient is precisely what a peer replica needs in order to route.
|
||||
type fannedOutEvent struct {
|
||||
ControlPlaneEvent
|
||||
PlayerID string `json:"player_id"`
|
||||
}
|
||||
|
||||
// EncodeFannedOutEvent and DecodeFannedOutEvent are exported for the
|
||||
// control-plane binary, which owns the transport wiring.
|
||||
func EncodeFannedOutEvent(event ControlPlaneEvent) ([]byte, error) {
|
||||
return json.Marshal(fannedOutEvent{ControlPlaneEvent: event, PlayerID: event.PlayerID})
|
||||
}
|
||||
|
||||
func DecodeFannedOutEvent(payload []byte) (ControlPlaneEvent, error) {
|
||||
var decoded fannedOutEvent
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
return ControlPlaneEvent{}, err
|
||||
}
|
||||
event := decoded.ControlPlaneEvent
|
||||
event.PlayerID = decoded.PlayerID
|
||||
if event.Event == "" || event.ResourceID == "" || event.PlayerID == "" {
|
||||
return ControlPlaneEvent{}, fmt.Errorf("invalid fanned-out control-plane event")
|
||||
}
|
||||
return event, nil
|
||||
}
|
||||
|
||||
// publishOutboxEvent is how the outbox dispatchers publish. When EventFanout
|
||||
// is configured it hands the event to the shared transport so every replica --
|
||||
// including whichever one holds the subscriber's WebSocket -- can deliver it.
|
||||
// Without it, behaviour is unchanged: local-hub only, correct for a single
|
||||
// replica and for tests.
|
||||
func (s *Service) publishOutboxEvent(event ControlPlaneEvent) error {
|
||||
if s.EventFanout != nil {
|
||||
return s.EventFanout(event)
|
||||
}
|
||||
return s.PublishControlPlaneEvent(event)
|
||||
}
|
||||
|
||||
func (s *Service) publishTicketEvent(ticket domain.QueueTicket, now time.Time) {
|
||||
_ = s.PublishControlPlaneEvent(ControlPlaneEvent{
|
||||
Event: "state_changed", Revision: ticket.Revision, ResourceID: ticket.TicketID,
|
||||
|
||||
+36
-9
@@ -35,7 +35,7 @@ func RunProposalOutboxDispatcher(ctx context.Context, db *sql.DB, service *Servi
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = dispatchOutboxEvents(ctx, dispatcher, events)
|
||||
_ = dispatchOutboxEvents(ctx, db, dispatcher, events)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func RunResultOutboxDispatcher(ctx context.Context, db *sql.DB, service *Service
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = dispatchOutboxEvents(ctx, dispatcher, events)
|
||||
_ = dispatchOutboxEvents(ctx, db, dispatcher, events)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,29 +87,56 @@ func RunStateOutboxDispatcher(ctx context.Context, db *sql.DB, service *Service)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = dispatchOutboxEvents(ctx, dispatcher, events)
|
||||
_ = dispatchOutboxEvents(ctx, db, dispatcher, events)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dispatchOutboxEvents(ctx context.Context, dispatcher *store.OutboxDispatcher, events []store.OutboxEvent) error {
|
||||
func dispatchOutboxEvents(ctx context.Context, db *sql.DB, dispatcher *store.OutboxDispatcher, events []store.OutboxEvent) error {
|
||||
if len(events) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Use the same delivery-before-ack contract as the general dispatcher,
|
||||
// while keeping the already-filtered batch from being read a second time.
|
||||
//
|
||||
// A delivery failure does not abort the batch. Returning here meant one
|
||||
// undeliverable payload -- reads are oldest-first -- was retried ahead of
|
||||
// every later event of its type on every poll, forever. Instead the failure
|
||||
// is counted against that row (dead-lettering it once exhausted) and the
|
||||
// batch continues.
|
||||
//
|
||||
// Ordering within one aggregate is still honoured: once an event for a
|
||||
// match fails, its later events are left for a subsequent poll so a client
|
||||
// can never observe that match's newer state before its older state. Other
|
||||
// aggregates are independent and proceed.
|
||||
blocked := make(map[string]struct{})
|
||||
var firstErr error
|
||||
for _, event := range events {
|
||||
if event.EventID == "" {
|
||||
return fmt.Errorf("outbox event has no ID")
|
||||
}
|
||||
if _, skip := blocked[event.AggregateID]; skip {
|
||||
continue
|
||||
}
|
||||
if err := dispatcher.Deliver(ctx, event); err != nil {
|
||||
return err
|
||||
blocked[event.AggregateID] = struct{}{}
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
if db != nil {
|
||||
if _, failErr := store.RecordOutboxDeliveryFailure(ctx, db, event.EventID, err, time.Now().UTC()); failErr != nil {
|
||||
return failErr
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := dispatcher.Ack(ctx, event.EventID, time.Now().UTC()); err != nil {
|
||||
// An ack failure is a database problem, not a payload problem;
|
||||
// stop rather than counting it against the event.
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func deliverProposalOutboxEvent(_ context.Context, event store.OutboxEvent, service *Service) error {
|
||||
@@ -128,7 +155,7 @@ func deliverProposalOutboxEvent(_ context.Context, event store.OutboxEvent, serv
|
||||
return fmt.Errorf("invalid proposal outbox event")
|
||||
}
|
||||
for _, playerID := range envelope.PlayerIDs {
|
||||
if err := service.PublishControlPlaneEvent(ControlPlaneEvent{
|
||||
if err := service.publishOutboxEvent(ControlPlaneEvent{
|
||||
Event: envelope.Event, Revision: envelope.Revision, ResourceID: envelope.ResourceID,
|
||||
OccurredAt: envelope.OccurredAt, State: envelope.State, PlayerID: playerID,
|
||||
}); err != nil {
|
||||
@@ -154,7 +181,7 @@ func deliverResultOutboxEvent(ctx context.Context, db *sql.DB, event store.Outbo
|
||||
return fmt.Errorf("result outbox event has no participants")
|
||||
}
|
||||
for _, playerID := range players {
|
||||
if err := service.PublishControlPlaneEvent(ControlPlaneEvent{
|
||||
if err := service.publishOutboxEvent(ControlPlaneEvent{
|
||||
Event: "state_changed", Revision: event.Revision, ResourceID: event.AggregateID,
|
||||
OccurredAt: event.CreatedAt, State: "COMPLETED", MatchID: event.AggregateID,
|
||||
PlayerID: playerID,
|
||||
@@ -188,7 +215,7 @@ func deliverStateOutboxEvent(_ context.Context, event store.OutboxEvent, service
|
||||
if playerID == "" {
|
||||
return fmt.Errorf("state outbox event has empty participant")
|
||||
}
|
||||
if err := service.PublishControlPlaneEvent(ControlPlaneEvent{Event: "state_changed", Revision: envelope.Revision, ResourceID: envelope.ResourceID, OccurredAt: envelope.OccurredAt, State: envelope.State, MatchID: envelope.MatchID, PlayerID: playerID}); err != nil {
|
||||
if err := service.publishOutboxEvent(ControlPlaneEvent{Event: "state_changed", Revision: envelope.Revision, ResourceID: envelope.ResourceID, OccurredAt: envelope.OccurredAt, State: envelope.State, MatchID: envelope.MatchID, PlayerID: playerID}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -103,3 +105,53 @@ func TestDeliverStateOutboxEventRoutesLiveAbandonmentLifecycle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One malformed row used to abort the whole batch. Because reads are
|
||||
// oldest-first and the row was never acknowledged, it was re-read ahead of
|
||||
// every later event of its type on every 100ms poll -- blocking lifecycle
|
||||
// delivery for all matches indefinitely, not just its own.
|
||||
func TestDispatchOutboxEventsIsNotBlockedByOnePoisonRow(t *testing.T) {
|
||||
delivered := []string{}
|
||||
acked := []string{}
|
||||
dispatcher := &store.OutboxDispatcher{
|
||||
Read: func(context.Context, int) ([]store.OutboxEvent, error) { return nil, nil },
|
||||
Deliver: func(_ context.Context, event store.OutboxEvent) error {
|
||||
delivered = append(delivered, event.EventID)
|
||||
if event.AggregateID == "match-poison" {
|
||||
return errors.New("invalid state outbox payload")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Ack: func(_ context.Context, eventID string, _ time.Time) error {
|
||||
acked = append(acked, eventID)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
events := []store.OutboxEvent{
|
||||
{EventID: "poison-1", AggregateID: "match-poison"},
|
||||
{EventID: "healthy-1", AggregateID: "match-healthy"},
|
||||
{EventID: "poison-2", AggregateID: "match-poison"},
|
||||
{EventID: "healthy-2", AggregateID: "match-other"},
|
||||
}
|
||||
// nil db: the failure counter is exercised against a real PostgreSQL in
|
||||
// the store integration tests; here we assert only batch progress.
|
||||
err := dispatchOutboxEvents(context.Background(), nil, dispatcher, events)
|
||||
if err == nil {
|
||||
t.Fatal("expected the delivery failure to be reported to the caller")
|
||||
}
|
||||
|
||||
for _, eventID := range []string{"healthy-1", "healthy-2"} {
|
||||
if !slices.Contains(acked, eventID) {
|
||||
t.Fatalf("%s was not acknowledged; a poison row still blocks the batch (acked=%v)", eventID, acked)
|
||||
}
|
||||
}
|
||||
if slices.Contains(acked, "poison-1") {
|
||||
t.Fatal("a failed delivery must not be acknowledged")
|
||||
}
|
||||
// Ordering within the failing aggregate is preserved: poison-2 must wait
|
||||
// so no client sees that match's newer state before its older state.
|
||||
if slices.Contains(delivered, "poison-2") {
|
||||
t.Fatalf("later event of a failed aggregate was delivered out of order: %v", delivered)
|
||||
}
|
||||
}
|
||||
|
||||
+130
-25
@@ -20,13 +20,22 @@ import (
|
||||
|
||||
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/observability"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/steam"
|
||||
)
|
||||
|
||||
const maxBodyBytes = 8 << 10
|
||||
|
||||
type CandidateProvider func(playerID, ticketID string) (domain.Candidate, error)
|
||||
type CandidateProviderV2 func(playerID, ticketID string, spec domain.QueueSpec) (domain.Candidate, error)
|
||||
type ProbeProvider func(playerID, region string, opaqueLocation, nonce []byte, receivedAt time.Time) (domain.ProbeEvidence, []byte, error)
|
||||
|
||||
// ProbeProvider validates a probe answer against the nonce the backend issued
|
||||
// and returns evidence whose ServerRTT is derived from backend timestamps
|
||||
// only. It takes a context because the issued nonce is durable: any replica
|
||||
// may serve the submission for a challenge another replica issued.
|
||||
type ProbeProvider func(ctx context.Context, playerID, region string, opaqueLocation, nonce []byte, receivedAt time.Time) (domain.ProbeEvidence, []byte, error)
|
||||
|
||||
// ProbeChallengeIssuer mints the nonce a client must echo back.
|
||||
type ProbeChallengeIssuer func(ctx context.Context, playerID, region string, now time.Time) ([]byte, error)
|
||||
type ProbeRecorder interface {
|
||||
RecordProbe(context.Context, string, string, time.Duration, time.Time) error
|
||||
}
|
||||
@@ -59,7 +68,9 @@ type QueueBackend interface {
|
||||
// failures must never change the result of an already successful mutation.
|
||||
type CandidateIndex interface {
|
||||
Upsert(context.Context, domain.Candidate) error
|
||||
Remove(context.Context, string) error
|
||||
// Remove is playlist-scoped because the projection is partitioned per
|
||||
// playlist; a ticket ID alone does not identify its namespace.
|
||||
Remove(context.Context, domain.Playlist, string) error
|
||||
}
|
||||
|
||||
type SessionBackend interface {
|
||||
@@ -107,16 +118,25 @@ type RosterProvider func(context.Context, domain.WorkloadBinding, time.Time) ([]
|
||||
type ReadinessCheck func(context.Context) error
|
||||
|
||||
type Service struct {
|
||||
Sessions *domain.SessionStore
|
||||
SessionBackend SessionBackend
|
||||
SessionIssuer SessionIssuer
|
||||
SteamLogin SteamLoginProvider
|
||||
Queue *domain.Queue
|
||||
Candidate CandidateProvider
|
||||
CandidateV2 CandidateProviderV2
|
||||
QueueBackend QueueBackend
|
||||
CandidateIndex CandidateIndex
|
||||
Probe ProbeProvider
|
||||
Sessions *domain.SessionStore
|
||||
SessionBackend SessionBackend
|
||||
SessionIssuer SessionIssuer
|
||||
SteamLogin SteamLoginProvider
|
||||
Queue *domain.Queue
|
||||
Candidate CandidateProvider
|
||||
CandidateV2 CandidateProviderV2
|
||||
QueueBackend QueueBackend
|
||||
CandidateIndex CandidateIndex
|
||||
// EventFanout, when set, publishes outbox-sourced events through a shared
|
||||
// transport instead of only this replica's in-memory hub. Without it a
|
||||
// client connected to a replica other than the one that drained the outbox
|
||||
// row never receives the event.
|
||||
EventFanout func(ControlPlaneEvent) error
|
||||
Probe ProbeProvider
|
||||
ProbeChallenger ProbeChallengeIssuer
|
||||
// CandidateRefresh re-reads a player's durable queue candidate so the
|
||||
// transient index can be corrected after its RTT changes.
|
||||
CandidateRefresh func(context.Context, string, time.Time) (domain.Candidate, bool, error)
|
||||
ProbeRecorder ProbeRecorder
|
||||
WorkloadVerify WorkloadVerifier
|
||||
ResultSubmitter ResultSubmitter
|
||||
@@ -209,7 +229,7 @@ func (s *Service) Handler() http.Handler {
|
||||
mux.HandleFunc("/v1/proposals/", s.proposalMutation)
|
||||
mux.HandleFunc("/v1/assignments/", s.assignment)
|
||||
mux.HandleFunc("/v1/profile/ranked", s.rankedProfile)
|
||||
mux.HandleFunc("/v1/probes/", s.probe)
|
||||
mux.HandleFunc("/v1/probes/", s.probeRoute)
|
||||
mux.HandleFunc("/v1/events", s.controlPlaneEvent)
|
||||
mux.HandleFunc("/v1/servers/", s.serverMutation)
|
||||
// The public contract is served below /api/v1. Keep the original /v1
|
||||
@@ -337,7 +357,18 @@ func (s *Service) steamSession(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
now := s.now()
|
||||
identity, err := s.SteamLogin.Authenticate(r.Context(), input.WebAPITicket, now)
|
||||
if err != nil || identity.PlayerID == "" || identity.SteamID == "" {
|
||||
if err != nil {
|
||||
// A Valve outage or a bad publisher key is our problem, not the
|
||||
// player's; answering 401 would tell a legitimate player their login
|
||||
// failed and send them off to fix an account that is fine.
|
||||
if errors.Is(err, steam.ErrUnavailable) {
|
||||
writeError(w, http.StatusServiceUnavailable, "auth_unavailable")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
if identity.PlayerID == "" || identity.SteamID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
@@ -352,6 +383,12 @@ func (s *Service) steamSession(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
// Session issuance refuses an actively banned identity. That is a
|
||||
// decision about this account, not an outage.
|
||||
if errors.Is(err, domain.ErrSessionRejected) {
|
||||
writeError(w, http.StatusForbidden, "identity_banned")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusServiceUnavailable, "auth_unavailable")
|
||||
return
|
||||
}
|
||||
@@ -486,9 +523,9 @@ func (s *Service) projectCandidate(ctx context.Context, ticket domain.QueueTicke
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) removeCandidate(ctx context.Context, ticketID string) {
|
||||
func (s *Service) removeCandidate(ctx context.Context, playlist domain.Playlist, ticketID string) {
|
||||
if s.CandidateIndex != nil {
|
||||
_ = s.CandidateIndex.Remove(ctx, ticketID)
|
||||
_ = s.CandidateIndex.Remove(ctx, playlist, ticketID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -894,7 +931,7 @@ func (s *Service) queueMutation(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
s.logQueueOutcome(eventName, ticketID, ticket, nil, now)
|
||||
if ticket.State == domain.Cancelled {
|
||||
s.removeCandidate(r.Context(), ticket.TicketID)
|
||||
s.removeCandidate(r.Context(), ticket.Playlist, ticket.TicketID)
|
||||
} else {
|
||||
s.projectCandidate(r.Context(), ticket)
|
||||
}
|
||||
@@ -1140,7 +1177,50 @@ type probeRequest struct {
|
||||
Nonce []byte `json:"nonce"`
|
||||
}
|
||||
|
||||
func (s *Service) probe(w http.ResponseWriter, r *http.Request) {
|
||||
// probeRoute splits /v1/probes/{region} from /v1/probes/{region}/challenge.
|
||||
// The challenge must exist for the submission to mean anything: RTT is the
|
||||
// interval between the backend issuing a nonce and receiving the answer, so
|
||||
// without an issued nonce there is nothing to compare against and no
|
||||
// backend-derived latency to record.
|
||||
func (s *Service) probeRoute(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/v1/probes/")
|
||||
if strings.HasSuffix(path, "/challenge") {
|
||||
s.probeChallenge(w, r, strings.TrimSuffix(path, "/challenge"))
|
||||
return
|
||||
}
|
||||
s.probe(w, r, path)
|
||||
}
|
||||
|
||||
func (s *Service) probeChallenge(w http.ResponseWriter, r *http.Request, region string) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed")
|
||||
return
|
||||
}
|
||||
playerID, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if region != "EU" && region != "NA" {
|
||||
writeError(w, http.StatusNotFound, "not_found")
|
||||
return
|
||||
}
|
||||
if s.ProbeChallenger == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "probe_unavailable")
|
||||
return
|
||||
}
|
||||
now := s.now()
|
||||
nonce, err := s.ProbeChallenger(r.Context(), playerID, region, now)
|
||||
if err != nil || len(nonce) == 0 {
|
||||
writeError(w, http.StatusServiceUnavailable, "probe_unavailable")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"region": region, "nonce": nonce,
|
||||
"expires_in_seconds": int(domain.ProbeFreshness.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) probe(w http.ResponseWriter, r *http.Request, region string) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed")
|
||||
return
|
||||
@@ -1149,7 +1229,6 @@ func (s *Service) probe(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
region := strings.TrimPrefix(r.URL.Path, "/v1/probes/")
|
||||
if (region != "EU" && region != "NA") || strings.Contains(region, "/") {
|
||||
writeError(w, http.StatusNotFound, "not_found")
|
||||
return
|
||||
@@ -1163,7 +1242,7 @@ func (s *Service) probe(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
receivedAt := s.now()
|
||||
evidence, expectedNonce, err := s.Probe(playerID, region, input.OpaqueLocation, input.Nonce, receivedAt)
|
||||
evidence, expectedNonce, err := s.Probe(r.Context(), playerID, region, input.OpaqueLocation, input.Nonce, receivedAt)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnprocessableEntity, "probe_unavailable")
|
||||
return
|
||||
@@ -1172,12 +1251,23 @@ func (s *Service) probe(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusUnprocessableEntity, "invalid_probe")
|
||||
return
|
||||
}
|
||||
if s.ProbeRecorder != nil {
|
||||
if err := s.ProbeRecorder.RecordProbe(r.Context(), playerID, region, evidence.ServerRTT, receivedAt); err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "probe_persistence_failed")
|
||||
return
|
||||
}
|
||||
// Accepting a probe without persisting it used to look like success while
|
||||
// leaving predicted_rtt empty, which silently keeps the ticket invisible
|
||||
// to the matcher. A missing recorder is a misconfiguration, not a
|
||||
// successful probe.
|
||||
if s.ProbeRecorder == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "probe_unavailable")
|
||||
return
|
||||
}
|
||||
if err := s.ProbeRecorder.RecordProbe(r.Context(), playerID, region, evidence.ServerRTT, receivedAt); err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "probe_persistence_failed")
|
||||
return
|
||||
}
|
||||
// Refresh the transient projection. A candidate inserted at enqueue time
|
||||
// carries an empty RTT map, and the Redis keyspace has its TTL
|
||||
// continually refreshed, so without this the stale candidate need never
|
||||
// repair itself and stays unmatchable despite a successful probe.
|
||||
s.refreshCandidateAfterProbe(r.Context(), playerID, receivedAt)
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"region": region, "server_rtt_ms": evidence.ServerRTT.Milliseconds(), "status": "accepted"})
|
||||
}
|
||||
|
||||
@@ -1267,3 +1357,18 @@ func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
// refreshCandidateAfterProbe repairs the transient candidate index once a
|
||||
// probe has changed the durable predicted RTT. It is best-effort: the index is
|
||||
// an acceleration layer over PostgreSQL authority, and the probe itself has
|
||||
// already committed.
|
||||
func (s *Service) refreshCandidateAfterProbe(ctx context.Context, playerID string, now time.Time) {
|
||||
if s.CandidateIndex == nil || s.CandidateRefresh == nil {
|
||||
return
|
||||
}
|
||||
candidate, queued, err := s.CandidateRefresh(ctx, playerID, now)
|
||||
if err != nil || !queued {
|
||||
return
|
||||
}
|
||||
_ = s.CandidateIndex.Upsert(ctx, candidate)
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ func (i *candidateIndexSpy) Upsert(_ context.Context, candidate domain.Candidate
|
||||
return i.upsertErr
|
||||
}
|
||||
|
||||
func (i *candidateIndexSpy) Remove(_ context.Context, _ string) error {
|
||||
func (i *candidateIndexSpy) Remove(_ context.Context, _ domain.Playlist, _ string) error {
|
||||
i.removeCalls++
|
||||
return i.removeErr
|
||||
}
|
||||
@@ -1759,7 +1759,10 @@ func TestProbeAPIUsesServerEvidenceAndRejectsClientRTTField(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
service := &Service{Sessions: sessions, Now: func() time.Time { return now }, Probe: func(playerID, region string, location, nonce []byte, receivedAt time.Time) (domain.ProbeEvidence, []byte, error) {
|
||||
// A ProbeRecorder is required: accepting a probe without persisting it
|
||||
// reports success while leaving predicted_rtt empty, which silently keeps
|
||||
// the ticket invisible to the matcher.
|
||||
service := &Service{Sessions: sessions, Now: func() time.Time { return now }, ProbeRecorder: &probeRecorderSpy{}, Probe: func(_ context.Context, playerID, region string, location, nonce []byte, receivedAt time.Time) (domain.ProbeEvidence, []byte, error) {
|
||||
called = true
|
||||
if playerID != "player-a" || region != "EU" || string(location) != "opaque" || string(nonce) != "nonce" || !receivedAt.Equal(now) {
|
||||
t.Fatalf("probe provider arguments = %q %s %q %q %v", playerID, region, location, nonce, receivedAt)
|
||||
@@ -1791,7 +1794,7 @@ func TestProbeAPIRecordsOnlyValidatedServerEvidence(t *testing.T) {
|
||||
sessions := domain.NewSessionStore()
|
||||
session, token, _ := sessions.Issue("player-a", time.Hour, now)
|
||||
recorder := &probeRecorderSpy{}
|
||||
service := &Service{Sessions: sessions, Now: func() time.Time { return now }, ProbeRecorder: recorder, Probe: func(_ string, region string, location, nonce []byte, _ time.Time) (domain.ProbeEvidence, []byte, error) {
|
||||
service := &Service{Sessions: sessions, Now: func() time.Time { return now }, ProbeRecorder: recorder, Probe: func(_ context.Context, _ string, region string, location, nonce []byte, _ time.Time) (domain.ProbeEvidence, []byte, error) {
|
||||
return domain.ProbeEvidence{OpaqueLocation: location, Nonce: nonce, IssuedAt: now, Region: region, ServerRTT: 37 * time.Millisecond}, nonce, nil
|
||||
}}
|
||||
server := httptest.NewServer(service.Handler())
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
|
||||
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/steam"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/store"
|
||||
)
|
||||
|
||||
// SteamTicketVerifier is the boundary to Valve. Keeping it an interface means
|
||||
// the production login path can be exercised end to end with the external call
|
||||
// stubbed, instead of only through a fake login provider that skips the whole
|
||||
// flow.
|
||||
type SteamTicketVerifier interface {
|
||||
Verify(ctx context.Context, ticket string) (steam.Identity, error)
|
||||
}
|
||||
|
||||
// SteamLogin is the production SteamLoginProvider: verify the ticket with
|
||||
// Valve, then resolve the verified Steam ID to a durable player ID.
|
||||
type SteamLogin struct {
|
||||
DB *sql.DB
|
||||
Verifier SteamTicketVerifier
|
||||
}
|
||||
|
||||
// PlayerIDForSteamID derives the durable player ID for a Steam ID on first
|
||||
// sign-in. It is a hash rather than the Steam ID itself so player IDs, which
|
||||
// appear in rosters and logs, do not restate the platform identifier.
|
||||
func PlayerIDForSteamID(steamID string) string {
|
||||
digest := sha256.Sum256([]byte("cosmic-clash/player/" + steamID))
|
||||
return "player-" + hex.EncodeToString(digest[:12])
|
||||
}
|
||||
|
||||
func (s SteamLogin) Authenticate(ctx context.Context, ticket string, _ time.Time) (domain.VerifiedIdentity, error) {
|
||||
if s.DB == nil || s.Verifier == nil {
|
||||
return domain.VerifiedIdentity{}, domain.ErrTicketRejected
|
||||
}
|
||||
identity, err := s.Verifier.Verify(ctx, ticket)
|
||||
if err != nil {
|
||||
return domain.VerifiedIdentity{}, err
|
||||
}
|
||||
// A returning player keeps the player ID they already had, so ratings,
|
||||
// penalties and bans follow the account rather than the session.
|
||||
playerID, err := store.ResolveSteamIdentity(ctx, s.DB, identity.SteamID, PlayerIDForSteamID(identity.SteamID))
|
||||
if err != nil {
|
||||
return domain.VerifiedIdentity{}, err
|
||||
}
|
||||
return domain.VerifiedIdentity{PlayerID: playerID, SteamID: identity.SteamID}, nil
|
||||
}
|
||||
@@ -3,7 +3,10 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -34,6 +37,8 @@ func main() {
|
||||
allocationQuota := flag.Int("allocation-quota", 0, "optional per-replica allocation attempts per region per quota window; zero disables this local guard")
|
||||
allocationQuotaWindow := flag.Duration("allocation-quota-window", time.Minute, "window for --allocation-quota")
|
||||
metricsAddr := flag.String("metrics-addr", envOrDefault("COSMIC_CLASH_ALLOCATOR_METRICS_ADDR", ":9091"), "allocator Prometheus metrics address; empty disables metrics")
|
||||
joinKeyFile := flag.String("join-authorisations-key-file", os.Getenv("COSMIC_CLASH_JOIN_SIGNING_KEY_FILE"), "JSON file mapping join-signing key ID to base64 key; the same material allocated game servers mount. Required: without it no assignment roster is published and no allocated match can start")
|
||||
joinKeyID := flag.String("join-authorisations-key-id", os.Getenv("COSMIC_CLASH_JOIN_SIGNING_KEY_ID"), "which key in --join-authorisations-key-file signs new authorisations; other keys stay valid for verification so a rotation does not break in-flight matches")
|
||||
flag.Parse()
|
||||
if *dsn == "" || *agonesURL == "" {
|
||||
fatalf("--dsn/COSMIC_CLASH_POSTGRES_DSN and --agones-url/COSMIC_CLASH_AGONES_URL are required")
|
||||
@@ -47,6 +52,16 @@ func main() {
|
||||
if *allocationQuota < 0 || *allocationQuotaWindow <= 0 || *workloadTokenTTL <= 0 {
|
||||
fatalf("--allocation-quota must be non-negative and --allocation-quota-window/--workload-token-ttl must be positive")
|
||||
}
|
||||
// Refuse to start without signing material rather than running an
|
||||
// allocator that binds allocations and silently never publishes a roster,
|
||||
// which strands every match short of ASSIGNMENT_READY.
|
||||
if *joinKeyFile == "" || *joinKeyID == "" {
|
||||
fatalf("--join-authorisations-key-file/COSMIC_CLASH_JOIN_SIGNING_KEY_FILE and --join-authorisations-key-id/COSMIC_CLASH_JOIN_SIGNING_KEY_ID are required; without them allocated matches can never become joinable")
|
||||
}
|
||||
joinKeys, err := loadJoinSigningKeys(*joinKeyFile, *joinKeyID)
|
||||
if err != nil {
|
||||
fatalf("load join signing keys: %v", err)
|
||||
}
|
||||
db, err := sql.Open("pgx", *dsn)
|
||||
if err != nil {
|
||||
fatalf("open PostgreSQL: %v", err)
|
||||
@@ -89,7 +104,9 @@ func main() {
|
||||
Metrics: metrics,
|
||||
Now: now,
|
||||
},
|
||||
Now: now,
|
||||
Now: now,
|
||||
Roster: store.AssignmentRosters{DB: db},
|
||||
Keys: joinKeys,
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
@@ -152,3 +169,32 @@ func fatalf(format string, args ...any) {
|
||||
log.Printf("allocator: "+format, args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// loadJoinSigningKeys reads the key ID to base64 key map shared with allocated
|
||||
// game servers. Every key in the file stays valid for verification; only the
|
||||
// named one signs, so rotation is: publish the new key everywhere, then point
|
||||
// --join-authorisations-key-id at it, then drop the old key once no live match
|
||||
// can still reference it.
|
||||
func loadJoinSigningKeys(path, activeKeyID string) (allocator.JoinSigningKeys, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return allocator.JoinSigningKeys{}, err
|
||||
}
|
||||
var encoded map[string]string
|
||||
if err := json.Unmarshal(raw, &encoded); err != nil {
|
||||
return allocator.JoinSigningKeys{}, fmt.Errorf("expected a JSON object of key ID to base64 key: %w", err)
|
||||
}
|
||||
keys := make(map[string][]byte, len(encoded))
|
||||
for keyID, value := range encoded {
|
||||
key, err := base64.StdEncoding.DecodeString(value)
|
||||
if err != nil || len(key) == 0 {
|
||||
return allocator.JoinSigningKeys{}, fmt.Errorf("join signing key %q is not valid base64", keyID)
|
||||
}
|
||||
keys[keyID] = key
|
||||
}
|
||||
result := allocator.JoinSigningKeys{ActiveKeyID: activeKeyID, Keys: keys}
|
||||
if len(keys[activeKeyID]) == 0 {
|
||||
return allocator.JoinSigningKeys{}, fmt.Errorf("active key ID %q is not present in %s", activeKeyID, path)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/migrations"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/observability"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/steam"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/store"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -34,6 +36,9 @@ func main() {
|
||||
rateWindow := flag.Duration("rate-limit-window", time.Minute, "fixed window for the per-replica request limiter")
|
||||
rateMaxKeys := flag.Int("rate-limit-max-keys", 10000, "maximum credential/IP keys retained by the per-replica request limiter")
|
||||
trustedProxyCIDRs := flag.String("trusted-proxy-cidrs", os.Getenv("COSMIC_CLASH_TRUSTED_PROXY_CIDRS"), "comma-separated immediate proxy CIDRs allowed to supply X-Forwarded-For")
|
||||
steamPublisherKey := flag.String("steam-publisher-key", os.Getenv("COSMIC_CLASH_STEAM_PUBLISHER_KEY"), "Steamworks publisher Web API key. Required for player sign-in; POST /v1/session/steam returns 503 until it and --steam-app-id are set. Never expose this to clients")
|
||||
steamAppID := flag.Uint64("steam-app-id", 0, "Steamworks App ID this build authenticates tickets for; may also be set via COSMIC_CLASH_STEAM_APP_ID")
|
||||
steamRejectBanned := flag.Bool("steam-reject-banned", true, "refuse sign-in for VAC- or publisher-banned accounts")
|
||||
minProtocolVersion := flag.Int("min-protocol-version", 0, "reject queue_create below this protocol_version with 426 Upgrade Required instead of queueing a client the matcher can never pair with anyone; zero disables the floor")
|
||||
flag.Parse()
|
||||
if *role != "api" {
|
||||
@@ -79,7 +84,41 @@ func main() {
|
||||
if *workloadSecret == "" {
|
||||
fmt.Fprintln(os.Stderr, "control-plane: warning: --workload-secret / COSMIC_CLASH_WORKLOAD_SECRET is unset; server registration and result submission will return 503")
|
||||
}
|
||||
if *steamAppID == 0 {
|
||||
if value := os.Getenv("COSMIC_CLASH_STEAM_APP_ID"); value != "" {
|
||||
parsed, parseErr := strconv.ParseUint(value, 10, 64)
|
||||
if parseErr != nil {
|
||||
fatalf("COSMIC_CLASH_STEAM_APP_ID must be a positive integer")
|
||||
}
|
||||
*steamAppID = parsed
|
||||
}
|
||||
}
|
||||
service := newAPIService(db, *workloadSecret, candidateIndex)
|
||||
// Tier thresholds live in the database so they can be retuned with a
|
||||
// rolling restart rather than a rebuilt image. A malformed durable policy
|
||||
// stops startup instead of silently mis-tiering every player; an empty
|
||||
// table is a supported state and falls back to the compiled launch policy.
|
||||
tierPolicy, err := store.LoadTierPolicy(startupCtx, db)
|
||||
if err != nil {
|
||||
fatalf("load tier policy: %v", err)
|
||||
}
|
||||
service.TierPolicy = tierPolicy
|
||||
// Player sign-in is configuration-gated rather than always-on: without a
|
||||
// publisher key there is no safe way to verify a ticket, and silently
|
||||
// accepting one would be worse than refusing to authenticate at all. The
|
||||
// endpoint keeps returning 503 until both values are supplied.
|
||||
if *steamPublisherKey != "" && *steamAppID != 0 {
|
||||
service.SteamLogin = api.SteamLogin{
|
||||
DB: db,
|
||||
Verifier: steam.WebAPIVerifier{
|
||||
PublisherKey: *steamPublisherKey,
|
||||
AppID: *steamAppID,
|
||||
RejectBanned: *steamRejectBanned,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "control-plane: warning: --steam-publisher-key and --steam-app-id are unset; player sign-in will return 503")
|
||||
}
|
||||
service.RateLimiter = rateLimiter
|
||||
service.ClientIPs = clientIPs
|
||||
service.MinProtocolVersion = *minProtocolVersion
|
||||
@@ -105,6 +144,28 @@ func main() {
|
||||
}
|
||||
}
|
||||
}()
|
||||
// Fan committed outbox events out to every replica. Subscribers live in
|
||||
// each process's in-memory hub, but any replica may drain a given outbox
|
||||
// row, so without this a client connected elsewhere never sees the event
|
||||
// and delivery degrades as replicas are added.
|
||||
service.EventFanout = func(event api.ControlPlaneEvent) error {
|
||||
payload, err := api.EncodeFannedOutEvent(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return store.NotifyControlPlaneEvent(ctx, db, payload)
|
||||
}
|
||||
go store.ListenControlPlaneEvents(ctx, *dsn, func(payload []byte) {
|
||||
event, err := api.DecodeFannedOutEvent(payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Publishing to a player with no local subscriber is a no-op, so every
|
||||
// replica can handle every notification.
|
||||
_ = service.PublishControlPlaneEvent(event)
|
||||
}, func(err error) {
|
||||
fmt.Fprintf(os.Stderr, "control-plane: event fan-out listener: %v\n", err)
|
||||
})
|
||||
go api.RunProposalOutboxDispatcher(ctx, db, service)
|
||||
go api.RunResultOutboxDispatcher(ctx, db, service)
|
||||
go api.RunStateOutboxDispatcher(ctx, db, service)
|
||||
@@ -149,6 +210,21 @@ func newAPIService(db *sql.DB, workloadSecret string, indexes ...api.CandidateIn
|
||||
},
|
||||
CandidateIndex: candidateIndex,
|
||||
ProbeRecorder: store.PostgresQueue{DB: db},
|
||||
// Regional latency placement. Without both of these the probe endpoint
|
||||
// is unreachable, queue_tickets.predicted_rtt stays empty, and
|
||||
// domain.validCandidate rejects every client-created ticket -- so the
|
||||
// matcher can never form a match from real traffic.
|
||||
ProbeChallenger: func(ctx context.Context, playerID, region string, now time.Time) ([]byte, error) {
|
||||
return store.IssueProbeChallenge(ctx, db, playerID, region, now)
|
||||
},
|
||||
Probe: func(ctx context.Context, playerID, region string, opaqueLocation, nonce []byte, receivedAt time.Time) (domain.ProbeEvidence, []byte, error) {
|
||||
return store.ProbeEvidenceFromChallenge(ctx, db, playerID, region, opaqueLocation, nonce, receivedAt)
|
||||
},
|
||||
// Repairs the transient index after a probe changes the durable RTT;
|
||||
// the candidate inserted at enqueue time has an empty map.
|
||||
CandidateRefresh: func(ctx context.Context, playerID string, now time.Time) (domain.Candidate, bool, error) {
|
||||
return store.FindQueuedCandidateByPlayer(ctx, db, playerID, now)
|
||||
},
|
||||
WorkloadVerify: api.WorkloadVerifierFromSignedToken([]byte(workloadSecret), db),
|
||||
ReadinessCheck: db.PingContext,
|
||||
Now: func() time.Time { return time.Now().UTC() },
|
||||
|
||||
@@ -26,6 +26,7 @@ func main() {
|
||||
stalledAllocationBatch := flag.Int("stalled-allocation-batch", 100, "maximum stalled matches reclaimed per pass")
|
||||
initialConnectBatch := flag.Int("initial-connect-batch", 100, "maximum pre-live matches evaluated per pass")
|
||||
liveAbandonmentBatch := flag.Int("live-abandonment-batch", 100, "maximum live ranked matches evaluated for expired reconnect leases per pass")
|
||||
retentionBatch := flag.Int("retention-batch", 500, "maximum rows deleted per table per retention pass")
|
||||
flag.Parse()
|
||||
if *dsn == "" {
|
||||
fatalf("--dsn or COSMIC_CLASH_POSTGRES_DSN is required")
|
||||
@@ -69,6 +70,39 @@ func main() {
|
||||
if reclaimed > 0 {
|
||||
log.Printf("reclaimed %d stalled allocations, requeuing their participants", reclaimed)
|
||||
}
|
||||
// Retention. Without this, idempotency keys alone grow by roughly one
|
||||
// row per queued player per heartbeat interval, forever.
|
||||
purged, err := store.PurgeExpiredRecords(ctx, db, now, *retentionBatch)
|
||||
if err != nil {
|
||||
fatalf("retention maintenance: %v", err)
|
||||
}
|
||||
if purged.Total() > 0 {
|
||||
log.Printf("purged %d expired records (idempotency=%d outbox=%d dead-lettered=%d sessions=%d)",
|
||||
purged.Total(), purged.IdempotencyKeys, purged.PublishedOutbox, purged.DeadLetteredOutbox, purged.ExpiredSessions)
|
||||
}
|
||||
// Deletion lag: a backlog that keeps climbing means the interval or
|
||||
// batch size is too small for current volume.
|
||||
backlog, err := store.RetentionBacklog(ctx, db, now)
|
||||
if err != nil {
|
||||
fatalf("retention backlog: %v", err)
|
||||
}
|
||||
if backlog > 0 {
|
||||
log.Printf("retention backlog is %d rows past their window", backlog)
|
||||
}
|
||||
staleProbes, err := store.PurgeExpiredProbeChallenges(ctx, db, now)
|
||||
if err != nil {
|
||||
fatalf("probe challenge maintenance: %v", err)
|
||||
}
|
||||
if staleProbes > 0 {
|
||||
log.Printf("purged %d unanswered probe challenges", staleProbes)
|
||||
}
|
||||
deadLettered, err := store.CountDeadLetteredOutboxEvents(ctx, db)
|
||||
if err != nil {
|
||||
fatalf("dead-letter count: %v", err)
|
||||
}
|
||||
if deadLettered > 0 {
|
||||
log.Printf("WARNING: %d outbox events were never delivered and are dead-lettered", deadLettered)
|
||||
}
|
||||
}
|
||||
runInitialConnect := func(now time.Time) {
|
||||
reconciled, err := store.ReconcileInitialConnect(ctx, db, now, *initialConnectBatch)
|
||||
|
||||
@@ -66,29 +66,21 @@ func main() {
|
||||
defer redisClient.Close()
|
||||
candidateProjection := store.CandidateProjection{
|
||||
Index: store.RedisCandidateIndex{Client: redisClient, Prefix: *redisPrefix, TTL: *redisTTL},
|
||||
Source: func(ctx context.Context, at time.Time) ([]domain.Candidate, error) {
|
||||
return store.ListQueuedCandidates(ctx, db, selectedPlaylist, at, 1000)
|
||||
Source: func(ctx context.Context, playlist domain.Playlist, at time.Time, limit int) ([]domain.Candidate, error) {
|
||||
return store.ListQueuedCandidates(ctx, db, playlist, at, limit)
|
||||
},
|
||||
}
|
||||
projection = &candidateProjection
|
||||
}
|
||||
worker := matcher.Worker{
|
||||
// Both branches are now playlist-filtered and limit-bounded at the
|
||||
// source. The Redis branch previously read the whole shared queue,
|
||||
// truncated it to limit, and only then filtered by playlist -- so a
|
||||
// large casual prefix could leave the ranked worker with zero
|
||||
// candidates indefinitely even while ranked tickets were queued.
|
||||
Source: func(ctx context.Context, at time.Time, playlist domain.Playlist, limit int) ([]domain.Candidate, error) {
|
||||
if projection != nil {
|
||||
candidates, err := projection.Snapshot(ctx, at)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(candidates) > limit {
|
||||
candidates = candidates[:limit]
|
||||
}
|
||||
filtered := make([]domain.Candidate, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Playlist == playlist {
|
||||
filtered = append(filtered, candidate)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
return projection.Snapshot(ctx, playlist, at, limit)
|
||||
}
|
||||
return store.ListQueuedCandidates(ctx, db, playlist, at, limit)
|
||||
},
|
||||
|
||||
@@ -77,6 +77,14 @@ func main() {
|
||||
Metrics: observability.NewMetrics(),
|
||||
Now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
// Load the durable policy here too, so the control-plane integration
|
||||
// scripts exercise the same path production takes rather than the
|
||||
// compiled default.
|
||||
tierPolicy, err := store.LoadTierPolicy(startupCtx, db)
|
||||
if err != nil {
|
||||
fatalf("load tier policy: %v", err)
|
||||
}
|
||||
service.TierPolicy = tierPolicy
|
||||
handler := service.Handler()
|
||||
listener, err := net.Listen("tcp", *listen)
|
||||
if err != nil {
|
||||
|
||||
+1161
-53
File diff suppressed because it is too large
Load Diff
@@ -23,12 +23,17 @@ class ContractTest(unittest.TestCase):
|
||||
for operation in path.values()
|
||||
if isinstance(operation, dict) and "operationId" in operation
|
||||
}
|
||||
self.assertTrue({
|
||||
# These are the operation IDs generated clients bind to, so a rename
|
||||
# here is a breaking change for every consumer. Assert the difference
|
||||
# rather than a bare subset check: a plain assertTrue reports only
|
||||
# "False is not true" and hides which operation went missing.
|
||||
required = {
|
||||
"createSteamSession", "getProfile", "createQueueTicket",
|
||||
"heartbeatQueueTicket", "cancelQueueTicket", "acceptProposal",
|
||||
"declineProposal", "getAssignment", "registerServer",
|
||||
"recordPlayerConnected", "submitMatchResult", "getRankedProfile",
|
||||
} <= operations)
|
||||
"claimPlayerConnection", "submitMatchResult", "getRankedProfile",
|
||||
}
|
||||
self.assertEqual(set(), required - operations)
|
||||
|
||||
def test_ranked_profile_contract_is_authoritative_and_optional_season_metadata(self):
|
||||
schema = self.openapi["components"]["schemas"]["RankedProfile"]
|
||||
@@ -46,7 +51,12 @@ class ContractTest(unittest.TestCase):
|
||||
for method, operation in methods.items():
|
||||
if method not in {"post", "delete", "put", "patch"} or "operationId" not in operation:
|
||||
continue
|
||||
if operation["operationId"] == "createSteamSession":
|
||||
# Exempt: these establish or consume a single-use credential
|
||||
# rather than mutating a revisioned resource. A probe challenge
|
||||
# is deliberately new on every call, and its answer is made
|
||||
# single-use by consuming the nonce, so an idempotency key
|
||||
# would be meaningless rather than protective.
|
||||
if operation["operationId"] in {"createSteamSession", "createProbeChallenge", "submitProbeAnswer"}:
|
||||
continue
|
||||
refs = {item.get("$ref") for item in operation.get("parameters", [])}
|
||||
self.assertIn("#/components/parameters/IdempotencyKey", refs, path)
|
||||
|
||||
@@ -46,6 +46,10 @@ type Allocation struct {
|
||||
Transport string
|
||||
State ServerLifecycle
|
||||
AllocatedAt time.Time
|
||||
// Endpoint is the client-facing address the provider returned. It is
|
||||
// persisted so a worker that crashes between allocating and publishing the
|
||||
// assignment roster can recover it instead of stranding the match.
|
||||
Endpoint string
|
||||
}
|
||||
|
||||
type Allocator struct {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// arenaRegistryPath is the Godot-side single source of truth for the arena
|
||||
// list (CLAUDE.md says so explicitly). rankedArenas in ranked.go is a
|
||||
// hand-maintained mirror of its floor-goal entries, and nothing has ever
|
||||
// checked the two against each other -- ranked_test.go asserts the same three
|
||||
// paths the production code hardcodes, so both could drift together silently.
|
||||
//
|
||||
// Drift is not hypothetical in either direction:
|
||||
//
|
||||
// - The registry's own comment anticipates flipping an elevated variant's
|
||||
// `random` flag to true once a checkpoint trained on that geometry is
|
||||
// promoted. Ranked would keep excluding it indefinitely.
|
||||
// - Adding an arena leaves ranked never selecting it.
|
||||
// - Renaming or removing one leaves the allocator handing out a scene path
|
||||
// that no longer exists, and an allocated ranked server fails to load its
|
||||
// arena at match start -- after allocation, so it burns a real match.
|
||||
const arenaRegistryPath = "../../Game/scripts/arena_registry.gd"
|
||||
|
||||
// gameScenesDir resolves a res:// path to the checked-out scene file.
|
||||
const gameScenesDir = "../../Game"
|
||||
|
||||
var arenaEntryPattern = regexp.MustCompile(`\{"name":\s*"([^"]*)",\s*"path":\s*"([^"]*)",\s*"random":\s*(true|false)\}`)
|
||||
|
||||
type registryArena struct {
|
||||
Name string
|
||||
Path string
|
||||
Random bool
|
||||
}
|
||||
|
||||
func parseArenaRegistry(t *testing.T) []registryArena {
|
||||
t.Helper()
|
||||
source, err := os.ReadFile(arenaRegistryPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read the Godot arena registry: %v", err)
|
||||
}
|
||||
matches := arenaEntryPattern.FindAllStringSubmatch(string(source), -1)
|
||||
arenas := make([]registryArena, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
arenas = append(arenas, registryArena{Name: match[1], Path: match[2], Random: match[3] == "true"})
|
||||
}
|
||||
|
||||
// Guard the guard. If the literal format changes and the pattern stops
|
||||
// matching, every assertion below would pass vacuously against an empty
|
||||
// list -- which is the exact failure mode this test exists to prevent.
|
||||
if len(arenas) < 2 {
|
||||
t.Fatalf("parsed %d arenas from %s; the entry format probably changed and this parser needs updating", len(arenas), arenaRegistryPath)
|
||||
}
|
||||
var eligible, ineligible int
|
||||
for _, arena := range arenas {
|
||||
if arena.Random {
|
||||
eligible++
|
||||
} else {
|
||||
ineligible++
|
||||
}
|
||||
}
|
||||
if eligible == 0 || ineligible == 0 {
|
||||
t.Fatalf("parsed %d eligible and %d ineligible arenas; expected both kinds, so the `random` flag is probably not being read correctly", eligible, ineligible)
|
||||
}
|
||||
return arenas
|
||||
}
|
||||
|
||||
// TestRankedArenasMatchTheGodotRegistry is the cross-language contract. It is
|
||||
// the arena equivalent of the golden join-authorisation token in
|
||||
// Game/tests/cases/test_match_net.gd: one side owns the truth, and this fails
|
||||
// loudly when the other stops agreeing.
|
||||
func TestRankedArenasMatchTheGodotRegistry(t *testing.T) {
|
||||
registry := parseArenaRegistry(t)
|
||||
|
||||
expected := map[string]string{}
|
||||
var expectedOrder []string
|
||||
for _, arena := range registry {
|
||||
if !arena.Random {
|
||||
continue
|
||||
}
|
||||
expected[arena.Path] = arena.Name
|
||||
expectedOrder = append(expectedOrder, arena.Path)
|
||||
}
|
||||
|
||||
actual := map[string]string{}
|
||||
for id, arena := range rankedArenas {
|
||||
actual[arena.Path] = id
|
||||
}
|
||||
|
||||
for path, name := range expected {
|
||||
if _, present := actual[path]; !present {
|
||||
t.Errorf("registry arena %q (%s) is ranked-eligible in Godot but missing from rankedArenas.\n"+
|
||||
"If a checkpoint trained on this geometry was promoted, add it to rankedArenas and rankedArenaOrder in ranked.go.", path, name)
|
||||
}
|
||||
}
|
||||
for path, id := range actual {
|
||||
if _, present := expected[path]; !present {
|
||||
t.Errorf("rankedArenas contains %q (id %q), which is not a random:true entry in %s.\n"+
|
||||
"Ranked would allocate a scene the Godot registry no longer offers.", path, id, arenaRegistryPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Rotation order must follow the registry's declaration order, since
|
||||
// RankedArenaForProposal indexes rankedArenaOrder and callers reason about
|
||||
// "the arenas, in order" across both languages.
|
||||
if len(rankedArenaOrder) != len(expectedOrder) {
|
||||
t.Fatalf("rankedArenaOrder has %d entries, registry has %d eligible", len(rankedArenaOrder), len(expectedOrder))
|
||||
}
|
||||
for index, id := range rankedArenaOrder {
|
||||
arena, known := rankedArenas[id]
|
||||
if !known {
|
||||
t.Fatalf("rankedArenaOrder[%d] = %q, which is not a key of rankedArenas", index, id)
|
||||
}
|
||||
if arena.Path != expectedOrder[index] {
|
||||
t.Errorf("rotation position %d is %q, registry declares %q there", index, arena.Path, expectedOrder[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A ranked arena path is handed to an allocated server after allocation, so a
|
||||
// path with no scene behind it fails at match start rather than at selection --
|
||||
// burning a real match and a real server. Cheap to catch here instead.
|
||||
func TestRankedArenaPathsResolveToRealScenes(t *testing.T) {
|
||||
for id, arena := range rankedArenas {
|
||||
relative, ok := scenePathFromRes(arena.Path)
|
||||
if !ok {
|
||||
t.Errorf("ranked arena %q has path %q, which is not a res:// path", id, arena.Path)
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(gameScenesDir, relative)); err != nil {
|
||||
t.Errorf("ranked arena %q points at %q, which does not exist: %v", id, arena.Path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Elevated-goal variants stay ranked-ineligible until a policy trained on that
|
||||
// geometry is promoted; the current bots cannot score on one. Assert this
|
||||
// against the registry's own flag rather than a second hardcoded list, so the
|
||||
// exclusion tracks the registry instead of drifting alongside it.
|
||||
func TestIneligibleRegistryArenasAreRejectedForRanked(t *testing.T) {
|
||||
registry := parseArenaRegistry(t)
|
||||
checked := 0
|
||||
for _, arena := range registry {
|
||||
if arena.Random {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
if IsRankedArenaPath(arena.Path) {
|
||||
t.Errorf("%q (%s) is random:false in the Godot registry but accepted for ranked", arena.Path, arena.Name)
|
||||
}
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Fatal("no ineligible arenas were checked")
|
||||
}
|
||||
}
|
||||
|
||||
func scenePathFromRes(path string) (string, bool) {
|
||||
const prefix = "res://"
|
||||
if len(path) <= len(prefix) || path[:len(prefix)] != prefix {
|
||||
return "", false
|
||||
}
|
||||
return path[len(prefix):], true
|
||||
}
|
||||
@@ -48,3 +48,14 @@ func manifestBytes(manifest AllocationManifest) []byte {
|
||||
func ManifestDigest(manifest AllocationManifest) [32]byte {
|
||||
return sha256.Sum256(manifestBytes(manifest))
|
||||
}
|
||||
|
||||
// AssignmentParticipant is the durable roster row the allocator turns into one
|
||||
// signed join authorisation. It lives here rather than in the store so the
|
||||
// allocator can consume it through an interface without depending on the
|
||||
// persistence package.
|
||||
type AssignmentParticipant struct {
|
||||
PlayerID string
|
||||
SteamID string
|
||||
Slot int
|
||||
Team int
|
||||
}
|
||||
|
||||
@@ -152,8 +152,13 @@ func (c *AuthCoordinator) Expire(now time.Time) []AuthAttempt {
|
||||
}
|
||||
|
||||
var (
|
||||
ErrTicketRejected = fmt.Errorf("steam ticket rejected")
|
||||
ErrTicketRejected = fmt.Errorf("steam ticket rejected")
|
||||
// ErrSessionRejected is deliberately opaque to the client: it must not
|
||||
// distinguish "no such session" from "wrong token".
|
||||
ErrSessionRejected = fmt.Errorf("session rejected")
|
||||
// ErrIdentityBanned is separate so the server can log and act on a ban
|
||||
// distinctly, even though the client sees the same rejection.
|
||||
ErrIdentityBanned = fmt.Errorf("identity is banned")
|
||||
)
|
||||
|
||||
func NewTicketVerifier(expectedApp uint64) (*TicketVerifier, error) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BackfillProposalWindow is the response window for a backfill offer. The
|
||||
// design specifies "a separate 10-second opt-in proposal", which is the same
|
||||
// duration as an ordinary proposal -- it is named separately because what
|
||||
// differs is the payload (score, time remaining, team and slot) and the
|
||||
// absence of any decline penalty, not the timing.
|
||||
const BackfillProposalWindow = ProposalWindow
|
||||
|
||||
// BackfillTarget describes the vacated slot a backfill is trying to fill, plus
|
||||
// the compatibility contract the running match already committed to. The
|
||||
// backfilled player joins an existing server, so build, protocol and region
|
||||
// are fixed by that match rather than negotiated.
|
||||
type BackfillTarget struct {
|
||||
MatchID string
|
||||
ServerID string
|
||||
Region string
|
||||
// Anchor carries the match's build/protocol/playlist contract. Only the
|
||||
// compatibility fields are read; rating and RTT come from the candidate.
|
||||
Anchor Candidate
|
||||
Slot CasualSlot
|
||||
Phase CasualPhase
|
||||
// AnchorRating is the match's representative rating, used for the same
|
||||
// widening tolerance an ordinary proposal would apply.
|
||||
AnchorRating float64
|
||||
// VacatedAt is when the slot became fillable. Tolerance widens with the
|
||||
// wait, matching ordinary queue behaviour.
|
||||
VacatedAt time.Time
|
||||
}
|
||||
|
||||
var ErrNoBackfillCandidate = fmt.Errorf("no eligible backfill candidate")
|
||||
|
||||
// SelectCasualBackfillCandidate implements docs/MATCHMAKING.md's rule for the
|
||||
// vacated human slot: the oldest ordinary casual ticket meeting the same
|
||||
// build, a region RTT at or under the placement ceiling, and the current
|
||||
// anchor-tolerance rule, with ties broken by ticket ID.
|
||||
//
|
||||
// It is deliberately a pure function over an already-fetched candidate set, so
|
||||
// the choice is reproducible and testable without a database. It selects only;
|
||||
// claiming the ticket remains a durable transaction, as with ordinary
|
||||
// proposals.
|
||||
func SelectCasualBackfillCandidate(target BackfillTarget, candidates []Candidate, now time.Time) (Candidate, error) {
|
||||
if target.MatchID == "" || target.ServerID == "" || target.Region == "" || now.IsZero() {
|
||||
return Candidate{}, fmt.Errorf("invalid backfill target")
|
||||
}
|
||||
// Backfill replaces a bot slot at a kickoff boundary only. Enforcing it
|
||||
// here as well as at the durable boundary keeps an ineligible mid-play
|
||||
// slot from ever reaching candidate selection.
|
||||
if !CanCasualBackfill(target.Phase, target.Slot) {
|
||||
return Candidate{}, ErrNoBackfillCandidate
|
||||
}
|
||||
if target.Anchor.Playlist != "" && target.Anchor.Playlist != Casual {
|
||||
// Ranked is never backfilled: exactly six verified humans, never bots.
|
||||
return Candidate{}, ErrNoBackfillCandidate
|
||||
}
|
||||
tolerance := RatingTolerance(now.Sub(target.VacatedAt).Seconds())
|
||||
|
||||
var best Candidate
|
||||
found := false
|
||||
for _, candidate := range candidates {
|
||||
if !eligibleBackfillCandidate(target, candidate, tolerance) {
|
||||
continue
|
||||
}
|
||||
if !found || betterBackfillCandidate(candidate, best) {
|
||||
best = candidate
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Candidate{}, ErrNoBackfillCandidate
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
func eligibleBackfillCandidate(target BackfillTarget, candidate Candidate, tolerance float64) bool {
|
||||
if !validCandidate(candidate) {
|
||||
return false
|
||||
}
|
||||
// "ordinary casual ticket": a backfill offer is only ever made to someone
|
||||
// queuing normally, never to another match's participant.
|
||||
if candidate.Playlist != Casual {
|
||||
return false
|
||||
}
|
||||
if !compatibleMetadata(target.Anchor, candidate) {
|
||||
return false
|
||||
}
|
||||
// The server already exists in one region, so the candidate must reach
|
||||
// that region specifically -- not merely share some region with others.
|
||||
rtt, measured := candidate.PredictedRTT[target.Region]
|
||||
if !measured || rtt > MaxPlacementRTT {
|
||||
return false
|
||||
}
|
||||
return abs(candidate.Rating-target.AnchorRating) <= tolerance
|
||||
}
|
||||
|
||||
// betterBackfillCandidate is the design's ordering: oldest ticket first, ties
|
||||
// broken by ticket ID so the choice is deterministic across replicas rather
|
||||
// than dependent on scan order.
|
||||
func betterBackfillCandidate(candidate, best Candidate) bool {
|
||||
if candidate.EnqueuedAt.Before(best.EnqueuedAt) {
|
||||
return true
|
||||
}
|
||||
if candidate.EnqueuedAt.After(best.EnqueuedAt) {
|
||||
return false
|
||||
}
|
||||
return candidate.TicketID < best.TicketID
|
||||
}
|
||||
|
||||
// BackfillDeclinePenalty is zero by design. Declining or ignoring a backfill
|
||||
// offer costs nothing: the player asked for an ordinary match and is being
|
||||
// offered a partly-played one, so refusing is not antisocial the way declining
|
||||
// an ordinary proposal is.
|
||||
func BackfillDeclinePenalty() time.Duration { return 0 }
|
||||
@@ -0,0 +1,153 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func backfillTarget() BackfillTarget {
|
||||
return BackfillTarget{
|
||||
MatchID: "match-1", ServerID: "server-1", Region: "EU",
|
||||
Anchor: Candidate{Playlist: Casual, ClientBuild: "build-1", ProtocolVersion: 1},
|
||||
Slot: CasualSlot{Slot: 2, Team: 0, PlayerID: "bot-slot-2", IsBot: true},
|
||||
Phase: CasualKickoff,
|
||||
AnchorRating: 1500,
|
||||
VacatedAt: time.Unix(1000, 0).UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func backfillCandidate(ticketID string, enqueuedAt time.Time) Candidate {
|
||||
return Candidate{
|
||||
TicketID: ticketID, PlayerID: "player-" + ticketID, Playlist: Casual,
|
||||
ClientBuild: "build-1", ProtocolVersion: 1, Rating: 1500,
|
||||
EnqueuedAt: enqueuedAt, PredictedRTT: map[string]float64{"EU": 40},
|
||||
}
|
||||
}
|
||||
|
||||
// docs/MATCHMAKING.md: "Choose the oldest ordinary casual ticket ... ties use
|
||||
// ticket ID."
|
||||
func TestBackfillPicksTheOldestTicketAndBreaksTiesByID(t *testing.T) {
|
||||
now := time.Unix(1000, 0).UTC()
|
||||
base := now.Add(-time.Minute)
|
||||
candidates := []Candidate{
|
||||
backfillCandidate("ticket-c", base.Add(2*time.Second)),
|
||||
backfillCandidate("ticket-b", base), // tie with ticket-a, loses on ID
|
||||
backfillCandidate("ticket-a", base), // oldest, lowest ID
|
||||
backfillCandidate("ticket-d", base.Add(time.Second)),
|
||||
}
|
||||
chosen, err := SelectCasualBackfillCandidate(backfillTarget(), candidates, now)
|
||||
if err != nil {
|
||||
t.Fatalf("select: %v", err)
|
||||
}
|
||||
if chosen.TicketID != "ticket-a" {
|
||||
t.Fatalf("chose %q, want the oldest ticket with the lowest ID", chosen.TicketID)
|
||||
}
|
||||
|
||||
// Determinism: the result must not depend on scan order, or two replicas
|
||||
// could offer the same slot to different players.
|
||||
reversed := []Candidate{candidates[2], candidates[1], candidates[3], candidates[0]}
|
||||
again, err := SelectCasualBackfillCandidate(backfillTarget(), reversed, now)
|
||||
if err != nil || again.TicketID != chosen.TicketID {
|
||||
t.Fatalf("selection depends on input order: %q vs %q (err=%v)", again.TicketID, chosen.TicketID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillRejectsIncompatibleCandidates(t *testing.T) {
|
||||
now := time.Unix(1000, 0).UTC()
|
||||
base := now.Add(-time.Minute)
|
||||
for name, mutate := range map[string]func(*Candidate){
|
||||
"wrong build": func(c *Candidate) { c.ClientBuild = "build-2" },
|
||||
"wrong protocol": func(c *Candidate) { c.ProtocolVersion = 2 },
|
||||
"ranked ticket": func(c *Candidate) { c.Playlist = Ranked },
|
||||
// The server already exists in one region; sharing some other region
|
||||
// is not enough.
|
||||
"no RTT for the match region": func(c *Candidate) { c.PredictedRTT = map[string]float64{"NA": 20} },
|
||||
"over the placement ceiling": func(c *Candidate) { c.PredictedRTT = map[string]float64{"EU": MaxPlacementRTT + 1} },
|
||||
"no RTT evidence at all": func(c *Candidate) { c.PredictedRTT = nil },
|
||||
"rating far outside tolerance": func(c *Candidate) { c.Rating = 1500 + MaxRatingTolerance + 1 },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
candidate := backfillCandidate("ticket-a", base)
|
||||
mutate(&candidate)
|
||||
if _, err := SelectCasualBackfillCandidate(backfillTarget(), []Candidate{candidate}, now); !errors.Is(err, ErrNoBackfillCandidate) {
|
||||
t.Fatalf("err = %v, want ErrNoBackfillCandidate", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill replaces a bot slot at a kickoff boundary only, never a live human
|
||||
// slot and never mid-play.
|
||||
func TestBackfillOnlyFillsBotSlotsAtKickoff(t *testing.T) {
|
||||
now := time.Unix(1000, 0).UTC()
|
||||
candidates := []Candidate{backfillCandidate("ticket-a", now.Add(-time.Minute))}
|
||||
for name, mutate := range map[string]func(*BackfillTarget){
|
||||
"mid-play": func(target *BackfillTarget) { target.Phase = CasualLive },
|
||||
"occupied by a human": func(target *BackfillTarget) { target.Slot.IsBot = false },
|
||||
"ranked match": func(target *BackfillTarget) { target.Anchor.Playlist = Ranked },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
target := backfillTarget()
|
||||
mutate(&target)
|
||||
if _, err := SelectCasualBackfillCandidate(target, candidates, now); !errors.Is(err, ErrNoBackfillCandidate) {
|
||||
t.Fatalf("err = %v, want ErrNoBackfillCandidate", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Tolerance widens with the wait, exactly as it does for an ordinary queue, so
|
||||
// a slot that has sat vacant longer accepts a wider rating spread.
|
||||
func TestBackfillToleranceWidensWithTheVacancy(t *testing.T) {
|
||||
base := time.Unix(1000, 0).UTC()
|
||||
target := backfillTarget()
|
||||
target.VacatedAt = base
|
||||
distant := backfillCandidate("ticket-a", base.Add(-time.Minute))
|
||||
distant.Rating = target.AnchorRating + MinRatingTolerance + 1
|
||||
|
||||
if _, err := SelectCasualBackfillCandidate(target, []Candidate{distant}, base); !errors.Is(err, ErrNoBackfillCandidate) {
|
||||
t.Fatalf("a candidate outside the initial tolerance was accepted: %v", err)
|
||||
}
|
||||
widened := base.Add(10 * time.Minute)
|
||||
if _, err := SelectCasualBackfillCandidate(target, []Candidate{distant}, widened); err != nil {
|
||||
t.Fatalf("tolerance did not widen with the vacancy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillRejectsInvalidTargets(t *testing.T) {
|
||||
now := time.Unix(1000, 0).UTC()
|
||||
candidates := []Candidate{backfillCandidate("ticket-a", now.Add(-time.Minute))}
|
||||
for name, mutate := range map[string]func(*BackfillTarget){
|
||||
"no match": func(target *BackfillTarget) { target.MatchID = "" },
|
||||
"no server": func(target *BackfillTarget) { target.ServerID = "" },
|
||||
"no region": func(target *BackfillTarget) { target.Region = "" },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
target := backfillTarget()
|
||||
mutate(&target)
|
||||
if _, err := SelectCasualBackfillCandidate(target, candidates, now); err == nil {
|
||||
t.Fatalf("invalid target %s was accepted", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := SelectCasualBackfillCandidate(backfillTarget(), nil, time.Time{}); err == nil {
|
||||
t.Fatal("zero time was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// Declining or ignoring a backfill offer costs nothing: the player asked for
|
||||
// an ordinary match and is being offered a partly-played one.
|
||||
func TestBackfillCarriesNoDeclinePenaltyAndAShortWindow(t *testing.T) {
|
||||
if BackfillDeclinePenalty() != 0 || CasualBackfillPenalty() != 0 {
|
||||
t.Fatal("backfill must not carry a cooldown")
|
||||
}
|
||||
if BackfillProposalWindow != 10*time.Second {
|
||||
t.Fatalf("backfill window = %v, want the documented 10s", BackfillProposalWindow)
|
||||
}
|
||||
// Same duration as an ordinary proposal. What makes a backfill offer
|
||||
// "separate" is its payload and the absent penalty, not its timing.
|
||||
if BackfillProposalWindow != ProposalWindow {
|
||||
t.Fatalf("backfill window %v diverged from the ordinary proposal window %v", BackfillProposalWindow, ProposalWindow)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -15,9 +18,14 @@ type SignedJoinAuthorisation struct {
|
||||
Signature []byte
|
||||
}
|
||||
|
||||
// JoinAuthorisationBytes is the canonical claim encoding. KeyID is appended
|
||||
// last and is covered by the signature, so an attacker cannot redirect an
|
||||
// authorisation at a different key than the one that signed it. Game/scripts/
|
||||
// match_net.gd builds the identical byte sequence; the two must change
|
||||
// together.
|
||||
func JoinAuthorisationBytes(auth JoinAuthorisation) []byte {
|
||||
return []byte(fmt.Sprintf("%s\x00%s\x00%s\x00%s\x00%d\x00%d\x00%s\x00%d\x00%s",
|
||||
auth.MatchID, auth.ServerID, auth.PlayerID, auth.SteamID, auth.Slot, auth.Team, auth.Protocol, auth.Generation, auth.ExpiresAt.UTC().Format(time.RFC3339Nano)))
|
||||
return []byte(fmt.Sprintf("%s\x00%s\x00%s\x00%s\x00%d\x00%d\x00%s\x00%d\x00%s\x00%s",
|
||||
auth.MatchID, auth.ServerID, auth.PlayerID, auth.SteamID, auth.Slot, auth.Team, auth.Protocol, auth.Generation, auth.ExpiresAt.UTC().Format(time.RFC3339Nano), auth.KeyID))
|
||||
}
|
||||
|
||||
func SignJoinAuthorisation(auth JoinAuthorisation, sign func([]byte) ([]byte, error)) (SignedJoinAuthorisation, error) {
|
||||
@@ -34,6 +42,8 @@ func SignJoinAuthorisation(auth JoinAuthorisation, sign func([]byte) ([]byte, er
|
||||
// SignJoinAuthorisationHMAC is the interoperable production profile used by
|
||||
// the Godot allocated server. The key is mounted out-of-band; the signed
|
||||
// bytes remain the same canonical claim bytes used by the generic signer.
|
||||
// The caller must have set auth.KeyID to the ID of this key, so the verifier
|
||||
// can pick the right one out of its key set.
|
||||
func SignJoinAuthorisationHMAC(auth JoinAuthorisation, key []byte) (SignedJoinAuthorisation, error) {
|
||||
if len(key) == 0 {
|
||||
return SignedJoinAuthorisation{}, ErrJoinAuthorisation
|
||||
@@ -49,3 +59,54 @@ func (r *RankedConnections) AdmitSigned(signed SignedJoinAuthorisation, verify f
|
||||
}
|
||||
return r.Admit(signed.Authorisation, now)
|
||||
}
|
||||
|
||||
// AssignmentRosterDigest binds a manifest to the exact roster it was issued
|
||||
// with. Signing each authorisation individually proves each claim, but the
|
||||
// manifest also has to commit to the set, so a server cannot be handed a
|
||||
// truncated roster whose entries are each individually valid.
|
||||
//
|
||||
// Entries are hashed in slot order so the digest is independent of the order
|
||||
// the caller happened to build them in.
|
||||
func AssignmentRosterDigest(roster []SignedJoinAuthorisation) (string, error) {
|
||||
if len(roster) == 0 {
|
||||
return "", ErrJoinAuthorisation
|
||||
}
|
||||
ordered := make([]SignedJoinAuthorisation, len(roster))
|
||||
copy(ordered, roster)
|
||||
sort.Slice(ordered, func(i, j int) bool {
|
||||
return ordered[i].Authorisation.Slot < ordered[j].Authorisation.Slot
|
||||
})
|
||||
digest := sha256.New()
|
||||
for _, signed := range ordered {
|
||||
if signed.Authorisation.PlayerID == "" {
|
||||
return "", ErrJoinAuthorisation
|
||||
}
|
||||
digest.Write(JoinAuthorisationBytes(signed.Authorisation))
|
||||
digest.Write([]byte{0})
|
||||
}
|
||||
return hex.EncodeToString(digest.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// VerifyJoinAuthorisationHMAC builds the verifier the persistence boundary
|
||||
// re-checks each signature with, selecting the key named by the claim. Keys is
|
||||
// key ID to raw key; an unknown ID verifies as false rather than falling back
|
||||
// to any other key.
|
||||
func VerifyJoinAuthorisationHMAC(keys map[string][]byte) func([]byte, []byte) bool {
|
||||
return func(claims, signature []byte) bool {
|
||||
if len(keys) == 0 || len(claims) == 0 || len(signature) == 0 {
|
||||
return false
|
||||
}
|
||||
// The key ID is the last NUL-separated field of the canonical bytes.
|
||||
separator := bytes.LastIndexByte(claims, 0)
|
||||
if separator < 0 {
|
||||
return false
|
||||
}
|
||||
key, known := keys[string(claims[separator+1:])]
|
||||
if !known || len(key) == 0 {
|
||||
return false
|
||||
}
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write(claims)
|
||||
return hmac.Equal(mac.Sum(nil), signature)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,12 @@ type JoinAuthorisation struct {
|
||||
Protocol string
|
||||
Generation uint64
|
||||
ExpiresAt time.Time
|
||||
// KeyID names the signing key so the allocator can rotate without
|
||||
// invalidating authorisations already issued for in-flight matches: the
|
||||
// game server holds a set of currently-valid keys and selects by this ID.
|
||||
// It is part of the signed bytes, so it cannot be swapped to point at a
|
||||
// different key than the one that actually signed.
|
||||
KeyID string
|
||||
}
|
||||
|
||||
type rankedConnection struct {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
ALTER TABLE outbox
|
||||
ADD COLUMN delivery_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN last_delivery_error TEXT,
|
||||
ADD COLUMN dead_lettered_at TIMESTAMPTZ;
|
||||
|
||||
-- The unpublished dispatchers read oldest-first and previously stopped on the
|
||||
-- first delivery error, so one permanently malformed payload blocked every
|
||||
-- later event of that type forever. Dead-lettered rows leave the working set
|
||||
-- via this partial index so a poison row degrades to one lost event instead of
|
||||
-- a stalled queue.
|
||||
DROP INDEX IF EXISTS outbox_unpublished_order;
|
||||
|
||||
CREATE INDEX outbox_unpublished_order
|
||||
ON outbox (created_at, event_id)
|
||||
WHERE published_at IS NULL AND dead_lettered_at IS NULL;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Retention support. Three tables grow without bound today:
|
||||
--
|
||||
-- idempotency_keys -- the client heartbeats every 10s and mints a fresh key
|
||||
-- each time, so at 10,000 queued players this alone adds roughly 60,000
|
||||
-- rows per minute, forever.
|
||||
-- outbox -- published rows are never purged.
|
||||
-- sessions -- expired and revoked rows are never purged.
|
||||
--
|
||||
-- The maintenance role performed lifecycle reconciliation only, so storage,
|
||||
-- index size, vacuum pressure, backup size and recovery time all grew without
|
||||
-- limit on a service meant to scale horizontally.
|
||||
--
|
||||
-- These indexes exist to make the deletion predicates cheap; without them each
|
||||
-- purge pass would sequentially scan the very tables it is trying to bound.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idempotency_keys_created_at
|
||||
ON idempotency_keys (created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS outbox_published_at
|
||||
ON outbox (published_at)
|
||||
WHERE published_at IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sessions_expires_at
|
||||
ON sessions (expires_at);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- The allocator learns the server's client-facing endpoint from the provider
|
||||
-- allocation response, but nothing persisted it. Publishing the assignment
|
||||
-- roster needs that endpoint, and a worker that crashed between allocating and
|
||||
-- publishing had no way to recover it -- FindProviderAllocation would report
|
||||
-- the allocation as already recorded while the endpoint was gone, leaving the
|
||||
-- match permanently unable to reach ASSIGNMENT_READY.
|
||||
ALTER TABLE allocations
|
||||
ADD COLUMN endpoint TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Latency probes are nonce-bound: the backend issues a challenge, the client
|
||||
-- echoes it back with its opaque Steam location, and the backend computes RTT
|
||||
-- from its own send/receive timestamps rather than trusting a client-reported
|
||||
-- number.
|
||||
--
|
||||
-- Nothing issued that nonce before, so ProbeProvider had no expected value to
|
||||
-- compare against and /v1/probes/{region} was unreachable in every real
|
||||
-- binary. With no probe, queue_tickets.predicted_rtt stayed empty, and
|
||||
-- domain.validCandidate hard-requires a non-empty map -- so no client-created
|
||||
-- ticket could ever be selected by the matcher.
|
||||
--
|
||||
-- The challenge is durable rather than per-process because any control-plane
|
||||
-- replica may serve the follow-up submission.
|
||||
CREATE TABLE probe_challenges (
|
||||
player_id TEXT NOT NULL REFERENCES identities(player_id) ON DELETE CASCADE,
|
||||
region TEXT NOT NULL CHECK (region IN ('EU', 'NA')),
|
||||
nonce BYTEA NOT NULL,
|
||||
issued_at TIMESTAMPTZ NOT NULL,
|
||||
PRIMARY KEY (player_id, region)
|
||||
);
|
||||
|
||||
-- Supports the expiry sweep; challenges are short-lived and single-use.
|
||||
CREATE INDEX probe_challenges_issued_at ON probe_challenges (issued_at);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user