Compare commits

...

565 Commits

Author SHA1 Message Date
Josh Creek c02aad66a0 fix(ui): keep menu content reachable at any window size
The main menu clipped its own title and bottom button in debug builds. The
project lays out in a hard-fixed 1920x1080 logical viewport
(window/stretch/mode="viewport"), and the only overflow strategy in the
scene was a CenterContainer, which centres its child rather than clipping
and scrolling. With DevSection visible the content measures 1133px against
1080, so roughly 53px spilled off both ends with no way to reach it — and
main_menu.gd grabs focus on a button that may itself be off-screen.

Worth recording because it is counter-intuitive: this is not
resolution-dependent. Because the viewport is fixed, a 4K display magnifies
the same clipped 1080p frame rather than giving the menu more room, so the
fix has to make the layout scroll, not scale.

Each menu is now MarginContainer > ScrollContainer > CenterContainer >
VBoxContainer. ScrollContainer sizes its child to max(own size, child
minimum), so an expanding CenterContainer keeps today's centred look when
the content is short and grows past the viewport when it is tall — which is
exactly when scrolling should start. follow_focus is on so keyboard and
controller navigation cannot strand focus off-screen. Lobby and matchmaking
share the same shape and get the same treatment before they hit the same
wall; settings gained its wrapper alongside the Controls tab.

Also stops the dev bot dropdowns widening the whole menu: they are filled
from res://bots filenames and expand horizontally, so a long checkpoint
name dragged the layout past its 420px minimum.

test_menu_layout asserts each screen's bottom-most control really sits
inside a ScrollContainer. That is a structural guard against the wrapper
being removed or a new section being added outside it — not proof that
nothing visually clips, which was checked by hand at 1000x600, 1280x720 and
1920x1080.
2026-09-06 20:41:41 +01:00
Josh Creek 076d27a564 feat(input): full controller support, rebindable controls, and rotation fixes
Playing with a gamepad did not work: all six move_* actions had no joypad
event at all, so a pad could yaw/pitch/roll/turbo but could not translate.
Nothing caught it because every action existed and the game booted fine —
no assertion checked that an action is reachable on *both* devices.

Controller layout, on the 6DOF convention (left stick aims, right stick
translates), using all six of the pad's analog axes for the ship's six
degrees of freedom:

  left stick   yaw + pitch        right stick  strafe + vertical
  LB / RB      roll               RT / LT      forward / back
  L3           turbo              R3           ball camera

Input is now read with Input.get_axis instead of is_action_pressed, so
triggers and sticks are proportional. Keyboard values are unchanged.

Three rotation bugs found by measuring a real Ship rather than reading the
code:

- apply_torque() is world-space and the torque was never rotated into the
  hull's frame (unlike thrust, which uses -ship_basis.z). Roll input became
  pitch after a 90 degree turn and inverted at 180, so the controls were
  correct flying up-field and backwards flying back.
- ship.tscn's inertia is Vector3(7, 1, 7) but a flat torque was applied to
  every axis, giving yaw 7x the angular acceleration of pitch and roll
  (172 deg/s vs 52). Torque is now scaled per-axis by inertia, so
  rotation_acceleration means rad/s^2 and all three axes match. Yaw is
  unchanged.
- pitch_down pitched the nose UP: get_axis's arguments were reversed, so
  the I/K keys and the stick each did the opposite of their label.

Menus were unusable on a pad for a separate reason: Godot 4.7 gives
ui_up/down/left/right joypad events by default but leaves ui_accept and
ui_cancel with none (verified against a pristine project), so a controller
could move the highlight and never press anything. A confirms and B goes
back. Gameplay exits on a new leave_gameplay action (Escape / Start) rather
than ui_cancel, so carrying B for menus cannot abandon a live match.

Bindings for both devices are rebindable in Settings -> Controls, persisted
to user://input.cfg — a separate file from settings.cfg because
VideoSettings.save() rewrites that file wholesale and would drop any
section it does not know about. project.godot stays the source of truth for
defaults; overrides are only ever a delta on top of a boot-time snapshot.

Verified: 268 unit tests, the ENet integration gate, and a 16-sample
before/after comparison of networked prediction residuals showing the
physics change does not regress them (median 0.083m -> 0.065m).

Note for follow-up: every policy in Game/bots/ was trained against the old
sluggish, world-axis rotation and will over-rotate until retrained.
2026-09-06 20:41:20 +01:00
Josh Creek 00b900d864 Merge pull request #30 from jcreek/feat/multiplayer
Feat/multiplayer
2026-09-06 10:58:50 +01:00
Josh Creek fe453ab607 docs: add a version inventory to TECH_STACK.md, refresh stale claims
TECH_STACK.md explained why each choice was made but never listed what is
actually pinned, so there was no single place to answer "what version of X do
we use". Adds a Version inventory section covering the shipped game, the Go
control plane's four direct dependencies, the datastore/platform versions and
the exactly-pinned training stack, plus a Verification toolchain subsection for
the Make/Docker/kind/Kustomize/Actions harness.

Also corrects two things the doc had outgrown: the allocation pipeline is now
wired end to end and gated in CI, so only the provider deployment remains; and
the Steam section covered only the GodotSteam client transport, omitting the
server-side Web API ticket verifier in server/steam.
2026-09-05 23:16:10 +01:00
Josh Creek 52cc478b38 docs: record the assertion-first debugging habit, and refresh CLAUDE.md
Adds the lesson this branch paid for repeatedly: the expensive failures
were not broken behaviour but assertions that could not distinguish the
two states they implicitly claimed to, each reporting its own ambiguity
as a confident verdict about the system under test. Waiting on a Fleet
field that does not exist, treating not-yet-started as exited, a p99
that conflated regression with scheduler noise, a validator reading a
response shape Agones never sends, and a build guard that verified stale
code. Five in one session, several costing multiple CI round trips.

Two habits go with it, both of which beat reading code every time they
were tried: make the script report what it saw before theorising about
why, and verify the diagnostics actually fire -- two dumps were added
here and neither ran, one suppressed by a reachability guard and one by
an ERR trap that cannot fire inside functions without errtrace.

Also fixes two stale claims and one gap. Audio is no longer "none at
all"; a procedural AudioManager covers UI, countdown, impact, goal and
engine cues, and only authored assets remain. Five docs/ contracts that
server/security asserts against the manifests were unlisted. And the Go
control plane -- a third of the codebase and the current focus -- had no
structural description at all, so it now gets one: package layout, which
binary is test-only, and the three things easiest to get wrong
(integration tests hidden behind a build tag, start-time config, the
versioned wire contract).

TODO.md's entry now points at its ordered backlog rather than describing
it as deferred non-multiplayer work.

Every factual claim in the new section was checked against the tree.
2026-09-05 23:10:06 +01:00
Josh Creek 4ea72be581 fix(compose): stop calling a still-starting game server dead
Allocated Compose failed on a docs-only commit, so nothing functional
had changed. Its own diagnostics showed why: the game server logged a
clean `server_started` -- the exact string the readiness loop waits for
-- and the script reported "game server exited before becoming ready".

The guard asked whether the service was absent from
`compose ps --status running`, which is also true of a container that
has been created but has not started yet. On a slow runner the first
poll can land in that window, and the script concluded the server was
dead when it was still coming up. Ask whether it actually exited
instead.

Also set errtrace. This failure produced no "failed at line N" report
despite the ERR trap added in 432e5a11, because a bare `trap ... ERR`
does not fire inside functions or subshells without it -- the
instrumentation had a blind spot exactly where a readiness loop lives.

The other --status running check, after an explicit `compose stop`, is
correct and unchanged: stop is synchronous, so absence there really does
mean stopped.

Verified by two consecutive local runs.
2026-09-05 23:00:03 +01:00
Josh Creek 4560d2de8a docs: say what order the outstanding work goes in
The backlog listed what is left but not what to do first, and priority
labels do not answer that: #33 is P2 yet belongs before the P0 cluster,
because standing the cluster up first means migrating a running one
afterwards.

Add an explicit ordering to TODO.md in three parts -- a critical path
where each item unblocks the next, a Steam track that runs in parallel
and should start early because its lead time is Valve's, and the set
that is unblocked today and waiting on nobody. The playtests, training
runs and asset work need no cluster and could start now, which was not
obvious from a flat list sorted by priority.

#31 is called out as the highest-leverage item: two answers unblock the
whole of Phase 8, and the work behind them is an agent's.

Two open issues were in no list at all -- #23's design question and
#32's backfill work -- so TODO.md now covers every open issue.

Also record the dependency direction on GitHub rather than only here:
#17, #32 and #33 carried no blocked-by statement, so the graph was
invisible from the issue tracker. And note in §7 task 8.12 why the
workload namespace enforces privileged and where the split is tracked.
2026-09-05 22:50:26 +01:00
Josh Creek 4912837dd7 docs: narrow what #31 actually needs from a person
The issue read as broadly human-gated. Most of it is not: GHCR accepts
the built-in GITHUB_TOKEN with packages: write for the repository's own
namespace, so publishing needs no account, stored secret or spend
approval, and signing and tagging policy can land as a reviewable
default rather than waiting on a decision.

Two things genuinely block. Every manifest references
ghcr.io/cosmic-clash/*, and no such organisation exists -- the API
returns 404 and it is not among this account's orgs -- so nothing can be
pushed there. And this repository is private, so GHCR packages inherit
that, while no manifest declares imagePullSecrets; public packages work
as written, private ones need pull secrets threaded through every
workload.

Same optimistic-to-pessimistic drift the §7 audit found in fifteen other
entries: work described as blocked on a person when the person only owes
a decision.
2026-09-05 22:45:26 +01:00
Josh Creek aac00f148e fix(tests): stop the ENet residual gate failing on host scheduling noise
The client-bot smoke asserted remote_residual_position_p99 < 0.3. Commit
52ee1810 both passed and failed on that assertion, in runs three seconds
apart on identical code: two clients in one run reported 0.324 and 0.187
with matching snapshot counts, scores and slot checks.

A p99 over a few hundred samples is its worst handful, so on a shared CI
host it measures how often the process was descheduled as much as how
well the interpolator tracks. Locally the same test reports p95 0.025m
and p99 0.081-0.149m; CI's p99 runs 2-4x higher on a healthy build,
which is the entire margin the 0.3 bar had.

Assert two bars instead of one. The tight numeric bar moves to p95,
which is stable run to run, and p99 is bounded by the product's own
REMOTE_VISUAL_MAX_OFFSET/REMOTE_VISUAL_MAX_ROTATION_DEGREES: past those
the visual smoother stops absorbing a correction in a single step, so
exceeding them is a real defect rather than a slow runner. Referencing
the constants also means the test follows the product if that tolerance
is ever retuned.

This is a genuine trade, not a free win: a regression that pushed p99
from 0.2 to 0.35 while leaving p95 healthy now passes where it once
failed. That band is exactly where the noise lives -- 0.324 was observed
on a healthy build -- so the old bar could not tell that regression from
a busy runner either, and paid for the ambiguity with false reds.

Both percentiles are printed with their bars so a future failure shows
which one moved.

Checked first whether goal-driven kickoff teleports were polluting the
metric; they are not. Both the ship and ball paths already skip
accumulation across a reset_gen change.
2026-09-05 22:22:30 +01:00
Josh Creek 52ee181042 fix(kind): validate the allocation response Agones actually returns
With the Fleet readiness wait corrected, the gate reached the allocation
check for the first time and failed with "allocation did not return a
GameServer" -- while the cluster dump shows the allocation plainly
succeeded: one GameServer Allocated, Fleet reporting ALLOCATED 1.

GameServerAllocationStatus is flat: state, gameServerName, address,
ports, nodeName. It does not embed the allocated GameServer. The
validator read status.gameServer.metadata.name and
status.gameServer.status.{address,ports}, a shape Agones never sends,
and its unit tests asserted that same invented shape -- so validator and
tests agreed with each other while both disagreed with Agones. Nothing
caught it because the gate had never once allocated anything.

Read the real fields, keeping every existing check: non-empty name,
address neither blank nor unspecified, exactly one named "game" port in
range.

Also print the response body when validation fails. work_dir is removed
by the EXIT trap, so a shape mismatch was otherwise invisible from CI --
which is how this survived. If the shape is still not what I expect, the
next run says so instead of costing another round trip.
2026-09-05 22:03:41 +01:00
Josh Creek 4f48f0a6a8 fix(kind): wait on the Fleet field that exists
The gate asserted `--for=jsonpath='{.status.ready}'=2`. An Agones Fleet's
status carries replicas, readyReplicas, reservedReplicas and
allocatedReplicas -- there is no `ready` -- so the wait could never match
however healthy the Fleet was.

It failed in the most misleading way available: as "the Fleet never
became ready", which sent three separate investigations after the game
server. Two of those found genuine bugs, but the gate would have stayed
red with both fixed.

The evidence is in the previous CI run's own dump, which the new
per-container diagnostics produced: both GameServers Ready and stable for
5m6s, and the Fleet reporting DESIRED 2 / CURRENT 2 / READY 2, while
kubectl wait timed out beside it. That same dump also confirms the health
fix in 0de97381 worked -- those GameServers had been churning every ~20s
before it.

Assert the corrected jsonpath in test_fleet_manifests.py and reject the
old one, alongside the build-by-default behaviour, so neither silently
regresses.
2026-09-05 21:55:20 +01:00
Josh Creek 0de97381b7 fix(agones): stop a dead health loop from passing as a healthy server
Every allocated GameServer reached Ready and was recycled by Agones ~20s
later. Health pings are the game process's job by design -- the
supervisor has no health implementation at all -- so a server that stops
pinging is exactly what Agones is built to reclaim.

start_health() armed a Timer on a node that might not be inside the
SceneTree. A Timer only ticks inside the tree, so the node reported
itself configured, sent nothing, and said nothing about it. It now
returns a bool, refuses loudly when unconfigured, and defers to _ready()
when called before parenting, so the SDK arms its own timer and no
caller has to get the ordering right. server_boot.gd defers the add like
every sibling does (§9 gotcha 27) and logs when AGONES_SDK_HTTP_PORT is
missing, which previously read identically to a healthy start.

Also bounded the in-flight latch: it is set across an await, so a request
that never completes would silence health permanently. Defence in depth
rather than an observed fault.

Tests target the contract rather than the mechanism: a test that parents
the SDK correctly and asserts pings passes with the bug present, because
the defect was in the wiring. The unit tests assert start_health()
cannot claim success out of tree, and were confirmed to fail against the
previous code. The smoke gains a counting sidecar and asserts a
*repeating* ping -- it reports "health pings in 3.0s = 1, want at least
2" when the loop is broken, which is the production symptom exactly. It
is also now actually run: nothing referenced it before.

Two diagnostic fixes, both of which changed conclusions during this work:

The kind gate only built the game-server image when the tag was absent,
so a local rerun silently verified whatever was built last. That is why
local runs and CI disagreed about the same commit. It now builds by
default, with KIND_REUSE_GAME_SERVER_IMAGE=1 as the opt-in fast path.

The failure dump logged only not-ready pods, and used --all-containers
with a shared tail. A GameServer recycled after reaching Ready leaves no
unready pod behind, and the Agones sidecar out-logs the game server, so
the relevant output was never captured. It now dumps every pod, per
container, current and previous, plus the GameServer and Fleet resources
-- Agones' own state machine is what rejects these.
2026-09-05 21:35:50 +01:00
Josh Creek ca70568fad fix(agones): make the kind gate's Agones lifecycle actually work
Several independent causes, all of which had to be right before the
Fleet could reach Ready.

The supervisor pointed --sdk-base-url at 127.0.0.1:9357, which is the
Agones sidecar's gRPC port; its HTTP surface is 9358, and that is what
AGONES_SDK_HTTP_PORT carries and what agones_sdk.gd reads. An HTTP
client against the gRPC port could never have worked, in kind or in
production.

The supervisor also treated the sidecar's first incomplete /gameserver
response as fatal. The sidecar accepts requests before the controller
populates status.address and status.ports, so this produced a restart
loop precisely during normal Agones startup. It now polls until the
endpoint is assigned or ReadyTimeout elapses.

server_boot.gd started ServerControl and the Agones SDK only under
--allocated-mode, but the kind smoke deliberately strips that flag, so
nothing served the readiness probe and the GameServer could never become
Ready. Lifecycle now keys on AGONES_SDK_HTTP_PORT, which Agones injects
into every managed container, while allocation and roster semantics stay
tied to --allocated-mode. The SDK node is added to the tree
non-deferred, since start_health() creates a Timer immediately.

Fleet: Agones assigns its own SDK service account and masks that token
from the game container while keeping it for the injected sidecar, so
the manifest must not pin serviceAccountName or
automountServiceAccountToken. Godot stores user:// under HOME, so HOME
points at the writable runtime volume to keep the root filesystem
read-only, and fsGroup makes that volume writable for the non-root user.

Namespace: Agones' Dynamic port policy injects a hostPort, which both
the baseline and restricted Pod Security Standards forbid, so the
workload namespace enforces privileged while continuing to audit and
warn against restricted.

NetworkPolicy: the injected sidecar reaches the Kubernetes API over
HTTPS, and NetworkPolicy applies to the whole Pod rather than to the
container whose token was masked.

The kind runner creates the namespace before Helm so Agones can install
its per-namespace SDK RBAC, scopes gameservers.namespaces to it, forces
the allocator and ping Services to ClusterIP because LoadBalancer
ingress never becomes ready in plain kind, and labels the node so the
production Fleet's on-demand/zone constraints are exercised rather than
edited out of the rendered manifest.
2026-09-05 20:50:01 +01:00
Josh Creek 8aa4af3a3a test(kind): always dump on failure, and record the second Agones failure
The dump added in 9ab1bec8 never ran. A `kubectl cluster-info`
reachability guard suppressed it, so its first exercise produced exactly
the silence it was written to prevent. Every command inside is already
`|| true`, so the guard bought nothing and cost the whole dump; removed.

That run did establish something the CI logs cannot: after clearing
local Docker pressure, the Agones install completes cleanly (controller
and allocator both reach "condition met") and the gate instead fails
later, waiting for the Fleet's game-server pods to become Ready. CI
never reaches that point because the Agones install times out first.

So there are likely two failures stacked, and fixing the CI timeout will
probably expose the Fleet one. Recorded in AGONES-CI-INVESTIGATION.md
along with the reasons the Fleet failure warrants suspicion -- fleet.yaml
changed its join-signing key mount from raw bytes to a JSON map this
branch -- and the reasons it may be unrelated.
2026-09-05 20:03:16 +01:00
Josh Creek 9ab1bec89a test(kind): dump cluster state before the Agones gate deletes its cluster
This gate fails with nothing but Helm's "context deadline exceeded" and
three Deployments reporting Available: 0/1, then the EXIT trap deletes
the cluster -- so there is no way to learn why the pods never became
ready. Both CI runs and a local run are equally uninformative.

Dump node capacity and conditions, pods and recent events for
agones-system and cosmic-clash, and describe plus current/previous logs
for every not-ready pod, on any failure and before deletion. Events
matter as much as pod status here: FailedScheduling, ImagePullBackOff
and probe failures are all invisible in a status column.

KIND_KEEP_ON_FAILURE=1 retains the cluster for interactive inspection.

Same approach that just found the allocated-Compose cause, where a
silent assertion had hidden a real 422-instead-of-409 API bug across
several CI runs.
2026-09-05 19:57:05 +01:00
Josh Creek 14da286e11 fix(store): return 409 for idempotency key reuse, not 422
Both idempotency paths returned a bare fmt.Errorf, and writeDomainError
maps anything it does not recognise to its 422 "invalid_request"
default. So reusing a key with a different payload answered 422 where
openapi.json declares 409 and state-transitions.json requires
"reject_conflict_without_state_change".

That is the difference between "your request was malformed" and "that
key is taken". A client acting on 422 would rewrite a request that was
never wrong, and the 409 branch of every generated client was
unreachable.

Wrap domain.ErrConflict on both the create and mutate paths, and add an
integration test covering identical replay and conflicting reuse.

Pre-existing: both bare errors are unchanged from 089c127c, which is why
verify-allocated-compose failed in CI before this branch's work as well.
Found only after adding the diagnostics in 432e5a11 and fc2f5c86 --
until then the assertion aborted silently and three CI runs reported
nothing but "make: *** Error 1".
2026-09-05 19:30:41 +01:00
Josh Creek fc2f5c8669 test(compose): report the actual status when the idempotency conflict check fails
The ERR trap added in 432e5a11 located the CI failure at the
`[[ "$conflict_status" == 409 ]]` assertion, but the request discarded
its body and the assertion printed nothing, so three failing runs never
revealed what the API actually returned.

Print the status and body on mismatch.
2026-09-05 19:27:35 +01:00
Josh Creek 432e5a11e8 test(compose): make the allocated smoke explain its own CI failures
This suite fails on GitHub Actions while passing locally, and it failed
the same way at 089c127c -- the branch head before any of this branch's
recent work -- so it is pre-existing rather than newly broken.

Diagnosing it is currently impossible from CI alone. The script is
mostly `curl -fsS` and bare [[ ]] assertions under `set -e`, all of
which abort with no output, so the run log contains nothing but
"make: *** Error 1". Both failing runs are equally silent.

Add an ERR trap that reports the script line and the failing command,
and dump `compose ps` plus the service logs on any non-zero exit rather
than only when COMPOSE_KEEP_ON_FAILURE is set. The next CI run should
therefore say what actually broke instead of needing another round trip
to find out.

No behaviour change on success; the target still passes locally.
2026-09-05 19:19:45 +01:00
Josh Creek 707aea5898 docs: mark the 8.48 Compose fixture item done
compose.allocated-smoke.yml and verify_allocated_compose.sh are already
independent of compose.phase6-smoke.yml -- the script says 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.

Fifteenth stale backlog entry found this session, and the first in
TODO.md rather than multiplayer-next.md §7. It was also the only
remaining item marked agent-actionable.
2026-09-05 18:21:51 +01:00
Josh Creek 8ba045063d fix(compose): give the allocated smoke's allocator its signing key
Making cmd/allocator refuse to start without join-signing material was
right -- an allocator that binds allocations it can never publish rosters
for strands every match silently -- but I updated the Kubernetes
manifests and the kind fixture without updating the Compose one. The
allocator container exited at startup, so no allocation was ever bound
and verify-allocated-compose failed with "allocator did not bind a
provider allocation".

Mount the same join-signing-keys.json fixture the game server already
uses and name the key it was written with. Caught by running the target
locally rather than by CI after a push.
2026-09-05 18:19:56 +01:00
Josh Creek 654f20e28f docs: record the backfill roster-delivery decision and remaining work
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, so backfill cannot
work end to end regardless of how good the selection rule is.

Decided: the control plane marks the roster changed, the supervisor
re-fetches and rewrites the roster file, then signals the game process to
reload. Chosen because it reuses the authenticated channel and roster
endpoint that already exist -- no inbound path into the game pod, no new
trust boundary -- and keeps the roster an allowlist the server was told
to expect rather than admitting anyone holding a valid signature.
Signature verification is untouched and already binds match, server, slot
and generation.

Recorded in docs/MATCHMAKING.md, which the repo treats as the design
source of truth, so the decision is not re-litigated from a task row.
Remaining implementation is tracked in #32 and summarised in §7 8.19.
2026-09-05 18:09:10 +01:00
Josh Creek 1becfb4f3f feat(domain): add casual backfill candidate selection
First slice of task 8.19. docs/MATCHMAKING.md specifies the choice
precisely -- "the oldest ordinary casual ticket that meets the same
build, region <=100 ms and current anchor-tolerance rules for the
vacated human slot; ties use ticket ID" -- and that rule is needed
whatever is decided about delivering a late authorisation to a running
server, so it is worth landing on its own.

Kept a pure function over an already-fetched candidate set: the choice
is then reproducible and testable without a database, and claiming the
ticket stays a durable transaction as it is for ordinary proposals. Ties
break on ticket ID rather than scan order, so two replicas evaluating
the same queue cannot offer one slot to different players.

Region matching is stricter than ordinary formation: the server already
exists in one region, so a candidate must have RTT evidence for that
region specifically, not merely share some region with the others.

Eligibility is re-checked here as well as at the durable boundary, so an
ineligible mid-play or human-occupied slot never reaches selection at
all. Ranked is refused outright.

Corrected a comment I had written claiming the backfill window is
shorter than an ordinary proposal's; both are 10 seconds. What makes a
backfill offer separate is its payload and the absent decline penalty,
not its timing.

This does not yet make backfill work end to end -- see the roster
delivery question raised alongside this commit.
2026-09-05 17:32:49 +01:00
Josh Creek 61a073099d fix(store): widen the serializable retry budget, stop leaking test volumes
Two things that made the integration gate untrustworthy.

The retry budget was too small for expected contention.
TestPostgreSQLConcurrentIdenticalResultSubmission fires five identical
concurrent submissions and requires all five to succeed; it failed 4 runs
in 20. The error was retryable and retries did fire -- three attempts
simply was not enough. Contention here is normal rather than
exceptional: several game servers can submit results, and several
matchers can claim candidates, against the same rows at once. Raised to
five attempts, which is 0 failures in 40 runs.

Also jittered the backoff, but measured rather than assumed: my first
theory was a thundering herd, since the delay was exactly
RetryBackoff*(attempt+1) and every loser of a race woke at the same
instant. Isolating the two changes showed jitter alone moved 4/20 to
3/20, while the budget alone reached 0/20. The budget was the real
constraint. Jitter is kept because it costs nothing and its benefit
grows with the number of contending writers -- production is not capped
at five -- but the comment now says plainly that it is the smaller half,
so nobody inherits my wrong explanation.

Second, the integration scripts leaked one throwaway database volume per
run. --rm does reclaim anonymous volumes on a normal exit, but these
scripts force-remove the container from a trap, and `docker rm -f`
without -v keeps the volume. Sixty-four accumulated during this branch
until PostgreSQL stopped starting, surfacing only as the scripts' own
readiness timeout rather than as a disk error -- which is what the
"Docker storage exhausted locally" notes were really describing.
Measured at one volume per run before, zero after, across all five
scripts.
2026-09-05 17:14:43 +01:00
Josh Creek a1f30f6af9 test(store): prove the audited claims instead of asserting them
The audit established which rows understated what was built by locating
implementations and their call sites. That proves code exists, not that
it works, so each corrected claim is now tied to an executable test.

Seven of the nine were already covered and just needed naming: the
allocated ServerConfig fields, signed-authorisation admission, endpoint
wiring, casual lineup being reached through formation, three of the four
penalty kinds, signed roster metadata, the season countdown, and the
matcher deployments.

Two had no proof at all:

- INITIAL_CONNECT_NO_SHOW was the one penalty kind with no integration
  coverage, so "all four penalty kinds are written durably" rested
  entirely on reading the code.
- Cross-replica revocation is a behavioural property. An in-memory cache
  in front of the session read would break it while leaving every call
  site looking correct, so no amount of reading establishes it.

Writing the first one found my own error rather than a defect: casual
deliberately waits past InitialConnectWindow to CasualBotStartAfter
before deciding a no-show, giving a slow-loading player longer than the
ranked deadline. Reconciling at the earlier window only yields WAIT.

Both new tests were mutation-checked -- removing the penalty insert and
removing the revoked_at check each make them fail -- so they assert
something real rather than passing incidentally.
2026-09-05 17:07:05 +01:00
Josh Creek 8033d52db3 docs: audit every Phase 7/8 task row against the code
Three tasks in a row this branch turned out to be partly built already:
8.20's allocation wiring was complete end to end, 8.22's client UI was
built, and 7.4/8.7 listed durable ban storage and the Steam adapter as
outstanding after both had landed. That is a systematic problem, not
three coincidences, so this checks all 56 rows rather than fixing them
one at a time as they are picked up.

Nine more rows understated what exists:

- 8.6  every allocated-mode ServerConfig field is present, signed
       authorisation admission is in MatchNet, endpoint wiring is in
       AssignmentState
- 8.8  distributed revocation needs no cross-replica protocol: sessions
       are durable and read on every authenticated request
- 8.19 casual lineup formation is built and wired, and all four penalty
       kinds are written durably; only the backfill proposal path is
       genuinely missing
- 8.30 signed roster metadata landed with 8.31
- 8.42 the season countdown is implemented
- 8.16/8.43 the matcher is deployed; what remains is soak, not integration
- 8.13/8.52 cross-referenced to #31 rather than described loosely

The drift runs one way -- rows keep listing work that has since landed --
which inflates the apparent backlog and invites rebuilding what exists.
8.19 is the clearest case: it reads as four missing pieces and is one.

The §7 preamble claimed every row lists only what is still open. It
doesn't, so it now says so and points at the audit note, with the
standing instruction to verify a row against the code before planning
against it.
2026-09-05 17:01:00 +01:00
Josh Creek 2702e53068 feat(ranked): make tier thresholds durable instead of compiled in
Task 8.22. Tier bands lived in domain.DefaultTierPolicy(), compiled into
every API binary, so retuning one meant building and rolling a new image
-- least attractive exactly when it is most needed, as the rating
distribution settles after launch.

Bands now live in a tier_bands table, seeded by the migration with the
exact policy the binaries hardcode, so this changes durable state without
changing behaviour. Retuning is a rolling restart rather than a rebuild.

Three properties the loader deliberately holds:

- A malformed durable policy stops startup. Falling back on error would
  silently mis-tier every player, which is worse than not starting.
- An empty table is supported and falls back to the compiled default, so
  an operator can truncate back to known-good without a deploy, and a
  fresh database works before the seed is reviewed.
- PROVISIONAL is rejected as a band. It is derived from ranked game
  count, not rating, so a band claiming it would be unreachable at best
  and would shadow a real tier at worst.

Bands stay backend-owned; clients still receive only the resulting label,
per docs/MATCHMAKING.md. UNIQUE(min_rating) rejects two bands sharing a
threshold, catching an ambiguous policy before NewTierPolicy does.

testkit-api loads it too, so the control-plane integration scripts
exercise the durable path rather than the compiled default.

Integration tests cover the seeded policy matching the compiled one,
retuning taking effect from the database alone, truncation falling back,
and each invalid-policy shape being rejected. Verified they fail against
a loader that ignores durable bands.

The other two parts of 8.22 needed no work: the client UI already renders
tier, provisional status, ranked games and the season countdown, and
reconnect transport is 8.42's, dependent on live backend events.
2026-09-05 15:38:24 +01:00
Josh Creek 8b9ae35b43 test(domain): guard the ranked arena list against Godot registry drift
Task 8.20. `arena_registry.gd` is the documented single source of truth
for arenas, but `domain/ranked.go` keeps a hand-maintained mirror of its
floor-goal entries and nothing 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 to random:true once a checkpoint
trained on that geometry is promoted, which ranked would then keep
excluding indefinitely. A rename or removal is worse: the allocator would
hand out a scene path that no longer exists, and the ranked server fails
to load its arena at match start -- after allocation, so it burns a real
match and a real server.

Keeping the two copies is deliberate rather than a wart: ranked arena
selection is server-authoritative and happens before any Godot process
exists. So this guards the relationship instead of removing it, the same
way the golden join-authorisation token guards the signing format. It
parses the registry and fails if the sets disagree either way, if
rotation order diverges from declaration order, or if a ranked path has
no scene behind it. The parser asserts it found both eligible and
ineligible entries, so a format change cannot make everything pass
vacuously. Verified against four drift scenarios.

The allocation-wiring half of 8.20 turned out to be already complete end
to end, with coverage at each hop; recorded in the task row rather than
rebuilt.

Add a Server Unit Tests workflow, because none of this would otherwise
run: the only Go tests CI executed were multiplayer-load's two load
tests, so ~24k lines of control plane gated nothing. Docker-free so it
can gate every push, and it vets the integration-tagged files too, since
those are excluded from the default build and could otherwise rot
uncompiled.

CLAUDE.md's CI section claimed two workflows and no unit-test job; there
were seven and now eight.
2026-09-05 15:05:49 +01:00
Josh Creek a4b362cb01 fix(deploy): supply Steam credentials to the control plane, refresh stale status
The Steam adapter took --steam-publisher-key/--steam-app-id and the
matching env vars, but no manifest supplied them, so a deployed control
plane would have kept sign-in returning 503 even once the App ID from
#15 arrived -- that issue would have unblocked nothing on landing.

Mount them from a new cosmic-clash-steam Secret, into the control-plane
Deployment alone: the publisher key is issued to us, never to a client,
and no other workload (least of all a game server) has any use for it. A
manifest test asserts both the wiring and that the Secret appears in no
other manifest; verified it fails in both directions.

Both keys are optional, so the Deployment still rolls out before the App
ID exists and sign-in simply stays 503.

Also correct task rows this branch made stale: 7.4 (durable ban storage
landed), 8.7 (adapter, bans and secret store landed), 8.39 (cross-replica
fan-out landed), and 8.5's migration range, which stopped at 0013.

Move the branch review into docs/ with a header marking it a point-in-time
artefact -- all thirteen findings are addressed, and its present tense
would otherwise read as current behaviour.

Record gotcha 52: the integration scripts use `docker run --rm`, which
reclaims the container but not its anonymous volume. Sixty-four of them,
~4 GB, accumulated during this session until PostgreSQL stopped starting
-- surfacing only as the script's own readiness timeout, not as a disk
error. That is the real cause behind the "Docker storage exhausted
locally" notes those rows carried.
2026-09-05 12:38:15 +01:00
Josh Creek 0a8f3924d0 docs: cross-reference the new image-publishing issue in TODO.md
Issue #31 covers the gap that no workflow builds or pushes the images
deploy/k8s/base references, and that every digest there is still an
all-zero placeholder which the supply-chain gate accepts because it runs
without --require-concrete. It blocks #17.
2026-09-05 12:18:32 +01:00
Josh Creek ccf7d0fbfe docs: record the closed root blocker and the new probe/rotation contract
docs/MATCHMAKING.md is the stated source of truth for this design, so it
changes first: the probe challenge endpoint and why probing gates
matching rather than merely improving it, and the key-ID rotation
procedure that makes overlapping-key rotation concrete.

multiplayer-next.md §0's root blocker is closed rather than deleted --
what it was, why it blocked everything, and how it was resolved, since
the reasoning is what a future reader needs. Tasks 7.6, 8.15 and 8.31
updated to what actually remains, which in every case is now external
rather than unbuilt.

TODO.md #14 asked for a join-signing design decision; that decision is
recorded with its rationale. CLAUDE.md no longer says a real deployment
cannot complete a match end to end.
2026-09-05 11:00:50 +01:00
Josh Creek f628ccfd35 feat(auth): wire production Steam sign-in and the client login flow
newAPIService never supplied SteamLogin, so POST /v1/session/steam
always returned 503 auth_unavailable in production. The only
implementation was cmd/testkit-api's fake, which derives an identity
from the ticket string itself and accepts anything -- so the passing
integration path was neither deployable nor secure. On the client side
the game started with an empty token and a loopback base URL, and no
production code called configure() or login_steam(); the menu entered
matchmaking directly, so every request failed ERR_UNAUTHORIZED before
reaching the network.

Add a real ISteamUserAuth/AuthenticateUserTicket adapter behind an
interface, so the production login path is testable with only the Valve
call stubbed. It rejects family-shared copies (the account playing does
not own the app) and, by default, VAC- or publisher-banned accounts, and
refuses malformed tickets locally rather than forwarding them.

Crucially it separates our faults from the player's: a Valve outage or a
revoked publisher key returns 503, not 401. Answering 401 would tell a
legitimate player their login failed and send them to fix an account
that is fine while the real fault went unnoticed. A banned identity now
returns 403 rather than a misleading 503.

Sign-in is configuration-gated on the publisher key and App ID: without
them the endpoint keeps returning 503, since silently accepting an
unverified ticket would be worse than refusing to authenticate. A
returning player keeps the player ID they already had, so ratings,
penalties and bans follow the account rather than the session.

Client side: acquire a web-API ticket through GodotSteam's async
signal -- requesting one returns a handle, not a ticket -- using the
existing dynamic-call pattern so stock Godot still parses the project.
The endpoint is configurable for release builds, and matchmaking
completes sign-in before it will queue.

Verified against real PostgreSQL; 232 Godot tests pass.
2026-09-05 10:57:50 +01:00
Josh Creek d40344a2c0 fix(deploy): ship runnable control-plane and matcher workloads
The Kubernetes base deployed a control-plane image the Dockerfile never
built -- cmd/control-plane was absent from the Go build stage and no
target existed -- while the Dockerfile built a matcher image no manifest
ever deployed. Applying the checked-in base therefore could not produce
the advertised topology: one required workload had no repository-defined
artifact, and nothing consumed queued tickets. Tickets could be created
but never became proposals.

Add the production control-plane build and image target, explicitly not
the testkit-api target, which injects a fake login accepting any ticket.
Add casual and ranked matcher Deployments as separate workloads: they
have different match sizes, and separating them keeps a ranked backlog
from delaying casual formation. One replica each -- CreateProposal's
SKIP LOCKED fences make more replicas safe, but they would halve the
candidate pool each worker sees per poll and worsen formation for no
throughput gain at this scale. Their PDB uses maxUnavailable, since
minAvailable against a single replica blocks node drains outright.

Also fix both blocked traffic directions. No ingress policy admitted
UDP/7777 to game-server pods, so an allocated server was unreachable
from the internet under the namespace-wide default deny. And
control-plane ingress admitted only edge-gateway pods, so roster fetch,
registration, connection receipts, shutdown and result submission from
game servers were dropped even inside the cluster, despite their egress
being permitted. The default deny stays.

Manifest tests now assert every required role is deployed, both
playlists are scheduled, every referenced image maps to a real
Dockerfile target, and both traffic directions are permitted. Each was
verified to fail against the defect it covers. The control-plane image
was built and run to confirm the target works.
2026-09-05 10:52:52 +01:00
Josh Creek 801fca7cb0 fix(matchmaking): make regional RTT evidence obtainable end to end
domain.validCandidate hard-requires a non-empty PredictedRTT map, but
CreateQueueTicket persisted an empty one and the only endpoint that
could fill it returned 503 in every real binary, because Service.Probe
was assigned nowhere outside api tests. No client-created ticket could
ever be selected by the matcher. The Godot client had no probe method at
all, so even a wired backend was unreachable from the game.

Four distinct defects had to be fixed for this path to work:

Nothing issued the nonce ProbeProvider was meant to compare against, so
the contract could not be satisfied even in principle. Add
POST /v1/probes/{region}/challenge, backed by a durable single-use
challenge -- durable because any replica may serve the answer for a
challenge another replica issued. RTT is the interval between issuing
and receiving, so no client-reported latency reaches placement.

CreateQueueTicket marshalled a nil map to JSON `null`, a JSONB scalar
rather than an object, and jsonb_set rejects that with "cannot set path
in scalar". RecordProbe would have failed at runtime even once wired.
Persist an object, and normalise non-object values in the update for
rows already written.

A nil ProbeRecorder made the handler report success while persisting
nothing, which silently leaves the ticket unmatchable. That is a
misconfiguration, not a successful probe; it now returns 503.

A successful probe updated PostgreSQL only. The candidate inserted at
enqueue time carries an empty RTT map, and the Redis keyspace has its
TTL continually refreshed, so the stale entry need never repair itself.
Refresh that player's projection after the probe commits.

Client side: add the challenge/answer round trip and have the
matchmaking screen collect evidence before creating a ticket, since
queueing first produces a search that can never match. Probing every
region fully is not required -- placement uses whichever regions
answered -- but queueing with none is refused rather than silently
stalling.

New integration test drives the real enqueue and probe paths and then
asks the actual matcher predicate, rather than hand-building a candidate
the way the unit tests do -- which is exactly why they missed this.

Also make the integration schema reset drop the whole public schema: the
enumerated table list silently broke with each new migration.
2026-09-05 10:49:28 +01:00
Josh Creek 5765532409 fix(allocator): publish signed assignment rosters before servers start
The root blocker (issue #14). The worker bound the provider allocation
and stopped. Service.PublishRoster and store.SaveVerifiedAssignmentRoster
both existed, fully tested, with zero non-test callers, and the
production allocator configured neither a roster store nor a signing
key. Nothing ever wrote the assignments table.

The allocated supervisor fetches a non-empty roster before it launches
the game child, so every real allocation failed at that fetch: no match
could reach ASSIGNMENT_READY or accept a player. Existing tests seeded
assignments directly, which is exactly why the missing hand-off went
unnoticed.

The worker now builds one join authorisation per durable participant,
signs each with the active key, and publishes them. Participants are
read through the same query SaveVerifiedAssignmentRoster re-validates
against, so the allocator cannot construct a roster the persistence
boundary would reject. The manifest commits to a digest over the whole
roster, so a server cannot be handed a truncated roster whose surviving
entries are each individually valid.

Persist the provider endpoint on the allocation: it arrived on the
provider response and was never stored, so a worker crashing between
allocating and publishing had no endpoint to recover and would have
stranded the match permanently. Republishing is idempotent, so that
crash now simply retries.

cmd/allocator refuses to start without key material rather than running
an allocator that binds allocations and silently strands every match.
The k8s allocator Deployment mounts the same key set the Fleet does, and
both now take the JSON key map so a rotation can publish several.

New integration test drives the real worker through to the supervisor's
own roster read path without seeding the assignments table. Verified it
fails with "assignments = 0, want 2" when the publish step is removed.
2026-09-05 10:42:31 +01:00
Josh Creek b8bcc1f3c1 feat(join-auth): add key-ID rotation to signed join authorisations
Prerequisite for wiring the allocator to publish rosters. The signing
key is a shared HMAC secret mounted into both the allocator and the
allocated game server; without a key ID, rotating it would invalidate
every authorisation already issued for an in-flight match, because a
server holding only the new key cannot verify a token signed with the
old one.

Add KeyID to JoinAuthorisation and append it to the canonical claim
bytes, so it is covered by the signature and cannot be repointed at a
different key than the one that actually signed. Allocated servers now
hold a set of currently-valid keys and select by ID: a rotation
publishes the new key alongside the old, and the old is dropped once no
live match can still reference it.

The key file becomes a JSON map of key ID to base64 key. A file of raw
key bytes is still accepted as a single key under the empty ID, which is
what an unrotated deployment and the kind fixture use.

Game/scripts/match_net.gd builds the canonical bytes independently, so
it changes in lockstep; the cross-language golden token in
test_match_net.gd is regenerated from the Go implementation and now
carries a key ID. Added tests cover accepting either key mid-rotation,
rejecting a retired key ID, and rejecting a token whose key ID was
swapped to name a key the server does hold.

Go suite and 223 Godot tests pass.
2026-09-05 10:36:06 +01:00
Josh Creek 5453e19761 feat(server): add retention for idempotency, outbox and session records
Each ten-second queue heartbeat mints a fresh idempotency key and
permanently inserts a row. Published outbox rows and expired/revoked
sessions were never purged either -- the maintenance role performed
lifecycle reconciliation only. At 10,000 queued players heartbeats alone
add roughly 60,000 durable rows per minute, so table and index growth,
vacuum pressure, backup size and recovery time were all unbounded on a
service intended to scale horizontally.

Add retention windows chosen to exceed every retry and recovery horizon
that could still consult the row -- deleting an idempotency key early
would turn a client replay into a second real mutation, so this is a
correctness bound, not just a housekeeping one. Dead-lettered outbox
rows are kept longest, being the record of events never delivered.

Deletes run in bounded SKIP LOCKED batches so a purge never blocks live
traffic, never holds a long transaction, and concurrent maintenance
replicas do not contend. Indexes back each predicate so a pass cannot
degrade into a sequential scan of the table it is bounding. The
maintenance role reports rows purged, the backlog past its window
(deletion lag), and any dead-lettered events.

Also make the migration-rollback test derive its step counts instead of
hardcoding them: adding a migration silently shifted the fixed counts so
the failure surfaced as an unrelated "0006 rollback did not drop
matches.allocation_id".
2026-09-05 10:32:08 +01:00
Josh Creek 129b0c7ef0 fix(server): fan outbox events out to every control-plane replica
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 succeeded even when the
winning replica held no matching local subscriber -- that replica then
set the single global published_at. A client connected to the other
replica never received the event, and delivery degraded further with
each replica added. REST recovery eventually converged, but short-lived
proposal transitions could be observed late or not at all.

Publish committed events through PostgreSQL LISTEN/NOTIFY so the replica
that owns the subscriber's connection delivers it, regardless of which
replica drained the row. The listener holds its own pgx connection --
LISTEN is session state, so a pooled database/sql connection cannot
carry it -- and reconnects with backoff, since losing it would silently
downgrade that replica's subscribers to REST-only recovery.

The fan-out is optional: without EventFanout configured, behaviour is
unchanged local-hub publication, which stays correct for a single
replica and for tests. Only outbox-sourced events are routed through it;
the in-request-path publishes remain local, as those are a latency
optimisation for the caller's own connection.

Fan-out needs a wire shape of its own because ControlPlaneEvent hides
PlayerID from clients, and the recipient is exactly what a peer replica
needs to route on.
2026-09-05 10:29:23 +01:00
Josh Creek 4248e51c60 fix(server): enforce durable identity bans on session issuance and auth
banned_until and ban_reason have been in the schema since 0001, but no
production query ever read them -- grepping the tree found no reference
outside the migration itself. The only ban check was an in-memory map on
domain.TicketVerifier used by domain tests. Once real Steam login is
wired, a banned identity would keep full access through every existing
session until expiry and could obtain new ones.

Make the ban part of the durable authentication transaction rather than
a policy each login adapter must remember to re-implement:

- Session issuance inserts only when the identity exists and has no
  active ban, so a banned player cannot mint a session.
- Authentication joins the identity and rejects an active ban on every
  request, so a ban takes effect immediately on every replica rather
  than at session expiry.
- ApplyIdentityBan sets the ban and revokes that identity's sessions in
  one serializable transaction, closing the window where the ban is
  durable but another replica still accepts an issued session.

Bans are time-bounded and clearing one does not resurrect sessions the
ban revoked.

Tests cover enforcement across two independently constructed stores
standing in for two replicas, expiry/unban semantics, and -- separately,
because revocation would otherwise mask it -- that a ban applied without
revoking anything still blocks the next request.
2026-09-05 10:26:25 +01:00
Josh Creek 320ec46ba2 fix(server): partition and bound the Redis candidate projection
Both playlists shared one hash and sorted set, causing two independent
failures.

Starvation: Snapshot performed an unbounded ZRANGEBYSCORE and HMGET,
decoded the whole queue, and the matcher then truncated to its candidate
limit *before* filtering by playlist. A large casual prefix could
therefore leave the ranked worker with zero candidates indefinitely even
while ranked tickets were queued further down the set.

Mutual erasure: each matcher captured only its own playlist as the
durable source, but Rebuild replaced the shared keys, so a casual repair
wiped ranked projections and vice versa.

Namespace the keys per playlist, push the limit into Redis (LIMIT 0 N)
so reads no longer scale with total queue depth, and scope Rebuild to
one namespace. Rebuild now rejects a candidate whose playlist does not
match the namespace, which would reintroduce the starvation. Upsert
derives the namespace from the candidate; Remove takes the playlist,
since a ticket ID alone no longer identifies its namespace.

Add tests for a 300-deep casual backlog not starving ranked, for neither
playlist's rebuild erasing the other, and for the limit being applied
without losing enqueue ordering.
2026-09-05 10:23:52 +01:00
Josh Creek f6a87463c5 fix(server): load authoritative ratings into ranked candidates
The candidate projection selected only from queue_tickets and its scan
never set Candidate.Rating, so every PostgreSQL-sourced ranked candidate
arrived with Go's zero value. Rating tolerance, selection scoring and
team partitioning all read that field, so ranked matchmaking treated a
900-rated player as identical to a 2100-rated one. Unit tests missed it
because they construct candidates with ratings already populated.

Join the ratings table, defaulting to domain.GlickoInitialRating for a
player with no ratings row yet -- a genuinely new profile, matching the
column default.

Fix the same defect on the Redis path too, which is reached differently:
the projection is seeded from the candidate CreateQueueTicket builds,
not from the candidate query, and that candidate also left Rating unset.
Resolve the rating inside the enqueue transaction so both projections
agree on one authoritative value. The rating is never client-supplied.

Add a store-backed test with deliberately distant ratings (900 vs 2100)
plus an unrated player, asserting both projections and that the spread
survives. Verified it fails without the fix.
2026-09-05 10:19:23 +01:00
Josh Creek 1dd05c75f1 fix(server): repair the initial-connect outbox envelope and unblock dispatch
ApplyInitialConnectPlan wrote a payload of {match_id,state,action},
omitting event, revision, resource_id, occurred_at and player_ids --
every field deliverStateOutboxEvent requires. Delivery rejected the row,
dispatch returned on the first error so it was never acknowledged, and
because reads are ordered oldest-first it was retried ahead of every
later state_changed event on every 100ms poll. One initial-connect
transition therefore blocked lifecycle delivery for all matches, not
just its own.

Two independent fixes, since either alone leaves the system fragile:

Build envelopes through one validating helper (MarshalOutboxEnvelope)
and convert all five writers to it. A writer that omits a required
field now fails its own transaction instead of committing a row that
can only ever poison the queue. The helper takes revision as int64 so
the -1 "nothing matched" sentinel some CTEs return surfaces as an error
rather than wrapping to a huge uint64.

Make dispatch resilient regardless: a delivery failure is now counted
against that row and the batch continues, with the row dead-lettered
after MaxOutboxDeliveryAttempts so a poison event degrades to one lost
notification instead of a stalled queue. Ordering within an aggregate
is still honoured -- later events of a failed match are deferred, so no
client observes that match's newer state before its older state. An ack
failure still stops the batch, being a database rather than a payload
problem.

Initial-connect events now address every participant, not just the
connected ones: a no-show needs to learn their ticket was failed and a
penalty applied.
2026-09-05 10:17:16 +01:00
Josh Creek 2c648514ba test(server): fix and wire up the two unrun Python suites
test_contracts.py required operation ID `recordPlayerConnected`, but
openapi.json names that endpoint `claimPlayerConnection` — the accurate
name, since POST /servers/{id}/connect claims a connection lease and
returns a generation. Align the test on the document and assert the set
difference, so a future mismatch names the missing operation instead of
reporting "False is not true".

test_observability_manifests.py copied only two of the four files the
checker reads, so it died on a missing kustomization.yaml before ever
reaching the mutated namespace. Copy the full fixture, split the
namespace and scrape-path mutations into separate cases so either
defect produces its own diagnostic, and add an unmutated-copy case so a
broken fixture can't make the mutation cases pass vacuously.

Neither suite was invoked by any Make target or workflow, which is why
both could sit red. Add them, plus test_threat_model.py, to
verify_multiplayer_local.sh.
2026-09-05 10:10:11 +01:00
CosmicClash Training Bot 6983ddd7df chore(training): generation 5 progress after 20260903-1146-gen5-s6-league-retry3 2026-09-05 01:01:25 +01:00
CosmicClash Training Bot 7d247bc516 chore(training): Add 20260903-1146-gen5-s6-league-retry3 checkpoints, logs, and exported policy 2026-09-05 00:55:28 +01:00
Josh Creek 089c127cc3 docs: cross-reference the human-actionable backlog to GitHub issues
File the outstanding work that needs a person — hardware, an external
account, a playtest, a design decision — as GitHub issues (#14-#29),
labelled needs:human plus a P0-blocker..P3-low priority, an area and a
phase. Link each one inline from the doc bullet it came from.

The split: issues carry status for human-gated work, these documents
keep the detail, and the numbered agent-actionable tasks in
multiplayer-next.md §7 deliberately get no issues. Mark the 8.48 Compose
fixture bullet as agent-actionable so its lack of an issue does not read
as an omission.
2026-09-04 22:56:46 +01:00
Josh Creek b43ad207c1 docs(multiplayer): split spec into MULTIPLAYER_SPEC.md, trim task doc to outstanding work
multiplayer-next.md was a 1662-line mix of standing architecture spec
and task-completion tracking, most of which was dense per-task DONE
evidence for finished Phases 0-6. Split it:

- MULTIPLAYER_SPEC.md (new): the locked architecture decisions, wire
  format, server-side input handling, prediction/reconciliation,
  latency/frame-rate budget, and match lifecycle state machine -
  standing design reference, not task-tracked.
- multiplayer-next.md (trimmed 1662 -> ~370 lines): only outstanding
  work remains - §0 status, §7 Phase 7/8 task tables condensed to
  "what's left" per task, §8-11 reference material (refactoring notes,
  gotchas, testing, flagged items). Phases 0-6 collapsed to a pointer
  at git history instead of ~500 lines of DONE evidence.

Also:
- Repointed every `multiplayer-next.md §N` code comment (N 1-6) across
  Game/scripts, Game/tools and Game/tests to MULTIPLAYER_SPEC.md, since
  those sections moved. Task-number references (`task N.N`, §7-11)
  correctly still point at multiplayer-next.md.
- Updated CLAUDE.md's doc index and docs/TECH_STACK.md's spec-section
  citations to match.
- TODO.md: added a "what's left to actually finish multiplayer
  (human-actionable)" checklist pulled from multiplayer-next.md §0 and
  docs/MATCHMAKING.md - things that need a person (hardware, a design
  decision, a Steam App ID, hands on a controller), not more agent code.
2026-09-04 22:43:13 +01:00
Josh Creek de263f30e8 docs: explain why the matchmaking control plane is Go, fix stale status
Add a Go-vs-C#/Rust/C++ rationale for the matchmaking control plane to
TECH_STACK.md, and point at it from MATCHMAKING.md and README.md.

Also correct CLAUDE.md and README.md, which still described the backend
as unstarted/not built even though server/ has ~13k lines of Go across
matcher, allocator, api, store, security, supervisor and agones.
2026-09-04 19:10:09 +01:00
Josh Creek ad9289fb21 docs(multiplayer): flag the real root blocker of the allocation pipeline
Found while investigating why the connect-wiring fix (§8.41,
ControlPlaneClient._connect_when_assigned) would still not work
end to end in a real deployment: nothing in production ever
publishes a player's signed match assignment.

store.SaveAssignment/SaveAssignments/SaveVerifiedAssignmentRoster --
the only functions that ever write the assignments table -- are
called only from tests, never from allocator/worker.go, cmd/allocator,
or anywhere else in the real service. allocator.Service.PublishRoster
(wired to store.PostgresRosterStore) is likewise never called from
production code.

allocation_match_sql.go's AdvanceServerRegistration SQL requires an
assignments row for every match participant before allowing the
ASSIGNMENT_READY transition. With nothing ever creating those rows, a
real match cannot advance past PROCESS_READY -- no player can ever
receive a real assignment or connect, regardless of how correct the
client-side connect-wiring fix from earlier this session is.

TestRealSupervisorRegistersAllocatedServerThroughControlPlane -- the
test that was supposed to prove this end to end -- manually seeds
store.SaveAssignment in its own setup rather than exercising the real
production write path, which is why this was never caught.

Per the user's explicit direction, this is flagged rather than fixed:
closing it needs new security-relevant design (a join-signing key
shared between the allocator, which would sign, and the game server,
which fleet.yaml already mounts a verification key for via
--join-authorisations-key-file but which no control-plane binary has
a matching signing flag for; roster-digest computation; per-player
domain.JoinAuthorisation construction from match_participants/identities
via the already-built domain.SignJoinAuthorisationHMAC), not a simple
wiring fix -- a wrong design choice here is a join-authorization
forgery risk, not just a UX gap, so it isn't something to build
unprompted the way the smaller fixes earlier this session were.

Recorded in three places for visibility: a new root-blocker callout
in §0 (the outstanding-work index), the top Status line, and expanded
detail in §8.31's own row, which previously undersold this as merely
'production signer... remain'.

No code changes. Verified the doc edit didn't touch anything else:
go build/vet/test -race clean, full Godot suite 220/220 clean
(unchanged, as expected).
2026-09-04 18:18:22 +01:00
Josh Creek ae4a6f937f fix(multiplayer): surface matchmaking connect failures to the player
Closes §8.43's 'failed-reconnect UX' gap, found immediately after
wiring connect_to_assignment() itself: even with that fix in place, a
connection failure had nowhere to go. connect_to_assignment()'s
synchronous failures (assignment missing/expired, invalid endpoint,
NetworkManager.join() erroring immediately) only ever emitted
assignment_connection_failed -- a signal nothing in the client
listened to. state.phase would stay stuck at ASSIGNED, the UI would
keep showing "Your match server is ready" forever, with no way back
to a fresh search.

Worse, the likelier real-world failure mode had no handler at all:
NetworkManager.join() returns OK immediately once the attempt starts,
but the actual ENet handshake can still fail asynchronously afterward
(unreachable server, refused connection, ENet's own ~5s connect
timeout). This is exactly the gap main_menu.gd's own
_on_connection_failed exists to cover for the direct-join flow (see
its header comment) -- nothing covered the equivalent for a
matchmaking-driven connect.

ControlPlaneClient now connects both assignment_connection_failed and
NetworkManager.connection_failed (guarded to state.phase == CONNECTING,
so it never misattributes an unrelated direct-join failure to a
matchmaking search) to state.fail(...), so either failure mode now
surfaces as a failed search the player can actually retry from.

Verified: three new test_control_plane_client.gd tests cover the
synchronous failure path, the async NetworkManager.connection_failed
path (via a real end-to-end ASSIGNED -> CONNECTING flow), and that an
unrelated connection_failed outside CONNECTING is correctly ignored.
220/220 tests pass, stable across 3 repeated runs, no crash, no
engine-level error; full make verify-multiplayer-local and the
complete make verify-enet-integration suite (all five cases) both
clean; zero new crash reports throughout.

Also fixes a markdown table-integrity mistake introduced while
documenting this in the same edit pass: an earlier Edit call
accidentally duplicated a sentence and dropped the row's closing
'remains' clause in §8.43 -- caught and corrected before commit via
the usual pipe-count check.
2026-09-04 18:12:25 +01:00
Josh Creek 7d50612abb feat(multiplayer): reject outdated clients with distinct messaging
Closes §8.43's 'version-mismatch-specific client messaging' gap. Per
the user's explicit go-ahead to design new server behavior for this
(rather than only wiring up something that already existed, the
pattern every other fix this session followed): before this, there
was no server-side protocol rejection at all. queue_create accepted
any protocol_version >= 1 unconditionally, so an outdated client could
only ever discover a mismatch by waiting in the queue forever
unmatched -- the matcher's own compatibility check requires every
formed player to share an identical protocol_version -- with no error
and no explanation given to the player.

Server: Service.MinProtocolVersion (opt-in, zero by default so every
existing caller keeps accepting protocol_version 1 unconditionally)
rejects a below-floor queue_create with 426 Upgrade Required /
client_outdated before the request ever reaches the candidate
provider. Wired via cmd/control-plane's new --min-protocol-version
flag (validated non-negative at startup).

Client: ControlPlaneClient recognises HTTPClient.RESPONSE_UPGRADE_REQUIRED
on queue_create specifically and sets a distinct 'Your client is out
of date -- please update to continue searching' message instead of
the server's raw generic error string, and clears _last_queue_create
so can_retry_queue_create() never offers 'Retry Search' for a failure
that retrying with the same build can never fix.

Verified: go build/vet/test -race clean across every server package.
TestQueueCreateEnforcesMinProtocolVersion covers below-floor rejection
(candidate provider never reached), exactly-at-floor acceptance, and
the error body naming client_outdated; TestQueueCreateMinProtocolVersionZeroIsDisabled
proves the opt-in default doesn't change behavior for every existing
caller. Godot: test_outdated_client_receives_a_distinct_message_and_no_retry_offer
proves the distinct message and suppressed retry offer. Full Godot
suite (217/217, 0 failed, no crash), full make verify-multiplayer-local
gate, zero new crash reports.
2026-09-04 18:07:29 +01:00
Josh Creek 24620be5c1 docs(multiplayer): correct two more stale 'remains' notes
Investigating §8.16's 'arena selection... remain' note (looking for
the next real Godot-side gap) found the described work was already
fully done, not missing: domain.RankedArenaForProposal selects the
ranked arena deterministically at proposal time; agones.Client.Allocate
already requests it (plus playlist/region/build/protocol/transport) as
Agones GameServerAllocation annotations; supervisor.withAllocatedCompatibility
already overlays every one of those onto the allocated Godot process's
launch command, overriding the Fleet's static per-image defaults --
fully tested (TestWithAllocatedCompatibilityOverridesStaleFlagsAndRejectsUnsafeValues)
and wired into Supervisor.Start(). Casual deliberately leaves
proposal.ArenaPath empty and the allocated server falls back to its
own ArenaRegistry.path_for_match rotation -- the same mechanism the
community server already used, always the intended design, not a gap.

§8.41's 'dynamic per-match launch flags... remain' note was the same
stale claim about the same already-built mechanism, described from
the other side (the assignment/roster row instead of the matcher
row). Corrected there too, pointing back to §8.16 rather than
duplicating the explanation.

No code changes -- this is the same category of finding as the
crash-loop and connect-wiring fixes earlier this session, just
resolved by correcting the record instead of writing new code, since
the record was wrong rather than the implementation. Verified the
referenced test by name: go test ./supervisor/... -run
TestWithAllocatedCompatibilityOverridesStaleFlagsAndRejectsUnsafeValues
passes; full go build/vet/test -race clean across every server
package (unchanged from before, since only the doc changed).
2026-09-04 18:02:36 +01:00
Josh Creek 4c61b1e28d fix(multiplayer): actually connect once matchmaking assigns a match
A player who completed the entire queue -> proposal -> allocate ->
assign pipeline would reach ASSIGNED, see "Your match server is
ready", and then simply sit there forever. connect_to_assignment()
existed in control_plane_client.gd, fully validated (checks the
assignment is available and fresh, splits and validates the
endpoint, carries the join authorisation via MatchNet's hello payload
rather than the URL) with its own assignment_connection_started/
assignment_connection_failed signals, and multiplayer-next.md's own
§8.41 row already described it as wired -- but grepping the whole
client found zero callers. Nothing anywhere in matchmaking.gd or
control_plane_client.gd itself ever invoked it.

ControlPlaneClient._connect_when_assigned() now calls it automatically
the moment state.phase reaches ASSIGNED. Wired into the single call
site every queue-shaped HTTP response already shares (heartbeat,
recover, and resync-triggered recover alike, since the WebSocket
match-lifecycle path always funnels into a REST resync first), so
both the ordinary poll path and the WebSocket-push path are covered
without a second call site to keep in sync. Two orderings are handled:
if the assignment fetch triggered earlier by ASSIGNMENT_READY has
already completed, it connects immediately; if not, it defers via
_pending_connect_match_id and resolves once the assignment becomes
available. _connect_attempted_match_id guards against a duplicate or
replayed ASSIGNED event reattempting the connection.

Verified against the real Godot 4.7.1 binary now that headless
testing has resumed: two new test_control_plane_client.gd tests cover
both orderings and the duplicate-attempt guard directly (216/216
total, 0 failed, no crash, no engine-level error, stable across
repeated runs); full make verify-multiplayer-local and the complete
make verify-enet-integration suite (all five cases, including the
3-process match) both pass clean; zero new crash reports throughout.

multiplayer-next.md's §8.41 row is corrected to describe what was
actually true (connect_to_assignment existed but was never called)
rather than repeating the prior, inaccurate 'already wired' claim.
2026-09-04 18:00:42 +01:00
Josh Creek 8810bf7d8f test(multiplayer): cover generic mutation retry recovery
Closes most of §8.43's 'decline, regional outage retry UI, failed
reconnect, duplicate-action recovery beyond proposals' remaining
list -- turned out to be mostly stale doc, not missing code.

matchmaking.gd's decline button/handler already existed
(%DeclineButton, _on_decline_pressed, visibility toggled by
MatchmakingState.PROPOSED alongside accept). ControlPlaneClient's
can_retry_last_mutation()/retry_last_mutation() -- the generic
'duplicate-action recovery beyond proposals' and 'regional outage
retry' mechanism -- also already existed: any mutation (not just a
proposal response) becomes retryable on a transport failure or a
408/429/503 response, and matchmaking.gd's queue button already fell
back to it ('Retry Request'). Neither had any test coverage proving
the mechanism actually works for a non-proposal mutation --
is_retryable_mutation_response's pure classification was the only
thing tested.

Two new tests: test_generic_mutation_retry_recovers_after_a_transient_failure
proves can_retry_last_mutation() transitions from false (mutation
in flight) to true after a transport-level failure on an ordinary
queue_heartbeat, exactly the 'regional outage' case; test_generic_mutation_retry_is_not_offered_for_unsafe_failures
proves a 409 (revision conflict) is never offered as a blind retry
and that retry_last_mutation() fails closed with ERR_INVALID_DATA
rather than resending a stale mutation. retry_last_mutation's literal
network dispatch (HTTPRequest.request()) is not exercised -- it needs
a live SceneTree that test_runner.tscn's synchronous single-_ready()
execution model cannot provide mid-suite; the two tests cover the
can_retry_last_mutation() decision boundary and the fail-closed path
instead, which is what's actually new here.

Verified against the real Godot 4.7.1 binary now that headless
testing has resumed: test_runner.tscn 214/214 clean (no crash, no
engine-level error), full make verify-multiplayer-local re-run clean,
zero new crash reports.

Remaining in §8.43: version-mismatch-specific messaging (a protocol
rejection currently surfaces only as the server's generic error
string), failed-reconnect UX, and §8.16's arena selection/long-running
worker integration.
2026-09-04 17:51:54 +01:00
Josh Creek 91b3fc938c docs(multiplayer): resolve the Godot crash-attribution blocker
Godot testing had been paused since earlier this session after a run
of native engine crashes (macOS crash reporter, EXC_BAD_ACCESS/SIGBUS)
that the user had confirmed as caused by this session's headless
invocations, based on temporal correlation with the session's own
activity.

Read the actual crash reports at
~/Library/Logs/DiagnosticReports/Godot-*.ips instead of relying on
that correlation. Every one of the 25 reports on the machine names
ChatGPT/codex (17 directly, 6 via an already-exited process in that
same tree) or a manual iTerm2 session (1) as the responsible/parent
process in the crash's own process tree -- none name Claude Code.
Codex (via the ChatGPT desktop app) was apparently running headless
Godot invocations concurrently with this session that day; the
crashes were most likely misattributed to Claude Code on timing
alone, not on anything in the crash reports themselves.

Presented this finding to the user, who confirmed resuming Godot
testing. Re-verified clean with zero new crash reports: test_runner.tscn
(212/212), the full make verify-enet-integration suite (all five
cases including the 3-process match), scripts/verify_control_plane_proposal_integration.sh
(passed twice -- the real matcher forms the proposal and both real
headless clients accept it), and the complete make verify-multiplayer-local
gate end to end (Go tests/race/vet, all three fuzz targets, 212 Godot
tests, contracts, manifests).

This unblocks the Godot-side work multiplayer-next.md had been
holding open pending this question: the two-player proposal
integration script (already on disk, now proven to pass) and the
Phase 8 client-experience tasks (8.39-8.43) that were waiting on the
same answer.
2026-09-04 17:45:27 +01:00
Josh Creek 79ab0d1404 fix(multiplayer): serve queue candidates when Redis is down, not just empty
Closes part of the 'live Redis failover' gap in §8.46, found by
reproducing a genuine Redis outage (not just an empty/partial cache)
against CandidateProjection.Snapshot with a killed miniredis instance.

CandidateProjection.Snapshot funnelled two different situations into
the same code path: the index erroring outright (Redis unreachable)
and the index coming back empty (ambiguous — a genuinely empty queue,
or a lost keyspace). Both went through Repair, which itself calls
Index.Rebuild — a second Redis round-trip that fails for exactly the
same reason the first one did. The result: a real Redis outage, or
the window during a failover, made Snapshot fail outright even though
PostgreSQL — the documented authoritative source everywhere
(RedisCandidateIndex's own comment, cmd/matcher, cmd/control-plane's
--redis-addr help text all call it a rebuildable/optional
acceleration layer) — was completely healthy. Matchmaking would stop
entirely on a Redis outage despite the architecture explicitly not
requiring that.

Snapshot now falls back to serving Source (PostgreSQL) directly
whenever the index errors OR comes back empty, and only best-effort
attempts to repopulate Redis afterward — that attempt's outcome is
deliberately ignored, since a caller must never be denied service
just because the opportunistic rebuild also hit the same down Redis.
Snapshot still fails when Source itself is unavailable; the fallback
is not unconditional.

Verified: reproduced the bug first (killed-miniredis Snapshot call
failed even though Source was healthy), then fixed it. go build/vet
clean; all pre-existing store-package tests pass unmodified,
including the two live-redis:7-alpine-container tests
(TestRealRedisCandidateIndexUpsertSnapshotRemove,
TestRealRedisCandidateProjectionRepairsAfterFlush, run against a real
container and torn down after). Two new tests cover the fallback
directly (killed miniredis, Source still served, exactly one Source
call) and that the fallback is not unconditional (both Redis and
Source down still fails). Full go test ./... -race clean across
every server package.

Remaining: live matcher-worker-under-load-during-failover integration,
i.e. running the actual matcher process against a real Redis that
goes down mid-run under concurrent load, not just this unit-level
reproduction.
2026-09-04 17:37:58 +01:00
Josh Creek 5190cded56 fix(multiplayer): stop a doomed formation from wedging the matcher
Closes the 'innocent-ticket restoration' gap noted in §8.20 and found
by re-examining §8.16's matcher worker. domain.FormFromQueue's anchor
is always the single oldest candidate, deterministically. If
domain.PrepareProposal then rejected that exact formation for a
reason specific to those particular players — mismatched protocol,
incomplete ranked identity metadata, a duplicate-SteamID pair, ranked
admission generally — RunOnce returned immediately and the next
matcher interval reproduced the identical formation and failed again.
Forever: nothing in the queue ever changes, so the same doomed anchor
group would be retried every single pass, permanently head-of-line-
blocking every other waiting player behind it too, not just the
players actually at fault. This is worse than the already-fixed
no-common-region crash-loop (§8.16) — that one killed the process;
this one fails silently and just never matches anyone again.

Two changes, both required together:

1. RunOnce now excludes a failed formation's players and retries
   with the remaining candidate pool, bounded to 8 attempts per pass.
   A batch with no viable formation at all (the pre-existing
   no-common-region case) still returns immediately, since retrying
   that can't help.

2. That fix was inert without a second one: RunOnce was asking
   Source for exactly w.Size candidates, so after excluding one
   failed formation's players there was nothing left to retry
   against. domain.SelectCandidates was always designed to search a
   larger pool (anchor plus an arbitrary remainder, widening through
   it) — the call site just never gave it one. RunOnce now requests
   up to 10x w.Size, capped at 200.

Verified: go build/vet/test -race clean across every server package.
Three new matcher tests cover the exclusion retry (an 8-candidate
batch whose permanently-doomed oldest 4 still lets the remaining 4
form and claim, correctly excluding the doomed players from the
claimed ticket set), that exhausting every attempt surfaces the last
real error rather than a silent false/nil, and that Source is
actually asked for more than w.Size candidates — a regression guard
for exactly the companion bug above. All six pre-existing worker
tests still pass unmodified, confirming the fix preserves every prior
guarantee (mixed-playlist/duplicate-identity rejection, incomplete
batch handling, durable claim failure propagation, Run's existing
per-pass-error survival).
2026-09-04 17:33:00 +01:00
Josh Creek f09ef7da8f test(multiplayer): cover concurrent proposal-expiry recovery race
Closes the 'concurrent proposal-recovery expiry races' gap noted in
§8.46. GetProposal (read-side recovery) and RespondToProposal both
run the identical expiry-advance SQL in their own transaction, so any
number of them can observe the same past-expiry proposal at once —
this had never been exercised concurrently, only sequentially (the
existing late-response test drives one call at a time).

TestPostgreSQLConcurrentProposalExpiryRecoveryAppliesCooldownsExactlyOnce
races 8 concurrent GetProposal/RespondToProposal calls, each with a
distinct 'now' past the proposal window, against one proposal and
asserts: EXPIRED lands on the proposal and both tickets exactly once,
a PROPOSAL_TIMEOUT penalty lands exactly once per offending player
(not once per racing transaction), and no idempotency row survives a
closed-proposal response. The design already defends against this —
ProposalParticipantExpireSQL only ever flips a still-PENDING row
once, so a losing racer's 'now' can't match
recordProposalTimeoutCooldowns' responded_at filter — this test is
what actually proves that holds under real concurrent load rather
than by inspection.

Verified: real postgres:17-alpine container, go test -tags
integration ./store/... -run
TestPostgreSQLConcurrentProposalExpiryRecoveryAppliesCooldownsExactlyOnce
-race -count=3 clean; full -tags integration ./store/... -race run
clean; full non-integration go build/vet/test -race clean across
every server package; container removed after the run.
2026-09-04 17:26:44 +01:00
Josh Creek ce17a45afb feat(multiplayer): alert on workload server-mutation conflicts
Closes the 'live duplicate/conflict alerting also remains' gap noted
in §8.10: a durable domain.ErrConflict/ErrResultConflict rejection on
/v1/servers/{id}/{register,connect,disconnect,shutdown,result} was
already logged as a structured 'conflict' stage event, but had no
Prometheus signal distinct from the generic 4xx-class counter, which
also catches ordinary client noise (malformed bodies, expired
tokens). A real duplicate registration, raced reconnect, or replayed
result would have been invisible to alerting until someone went
looking through logs.

observability.Metrics gains ObserveServerConflict(kind), a bounded
counter keyed to serverMutation's own five routes (an unrecognized
kind folds into "other", so a caller mistake can't grow the label
set), exported as cosmic_clash_api_server_conflicts_total. Wired at
each of serverMutation's four conflict branches in server/api/service.go.
deploy/observability/prometheus-rules.yaml adds
CosmicClashControlPlaneServerConflicts, mirroring the existing
allocator quota-denial alert shape, firing on >3 conflicts of one
kind in 15 minutes.

Verified: go build/vet/test -race clean across every server package;
new unit tests cover per-kind counting, the bounded 'other' fallback,
the counter's absence until first observed, and a nil-receiver no-op;
a service-level test proves a real register conflict is exported
through the live /metrics endpoint. scripts/verify_observability_manifests.py
passes against the edited rules file.

Remaining, and explicitly out of scope here: this alert has only been
validated statically, never against a live Prometheus/Alertmanager
firing on real traffic — that requires the same live cluster this
sandbox has never had.
2026-09-04 17:24:09 +01:00
Josh Creek 817572a6ce fix(multiplayer): size kind agones smoke resources 2026-09-04 17:07:13 +01:00
Josh Creek a5cbba8ac9 fix(multiplayer): harden live registration verification 2026-09-04 16:48:25 +01:00
Josh Creek 482d4b0985 docs(multiplayer): record load gate verification 2026-09-04 16:40:21 +01:00
Josh Creek f7958f3102 docs(multiplayer): record chaos recovery verification 2026-09-04 16:39:53 +01:00
Josh Creek d64920b0f9 fix(multiplayer): repair allocated compose verification 2026-09-04 16:38:10 +01:00
Josh Creek e6733bd6cb fix(multiplayer): repair PostgreSQL integration invariants 2026-09-04 15:43:31 +01:00
Josh Creek 0acf144f45 docs(multiplayer): record latest Godot coverage 2026-09-04 11:07:21 +01:00
Josh Creek 61daf139c5 fix(multiplayer): fence workload control URLs 2026-09-03 21:31:09 +01:00
Josh Creek 4e1dd0d24e test(multiplayer): preserve result integrity state 2026-09-03 21:30:16 +01:00
Josh Creek 3f3dada35f docs(multiplayer): refresh verification evidence 2026-09-03 21:29:18 +01:00
Josh Creek 9a724bf562 test(multiplayer): recover from native Godot crashes 2026-09-03 21:28:21 +01:00
Josh Creek ea1c65acfb fix(multiplayer): bound workload credential lifetime 2026-09-03 21:27:26 +01:00
Josh Creek 8507472635 fix(multiplayer): submit allocated match results 2026-09-03 21:24:07 +01:00
Josh Creek 759dbe2b65 docs(multiplayer): state workload annotation residual risk 2026-09-03 21:17:20 +01:00
Josh Creek f50264dae6 docs(multiplayer): correct outbox ownership 2026-09-03 21:15:52 +01:00
Josh Creek 6632cdace7 fix(multiplayer): validate initial-connect backfill 2026-09-03 21:15:32 +01:00
Josh Creek b5b6bdea95 fix(multiplayer): validate legacy connection leases 2026-09-03 21:14:41 +01:00
Josh Creek 2050acd63d test(multiplayer): make local gate portable 2026-09-03 21:12:40 +01:00
Josh Creek 3b523cf525 docs(multiplayer): record current Godot harness evidence 2026-09-03 21:10:12 +01:00
Josh Creek 854d160f27 docs(multiplayer): align workload authentication model 2026-09-03 21:08:52 +01:00
Josh Creek 25cf1e0cfa fix(multiplayer): keep abandonment maintenance available 2026-09-03 21:07:50 +01:00
Josh Creek addcea9fed fix(multiplayer): refresh dedicated server build base 2026-09-03 21:06:22 +01:00
Josh Creek bcc2639a33 fix(multiplayer): deploy reconnect abandonment maintenance 2026-09-03 21:05:58 +01:00
Josh Creek a4de140424 fix(multiplayer): fail closed without durable leases 2026-09-03 21:04:07 +01:00
Josh Creek 947fefc95c fix(multiplayer): dispatch live abandonment lifecycle 2026-09-03 20:55:56 +01:00
Josh Creek bd93a2657e docs(multiplayer): reflect durable admission leases 2026-09-03 20:54:12 +01:00
Josh Creek 2463713cde feat(multiplayer): persist live reconnect abandonments 2026-09-03 20:53:28 +01:00
Josh Creek aac81c89b6 feat(multiplayer): bind admissions to durable leases 2026-09-03 13:45:39 +01:00
Josh Creek 3e0022ce9c feat(multiplayer): persist connection generation leases 2026-09-03 13:38:13 +01:00
Josh Creek 2e9da3032c fix(multiplayer): complete live results atomically 2026-09-03 13:29:31 +01:00
Josh Creek 8c28374eb4 fix(multiplayer): harden reconnect lifecycle fencing 2026-09-03 13:24:52 +01:00
Josh Creek cee0163eac fix(multiplayer): verify complete durable assignment rosters 2026-09-03 13:22:02 +01:00
CosmicClash Training Bot 4519f2db82 chore(training): generation 5 progress after 20260901-2301-gen5-s6-league-retry2 2026-09-03 11:46:05 +01:00
CosmicClash Training Bot 0f1a7403e4 chore(training): Add 20260901-2301-gen5-s6-league-retry2 checkpoints, logs, and exported policy 2026-09-03 11:40:08 +01:00
Josh Creek bef71e1dcf fix(multiplayer): fence provider allocation results 2026-09-03 00:19:03 +01:00
Josh Creek a15368ed29 fix(multiplayer): fence client queue cancellation states 2026-09-03 00:15:41 +01:00
Josh Creek 1976c6eac6 fix(multiplayer): promote accepted matches atomically 2026-09-03 00:13:18 +01:00
Josh Creek eaf7ea8748 fix(multiplayer): make match promotion replay lifecycle-safe 2026-09-03 00:10:57 +01:00
Josh Creek f8af212e3f fix(multiplayer): terminate proposal offenders atomically 2026-09-03 00:09:07 +01:00
Josh Creek aa446cfbfe fix(multiplayer): reconcile authoritative initial connections 2026-09-03 00:02:04 +01:00
Josh Creek 781cbc35aa docs(multiplayer): reconcile review progress 2026-09-02 19:15:47 +01:00
Josh Creek 51f8008a38 fix(multiplayer): gate API readiness on database 2026-09-02 19:14:21 +01:00
Josh Creek 1bce603c33 fix(multiplayer): bound adapter HTTP calls 2026-09-02 19:12:40 +01:00
Josh Creek cbefa86c5c fix(multiplayer): report allocator readiness 2026-09-02 19:11:01 +01:00
Josh Creek 6ebd6e59c1 fix(multiplayer): resolve client IP behind proxies 2026-09-02 19:08:34 +01:00
Josh Creek d59e0017f7 fix(multiplayer): lock signed team assignments 2026-09-02 19:05:45 +01:00
Josh Creek 0bca441ca1 fix(multiplayer): enforce drain at admission 2026-09-02 19:04:56 +01:00
Josh Creek 670466dbd7 fix(multiplayer): authenticate Agones Kubernetes API 2026-09-02 18:58:57 +01:00
Josh Creek 55b88a3aa5 fix(multiplayer): converge events through REST 2026-09-02 18:54:10 +01:00
Josh Creek 91658fbc13 fix(multiplayer): recover assignment handoff 2026-09-02 18:47:00 +01:00
Josh Creek 84d27d82da docs: narrow remaining font TODO 2026-09-01 23:31:27 +01:00
Josh Creek 3e9f3f2c4e docs: reconcile audio progress notes 2026-09-01 23:30:06 +01:00
Josh Creek 4353abfd97 feat(audio): add wall contact cue 2026-09-01 23:26:18 +01:00
Josh Creek f5fd3bb207 feat(audio): add turbo engagement cue 2026-09-01 23:24:42 +01:00
Josh Creek 5dc659007e docs: clarify video settings profiling gate 2026-09-01 23:23:17 +01:00
Josh Creek 19ef84c3cb feat(audio): drive engine tone from thrust 2026-09-01 23:22:20 +01:00
Josh Creek fcb38b3d57 feat(audio): connect ball impacts to feedback 2026-09-01 23:20:35 +01:00
Josh Creek 510138eb0a feat(audio): wire menu click feedback 2026-09-01 23:19:48 +01:00
Josh Creek b883338396 feat(audio): add procedural gameplay sound foundation 2026-09-01 23:18:48 +01:00
Josh Creek a9b8f53ef1 docs(multiplayer): record allocation outbox coverage 2026-09-01 23:15:47 +01:00
Josh Creek b15d19eec2 feat(multiplayer): explain queue and connection health 2026-09-01 23:11:37 +01:00
Josh Creek 9b14109727 test(multiplayer): retain failed database container 2026-09-01 23:08:01 +01:00
Josh Creek 870b89d279 test(multiplayer): expose database gate failures 2026-09-01 23:07:08 +01:00
Josh Creek bff71bfc2a docs(multiplayer): record live gate storage blocker 2026-09-01 23:06:21 +01:00
Josh Creek bfaf5d40ff fix(multiplayer): validate timestamp calendar 2026-09-01 23:02:31 +01:00
CosmicClash Training Bot 8a55c33666 chore(training): generation 5 progress after 20260831-0724-gen5-s6-league-retry1 2026-09-01 23:01:35 +01:00
Josh Creek 6a9b269798 fix(multiplayer): validate contract ticket input 2026-09-01 22:59:57 +01:00
Josh Creek badd0b1b47 fix(multiplayer): validate contract route ids 2026-09-01 22:58:28 +01:00
Josh Creek f7ab77dec5 fix(multiplayer): validate queue response contract 2026-09-01 22:56:33 +01:00
CosmicClash Training Bot ad6f9cc148 chore(training): Add 20260831-0724-gen5-s6-league-retry1 checkpoints, logs, and exported policy 2026-09-01 22:55:32 +01:00
Josh Creek fc3a7ea359 fix(multiplayer): validate proposal expiry 2026-09-01 22:54:33 +01:00
Josh Creek ffc7856993 fix(multiplayer): align proposal participant contract 2026-09-01 22:52:52 +01:00
Josh Creek 807aaa4478 fix(multiplayer): validate match admission context 2026-09-01 22:51:15 +01:00
Josh Creek 3bd2387dc3 fix(multiplayer): validate session expiry response 2026-09-01 22:50:00 +01:00
Josh Creek edd78d2548 fix(multiplayer): validate REST response ids 2026-09-01 22:47:57 +01:00
Josh Creek bda3fc5aff fix(multiplayer): validate persisted matchmaking ids 2026-09-01 22:46:40 +01:00
Josh Creek e9ed8923c9 fix(multiplayer): validate client resource paths 2026-09-01 22:45:04 +01:00
Josh Creek 56e8e2554c fix(multiplayer): validate allocated config ids 2026-09-01 22:44:12 +01:00
Josh Creek 75f9026ea1 fix(multiplayer): reject fractional assignment values 2026-09-01 22:42:46 +01:00
Josh Creek e376e1e80d fix(multiplayer): align websocket assignment ids 2026-09-01 22:42:04 +01:00
Josh Creek 2bdf876b47 fix(multiplayer): enforce assignment opaque ids 2026-09-01 22:40:43 +01:00
Josh Creek dac414274e fix(multiplayer): enforce ranked tier enum 2026-09-01 22:39:06 +01:00
Josh Creek c580e46125 fix(multiplayer): fail closed on ticket timestamps 2026-09-01 22:36:54 +01:00
Josh Creek 03bc723b70 fix(multiplayer): reject fractional ranked games 2026-09-01 22:36:05 +01:00
Josh Creek a10c6d47f9 fix(multiplayer): validate persisted matchmaking snapshots 2026-09-01 22:34:59 +01:00
Josh Creek 3985b74bcf fix(multiplayer): validate ranked season ids 2026-09-01 22:33:54 +01:00
Josh Creek ec0bc362cd fix(multiplayer): validate ranked season expiry 2026-09-01 22:33:00 +01:00
Josh Creek d819658ace fix(multiplayer): reject typed claim coercion 2026-09-01 22:32:05 +01:00
Josh Creek 4d8638e62f fix(multiplayer): harden join expiry validation 2026-09-01 22:30:36 +01:00
Josh Creek 82691eaf80 fix(multiplayer): validate assignment expiry format 2026-09-01 22:29:45 +01:00
Josh Creek 07fd0fde44 feat(ui): add shared cosmic clash theme 2026-09-01 22:28:02 +01:00
Josh Creek 889e30a434 fix(multiplayer): reset expiry on reauthentication 2026-09-01 22:26:39 +01:00
Josh Creek 27017043f9 feat(multiplayer): explain allocation lifecycle 2026-09-01 22:25:54 +01:00
Josh Creek 429fb87c08 fix(multiplayer): validate server event resource ids 2026-09-01 22:25:04 +01:00
Josh Creek fd1a4d9577 feat(multiplayer): show authoritative proposal countdown 2026-09-01 22:23:33 +01:00
Josh Creek 17e6a9bc20 fix(multiplayer): enforce opaque event resource ids 2026-09-01 22:22:34 +01:00
Josh Creek 87d43302e1 fix(multiplayer): validate event timestamps 2026-09-01 22:21:18 +01:00
Josh Creek 73e5d64ba2 test(multiplayer): cover queue conflict recovery wiring 2026-09-01 22:20:07 +01:00
Josh Creek 7534d8436c fix(multiplayer): recover queue revision conflicts 2026-09-01 22:19:02 +01:00
Josh Creek 1f05f6d524 fix(multiplayer): align resync recovery targets 2026-09-01 22:16:50 +01:00
Josh Creek cfc82bcea5 fix(multiplayer): expire client sessions proactively 2026-09-01 22:13:41 +01:00
Josh Creek 1e5825b096 fix(multiplayer): validate ticket timestamps 2026-09-01 22:08:19 +01:00
Josh Creek c08c761af3 fix(multiplayer): recover requeued tickets 2026-09-01 22:06:30 +01:00
Josh Creek 0c4ad6a5aa fix(multiplayer): preserve proposal requeues 2026-09-01 22:04:53 +01:00
Josh Creek 277ad4bf98 fix(multiplayer): validate ticket playlists 2026-09-01 22:02:01 +01:00
Josh Creek 0ce2a49419 fix(multiplayer): enforce proposal transitions 2026-09-01 22:00:37 +01:00
Josh Creek a60c1a097e fix(multiplayer): validate client revisions 2026-09-01 21:59:32 +01:00
Josh Creek f491725144 fix(multiplayer): stop recovery after completion 2026-09-01 21:58:20 +01:00
Josh Creek 0f1864f8bc fix(multiplayer): enforce client state transitions 2026-09-01 21:57:13 +01:00
Josh Creek 30a5a89164 fix(multiplayer): project complete queue lifecycle 2026-09-01 21:55:19 +01:00
Josh Creek 6ac2d0fbb1 fix(multiplayer): project accepted queue state 2026-09-01 21:54:10 +01:00
Josh Creek fa2c93ec06 fix(multiplayer): defer reconnect recovery during mutations 2026-09-01 21:53:07 +01:00
Josh Creek 47aa196b59 feat(multiplayer): publish ranked profile contract 2026-09-01 21:51:42 +01:00
Josh Creek 51f6e19339 feat(multiplayer): expose ranked season countdown 2026-09-01 21:49:48 +01:00
Josh Creek 9240cd4b27 feat(multiplayer): preserve authoritative queue wait 2026-09-01 21:45:07 +01:00
Josh Creek 950e879861 fix(multiplayer): bind allocation to accepted proposal 2026-09-01 21:41:44 +01:00
Josh Creek d3a457d8d0 fix(multiplayer): avoid quota double charge on recovery 2026-09-01 21:39:18 +01:00
Josh Creek eb3b685af0 feat(multiplayer): expose active ranked season 2026-09-01 21:34:57 +01:00
Josh Creek 3151e54ab3 fix(multiplayer): use locked rating for season rollover 2026-09-01 21:32:34 +01:00
Josh Creek 96f311c129 fix(multiplayer): finalize empty ranked seasons 2026-09-01 21:30:23 +01:00
Josh Creek 836cedec3c feat(multiplayer): publish allocation progress events 2026-09-01 21:27:24 +01:00
Josh Creek 614b87f7e1 fix(multiplayer): bound websocket writes 2026-09-01 21:24:38 +01:00
Josh Creek 30a22c366b fix(multiplayer): harden websocket frame parser 2026-09-01 21:23:20 +01:00
Josh Creek 3eb47bb9d3 fix(multiplayer): fence recovered arena identity 2026-09-01 21:21:10 +01:00
Josh Creek 701d7a9a2e fix(multiplayer): centralize arena path validation 2026-09-01 21:18:48 +01:00
Josh Creek bfeb822279 fix(multiplayer): persist arena identity on allocations 2026-09-01 21:17:39 +01:00
Josh Creek 2ff348adf0 fix(multiplayer): persist arena identity on allocations 2026-09-01 21:16:42 +01:00
Josh Creek d4bde67e0f test(multiplayer): harden ranked arena provider boundary 2026-09-01 21:13:04 +01:00
Josh Creek 0666d8d308 fix(multiplayer): verify recovered arena annotations 2026-09-01 21:12:06 +01:00
Josh Creek e83f84f527 test(multiplayer): refresh proposal cooldown evidence 2026-09-01 21:10:27 +01:00
Josh Creek b01db32908 docs(multiplayer): reconcile phase eight status 2026-09-01 21:09:43 +01:00
Josh Creek caa7e3e793 fix(multiplayer): constrain ranked arena paths in postgres 2026-09-01 21:09:01 +01:00
Josh Creek bd617dbf06 fix(multiplayer): validate ranked arena paths durably 2026-09-01 21:07:56 +01:00
Josh Creek ff80ab46b7 feat(multiplayer): rotate ranked arenas deterministically 2026-09-01 21:06:26 +01:00
Josh Creek 84372204fd feat(multiplayer): persist ranked arena allocations 2026-09-01 21:04:55 +01:00
Josh Creek 5630c5c8dc feat(multiplayer): harden ranked arena admission 2026-09-01 19:39:11 +01:00
Josh Creek 52e3d73678 fix(multiplayer): reject websocket caps before upgrade 2026-09-01 19:35:34 +01:00
Josh Creek ad59c5f567 test(multiplayer): cover cooldown response boundary 2026-09-01 19:34:16 +01:00
Josh Creek 957bb65a26 feat(multiplayer): enforce proposal timeout cooldowns 2026-09-01 19:32:38 +01:00
Josh Creek 455055c67c feat(multiplayer): enforce proposal decline cooldowns 2026-09-01 19:30:45 +01:00
Josh Creek da92be73ef feat(multiplayer): cap websocket connections per player 2026-09-01 19:27:45 +01:00
Josh Creek 515b06d97c feat(multiplayer): bound control-plane websocket traffic 2026-09-01 19:25:34 +01:00
Josh Creek b110bfc5f7 docs(multiplayer): reconcile session progress 2026-09-01 19:24:01 +01:00
Josh Creek b4d2dd8a9f feat(multiplayer): protect control-plane rollouts 2026-09-01 19:23:22 +01:00
Josh Creek bb9f25ee8d fix(multiplayer): wire control-plane runtime secrets 2026-09-01 19:22:06 +01:00
Josh Creek 81346ac149 feat(multiplayer): harden control-plane availability 2026-09-01 19:20:53 +01:00
Josh Creek e1abac1271 feat(multiplayer): enforce account and IP rate limits 2026-09-01 19:18:40 +01:00
Josh Creek aa97259165 feat(multiplayer): declare control-plane rate limits 2026-09-01 19:17:09 +01:00
Josh Creek 9cc68d7707 feat(multiplayer): wire control-plane request limits 2026-09-01 19:15:50 +01:00
Josh Creek 6366b5e1f6 feat(multiplayer): add degraded admission mode 2026-09-01 19:14:28 +01:00
Josh Creek d04523accd feat(multiplayer): package observability resources 2026-09-01 19:10:10 +01:00
Josh Creek 05e8a1b398 docs(training): record stage six league evidence 2026-09-01 19:08:52 +01:00
Josh Creek e56850a236 fix(training): make policy evaluation portable 2026-09-01 18:56:31 +01:00
Josh Creek 066aee96cc test(training): add reproducible verification target 2026-09-01 18:53:56 +01:00
Josh Creek e78f805c92 feat(multiplayer): spread allocator replicas 2026-09-01 18:52:15 +01:00
Josh Creek 34cc994598 feat(multiplayer): protect allocator availability 2026-09-01 18:51:18 +01:00
Josh Creek 398d6ade61 feat(multiplayer): harden allocator rollout 2026-09-01 18:50:09 +01:00
Josh Creek 2e1c010fa2 feat(multiplayer): deploy allocator role 2026-09-01 18:48:51 +01:00
Josh Creek 95e82cc719 feat(multiplayer): wire allocator metrics discovery 2026-09-01 18:46:31 +01:00
Josh Creek 9a72dd5eab feat(multiplayer): expose allocator quota metrics 2026-09-01 18:43:55 +01:00
Josh Creek fc000e71d5 docs(multiplayer): include quota migration in phase index 2026-09-01 18:39:44 +01:00
Josh Creek 4a1a6f6697 docs(multiplayer): record shared quota evidence 2026-09-01 18:38:44 +01:00
Josh Creek 72e8d27633 feat(multiplayer): add shared allocation quota 2026-09-01 18:38:06 +01:00
Josh Creek 55706ba9ea test(multiplayer): cover matcher load boundary 2026-09-01 18:32:40 +01:00
Josh Creek 71935e61b5 test(multiplayer): add chaos recovery smoke 2026-09-01 18:30:09 +01:00
Josh Creek 25236cc0a1 docs(multiplayer): update phase eight index 2026-09-01 18:28:25 +01:00
Josh Creek 4b243f1a47 test(multiplayer): add release promotion gate 2026-09-01 18:26:56 +01:00
Josh Creek 181a928c87 feat(multiplayer): add regional allocation budget 2026-09-01 18:25:25 +01:00
Josh Creek c4c2ada1f6 test(multiplayer): add api load gate 2026-09-01 18:23:22 +01:00
Josh Creek 76c1c3d600 fix(multiplayer): publish stalled allocation recovery 2026-09-01 18:19:28 +01:00
Josh Creek 7c044b7094 test(multiplayer): harden agones allocation gate 2026-09-01 18:16:54 +01:00
Josh Creek e3fad7064b fix(training): restore ai reward script parsing 2026-09-01 18:13:53 +01:00
Josh Creek c65cd2d17e docs(multiplayer): sync fleet wiring status 2026-09-01 18:11:14 +01:00
Josh Creek 3317574bf2 fix(multiplayer): fence recovered allocation tuples 2026-09-01 18:06:56 +01:00
Josh Creek bf396afcaf fix(multiplayer): recover ambiguous agones allocations 2026-09-01 18:05:43 +01:00
Josh Creek b72a7cf843 test(multiplayer): cover compose allocator binding 2026-09-01 18:02:05 +01:00
Josh Creek 063dff463d test(multiplayer): drive compose proposal orchestration 2026-09-01 17:58:37 +01:00
Josh Creek 00d6b1edd3 test(multiplayer): cover compose queue lifecycle 2026-09-01 17:56:47 +01:00
Josh Creek a57b582cf1 docs(multiplayer): record supervisor compose coverage 2026-09-01 17:54:40 +01:00
Josh Creek b33f681ffa test(multiplayer): exercise compose supervisor drain 2026-09-01 17:54:16 +01:00
Josh Creek b1783abdce test(multiplayer): isolate agones lifecycle smoke 2026-09-01 17:52:02 +01:00
Josh Creek 88eb510dc3 test(multiplayer): add allocated compose smoke flow 2026-09-01 17:50:52 +01:00
Josh Creek 8129bb2571 fix(multiplayer): correct allocated fleet entrypoint 2026-09-01 17:47:15 +01:00
Josh Creek 6042c3b154 ci(multiplayer): run agones integration gate 2026-09-01 17:44:13 +01:00
Josh Creek 9b76d47c52 test(multiplayer): add disposable kind agones gate 2026-09-01 17:43:35 +01:00
Josh Creek e934bbfe44 test(training): cover wall rebound geometry 2026-09-01 17:39:53 +01:00
Josh Creek e376675fa6 feat(training): add wall and rebound curriculum states 2026-09-01 17:37:52 +01:00
Josh Creek 7170400f49 test(multiplayer): verify observability manifests locally 2026-09-01 17:34:15 +01:00
Josh Creek 4c1ed87344 test(training): verify 2v2 evaluator command 2026-09-01 17:32:10 +01:00
Josh Creek 7b2f9c26f4 feat(training): add opt-in teamplay evaluation 2026-09-01 17:30:40 +01:00
Josh Creek 9004800326 test(training): require multi-seed curriculum evaluation 2026-09-01 17:26:44 +01:00
Josh Creek 533ac1afab ops(multiplayer): wire Prometheus service discovery 2026-09-01 17:24:21 +01:00
Josh Creek dc901f5951 docs(multiplayer): mark observability local gates 2026-09-01 17:23:25 +01:00
Josh Creek 67da422ea6 ops(multiplayer): add control-plane alert rules 2026-09-01 17:22:09 +01:00
Josh Creek 0bf33e7fe8 feat(multiplayer): export queryable API latency histograms 2026-09-01 17:21:13 +01:00
Josh Creek 20f376f713 docs(multiplayer): index local completion status 2026-09-01 17:17:37 +01:00
Josh Creek d0c96422b2 fix(multiplayer): harden observability redaction 2026-09-01 17:15:44 +01:00
Josh Creek 65f0ad5dfd test(multiplayer): strengthen local verification gate 2026-09-01 17:14:18 +01:00
Josh Creek 98ff2aed81 test(multiplayer): detect macOS Godot bundle 2026-09-01 17:13:18 +01:00
Josh Creek 0787702f23 test(multiplayer): record ENet integration gate 2026-09-01 17:11:55 +01:00
Josh Creek f1b9527d3f test(multiplayer): verify domain fuzz targets 2026-09-01 17:10:26 +01:00
Josh Creek c5f6c95ca5 docs(multiplayer): close snapshot disconnect race note 2026-09-01 17:09:23 +01:00
Josh Creek d1bcb51225 test(multiplayer): add consolidated local gate 2026-09-01 17:07:44 +01:00
Josh Creek f1b8366531 feat(multiplayer): export bounded API metrics 2026-09-01 17:06:38 +01:00
Josh Creek d85c8b8194 feat(multiplayer): expose action retry in matchmaking UI 2026-09-01 17:03:31 +01:00
Josh Creek 6ef55affcb feat(multiplayer): add idempotent client mutation retries 2026-09-01 17:02:55 +01:00
Josh Creek 9ae01ecc5a fix(multiplayer): propagate allocated playlist 2026-09-01 17:00:37 +01:00
Josh Creek 8bd1455a2c feat(multiplayer): propagate allocated launch configuration 2026-09-01 16:58:09 +01:00
Josh Creek 315c524c42 feat(multiplayer): propagate allocated launch configuration 2026-09-01 16:57:27 +01:00
Josh Creek 75cb8faac4 feat(multiplayer): acknowledge supervisor shutdown 2026-09-01 16:54:40 +01:00
Josh Creek cc12260225 feat(multiplayer): expose server shutdown acknowledgement 2026-09-01 16:50:26 +01:00
Josh Creek 1d926e705b fix(multiplayer): preserve planned shutdown reason 2026-09-01 16:44:42 +01:00
Josh Creek 4e88ea80f3 feat(multiplayer): handle planned shutdowns on clients 2026-09-01 16:43:47 +01:00
Josh Creek b24f9fc448 feat(multiplayer): notify clients before server shutdown 2026-09-01 16:42:15 +01:00
Josh Creek 8c2dc66c7c fix(multiplayer): release innocent no-show participants 2026-09-01 16:39:46 +01:00
Josh Creek 334d8a26ac feat(multiplayer): enforce allocated initial connect policy 2026-09-01 16:36:26 +01:00
Josh Creek 74e50caf70 feat(multiplayer): publish allocation state events 2026-09-01 16:31:17 +01:00
Josh Creek d15d16d593 feat(multiplayer): publish allocation state events 2026-09-01 16:30:35 +01:00
Josh Creek ec82367c50 fix(multiplayer): close initial connect sweep rows 2026-09-01 16:28:08 +01:00
Josh Creek a6d2bdf8bd feat(multiplayer): sweep initial connect outcomes 2026-09-01 16:27:15 +01:00
Josh Creek bae7458668 feat(multiplayer): apply initial connect outcomes durably 2026-09-01 16:26:06 +01:00
Josh Creek b7642ad2be feat(multiplayer): model initial connect outcomes 2026-09-01 16:21:40 +01:00
Josh Creek 3dbecb0bd5 fix(multiplayer): fence roster topology at persistence 2026-09-01 16:20:31 +01:00
Josh Creek a439a1059b fix(multiplayer): fence roster topology at persistence 2026-09-01 16:20:01 +01:00
Josh Creek 80d6ee8cf5 fix(multiplayer): validate allocated roster shape 2026-09-01 16:18:31 +01:00
Josh Creek 69a8402f11 fix(multiplayer): honor assigned team and slot 2026-09-01 16:17:10 +01:00
Josh Creek 5812386676 feat(observability): log authenticated read routes 2026-09-01 16:14:20 +01:00
Josh Creek e62909d457 docs(multiplayer): mark identity and launch gates 2026-09-01 16:10:39 +01:00
Josh Creek 12b9712a8d test(multiplayer): lock allocated launch overrides 2026-09-01 16:09:57 +01:00
Josh Creek b4ea50d76a fix(multiplayer): reclaim slots by signed identity 2026-09-01 16:08:01 +01:00
Josh Creek bdb3c8f4a7 fix(multiplayer): gate allocated matches on full roster 2026-09-01 16:03:43 +01:00
Josh Creek 498f219ab9 fix(multiplayer): align NA fleet region runtime 2026-09-01 16:01:38 +01:00
Josh Creek c0861bfcad feat(multiplayer): wire allocated fleet runtime 2026-09-01 16:00:03 +01:00
Josh Creek 68e76b5feb feat(multiplayer): deliver allocated server rosters 2026-09-01 15:54:07 +01:00
Josh Creek 863cf61f1a test(multiplayer): verify result websocket fanout 2026-09-01 15:47:08 +01:00
Josh Creek 802e5fc96f test(multiplayer): fence conflicting result races 2026-09-01 15:44:47 +01:00
Josh Creek f3b56f70e3 feat(multiplayer): dispatch completed result events 2026-09-01 15:43:32 +01:00
Josh Creek 9ec707674a test(multiplayer): verify allocated supervisor registration 2026-09-01 15:39:28 +01:00
Josh Creek 207ab47866 test(multiplayer): verify allocator worker lifecycle 2026-09-01 15:34:51 +01:00
Josh Creek dad26a164c fix(multiplayer): recover stale proposal mutations 2026-09-01 15:31:01 +01:00
Josh Creek 04b5d3b29d feat(multiplayer): verify ranked profile delivery 2026-09-01 15:29:49 +01:00
Josh Creek d081a72b9a feat(multiplayer): wire ranked profile runtime policy 2026-09-01 15:28:37 +01:00
Josh Creek f096e8ff0b test(multiplayer): verify live assignment delivery 2026-09-01 15:25:36 +01:00
Josh Creek 79a6092b28 feat(multiplayer): wire production proposal outbox delivery 2026-09-01 15:21:49 +01:00
Josh Creek aa93aeec95 test(multiplayer): verify two-player proposal round trip 2026-09-01 15:17:53 +01:00
Josh Creek 1e1db3525e docs(multiplayer): record the workload-token delivery channel closing
Updates §8.10 and §8.28's cross-reference to reflect the previous two
commits: the token now binds allocation_id only (resolved durably at verify
time, not embedded match/server), and the delivery channel itself is wired
end to end (cmd/allocator mints -> Agones annotation -> supervisor reads ->
Authorization header), not just the verification core. Records what's left:
this has only run against HTTP-level Agones fakes, never a real cluster, so
the object_meta JSON casing remains unverified from this sandbox; fleet.yaml
still needs its own environment-specific manifest values.
2026-09-01 14:58:30 +01:00
Josh Creek 544f76c502 feat(multiplayer): deliver signed workload tokens via Agones allocation annotation
Closes the remaining gap the previous two commits left open: WorkloadVerify
itself worked, but nothing minted a real token at allocation time or handed
it to a running pod, so it had no real caller yet.

agones.Client gains WorkloadSecret/WorkloadTokenTTL. When set, Allocate
mints a signed workload token for the allocation (allocation_id is known at
request-construction time, before Agones has picked a server -- see the
previous commit for why that's the only identifier the token can bind) and
requests it as a third cosmic-clash.io/workload-token annotation, alongside
the existing match-id/allocation-id ones. Left unset (the default), Allocate
requests no such annotation, so a deployment not yet using this path is
unaffected. cmd/allocator wires it from a new --workload-secret /
COSMIC_CLASH_WORKLOAD_SECRET flag (must match cmd/control-plane's own), with
a startup warning if left unset.

supervisor.Supervisor.workloadToken() resolves the bearer credential for
control-plane registration: an explicitly configured --workload-token-path
always wins (kept for a future Kubernetes-projected-JWT WorkloadVerify path,
not yet wired server-side), otherwise it falls back to the
cosmic-clash.io/workload-token annotation on the allocated GameServer --
the same annotation-fallback pattern matchID already used for
cosmic-clash.io/match-id. WorkloadTokenPath is accordingly no longer
required at construction time when ControlPlaneURL is set.

Verified: new agones test proves the annotation is requested (and parses/
verifies against the same secret, naming the right allocation) when
WorkloadSecret is configured, and that it's absent when it isn't; new
supervisor tests prove the annotation-sourced token is what's actually sent
as the Authorization bearer, and that Start fails closed with neither a
configured path nor an annotation present. Full
`go build ./... && go vet ./... && gofmt -l . && go test ./... -race` and
`go test -tags integration ./... -race` both clean.
2026-09-01 14:57:45 +01:00
Josh Creek d588898f5d fix(multiplayer): bind signed workload tokens to allocation_id only
The just-landed signed workload token embedded (allocation_id, match_id,
server_id) as claims. That doesn't actually work for its intended delivery
channel: the token is meant to be requested as a GameServerAllocation
annotation in the SAME request that asks Agones to pick a server, so at mint
time the allocator knows allocation_id (it generates it) but not yet which
server_id Agones will return -- server_id only exists in Agones's response,
after the annotation request has already been sent. Embedding it was simply
not possible for the real caller this was built for; only the (allocator ->
signed_token) unit tests and hand-constructed integration tests happened to
supply it directly, masking the gap.

Fixes it by having the token bind only allocation_id (the one identifier
actually known at mint time) plus expiry. match_id/server_id are resolved at
verify time from the durable allocations table via the new
store.AllocationBindingByAllocationID, keyed by allocation_id -- which the
allocator already records immediately after Agones responds. This is
strictly stronger, not just a workaround: a caller can no longer claim any
match/server pairing at all, even one that happens to be internally
consistent -- the binding returned is entirely durable-record-derived.

Verified: server/workload's unit tests updated for the new two-field claim
shape; server/api's Postgres integration suite gains
TestWorkloadVerifierFromSignedTokenNeverTrustsCallerSuppliedBinding (two
distinct real allocations each resolve to their own, and only their own,
match/server pairing) replacing the now-inapplicable mismatched-triple test.
Full `go build ./... && go vet ./... && gofmt -l . && go test ./... -race`
and `go test -tags integration ./... -race` both clean; the api integration
suite re-run 3x clean against a live postgres:17-alpine container.
2026-09-01 14:54:38 +01:00
Josh Creek 939b7a9584 docs(multiplayer): record WorkloadVerify closed via self-issued token
Updates §8.10 and §8.28's cross-reference in multiplayer-next.md to reflect
the previous commit: the WorkloadVerify blocker both rows named as the actual
next thing standing in the way of a working server registration/result route
is closed, via a control-plane-self-issued signed token rather than the
Kubernetes-JWT approach originally assumed necessary. Records precisely what
remains: the real delivery channel (an Agones annotation carrying a minted
token, and the supervisor reading it) and fleet.yaml's still-unaddressed
manifest wiring.
2026-09-01 14:52:07 +01:00
Josh Creek 520613aab0 feat(multiplayer): implement WorkloadVerify without a Kubernetes trust boundary
WorkloadVerify (api.Service.WorkloadVerify) was permanently unwired: both
/v1/servers/{id}/register and /v1/servers/{id}/result always 503, because
the only design considered so far was verifying a Kubernetes-projected
service-account JWT (server/workload/jwt.go), which needs a live cluster's
TokenReview/JWKS endpoint to validate against safely -- something this
sandbox cannot do without guessing at a trust boundary.

The API layer doesn't actually require that specific mechanism. serverMutation
only compares WorkloadBinding.ServerID and .MatchID (server/api/service.go);
AdvanceServerRegistration only uses .MatchID/.ServerID/.AllocationID. Nothing
downstream needs Namespace/ServiceAcct/PodUID/GameServerUID populated.

This adds a self-contained alternative: a short-lived, HMAC-signed token the
control plane mints and verifies with a secret only it holds (server/workload/
signed_token.go), the same trust model domain.SessionStore already uses for
player sessions elsewhere in this codebase. It needs no cluster to verify --
signature + expiry is fully self-contained and unit-testable.

The design's soundness rests on the delivery channel, not the crypto: the
token is meant to reach the allocated GameServer via the same Agones
GameServerAllocation annotation channel allocation.go already uses for
match-id/allocation-id, readable only by that pod's own local SDK sidecar. A
caller presenting this token has already proven, via that channel, that it is
the pod Agones allocated. (Wiring the actual annotation delivery -- extending
agones.Client.Allocate and the supervisor's token source -- is a separate,
follow-up change; this commit lands the verification core it depends on.)

store.AllocationBindingStillValid adds defense-in-depth on top of signature
and expiry: it cross-checks the token's claims against the durable
allocations table (append-only, never leaves 'ALLOCATED'), so a validly-signed
token naming an allocation that was never recorded -- or a real allocation id
paired with a mismatched match/server -- is still rejected.

api.WorkloadVerifierFromSignedToken wires the two together and is now plugged
into cmd/control-plane (new --workload-secret / COSMIC_CLASH_WORKLOAD_SECRET
flag; a startup warning is logged if it's left unset, since the route then
stays 503 exactly as before) and cmd/testkit-api (fixed test secret, since
that binary is test-only already).

Verified: new unit tests in server/workload (signature tamper, wrong secret,
expiry boundary, malformed input) and a new Postgres integration suite in
server/api (real allocation row, real signed token, acceptance / unknown-
allocation rejection / mismatched-triple rejection / the previously-503
Service.WorkloadVerify field itself) -- both run clean with -race across
multiple passes against a live postgres:17-alpine container. Full
`go build ./... && go vet ./... && gofmt -l . && go test ./... -race` and
`go test -tags integration ./... -race` both clean.
2026-09-01 14:51:21 +01:00
Josh Creek 330f99bb0e docs(multiplayer): record the cancel-cascade fix in task 8.17 2026-09-01 14:40:48 +01:00
Josh Creek 79318b56bd feat(multiplayer): cascade a queue-ticket cancel into an open proposal
The last two commits fixed the severe stranding bug in decline and
timeout, but left a real responsiveness gap: cancelling a ticket
directly while it's part of an OPEN proposal used to leave the OTHER
participant waiting out the full response window for something the
system already knew couldn't happen -- their proposal partner just
abandoned the queue. ProposalExpireRequeueSQL eventually rescues them,
but only after the full window elapses, not immediately.

CascadeCancelToOpenProposal runs inside the same transaction as the
cancel itself: if the cancelled ticket belonged to a currently-OPEN
proposal, decline that proposal right now and requeue every other
participant immediately via the same ProposalDeclineRequeueSQL the
decline path already uses. The cancelling player's own ticket
correctly stays CANCELLED, not swept back into the requeue meant for
everyone else (ProposalDeclineRequeueSQL only touches tickets still at
PROPOSED).

Covered by a real PostgreSQL integration test: cancelling one
participant's ticket mid-proposal immediately declines the proposal
and requeues the other participant with a refreshed expiry, while the
cancelling player's own ticket stays CANCELLED. First draft used a
stale expected revision (0) for the cancel call -- CreateProposal's
own QueueTicketProposeSQL already bumps a ticket's revision to 1 when
forming the proposal, caught immediately by actually running the test
against real Postgres rather than assuming. Clean across 5 runs after
the fix, plus the full integration and unit suites.
2026-09-01 14:40:30 +01:00
Josh Creek fe0a0b72a8 docs(multiplayer): record the proposal-timeout requeue fix in task 8.17 2026-09-01 14:36:50 +01:00
Josh Creek 4627dd58fb fix(multiplayer): requeue every participant after a proposal times out
The timeout sibling of the previous commit's decline fix: a proposal
that simply times out (the 10s window elapses with no unanimous
response) hits ProposalExpireSQL/ProposalParticipantExpireSQL, and
neither of those -- same as the decline path -- ever touched
queue_tickets. Same severe consequence: every participant still
holding a PROPOSED ticket, response pending or already accepted, is
left stranded (invisible to the matcher, blocking a fresh
queue_create, renewable forever by heartbeat) with no automatic way
back into matchmaking. This path is reached from both GetProposal
(the recovery/read boundary -- a client that missed the expiry event
entirely) and RespondToProposal (a response arriving after the
window), so both needed the fix.

ProposalExpireRequeueSQL mirrors ProposalDeclineRequeueSQL, guarded on
state = 'EXPIRED' so it's safe to call unconditionally right after
ProposalExpireSQL: a no-op on a proposal that's still OPEN, and a
no-op on a proposal that was already EXPIRED on a prior pass (nothing
left at PROPOSED to requeue a second time).

Covered by a real PostgreSQL integration test via GetProposal (nobody
ever responds; recovering the proposal well after its window expires
it and must requeue both participants), confirming both tickets land
back at QUEUED with a refreshed expiry and are visible again to
ListQueuedCandidates. Clean across 5 runs, plus the full integration
and unit suites.
2026-09-01 14:36:34 +01:00
Josh Creek c8c363e667 docs(multiplayer): record the proposal-decline requeue fix in task 8.17 2026-09-01 14:33:46 +01:00
Josh Creek 6237a25a69 fix(multiplayer): requeue every participant after a proposal is declined
Found by reading the code, not a failing test: no path anywhere
transitioned a queue ticket from PROPOSED back to QUEUED after a
proposal was declined. A stranded PROPOSED ticket is invisible to the
matcher (ListQueuedCandidates only ever reads state='QUEUED'), still
counts as that player's one active ticket (blocking a fresh
queue_create), and is renewable forever by an ordinary heartbeat --
a player proposed a match with someone who then declines had no way
back into matchmaking without realising, on their own, that they
needed to manually cancel first. This affects every participant, not
just the decliner: an uninvolved player who never even responded was
left stuck by someone else's decision.

ProposalDeclineRequeueSQL requeues every participant's ticket,
including the decliner's own -- nothing yet enforces the decline
cooldown task 8.17 documents as a separate, not-yet-built feature, so
leaving anyone behind at PROPOSED today isn't "cooldown behaviour",
it's just broken. Once that cooldown exists it can exempt the
decliner from this immediate requeue; today nothing does.

Covered by a real PostgreSQL integration test: after one player
declines, both the decliner's and an uninvolved participant's tickets
land back at QUEUED with a refreshed expiry, and -- the actual
end-to-end regression -- both are visible again to
ListQueuedCandidates, the same query the matcher itself uses. Clean
across 5 runs, plus the full integration and unit suites.
2026-09-01 14:33:30 +01:00
Josh Creek 801a8487f5 docs(multiplayer): record the matcher crash-loop fix and pause on native Godot crashes
The two-player proposal integration attempt (Game/tests/control_plane_proposal_smoke.*,
scripts/verify_control_plane_proposal_integration.sh) is left uncommitted
on disk: it found the matcher crash-loop bug, but running it required two
simultaneous headless Godot processes, and this session had been
intermittently crashing the native Godot engine (confirmed by the user
to be caused by this session's testing, not unrelated activity) --
9 macOS crash reports today, clustered in a way that doesn't obviously
implicate only the concurrent-process case. Stopped launching further
Godot processes pending that investigation rather than keep reproducing
a crash to chase a test result.
2026-09-01 14:29:53 +01:00
Josh Creek 3168dd9897 fix(multiplayer): stop the matcher worker crashing on a routine no-match pass
Found building the two-player proposal integration test (next commit):
Worker.Run treated ANY RunOnce error as fatal to the whole loop,
including domain.FormFromQueue's "no compatible candidates" -- which
is not a failure, it's the completely routine and expected outcome of
a queue whose currently-waiting players don't share a verified region
yet. Two real players with no common region formed exactly this
shape, and the entire matcher process exited -- taking matchmaking
down for every OTHER player in the same playlist, not just the
incompatible pair, since cmd/matcher runs one process per playlist.
Worse: on a real supervisor restart, the same still-incompatible
candidates are still queued, so it would crash again immediately --
an actual crash loop, not a one-off.

RunOnce's own per-call contract (return an error for source failure,
bad formation, mixed playlist, an incomplete batch, a lost durable
claim) is deliberately tested and unchanged. The fix is entirely in
Run's loop: only the three genuinely static misconfiguration errors
(nil dependencies, unsupported playlist, invalid size -- true on every
future pass just as much as this one, so retrying can never help) now
stop it, via new exported sentinels (ErrWorkerNotConfigured,
ErrUnsupportedPlaylist, ErrInvalidMatcherSize) and errors.Is. Every
other RunOnce error is a single pass's worth of "no match formed this
time" and Run keeps polling.

Covered by two new tests: Run recovering from a first-pass error and
still forming a proposal once the pool becomes viable (a real
concurrent goroutine driving Run, not just calling RunOnce directly --
Run's own loop had no test coverage at all before this), and Run still
stopping immediately on a genuine configuration error. Both clean
across 3 runs with -race.
2026-09-01 14:26:19 +01:00
Josh Creek 01bb9a9431 docs(multiplayer): record the RankedProfileProvider fix and its coverage 2026-09-01 14:20:44 +01:00
Josh Creek 57fe6f4acc test(multiplayer): extend the real integration test to cover ranked profile fetch
Adds fetch_ranked_profile() right after login, asserting the expected
404 for a brand-new identity round-trips correctly through the real
RankedProfileProvider (previous commit) before proceeding to the
existing queue_create -> heartbeat -> cancel sequence. Verified stable
across 3 consecutive full runs.
2026-09-01 14:20:09 +01:00
Josh Creek a1ae36a54c fix(multiplayer): wire a durable RankedProfileProvider
Same discovery pattern as ResultSubmitter/SessionIssuer, one level
deeper: api.Service.rankedProfile and .profile both only ever read
from an in-memory RankedProfiles map with no durable-store equivalent
at all -- not "adapter exists but unwired" this time, there was no
adapter. Every real request to GET /v1/profile/ranked or /api/v1/profile
always 404'd regardless of a player's actual rating.

Add RankedProfileProvider (an interface, not a struct-literal adapter
this time) and store.PostgresRankedProfiles reading the ratings table;
Service.rankedProfileFor prefers it when set and falls back to the
map otherwise, so every existing test/direct Service literal keeps
compiling and passing unchanged. A missing ratings row maps to the
exact same (zero value, false, nil) the map lookup already produced,
preserving existing not-found semantics rather than reinterpreting
them. LastSeasonID/SeasonHistory are deliberately left unset -- the
ratings table has no season pointer, and reconstructing history needs
its own query and display semantics, not bundled in here speculatively.

Wired into both cmd/control-plane and cmd/testkit-api. Verified
against real PostgreSQL via curl: a fresh identity's ranked profile
correctly 404s through the real adapter (same behavior as before,
now for a real reason instead of an empty map).
2026-09-01 14:20:04 +01:00
Josh Creek 8a9972b6fc docs(multiplayer): record cancel_queue coverage in the integration test 2026-09-01 14:16:24 +01:00
Josh Creek f7657ad9ad test(multiplayer): extend the real integration test to cover queue cancel
Adds a fourth real round trip to control_plane_smoke.gd: heartbeat ->
cancel_queue -> CANCELLED, using the same idle-wait pattern the
heartbeat step already needed. Verified stable across 3 consecutive
full runs (real Postgres, real testkit-api, real headless Godot
client), plus the full Go and Godot unit suites clean.
2026-09-01 14:16:06 +01:00
Josh Creek 6edaedb50d docs(multiplayer): record the real Go+Postgres+Godot integration test and its findings 2026-09-01 14:14:37 +01:00
Josh Creek 521b8122ac test(multiplayer): add a real Go+Postgres+Godot end-to-end integration test
Every existing test of the client/control-plane boundary is either a
Go unit test with a mocked HTTP layer or a GDScript unit test with no
network at all (multiplayer-next.md 8.40's own evidence names "live
multi-process control-plane/game verification" as remaining). Nothing
before this actually ran the real compiled Go binary, a real
PostgreSQL instance, and a real headless Godot process talking real
HTTP to each other -- and it immediately found a real bug (previous
commit).

server/cmd/testkit-api is a new, deliberately separate, clearly-marked
test-only binary wired identically to cmd/control-plane except for
SteamLogin: cmd/control-plane has no way to authenticate against a
real Steam Web API from this sandbox (task 8.7's own documented
blocker), so testkit-api accepts any non-empty ticket string and
derives a deterministic identity instead. This bypass is confined to
its own binary -- never a flag on cmd/control-plane, never referenced
by any Dockerfile stage or Kubernetes manifest -- specifically so it
can't become a footgun on the real one.

Game/tests/control_plane_smoke.gd drives the real ControlPlaneClient
autoload through login -> queue_create -> heartbeat against a real
server and prints SMOKE PASS/FAIL, matching the existing net_smoke.gd
convention. scripts/verify_control_plane_integration.sh orchestrates
both sides (real postgres:17-alpine, the built testkit-api binary, the
Godot client) end to end.

Two real bugs surfaced building this, both fixed and re-verified, not
just the target bug: the smoke script's own use of `go run` left a
zombie process that survived cleanup and squatting on its port
corrupted the NEXT run with a misleading "http=401 unauthorized" (now
builds and runs a real binary directly, plus a belt-and-suspenders
port-kill in cleanup); and calling heartbeat() synchronously from
within a request_succeeded handler produced a spurious "Busy" because
ControlPlaneClient's own internal resync (see previous commit) was
still in flight -- the test now waits for ControlPlaneClient to go
idle via a real Timer (call_deferred alone floods the message queue
without ever yielding a frame for the in-flight request to complete).

Verified stable across 3 consecutive full runs: real PostgreSQL
container up, migrations applied, testkit-api built and started, real
headless Godot client round-tripping login/queue/heartbeat, clean
teardown with no leftover processes, containers, or bound ports each
time.
2026-09-01 14:13:52 +01:00
Josh Creek 8fa53b778c fix(multiplayer): stop an infinite queue-ticket resync loop at revision 0
Found via a new real end-to-end integration test (next commit), not by
inspection: a client that just called begin_queue() and receives the
server's first confirmation at the same revision (0) always treated
it as a conflict and requested a resync -- forever, since the resync
response is itself a same-revision confirmation hitting the exact same
false mismatch. A real Godot client against a real running server
would loop on GET /v1/queue/{id} without ever settling into QUEUED.

Root cause: apply_ticket_update()'s incoming_revision == revision
branch never adopts fields on acceptance, but _ticket_differs()
compared expires_at_unix -- a field begin_queue() has no way to set in
advance, since it doesn't know the server-assigned expiry yet. Every
first same-revision confirmation therefore looked like a conflict
unconditionally, not just occasionally.

Fix: exclude expires_at_unix from the conflict check (a differing
expiry at the same revision is expected, not a sign of corruption --
real conflicts are still caught via state/playlist), and adopt it on
acceptance so the field doesn't just become permanently stale instead.
The existing "same-revision conflict requests recovery" unit test
still passes unchanged: its fixture differs on `state`, not
expires_at_unix, so it was never actually exercising this bug.
2026-09-01 14:13:36 +01:00
Josh Creek e5c4e0b89a fix(multiplayer): wire SessionIssuer in the real control-plane binary
Same pattern and same discovery method as the ResultSubmitter fix:
store.PostgresSessions already implements SessionIssuer.Issue (used by
steamSession() to mint sessions) as well as SessionBackend.Authenticate
(used to verify them), and was already wired for the latter -- but not
the former, so /v1/session/steam always 503'd with auth_unavailable
even before considering whether SteamLogin (the real, still-correctly-
unwired Steam blocker) was available. Wire it: same struct value,
second field.

Not independently visible via a black-box HTTP test yet -- SteamLogin
still nil means the handler's first guard clause still 503s before
ever reaching SessionIssuer, so the observable symptom is unchanged
until Steam access exists. Verified by reading the handler's actual
branch order, not by a test that would currently pass for the wrong
reason.
2026-09-01 14:04:10 +01:00
Josh Creek 77c04a57a9 docs(multiplayer): record the WorkloadVerify gap as the real blocker for 8.9/8.10/8.28 2026-09-01 14:00:12 +01:00
Josh Creek 80a47d850e fix(multiplayer): wire the ResultSubmitter adapter; pin the deeper gap it exposed
Investigating the fleet.yaml wiring task found something more
fundamental than a manifest problem: cmd/control-plane/main.go never
wires WorkloadVerify, and store.PostgresResults (a ready-made,
already-correct ResultSubmitter adapter matching the interface
exactly) was referenced from nowhere outside its own file -- not even
a test. Both server-authenticated routes this session built
(/v1/servers/{id}/register and the pre-existing /result) are
completely unreachable in the actual running control-plane binary
today: Service.serverMutation treats a nil WorkloadVerify as fatal
for both routes regardless of ServerRegistrar/ResultSubmitter being
present, so every real request 503s.

Wire the safe, obviously-correct half: ResultSubmitter now uses
store.PostgresResults{DB: db}, same pattern as ServerRegistrar.

Deliberately NOT attempting a WorkloadVerify implementation here.
server/workload/jwt.go's ParseAndValidate needs a pre-known "expected"
WorkloadBinding to construct its policy against (itself needing a
durable per-allocation lookup that doesn't exist yet) plus a real
cryptographic SignatureVerifier -- which for a Kubernetes projected
service account token means either fetching/caching the cluster's own
JWKS or delegating to the API server's TokenReview endpoint, a
different verification model that doesn't fit ParseAndValidate's
signature-callback shape at all and would need its own domain-level
adapter. This is authentication-critical code with no existing
wiring example anywhere in the codebase to follow, and the actual
trust boundary (a live cluster's key material) can't be validated
from this sandbox regardless of how carefully the client code is
written. Building it fast under this session's already-heavy pace
risked a subtle, dangerous mistake far more costly than leaving the
gap named precisely, which is what this commit does instead.

Added TestServerRoutesRequireWorkloadVerifyToBeWired: pins the
current 503-on-every-request behavior as an explicit, visible
regression trip-wire rather than a silent gap -- it's designed to
start failing (and be updated, not deleted) the day WorkloadVerify is
actually wired.
2026-09-01 13:59:34 +01:00
Josh Creek bf160e4237 docs(multiplayer): record stalled-allocation reclaim, closing 8.28's health-reclaim item 2026-09-01 13:55:22 +01:00
Josh Creek fc2faf9723 feat(multiplayer): reclaim stalled allocations without penalising players
Closes the last named item on task 8.28 (Health-reclaim): nothing
currently detects or cleans up a match stuck in
ALLOCATING/PROCESS_READY/ASSIGNMENT_READY forever because its server
crashed or was reclaimed by Agones as unhealthy before ever
registering -- players would wait indefinitely for a match that was
never coming.

The design question this was blocked on -- does an abandoned match
auto-requeue its players, or fail and make them re-queue -- isn't
actually open: task 8.50's own stated acceptance criterion already
answers it ("infrastructure-caused cases cannot penalise affected
players"). A server-side crash/reclaim is exactly that, not player
behaviour, so store.ExpireStalledAllocations fails the match but
requeues every participant's ticket to QUEUED with a fresh expiry
(matching the ordinary 30s queue window), releases their
match_participants row (participation_active = false, so they're
matchable again immediately), all inside one FOR UPDATE SKIP LOCKED
pass so a second maintenance replica continues past whatever a
concurrent one is already reclaiming.

Wired into cmd/maintenance alongside the existing season-rollover
sweep: --stalled-allocation-deadline (default 2m) and
--stalled-allocation-batch (default 100).

Covered by a SQL-fragment test and a real PostgreSQL integration test:
two matches (one genuinely stalled, one recent), confirming the
deadline boundary is respected (recent match untouched), both
stranded participants' tickets requeue with a refreshed expiry, the
match_participants row releases, and a second pass doesn't reprocess
an already-FAILED match. Verified clean across 5 runs, plus the full
integration and unit suites.
2026-09-01 13:54:54 +01:00
Josh Creek d8245047a9 docs(multiplayer): record the stdout-buffering fix, closing 8.28's detached-container item 2026-09-01 13:50:36 +01:00
Josh Creek c0a1ef94f0 fix(server): force line-buffered stdout so a detached server actually logs
Godot's stdout is fully (block) buffered whenever it isn't attached to
a TTY -- true of every real deployment path this repo documents:
'docker run -d' (Docker's log driver presents a pipe), a plain
'docker run' without -d, and systemd's journal capture (also a pipe).
Confirmed directly, not from the existing gotcha note alone: a real
'docker run -d' container sat for 20+ seconds with 'docker logs'
showing nothing at all -- not even the startup line -- while the
process was confirmed alive and running (ps aux inside the container).
'docker stop' then killed it via SIGTERM (Godot has no SIGTERM hook)
without ever flushing that buffered output, losing it permanently
rather than merely delaying it.

This affects the already-shipped community server path today, not
just the not-yet-built Agones fleet path multiplayer-next.md's task
8.28 gotcha originally flagged this for -- SERVER.md's Docker AND
native-systemd instructions both route through this exact launcher
script, and journald's capture has the same non-TTY-pipe buffering
problem docker logs does.

Wrap the exec in 'stdbuf -oL -eL' (LD_PRELOAD-based line buffering,
touches no binary) when available, falling back to the unwrapped exec
otherwise so a minimal image without GNU coreutils still starts.
Re-verified the same failing scenario against the actual launcher
script in a real image: the startup line now appears within 3s of a
genuinely detached 'docker run -d'. Re-ran the full make verify-phase6
gate end to end afterward to confirm no regression: both arenas
rotated, both clients observed both goals, clean teardown.
2026-09-01 13:50:09 +01:00
Josh Creek 21c57a6d22 docs(multiplayer): record assignment-ready reporting in task 8.28 2026-09-01 13:44:53 +01:00
Josh Creek 4cad0f0cce feat(multiplayer): report assignment-ready from the supervisor
Closes the second blocker named last commit. Re-traced the actual code
path rather than trusting the earlier assumption: server_boot.gd
verifies its mounted roster file synchronously in _ready(), before
NetworkManager.host() runs and before ServerControl.set_process_ready
is ever called -- so by the time the loopback /ready probe (and thus
Agones Ready, and thus process-ready registration) succeeds, Godot has
already verified its own roster. And the API's ASSIGNMENT_READY gate
(AdvanceServerRegistrationSQL) checks only durable `assignments` rows
server-side, nothing Godot reports. No new Godot-side state was needed
-- the earlier 'needs Godot's own roster-verification state exposed'
claim was overcautious and is corrected here.

The supervisor now calls registerControlPlane(ctx, true) right after
process-ready succeeds, with a bounded retry (default 5 attempts, 2s
apart, both configurable) rather than a single attempt: the durable
`assignments` rows the server-side gate checks may not have propagated
by the first attempt, and that is expected, not fatal. Unlike a
process-ready registration failure, a persistent assignment-ready
failure does NOT kill the child -- the process is already legitimately
listening and usable, and killing a healthy process over a lagging
control-plane read would be actively harmful; it's logged to stderr
instead.

Covered by two tests: the full process-ready-then-assignment-ready
sequence and body shapes, and a retry test that fails the assignment-
ready call twice with 409 (simulating the real gate not yet
satisfied) before succeeding on the third attempt, asserting Start()
still succeeds and the child is never killed.
2026-09-01 13:44:27 +01:00
Josh Creek 4c2ade3930 docs(multiplayer): fix malformed 8.28 table row and record this session's work
An earlier edit this session (commit 4278c04d) had appended new
content to the row's evidence column using the same duplicated-prefix
text twice with an extra '|' between them, silently turning a 3-column
markdown table row into 4 columns -- caught only by literally counting
pipe characters, not by reading the rendered text. Rewritten as a
clean 3-column row: task description, then evidence, deduplicated.

Also records the game-server Docker image, the allocation-annotation
match-ID channel, and the dead ubuntu digest fix from this session's
commits.
2026-09-01 13:40:17 +01:00
Josh Creek 14e1e62deb feat(multiplayer): add game-server Docker image with the supervisor as PID 1
New additive targets, the existing server/exporter/smoke-client/enet-test
targets are byte-for-byte unchanged (make verify-phase6 re-run clean
after this, see previous commit):

  - supervisor-build: builds server/cmd/game-server-supervisor with a
    pinned golang:1.23-alpine (matching go.mod's go 1.23)
  - game-server: the same dedicated-server export as `server`, plus the
    supervisor binary, with the supervisor as ENTRYPOINT instead of the
    direct launcher script -- this is what makes the process-ready/
    assignment-ready control-plane registration from the last two
    commits actually reachable in a real deployment.

Verified with a real docker build --target game-server, not just by
reading the file: both binaries land at the expected paths with
correct permissions, and the supervisor prints its usage text when
run directly.

deploy/k8s/base/fleet.yaml does not reference this image or invoke the
supervisor's flags yet -- documented inline and in
multiplayer-next.md; that's the next concrete step, not attempted here
since the exact flag values (control-plane URL, workload-token mount
path) are deployment-environment decisions this sandbox can't make.
2026-09-01 13:38:19 +01:00
Josh Creek 9b26f867bc fix(docker): re-pin dead ubuntu base image digest
The server stage's ubuntu@sha256:571c2ab1... no longer resolves from
Docker Hub (docker pull by that exact digest returns 'not found',
verified directly, independent of anything in this Dockerfile) --
make verify-phase6 was currently broken for anyone building from a
clean cache. Found while adding a new build stage below it and
actually running the build rather than just editing the file.
Re-pinned to a digest verified to pull, and re-ran the full
make verify-phase6 gate end to end to confirm: both arenas rotated,
both headless clients observed both authoritative goals, clean
teardown.
2026-09-01 13:38:07 +01:00
Josh Creek fd18cf6ac0 feat(multiplayer): propagate match ID to an already-allocated pod via annotations
Investigated the Fleet-manifest wiring task flagged last commit and
found a deeper, previously-undesigned gap: Kubernetes env vars are
fixed at pod creation, but Agones allocates a match to an already-
running Ready pod well after it starts -- so there was no channel at
all for match-specific data (match ID) to reach that pod's processes.

Close it using the Agones GameServerAllocation API's documented
spec.metadata.annotations field, which Agones applies to the allocated
GameServer's own object_meta on success: server/agones.Client.Allocate
now requests cosmic-clash.io/match-id and cosmic-clash.io/allocation-id
annotations, and the supervisor reads them back from the same
/gameserver SDK call it already makes for the assigned port/address
(GameServer.ObjectMeta.Annotations), falling back to them for its own
control-plane registration only when MatchID isn't explicitly
configured -- an explicit value always wins, and a match ID resolvable
from neither source fails Start() closed before any HTTP call.

The exact object_meta vs objectMeta JSON key from a live Agones SDK
sidecar is not independently verified from this sandbox; documented
inline, and the fallback degrades safely (empty annotations map, same
as before this change) if it turns out to be wrong.

Covered by two new tests: the annotation actually flowing through to
the registration body, and fail-closed with neither config nor
annotation supplying a match ID (registerCalled stays false, not just
that Start() errors).
2026-09-01 13:34:08 +01:00
Josh Creek 4278c04d60 docs(multiplayer): record supervisor control-plane registration in task 8.28
Also records the concrete gap this surfaced: the Fleet manifest never
actually invokes game-server-supervisor today, so the new capability
has no deployment wiring yet -- and names exactly what's needed
(entrypoint/sidecar decision, token volume, Downward API env vars)
rather than leaving it as an unspecified 'remains'.
2026-09-01 13:28:00 +01:00
Josh Creek 0bad9e07db feat(multiplayer): report process-ready to the control plane from the supervisor
Add opt-in control-plane registration to server/supervisor: once Agones
Ready succeeds, POST /v1/servers/{id}/register (assignment_ready=false)
using a workload token read fresh from disk each call -- matching how a
Kubernetes projected service account token is rotated in place by
kubelet, unlike a cached/env-var secret. ControlPlaneURL empty (the
default) is a total no-op, so direct/Compose mode and allocated-without-
control-plane mode are both byte-for-byte unaffected; New() rejects a
half-configured registration (URL set without token path/server/match/
digest) rather than silently skipping it.

ServerID/MatchID/ImageDigest are read from env vars named by CLI flags
(--server-id-env, --match-id-env, --image-digest-env), matching the
existing --drain-token-env convention in this same binary, rather than
parsed out of the Agones SDK's own GameServer JSON -- that shape isn't
independently verifiable from here, whereas the Kubernetes Downward API
(fieldRef: metadata.name) populating an env var is a standard, safe
pattern already used elsewhere in this codebase for exactly this class
of secret.

A registration failure now kills the child (matching the existing
waitReady failure path) rather than leaving Agones-Ready-but-
control-plane-unregistered process running -- a real gap the second new
test (TestControlPlaneRegistrationFailureKillsChildRatherThanRunningUnregistered)
had to be corrected to actually exercise: its first draft omitted
ReadyURL and was failing at waitReady, before ever reaching the code
path it claimed to test.
2026-09-01 13:27:43 +01:00
Josh Creek 8b76f80b5c docs(multiplayer): record the concurrent queue-heartbeat race test in 8.14/8.46 2026-09-01 13:22:18 +01:00
Josh Creek 270ab00c52 test(multiplayer): add real concurrent queue-heartbeat revision race test
Races 5 concurrent HeartbeatQueueTicket calls, all at the same expected
revision, against a real PostgreSQL instance -- a genuinely plausible
client scenario (slow-response retry, duplicate tab/process), and one
the existing sequential stale-revision test can't exercise since it
only calls the second heartbeat after the first has already
committed. Asserts exactly one wins, the durable revision ends at
exactly 1 (not higher, which a stale winner slipping through would
produce), and every loser fails cleanly rather than hanging or
returning a raw serialization error. Stable across 6 runs with -race,
plus the full integration and unit suites.
2026-09-01 13:21:59 +01:00
Josh Creek d9e806ed27 docs(multiplayer): record real-Redis integration coverage in 8.14/8.46 2026-09-01 13:20:39 +01:00
Josh Creek 17bd7768eb test(multiplayer): add real-Redis integration coverage for the candidate index
Every existing RedisCandidateIndex test runs against miniredis -- a
from-scratch Go reimplementation of the Redis command set, not real
Redis's own float64 score encoding, TTL/expiry, or RESP behavior.
Add an opt-in integration suite (mirroring postgres_integration_test.go's
pattern: //go:build integration, COSMIC_CLASH_REDIS_ADDR-gated) plus
scripts/run_redis_integration.sh against a disposable redis:7-alpine
container, covering:

  - upsert/snapshot/remove against a real server
  - a real TTL actually waited out (not miniredis's manual
    FastForward), proving expiry really happens on the wire
  - the documented 'Redis restart or lost keyspace' repair path
    exercised against an actual FLUSHALL, not a simulated empty map,
    including that the repair actually persists back to Redis (a
    second snapshot reads it without a second durable-source call)

Verified stable across 3 runs with -race against a real container.
2026-09-01 13:20:09 +01:00
Josh Creek bf64cce947 docs(multiplayer): record the concurrent result-submission test in 8.21/8.25 2026-09-01 13:18:12 +01:00
Josh Creek caece00e7f test(multiplayer): add real concurrent result-submission rating test
Races 5 concurrent identical CompleteResultWithResult calls for the
same RESULT_PENDING match against a real PostgreSQL instance -- the
scenario behind 8.25's 'identical duplicates idempotent' claim, which
every existing result test only exercised sequentially. All five must
succeed (idempotent replay, not conflict), and the rating update must
apply exactly once: asserted by computing the expected post-match
rating independently via the same domain.CasualOpponents/UpdateRating
functions and requiring an exact match, since a doubled application
would compound the rating further away from baseline rather than
merely producing 'some' change that a looser inequality check would
miss.

First draft asserted ranked_games == 1, which doesn't hold for a
casual result (rankedIncrement is unconditionally 0 for casual by
design) -- caught by actually running it, not by inspection. Verified
clean across 6 runs with -race, plus the full integration and unit
suites.
2026-09-01 13:17:58 +01:00
Josh Creek dbdfaa5d9e docs(multiplayer): record the concurrent allocation-claim test in 8.30 2026-09-01 13:14:56 +01:00
Josh Creek 42ac34ca70 test(multiplayer): add real concurrent allocation-claim integration test
Fires more concurrent ClaimAllocation calls than there is Ready
capacity at a real PostgreSQL instance and asserts: exactly as many
win as there was capacity, every winner gets a distinct server (no
double-booking), every loser gets ErrNoCapacity rather than a raw
serialization error or a hang, and the durable game_servers.state
count matches. This is the cross-allocator-replica race 8.30 calls
out as untested -- the existing capacity test in this file claims
strictly one request at a time. Verified clean across 6 runs with
-race, plus the full integration and unit suites.
2026-09-01 13:14:46 +01:00
Josh Creek ea6939e18d docs(multiplayer): record the concurrent proposal-claim test in 8.18/8.46 2026-09-01 13:13:32 +01:00
Josh Creek 63eb43d50a test(multiplayer): add real concurrent proposal-claim integration test
Every existing Postgres integration test runs strictly one transaction
at a time, so none of them exercise the SERIALIZABLE retry-and-fence
path CreateProposal actually depends on for correctness under real
matcher-replica contention -- only concurrent goroutines against a
real connection can. Add a test that races two goroutines each
proposing a formation that shares one contested ticket (a realistic
scenario: nothing stops two matcher replicas reading the same QUEUED
ticket in the same poll window), and asserts exactly one proposal
commits, the loser's proposal and participant rows are fully rolled
back, the contested ticket ends up claimed by the winner, and -- the
part a single-threaded test can't show -- the loser's OWN uncontested
ticket also rolls back to QUEUED rather than being left stranded as
PROPOSED with no surviving proposal.

Adversarial review of my own first draft: it initially failed
deterministically (5/5 runs), but the failure was in the test itself
-- the winner/loser branch picking the loser's uncontested ticket had
the two branches swapped, so it was checking the WINNER's ticket
against the QUEUED expectation. Fixed and re-verified clean across 8
runs with -race, plus the full integration suite.
2026-09-01 13:13:16 +01:00
Josh Creek e9d9f59a6a docs(multiplayer): record queue/proposal event logging in task 8.44 2026-09-01 13:10:23 +01:00
Josh Creek 4eaa3304c3 feat(multiplayer): extend event logging to queue and proposal mutations
Wire the same Service.Log hook added for the server register/result
routes into queue create/heartbeat/cancel and proposal accept/decline:
log the resulting state on success (queue_create, queue_heartbeat,
queue_cancel, proposal_response) or 'rejected' on a domain error,
using only the ticket/proposal ID and outcome -- never the domain
error text itself, which isn't documented as credential-free.

Read-only routes (queue GET, proposal GET, assignment fetch) and the
early availability/not-found rejections that return before reaching
the domain call are deliberately not logged in this pass.

Covered by a new end-to-end test driving real create/heartbeat/cancel
and an accept followed by a stale-revision accept (fenced for real by
the domain layer behind proposalBackendSpy, unlike the dumb queue
spy), asserting the exact sequence of events logged.
2026-09-01 13:10:08 +01:00
Josh Creek 1490ff7fcf docs(multiplayer): record observability wiring in task 8.44 2026-09-01 13:07:35 +01:00
Josh Creek 3817df2a12 feat(multiplayer): wire structured event logging into server routes
server/observability existed fully unit-tested but was imported by
nothing outside its own package -- no HTTP handler ever called it, so
its credential redaction protected zero real log output. Wire it into
Service via an optional Log field (nil-safe, so every existing Service
literal keeps compiling unchanged) and call it from the two
workload-authenticated server routes -- register and result -- at
every outcome: unauthorized, rejected, conflict and success. Wire
cmd/control-plane to actually emit those events as JSON lines on
stderr.

Add a secret canary test that drives both routes end to end with
realistic bearer-token and result-nonce values and asserts neither
literal secret appears anywhere in what Service.Log actually received
-- a stronger claim than the existing observability unit test, which
only proves redact() strips a synthetic value under a denylisted key
name. redact() is still key-name-based, not content-based: a future
call site that logs a secret under an unlisted key name would not be
caught by this test or by redact() itself, only by the same discipline
applied here of never putting raw request/token bytes into Fields.

Queue, proposal and assignment mutation routes are not wired yet.
2026-09-01 13:07:23 +01:00
Josh Creek 06a4ea0a02 docs(multiplayer): record live Postgres verification and the bugs it found
Tasks 8.5, 8.14, 8.18 and 8.23 all claimed opt-in PostgreSQL execution
already covered their durable paths. It existed, but per this session's
run had apparently never actually been exercised clean: it surfaced
three real bugs (queue ticket insert param-count mismatch, an
unconditional proposal-participant timeout, a missing seasons row in
one integration test) that a passing unit-test suite could not have
caught, since the unit tests mock the driver. Record what was found,
fixed and re-verified live, and that the new migration rollback runner
is now in place.
2026-09-01 12:45:27 +01:00
Josh Creek 66d114bfe3 test(server): seed a seasons row for the ranked rollover integration test
ranked_season_rollovers.season_id has a foreign key into seasons, but
the integration test never inserted a seasons row for 'season-1' --
ApplyRankedSeasonRollover failed on the FK constraint before the
rollover logic itself ran at all. Insert a matching seasons row,
mirroring how a real 12-week season would already exist when
maintenance's rollover sweep runs. Verified against a real
PostgreSQL instance.
2026-09-01 12:44:48 +01:00
Josh Creek 6d3490da14 fix(server): gate proposal participant timeout on actual expiry
ProposalParticipantExpireSQL marked every PENDING participant on a
proposal TIMED_OUT unconditionally -- it took a proposal_id and 'now'
but never actually compared 'now' against the proposal's expires_at,
unlike its sibling ProposalExpireSQL (which does gate on
'expires_at <= $2'). Both GetProposal and RespondToProposal run this
statement on every call as a recovery step, so the very first
RespondToProposal for any proposal timed out every participant
(including the one about to respond) before checking their response,
then rejected the real accept/decline with ErrConflict. Add the same
expiry gate via an EXISTS against proposals.expires_at, matching
ProposalExpireSQL's own condition, and update the SQL-fragment test to
assert the gate is present. Verified end to end against a real
PostgreSQL instance: TestPostgreSQLProposalClaimAndResponseAreAtomic
now passes a two-participant accept/accept sequence that previously
failed on the first response.
2026-09-01 12:44:43 +01:00
Josh Creek e23243ff56 fix(server): drop extra unused argument in queue ticket insert
CreateQueueTicket passed 9 arguments to QueueTicketInsertSQL, which
only has 8 placeholders (state is a hardcoded 'QUEUED' literal in the
SQL, not $4) -- every real queue-ticket creation against PostgreSQL
failed with 'mismatched param and argument count'. Found by actually
running the opt-in Postgres integration suite (previously never
exercised locally, per its own gating) rather than trusting the unit
tests, which mock the driver and can't catch a placeholder-count
mismatch. Verified fixed against a real postgres:17-alpine container.
2026-09-01 12:44:36 +01:00
Josh Creek 67609d71c0 feat(multiplayer): add down migrations and a rollback runner
Add migrations.Rollback(ctx, db, dir, steps): reverses the N most
recently applied migrations, newest first, each in its own committed
transaction under the same advisory lock Apply uses. Down SQL lives in
migrations/down/<version>.sql (a subdirectory, so Apply's *.sql glob
over the main directory is untouched); a missing down file for a
migration being rolled back is a hard error rather than a silent
partial reversal. Wire it into cmd/migrate as --rollback=N.

Add down files for all six existing migrations, each dropping objects
in FK-safe reverse dependency order.

Adversarial review: could not run the new integration test
(TestPostgreSQLMigrationsRollBackAndReapplyCleanly, gated behind
COSMIC_CLASH_POSTGRES_DSN / scripts/run_postgres_integration.sh)
against a real database in this sandbox - Docker Desktop's own
overlayfs ran out of space pulling postgres:17-alpine, unrelated to
this change. Verified instead by hand-tracing every DROP against its
forward migration's FK graph, confirming Apply's directory glob does
not pick up the down/ subdirectory, and a clean go build/vet/test
-tags integration. Worth an explicit real run before this is trusted
in CI.
2026-09-01 12:41:30 +01:00
Josh Creek 7e5cfdeceb style(server): gofmt allocation_match_sql_test.go 2026-09-01 12:41:18 +01:00
Josh Creek 99df7e20e0 docs(multiplayer): record server registration API in task 8.28 2026-09-01 12:36:04 +01:00
Josh Creek d937cb153c feat(multiplayer): add server process/assignment-ready registration API
Add POST /v1/servers/{id}/register (and its /api/v1 contract alias),
authenticated by the same workload binding as the result route. A
game server reports its protocol version and image digest and asks
to advance ALLOCATING -> PROCESS_READY -> ASSIGNMENT_READY; the store
boundary (AdvanceServerRegistration) does this as one idempotent
SERIALIZABLE transaction that also advances every participant's queue
ticket, and gates the final transition on every participant having a
live, unexpired assignment.

Adversarial review of the surrounding routing turned up a pre-existing
bug: contractServerMutation rejected any path containing '/', so the
already-documented /api/v1/servers/{id}/result route (and this new
/register route) 404'd for every real caller despite being declared
in the OpenAPI contract. Fix it to delegate shape validation to
serverMutation, matching how contractQueueMutation handles its own
two-segment paths, and add a regression test covering both contract
routes end to end.
2026-09-01 12:35:30 +01:00
Josh Creek 4fb7ddfecf docs(multiplayer): consolidate tracking into one document
multiplayer-todo.md and multiplayer-next.md tracked overlapping
information in two places. Fold everything into multiplayer-next.md
(architecture decisions, wire format, task breakdown with checkboxes,
gotchas list, testing notes) and delete multiplayer-todo.md. Section
numbers are unchanged, so existing code comments citing them by
section/task number still resolve; update every such reference to
point at the new filename.
2026-09-01 12:32:43 +01:00
Josh Creek 9dc1cc2d6f feat(multiplayer): refresh allocator ready servers 2026-09-01 10:44:43 +01:00
Josh Creek 013eb0778b fix(multiplayer): advance tickets on allocation bind 2026-09-01 10:42:10 +01:00
Josh Creek 25fdc2c2c8 fix(multiplayer): recover recorded allocations 2026-09-01 10:40:10 +01:00
Josh Creek 55c46f56ec feat(multiplayer): run leased allocator worker 2026-09-01 10:38:45 +01:00
Josh Creek 6f7d61eafb feat(multiplayer): lease allocating match claims 2026-09-01 10:36:13 +01:00
Josh Creek 03ff8e485e feat: promote accepted proposals from API 2026-09-01 10:29:50 +01:00
Josh Creek e0ba6c6ead fix: enforce ranked match promotion size 2026-09-01 10:22:26 +01:00
Josh Creek 7807b9706b feat: promote accepted proposals into matches 2026-09-01 10:21:41 +01:00
Josh Creek 11599889fa feat: gate allocation on accepted proposals 2026-09-01 10:18:22 +01:00
Josh Creek 2d750cbcab feat: enable guarded ranked matcher role 2026-09-01 10:14:22 +01:00
Josh Creek d882469c79 feat: repair matcher candidates through redis projection 2026-09-01 10:11:54 +01:00
Josh Creek 7a3d520608 fix: bind rated result to durable receipt 2026-09-01 10:09:40 +01:00
Josh Creek dd80b52a11 feat: apply certified ratings during result completion 2026-09-01 10:08:11 +01:00
Josh Creek 388300c553 fix: validate durable result submissions 2026-09-01 10:02:29 +01:00
Josh Creek eebab1bc19 feat: add workload-authenticated result API 2026-09-01 10:01:04 +01:00
Josh Creek a70a0ebc74 feat: add projected workload JWT adapter 2026-09-01 09:57:33 +01:00
Josh Creek febc69bdef feat: gate allocator roster publication 2026-09-01 09:55:09 +01:00
Josh Creek 8b5b5333c6 fix: verify signed assignment rosters 2026-09-01 09:53:45 +01:00
Josh Creek 55e07648cf test: cover provider allocation reconciliation SQL 2026-09-01 09:52:00 +01:00
Josh Creek 0023bdab6e feat: reconcile Agones allocations durably 2026-09-01 09:51:52 +01:00
Josh Creek 931e51a647 feat: add Agones allocation client 2026-09-01 09:49:09 +01:00
Josh Creek 4bbaf0976f feat: add ranked season maintenance role 2026-09-01 09:46:57 +01:00
Josh Creek e729570010 test: cover allocator PostgreSQL claims 2026-09-01 09:45:02 +01:00
Josh Creek b1966a3423 feat: add durable allocator claim boundary 2026-09-01 09:43:33 +01:00
Josh Creek 7803e1ec7c feat: wire control plane probe recorder 2026-09-01 09:40:35 +01:00
Josh Creek def60169a8 feat: persist authenticated queue probe RTT 2026-09-01 09:40:25 +01:00
Josh Creek 25cc182793 feat: persist queue probe metadata 2026-09-01 09:37:47 +01:00
Josh Creek e170bcf0ef fix: bind matcher source to playlist 2026-09-01 09:35:00 +01:00
Josh Creek d0952adaf9 feat: add runnable casual matcher role 2026-09-01 09:32:22 +01:00
Josh Creek bdc89303b4 feat: add durable matcher worker orchestration 2026-09-01 09:30:28 +01:00
Josh Creek 18538e833b feat: wire API queue projection to Redis 2026-09-01 09:28:49 +01:00
Josh Creek fe9f6f3cb5 feat: add runnable control-plane API role 2026-09-01 09:24:05 +01:00
Josh Creek 18888ed520 feat: add PostgreSQL migration runner 2026-09-01 09:21:26 +01:00
Josh Creek 4d83ec1525 feat: add signal-bound server supervisor command 2026-09-01 09:19:25 +01:00
Josh Creek 58508bd87c feat: bound supervisor drain termination 2026-09-01 09:18:19 +01:00
Josh Creek e7c835af52 feat: add Godot Agones REST bridge 2026-09-01 09:16:41 +01:00
Josh Creek 6caf719158 feat: add allocated server readiness control 2026-09-01 09:12:10 +01:00
Josh Creek 5b80c97337 test: cover PostgreSQL season rollover 2026-09-01 09:06:10 +01:00
Josh Creek bd26aa3dc4 test: cover PostgreSQL result and outbox flow 2026-09-01 09:05:15 +01:00
Josh Creek 9d210d254a test: cover PostgreSQL proposal transactions 2026-09-01 09:04:16 +01:00
Josh Creek c8a542a3af feat: repair Redis candidates from durable source 2026-09-01 09:02:55 +01:00
Josh Creek eadaa7b54e feat: add rebuildable Redis candidate index 2026-09-01 09:01:32 +01:00
Josh Creek 414df530f0 test: expand PostgreSQL adapter coverage 2026-09-01 08:59:39 +01:00
Josh Creek 5234390931 test: add PostgreSQL integration harness 2026-09-01 08:58:28 +01:00
Josh Creek eb8eecf080 docs: record backend vet verification 2026-09-01 08:53:32 +01:00
Josh Creek 3aad68a6e6 feat: fence expired allocated reconnects 2026-09-01 08:52:12 +01:00
Josh Creek e25d61d80e feat: verify allocated join authorisations with hmac 2026-09-01 08:48:48 +01:00
Josh Creek 66a1ee8007 docs: record backend fuzz coverage 2026-09-01 08:43:06 +01:00
Josh Creek 172775c141 docs: record backend race suite 2026-09-01 08:42:13 +01:00
Josh Creek 4b01b7fc88 fix: prevent concurrent join token reuse 2026-09-01 08:41:38 +01:00
Josh Creek 68d832f7bc feat: enforce allocated join roster admission 2026-09-01 08:39:56 +01:00
Josh Creek d8ea2f4ac7 feat: connect clients from validated assignments 2026-09-01 08:37:00 +01:00
Josh Creek 82bb9baec6 docs: record packet-loss prediction verification 2026-09-01 08:34:35 +01:00
Josh Creek 9848f4b92d docs: record impaired-link prediction verification 2026-09-01 08:33:57 +01:00
Josh Creek aeb37a4c6e fix: ignore pre-history prediction acknowledgements 2026-09-01 08:32:44 +01:00
Josh Creek afcb01d155 fix: guard match simulation after transport shutdown 2026-09-01 08:27:56 +01:00
Josh Creek c5678ce877 fix: pass Godot 4.7 multiplayer test suite 2026-09-01 08:24:33 +01:00
Josh Creek 76aad61191 fix: update Godot websocket handshake API 2026-09-01 08:21:05 +01:00
Josh Creek 467c25b20c feat: expose validated assignment endpoints 2026-09-01 08:17:39 +01:00
Josh Creek 0cdb60c0d9 fix: preserve assignment event revisions 2026-09-01 08:13:35 +01:00
Josh Creek 23134796ee feat: add durable outbox dispatcher 2026-09-01 08:12:33 +01:00
Josh Creek fec4f91672 feat: bind signed rosters to assignments 2026-09-01 08:10:05 +01:00
Josh Creek 5b40bf9066 fix: fail closed for unsupported allocated transport 2026-09-01 08:08:43 +01:00
Josh Creek 63f4f11aa8 feat: publish assignment rosters atomically 2026-09-01 08:08:00 +01:00
Josh Creek f520584368 fix: expire proposals on response boundary 2026-09-01 08:06:17 +01:00
Josh Creek eef7cf28da feat: make proposal responses durable 2026-09-01 07:52:52 +01:00
Josh Creek 30b4560bd5 feat: persist participant-scoped proposal recovery 2026-09-01 07:50:11 +01:00
Josh Creek 1eb6e8f9f4 feat: wire durable assignments into API 2026-08-31 23:20:25 +01:00
Josh Creek 3ae2daec88 feat: persist player-scoped assignments 2026-08-31 23:19:23 +01:00
Josh Creek 7f7516d9a0 feat: add bounded control plane rate limiting 2026-08-31 23:17:02 +01:00
Josh Creek 3533e8ae6c feat: consume assignment matchmaking events 2026-08-31 23:15:42 +01:00
Josh Creek fe246bf54e feat: add replayable outbox adapter 2026-08-31 23:14:33 +01:00
Josh Creek 4b82347d43 docs: align transport architecture with control plane 2026-08-31 23:12:46 +01:00
Josh Creek 60fe2caf8f feat: publish matchmaking state events 2026-08-31 23:12:20 +01:00
Josh Creek 161d2cdceb fix: validate matchmaking event vocabulary 2026-08-31 23:10:31 +01:00
Josh Creek a4bc8cdac8 fix: close slow event subscribers safely 2026-08-31 23:09:16 +01:00
Josh Creek 0be51ab902 fix: reconnect matchmaking event stream 2026-08-31 23:08:24 +01:00
Josh Creek a16db39884 feat: connect Godot matchmaking event stream 2026-08-31 23:06:44 +01:00
Josh Creek d258f17852 feat: add authenticated matchmaking event stream 2026-08-31 23:05:04 +01:00
Josh Creek d77563147c test: expand offline multiplayer failure matrix 2026-08-31 23:01:34 +01:00
Josh Creek fcc5d82763 feat: expose documented control plane routes 2026-08-31 23:00:22 +01:00
Josh Creek 6b3db98327 fix: bind assignments to authenticated player 2026-08-31 22:55:22 +01:00
Josh Creek 69f1ef6be1 feat: add player-scoped assignment recovery 2026-08-31 22:54:16 +01:00
Josh Creek 99680dbf6f fix: bind queue idempotency to compatibility 2026-08-31 22:51:00 +01:00
Josh Creek 585056ccc3 feat: add Godot Steam session login 2026-08-31 22:49:05 +01:00
Josh Creek e1807d2492 fix: handle matchmaking session expiry 2026-08-31 22:47:31 +01:00
Josh Creek 3e94960f0a fix: retry queue creation idempotently 2026-08-31 22:46:45 +01:00
Josh Creek 222bbb5b84 feat: classify matchmaking recovery failures 2026-08-31 22:45:20 +01:00
Josh Creek fddbebb33b feat: display backend ranked profile 2026-08-31 22:44:21 +01:00
Josh Creek 62c1478913 feat: persist matchmaking recovery state 2026-08-31 22:42:43 +01:00
Josh Creek 66a67c931d feat: add participant-scoped proposal recovery 2026-08-31 22:41:26 +01:00
Josh Creek 550d73f1d7 fix: preserve active matchmaking on transient errors 2026-08-31 22:39:42 +01:00
Josh Creek 47550aefae feat: add matchmaking queue UI 2026-08-31 22:39:29 +01:00
Josh Creek 5106ac64da feat: add authenticated matchmaking control client 2026-08-31 22:36:05 +01:00
Josh Creek 39ebfce1bb feat: add revisioned matchmaking client state 2026-08-31 22:33:33 +01:00
Josh Creek 2379c154a5 docs: track Steam browser dependency 2026-08-31 22:30:42 +01:00
Josh Creek 5265b1738b docs: record transport feature-gate verification 2026-08-31 22:29:24 +01:00
Josh Creek 4d3a8cc217 feat: add ban policy to ticket verification 2026-08-31 22:28:45 +01:00
Josh Creek 8253d772cb feat: add verified Steam session endpoint 2026-08-31 22:26:27 +01:00
Josh Creek a2a7107dd7 feat: wire durable session authentication into API 2026-08-31 22:24:51 +01:00
Josh Creek b3284d4bd6 feat: persist authenticated sessions 2026-08-31 22:23:59 +01:00
Josh Creek ae164e625a fix: expire abandoned auth attempts 2026-08-31 22:22:33 +01:00
Josh Creek 9fe6c57b5d feat: model asynchronous Steam auth sessions 2026-08-31 22:21:31 +01:00
Josh Creek 1a43a342ea test: verify persistent queue API delegation 2026-08-31 22:18:59 +01:00
Josh Creek 59cf29949f feat: wire persistent queue backend into API 2026-08-31 22:17:32 +01:00
Josh Creek b2d68d93cf fix: make SQL queue recovery expire authoritatively 2026-08-31 22:14:30 +01:00
Josh Creek 068b66b234 fix: bind proposal claims to playlist 2026-08-31 22:13:37 +01:00
Josh Creek e37a519ef8 fix: bind proposal claims to players 2026-08-31 22:12:47 +01:00
Josh Creek a45d2ddbb7 fix: rebuild candidate cache from queue authority 2026-08-31 22:12:03 +01:00
Josh Creek 4c131db2ca docs: track durable queue adapters 2026-08-31 22:11:06 +01:00
Josh Creek 948d603c0e feat: persist queue heartbeat and cancel mutations 2026-08-31 22:10:36 +01:00
Josh Creek 9263133e64 feat: add durable queue ticket repository 2026-08-31 22:08:45 +01:00
Josh Creek 3b208ae860 fix: bind queue candidates to their owner 2026-08-31 22:05:44 +01:00
Josh Creek b47c7dc3fa fix: enforce matchmaking compatibility boundaries 2026-08-31 22:04:57 +01:00
Josh Creek b1314074a8 docs: reflect multiplayer foundation status 2026-08-31 22:03:00 +01:00
Josh Creek 52a96de8d6 feat: protect game fleet during voluntary disruption 2026-08-31 22:01:18 +01:00
Josh Creek 72d4a604c2 feat: spread game fleet across on-demand zones 2026-08-31 21:59:51 +01:00
Josh Creek 90126a4be7 feat: add fleet autoscaling baseline 2026-08-31 21:58:42 +01:00
Josh Creek b2ee9ec92d feat: validate queue compatibility metadata 2026-08-31 21:57:40 +01:00
Josh Creek 0551fb0b1f feat: add regional Agones fleet overlays 2026-08-31 21:51:37 +01:00
Josh Creek 6253b620a9 fix: restrict supervisor drain to loopback 2026-08-31 21:49:22 +01:00
Josh Creek faede927fc fix: validate Agones assigned endpoints 2026-08-31 21:48:00 +01:00
Josh Creek 307828ff7f feat: atomically complete match results 2026-08-31 21:46:32 +01:00
Josh Creek 7b6e22292a feat: sign reconnect authorisations 2026-08-31 21:44:27 +01:00
Josh Creek 4b5e40bff7 feat: persist ranked season rollovers 2026-08-31 21:43:07 +01:00
Josh Creek bf4da9fd39 feat: atomically create matchmaking proposals 2026-08-31 21:40:21 +01:00
Josh Creek 229ded8613 feat: gate proposals on formed match policy 2026-08-31 21:38:47 +01:00
Josh Creek 4dcf98cbb0 feat: form matches from queue projections 2026-08-31 21:36:00 +01:00
Josh Creek 02a11704ce feat: add authenticated probe evidence boundary 2026-08-31 21:34:27 +01:00
Josh Creek 91e536425d feat: enforce supply chain policy 2026-08-31 21:32:03 +01:00
Josh Creek 0f1cc17af6 feat: add authenticated queue recovery 2026-08-31 21:29:33 +01:00
Josh Creek 236cca30ba feat: expose authoritative ranked profile 2026-08-31 21:27:21 +01:00
Josh Creek 846663e320 feat: derive authoritative ranked tiers 2026-08-31 21:25:53 +01:00
Josh Creek 726fe1ce2e feat: gate assignment publication on allocation 2026-08-31 21:24:19 +01:00
Josh Creek 88b5ffedb2 feat: add Kubernetes multiplayer security baseline 2026-08-31 21:21:55 +01:00
Josh Creek 79e66c7a95 feat: validate workload-bound result credentials 2026-08-31 21:20:12 +01:00
Josh Creek 8d1b407bb0 feat: add authenticated proposal API 2026-08-31 21:17:15 +01:00
Josh Creek 77c01dc531 fix: reject ambiguous queue JSON bodies 2026-08-31 21:13:47 +01:00
Josh Creek cd30098eca feat: add authenticated queue HTTP API 2026-08-31 21:13:10 +01:00
Josh Creek caa875f15c fix: fence concurrent queue mutations 2026-08-31 21:09:58 +01:00
Josh Creek 58e8a5c523 fix: calculate conservative SLO percentiles 2026-08-31 21:08:49 +01:00
Josh Creek bc7136b2cb feat: add ranked season window policy 2026-08-31 21:08:10 +01:00
Josh Creek e13a64756f feat: add authoritative rating outcome scoring 2026-08-31 21:07:04 +01:00
Josh Creek 63332435d2 feat: classify match integrity separately from delivery 2026-08-31 21:05:54 +01:00
Josh Creek 4e66c5758c docs: reflect current multiplayer implementation status 2026-08-31 21:04:58 +01:00
Josh Creek 723f8aea5d test: verify offline matchmaking pipeline 2026-08-31 21:04:22 +01:00
Josh Creek c0d1c33f54 feat: add executable multiplayer SLO checks 2026-08-31 21:02:29 +01:00
Josh Creek 3dde0ffb79 feat: add credential-safe multiplayer observability 2026-08-31 21:01:30 +01:00
Josh Creek 0364d3f172 test: add offline Steam and allocator fakes 2026-08-31 21:00:31 +01:00
Josh Creek 1902084523 test: add multiplayer control-plane fuzz targets 2026-08-31 20:59:29 +01:00
Josh Creek bbabd259b6 docs: complete multiplayer threat model 2026-08-31 20:58:11 +01:00
Josh Creek 217ae263cd feat: add rebuildable candidate cache 2026-08-31 20:56:13 +01:00
Josh Creek 3b5f50023b feat: enforce ranked admission policy 2026-08-31 20:55:11 +01:00
Josh Creek 518df3a73a fix: bind reconnects to verified Steam identity 2026-08-31 20:54:16 +01:00
Josh Creek 893db17c03 feat: add authenticated supervisor drain 2026-08-31 20:52:37 +01:00
Josh Creek 7ce28559d2 feat: add revisioned resync reducer 2026-08-31 20:51:46 +01:00
Josh Creek b1b4608dd8 docs: synchronize multiplayer progress checklist 2026-08-31 20:50:24 +01:00
Josh Creek e702661388 feat: add ticket and session policy 2026-08-31 20:49:45 +01:00
Josh Creek dfe8d46d99 feat: add casual bot backfill policy 2026-08-31 20:48:10 +01:00
Josh Creek 9c6d48ae2c feat: add initial connect no-show policy 2026-08-31 20:46:52 +01:00
Josh Creek 81e68bb5fa feat: add assignment readiness gate 2026-08-31 20:45:29 +01:00
Josh Creek 2b8bce5e4b feat: add deterministic allocation policy 2026-08-31 20:43:56 +01:00
Josh Creek 9810ee543f fix: propagate allocated transport ports 2026-08-31 20:42:19 +01:00
Josh Creek a0195987bd feat: add Agones readiness supervisor core 2026-08-31 20:40:51 +01:00
Josh Creek 698413cd91 feat: validate allocated assignment manifest 2026-08-31 20:38:56 +01:00
Josh Creek ecc78b7a2a feat: add result transaction SQL boundary 2026-08-31 20:37:49 +01:00
Josh Creek a616b7637e feat: add serializable matchmaking store boundary 2026-08-31 20:36:43 +01:00
Josh Creek 7c4b64b50a feat: add durable matchmaking metadata schema 2026-08-31 20:35:34 +01:00
Josh Creek 637b522486 test: harden result annotation reconciliation 2026-08-31 20:34:50 +01:00
Josh Creek 864e4e8aaf feat: add durable result policy core 2026-08-31 20:33:16 +01:00
Josh Creek b04f3318b9 feat: add ranked reconnect policy 2026-08-31 20:31:16 +01:00
Josh Creek cc2cd80a01 feat: add ranked season rollover policy 2026-08-31 20:29:11 +01:00
Josh Creek 1793eb7621 feat: add canonical matchmaking rating engine 2026-08-31 20:25:07 +01:00
Josh Creek 7643dbc439 feat: add matchmaking proposal policy 2026-08-31 20:23:17 +01:00
Josh Creek cf212d94f9 feat: validate matchmaking latency evidence 2026-08-31 20:21:55 +01:00
Josh Creek 997175c753 docs: track queue domain progress 2026-08-31 20:20:59 +01:00
Josh Creek 6c0163c3ec feat: add retry-safe matchmaking queue domain 2026-08-31 20:20:42 +01:00
Josh Creek b79d358db9 feat: add deterministic matchmaking team partitioning 2026-08-31 20:18:43 +01:00
Josh Creek 07fe144b2f feat: add deterministic matchmaking candidate selection 2026-08-31 20:17:53 +01:00
Josh Creek f5d9c08468 feat: add matchmaking domain state core 2026-08-31 20:15:52 +01:00
Josh Creek e3119bf77c docs: mark matchmaking contracts complete 2026-08-31 20:14:04 +01:00
Josh Creek 4264a2bbd3 feat: validate allocated server compatibility 2026-08-31 20:13:48 +01:00
Josh Creek d864ce2475 feat: add allocated server compatibility config 2026-08-31 20:13:22 +01:00
Josh Creek 15fdd989e2 feat: add matchmaking durable schema migration 2026-08-31 20:12:19 +01:00
Josh Creek 5b8638e15e feat: define matchmaking state transitions 2026-08-31 20:10:51 +01:00
Josh Creek f3e7538fb7 feat: publish matchmaking v1 contracts 2026-08-31 20:09:50 +01:00
Josh Creek af8592082e docs: define matchmaking launch SLOs 2026-08-31 20:07:41 +01:00
Josh Creek 62ee3f2777 docs: lock matchmaking platform architecture 2026-08-31 20:06:45 +01:00
Josh Creek 835233672f fix multiplayer snapshot disconnect race 2026-08-31 20:05:40 +01:00
Josh Creek 8d4a0640e2 docs: finalize scalable matchmaking plan 2026-08-31 19:57:49 +01:00
Josh Creek bcc12aad19 docs(multiplayer-todo): add Phase 8 task breakdown for matchmaking and autoscaling
multiplayer-next.md carried the Phase 8 checklist but not the numbered
tasks with acceptance criteria that work actually gets picked up from.
That format lives in multiplayer-todo.md section 7, which already hosts
Phase 7 as in-progress, so Phase 8 goes there too.

Tasks 8.1-8.20 across four groups: backend service (identity, rating
store, Glicko-2, queue), server orchestration and autoscaling, playlists
and client UI, and keeping Docker/CI green. Section 0's short list gains
an index entry, and the status header now says Phase 8 is a 1.0 launch
blocker and the first phase to add a component outside the Godot
project.

Three entries are measured findings rather than plans, each of which
would break a naive implementation: stdout block-buffering making a
log-grep readiness probe hang forever, the hardcoded 7777/udp port
preventing more than one match per host, and compose.phase6-smoke.yml's
dependence on the exact behaviour allocation work would change.

Also fixes a now-false cross-reference: a Phase 4 note read 'not
Phase 8' meaning 'not a later phase', written when no Phase 8 existed.
CLAUDE.md's 'never add new work to multiplayer-todo.md' rule gains the
new-phase exception it always had in practice - Phase 7 was already
there.
2026-08-31 18:49:59 +01:00
Josh Creek 4ffa1543cc docs: design per-match server autoscaling, with measured boot time
Casual/ranked queues need servers allocated per match and shut down
afterwards, so cost is incurred only while a match runs - while the
existing Docker and CI gates keep passing unchanged.

Measured against the repo's own cosmicclash-server image rather than
estimated: the runtime image is ~148 MB of content, and boot to the
server_started line is ~870 ms on the container's own clock. That was
taken under x86_64 emulation on an arm64 host, so it is a pessimistic
bound and is recorded as one - it needs re-measuring on native Linux
before it sets any timeout.

Two findings that would each break a naive implementation, both hit
while taking that measurement:

- Godot's stdout is block-buffered off a TTY. A detached container logs
  nothing at all - server_started does not appear even after 35s - so an
  orchestrator readiness probe that greps the log hangs forever. Probe
  the UDP socket or flush explicitly.
- --port defaults to 7777 and the Dockerfile hardcodes EXPOSE 7777/udp,
  so several matches cannot share a host without a port range or an
  address per match. Being UDP, L7 ingress routing does not apply.

Also records the honest tension in 'only pay during a match': a server
must listen before players connect, and image pull plus scheduling can
dwarf 870 ms, so the recommendation is match-level scale-to-zero over a
small warm node pool rather than node-level scale-to-zero.

The rule for keeping verify-phase6 and verify-enet-integration green:
every allocation feature is opt-in via a ServerConfig flag defaulting to
current behaviour, with a second Compose file rather than mutating
compose.phase6-smoke.yml.
2026-08-31 18:45:05 +01:00
Josh Creek 3aa0f5b9c2 docs: scope casual and ranked matchmaking as a 1.0 launch blocker
Queued matchmaking had never been considered anywhere in the planning
docs - not as planned work, and not even on the explicitly-deferred
list. It is a launch requirement, so record the design before code.

Add docs/MATCHMAKING.md covering the model change (community-server ->
per-match allocation), the decision to use Steam for identity and a
project-owned backend for queue/rating/allocation, what the existing
server already provides (--max-matches=1 is the allocation primitive,
ServerConfig, the roster, MatchState), the casual/ranked ruleset split,
and the open questions - rating algorithm, team-to-individual rating,
and the server cost that allocated matches reintroduce.

Ranked is hard-blocked on Phase 7 Steam auth tickets: slot reclaim is
keyed by display name today, and a rating on a spoofable identity is
worse than no rating.

Add Phase 8 to multiplayer-next.md, and correct README/CLAUDE.md/
TECH_STACK.md, which asserted no backend exists or is planned - true
before this was scoped, wrong now.
2026-08-31 18:33:36 +01:00
Josh Creek 964094f65a docs: correct stale multiplayer/C#-backend claims, add TECH_STACK doc
CLAUDE.md and README.md described the pre-multiplayer state (local-only
MVP, planned C# backend) even though server-authoritative multiplayer,
the dedicated server, Docker/CI verification, and Steam transport have
since shipped (Phases 1-6). Update both to reflect reality and add a
docs index in CLAUDE.md pointing at multiplayer-next.md as the current
checklist.

- Add docs/TECH_STACK.md, linked from README, explaining the stack and
  why it's a single GDScript project with no separate backend.
- Add one TODO item for the video settings menu (missing presets/vsync/
  resolution scaling), blocked on the same profiling gate as the
  multiplayer 0.17 tasks.
- Pick up editor-generated .gd.uid sidecars and minor project.godot
  formatting noise from opening the project in Godot 4.7.
2026-08-31 18:20:52 +01:00
CosmicClash Training Bot c51d5ee369 chore(training): generation 5 progress after 20260829-1649-gen5-s6-league 2026-08-31 07:24:53 +01:00
CosmicClash Training Bot 6d837b2bf3 chore(training): Add 20260829-1649-gen5-s6-league checkpoints, logs, and exported policy 2026-08-31 07:19:18 +01:00
Josh Creek 4f13b4eca9 chore(training): close Stage 5 by human override, re-derive its air-touch gate
productive_air_touch_episode_fraction's 0.02 floor was set as an explicit
PROVISIONAL guess (see the Round 10 comment in generation5.py) with
instructions to re-derive it from attempt 1's measured tail. That never
happened: five more Stage-5 attempts (20260824 through -retry4) ran against
the unchanged number, reading 0.00004/0.00006/0.00002/0.00018/0.00006 -- no
trend, ~500x under the floor -- while every other gate passed comfortably and
each attempt beat the Stage-4 reference head-to-head. Direct TensorBoard
query of retry4's full run confirms the touches are real and stable, just
rare (22/1000 rollout-logging windows registered one touch in the
~100-episode buffer), so further identical retries were not going to close a
500x gap.

Lowered the floor to 0.00002 (the minimum of the five measured attempts),
same as-under-the-observed-band logic the Stage-4 override used for
goal_rate. Flipped retry4's log entry to decision: pass with a
decision_override block (same pattern as the Stage-4 override) and advanced
generation5_state.json to Stage 6 attempt 0. Documented in TRAINING.md and
flagged Stage 6's own 0.015 floor for the same metric as equally unvalidated.
2026-08-29 16:45:09 +01:00
CosmicClash Training Bot b946f78d1f chore(training): generation 5 progress after 20260828-0214-gen5-s5-intercepts-retry4 2026-08-29 00:16:31 +01:00
CosmicClash Training Bot ca17265bf1 chore(training): Add 20260828-0214-gen5-s5-intercepts-retry4 checkpoints, logs, and exported policy 2026-08-29 00:14:04 +01:00
CosmicClash Training Bot d235342889 chore(training): generation 5 progress after 20260827-0426-gen5-s5-intercepts-retry3 2026-08-28 02:14:11 +01:00
CosmicClash Training Bot 706ac9bd2c chore(training): Add 20260827-0426-gen5-s5-intercepts-retry3 checkpoints, logs, and exported policy 2026-08-28 02:11:26 +01:00
CosmicClash Training Bot 5de72b0636 chore(training): generation 5 progress after 20260826-0631-gen5-s5-intercepts-retry2 2026-08-27 04:26:36 +01:00
CosmicClash Training Bot 295be3d26a chore(training): Add 20260826-0631-gen5-s5-intercepts-retry2 checkpoints, logs, and exported policy 2026-08-27 04:24:16 +01:00
413 changed files with 45542 additions and 1618 deletions
+23
View File
@@ -0,0 +1,23 @@
name: Agones Integration
on:
workflow_dispatch:
pull_request:
paths:
- Dockerfile
- Makefile
- deploy/k8s/**
- scripts/verify_kind_agones.sh
- .github/workflows/agones-integration.yml
permissions:
contents: read
jobs:
kind-agones:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Verify disposable kind/Agones lifecycle
run: make verify-kind-agones
+27
View File
@@ -0,0 +1,27 @@
name: Allocated Compose Smoke
on:
workflow_dispatch:
pull_request:
paths:
- Dockerfile
- Makefile
- compose.allocated-smoke.yml
- server/api/**
- server/store/**
- server/workload/**
- server/migrations/**
- scripts/verify_allocated_compose.sh
- .github/workflows/allocated-compose.yml
permissions:
contents: read
jobs:
allocated-compose:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Verify independent allocated Compose flow
run: make verify-allocated-compose
+26
View File
@@ -0,0 +1,26 @@
name: Multiplayer Chaos Recovery
on:
workflow_dispatch:
pull_request:
paths:
- Dockerfile
- Makefile
- compose.chaos-smoke.yml
- server/cmd/maintenance/**
- server/store/**
- server/migrations/**
- scripts/verify_chaos_recovery.sh
- .github/workflows/multiplayer-chaos.yml
permissions:
contents: read
jobs:
api-restart-recovery:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Verify API restart and stalled-allocation recovery
run: make verify-chaos-recovery
+23
View File
@@ -0,0 +1,23 @@
name: Multiplayer API Load
on:
workflow_dispatch:
pull_request:
paths:
- server/api/**
- server/domain/**
- server/matcher/**
- Makefile
- .github/workflows/multiplayer-load.yml
permissions:
contents: read
jobs:
api-load:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Verify 10,000-client API load boundary
run: make verify-multiplayer-load
+47
View File
@@ -0,0 +1,47 @@
# The Go control plane is ~24k lines, and until this workflow existed the only
# Go tests CI ever ran were the two load tests in multiplayer-load.yml. Nothing
# else — domain policy, the wire/store boundaries, the allocator, the Steam
# adapter — gated a change. The Godot unit suite is covered (verify-phase6 runs
# test_runner.tscn as its first step); this closes the equivalent gap on the
# Go side.
#
# Deliberately Docker-free and cluster-free so it stays fast enough to gate
# every push. Tests that need a real PostgreSQL or Redis are behind the
# `integration` build tag and stay with their own scripts; `go vet` is still
# run over that tag so those files cannot rot uncompiled.
name: Server Unit Tests
on:
push:
pull_request:
permissions:
contents: read
jobs:
go-tests:
runs-on: ubuntu-latest
defaults:
run:
working-directory: server
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: server/go.mod
cache-dependency-path: server/go.sum
- name: Build
run: go build ./...
- name: Vet
run: go vet ./...
# Integration-tagged files are excluded from the default build, so
# without this a signature change could leave them broken until someone
# ran the integration scripts by hand.
- name: Vet integration-tagged tests
run: go vet -tags integration ./...
- name: Test
run: go test ./...
# The control plane is concurrent by design: outbox dispatchers, the
# event hub, the matcher worker and the allocator all run in parallel.
- name: Test with race detector
run: go test -race ./...
+18
View File
@@ -0,0 +1,18 @@
name: Supply Chain Policy
on:
push:
pull_request:
permissions:
contents: read
jobs:
repository-policy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Verify immutable image references and secret hygiene
run: make verify-supply-chain
- name: Verify release process is documented
run: test -s docs/SUPPLY-CHAIN.md
+141
View File
@@ -0,0 +1,141 @@
# Investigate and fix the failing Agones Integration CI gate
## Task
`make verify-kind-agones` (workflow `.github/workflows/agones-integration.yml`,
script `scripts/verify_kind_agones.sh`) fails. Find the root cause and fix it so
the gate passes on CI. Repo: `jcreek/CosmicClash`, branch `feat/multiplayer`,
PR #30.
## What is already known — do not re-derive this
**The failure.** `helm upgrade --install agones ... --wait --timeout 5m` fails
with `Error: context deadline exceeded`. Immediately before, Helm reports:
```
resource Deployment/agones-system/agones-controller not ready. status: InProgress, message: Available: 0/1
resource Deployment/agones-system/agones-extensions not ready. status: InProgress, message: Available: 0/1
resource Deployment/agones-system/agones-allocator not ready. status: InProgress, message: Available: 0/1
```
So the cluster is created, the game-server image loads, and the Agones chart
installs — but none of its Deployments become Available inside 5 minutes. The
script never reaches the parts that exercise this repo's own manifests.
**It is pre-existing.** It fails identically at `089c127c`, the branch head
before recent work. It is not caused by the branch's changes. Do not assume a
recent commit broke it.
**It is not architecture-specific.** It fails the same way on GitHub's
`ubuntu-24.04` amd64 runners and on an arm64 macOS developer machine. Agones
1.49.0 publishes both amd64 and arm64 images.
**It is not a Helm kubeVersion rejection.** Agones charts 1.49.0, 1.50.0 and
1.51.0 declare no `kubeVersion` constraint, so Helm is not refusing the
Kubernetes version — the pods are being created and are not becoming ready.
**Ruled out as a red herring:** reproducing locally on a machine with heavy
Docker usage produced `FailedCreatePodSandBox: containerd connection reset`,
which is local resource pressure, not the CI cause. If you see that locally,
clear Docker state and retry rather than chasing it.
**There may be two distinct failures, not one.** After `docker system prune`,
a local run got *past* the Agones install cleanly (controller and allocator
both reached "condition met") and failed later, at:
```
scripts/verify_kind_agones.sh:146
kubectl wait --for=jsonpath='{.status.ready}'=2 fleet/cosmic-clash-game -n cosmic-clash --timeout=5m
error: timed out waiting for the condition on fleets/cosmic-clash-game
```
So locally the Agones install is fine and the **Fleet's game-server pods never
become Ready**; on CI the run never gets that far because the Agones install
itself times out. Treat these as potentially separate problems: fixing the CI
Agones timeout may simply expose the Fleet one underneath. Both need to pass.
The Fleet failure is the more suspicious of the two for recent work, because
`deploy/k8s/base/fleet.yaml` changed: the join-signing key material moved from
a single raw-bytes secret key (`join-signing-key`) to a JSON map
(`join-signing-keys.json`), and the mount's `items[].key` moved with it. The
script's `kubectl create secret` was updated to match and does succeed
(`secret/cosmic-clash-game-server created`), so the obvious mismatch is not
present -- but verify the pod actually mounts and starts rather than assuming.
Note the script's `sed` also strips `--allocated-mode` and the roster path and
blanks `--control-plane-url`, so the game server runs in a reduced mode here;
check whether it is failing for a reason unrelated to the key at all.
## Pinned versions (all in `scripts/verify_kind_agones.sh`)
| Thing | Value | Override |
|---|---|---|
| Agones chart | `1.49.0` | `AGONES_VERSION` |
| kind node image | `kindest/node:v1.33.1` (Kubernetes 1.33) | `KIND_NODE_IMAGE` |
| Cluster | single node, `--wait 120s` | `KIND_CLUSTER_NAME` |
| Runner | `ubuntu-latest` (ubuntu-24.04), 30 min timeout | — |
The chart is installed with `--set agones.controller.replicas=1`,
`agones.extensions.replicas=1`, `agones.allocator.replicas=1`, and
`agones.extensions.resources.{requests,limits}.ephemeral-storage` lowered to
128Mi/512Mi. That ephemeral-storage override already exists because Agones 1.49
otherwise requests 10,100 MiB and will not schedule on a default kind node —
there is a comment saying so. **A similar resource-fit problem for the other
Deployments is a strong hypothesis worth checking first.**
## Diagnostics are already in place
The script now dumps, on any failure and before the cluster is deleted: node
capacity and conditions, pods in `agones-system` and `cosmic-clash`, recent
events per namespace, and describe + current/previous logs for every not-ready
pod. Set `KIND_KEEP_ON_FAILURE=1` to retain the cluster for interactive
inspection instead of deleting it.
Its first run revealed a bug in the diagnostics themselves: a
`kubectl cluster-info` reachability guard suppressed the entire dump. That
guard has been removed, so the dump now always runs on failure.
**Start by reading that output**, either from a CI run or a local run. The most
likely candidates it will distinguish between:
1. **Resource pressure** — `FailedScheduling ... Insufficient cpu/memory/
ephemeral-storage`. Fix by lowering requests for the other Deployments the
way extensions already is, or by giving the kind cluster more capacity.
2. **Version incompatibility** — Agones 1.49 against Kubernetes 1.33. Check
Agones' release notes for its supported Kubernetes range; if 1.33 is outside
it, either raise `agones_version` or lower `kind_node_image`. Confirm the
pairing is one Agones actually tests.
3. **Probe/readiness failure** — pods Running but never Ready. The pod logs and
describe output will show the failing probe.
4. **Image pull** — `ImagePullBackOff` on an Agones image.
## Constraints
- **Do not weaken the gate to make it pass.** Removing `--wait`, extending the
timeout to hide a real failure, or `|| true` around the install are all wrong.
If the cause is genuinely a timeout on slow-but-working startup, raising it
is acceptable *only* with evidence that the pods do become Available, and the
new value should be justified in a comment.
- Keep it a disposable, isolated cluster: it must not touch an existing cluster,
and the EXIT trap must still remove the one it created.
- If you change a pinned version, pin the new one explicitly and say why in the
commit message. Do not float to `latest`.
- `CLAUDE.md` applies: never create co-authored commits, never mention Claude.
## Verification
- `make verify-kind-agones` passes locally (needs Docker, kind, kubectl, Helm).
- The `Agones Integration` workflow passes on PR #30. It is `pull_request`
triggered with path filters on `Dockerfile`, `Makefile`, `deploy/k8s/**`,
`scripts/verify_kind_agones.sh`, and its own workflow file — so a change to
the script will trigger it.
- Do not regress the other seven workflows. `Allocated Compose Smoke` was also
failing and has just been fixed; confirm it stays green.
## Useful context
- `multiplayer-next.md` §7 task 8.49 describes what this gate is meant to prove.
- `deploy/k8s/base/fleet.yaml` is the Fleet the script applies after Agones is
up, with a `sed` that swaps the release digest placeholder for the locally
built image and strips `--allocated-mode` and the roster path (there is no
control plane in this disposable cluster).
- The gate is a prerequisite for issue #17 (standing up a real cluster).
+233 -20
View File
@@ -2,14 +2,30 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Important rule: never create co-authored commits. Never mention Claude in commits.
Important rule: never create co-authored commits. Never mention Claude in commits.
## 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 project is GDScript/Godot only right now — the "C# backend" mentioned in README.md is planned but not yet started. There is no server-side code; the MVP is local-only play against bots.
Cosmic Clash is an open-source, physics-based "vehicle soccer" game (a spiritual successor to Rocket League) built in Godot 4.7, using space ships instead of cars. The game and the dedicated server are GDScript/Godot only — the "C# backend" an early README described was never started, and the dedicated server is an export of this same Godot project. There is one component outside the Godot project: a **Go matchmaking control plane** in `server/` for casual/ranked queues, ranked ratings and Agones-based server allocation. It is a 1.0 launch blocker — see `docs/MATCHMAKING.md` for the design, `multiplayer-next.md` §0 and §7 for what remains (the allocation-to-connect pipeline is now wired end to end; what is left is external — a Steamworks App ID, custom GodotSteam builds, a registry to publish images to, and a live Agones cluster; `TODO.md` orders them), and `docs/TECH_STACK.md` for why the control plane is Go rather than C#, Rust or C++. README.md's "MVP is local-only against bots" section is historical: server-authoritative online multiplayer, a headless dedicated server, Docker/CI verification, and an optional Steam transport are all implemented (Phases 16). See `multiplayer-next.md` for what actually remains.
Because the gameplay concept (vehicle soccer) can't be copyrighted but specific expression can, all code/art/assets must be original — this is why the project uses Godot instead of Unreal/Unity and ships instead of cars. Keep this in mind when writing code or pulling in assets: don't port or closely mirror Rocket League's actual implementation.
### Where the documentation lives
The prose docs carry far more design rationale than the code comments, and several are load-bearing:
- `multiplayer-next.md`**the multiplayer task-tracking document**: outstanding work (§0), a numbered "gotchas" list (§9), and the current task breakdown with checkboxes (§7), all in one file. Start at §0 for "what's left". Day-to-day work is Phase 7 (Steam) and Phase 8 (matchmaking), whose numbered task breakdown and acceptance criteria live in §7, because that is the format tasks are picked up from. Phases 06 are done and archival — their evidence lives in git history, not the current doc.
- `MULTIPLAYER_SPEC.md` — the architecture decisions, wire format, server-side input handling, prediction/reconciliation, latency/frame-rate budget and match lifecycle state machine, as sections 16. Code comments across `Game/scripts/` cite it constantly by section number (`§2.4`, `§4.1`); many still say `multiplayer-next.md §N` for `N` 16 from before this doc was split out — when a comment does, the content is now here, not there. `multiplayer-next.md`'s own §7+ cites `§N` the same way and disambiguates by number (16 → this doc, 7+ → itself).
- `TRAINING.md` — the full RL workflow (training, curriculum generations, export, eval, difficulty tiers).
- `SERVER.md` — dedicated-server build, config, systemd deploy, sizing.
- `STEAM.md` — optional GodotSteam custom-build setup and the transport contract.
- `FLIGHT_MANUAL.md` — the player-facing flight model.
- `docs/MATCHMAKING.md` — casual/ranked queue design, and the locked constraints (Go/PostgreSQL/Redis/Agones) the `server/` module implements. Partially implemented; a 1.0 launch blocker, and the reason a backend service outside the Godot project exists at all.
- `docs/TECH_STACK.md` — what the project is built with and why, including the Go-vs-C#/Rust/C++ rationale for the matchmaking control plane.
- `TODO.md` — deferred non-multiplayer work, **and** the ordered human-actionable backlog: which GitHub issue to do first, what each one unblocks, and which items are waiting on nobody. Start there when asking "what next". Audio is no longer absent — a procedural `AudioManager` covers UI, countdown, impact, goal and engine/turbo cues; what remains is authored assets.
- `docs/THREAT-MODEL.md`, `docs/SUPPLY-CHAIN.md`, `docs/OBSERVABILITY.md`, `docs/MATCHMAKING-SLOs.md`, `docs/ADR-001-matchmaking-platform.md` — the control plane's security, release, telemetry and SLO contracts. `server/security/*.py` asserts several of them against the checked-in manifests, so changing a manifest often means changing one of these.
- `docs/REVIEW-2026-09-feat-multiplayer.md` — a point-in-time adversarial review of this branch. Every finding in it is fixed; it is kept for the reasoning, not as a status report, and its header says so.
## Godot MCP server
This repo vendors [godot-mcp](https://github.com/tugcantopaloglu/godot-mcp) as a git submodule at `mcp/godot-mcp` and registers it in `.mcp.json`. **Prefer the godot-mcp tools over manual file edits or shell commands** when the task involves inspecting or modifying the Godot project — reading/editing scenes, nodes, scripts, running the project, or interacting with a live Godot editor/runtime instance. It understands Godot's scene tree and `.tscn`/`.gd` structures directly, which is more reliable than hand-parsing them.
@@ -55,35 +71,232 @@ Upstream ships telemetry, and there are **two independent switches** — turning
There is no build step or linter for the GDScript project itself — Godot projects run directly from source.
### Running the game
- **Open the project**: open `Game/` as a project in the Godot 4.7 editor, or run `godot --path Game` from the repo root.
- **Run the game**: press Play in the editor, or `godot --path Game res://scenes/main_menu.tscn`.
- **Headless smoke test** (RL/CI precondition — the game must run without rendering): `godot --headless --path Game res://scenes/free_play.tscn`.
- **Unit tests** (pure-function assertions, see `multiplayer-todo.md` task 1.0): `godot --headless --path Game res://tests/test_runner.tscn`. Exits 0/1. Add a test by dropping a `*.gd` file under `Game/tests/cases/` that extends `res://tests/test_case.gd` (path-based `extends`, not the bare `class_name` — see that file for why) with any number of `test_*()` methods; the runner discovers it, no registration needed.
- **Networking smoke tests** (real two-process ENet connect/disconnect, see `multiplayer-todo.md` §7 Phase 1 tasks): each starts a host then a client, each in its own `godot --headless` process, printing `SMOKE PASS/FAIL: ...` and exiting 0/1. Not part of `test_runner.tscn` — a live ENet handshake needs two real processes. `res://tests/net_smoke.tscn` (task 1.2 — `--role=host|client`, now also confirms the host observes `client_disconnected`, not just that each side exits cleanly on its own), `res://tests/match_net_smoke.tscn` (task 1.4 — `--role=host|client|client-badversion|client-longname|host_recycle`; `client-longname` sends an oversized player name and expects rejection, `host_recycle` hosts, lets a client join, leaves, re-hosts, and confirms the roster is actually empty — run a plain `client` role against it), `res://tests/clock_smoke.tscn` (task 1.8 — `--role=host|client`, clock convergence cross-checked against independent OS-wall-clock ground truth, not just self-consistency), `res://tests/lobby_smoke.tscn` (task 1.5 — `--role=host|client`; **both** roles load `lobby.tscn` for real via `change_scene_to_file` now, exercising the server's read-only view as well as the client's interactive one). See `network_manager.gd`'s header comment and `multiplayer-todo.md` §9 gotchas 2530 for the non-obvious Godot/ENet failure modes these caught (`OfflineMultiplayerPeer` sentinel, premature peer teardown, `change_scene_to_file` off the real `current_scene`, unbounded `connection_failed`, the `is_client`-before-actually-connected race, `load()` not returning null on a broken script).
- **`main_menu.tscn`'s Host/Join flow** (task 1.7) is verified the same way but needs a temporary autoload since it's the real main scene, not a wrapper: add `MainMenuTestHooks="*res://tests/main_menu_test_hooks.gd"` to `project.godot [autoload]`, run `godot --headless --path Game res://scenes/main_menu.tscn -- --role=<host|join_ok|join_refused|join_cancel>` (host first, sleep ~1s, then the join role), then remove the autoload line again — it must never ship registered.
- **Import resources first** on a fresh checkout or in a container: `godot --headless --path Game --import`. Do **not** add `--quit` — it ends the editor after one iteration and can leave `.godot/imported` half-generated (see the Dockerfile comment).
### Unit tests
`godot --headless --path Game res://tests/test_runner.tscn` — pure-function assertions, exits 0/1.
Add a test by dropping a `*.gd` file under `Game/tests/cases/` that extends `res://tests/test_case.gd` (path-based `extends`, not the bare `class_name` — see that file for why) with any number of `test_*()` methods; the runner discovers it, no registration needed. The same path-vs-`class_name` caveat is why `net_codec.gd`, `sim_constants.gd` etc. are reached by `preload()` elsewhere in the codebase.
The runner has **no filter flag** — it always runs everything (it's fast). To isolate one case, temporarily move the others out of `tests/cases/`.
Two runner behaviours exist because of past silent-pass bugs, and new tests must respect them: a case file that fails to parse is a failure (`can_instantiate()` is the guard — `load()` does *not* return null on a broken script), and **a test that completes having made zero assertions is itself a failure**, because GDScript has no exceptions and a crash before the first `assert_*` would otherwise read as a pass.
### Networking smoke tests (multi-process)
These need two or three real `godot --headless` processes for a live ENet handshake, so they are **not** part of `test_runner.tscn`. Each prints `SMOKE PASS/FAIL: ...` and exits 0/1. Run them all through the wrapper:
```bash
make verify-enet-integration # host+client pairs for every case, then the 3-process match
VERIFY_ENET_CASES=net,clock make verify-enet-integration # subset, for local debugging
GODOT_BIN=/path/to/godot make verify-enet-integration # non-default Godot binary
```
`scripts/verify_enet_integration.sh` starts each role, waits, and fails on any `SCRIPT ERROR`/`ERROR:`/`SMOKE FAIL` in the logs — a clean exit code alone is not the bar. It prints its temp log directory and dumps the logs on failure. The individual scenes, if you need to drive one by hand with `-- --role=<role>`:
| Scene | Roles | What it proves |
| --- | --- | --- |
| `tests/net_smoke.tscn` | `host`, `client` | Raw connect/disconnect; the host observes `client_disconnected`, not just clean self-exit. |
| `tests/match_net_smoke.tscn` | `host`, `client`, `client-badversion`, `client-longname`, `host_recycle` | Handshake gating (protocol version, oversized name rejection) and that a host→leave→re-host cycle actually empties the roster (run a plain `client` against `host_recycle`). |
| `tests/clock_smoke.tscn` | `host`, `client` | Clock convergence, cross-checked against independent OS-wall-clock ground truth rather than self-consistency. |
| `tests/lobby_smoke.tscn` | `host`, `client` | **Both** roles load `lobby.tscn` for real via `change_scene_to_file`, exercising the server's read-only view as well as the client's interactive one. |
| `tests/networked_match_ci.tscn` | `host`, `client-bot --test-bot` (×2) | A full server + two AI-driven clients playing a real match: snapshot throughput and cross-peer score agreement, via a deterministic server-forced goal. |
| `tests/networked_match_smoke.tscn` | see its header | Shorter attended variant of the above. |
| `tests/net_sim_smoke.tscn` | see its header | The `--net-sim-*` latency/loss decorator actually changes observed behaviour. |
See `network_manager.gd`'s header comment and `multiplayer-next.md` §9 gotchas 2530 for the non-obvious Godot/ENet failure modes these caught (`OfflineMultiplayerPeer` sentinel, premature peer teardown, `change_scene_to_file` off the real `current_scene`, unbounded `connection_failed`, the `is_client`-before-actually-connected race, `load()` not returning null on a broken script).
**`main_menu.tscn`'s Host/Join flow** is verified the same way but needs a temporary autoload since it's the real main scene, not a wrapper: add `MainMenuTestHooks="*res://tests/main_menu_test_hooks.gd"` to `project.godot [autoload]`, run `godot --headless --path Game res://scenes/main_menu.tscn -- --role=<host|join_ok|join_refused|join_cancel>` (host first, sleep ~1s, then the join role), then remove the autoload line again — it must never ship registered.
`tests/server_physics_parity.gd` is a standalone `SceneTree` script (no `.tscn`, no wired runner) that traces the real Ship scene through fixed inputs and dumps every physics step's pose/velocity plus the observation vector. It exists to be diffed against the same file run from a `git archive HEAD` tree, proving a client-side change didn't perturb shared physics.
### Dedicated server (Docker)
```bash
make verify-phase6 # the whole exported-server gate; also the entire Phase 6 CI workflow
```
This builds the stripped `Linux Dedicated Server` export, runs it in one container, joins two independent headless clients from two others, forces a server-owned goal in each of two matches, and asserts both clients saw both scores **and that the arena actually rotated between matches**. It needs several GB of free Docker space (the pinned `barichello/godot-ci:4.7.1` image is ~2.4 GB) and always tears down its Compose containers, printing its log directory either way.
The Dockerfile's targets are worth knowing: `project-imported` (base, resources imported) → `enet-test` (source client, used by the ENet CI workflow) → `exporter` (rewrites `run/main_scene` and exports the server) → `server` (slim Ubuntu runtime) and `smoke-client` (test-only harness). **Godot dedicated exports refuse command-line scene overrides**, which is why the server scene is baked in by `sed` at export time rather than passed as an argument.
To run one by hand, and for every config flag, see `SERVER.md`. `--smoke-force-goal-after=<seconds>` is a verification-only switch and must never be used for a real match.
### Steam builds (optional)
`make verify-steam-templates` requires custom GodotSteam executables pinned in `steam-dependencies.lock.json` and pointed at by `COSMIC_CLASH_STEAM_CLIENT_GODOT` / `COSMIC_CLASH_STEAM_SERVER_GODOT`. It deliberately refuses a stock Godot binary. Nothing else in the repo needs Steam — the default build and every Docker check are ENet-only. See `STEAM.md`.
### CI
`.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
- The `mcp/godot-mcp` submodule is a separate Node/TypeScript project with its own `npm install` / `npm run build` (see above) — it is tooling, not part of the game itself.
- `Game/tools/` holds editor-run utilities (`bake_arena_boundary.gd`, `gpu_profile_harness.tscn`, `replay_dump.gd` for reading server replay logs); `tools/blender/` and `tools/textures/` hold the Python generators for the original ship/ball/nebula assets.
## Architecture
The structure was deliberately chosen so an RL-trained AI opponent and, later, multiplayer bolt on without rework (see `TODO.md` for the deferred work). The three load-bearing seams are the controller abstraction, the arena/game-mode split, and code-driven spawning.
The structure was deliberately chosen so an RL-trained AI opponent and, later, multiplayer bolt on without rework. The load-bearing seams are the controller abstraction, the arena/game-mode split, code-driven spawning, and (for networking) the pure-function codec/state modules that can be unit-tested without a live connection.
- **Scene flow**: `scenes/main_menu.tscn` (`main_menu.gd`, one handler per mode) → `scenes/free_play.tscn` (practice: no timer, R resets ball) or `scenes/match.tscn` (150s timer, per-team score, kickoff resets). Esc returns to the menu from either mode.
- **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; a future `AIShipController` (RL policy) or network-replication controller implements the same `get_action()` interface. A ship with no controller is inert but simulated. The ShipAction shape *is* the future RL action space — change it deliberately.
- **Arena vs game mode**: `scenes/arena_01.tscn` (`scripts/arena.gd`, group `"arena"`) is a stateless stadium — a setting (space-platform floor, starfield sky, lighting), an enclosing `Boundary` (instance of `objects/arena_boundary.tscn`: floor/walls/ceiling colliders), two `Goal` instances (team 0 and 1), `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. `free_play.gd` and `match_mode.gd` override `_start()` and `_on_goal_scored(conceding_team)`.
### Core game
- **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. **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 formulas are commented inline; see `FLIGHT_MANUAL.md` for the player-facing flight model. Physics properties (mass, inertia, friction material) live in `objects/ship.tscn`, not in `_ready` overrides — keep the scene truthful; RL tuning depends 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. Because it runs inside `Ship`'s shared `_integrate_forces`, it reaches trained bots too; see `TRAINING.md` for the retrain this warrants.
- **Camera** (`scenes/ship_camera_rig.tscn`, `scripts/ship_camera.gd`, group `"ship_camera"`) is spawned by the game mode and given a `target` ship — ships have no camera/HUD dependency, so headless RL runs work (`godot --headless`).
- **HUD / telemetry pattern**: `Ship` emits flight data via signals only when values change past thresholds (`_last_*` fields, `*_THRESHOLD` constants). `HUDController` (`scripts/HUDController.gd` on `scenes/HUD.tscn`, instanced by each mode's scene) discovers the camera rig and game mode via groups (`"ship_camera"`, `"game"`) but receives its target ship directly from the game mode via `GameMode.spawn_camera_rig` (mirroring the camera rig's `target`, not group lookup — the `"ship"` group can have 2+ members), connects to signals, and only updates labels — no polling. Follow this discovery-by-group + signal-push pattern for new instruments or cross-node communication, not hardcoded `get_node` paths or per-frame polling.
- **Input actions** are defined in `Game/project.godot` under `[input]` (`move_forward`, `turn_left`, `turbo`, `reset_ball`, etc.) and read only by `PlayerShipController` (plus mode-level `_unhandled_input` for `reset_ball`/`ui_cancel`) — add new controls there rather than hardcoding key checks.
- Physics engine is Jolt (`Game/project.godot`, `[physics] 3d/physics_engine="Jolt Physics"`).
- **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.
- **Camera** (`scenes/ship_camera_rig.tscn`, `scripts/ship_camera.gd`, group `"ship_camera"`) is spawned by the game mode and given a `target` ship — ships have no camera/HUD dependency, so headless runs work.
- **HUD / telemetry pattern**: `Ship` emits flight data via signals only when values change past thresholds (`_last_*` fields, `*_THRESHOLD` constants). `HUDController` (`scripts/HUDController.gd`) discovers the camera rig and game mode via groups (`"ship_camera"`, `"game"`) but receives its target ship directly from the game mode via `GameMode.spawn_camera_rig`, connects to signals, and only updates labels — no polling. Follow this discovery-by-group + signal-push pattern for new instruments, not hardcoded `get_node` paths or per-frame polling. **`HUDController` duck-types on optional signals** (`timer_updated`, `match_ended`, `kickoff_countdown`, …) and hides the corresponding widget when a mode lacks one — so declaring a signal you never emit is worse than not declaring it (it shows a permanently frozen timer instead of hiding it).
- **`SimConstants.TICK_HZ`** is the single source of truth for the 60 Hz tick — every derived timing constant reads it rather than restating `60`. It is *not* wired to `project.godot`'s `physics/common/physics_ticks_per_second` (an engine setting), so those must be kept in sync by hand.
- **Input actions** are defined in `Game/project.godot` under `[input]` (`move_forward`, `turbo`, `reset_ball`, `toggle_perf_overlay` F3, `toggle_net_overlay` F4, …) and read only by `PlayerShipController` plus mode-level `_unhandled_input` — add new controls there rather than hardcoding key checks.
- Physics engine is Jolt (`[physics] 3d/physics_engine="Jolt Physics"`).
### `project.godot` hazard
Godot's `ConfigFile` writer does not round-trip comments, and a `#` block directly above a setting can be spliced into that setting's own line on rewrite, silently commenting it out. **Do not add comments to `Game/project.godot`.** This matters most for the feature overrides `run/main_scene.training` and `run/main_scene.dedicated_server`, which are how the training and server exports reach the right scene without a CLI flag; `tests/cases/test_project_settings.gd` fails loudly if they ever break.
### Networking (server-authoritative, with client prediction)
All hot-path RPCs live on **autoloads**, never scene nodes, so RPC NodePaths never depend on which scene is loaded. The autoload chain, in `project.godot` order:
- **`NetSim`** (`net_sim.gd`) — debug-only seeded latency/jitter/loss/duplicate decorator around outgoing dispatch. A pure passthrough unless `--net-sim-latency=` / `--net-sim-jitter=` / `--net-sim-loss=` / `--net-sim-dup=` / `--net-sim-seed=` are passed, so its mere existence changes nothing. Each process reads only its own flags, which is what makes asymmetric (e.g. lossy-upload-only) testing free.
- **`NetworkManager`** (`network_manager.gd`) — transport-neutral host/join/shutdown and connection signals. Sets `server_relay = false` the moment a peer exists (the default `true` would let any client RPC any other client through the server). Runs **manual multiplayer polling** (`set_multiplayer_poll_enabled(false)`), because SceneTree's automatic poll runs on the idle frame and would cost a frame in each direction for RPCs issued from `_physics_process` — anything driving a connection must call `NetworkManager.poll()` itself or nothing is ever sent or received.
- **`MatchNet`** (`match_net.gd`) — hello/welcome handshake, strict `protocol_version` and tick-rate gating, and the roster (name, team, ready) that survives the lobby→match transition. Slot assignment is *not* stored here; it's derived at spawn time.
- **`MatchSim`** (`match_sim.gd`) — the Phase 2+ simulation RPCs: `match_config`, `input` (client→server), `snapshot` (server→client), score/state/kickoff/goal/clock messages. Also owns protocol-level input validation (leaky-bucket packet and byte rate limits), because framing abuse is independent of any particular match's state. Emits `input_rejected` with the verbatim bytes so the replay log can explain "my input did nothing".
- **`NetDebugOverlay`** (F4) and **`PerfOverlay`** (F3) — headless-guarded read-only overlays.
Supporting modules, deliberately standalone (`RefCounted`/`class_name`, no scene or RPC dependency) so they unit-test head-on:
- `net_codec.gd`**the wire format**. `PROTOCOL_VERSION`, channel intents, quantisers, pack/unpack for the input and snapshot packets. Any change here is a protocol change.
- `match_state.gd` — the match lifecycle enum and its legal-transition table. **The integer values are the wire format** (`match_state` is a u8 in the snapshot header): never renumber an existing state, only append.
- `net_body_state.gd`, `net_interpolator.gd`, `net_ship_predictor.gd`, `local_prediction_history.gd`, `local_input_timeline.gd`, `input_jitter_buffer.gd`, `input_lead_controller.gd`, `adaptive_input_depth_controller.gd`, `ship_action_codec.gd`, `replay_log.gd`, `server_config.gd`, `server_log.gd` — each has a header comment explaining its role and the task it came from.
`networked_match.gd` (`NetworkedMatch extends GameMode`, ~2.4k lines) is where it all meets: the server simulates every slot and broadcasts 60 Hz snapshots; a client simulates **only its own unfrozen slot** with one real controller while every remote slot and the ball stay frozen and interpolated. Its scene has no Arena or HUD child — both are built in code once the arena is actually known (the server picks it, the client learns it from `match_config`), which is why it overrides `_ready()` entirely rather than using `GameMode`'s arena-required-synchronously flow.
Server process: `scenes/server_boot.tscn` (`server_boot.gd`) is the shell — strict CLI parsing via `ServerConfig` (unknown flag or bad value refuses to start), structured logging via `ServerLog`, physics-overrun watchdog. `ServerMatchLoop` (`server_match_loop.gd`) is the actual match loop: wait for `--min-players` **by roster, not raw peers**, count down, load the next arena from the rotation, run the match, return to the lobby, repeat or drain at `--max-matches`. It parents itself to the scene-tree **root**, never `current_scene`, because `change_scene_to_file` frees the live scene and an orchestrator freed by its own transition can't orchestrate the next one.
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.
## Reinforcement learning / AI bots
See `TRAINING.md` for the full workflow (training, exporting, evaluating, difficulty tiers). Architecture summary:
See `TRAINING.md` for the full workflow. Architecture summary:
- `scenes/training.tscn` (`scripts/training_mode.gd`, extends `GameMode`) is the headless self-play environment: two ships driven by `RLShipController`s, with `ShipAIController` (extends the vendored plugin's `AIController3D`) as the only class touching godot_rl types. The plugin is vendored (not a submodule) at `Game/addons/godot_rl_agents` — see its `VENDORED.md`; its C#/ONNX files are unused.
- `scripts/ship_observations.gd` is the shared observation builder used by both training and in-game inference — never fork or diverge these two paths. Team 1's observations are mirrored (180° about Y) so one policy plays both sides.
- In-game bots: `scripts/ai_ship_controller.gd` (a `ShipController`) runs the exported policy JSON via `scripts/policy_network.gd` (pure-GDScript MLP) — no .NET/ONNX/Python at runtime. Models live in `Game/bots/`; Match mode's `bot_model_path`/`bot_reaction_ticks`/`bot_action_noise` exports configure the opponent.
- Python side lives in `training/` (venv, not committed): `train.py` (SB3 PPO, launches parallel headless Godot instances from source), `export_policy.py` (checkpoint → JSON with parity check), `evaluate.py` (head-to-head eval, appends `training/eval_history.json`).
- `scenes/training.tscn` / `training_elevated.tscn` (`scripts/training_mode.gd`, extends `GameMode`) is the headless self-play environment: two ships driven by `RLShipController`s, with `ShipAIController` (extends the vendored plugin's `AIController3D`) as the only class touching godot_rl types. The plugin is vendored (not a submodule) at `Game/addons/godot_rl_agents` — see its `VENDORED.md`; its C#/ONNX files are unused.
- `scripts/ship_observations.gd` is the shared observation builder used by training, in-game inference, **and** the server — never fork or diverge these paths. Team 1's observations are mirrored (180° about Y) so one policy plays both sides.
- In-game bots: `scripts/ai_ship_controller.gd` (a `ShipController`) runs the exported policy JSON via `scripts/policy_network.gd` (pure-GDScript MLP) — no .NET/ONNX/Python at runtime. Models live in `Game/bots/` (promoted tiers in `Game/bots/promoted/`); Match mode's `bot_model_path`/`bot_reaction_ticks`/`bot_action_noise` exports configure the opponent, with the main menu's `GameSettings` autoload selections overriding them.
- Python side lives in `training/` (venv, not committed): `train.py` (SB3 PPO, launches parallel headless Godot instances from source), `export_policy.py` (checkpoint → JSON with parity check), `evaluate.py` (head-to-head eval, appends `training/eval_history.json`), plus the curriculum drivers (`curriculum.py`, `generation5.py`) and their JSON state files.
- The flattened action space is Box(7) in gymnasium's **sorted-key order**: rotation xyz, thrust xyz, turbo (>0 = on). The fields are `ShipAction`'s, but gymnasium alphabetizes Dict spaces, so the flat order is NOT ShipAction's thrust-first declaration order — `AIShipController._decide` consumes exported policies in sorted order; change the action space only deliberately and everywhere together.
+80 -5
View File
@@ -1,10 +1,12 @@
# Local-only dedicated-server build and verification image. Pin the Godot
# release family used by project.godot; no image is pushed by this repository.
FROM --platform=linux/amd64 barichello/godot-ci:4.7.1 AS project-imported
# barichello/godot-ci:4.7.1 (linux/amd64), resolved 2026-08-29.
FROM --platform=linux/amd64 barichello/godot-ci@sha256:622e5ca81b54cd8038ecf7de5d157b47efc800d7cf635af2eec18a6aee4bab7e AS project-imported
WORKDIR /workspace
RUN apt-get update \
&& apt-get install -y --no-install-recommends libfontconfig1 \
&& rm -rf /var/lib/apt/lists/*
# The pinned headless Godot image already runs imports without fontconfig.
# Do not refresh its old Ubuntu archive here: its historical keyring rejects
# current Noble signatures, while this source-only import stage needs no OS
# packages at all.
COPY Game /workspace/Game
# `--import` starts the editor, waits for resource import to finish, then
# exits. Do not combine it with `--quit`, which ends the editor after one
@@ -28,7 +30,11 @@ RUN sed -i 's|^run/main_scene=.*$|run/main_scene="res://scenes/server_boot.tscn"
&& mkdir -p /opt/cosmic-clash \
&& godot --headless --path Game --export-release "Linux Dedicated Server" /opt/cosmic-clash/CosmicClashServer.x86_64
FROM --platform=linux/amd64 ubuntu:24.04 AS server
# ubuntu:noble linux/amd64 manifest, resolved 2026-09-03. The prior pin
# carried an obsolete archive keyring and rejected current Noble signatures
# during apt-get update. This remains a digest pin; package verification is
# deliberately not bypassed.
FROM --platform=linux/amd64 ubuntu@sha256:1e0a86e57d247923571b75e0aaf48a1449cf8c543d51fb3e07a4a7d7bfa79316 AS server
RUN apt-get update && apt-get install -y --no-install-recommends libfontconfig1 libgl1 libstdc++6 && rm -rf /var/lib/apt/lists/*
COPY --from=exporter /opt/cosmic-clash/ /opt/cosmic-clash/
COPY deploy/cosmic-clash-server /opt/cosmic-clash/cosmic-clash-server
@@ -41,3 +47,72 @@ ENTRYPOINT ["/opt/cosmic-clash/cosmic-clash-server"]
FROM exporter AS smoke-client
WORKDIR /workspace
ENTRYPOINT ["godot", "--headless", "--path", "Game", "res://tests/export_server_smoke.tscn", "--"]
# Builds the process supervisor (server/supervisor, multiplayer-next.md task
# 8.27/8.28) that wraps the Agones-allocated dedicated server as PID 1.
# golang:1.23-alpine (matches server/go.mod's `go 1.23`), resolved 2026-09-01.
FROM --platform=linux/amd64 golang@sha256:383395b794dffa5b53012a212365d40c8e37109a626ca30d6151c8348d380b5f AS supervisor-build
WORKDIR /workspace/server
COPY server/go.mod server/go.sum ./
RUN go mod download
COPY server/ ./
RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/game-server-supervisor ./cmd/game-server-supervisor
RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/control-plane ./cmd/control-plane
RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/testkit-api ./cmd/testkit-api
RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/matcher ./cmd/matcher
RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/allocator ./cmd/allocator
RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/maintenance ./cmd/maintenance
# Agones-allocated fleet image: the same dedicated-server export as `server`
# (unchanged above; make verify-phase6 exercises that target exactly as
# before), wrapped by the Go supervisor as PID 1 instead of the direct
# launcher script -- required for process-ready/assignment-ready Agones SDK
# calls and control-plane registration (multiplayer-next.md §8.27/§8.28).
# deploy/k8s/base/fleet.yaml invokes this target with the deployment-specific
# supervisor flags and mounts the roster/signing material required by the
# allocated startup path. Workload credentials are delivered through the
# Agones allocation annotation; a projected token volume is not required.
FROM server AS game-server
COPY --from=supervisor-build /opt/cosmic-clash/game-server-supervisor /opt/cosmic-clash/game-server-supervisor
RUN chmod 0755 /opt/cosmic-clash/game-server-supervisor
ENTRYPOINT ["/opt/cosmic-clash/game-server-supervisor"]
# The production control-plane API. deploy/k8s/base/control-plane-deployment.yaml
# has always referenced this image, but nothing built it: cmd/control-plane was
# absent from the Go build stage and no target existed, so the checked-in
# Kubernetes base could not produce its own advertised topology.
#
# This must never be substituted with the testkit-api target below, which
# injects a fake login provider that accepts any ticket string.
FROM server AS control-plane
COPY --from=supervisor-build /opt/cosmic-clash/control-plane /opt/cosmic-clash/control-plane
COPY server/migrations /opt/cosmic-clash/migrations
RUN chmod 0755 /opt/cosmic-clash/control-plane
EXPOSE 8080
ENTRYPOINT ["/opt/cosmic-clash/control-plane"]
# TEST ONLY. Supplies a fake Steam login that accepts any ticket; never deploy
# this in place of the control-plane target above.
FROM server AS testkit-api
COPY --from=supervisor-build /opt/cosmic-clash/testkit-api /opt/cosmic-clash/testkit-api
COPY server/migrations /opt/cosmic-clash/migrations
RUN chmod 0755 /opt/cosmic-clash/testkit-api
ENTRYPOINT ["/opt/cosmic-clash/testkit-api"]
FROM server AS matcher
COPY --from=supervisor-build /opt/cosmic-clash/matcher /opt/cosmic-clash/matcher
COPY server/migrations /opt/cosmic-clash/migrations
RUN chmod 0755 /opt/cosmic-clash/matcher
ENTRYPOINT ["/opt/cosmic-clash/matcher"]
FROM server AS allocator
COPY --from=supervisor-build /opt/cosmic-clash/allocator /opt/cosmic-clash/allocator
COPY server/migrations /opt/cosmic-clash/migrations
RUN chmod 0755 /opt/cosmic-clash/allocator
ENTRYPOINT ["/opt/cosmic-clash/allocator"]
FROM server AS maintenance
COPY --from=supervisor-build /opt/cosmic-clash/maintenance /opt/cosmic-clash/maintenance
COPY server/migrations /opt/cosmic-clash/migrations
RUN chmod 0755 /opt/cosmic-clash/maintenance
ENTRYPOINT ["/opt/cosmic-clash/maintenance"]
+52 -12
View File
@@ -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
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
File diff suppressed because one or more lines are too long
+44 -25
View File
@@ -25,24 +25,10 @@ run/main_scene.dedicated_server="res://scenes/server_boot.tscn"
[autoload]
GameSettings="*res://scripts/game_settings.gd"
ControlPlaneClient="*res://scripts/control_plane_client.gd"
VideoSettings="*res://scripts/video_settings.gd"
InputSettings="*res://scripts/input_settings.gd"
BackgroundFPS="*res://scripts/background_fps.gd"
PerfOverlay="*res://scripts/perf_overlay.gd"
NetSim="*res://scripts/net_sim.gd"
@@ -50,6 +36,7 @@ NetworkManager="*res://scripts/network_manager.gd"
MatchNet="*res://scripts/match_net.gd"
MatchSim="*res://scripts/match_sim.gd"
NetDebugOverlay="*res://scripts/net_debug_overlay.gd"
AudioManager="*res://scripts/audio_manager.gd"
[display]
@@ -69,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={
@@ -122,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={
@@ -143,7 +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)
@@ -164,15 +183,15 @@ toggle_net_overlay={
[physics]
common/physics_jitter_fix=0.0
3d/physics_engine="Jolt Physics"
common/physics_interpolation=true
common/physics_jitter_fix=0.0
[rendering]
anti_aliasing/quality/msaa_3d=2
anti_aliasing/quality/screen_space_aa=1
anti_aliasing/quality/use_debanding=true
lights_and_shadows/positional_shadow/atlas_size=2048
lights_and_shadows/directional_shadow/size=2048
anti_aliasing/quality/msaa_3d=2
anti_aliasing/quality/use_debanding=true
anti_aliasing/quality/screen_space_aa=1
lights_and_shadows/positional_shadow/atlas_size=2048
lights_and_shadows/soft_shadow_filter_quality=2
+38 -22
View File
@@ -1,6 +1,7 @@
[gd_scene load_steps=2 format=3]
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://scripts/lobby.gd" id="1_lobby"]
[ext_resource type="Theme" path="res://themes/cosmic_clash_theme.tres" id="2_theme"]
[node name="Lobby" type="Control"]
layout_mode = 3
@@ -10,27 +11,42 @@ anchor_bottom = 1.0
grow_horizontal = 2
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
@@ -39,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"]
+73 -50
View File
@@ -1,6 +1,7 @@
[gd_scene load_steps=2 format=3 uid="uid://bcq14356s3e2i"]
[gd_scene load_steps=3 format=3 uid="uid://bcq14356s3e2i"]
[ext_resource type="Script" path="res://scripts/main_menu.gd" id="1_menu"]
[ext_resource type="Theme" path="res://themes/cosmic_clash_theme.tres" id="2_theme"]
[node name="MainMenu" type="Control"]
layout_mode = 3
@@ -10,120 +11,141 @@ anchor_bottom = 1.0
grow_horizontal = 2
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="HostButton" 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="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
@@ -131,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
@@ -145,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"
@@ -272,11 +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/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"]
+119
View File
@@ -0,0 +1,119 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://scripts/matchmaking.gd" id="1_matchmaking"]
[ext_resource type="Theme" path="res://themes/cosmic_clash_theme.tres" id="2_theme"]
[node name="Matchmaking" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_matchmaking")
theme = ExtResource("2_theme")
[node name="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="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="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="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
[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="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="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
modulate = Color(1, 1, 1, 0.65)
layout_mode = 2
text = "Ranked profile unavailable"
horizontal_alignment = 1
visible = false
[node name="QueueButton" type="Button" parent="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="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="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[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
size_flags_horizontal = 3
text = "Accept"
visible = false
[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
size_flags_horizontal = 3
text = "Decline"
visible = false
[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="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"]
+131 -45
View File
@@ -1,6 +1,8 @@
[gd_scene load_steps=2 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
@@ -10,70 +12,90 @@ anchor_bottom = 1.0
grow_horizontal = 2
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
@@ -84,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
@@ -111,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
@@ -138,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"]
+152
View File
@@ -0,0 +1,152 @@
class_name AgonesSDK
extends Node
# Dependency-free REST bridge for the Agones sidecar. The Go supervisor owns
# the process-ready probe and /ready transition; this node owns the game
# process's periodic Health pings and terminal Shutdown/annotation calls.
const HEALTH_INTERVAL_SECONDS := 2.0
const REQUEST_TIMEOUT_SECONDS := 2.0
const MAX_ANNOTATION_VALUE_LENGTH := 4096
var _base_url := ""
var _health_timer: Timer = null
var _health_in_flight := false
var _health_started_msec := 0
# Set when start_health() is called before this node is inside the tree, so
# _ready() can arm the timer at the first moment it is legal to do so.
var _health_pending := false
# Health is armed here rather than by the caller. A Timer only ticks while its
# owner is inside the SceneTree, so arming it from a caller that has not yet
# parented this node produces a node that looks configured and never pings —
# which is exactly how every allocated GameServer silently failed its Agones
# health check and was recycled.
func _ready() -> void:
if _health_pending:
_health_pending = false
_arm_health()
func configure_from_environment() -> bool:
var port := OS.get_environment("AGONES_SDK_HTTP_PORT")
if port.is_empty() or not port.is_valid_int() or int(port) < 1 or int(port) > 65535:
return false
_base_url = "http://127.0.0.1:%d" % int(port)
return true
func configure_for_testing(base_url: String) -> bool:
if not base_url.begins_with("http://127.0.0.1:") and not base_url.begins_with("http://localhost:"):
return false
_base_url = base_url.trim_suffix("/")
return true
func is_available() -> bool:
return not _base_url.is_empty()
# Returns whether health pings are running. It is a bool rather than void
# because every way this can fail used to be silent, and a game server that
# believes it is healthy while sending nothing is worse than one that refuses
# to start: Agones recycles the former every ~20 seconds forever.
func start_health() -> bool:
if not is_available():
push_error("AgonesSDK: start_health() before configuration; no health pings will be sent")
return false
if _health_timer != null:
return true
if not is_inside_tree():
# Deferred rather than fatal: the caller may legitimately configure
# before parenting. _ready() arms it. Still reported, because if the
# node is never parented this is the whole failure.
_health_pending = true
push_warning("AgonesSDK: start_health() called outside the tree; deferring until ready")
return false
_arm_health()
return true
func _arm_health() -> void:
if _health_timer != null:
return
_health_timer = Timer.new()
_health_timer.name = "AgonesHealth"
_health_timer.wait_time = HEALTH_INTERVAL_SECONDS
_health_timer.one_shot = false
_health_timer.timeout.connect(_send_health)
add_child(_health_timer)
_health_timer.start()
_send_health()
func health_is_running() -> bool:
return _health_timer != null and is_inside_tree()
func stop_health() -> void:
if _health_timer != null:
_health_timer.stop()
_health_timer.queue_free()
_health_timer = null
func health() -> int:
return await _request(HTTPClient.METHOD_POST, "/health", {})
func mark_ready() -> int:
return await _request(HTTPClient.METHOD_POST, "/ready", {})
func shutdown() -> int:
return await _request(HTTPClient.METHOD_POST, "/shutdown", {})
func set_annotation(key: String, value: String) -> int:
if not annotation_is_valid(key, value):
return 400
return await _request(HTTPClient.METHOD_PUT, "/metadata/annotation", {"key": key, "value": value})
static func annotation_is_valid(key: String, value: String) -> bool:
return not (key.is_empty() or value.is_empty() or value.length() > MAX_ANNOTATION_VALUE_LENGTH or "\n" in key or "\r" in key or "\n" in value or "\r" in value)
func _send_health() -> void:
if not is_available():
return
# The latch stops overlapping requests, but it must never become permanent.
# It is set across an await, and a request that never completes would
# otherwise silence health for the lifetime of the process. HTTPRequest's
# own timeout normally resolves this; the elapsed check is the backstop for
# the case where request_completed never fires at all.
if _health_in_flight:
var stuck_for := Time.get_ticks_msec() - _health_started_msec
if stuck_for < int(REQUEST_TIMEOUT_SECONDS * 2.0 * 1000.0):
return
push_warning("Agones health ping did not complete in %dms; sending another" % stuck_for)
_health_in_flight = true
_health_started_msec = Time.get_ticks_msec()
var status := await health()
_health_in_flight = false
if status < 200 or status >= 300:
push_warning("Agones health ping failed (%d)" % status)
func _request(method: int, path: String, payload: Dictionary) -> int:
if not is_available() or not path.begins_with("/"):
return 408
var request := HTTPRequest.new()
request.timeout = REQUEST_TIMEOUT_SECONDS
add_child(request)
var body := JSON.stringify(payload)
var err := request.request(_base_url + path, PackedStringArray(["Content-Type: application/json"]), method, body)
if err != OK:
request.queue_free()
return 599
var result = await request.request_completed
request.queue_free()
return int(result[1])
+85
View File
@@ -0,0 +1,85 @@
class_name AssignmentState
extends RefCounted
# Verified assignment-ready manifest returned by the control plane. The join
# authorisation is retained in memory only and is never written to the restart
# snapshot; transport installation belongs to the explicit ENet/Steam layer.
var available := false
var match_id := ""
var server_id := ""
var slot := -1
var expires_at := ""
var protocol_version := 0
var transport := ""
var endpoint := ""
var join_authorisation := ""
var error_message := ""
func apply(payload: Dictionary, expected_player_id: String = "") -> bool:
for key in ["match_id", "server_id", "player_id", "slot", "expires_at", "protocol_version", "transport", "endpoint", "join_authorisation"]:
if not payload.has(key):
return _reject("Assignment response is missing " + key)
if not payload["match_id"] is String or not payload["server_id"] is String or not payload["player_id"] is String or not _valid_nonnegative_integer(payload["slot"]) or not payload["expires_at"] is String or not _valid_nonnegative_integer(payload["protocol_version"]) or not payload["transport"] is String or not payload["endpoint"] is String or not payload["join_authorisation"] is String:
return _reject("Assignment response contains invalid types")
var next_match_id := String(payload["match_id"])
var next_server_id := String(payload["server_id"])
var next_transport := String(payload["transport"])
var next_endpoint := String(payload["endpoint"])
var next_player_id := String(payload["player_id"])
var next_expires_at := String(payload["expires_at"])
if not is_valid_expiry_timestamp(next_expires_at):
return _reject("Assignment response contains invalid expiry")
var expiry_unix := Time.get_unix_time_from_datetime_string(next_expires_at)
if not is_valid_opaque_id(next_match_id) or not is_valid_opaque_id(next_server_id) or not is_valid_opaque_id(next_player_id) or (not expected_player_id.is_empty() and next_player_id != expected_player_id) or int(payload["slot"]) < 0 or int(payload["slot"]) > 5 or int(payload["protocol_version"]) < 1 or (next_transport != "enet" and next_transport != "steam_sdr") or not _valid_endpoint(next_endpoint) or expiry_unix <= Time.get_unix_time_from_system() or String(payload["join_authorisation"]).is_empty():
return _reject("Assignment response contains invalid values")
match_id = next_match_id
server_id = next_server_id
slot = int(payload["slot"])
expires_at = next_expires_at
protocol_version = int(payload["protocol_version"])
transport = next_transport
endpoint = next_endpoint
join_authorisation = String(payload["join_authorisation"])
available = true
error_message = ""
return true
static func is_valid_expiry_timestamp(value: String) -> bool:
if value.is_empty():
return false
var timestamp_pattern := RegEx.create_from_string("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$")
return timestamp_pattern.search(value) != null
static func is_valid_opaque_id(value: String) -> bool:
if value.length() < 16 or value.length() > 128:
return false
var resource_pattern := RegEx.create_from_string("^[A-Za-z0-9_-]+$")
return resource_pattern.search(value) != null
static func _valid_endpoint(value: String) -> bool:
if value.is_empty() or value.contains("/") or value.contains("?") or value.contains("#"):
return false
var separator := value.rfind(":")
if separator <= 0 or separator >= value.length() - 1:
return false
var port := value.substr(separator + 1)
return port.is_valid_int() and int(port) >= 1 and int(port) <= 65535
static func _valid_nonnegative_integer(value: Variant) -> bool:
if value is int:
return int(value) >= 0
if value is float:
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
return false
func _reject(reason: String) -> bool:
available = false
error_message = reason
return false
+149
View File
@@ -0,0 +1,149 @@
extends Node
# Dependency-free audio foundation. Authored assets can replace these tones
# later without changing gameplay call sites or the multiplayer event flow.
const SAMPLE_RATE := 44100
const MAX_INTENSITY := 1.0
var enabled := true
var _engine_player: AudioStreamPlayer
var _engine_turbo := false
var _last_wall_scrape_ms := -1000
func bind_tree_buttons(root: Node) -> void:
if root == null:
return
for node in root.find_children("*", "BaseButton", true, false):
bind_button(node as BaseButton)
func bind_button(button: BaseButton) -> void:
if button == null:
return
var callback := Callable(self, "play_ui_click")
if not button.pressed.is_connected(callback):
button.pressed.connect(callback)
func play_ui_click() -> void:
_play_tone(880.0, 0.045, 0.10)
func play_countdown(count: int) -> void:
if count <= 0:
_play_tone(1046.5, 0.12, 0.18)
else:
_play_tone(countdown_frequency(count), 0.08, 0.14)
func play_impact(intensity: float) -> void:
var amount := clamp_intensity(intensity)
if amount <= 0.0:
return
_play_tone(150.0 + 180.0 * amount, 0.06 + 0.08 * amount, 0.08 + 0.18 * amount)
func play_wall_scrape(intensity: float) -> void:
var amount := clamp_intensity(intensity)
if amount <= 0.0:
return
var now_ms := Time.get_ticks_msec()
if now_ms - _last_wall_scrape_ms < 80:
return
_last_wall_scrape_ms = now_ms
_play_tone(110.0 + 90.0 * amount, 0.05 + 0.07 * amount, 0.05 + 0.10 * amount)
func play_goal() -> void:
_play_tone(523.25, 0.22, 0.22)
_play_tone(783.99, 0.30, 0.18)
func set_engine_state(thrust: float, turbo: bool) -> void:
var amount := clamp_intensity(thrust)
var rising_turbo := should_play_turbo_cue(_engine_turbo, turbo, amount)
if not enabled or amount <= 0.01:
stop_engine()
return
if rising_turbo:
_play_tone(260.0, 0.16, 0.16)
_engine_turbo = turbo
if _engine_player == null or not is_instance_valid(_engine_player):
_engine_player = AudioStreamPlayer.new()
_engine_player.stream = _engine_stream()
add_child(_engine_player)
_engine_player.play()
_engine_player.pitch_scale = engine_pitch(amount, turbo)
_engine_player.volume_db = linear_to_db(engine_volume(amount, turbo))
func stop_engine() -> void:
if _engine_player != null and is_instance_valid(_engine_player):
_engine_player.stop()
_engine_turbo = false
static func engine_pitch(thrust: float, turbo: bool) -> float:
var amount := clamp_intensity(thrust)
return 0.75 + amount * 0.55 + (0.30 if turbo and amount > 0.01 else 0.0)
static func engine_volume(thrust: float, turbo: bool) -> float:
var amount := clamp_intensity(thrust)
return clampf(0.015 + amount * 0.045 + (0.025 if turbo and amount > 0.01 else 0.0), 0.0, 0.1)
static func should_play_turbo_cue(previous_turbo: bool, turbo: bool, thrust: float) -> bool:
return turbo and not previous_turbo and clamp_intensity(thrust) > 0.01
static func clamp_intensity(value: float) -> float:
if not is_finite(value):
return 0.0
return clampf(value, 0.0, MAX_INTENSITY)
static func countdown_frequency(count: int) -> float:
return 440.0 + float(clampi(count, 1, 9)) * 55.0
func _play_tone(frequency: float, duration: float, volume: float) -> void:
if not enabled or frequency <= 0.0 or duration <= 0.0 or volume <= 0.0:
return
var stream := AudioStreamWAV.new()
stream.format = AudioStreamWAV.FORMAT_16_BITS
stream.mix_rate = SAMPLE_RATE
stream.stereo = false
stream.data = _tone_data(frequency, duration, volume)
var player := AudioStreamPlayer.new()
player.stream = stream
add_child(player)
player.finished.connect(player.queue_free)
player.play()
func _engine_stream() -> AudioStreamWAV:
var stream := AudioStreamWAV.new()
stream.format = AudioStreamWAV.FORMAT_16_BITS
stream.mix_rate = SAMPLE_RATE
stream.stereo = false
stream.loop_mode = AudioStreamWAV.LOOP_FORWARD
stream.data = _tone_data(92.0, 1.0, 0.65)
stream.loop_end = SAMPLE_RATE
return stream
func _tone_data(frequency: float, duration: float, volume: float) -> PackedByteArray:
var frames := maxi(1, int(duration * SAMPLE_RATE))
var data := PackedByteArray()
data.resize(frames * 2)
for index in frames:
var envelope := minf(1.0, float(index) / 256.0) * minf(1.0, float(frames - index) / 1024.0)
var sample := int(sin(TAU * frequency * float(index) / SAMPLE_RATE) * volume * envelope * 32767.0)
if sample < 0:
sample += 65536
data[index * 2] = sample & 0xff
data[index * 2 + 1] = (sample >> 8) & 0xff
return data
+153
View File
@@ -0,0 +1,153 @@
class_name ConnectionLeaseClient
extends Node
const AssignmentState = preload("res://scripts/assignment_state.gd")
signal reconciliation_failed(reason: String)
const CLAIMED := "claimed"
const UNAVAILABLE := "unavailable"
const REJECTED := "rejected"
var _base_url := ""
var _workload_token := ""
var _match_id := ""
var _server_id := ""
var _pending: Array[Dictionary] = []
var _processing := false
func configure(base_url: String, workload_token: String, match_id: String, server_id: String) -> bool:
base_url = base_url.strip_edges().trim_suffix("/")
workload_token = workload_token.strip_edges()
if not valid_configuration(base_url, workload_token, match_id, server_id):
return false
_base_url = base_url
_workload_token = workload_token
_match_id = match_id
_server_id = server_id
return true
# Admission awaits one bounded request only. If the control plane is down, the
# same process may continue using its local generation and this exact event is
# retained ahead of every later disconnect/reconnect for ordered reconciliation.
func claim(player_id: String, expected_generation: int) -> Dictionary:
if not AssignmentState.is_valid_opaque_id(player_id) or expected_generation < 0:
return {"status": REJECTED}
var event := _connect_event(player_id, expected_generation)
if _processing or not _pending.is_empty():
if expected_generation == 0:
return {"status": REJECTED}
_pending.append(event)
_start_processing()
return {"status": UNAVAILABLE, "generation": expected_generation + 1}
var response := await _send(event, true)
if String(response.get("status", "")) == UNAVAILABLE:
if expected_generation == 0:
return {"status": REJECTED}
_pending.append(event)
_start_processing()
return {"status": UNAVAILABLE, "generation": expected_generation + 1}
return response
func record_disconnect(player_id: String, generation: int) -> void:
if not AssignmentState.is_valid_opaque_id(player_id) or generation < 1:
return
_pending.append(_disconnect_event(player_id, generation))
_start_processing()
func _start_processing() -> void:
if _processing or _pending.is_empty() or not is_inside_tree():
return
_process_pending()
func _process_pending() -> void:
_processing = true
while not _pending.is_empty() and is_inside_tree():
var event := _pending[0]
var response := await _send(event)
var status := String(response.get("status", ""))
if status == CLAIMED:
_pending.pop_front()
continue
if status == REJECTED:
reconciliation_failed.emit("durable connection lease conflict")
_processing = false
return
await get_tree().create_timer(1.0).timeout
_processing = false
func _send(event: Dictionary, allow_recovery := false) -> Dictionary:
var request := HTTPRequest.new()
request.timeout = 1.0
add_child(request)
var operation := String(event["operation"])
var endpoint := "%s/v1/servers/%s/%s" % [_base_url, _server_id.uri_encode(), operation]
var start_error := request.request(endpoint, [
"Authorization: Bearer " + _workload_token,
"Content-Type: application/json",
"Idempotency-Key: " + String(event["key"]),
], HTTPClient.METHOD_POST, JSON.stringify(event["payload"]))
if start_error != OK:
request.queue_free()
return {"status": UNAVAILABLE}
var raw: Array = await request.request_completed
request.queue_free()
return classify_response(operation, int(event["generation"]), int(raw[0]), int(raw[1]), raw[3], allow_recovery)
func _connect_event(player_id: String, expected_generation: int) -> Dictionary:
return {
"operation": "connect",
"generation": expected_generation,
"key": event_key(_match_id, player_id, "connect", expected_generation),
"payload": {"player_id": player_id, "expected_generation": expected_generation},
}
func _disconnect_event(player_id: String, generation: int) -> Dictionary:
return {
"operation": "disconnect",
"generation": generation,
"key": event_key(_match_id, player_id, "disconnect", generation),
"payload": {"player_id": player_id, "generation": generation},
}
static func classify_response(operation: String, generation: int, request_result: int, response_code: int, body: PackedByteArray, allow_recovery := false) -> Dictionary:
if request_result != HTTPRequest.RESULT_SUCCESS or response_code == 0 or response_code == 429 or response_code >= 500:
return {"status": UNAVAILABLE}
if operation == "disconnect" and response_code == 204:
return {"status": CLAIMED, "generation": generation}
if operation == "connect" and response_code == 200:
var decoded = JSON.parse_string(body.get_string_from_utf8())
if decoded is Dictionary and _valid_generation(decoded.get("generation")):
var claimed_generation := int(decoded["generation"])
if claimed_generation == generation + 1 or (allow_recovery and generation == 0 and claimed_generation > 1):
return {"status": CLAIMED, "generation": claimed_generation}
return {"status": REJECTED}
static func _valid_generation(value: Variant) -> bool:
if value is int:
return int(value) >= 1
if value is float:
return is_finite(float(value)) and float(value) >= 1.0 and float(value) == floor(float(value)) and float(value) <= 9007199254740991.0
return false
static func event_key(match_id: String, player_id: String, operation: String, generation: int) -> String:
return "server-lease-" + (match_id + "\n" + player_id + "\n" + operation + "\n" + str(generation)).sha256_text()
static func valid_configuration(base_url: String, workload_token: String, match_id: String, server_id: String) -> bool:
if not (base_url.begins_with("http://") or base_url.begins_with("https://")) or base_url.contains("\n") or base_url.contains("\r") or base_url.contains("?") or base_url.contains("#") or base_url.contains("@"):
return false
if workload_token.is_empty() or workload_token.contains("\n") or workload_token.contains("\r"):
return false
return AssignmentState.is_valid_opaque_id(match_id) and AssignmentState.is_valid_opaque_id(server_id)
+889
View File
@@ -0,0 +1,889 @@
extends Node
# Authenticated HTTP boundary for matchmaking. ENet/Steam carries the match
# itself; this client only handles queue/proposal control-plane state.
signal request_succeeded(operation: String, payload: Dictionary)
signal request_failed(operation: String, http_code: int, detail: String)
signal session_expired()
signal probe_challenge_received(region: String, nonce_base64: String)
signal probe_recorded(region: String, server_rtt_ms: int)
signal session_changed(player_id: String)
signal websocket_event(event: Dictionary)
signal websocket_status_changed(status: String)
signal assignment_connection_started(assignment: AssignmentState)
signal assignment_connection_failed(detail: String)
const DEFAULT_BASE_URL := "http://127.0.0.1:8080"
# Release builds must point at the real control plane rather than a developer's
# loopback. The environment variable is read at startup so the same binary can
# be pointed at a staging or production endpoint without a rebuild.
const BASE_URL_ENV := "COSMIC_CLASH_CONTROL_PLANE_URL"
const PERSIST_PATH := "user://matchmaking_state.cfg"
const AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS := 5.0
var base_url := DEFAULT_BASE_URL
var access_token := ""
var auth_expired := false
var player_id := ""
var session_expires_at := ""
var state: MatchmakingState
var ranked_profile: RankedProfileState
var assignment: AssignmentState
var _request: HTTPRequest
var _operation := ""
var _last_queue_create: Dictionary = {}
var _last_mutation: Dictionary = {}
var _last_mutation_retryable := false
var _websocket: WebSocketPeer
var _websocket_status := "DISCONNECTED"
var _websocket_retry_seconds := 0.0
var _websocket_backoff := 1.0
var _authoritative_recovery_seconds := AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS
var _pending_proposal_id := ""
var _pending_assignment_match_id := ""
var _pending_resync_resource_id := ""
# The final wiring step of the matchmaking pipeline: once state.phase reaches
# ASSIGNED, the client must actually start the game transport. connect_to_assignment()
# already existed with correct validation/signal behavior, but nothing ever
# called it -- a player would sit on "Your match server is ready" forever.
# These two fields defer the connect attempt until the assignment fetch
# (triggered independently, earlier, by ASSIGNMENT_READY) has actually
# completed, and prevent a duplicate/replayed ASSIGNED update from firing a
# second connection attempt for the same match.
var _pending_connect_match_id := ""
var _connect_attempted_match_id := ""
func _ready() -> void:
state = MatchmakingState.new()
ranked_profile = RankedProfileState.new()
assignment = AssignmentState.new()
_load_persisted_state()
state.changed.connect(_persist_state)
_request = HTTPRequest.new()
_request.timeout = 10.0
add_child(_request)
_request.request_completed.connect(_on_request_completed)
state.resync_required.connect(_on_resync_required)
_websocket = WebSocketPeer.new()
assignment_connection_failed.connect(_on_assignment_connection_failed)
NetworkManager.connection_failed.connect(_on_network_connection_failed)
# Covers §8.43's "failed reconnect UX": connect_to_assignment()'s own
# synchronous failures (assignment missing/expired, invalid endpoint,
# NetworkManager.join() erroring immediately) previously only emitted
# assignment_connection_failed -- a signal nothing in the client actually
# listened to. state.phase would stay stuck at ASSIGNED, the UI would keep
# showing "Your match server is ready" forever, and there was no way back to
# a fresh search.
func _on_assignment_connection_failed(detail: String) -> void:
state.fail(detail)
# The likelier real-world failure than the synchronous one above:
# NetworkManager.join() returns OK immediately (the attempt started), but the
# actual ENet handshake fails asynchronously later -- unreachable server,
# refused connection, ENet's own ~5s connect timeout. This is exactly the gap
# main_menu.gd's own _on_connection_failed exists to cover for the direct-join
# flow (see its header comment); nothing covered it for a matchmaking-driven
# connect. Guarded to CONNECTING so this never reacts to an unrelated
# connection_failed, such as one belonging to main_menu.gd's own direct join.
func _on_network_connection_failed() -> void:
if state.phase == MatchmakingState.CONNECTING:
state.fail("Unable to connect to the match server")
func _process(_delta: float) -> void:
if not auth_expired and is_session_expired(session_expires_at):
_expire_session()
if _websocket == null:
return
_websocket.poll()
var ready_state := _websocket.get_ready_state()
if ready_state == WebSocketPeer.STATE_OPEN:
_websocket_retry_seconds = 0.0
_websocket_backoff = 1.0
_set_websocket_status("CONNECTED")
while _websocket.get_available_packet_count() > 0:
_handle_websocket_packet(_websocket.get_packet())
elif ready_state == WebSocketPeer.STATE_CONNECTING:
_set_websocket_status("CONNECTING")
elif ready_state == WebSocketPeer.STATE_CLOSED:
_set_websocket_status("DISCONNECTED")
if not auth_expired and is_valid_access_token(access_token):
_websocket_retry_seconds -= _delta
if _websocket_retry_seconds <= 0.0:
_websocket_retry_seconds = _websocket_backoff
_websocket_backoff = minf(_websocket_backoff * 2.0, 30.0)
connect_event_stream()
if not _pending_proposal_id.is_empty() and _operation.is_empty() and not player_id.is_empty():
var proposal_id := _pending_proposal_id
_pending_proposal_id = ""
recover_proposal(proposal_id)
elif not _pending_assignment_match_id.is_empty() and _operation.is_empty() and not player_id.is_empty():
var match_id := _pending_assignment_match_id
_pending_assignment_match_id = ""
fetch_assignment(match_id)
if not _pending_connect_match_id.is_empty() and _assignment_ready_for(_pending_connect_match_id):
var match_id := _pending_connect_match_id
_pending_connect_match_id = ""
_connect_attempted_match_id = match_id
connect_to_assignment()
_poll_authoritative_recovery(_delta)
# The assignment fetch (triggered independently by ASSIGNMENT_READY, which
# always precedes ASSIGNED) and the ASSIGNED transition that should start the
# transport can arrive in either order. This is the shared readiness check
# both _connect_when_assigned and the deferred _process retry above use.
func _assignment_ready_for(match_id: String) -> bool:
return assignment != null and assignment.available and assignment.match_id == match_id and _assignment_is_fresh(assignment)
# Starts (or defers, if the assignment fetch triggered by the earlier
# ASSIGNMENT_READY event hasn't completed yet) the game transport once the
# ticket-state machine reaches ASSIGNED. connect_to_assignment() itself
# already existed with full validation and failure signalling; nothing ever
# called it, so a player reaching "Your match server is ready" never actually
# connected. _connect_attempted_match_id guards against a duplicate/replayed
# ASSIGNED update firing a second connection attempt for the same match.
func _connect_when_assigned(match_id: String) -> void:
if state.phase != MatchmakingState.ASSIGNED or not is_valid_resource_id(match_id) or match_id == _connect_attempted_match_id:
return
if _assignment_ready_for(match_id):
_connect_attempted_match_id = match_id
connect_to_assignment()
else:
_pending_connect_match_id = match_id
# configured_base_url resolves the endpoint this build should use, preferring
# explicit configuration over the loopback development default.
static func configured_base_url() -> String:
var configured := OS.get_environment(BASE_URL_ENV).strip_edges()
if is_valid_base_url(configured):
return configured
return DEFAULT_BASE_URL
# has_session reports whether matchmaking requests can be made at all. Without
# it every request fails ERR_UNAUTHORIZED at the first guard in _start_request.
func has_session() -> bool:
return not access_token.is_empty() and not is_session_expired(session_expires_at)
func configure(url: String, token: String) -> bool:
var normalized := url.strip_edges().trim_suffix("/")
var normalized_token := token.strip_edges()
if not is_valid_base_url(normalized) or not is_valid_access_token(normalized_token):
return false
base_url = normalized
access_token = normalized_token
session_expires_at = ""
auth_expired = false
if _websocket != null:
connect_event_stream()
return true
func connect_event_stream() -> Error:
if not is_valid_access_token(access_token) or auth_expired or not is_valid_base_url(base_url):
return ERR_UNAUTHORIZED
var socket_url := websocket_url(base_url) + "/v1/events"
_websocket = WebSocketPeer.new()
# Godot 4.7 moved handshake headers onto WebSocketPeer; the second
# connect_to_url argument is TLSOptions, not an HTTP header array. Keep the
# bearer token in the authenticated handshake without putting it in the URL.
_websocket.handshake_headers = PackedStringArray(["Authorization: Bearer " + access_token])
var err := _websocket.connect_to_url(socket_url)
if err != OK:
_set_websocket_status("DISCONNECTED")
return err
_websocket_retry_seconds = 0.0
_set_websocket_status("CONNECTING")
return OK
func disconnect_event_stream() -> void:
if _websocket != null:
_websocket.close()
_websocket_retry_seconds = 0.0
_websocket_backoff = 1.0
_set_websocket_status("DISCONNECTED")
static func websocket_url(url: String) -> String:
if url.begins_with("https://"):
return "wss://" + url.trim_prefix("https://")
if url.begins_with("http://"):
return "ws://" + url.trim_prefix("http://")
return ""
func queue_create(ticket_id: String, playlist: String, client_build: String, protocol_version: int) -> Error:
if not is_valid_resource_id(ticket_id) or (playlist != "casual" and playlist != "ranked") or client_build.is_empty() or protocol_version < 1:
return ERR_INVALID_PARAMETER
if not state.begin_queue(ticket_id, playlist):
return ERR_INVALID_PARAMETER
var key := _idempotency_key("queue")
_last_queue_create = {"ticket_id": ticket_id, "playlist": playlist, "client_build": client_build, "protocol_version": protocol_version, "key": key}
var err := _start_request("queue_create", HTTPClient.METHOD_POST, "/v1/queue", {"ticket_id": ticket_id, "playlist": playlist, "client_build": client_build, "protocol_version": protocol_version}, key)
if err != OK:
state.fail("Could not start matchmaking: %s" % error_string(err))
return err
# Regional latency probing. The backend issues a single-use nonce, the client
# echoes it back with its opaque platform location, and the backend derives the
# round trip from its own timestamps -- no client-measured latency is accepted.
#
# Until a ticket has RTT evidence for at least one region the matcher will not
# consider it (server/domain.validCandidate requires a non-empty map), so this
# has to complete before searching is meaningful.
const PROBE_REGIONS := ["EU", "NA"]
func request_probe_challenge(region: String) -> Error:
if not is_valid_probe_region(region):
return ERR_INVALID_PARAMETER
return _start_request("probe_challenge_" + region, HTTPClient.METHOD_POST, "/v1/probes/%s/challenge" % region, {}, "")
func submit_probe_answer(region: String, nonce_base64: String, opaque_location_base64: String) -> Error:
if not is_valid_probe_region(region) or nonce_base64.is_empty() or opaque_location_base64.is_empty():
return ERR_INVALID_PARAMETER
return _start_request("probe_answer_" + region, HTTPClient.METHOD_POST, "/v1/probes/%s" % region, {
"nonce": nonce_base64,
"opaque_location": opaque_location_base64,
}, "")
static func is_valid_probe_region(region: String) -> bool:
return region == "EU" or region == "NA"
# The platform location is opaque to us by design: the backend treats it as a
# blob and never derives placement from anything the client measured. Without a
# Steam runtime there is nothing to report, so send a stable non-empty marker
# rather than failing the probe -- the RTT is what actually matters and that is
# measured by the backend either way.
static func opaque_location_payload() -> String:
if Engine.has_singleton("Steam"):
var steam := Engine.get_singleton("Steam")
if steam.has_method("getLocalPingLocation"):
var location = steam.call("getLocalPingLocation")
if location is String and not String(location).is_empty():
return Marshalls.utf8_to_base64(String(location))
return Marshalls.utf8_to_base64("no-platform-ping-location")
func login_steam(web_api_ticket: String) -> Error:
if not is_valid_web_api_ticket(web_api_ticket):
return ERR_INVALID_PARAMETER
return _start_request("steam_session", HTTPClient.METHOD_POST, "/v1/session/steam", {"web_api_ticket": web_api_ticket}, "")
func retry_queue_create() -> Error:
if _last_queue_create.is_empty() or not _last_queue_create.has("ticket_id"):
return ERR_INVALID_DATA
var ticket_id := String(_last_queue_create["ticket_id"])
var playlist := String(_last_queue_create["playlist"])
if not state.begin_queue(ticket_id, playlist):
return ERR_INVALID_PARAMETER
var err := _start_request("queue_create", HTTPClient.METHOD_POST, "/v1/queue", {"ticket_id": ticket_id, "playlist": playlist, "client_build": String(_last_queue_create["client_build"]), "protocol_version": int(_last_queue_create["protocol_version"])}, String(_last_queue_create["key"]))
if err != OK:
state.fail("Could not retry matchmaking: %s" % error_string(err))
return err
func can_retry_queue_create() -> bool:
return not _last_queue_create.is_empty() and state.phase == MatchmakingState.FAILED and String(_last_queue_create.get("ticket_id", "")) == state.ticket_id
func retry_last_mutation() -> Error:
if not can_retry_last_mutation():
return ERR_INVALID_DATA
var request := _last_mutation.duplicate(true)
return _start_request(String(request["operation"]), int(request["method"]), String(request["path"]), request["payload"], String(request["key"]), int(request["expected_revision"]))
func can_retry_last_mutation() -> bool:
return _last_mutation_retryable and not _last_mutation.is_empty() and _operation.is_empty() and not auth_expired and is_valid_access_token(access_token)
func recover_queue(ticket_id: String) -> Error:
if not is_valid_resource_id(ticket_id):
return ERR_INVALID_PARAMETER
return _start_request("queue_recover", HTTPClient.METHOD_GET, "/v1/queue/" + ticket_id, {}, "")
func recover_proposal(proposal_id: String) -> Error:
if not is_valid_resource_id(proposal_id):
return ERR_INVALID_PARAMETER
return _start_request("proposal_recover", HTTPClient.METHOD_GET, "/v1/proposals/" + proposal_id, {}, "")
static func resync_target(resource_id: String, ticket_id: String, proposal_id: String, proposal_open: bool) -> String:
if resource_id == ticket_id and not ticket_id.is_empty():
return ticket_id
if resource_id == proposal_id and not proposal_id.is_empty():
return proposal_id if proposal_open else ticket_id
return ""
func fetch_ranked_profile() -> Error:
return _start_request("ranked_profile", HTTPClient.METHOD_GET, "/v1/profile/ranked", {}, "")
func fetch_assignment(match_id: String) -> Error:
if not is_valid_resource_id(match_id) or player_id.is_empty():
return ERR_INVALID_PARAMETER
return _start_request("assignment", HTTPClient.METHOD_GET, "/v1/assignments/" + match_id, {}, "")
# Starts the assigned game transport only after AssignmentState has validated
# the complete player-scoped manifest. The signed authorisation is passed to
# MatchNet's hello RPC, never appended to the endpoint URL or logged. Server
# admission remains authoritative; this method only owns the client-side
# readiness/transport boundary.
func connect_to_assignment() -> Error:
if assignment == null or not assignment.available or not _assignment_is_fresh(assignment):
var unavailable_detail := "Match assignment is unavailable or expired"
assignment_connection_failed.emit(unavailable_detail)
return ERR_UNAUTHORIZED
var endpoint := _split_assignment_endpoint(assignment.endpoint)
if endpoint.is_empty():
var invalid_detail := "Match assignment endpoint is invalid"
assignment_connection_failed.emit(invalid_detail)
return ERR_INVALID_PARAMETER
var transport := NetworkManager.TRANSPORT_ENET if assignment.transport == "enet" else NetworkManager.TRANSPORT_STEAM
MatchNet.join_authorisation = assignment.join_authorisation
state.mark_connecting()
var err := NetworkManager.join(String(endpoint["host"]), int(endpoint["port"]), transport)
if err != OK:
MatchNet.join_authorisation = ""
assignment_connection_failed.emit("Unable to connect to match server")
return err
assignment_connection_started.emit(assignment)
return OK
static func _assignment_is_fresh(value: AssignmentState) -> bool:
if value == null or not AssignmentState.is_valid_expiry_timestamp(value.expires_at):
return false
var expiry := Time.get_unix_time_from_datetime_string(value.expires_at)
return expiry > Time.get_unix_time_from_system()
static func _split_assignment_endpoint(value: String) -> Dictionary:
if not AssignmentState._valid_endpoint(value):
return {}
var separator := value.rfind(":")
return {"host": value.substr(0, separator), "port": int(value.substr(separator + 1))}
func heartbeat(ticket_id: String, expected_revision: int) -> Error:
if not is_valid_resource_id(ticket_id) or expected_revision < 0:
return ERR_INVALID_PARAMETER
return _start_request("queue_heartbeat", HTTPClient.METHOD_POST, "/v1/queue/%s/heartbeat" % ticket_id, {}, _idempotency_key("heartbeat"), expected_revision)
func cancel_queue(ticket_id: String, expected_revision: int) -> Error:
if not is_valid_resource_id(ticket_id) or expected_revision < 0 or not state.can_cancel():
return ERR_INVALID_PARAMETER
return _start_request("queue_cancel", HTTPClient.METHOD_POST, "/v1/queue/%s/cancel" % ticket_id, {}, _idempotency_key("cancel"), expected_revision)
func respond_to_proposal(proposal_id: String, accept: bool, expected_revision: int) -> Error:
if not is_valid_resource_id(proposal_id) or expected_revision < 0:
return ERR_INVALID_PARAMETER
var action := "accept" if accept else "decline"
return _start_request("proposal_" + action, HTTPClient.METHOD_POST, "/v1/proposals/%s/%s" % [proposal_id, action], {}, _idempotency_key("proposal"), expected_revision)
static func is_valid_base_url(url: String) -> bool:
if url.is_empty() or url.contains(" ") or url.contains("\r") or url.contains("\n") or url.contains("?") or url.contains("#") or url.contains("@") or url.ends_with("/"):
return false
return url.begins_with("http://") or url.begins_with("https://")
static func is_valid_web_api_ticket(ticket: String) -> bool:
return not ticket.is_empty() and ticket.length() <= 4096 and not ticket.contains("\r") and not ticket.contains("\n")
static func is_valid_access_token(token: String) -> bool:
var separator := token.find(":")
return separator > 0 and separator < token.length() - 1 and token.length() <= 4096 and not token.contains("\r") and not token.contains("\n")
static func is_session_expired(expires_at: String, now_unix: int = -1) -> bool:
if expires_at.is_empty():
return false
if not is_valid_rfc3339_timestamp(expires_at):
return true
var expiry_unix := Time.get_unix_time_from_datetime_string(expires_at)
if expiry_unix < 0:
return true
var current_unix := now_unix
if current_unix < 0:
current_unix = int(Time.get_unix_time_from_system())
return expiry_unix <= current_unix
static func is_valid_session_response(payload: Dictionary) -> bool:
if not payload.has("expires_at") or not payload["expires_at"] is String:
return false
var expires_at := String(payload["expires_at"])
return is_valid_rfc3339_timestamp(expires_at) and not is_session_expired(expires_at)
static func is_valid_rfc3339_timestamp(value: String) -> bool:
if value.is_empty():
return false
var timestamp_pattern := RegEx.create_from_string("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$")
if timestamp_pattern.search(value) == null:
return false
var year := int(value.substr(0, 4))
var month := int(value.substr(5, 2))
var day := int(value.substr(8, 2))
var hour := int(value.substr(11, 2))
var minute := int(value.substr(14, 2))
var second := int(value.substr(17, 2))
if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59:
return false
var days_in_month := [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
var leap_year := year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
if leap_year:
days_in_month[1] = 29
if day < 1 or day > days_in_month[month - 1]:
return false
var timezone_index := value.find("+", 19)
if timezone_index < 0:
timezone_index = value.find("-", 19)
if timezone_index >= 0:
var offset_hour := int(value.substr(timezone_index + 1, 2))
var offset_minute := int(value.substr(timezone_index + 4, 2))
if offset_hour > 23 or offset_minute > 59:
return false
return true
static func is_valid_resource_id(value: String) -> bool:
if value.length() < 16 or value.length() > 128:
return false
var resource_pattern := RegEx.create_from_string("^[A-Za-z0-9_-]+$")
return resource_pattern.search(value) != null
static func is_retryable_mutation_response(response_code: int) -> bool:
return response_code == 0 or response_code == HTTPClient.RESPONSE_REQUEST_TIMEOUT or response_code == HTTPClient.RESPONSE_TOO_MANY_REQUESTS or response_code >= 500
static func should_recover_queue_after_conflict(operation: String, response_code: int, ticket_id: String) -> bool:
return response_code == HTTPClient.RESPONSE_CONFLICT and operation in ["queue_heartbeat", "queue_cancel"] and not ticket_id.is_empty()
static func normalize_ticket(payload: Dictionary) -> Dictionary:
var result := payload.duplicate(true)
for pair in [["enqueued_at", "enqueued_at_unix"], ["expires_at", "expires_at_unix"]]:
var source_key: String = pair[0]
var target_key: String = pair[1]
if not result.has(source_key):
continue
if not result[source_key] is String or not is_valid_rfc3339_timestamp(String(result[source_key])):
result[target_key] = -1
else:
result[target_key] = Time.get_unix_time_from_datetime_string(String(result[source_key]))
return result
static func normalize_proposal(payload: Dictionary) -> Dictionary:
var result := payload.duplicate(true)
if not result.has("expires_at"):
return result
if not result["expires_at"] is String or not is_valid_rfc3339_timestamp(String(result["expires_at"])):
result["expires_at_unix"] = -1
else:
result["expires_at_unix"] = int(Time.get_unix_time_from_datetime_string(String(result["expires_at"])))
return result
func _start_request(operation: String, method: HTTPClient.Method, path: String, payload: Dictionary, idempotency_key: String, expected_revision: int = -1) -> Error:
if _request == null or not _operation.is_empty() or not is_valid_base_url(base_url):
return ERR_BUSY if not _operation.is_empty() else ERR_UNAUTHORIZED
if operation != "steam_session" and access_token.is_empty():
return ERR_UNAUTHORIZED
if operation != "steam_session" and is_session_expired(session_expires_at):
_expire_session()
return ERR_UNAUTHORIZED
var headers := PackedStringArray(["Accept: application/json"])
if operation != "steam_session":
headers.append("Authorization: Bearer " + access_token)
if not idempotency_key.is_empty():
headers.append("Idempotency-Key: " + idempotency_key)
if expected_revision >= 0:
headers.append("If-Match-Revision: %d" % expected_revision)
var body := "" if payload.is_empty() else JSON.stringify(payload)
_operation = operation
var err := _request.request(base_url + path, headers, method, body)
if err != OK:
_operation = ""
return err
if not idempotency_key.is_empty():
_last_mutation = {"operation": operation, "method": method, "path": path, "payload": payload.duplicate(true), "key": idempotency_key, "expected_revision": expected_revision}
_last_mutation_retryable = false
return OK
func _expire_session() -> void:
if auth_expired:
return
access_token = ""
auth_expired = true
disconnect_event_stream()
state.fail("Session expired; sign in again")
ranked_profile.set_error("Session expired; sign in again")
session_expired.emit()
func _on_request_completed(result: HTTPRequest.Result, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
var operation := _operation
_operation = ""
if result != HTTPRequest.RESULT_SUCCESS:
_last_mutation_retryable = _last_mutation.get("operation", "") == operation
if operation == "ranked_profile":
ranked_profile.set_error("Ranked profile request failed")
elif operation == "queue_create":
state.fail("Control-plane request failed")
elif operation == "queue_recover" or operation == "proposal_recover":
state.set_notice("Could not refresh matchmaking state; retrying")
else:
state.set_notice("Control-plane request failed; retrying is safe")
request_failed.emit(operation, response_code, "network error")
return
var parsed = JSON.parse_string(body.get_string_from_utf8())
if not parsed is Dictionary:
_last_mutation_retryable = _last_mutation.get("operation", "") == operation
if operation == "ranked_profile":
ranked_profile.set_error("Ranked profile returned invalid JSON")
elif operation == "queue_create":
state.fail("Control-plane returned invalid JSON")
elif operation == "queue_recover" or operation == "proposal_recover":
state.set_notice("Could not refresh matchmaking state; retrying")
else:
state.set_notice("Control-plane returned invalid JSON; retrying is safe")
request_failed.emit(operation, response_code, "invalid JSON")
return
if response_code < 200 or response_code >= 300:
_last_mutation_retryable = _last_mutation.get("operation", "") == operation and is_retryable_mutation_response(response_code)
var detail := String(parsed.get("error", "request rejected"))
var recover_proposal_after_conflict := response_code == HTTPClient.RESPONSE_CONFLICT and (operation == "proposal_accept" or operation == "proposal_decline") and not state.proposal_id.is_empty()
var recover_queue_after_conflict := should_recover_queue_after_conflict(operation, response_code, state.ticket_id)
if response_code == HTTPClient.RESPONSE_UNAUTHORIZED:
access_token = ""
auth_expired = true
disconnect_event_stream()
state.fail("Session expired; sign in again")
ranked_profile.set_error("Session expired; sign in again")
session_expired.emit()
elif response_code == HTTPClient.RESPONSE_GONE and operation == "queue_recover":
state.expire("Queue ticket expired")
elif response_code == HTTPClient.RESPONSE_SERVICE_UNAVAILABLE:
state.set_notice("Matchmaking is temporarily unavailable; retrying is safe")
elif response_code == HTTPClient.RESPONSE_UPGRADE_REQUIRED and operation == "queue_create":
# Distinct from the generic queue_create failure below: retrying
# with the same client build can never succeed, so the retry
# offer must not be shown (can_retry_queue_create() checks
# _last_queue_create; clearing it here suppresses "Retry Search").
_last_queue_create = {}
state.fail("Your client is out of date -- please update to continue searching")
elif operation == "ranked_profile":
ranked_profile.set_error(detail)
elif response_code == HTTPClient.RESPONSE_NOT_FOUND and (operation == "queue_recover" or operation == "proposal_recover"):
state.fail("Matchmaking record is no longer available")
elif operation == "queue_create" or operation == "queue_recover" or operation == "proposal_recover":
state.fail(detail)
else:
state.set_notice(detail)
request_failed.emit(operation, response_code, detail)
if recover_proposal_after_conflict:
_pending_resync_resource_id = state.proposal_id
call_deferred("_run_pending_resync")
if recover_queue_after_conflict:
_pending_resync_resource_id = state.ticket_id
call_deferred("_run_pending_resync")
return
var payload: Dictionary = parsed
_last_mutation_retryable = false
if operation == "steam_session":
var returned_token := String(payload.get("access_token", ""))
var returned_player_id := String(payload.get("player_id", ""))
if not is_valid_resource_id(returned_player_id) or not is_valid_access_token(returned_token) or not is_valid_session_response(payload):
request_failed.emit(operation, response_code, "invalid session response")
return
player_id = returned_player_id
access_token = returned_token
auth_expired = false
session_expires_at = String(payload.get("expires_at", ""))
connect_event_stream()
session_changed.emit(player_id)
elif operation == "queue_create" or operation == "queue_recover" or operation == "queue_heartbeat" or operation == "queue_cancel":
if not _valid_queue_response(payload):
state.fail("Queue response contains invalid contract data")
request_failed.emit(operation, response_code, "invalid queue response")
return
if operation == "queue_create":
state.begin_queue(String(payload["ticket_id"]), String(payload.get("playlist", "")))
elif operation.begins_with("proposal_"):
if not _valid_proposal_response(payload):
state.fail("Proposal response contains an invalid proposal identifier")
request_failed.emit(operation, response_code, "invalid proposal identifier")
return
if operation.begins_with("queue_"):
if state.apply_ticket_update(normalize_ticket(payload), operation == "queue_recover"):
_queue_proposal_if_ready(payload)
_queue_assignment_if_ready(payload)
_connect_when_assigned(String(payload.get("match_id", "")))
elif operation.begins_with("proposal_"):
state.apply_proposal_update(normalize_proposal(payload))
elif operation == "ranked_profile":
if not ranked_profile.apply(payload):
request_failed.emit(operation, response_code, ranked_profile.error_message)
return
elif operation == "assignment":
if not assignment.apply(payload, player_id):
request_failed.emit(operation, response_code, assignment.error_message)
return
elif operation.begins_with("probe_challenge_"):
# Answer immediately: the nonce is single-use and short-lived, and the
# interval to this answer is exactly what the backend measures.
var challenge_region := operation.trim_prefix("probe_challenge_")
var nonce := String(payload.get("nonce", ""))
if nonce.is_empty():
request_failed.emit(operation, response_code, "probe challenge did not include a nonce")
return
probe_challenge_received.emit(challenge_region, nonce)
elif operation.begins_with("probe_answer_"):
probe_recorded.emit(operation.trim_prefix("probe_answer_"), int(payload.get("server_rtt_ms", -1)))
request_succeeded.emit(operation, payload)
if not _pending_resync_resource_id.is_empty():
call_deferred("_run_pending_resync")
func _handle_websocket_packet(packet: PackedByteArray) -> void:
var parsed = JSON.parse_string(packet.get_string_from_utf8())
if not parsed is Dictionary or not _valid_websocket_event(parsed):
websocket_status_changed.emit("INVALID_EVENT")
return
var event: Dictionary = parsed
websocket_event.emit(event)
var event_name := String(event["event"])
if event_name == "state_changed":
# Allocation and match lifecycle rows are keyed by match ID, not ticket
# ID. Recover the owner-scoped ticket projection instead of feeding the
# match revision/resource into the ticket reducer. ASSIGNMENT_READY also
# carries the durable lookup key, so the assignment fetch can follow the
# recovery request without depending on a circular assignment_changed
# notification from the assignment GET itself.
if event.has("match_id"):
var match_id := String(event["match_id"])
_on_resync_required(state.ticket_id)
if String(event["state"]) == "ASSIGNMENT_READY":
_pending_assignment_match_id = match_id
return
var update := event.duplicate(true)
update["ticket_id"] = String(event["resource_id"])
if not state.apply_ticket_update(update):
return
elif event_name == "proposal_changed":
var proposal_update := event.duplicate(true)
proposal_update["proposal_id"] = String(event["resource_id"])
if state.prepare_proposal_recovery(String(proposal_update["proposal_id"])):
state.apply_proposal_update(proposal_update)
elif event_name == "assignment_changed":
state.mark_assignment_ready()
_pending_assignment_match_id = String(event["match_id"])
elif event_name == "error":
state.set_notice("Control-plane error: %s" % String(event["code"]))
_on_resync_required(String(event["resource_id"]))
static func _valid_websocket_event(event: Dictionary) -> bool:
if not event.has("event") or not event["event"] is String or String(event["event"]).is_empty():
return false
if not event.has("revision") or not _valid_revision(event["revision"]):
return false
if not event.has("resource_id") or not event["resource_id"] is String or not is_valid_resource_id(String(event["resource_id"])):
return false
if not event.has("occurred_at") or not event["occurred_at"] is String or not is_valid_rfc3339_timestamp(String(event["occurred_at"])):
return false
var event_name := String(event["event"])
if event_name == "assignment_changed":
return event.has("match_id") and event["match_id"] is String and is_valid_resource_id(String(event["match_id"])) and event.has("server_id") and event["server_id"] is String and is_valid_resource_id(String(event["server_id"]))
if event_name == "error":
return event.has("code") and String(event["code"]) in ["REVISION_GAP", "NOT_AUTHORISED", "INVALID_STATE", "RATE_LIMITED"]
if event_name == "state_changed":
if not event.has("state") or String(event["state"]) not in ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"]:
return false
if event.has("match_id"):
return event["match_id"] is String and is_valid_resource_id(String(event["match_id"])) and String(event["match_id"]) == String(event["resource_id"])
return true
if event_name == "proposal_changed":
return event.has("state") and String(event["state"]) in ["OPEN", "ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"]
return false
static func _valid_revision(value: Variant) -> bool:
if value is int:
return int(value) >= 0
if value is float:
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
return false
static func _valid_response_opaque_id(payload: Dictionary, key: String) -> bool:
return payload.has(key) and payload[key] is String and is_valid_resource_id(String(payload[key]))
static func _valid_queue_response(payload: Dictionary) -> bool:
for key in ["ticket_id", "player_id", "playlist", "state", "revision", "enqueued_at", "expires_at"]:
if not payload.has(key):
return false
if not _valid_response_opaque_id(payload, "ticket_id") or not _valid_response_opaque_id(payload, "player_id"):
return false
if not payload["playlist"] is String or not String(payload["playlist"]) in ["casual", "ranked"]:
return false
if not payload["state"] is String or not String(payload["state"]) in ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"]:
return false
if payload.has("match_id"):
if not payload["match_id"] is String or not is_valid_resource_id(String(payload["match_id"])):
return false
if String(payload["state"]) not in ["ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "FAILED", "CANCELLED"]:
return false
if payload.has("proposal_id"):
if not payload["proposal_id"] is String or not is_valid_resource_id(String(payload["proposal_id"])):
return false
if String(payload["state"]) != "PROPOSED":
return false
if payload.has("match_id") and payload.has("proposal_id"):
return false
if not _valid_revision(payload["revision"]):
return false
return payload["enqueued_at"] is String and is_valid_rfc3339_timestamp(String(payload["enqueued_at"])) and payload["expires_at"] is String and is_valid_rfc3339_timestamp(String(payload["expires_at"]))
func _queue_assignment_if_ready(payload: Dictionary) -> void:
if String(payload.get("state", "")) != "ASSIGNMENT_READY":
return
var match_id := String(payload.get("match_id", ""))
if is_valid_resource_id(match_id):
_pending_assignment_match_id = match_id
func _queue_proposal_if_ready(payload: Dictionary) -> void:
if String(payload.get("state", "")) != "PROPOSED":
return
var proposal_id := String(payload.get("proposal_id", ""))
if is_valid_resource_id(proposal_id) and state.prepare_proposal_recovery(proposal_id):
_pending_proposal_id = proposal_id
func _poll_authoritative_recovery(delta: float) -> void:
if auth_expired or not is_valid_access_token(access_token) or state.ticket_id.is_empty() or state.phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.COMPLETED]:
_authoritative_recovery_seconds = AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS
return
_authoritative_recovery_seconds -= maxf(0.0, delta)
if _authoritative_recovery_seconds > 0.0 or not _operation.is_empty():
return
_authoritative_recovery_seconds = AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS
var resource_id := state.proposal_id if state.has_open_proposal() else state.ticket_id
_run_resync(resource_id)
static func _valid_proposal_response(payload: Dictionary) -> bool:
if not _valid_response_opaque_id(payload, "proposal_id") or not payload.has("expires_at") or not payload["expires_at"] is String or not is_valid_rfc3339_timestamp(String(payload["expires_at"])) or not payload.has("participants") or not payload["participants"] is Array:
return false
var participants: Array = payload["participants"]
if participants.size() < 2 or participants.size() > 6:
return false
var seen := {}
for participant in participants:
if not participant is Dictionary:
return false
if not participant.has("player_id") or not participant["player_id"] is String or not is_valid_resource_id(String(participant["player_id"])) or seen.has(String(participant["player_id"])):
return false
if not participant.has("response") or not participant["response"] is String or not String(participant["response"]) in ["PENDING", "ACCEPTED", "DECLINED", "TIMED_OUT"]:
return false
if not participant.has("team") or not participant.has("slot") or not _valid_revision(participant["team"]) or not _valid_revision(participant["slot"]):
return false
var team := int(participant["team"])
var slot := int(participant["slot"])
if team > 1 or slot > 5 or slot / 3 != team:
return false
seen[String(participant["player_id"])] = true
return true
func _on_resync_required(resource_id: String) -> void:
if not _operation.is_empty():
_pending_resync_resource_id = resource_id
return
_run_resync(resource_id)
func _run_pending_resync() -> void:
if not _operation.is_empty() or _pending_resync_resource_id.is_empty():
return
var resource_id := _pending_resync_resource_id
_pending_resync_resource_id = ""
_run_resync(resource_id)
func _run_resync(resource_id: String) -> void:
var target := resync_target(resource_id, state.ticket_id, state.proposal_id, state.has_open_proposal())
if target == state.ticket_id and not state.ticket_id.is_empty():
recover_queue(state.ticket_id)
elif target == state.proposal_id and not state.proposal_id.is_empty():
recover_proposal(state.proposal_id)
func _set_websocket_status(status: String) -> void:
if _websocket_status == status:
return
_websocket_status = status
websocket_status_changed.emit(status)
if status == "CONNECTED":
if not state.ticket_id.is_empty() and state.phase not in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE, MatchmakingState.COMPLETED]:
var resource_id := state.proposal_id if state.has_open_proposal() else state.ticket_id
if _operation.is_empty():
_run_resync(resource_id)
else:
# A reconnect must not lose its authoritative recovery merely because
# the previous mutation has not acknowledged yet. The deferred path
# runs after that request completes and avoids an ERR_BUSY drop.
_pending_resync_resource_id = resource_id
func _idempotency_key(prefix: String) -> String:
return "%s-%s-%s" % [prefix, str(Time.get_ticks_usec()), str(randi())]
func _persist_state(snapshot: Dictionary) -> void:
var config := ConfigFile.new()
config.set_value("matchmaking", "snapshot", JSON.stringify(snapshot))
config.save(PERSIST_PATH)
func _load_persisted_state() -> void:
var config := ConfigFile.new()
if config.load(PERSIST_PATH) != OK:
return
var raw = config.get_value("matchmaking", "snapshot", "")
if not raw is String or String(raw).is_empty():
return
var parsed = JSON.parse_string(String(raw))
if parsed is Dictionary and not state.restore_snapshot(parsed):
state.fail("Saved matchmaking state is invalid")
+188
View File
@@ -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."
+1
View File
@@ -0,0 +1 @@
uid://dk7plirjfqvld
+1
View File
@@ -0,0 +1 @@
uid://505pjuvqynm0
+7 -1
View File
@@ -136,6 +136,7 @@ func _play_goal_celebration(scoring_team: int, conceding_team: int) -> void:
# real-time presentation delay between episodes.
if DisplayServer.get_name() == "headless" or not is_instance_valid(_camera_rig):
return
AudioManager.play_goal()
var goal_position := Vector3.ZERO
for goal in arena.get_goals():
if goal.team == conceding_team:
@@ -182,6 +183,7 @@ func spawn_camera_rig(target: Ship) -> ShipCameraRig:
var rig: ShipCameraRig = CAMERA_RIG_SCENE.instantiate()
add_child(rig)
_camera_rig = rig
rig.impact_feedback.connect(AudioManager.play_impact)
rig.target = target
# Also wires the scene's static HUD (if any) to the same ship, rather
# than letting it guess via the "ship" group.
@@ -274,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)
+2 -2
View File
@@ -1,7 +1,7 @@
class_name InputJitterBuffer
extends RefCounted
# Per-player server-side input state (multiplayer-todo.md §3, task 3.2).
# Per-player server-side input state (MULTIPLAYER_SPEC.md §3; multiplayer-next.md task 3.2).
# Deliberately a standalone RefCounted with no scene/RPC dependency — same
# reason net_codec.gd and net_interpolator.gd are pure classes — so task
# 3.5's unit tests can drive it with scripted arrival traces with no live
@@ -18,7 +18,7 @@ extends RefCounted
# class's, since only the caller knows the current server tick.
const RING_SIZE := 32
# 500ms at 60Hz (multiplayer-todo.md §3.2's own numbers) — a duration, not a
# 500ms at 60Hz (MULTIPLAYER_SPEC.md §3.2's own numbers) — a duration, not a
# tick-rate-derived constant, so left as a literal rather than pulling in
# SimConstants for one number.
const STARVE_ZERO_TICKS := 30
+1 -1
View File
@@ -1,7 +1,7 @@
class_name InputLeadController
extends RefCounted
# Client-owned input_lead control loop (multiplayer-todo.md §3.3, task 3.3).
# Client-owned input_lead control loop (MULTIPLAYER_SPEC.md §3.3; multiplayer-next.md task 3.3).
# Standalone RefCounted, same reason as input_jitter_buffer.gd — scene-free
# so it's directly unit-testable against scripted depth traces.
#
+329
View File
@@ -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))
+1
View File
@@ -0,0 +1 @@
uid://bjtdsbem7kwdv
+13 -1
View File
@@ -9,7 +9,7 @@ extends Control
# of something else: change_scene_to_file() operates on
# get_tree().current_scene, and _on_disconnected_from_server()/_leave()
# below call it themselves, which hangs if this scene isn't actually the
# tree's current_scene when that happens (see multiplayer-todo.md §9
# tree's current_scene when that happens (see multiplayer-next.md §9
# gotcha 27 — found the hard way while building tests/lobby_smoke.gd).
@onready var _status_label: Label = %StatusLabel
@@ -19,6 +19,7 @@ extends Control
@onready var _switch_team_button: Button = %SwitchTeamButton
@onready var _ready_button: CheckButton = %ReadyButton
@onready var _leave_button: Button = %LeaveButton
var _planned_server_shutdown := false
func _ready() -> void:
@@ -27,6 +28,7 @@ func _ready() -> void:
MatchNet.player_left.connect(_on_roster_changed)
MatchNet.player_state_changed.connect(_on_roster_changed)
MatchNet.rejected.connect(_on_rejected)
MatchNet.server_shutdown.connect(_on_server_shutdown)
NetworkManager.disconnected_from_server.connect(_on_disconnected_from_server)
# The server process is never a roster member (§1.1 decision 2) — it
@@ -34,6 +36,9 @@ func _ready() -> void:
_controls_row.visible = NetworkManager.is_client
_refresh()
if not MatchNet.last_server_shutdown_reason.is_empty():
_planned_server_shutdown = true
_status_label.text = "Server closed: %s" % MatchNet.last_server_shutdown_reason
func _process(_delta: float) -> void:
@@ -61,7 +66,14 @@ func _on_rejected(reason: String) -> void:
_status_label.text = "Connection rejected: %s" % reason
func _on_server_shutdown(reason: String) -> void:
_planned_server_shutdown = true
_status_label.text = "Server closed: %s" % reason
func _on_disconnected_from_server() -> void:
if _planned_server_shutdown:
return
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
+12 -1
View File
@@ -3,7 +3,7 @@ extends RefCounted
const NetBodyState = preload("res://scripts/net_body_state.gd")
# Client-owned local-ship prediction history (multiplayer-todo.md §4.3).
# Client-owned local-ship prediction history (MULTIPLAYER_SPEC.md §4.3).
# This is deliberately independent of NetworkedMatch and the scene tree so
# sequence/ring behaviour can be tested from scripted traces. Each entry is
# tagged with its full sequence number: an old value in a wrapped slot is
@@ -73,6 +73,7 @@ const RING_SIZE := 128
var _ring_seq: PackedInt32Array = PackedInt32Array()
var _ring_entry: Array = []
var _has_recorded := false
var _first_recorded_seq := -1
var newest_recorded_seq := -1
var last_acknowledged_seq := 0
@@ -94,6 +95,7 @@ func begin_epoch() -> void:
_ring_seq[i] = -1
_ring_entry[i] = null
_has_recorded = false
_first_recorded_seq = -1
newest_recorded_seq = -1
last_acknowledged_seq = 0
resync_required = false
@@ -106,6 +108,8 @@ func begin_epoch() -> void:
func record(seq: int, action: ShipAction, state: NetBodyState, contact_window: bool = false) -> bool:
var overflowed_now := false
if not _has_recorded or seq > newest_recorded_seq:
if not _has_recorded:
_first_recorded_seq = seq
if seq - last_acknowledged_seq > RING_SIZE:
# Only the LEADING edge of an episode counts: resync_required is
# still true for every subsequent tick of the same stall, and
@@ -140,6 +144,8 @@ func record(seq: int, action: ShipAction, state: NetBodyState, contact_window: b
func record_unsimulated(seq: int, action: ShipAction) -> bool:
var overflowed_now := false
if not _has_recorded or seq > newest_recorded_seq:
if not _has_recorded:
_first_recorded_seq = seq
if seq - last_acknowledged_seq > RING_SIZE:
overflowed_now = not resync_required
resync_required = true
@@ -289,6 +295,11 @@ func compare_authoritative(seq: int, authoritative: NetBodyState) -> Dictionary:
func _missing_status(seq: int) -> String:
# The server starts its acknowledgement clock at sequence 0, while the
# first local post-step prediction is normally sequence 1. This is a normal
# startup boundary, not a lost ring entry and must not trigger a hard snap.
if not _has_recorded or seq < _first_recorded_seq:
return "warmup_not_recorded"
if _has_recorded and seq <= newest_recorded_seq - RING_SIZE:
return "missing_evicted"
return "missing_not_recorded"
+12 -2
View File
@@ -37,6 +37,7 @@ const DIFFICULTIES := [
func _ready() -> void:
AudioManager.bind_tree_buttons(self)
# An idle menu has no reason to render past the display's own refresh
# rate; gameplay scenes are uncapped again by _leave_to_gameplay below.
var refresh_rate := DisplayServer.screen_get_refresh_rate()
@@ -51,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
@@ -119,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, "")
@@ -172,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:
@@ -190,6 +196,10 @@ func _on_host_pressed() -> void:
_leave_to_lobby()
func _on_find_match_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/matchmaking.tscn")
func _on_join_pressed() -> void:
_start_join()
+2
View File
@@ -122,6 +122,7 @@ func _run_kickoff_countdown() -> void:
_set_frozen(true)
for count in range(KICKOFF_COUNTDOWN_SECONDS, 0, -1):
kickoff_countdown.emit(count)
AudioManager.play_countdown(count)
# process_always=false: if full-time fires mid-countdown (see
# _on_match_timer_timeout's get_tree().paused = true), this stalls
# harmlessly in lockstep with the pause instead of ticking a
@@ -132,6 +133,7 @@ func _run_kickoff_countdown() -> void:
if _match_over:
return
kickoff_countdown.emit(0)
AudioManager.play_countdown(0)
_set_frozen(false)
+396 -11
View File
@@ -1,7 +1,7 @@
extends Node
# Autoload (project.godot [autoload] MatchNet). Handshake + roster layer on
# top of NetworkManager's raw transport (§2.5, §1.3 of multiplayer-todo.md).
# top of NetworkManager's raw transport (§2.5, §1.3 of MULTIPLAYER_SPEC.md).
# hello/welcome, strict protocol_version and physics_ticks_per_second
# gating, player_joined/player_left, and — since lobby.tscn (task 1.5) needs
# somewhere durable to keep it across the lobby→match scene transition —
@@ -11,14 +11,19 @@ extends Node
const NetCodec = preload("res://scripts/net_codec.gd")
const SimConstants = preload("res://scripts/sim_constants.gd")
const AssignmentState = preload("res://scripts/assignment_state.gd")
signal player_joined(peer_id: int, player_name: String)
signal player_left(peer_id: int)
signal player_state_changed(peer_id: int, team: int, ready: bool)
signal rejected(reason: String) # client-side only: the server refused our hello
signal welcomed() # client-side only: our hello was accepted
signal server_shutdown(reason: String) # client-side notification before planned close
signal result_submission_accepted
signal result_submission_retrying(http_code: int)
const TEAM_COUNT := 2
const RECONNECT_GRACE_SECONDS := 60.0
# player_name is the one client-supplied value in _hello that gets broadcast
# verbatim to every other peer (protocol_version/tick_hz are checked, never
@@ -36,18 +41,40 @@ const MAX_PLAYER_NAME_LENGTH := 24
class PlayerInfo:
var peer_id: int
var player_name: String
var player_identity: String
var team: int = 0
var spawn_index: int = -1
var ready: bool = false
func _init(p_peer_id: int, p_player_name: String, p_team: int = 0, p_ready: bool = false) -> void:
func _init(p_peer_id: int, p_player_name: String, p_team: int = 0, p_ready: bool = false, p_player_identity: String = "") -> void:
peer_id = p_peer_id
player_name = p_player_name
player_identity = p_player_identity
team = p_team
ready = p_ready
var roster: Dictionary = {} # peer_id (int) -> PlayerInfo. Never contains peer 1 (the server; §1.1 decision 2 — dedicated servers are never a player).
var local_player_name := "Player"
var last_server_shutdown_reason := ""
# Set by the assignment connection path. Direct-IP/community-server joins keep
# this empty for backwards compatibility; allocated matches carry the opaque
# signed authorisation in hello rather than putting it in the endpoint URL.
var join_authorisation := ""
var require_join_authorisation := false
var admissions_open := true
var _allowed_join_authorisations: Dictionary = {}
var _active_join_peers: Dictionary = {} # opaque authorisation -> peer_id
var _join_history: Dictionary = {} # token -> {generation, lost_at}
var _join_authorisation_context: Dictionary = {}
# Key ID -> raw HMAC key. A set rather than a single key so a signing-key
# rotation does not invalidate authorisations already issued for in-flight
# matches: the allocator signs with the new key while servers still accept
# both, and the old key is dropped once no live match can reference it.
var _join_signing_keys := {}
var _connection_lease_claim := Callable()
var _connection_lease_disconnect := Callable()
var _result_submit := Callable()
# Test hook (tests/match_net_smoke.gd): set false before connecting to
# suppress the automatic real hello, so a test can send a deliberately
@@ -64,8 +91,9 @@ func _ready() -> void:
func _on_connected_to_server() -> void:
roster.clear()
last_server_shutdown_reason = ""
if _auto_hello:
_hello.rpc_id(1, NetCodec.PROTOCOL_VERSION, SimConstants.TICK_HZ, local_player_name)
_hello.rpc_id(1, NetCodec.PROTOCOL_VERSION, SimConstants.TICK_HZ, local_player_name, join_authorisation)
func _on_disconnected_from_server() -> void:
@@ -81,6 +109,76 @@ func _on_disconnected_from_server() -> void:
# the same process.
func _on_shutting_down() -> void:
roster.clear()
_allowed_join_authorisations.clear()
_active_join_peers.clear()
_join_history.clear()
_join_authorisation_context.clear()
_join_signing_keys = {}
_connection_lease_claim = Callable()
_connection_lease_disconnect = Callable()
_result_submit = Callable()
require_join_authorisation = false
admissions_open = true
# signing_keys maps key ID to raw key bytes. An empty dictionary disables
# signature verification, which is only valid for local/direct-hosted play.
func configure_join_authorisations(tokens: Array, context: Dictionary, signing_keys: Dictionary = {}) -> bool:
var allowed := {}
for token in tokens:
if not token is String or String(token).is_empty():
return false
allowed[String(token)] = true
if allowed.is_empty() or not context.has("match_id") or not context["match_id"] is String or String(context["match_id"]).is_empty() or not context.has("server_id") or not context["server_id"] is String or String(context["server_id"]).is_empty() or not context.has("protocol_version") or not _valid_integer_claim(context["protocol_version"]) or int(context["protocol_version"]) < 1:
return false
_allowed_join_authorisations = allowed
_join_authorisation_context = context.duplicate(true)
_join_signing_keys = {}
for key_id in signing_keys:
var raw = signing_keys[key_id]
if not raw is PackedByteArray or PackedByteArray(raw).is_empty():
return false
_join_signing_keys[str(key_id)] = PackedByteArray(raw).duplicate()
require_join_authorisation = true
return true
func assigned_player_slots() -> Array:
var result: Array = []
var seen_identities := {}
var seen_slots := {}
for token in _allowed_join_authorisations.keys():
var claims := _join_claims(String(token))
if claims.is_empty():
return []
var identity := str(claims.get("PlayerID", ""))
var team := int(claims.get("Team", -1))
var slot := int(claims.get("Slot", -1))
if identity.is_empty() or team < 0 or team >= TEAM_COUNT or slot < 0 or slot > 5 or slot / 3 != team or seen_identities.has(identity) or seen_slots.has(slot):
return []
seen_identities[identity] = true
seen_slots[slot] = true
result.append({
"player_identity": identity,
"team": team,
"slot": slot,
})
result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return int(a["slot"]) < int(b["slot"]))
return result
func player_identity(peer_id: int) -> String:
if not roster.has(peer_id):
return ""
return String((roster[peer_id] as PlayerInfo).player_identity)
static func reservation_identity_matches(slot_identity: String, incoming_identity: String, slot_name: String, incoming_name: String) -> bool:
# Authenticated allocations must never fall back to a client-chosen display
# name. The name fallback exists only for direct, unauthenticated servers.
if not slot_identity.is_empty() or not incoming_identity.is_empty():
return not slot_identity.is_empty() and slot_identity == incoming_identity
return slot_name == incoming_name
# Server only: a raw ENet disconnect (crash, timeout) that never sent a
@@ -90,10 +188,24 @@ func _on_shutting_down() -> void:
func _on_peer_disconnected(peer_id: int) -> void:
if not multiplayer.is_server():
return
_cleanup_disconnected_peer(peer_id)
func _cleanup_disconnected_peer(peer_id: int) -> void:
NetworkManager.invalidate_peer(peer_id)
_remove_player(peer_id)
func _remove_player(peer_id: int) -> void:
for token in _active_join_peers.keys():
if int(_active_join_peers[token]) == peer_id:
_active_join_peers.erase(token)
var history: Dictionary = _join_history.get(token, {})
history["lost_at"] = Time.get_unix_time_from_system()
_join_history[token] = history
if _connection_lease_disconnect.is_valid():
_connection_lease_disconnect.call(_join_identity(token), int(history.get("generation", 0)))
break
if not roster.has(peer_id):
return
roster.erase(peer_id)
@@ -120,7 +232,8 @@ func _remove_player(peer_id: int) -> void:
# from inside signal-handling: by then poll() has fully returned, every
# disconnect event in this batch has been dispatched, and get_peers()
# reflects the settled, genuinely-still-connected set.
call_deferred("_broadcast_player_left", peer_id)
if is_inside_tree():
call_deferred("_broadcast_player_left", peer_id)
func _broadcast_player_left(peer_id: int) -> void:
@@ -129,6 +242,30 @@ func _broadcast_player_left(peer_id: int) -> void:
_player_left.rpc_id(other_peer_id, peer_id)
func broadcast_server_shutdown(reason: String) -> void:
if not multiplayer.is_server():
return
var safe_reason := _sanitize_shutdown_reason(reason)
for peer_id in multiplayer.get_peers():
_server_shutdown.rpc_id(peer_id, safe_reason)
static func _sanitize_shutdown_reason(raw: String) -> String:
var clean := ""
for c in raw:
var code := c.unicode_at(0)
if code >= 0x20 and code != 0x7F:
clean += c
clean = clean.strip_edges()
if clean.length() > 96:
clean = clean.substr(0, 96)
return clean if not clean.is_empty() else "server_shutdown"
static func admission_rejection(is_open: bool) -> String:
return "" if is_open else "server is draining"
# Balances a new joiner onto whichever team currently has fewer players
# (ties go to team 0). Server only.
func _pick_balanced_team() -> int:
@@ -145,12 +282,16 @@ func _pick_balanced_team() -> int:
@rpc("any_peer", "call_remote", "reliable")
func _hello(protocol_version: int, tick_hz: int, player_name: String) -> void:
func _hello(protocol_version: int, tick_hz: int, player_name: String, supplied_join_authorisation: String = "") -> void:
if not multiplayer.is_server():
return
var peer_id := multiplayer.get_remote_sender_id()
if roster.has(peer_id):
return # duplicate hello from an already-accepted peer; ignore
var admission_error := admission_rejection(admissions_open)
if not admission_error.is_empty():
await _reject(peer_id, admission_error)
return
if protocol_version != NetCodec.PROTOCOL_VERSION:
await _reject(peer_id, "protocol version mismatch: server=%d client=%d" % [NetCodec.PROTOCOL_VERSION, protocol_version])
@@ -158,10 +299,26 @@ func _hello(protocol_version: int, tick_hz: int, player_name: String) -> void:
if tick_hz != SimConstants.TICK_HZ:
await _reject(peer_id, "physics tick rate mismatch: server=%d client=%d" % [SimConstants.TICK_HZ, tick_hz])
return
if require_join_authorisation and not _valid_join_authorisation(supplied_join_authorisation):
await _reject(peer_id, "join authorisation rejected")
return
if require_join_authorisation and _active_join_peers.has(supplied_join_authorisation):
await _reject(peer_id, "join authorisation already in use")
return
var join_generation := 1
if require_join_authorisation:
join_generation = await _claim_join_authorisation(supplied_join_authorisation, peer_id)
if join_generation < 0:
await _reject(peer_id, "join authorisation lease rejected")
return
if player_name.length() > MAX_INPUT_LENGTH:
await _reject(peer_id, "player name too long")
return
var clean_name := _sanitize_player_name(player_name)
var identity := _join_identity(supplied_join_authorisation) if require_join_authorisation else clean_name
if identity.is_empty():
await _reject(peer_id, "join authorisation rejected")
return
# Tell the new peer about everyone already here before anyone is told
# about them, so no client ever observes an unknown peer_id in a
@@ -171,12 +328,222 @@ func _hello(protocol_version: int, tick_hz: int, player_name: String) -> void:
_player_joined.rpc_id(peer_id, existing_id, existing.player_name, existing.team, existing.ready)
var team := _pick_balanced_team()
roster[peer_id] = PlayerInfo.new(peer_id, clean_name, team, false)
var spawn_index := -1
if require_join_authorisation:
var claims := _join_claims(supplied_join_authorisation)
var assigned_slot := int(claims.get("Slot", -1))
var assigned_team := int(claims.get("Team", -1))
if assigned_slot < 0 or assigned_slot > 5 or assigned_team < 0 or assigned_team >= TEAM_COUNT or assigned_slot / 3 != assigned_team:
await _reject(peer_id, "join authorisation rejected")
return
team = assigned_team
spawn_index = assigned_slot % 3
var info := PlayerInfo.new(peer_id, clean_name, team, false, identity)
info.spawn_index = spawn_index
roster[peer_id] = info
if require_join_authorisation:
# _reserve_join_authorisation already owns the active peer reservation;
# keeping the generation in the history makes fencing auditable without
# exposing it to the client.
_join_history[supplied_join_authorisation]["generation"] = join_generation
player_joined.emit(peer_id, clean_name) # local: the broadcast below is call_remote, never loops back to the server itself
_welcome.rpc_id(peer_id)
_player_joined.rpc(peer_id, clean_name, team, false) # broadcast, includes the new peer itself
func _valid_join_authorisation(token: String) -> bool:
if token.is_empty() or not _allowed_join_authorisations.has(token):
return false
var standard_token := token.replace("-", "+").replace("_", "/")
while standard_token.length() % 4 != 0:
standard_token += "="
var decoded := Marshalls.base64_to_raw(standard_token)
if decoded.is_empty():
return false
var envelope = JSON.parse_string(decoded.get_string_from_utf8())
if not envelope is Dictionary or not envelope.has("Authorisation") or not envelope.has("Signature") or str(envelope["Signature"]).is_empty():
return false
var claims = envelope["Authorisation"]
if not claims is Dictionary:
return false
for string_claim in ["MatchID", "ServerID", "PlayerID", "SteamID", "Protocol", "ExpiresAt"]:
if not claims.has(string_claim) or not claims[string_claim] is String or String(claims[string_claim]).is_empty():
return false
for integer_claim in ["Slot", "Team", "Generation"]:
if not claims.has(integer_claim) or not _valid_integer_claim(claims[integer_claim]):
return false
if not envelope["Signature"] is String or String(envelope["Signature"]).is_empty():
return false
var claimed_team := int(claims.get("Team", -1))
var claimed_slot := int(claims.get("Slot", -1))
if claimed_team < 0 or claimed_team >= TEAM_COUNT or claimed_slot < 0 or claimed_slot > 5 or claimed_slot / 3 != claimed_team:
return false
var protocol := str(claims.get("Protocol", ""))
var expires_at := str(claims.get("ExpiresAt", ""))
if not AssignmentState.is_valid_expiry_timestamp(expires_at):
return false
var expiry := Time.get_unix_time_from_datetime_string(expires_at)
if not _join_signing_keys.is_empty():
var signature_token := str(envelope["Signature"])
var signature := Marshalls.base64_to_raw(signature_token)
if signature.size() != 32:
return false
# The key ID selects which of the currently-valid keys signed this
# authorisation, so the allocator can rotate without invalidating
# authorisations already issued for in-flight matches. It is part of
# the signed bytes below, so pointing it at a different key simply
# fails verification rather than choosing a weaker key.
var key_id := str(claims.get("KeyID", ""))
if not _join_signing_keys.has(key_id):
return false
var signing_key: PackedByteArray = _join_signing_keys[key_id]
if signing_key.is_empty():
return false
var canonical := PackedByteArray()
# Must stay byte-identical to server/domain/join_auth.go's
# JoinAuthorisationBytes; the two change together or every join fails.
var fields := [
str(claims.get("MatchID", "")), str(claims.get("ServerID", "")),
str(claims.get("PlayerID", "")), str(claims.get("SteamID", "")),
str(int(claims.get("Slot", -1))), str(int(claims.get("Team", -1))), protocol,
str(int(claims.get("Generation", 0))), expires_at, key_id,
]
for index in fields.size():
canonical.append_array(String(fields[index]).to_utf8_buffer())
if index < fields.size() - 1:
canonical.append(0)
var hmac := HMACContext.new()
hmac.start(HashingContext.HASH_SHA256, signing_key)
hmac.update(canonical)
if hmac.finish() != signature:
return false
return str(claims.get("MatchID", "")) == str(_join_authorisation_context.get("match_id", "")) \
and str(claims.get("ServerID", "")) == str(_join_authorisation_context.get("server_id", "")) \
and protocol == str(_join_authorisation_context.get("protocol", "")) \
and int(claims.get("Slot", -1)) >= 0 and int(claims.get("Slot", -1)) <= 5 \
and expiry > Time.get_unix_time_from_system()
static func _valid_integer_claim(value: Variant) -> bool:
if value is int:
return int(value) >= 0
if value is float:
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
return false
func _join_identity(token: String) -> String:
if token.is_empty():
return ""
var standard_token := token.replace("-", "+").replace("_", "/")
while standard_token.length() % 4 != 0:
standard_token += "="
var decoded := Marshalls.base64_to_raw(standard_token)
if decoded.is_empty():
return ""
var envelope = JSON.parse_string(decoded.get_string_from_utf8())
if not envelope is Dictionary or not envelope.has("Authorisation") or not envelope["Authorisation"] is Dictionary:
return ""
return str(envelope["Authorisation"].get("PlayerID", ""))
func _join_claims(token: String) -> Dictionary:
if token.is_empty():
return {}
var standard_token := token.replace("-", "+").replace("_", "/")
while standard_token.length() % 4 != 0:
standard_token += "="
var decoded := Marshalls.base64_to_raw(standard_token)
if decoded.is_empty():
return {}
var envelope = JSON.parse_string(decoded.get_string_from_utf8())
if not envelope is Dictionary or not envelope.has("Authorisation") or not envelope["Authorisation"] is Dictionary:
return {}
return envelope["Authorisation"]
func is_join_authorisation_active(token: String) -> bool:
return not token.is_empty() and _active_join_peers.has(token)
func configure_connection_lease_callbacks(claim: Callable, disconnect: Callable) -> void:
_connection_lease_claim = claim
_connection_lease_disconnect = disconnect
func configure_result_submission(callback: Callable) -> void:
_result_submit = callback
func submit_authoritative_result(score: Dictionary, integrity_state := "CERTIFIED") -> bool:
if not _result_submit.is_valid() or not score.has(0) or not score.has(1):
return false
_result_submit.call(int(score[0]), int(score[1]), integrity_state)
return true
func _claim_join_authorisation(token: String, peer_id: int) -> int:
var expected_generation := _available_join_generation(token)
if expected_generation < 0:
return -1
var generation := expected_generation + 1
if _connection_lease_claim.is_valid():
var response = await _connection_lease_claim.call(_join_identity(token), expected_generation)
generation = lease_claim_generation(response, expected_generation)
if generation < 0:
return -1
# The await above deliberately allows one bounded control-plane request.
# Re-evaluate every local fact that can change during that suspension before
# publishing the reservation. If a durable claim succeeded, close it again.
# A concurrent same-token hello can receive the same idempotent claim; its
# loser must not close the generation now owned by the local winner.
if _active_join_peers.has(token):
return -1
if not admissions_open or not _valid_join_authorisation(token) or peer_id not in multiplayer.get_peers():
if _connection_lease_disconnect.is_valid():
_connection_lease_disconnect.call(_join_identity(token), generation)
return -1
_join_history[token] = {"generation": generation, "lost_at": 0.0}
_active_join_peers[token] = peer_id
return generation
func _available_join_generation(token: String) -> int:
if token.is_empty() or _active_join_peers.has(token):
return -1
var now := Time.get_unix_time_from_system()
var history: Dictionary = _join_history.get(token, {})
var lost_at := float(history.get("lost_at", 0.0))
if lost_at > 0.0 and (now < lost_at or now - lost_at > RECONNECT_GRACE_SECONDS):
return -1
return int(history.get("generation", 0))
static func lease_claim_generation(response, expected_generation: int) -> int:
if not response is Dictionary or expected_generation < 0:
return -1
var status := String(response.get("status", ""))
if status not in ["claimed", "unavailable"] or not response.get("generation") is int:
return -1
var generation := int(response["generation"])
if status == "unavailable":
return generation if expected_generation > 0 and generation == expected_generation + 1 else -1
# A durable backend may return a later generation only to a fresh process
# recovering an already-disconnected lease. Locally known generations never
# skip, and outage fallback never invents a jump.
return generation if generation == expected_generation + 1 or (expected_generation == 0 and generation > 1) else -1
func _reserve_join_authorisation(token: String, peer_id: int) -> int:
var expected_generation := _available_join_generation(token)
if expected_generation < 0:
return -1
var generation := expected_generation + 1
_join_history[token] = {"generation": generation, "lost_at": 0.0}
_active_join_peers[token] = peer_id
return generation
# Strips control/formatting characters (so a name can't corrupt a log line
# or blow out UI layout with e.g. embedded newlines) and clamps to display
# length. Input is already bounded to MAX_INPUT_LENGTH by the caller before
@@ -223,17 +590,29 @@ func _set_team(team: int) -> void:
if not multiplayer.is_server():
return
var peer_id := multiplayer.get_remote_sender_id()
if not roster.has(peer_id) or team < 0 or team >= TEAM_COUNT:
if not _apply_team_change(peer_id, team):
return
var info: PlayerInfo = roster[peer_id]
if info.team == team:
return
info.team = team
info.ready = false # switching teams un-readies — the roster you were ready against just changed
player_state_changed.emit(peer_id, info.team, info.ready)
_state_changed.rpc(peer_id, info.team, info.ready)
func _apply_team_change(peer_id: int, team: int) -> bool:
# In allocated matches team and global slot are signed together. Changing
# only team would produce a roster that disagrees with the assignment and
# leave spawn_index anchored to the old team.
if require_join_authorisation:
return false
if not roster.has(peer_id) or team < 0 or team >= TEAM_COUNT:
return false
var info: PlayerInfo = roster[peer_id]
if info.team == team:
return false
info.team = team
info.ready = false # switching teams un-readies — the roster you were ready against just changed
return true
@rpc("any_peer", "call_remote", "reliable")
func _set_ready(ready: bool) -> void:
if not multiplayer.is_server():
@@ -269,6 +648,12 @@ func _rejected(reason: String) -> void:
rejected.emit(reason)
@rpc("authority", "call_remote", "reliable")
func _server_shutdown(reason: String) -> void:
last_server_shutdown_reason = _sanitize_shutdown_reason(reason)
server_shutdown.emit(last_server_shutdown_reason)
@rpc("authority", "call_remote", "reliable")
func _player_joined(peer_id: int, player_name: String, team: int, ready: bool) -> void:
roster[peer_id] = PlayerInfo.new(peer_id, player_name, team, ready)
+13 -2
View File
@@ -41,7 +41,7 @@ signal match_bootstrap_received(state: int, at_tick: int, score: Dictionary, end
# §6.3 task 5.8: a spectator has been given a vacated slot at a kickoff.
signal slot_assigned_received(peer_id: int, slot_index: int)
# Input validation (multiplayer-todo.md §3.1 steps 2-3, task 3.4). Deliberately
# Input validation (MULTIPLAYER_SPEC.md §3.1 steps 2-3; multiplayer-next.md task 3.4). Deliberately
# lives here rather than in NetworkedMatch: framing/rate abuse is a protocol-
# level concern independent of any particular match's roster/slot state, and
# this autoload already owns the RPC that receives the raw bytes.
@@ -192,7 +192,12 @@ func _physics_process(_delta: float) -> void:
var now := Time.get_ticks_msec()
var gap := now - _last_physics_ms
_last_physics_ms = now
if not multiplayer.is_server() or _peer_input_state.is_empty():
# NetworkManager.shutdown() swaps in an OfflineMultiplayerPeer before the
# smoke harness's deferred quit runs. Querying MultiplayerAPI.is_server()
# during that hand-off can call get_unique_id() on an inactive ENet peer and
# emit errors every physics frame; the NetworkManager role flag is the safe
# lifecycle guard at this boundary.
if not NetworkManager.is_server or _peer_input_state.is_empty():
return
if gap < STALL_DETECT_MS:
return
@@ -264,6 +269,11 @@ func send_input(bytes: PackedByteArray) -> void:
func send_snapshot(peer_id: int, bytes: PackedByteArray) -> void:
# A server-side disconnect can leave peer_id in get_peers() until the
# current poll batch settles. Do not enter Godot's RPC path for that stale
# target; NetSim repeats this check at fire time for delayed sends.
if not NetworkManager.can_send_to_peer(peer_id):
return
_track_sent(bytes.size())
NetSim.send(func() -> void: _snapshot.rpc_id(peer_id, bytes), peer_id)
@@ -477,6 +487,7 @@ func _disconnect_abusive_peer(peer_id: int, reason: String) -> void:
# which does not carry the peer, the reason or a timestamp into the log
# stream a container actually captures.
ServerLog.warn("peer_kicked", {"peer_id": peer_id, "reason": reason})
NetworkManager.invalidate_peer(peer_id)
_peer_input_state.erase(peer_id)
if multiplayer.multiplayer_peer is ENetMultiplayerPeer:
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
+1 -1
View File
@@ -1,6 +1,6 @@
class_name MatchState
# Match lifecycle states (multiplayer-todo.md §6.1, task 5.1).
# Match lifecycle states (MULTIPLAYER_SPEC.md §6.1; multiplayer-next.md task 5.1).
#
# Pure data + a transition table, deliberately with no scene, RPC or
# NetworkedMatch dependency — same reason net_codec.gd and
+388
View File
@@ -0,0 +1,388 @@
extends Control
const CLIENT_BUILD := "dev"
const PROTOCOL_VERSION := 1
const HEARTBEAT_SECONDS := 10.0
const RECOVERY_POLL_SECONDS := 2.0
@onready var playlist_dropdown: OptionButton = %PlaylistDropdown
@onready var status_label: Label = %StatusLabel
@onready var detail_label: Label = %DetailLabel
@onready var ranked_profile_label: Label = %RankedProfileLabel
@onready var queue_button: Button = %QueueButton
@onready var cancel_button: Button = %CancelButton
@onready var accept_button: Button = %AcceptButton
@onready var decline_button: Button = %DeclineButton
@onready var back_button: Button = %BackButton
var _elapsed_seconds := 0.0
var _heartbeat_seconds := 0.0
var _recovery_poll_seconds := 0.0
# Regions still awaiting RTT evidence, and the queue request deferred until at
# least one lands. The matcher ignores a ticket with no predicted RTT, so
# queueing before probing produces a search that can never match.
var _pending_probe_regions: Array[String] = []
var _probed_regions: Array[String] = []
var _deferred_queue := {}
var _web_api_ticket_handle := 0
func _ready() -> void:
AudioManager.bind_tree_buttons(self)
playlist_dropdown.add_item("Casual")
playlist_dropdown.set_item_metadata(0, "casual")
playlist_dropdown.add_item("Ranked")
playlist_dropdown.set_item_metadata(1, "ranked")
playlist_dropdown.item_selected.connect(_on_playlist_selected)
ControlPlaneClient.state.changed.connect(_on_state_changed)
ControlPlaneClient.request_failed.connect(_on_request_failed)
ControlPlaneClient.request_succeeded.connect(_on_request_succeeded)
ControlPlaneClient.session_expired.connect(_on_session_expired)
ControlPlaneClient.probe_challenge_received.connect(_on_probe_challenge_received)
ControlPlaneClient.probe_recorded.connect(_on_probe_recorded)
_ensure_signed_in()
_refresh_ranked_profile()
_render(ControlPlaneClient.state.snapshot())
# Matchmaking previously opened with an empty token against a loopback default,
# so every request failed ERR_UNAUTHORIZED before reaching the network. Point
# the client at its configured endpoint and complete Steam sign-in first.
func _ensure_signed_in() -> void:
if ControlPlaneClient.has_session():
return
if not ControlPlaneClient.configure(ControlPlaneClient.configured_base_url(), ""):
_on_local_error("Matchmaking endpoint is not configured")
return
if not SteamBootstrap.supports_web_api_ticket():
# Deliberately explicit rather than silently presenting a search that
# can never start: online matchmaking requires a verified identity.
_on_local_error("Sign-in requires the Steam build: %s" % SteamBootstrap.unavailable_reason())
return
var steam := Engine.get_singleton("Steam")
if not steam.get_auth_ticket_for_web_api.is_connected(_on_web_api_ticket):
steam.get_auth_ticket_for_web_api.connect(_on_web_api_ticket)
_web_api_ticket_handle = SteamBootstrap.request_web_api_ticket()
if _web_api_ticket_handle <= 0:
_on_local_error("Could not request a Steam authentication ticket")
return
ControlPlaneClient.state.set_notice("Signing in...")
func _on_web_api_ticket(_handle: int, result: int, ticket: PackedByteArray) -> void:
# Steam reports k_EResultOK as 1; anything else means no usable ticket.
if result != 1 or ticket.is_empty():
_on_local_error("Steam declined to issue an authentication ticket")
return
var encoded := SteamBootstrap.encode_web_api_ticket(ticket)
if encoded.is_empty():
_on_local_error("Steam returned an unusable authentication ticket")
return
var err := ControlPlaneClient.login_steam(encoded)
if err != OK:
_on_local_error("Could not sign in: %s" % error_string(err))
func _exit_tree() -> void:
# The ticket handle is a Steam resource; releasing it avoids leaking one
# per visit to this screen.
if _web_api_ticket_handle > 0:
SteamBootstrap.cancel_web_api_ticket(_web_api_ticket_handle)
_web_api_ticket_handle = 0
func _process(delta: float) -> void:
if ControlPlaneClient.state.phase in [MatchmakingState.QUEUED, MatchmakingState.PROPOSED, MatchmakingState.ACCEPTED, MatchmakingState.ALLOCATING]:
_elapsed_seconds += delta
_heartbeat_seconds += delta
_recovery_poll_seconds += delta
if _recovery_poll_seconds >= RECOVERY_POLL_SECONDS:
_recovery_poll_seconds = 0.0
var recovery_err := ControlPlaneClient.recover_proposal(ControlPlaneClient.state.proposal_id) if ControlPlaneClient.state.has_open_proposal() else ControlPlaneClient.recover_queue(ControlPlaneClient.state.ticket_id)
if recovery_err != OK and recovery_err != ERR_BUSY:
_on_local_error("State recovery unavailable: %s" % error_string(recovery_err))
if ControlPlaneClient.state.phase == MatchmakingState.QUEUED and _heartbeat_seconds >= HEARTBEAT_SECONDS:
_heartbeat_seconds = 0.0
var err := ControlPlaneClient.heartbeat(ControlPlaneClient.state.ticket_id, ControlPlaneClient.state.revision)
if err != OK:
_on_local_error("Heartbeat unavailable: %s" % error_string(err))
_render(ControlPlaneClient.state.snapshot())
func _on_queue_pressed() -> void:
if not ControlPlaneClient.has_session():
# Queueing without a session would fail at the first request guard.
_ensure_signed_in()
return
if ControlPlaneClient.can_retry_queue_create():
var retry_err := ControlPlaneClient.retry_queue_create()
if retry_err != OK:
_on_local_error("Could not retry matchmaking: %s" % error_string(retry_err))
return
if ControlPlaneClient.can_retry_last_mutation():
var mutation_err := ControlPlaneClient.retry_last_mutation()
if mutation_err != OK:
_on_local_error("Could not retry matchmaking action: %s" % error_string(mutation_err))
return
if not _can_start_new_search(ControlPlaneClient.state.phase):
return
_elapsed_seconds = 0.0
_heartbeat_seconds = 0.0
_recovery_poll_seconds = 0.0
var playlist := String(playlist_dropdown.get_selected_metadata())
var ticket_id := "ticket-%s-%s" % [str(Time.get_ticks_usec()), str(randi())]
# A ticket with no regional RTT evidence is invisible to the matcher, so
# collect it first and queue once the first region reports.
if _probed_regions.is_empty():
_deferred_queue = {"ticket_id": ticket_id, "playlist": playlist}
_start_probe_collection()
return
var err := ControlPlaneClient.queue_create(ticket_id, playlist, CLIENT_BUILD, PROTOCOL_VERSION)
if err != OK:
_on_local_error("Could not start matchmaking: %s" % error_string(err))
func _start_probe_collection() -> void:
_pending_probe_regions = []
for region in ControlPlaneClient.PROBE_REGIONS:
_pending_probe_regions.append(String(region))
ControlPlaneClient.state.set_notice("Measuring connection quality...")
_request_next_probe()
# One request at a time: the client serialises HTTP through a single
# HTTPRequest, so a second call would return ERR_BUSY.
func _request_next_probe() -> void:
if _pending_probe_regions.is_empty():
_finish_probe_collection()
return
var region := _pending_probe_regions[0]
var err := ControlPlaneClient.request_probe_challenge(region)
if err != OK and err != ERR_BUSY:
# A region we cannot probe is not fatal; placement just uses the
# regions that did respond.
_pending_probe_regions.remove_at(0)
_request_next_probe()
func _on_probe_challenge_received(region: String, nonce_base64: String) -> void:
var err := ControlPlaneClient.submit_probe_answer(region, nonce_base64, ControlPlaneClient.opaque_location_payload())
if err != OK:
_drop_pending_probe(region)
func _on_probe_recorded(region: String, _server_rtt_ms: int) -> void:
if not _probed_regions.has(region):
_probed_regions.append(region)
_drop_pending_probe(region)
func _drop_pending_probe(region: String) -> void:
var index := _pending_probe_regions.find(region)
if index >= 0:
_pending_probe_regions.remove_at(index)
_request_next_probe()
func _finish_probe_collection() -> void:
if _deferred_queue.is_empty():
return
var queued := _deferred_queue
_deferred_queue = {}
if _probed_regions.is_empty():
# Queueing now would create a ticket the matcher can never select.
_on_local_error("Could not measure connection quality to any region; matchmaking is unavailable")
return
var err := ControlPlaneClient.queue_create(String(queued["ticket_id"]), String(queued["playlist"]), CLIENT_BUILD, PROTOCOL_VERSION)
if err != OK:
_on_local_error("Could not start matchmaking: %s" % error_string(err))
func _on_cancel_pressed() -> void:
if not ControlPlaneClient.state.can_cancel():
return
var err := ControlPlaneClient.cancel_queue(ControlPlaneClient.state.ticket_id, ControlPlaneClient.state.revision)
if err != OK:
_on_local_error("Could not cancel matchmaking: %s" % error_string(err))
func _on_accept_pressed() -> void:
var err := ControlPlaneClient.respond_to_proposal(ControlPlaneClient.state.proposal_id, true, ControlPlaneClient.state.proposal_revision)
if err != OK:
_on_local_error("Could not accept proposal: %s" % error_string(err))
func _on_decline_pressed() -> void:
var err := ControlPlaneClient.respond_to_proposal(ControlPlaneClient.state.proposal_id, false, ControlPlaneClient.state.proposal_revision)
if err != OK:
_on_local_error("Could not decline proposal: %s" % error_string(err))
func _on_playlist_selected(_index: int) -> void:
_refresh_ranked_profile()
func _refresh_ranked_profile() -> void:
var ranked := String(playlist_dropdown.get_selected_metadata()) == "ranked"
ranked_profile_label.visible = ranked
if not ranked:
return
var err := ControlPlaneClient.fetch_ranked_profile()
if err != OK and err != ERR_BUSY:
ranked_profile_label.text = "Ranked profile unavailable: %s" % error_string(err)
func _on_back_pressed() -> void:
if ControlPlaneClient.state.can_cancel():
status_label.text = "Cancel the active search before leaving"
return
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
func _on_state_changed(snapshot: Dictionary) -> void:
_render(snapshot)
func _on_request_succeeded(_operation: String, _payload: Dictionary) -> void:
ranked_profile_label.text = ControlPlaneClient.ranked_profile.display_text()
_render(ControlPlaneClient.state.snapshot())
func _on_request_failed(_operation: String, _http_code: int, detail: String) -> void:
detail_label.text = detail
ranked_profile_label.text = ControlPlaneClient.ranked_profile.display_text()
_render(ControlPlaneClient.state.snapshot())
func _on_session_expired() -> void:
status_label.text = "Session expired"
detail_label.text = "Sign in again before searching for a match"
queue_button.disabled = true
func _on_local_error(detail: String) -> void:
detail_label.text = detail
static func phase_label(phase: String) -> String:
match phase:
MatchmakingState.IDLE:
return "Ready to search"
MatchmakingState.QUEUED:
return "Searching for players"
MatchmakingState.PROPOSED:
return "Match found — confirm"
MatchmakingState.ACCEPTED:
return "Match accepted — preparing server"
MatchmakingState.ALLOCATING:
return "Preparing match server"
MatchmakingState.PROCESS_READY:
return "Match server started"
MatchmakingState.ASSIGNMENT_READY:
return "Match assigned"
MatchmakingState.CONNECTING:
return "Connecting to match"
MatchmakingState.LIVE:
return "Match in progress"
MatchmakingState.RESULT_PENDING:
return "Recording match result"
MatchmakingState.COMPLETED:
return "Match complete"
MatchmakingState.ASSIGNED:
return "Match assigned"
MatchmakingState.CANCELLED:
return "Search cancelled"
MatchmakingState.EXPIRED:
return "Search expired"
MatchmakingState.FAILED:
return "Matchmaking unavailable"
_:
return "Recovering matchmaking state"
func _render(snapshot: Dictionary) -> void:
var phase := String(snapshot.get("phase", MatchmakingState.IDLE))
status_label.text = phase_label(phase)
if String(snapshot.get("message", "")) != "":
detail_label.text = String(snapshot["message"])
elif phase == MatchmakingState.QUEUED:
var waited := _elapsed_seconds
if int(snapshot.get("enqueued_at_unix", 0)) > 0:
waited = float(ControlPlaneClient.state.waited_seconds(int(Time.get_unix_time_from_system())))
detail_label.text = queue_wait_detail_text(int(waited), int(snapshot.get("revision", 0)))
elif phase == MatchmakingState.PROPOSED:
detail_label.text = proposal_countdown_text(int(snapshot.get("expires_at_unix", 0)), int(Time.get_unix_time_from_system()))
elif phase == MatchmakingState.ACCEPTED:
detail_label.text = phase_detail_label(phase)
elif phase in [MatchmakingState.ALLOCATING, MatchmakingState.PROCESS_READY, MatchmakingState.ASSIGNMENT_READY, MatchmakingState.ASSIGNED]:
detail_label.text = phase_detail_label(phase)
elif phase in [MatchmakingState.CONNECTING, MatchmakingState.LIVE]:
detail_label.text = "%s · %s" % [phase_detail_label(phase), latency_detail_text(NetworkManager.rtt_ms)]
elif phase == MatchmakingState.RESULT_PENDING:
detail_label.text = "The server is confirming the final result"
elif phase == MatchmakingState.COMPLETED:
detail_label.text = "The match result has been recorded"
elif phase == MatchmakingState.IDLE:
detail_label.text = "Choose a playlist to begin"
cancel_button.visible = ControlPlaneClient.state.can_cancel()
accept_button.visible = phase == MatchmakingState.PROPOSED
decline_button.visible = phase == MatchmakingState.PROPOSED
var retry_search := ControlPlaneClient.can_retry_queue_create()
var retry_mutation := ControlPlaneClient.can_retry_last_mutation()
queue_button.disabled = ControlPlaneClient.auth_expired or not (_can_start_new_search(phase) or retry_search or retry_mutation)
queue_button.text = "Retry Search" if retry_search else ("Retry Request" if retry_mutation else "Search")
static func _is_terminal(phase: String) -> bool:
return phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE, MatchmakingState.COMPLETED]
static func phase_detail_label(phase: String) -> String:
match phase:
MatchmakingState.ACCEPTED:
return "All players accepted; preparing the match server"
MatchmakingState.ALLOCATING:
return "Finding a dedicated match server"
MatchmakingState.PROCESS_READY:
return "Match server started; preparing player assignments"
MatchmakingState.ASSIGNMENT_READY:
return "Player assignments are ready"
MatchmakingState.ASSIGNED:
return "Your match server is ready"
MatchmakingState.CONNECTING:
return "Connecting to the match server"
MatchmakingState.LIVE:
return "Match in progress"
_:
return ""
static func proposal_countdown_text(expires_at_unix: int, now_unix: int) -> String:
if expires_at_unix <= 0:
return "Review the proposal before the countdown expires"
return "Review proposal · %ds remaining" % maxi(0, expires_at_unix - now_unix)
static func queue_wait_detail_text(waited_seconds: int, revision: int) -> String:
var waited := maxi(0, waited_seconds)
var suffix := "looking for compatible players"
if waited >= 30:
suffix = "widening skill range while keeping latency limits"
elif waited >= 10:
suffix = "matching nearby skill and latency"
return "Waiting %ds · %s · revision %d" % [waited, suffix, maxi(0, revision)]
static func latency_detail_text(rtt_ms: float) -> String:
if not is_finite(rtt_ms) or rtt_ms < 0.0:
return "Latency: measuring"
var rounded := int(round(rtt_ms))
if rtt_ms <= 50.0:
return "Latency: %dms · excellent" % rounded
if rtt_ms <= 100.0:
return "Latency: %dms · good" % rounded
return "Latency: %dms · high" % rounded
static func _can_start_new_search(phase: String) -> bool:
return phase == MatchmakingState.IDLE or phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.COMPLETED]
+392
View File
@@ -0,0 +1,392 @@
class_name MatchmakingState
extends RefCounted
# Client-side projection of the authenticated control-plane lifecycle. The
# server remains authoritative; this object only decides what the UI may show
# and refuses stale, gapped, or conflicting revisions instead of guessing.
signal changed(snapshot: Dictionary)
signal resync_required(resource_id: String)
const IDLE := "IDLE"
const QUEUED := "QUEUED"
const PROPOSED := "PROPOSED"
const ACCEPTED := "ACCEPTED"
const ALLOCATING := "ALLOCATING"
const PROCESS_READY := "PROCESS_READY"
const ASSIGNMENT_READY := "ASSIGNMENT_READY"
const CONNECTING := "CONNECTING"
const LIVE := "LIVE"
const ASSIGNED := "ASSIGNED"
const RESULT_PENDING := "RESULT_PENDING"
const COMPLETED := "COMPLETED"
const CANCELLED := "CANCELLED"
const EXPIRED := "EXPIRED"
const FAILED := "FAILED"
var phase := IDLE
var ticket_id := ""
var playlist := ""
var revision := 0
var enqueued_at_unix := 0
var expires_at_unix := 0
var proposal_id := ""
var proposal_revision := 0
var proposal_state := ""
var message := ""
var needs_resync := false
func begin_queue(new_ticket_id: String, new_playlist: String) -> bool:
if new_ticket_id.is_empty() or (new_playlist != "casual" and new_playlist != "ranked"):
return false
_reset()
ticket_id = new_ticket_id
playlist = new_playlist
phase = QUEUED
_emit_changed()
return true
func apply_ticket_update(update: Dictionary, authoritative_snapshot: bool = false) -> bool:
if not _has_string(update, "ticket_id") or not update.has("revision") or not _valid_revision(update["revision"]) or not update.has("state"):
return _request_resync(self.ticket_id)
if update.has("playlist") and not _valid_playlist(String(update["playlist"])):
return _request_resync(self.ticket_id)
if update.has("enqueued_at_unix") and not _valid_epoch(update["enqueued_at_unix"]):
return _request_resync(self.ticket_id)
if update.has("expires_at_unix") and not _valid_epoch(update["expires_at_unix"]):
return _request_resync(self.ticket_id)
if ticket_id.is_empty() or String(update["ticket_id"]) != ticket_id:
return _request_resync(self.ticket_id)
var incoming_revision := int(update["revision"])
if incoming_revision < revision:
return false
if incoming_revision == revision:
if _ticket_differs(update):
return _request_resync(self.ticket_id)
# expires_at_unix is deliberately not part of _ticket_differs' conflict
# check (see its own comment) but is still adopted here: begin_queue()
# has no way to know the server-assigned expiry in advance, so the
# very first same-revision confirmation is the only place a freshly
# queued ticket's expiry is ever set at all.
if update.has("expires_at_unix"):
expires_at_unix = int(update["expires_at_unix"])
if update.has("enqueued_at_unix"):
enqueued_at_unix = maxi(0, int(update["enqueued_at_unix"]))
return true
if incoming_revision > revision + 1 and not authoritative_snapshot:
return _request_resync(self.ticket_id)
var incoming_state := String(update["state"])
if not _is_ticket_state(incoming_state):
return _request_resync(self.ticket_id)
if authoritative_snapshot:
if not _can_reach_ticket_state(phase, incoming_state):
return _request_resync(self.ticket_id)
elif not _is_legal_ticket_transition(phase, incoming_state):
return _request_resync(self.ticket_id)
revision = incoming_revision
phase = incoming_state
if update.has("playlist"):
playlist = String(update["playlist"])
if update.has("expires_at_unix"):
expires_at_unix = int(update["expires_at_unix"])
if update.has("enqueued_at_unix"):
enqueued_at_unix = maxi(0, int(update["enqueued_at_unix"]))
if update.has("message"):
message = String(update["message"])
else:
message = ""
needs_resync = false
_emit_changed()
return true
func apply_proposal_update(update: Dictionary) -> bool:
if not _has_string(update, "proposal_id") or not update.has("revision") or not _valid_revision(update["revision"]) or not update.has("state"):
return _request_resync(proposal_id)
if update.has("expires_at_unix") and not _valid_epoch(update["expires_at_unix"]):
return _request_resync(proposal_id)
var incoming_id := String(update["proposal_id"])
if proposal_id.is_empty():
proposal_id = incoming_id
elif proposal_id != incoming_id:
return _request_resync(proposal_id)
var incoming_revision := int(update["revision"])
if incoming_revision < proposal_revision:
return false
if incoming_revision == proposal_revision and not proposal_state.is_empty():
if String(update["state"]) != proposal_state:
return _request_resync(proposal_id)
return true
if not proposal_state.is_empty() and incoming_revision > proposal_revision + 1:
return _request_resync(proposal_id)
var incoming_proposal_state := String(update["state"])
if incoming_proposal_state == "OPEN":
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
return _request_resync(proposal_id)
phase = PROPOSED
elif incoming_proposal_state == "ACCEPTED":
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
return _request_resync(proposal_id)
phase = ALLOCATING
elif incoming_proposal_state == "DECLINED":
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
return _request_resync(proposal_id)
if phase == PROPOSED:
phase = QUEUED
message = "A player declined the match proposal"
elif incoming_proposal_state == "EXPIRED":
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
return _request_resync(proposal_id)
if phase == PROPOSED:
phase = QUEUED
message = "The match proposal expired"
elif incoming_proposal_state == "CANCELLED":
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
return _request_resync(proposal_id)
if phase == PROPOSED:
phase = QUEUED
message = "The match proposal was cancelled"
else:
return _request_resync(proposal_id)
proposal_revision = incoming_revision
proposal_state = incoming_proposal_state
needs_resync = false
if incoming_proposal_state == "OPEN" or incoming_proposal_state == "ACCEPTED":
message = ""
if update.has("expires_at_unix"):
expires_at_unix = int(update["expires_at_unix"])
_emit_changed()
return true
func prepare_proposal_recovery(new_proposal_id: String) -> bool:
if not _valid_opaque_id(new_proposal_id):
return false
if proposal_id == new_proposal_id:
return true
if proposal_state not in ["", "DECLINED", "EXPIRED", "CANCELLED"]:
return false
proposal_id = new_proposal_id
proposal_revision = 0
proposal_state = ""
return true
func mark_assignment_ready() -> void:
phase = ASSIGNMENT_READY
message = "Match server is ready"
_emit_changed()
func mark_connecting() -> void:
phase = CONNECTING
message = "Connecting to match server"
_emit_changed()
func mark_live() -> void:
phase = LIVE
message = "Match in progress"
_emit_changed()
func fail(reason: String) -> void:
phase = FAILED
message = reason if not reason.is_empty() else "Matchmaking failed"
_emit_changed()
func expire(reason: String = "Matchmaking expired") -> void:
phase = EXPIRED
message = reason
_emit_changed()
func set_notice(notice: String) -> void:
message = notice
_emit_changed()
func restore_snapshot(saved: Dictionary) -> bool:
_reset()
if saved.is_empty():
return true
if saved.has("phase") and not saved["phase"] is String:
return false
if saved.has("ticket_id") and not saved["ticket_id"] is String:
return false
if saved.has("playlist") and not saved["playlist"] is String:
return false
if saved.has("proposal_id") and not saved["proposal_id"] is String:
return false
if saved.has("proposal_state") and not saved["proposal_state"] is String:
return false
if saved.has("message") and not saved["message"] is String:
return false
if saved.has("revision") and not _valid_revision(saved["revision"]):
return false
for epoch_key in ["enqueued_at_unix", "expires_at_unix"]:
if saved.has(epoch_key) and not _valid_epoch(saved[epoch_key]):
return false
if saved.has("proposal_revision") and not _valid_revision(saved["proposal_revision"]):
return false
var saved_phase := String(saved.get("phase", IDLE))
var saved_ticket_id := String(saved.get("ticket_id", ""))
if not _valid_opaque_id(saved_ticket_id) or not _is_ticket_state(saved_phase):
return false
var saved_playlist := String(saved.get("playlist", ""))
if saved_playlist != "casual" and saved_playlist != "ranked":
return false
var saved_proposal_id := String(saved.get("proposal_id", ""))
var saved_proposal_state := String(saved.get("proposal_state", ""))
if saved_proposal_state not in ["", "OPEN", "ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"] or (not saved_proposal_state.is_empty() and not _valid_opaque_id(saved_proposal_id)) or (saved_proposal_state.is_empty() and not saved_proposal_id.is_empty() and not _valid_opaque_id(saved_proposal_id)):
return false
ticket_id = saved_ticket_id
playlist = saved_playlist
phase = saved_phase
revision = maxi(0, int(saved.get("revision", 0)))
enqueued_at_unix = maxi(0, int(saved.get("enqueued_at_unix", 0)))
expires_at_unix = maxi(0, int(saved.get("expires_at_unix", 0)))
proposal_id = saved_proposal_id
proposal_revision = maxi(0, int(saved.get("proposal_revision", 0)))
proposal_state = saved_proposal_state
message = "Recovering authoritative matchmaking state"
needs_resync = phase != CANCELLED and phase != EXPIRED and phase != FAILED and phase != COMPLETED
_emit_changed()
return true
func can_cancel() -> bool:
return phase == QUEUED or phase == PROPOSED or phase == ALLOCATING
func has_open_proposal() -> bool:
return not proposal_id.is_empty() and proposal_state == "OPEN"
func waited_seconds(now_unix: int) -> int:
if enqueued_at_unix <= 0:
return 0
return maxi(0, now_unix - enqueued_at_unix)
func snapshot() -> Dictionary:
return {"phase": phase, "ticket_id": ticket_id, "playlist": playlist, "revision": revision, "enqueued_at_unix": enqueued_at_unix, "expires_at_unix": expires_at_unix, "proposal_id": proposal_id, "proposal_revision": proposal_revision, "proposal_state": proposal_state, "message": message, "needs_resync": needs_resync}
func _ticket_differs(update: Dictionary) -> bool:
# expires_at_unix is excluded on purpose: begin_queue()'s optimistic local
# state has no way to know the server-assigned expiry before the first
# real response arrives, so comparing it here made the very first
# same-revision confirmation after every begin_queue() look like a
# conflict, unconditionally -- found by an actual client hitting a real
# server: apply_ticket_update() kept requesting a resync, whose own
# response hit exactly the same false mismatch, forever, which
# control_plane_smoke.gd (a live end-to-end test, not a mock) surfaced as
# a request that legitimately never terminates. It's still kept current
# via the direct assignment below, just not treated as a conflict signal.
return String(update["state"]) != phase or (update.has("playlist") and String(update["playlist"]) != playlist)
func _request_resync(resource_id: String) -> bool:
needs_resync = true
resync_required.emit(resource_id)
return false
func _emit_changed() -> void:
changed.emit(snapshot())
func _reset() -> void:
phase = IDLE
playlist = ""
revision = 0
enqueued_at_unix = 0
expires_at_unix = 0
proposal_id = ""
proposal_revision = 0
proposal_state = ""
message = ""
needs_resync = false
func _is_ticket_state(value: String) -> bool:
return value in [QUEUED, PROPOSED, ACCEPTED, ALLOCATING, PROCESS_READY, ASSIGNMENT_READY, ASSIGNED, CONNECTING, LIVE, RESULT_PENDING, COMPLETED, CANCELLED, EXPIRED, FAILED]
func _is_legal_ticket_transition(from: String, to: String) -> bool:
if from == to:
return true
var transitions := {
QUEUED: [PROPOSED, CANCELLED, EXPIRED],
PROPOSED: [QUEUED, ACCEPTED, CANCELLED, EXPIRED],
ACCEPTED: [QUEUED, ALLOCATING, CANCELLED, FAILED],
ALLOCATING: [PROCESS_READY, FAILED, CANCELLED],
PROCESS_READY: [ASSIGNMENT_READY, FAILED, CANCELLED],
ASSIGNMENT_READY: [ASSIGNED, FAILED, CANCELLED],
ASSIGNED: [CONNECTING, FAILED, CANCELLED],
CONNECTING: [LIVE, FAILED, EXPIRED],
LIVE: [RESULT_PENDING, FAILED],
RESULT_PENDING: [COMPLETED, FAILED],
}
return transitions.has(from) and to in transitions[from]
func _can_reach_ticket_state(from: String, to: String) -> bool:
if from == to:
return true
var pending: Array[String] = [from]
var visited := {}
visited[from] = true
while not pending.is_empty():
var current: String = pending.pop_front()
for candidate in [QUEUED, PROPOSED, ACCEPTED, ALLOCATING, PROCESS_READY, ASSIGNMENT_READY, ASSIGNED, CONNECTING, LIVE, RESULT_PENDING, COMPLETED, CANCELLED, EXPIRED, FAILED]:
if visited.has(candidate) or not _is_legal_ticket_transition(current, candidate):
continue
if candidate == to:
return true
visited[candidate] = true
pending.append(candidate)
return false
func _is_legal_proposal_transition(from: String, to: String) -> bool:
if from.is_empty():
return to == "OPEN"
if from == to:
return true
return from == "OPEN" and to in ["ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"]
func _has_string(value: Dictionary, key: String) -> bool:
return value.has(key) and value[key] is String and not String(value[key]).is_empty()
func _valid_revision(value: Variant) -> bool:
if value is int:
return int(value) >= 0
if value is float:
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
return false
func _valid_playlist(value: String) -> bool:
return value == "casual" or value == "ranked"
func _valid_epoch(value: Variant) -> bool:
if value is int:
return int(value) >= 0
if value is float:
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
return false
func _valid_opaque_id(value: String) -> bool:
if value.length() < 16 or value.length() > 128:
return false
var resource_pattern := RegEx.create_from_string("^[A-Za-z0-9_-]+$")
return resource_pattern.search(value) != null
+1 -1
View File
@@ -1,6 +1,6 @@
extends RefCounted
# Plain data holder for one body's snapshot state (§2.4 of multiplayer-todo.md).
# Plain data holder for one body's snapshot state (§2.4 of MULTIPLAYER_SPEC.md).
# Deliberately not Ship/Ball themselves, and deliberately not a scene-tree
# node — NetCodec's pack/unpack must stay callable from pure-function tests
# with no live scene. Phase 2's snapshot writer fills one of these per body
+2 -2
View File
@@ -1,7 +1,7 @@
class_name NetCodec
# Wire-format constants, quantisers, and pack/unpack for the two hot-path
# packets (§2 of multiplayer-todo.md). Pure functions only — no networking,
# packets (§2 of MULTIPLAYER_SPEC.md). Pure functions only — no networking,
# no autoload state — so they're testable head-on by tests/test_runner.tscn
# without a live connection.
#
@@ -48,7 +48,7 @@ const BODY_FLAG_STALLED := 1 << 5
const BODY_FLAG_QUAT_W_SIGN := 1 << 6
# --- Quantisation ranges (§2.4 — derived from arena/gameplay constants, not
# restated prose; see multiplayer-todo.md for the ArenaBoundary/Ship/Ball
# restated prose; see MULTIPLAYER_SPEC.md for the ArenaBoundary/Ship/Ball
# constants these are sized against) ---
const POS_RANGE := 64.0 # metres, ±
const VEL_RANGE := 64.0 # m/s, ±
+1 -1
View File
@@ -3,7 +3,7 @@ extends RefCounted
# Buffers recent snapshot samples for ONE remote body and produces
# interpolated states at any requested (possibly fractional) server tick —
# used twice per body (multiplayer-todo.md §4.1/§4.6, "dual-time remote
# used twice per body (MULTIPLAYER_SPEC.md §4.1/§4.6, "dual-time remote
# entities"): once at the present-time estimate for the collider, once
# further back at present-minus-INTERP_DELAY for $Visual.
#
+6 -1
View File
@@ -1,6 +1,6 @@
extends RefCounted
# Local-ship reconciliation policy (multiplayer-todo.md §4.4). Kept out of
# Local-ship reconciliation policy (MULTIPLAYER_SPEC.md §4.4). Kept out of
# NetworkedMatch so the decision table is pure-testable; the imperative half
# only writes Ship's existing Jolt-safe queued correction hooks.
@@ -44,6 +44,11 @@ static func decide(comparison: Dictionary, local_frozen: bool, reset_changed: bo
# normally against real data.
if comparison.get("status", "") == "unsimulated_gap":
return {"mode": "skip", "reason": "unsimulated_gap"}
if comparison.get("status", "") == "warmup_not_recorded":
# Sequence acknowledgements that predate the first local post-step state
# are expected during startup. The initial snapshot already placed the
# body, so there is no correction to apply and no resync to arm.
return {"mode": "skip", "reason": "warmup_not_recorded"}
if comparison.get("status", "missing_not_recorded") != "matched":
return {"mode": "hard", "reason": comparison.get("status", "missing")}
if authoritative == null or authoritative.frozen != local_frozen:
+1 -1
View File
@@ -89,7 +89,7 @@ func _schedule(dispatch: Callable, target_peer_id: int, delay_sec: float) -> voi
return
# process_always = true: a simulated wire delay must keep counting down
# even if the local SceneTree pauses (match_mode.gd's goal-pause does
# this today; multiplayer-todo.md §8 already flags get_tree().paused
# this today; multiplayer-next.md §8 already flags get_tree().paused
# stopping the client's own send/receive loop as a separate refactor
# item). Pausing this timer too would let a paused client's in-flight
# packets pile up and arrive in a burst on unpause instead of on their
+1
View File
@@ -0,0 +1 @@
uid://cjij4dxir0qxd
+25 -2
View File
@@ -3,7 +3,7 @@ extends Node
# Autoload (project.godot [autoload] NetworkManager). Owns transport-neutral
# hosting, joining, shutdown, and connection-state signals. Lives
# at a fixed autoload path so RPC NodePaths never depend on which scene is
# loaded (§1.3 of multiplayer-todo.md's derived decisions).
# loaded (§1.3 of MULTIPLAYER_SPEC.md's derived decisions).
#
# server_relay = false is set the moment a peer exists: the default `true`
# lets any client rpc() any other client *through the server*, which this
@@ -25,7 +25,7 @@ extends Node
# pays the same tax again. set_multiplayer_poll_enabled(false) below turns
# that off; every caller that sends or expects to receive on a tight cadence
# must now call NetworkManager.poll() itself. The intended placement per
# multiplayer-todo.md §7 task 1.3 (client: end of _physics_process after
# multiplayer-next.md §7 task 1.3 (client: end of _physics_process after
# sending input, plus top of both _process and _physics_process for receive;
# server: tick start to drain, tick end to flush) has no real per-tick caller
# yet — that lands with the input/snapshot pipeline (tasks 1.4+, Phase 2-3).
@@ -71,6 +71,7 @@ var is_server := false
var is_client := false
var _peer: MultiplayerPeer # keep a strong ref alongside multiplayer.multiplayer_peer
var active_transport := ""
var _invalidated_peer_ids: Dictionary = {}
var rtt_ms := -1.0 # min-RTT sample currently in the window; -1 = no sample yet
var clock_offset_ms := 0.0 # add to a local Time.get_ticks_msec() reading to estimate the server's clock
@@ -127,6 +128,27 @@ func poll() -> void:
multiplayer.poll()
# A peer can be removed from the transport while Godot is still draining the
# same poll batch. During that interval get_peers() may still contain it, but
# an RPC send already fails because ENet has torn down its channels.
func invalidate_peer(peer_id: int) -> void:
_invalidated_peer_ids[peer_id] = true
func can_send_to_peer(peer_id: int) -> bool:
if _invalidated_peer_ids.has(peer_id):
return false
if _peer == null or _peer is OfflineMultiplayerPeer:
return false
# A listening server's peer status is transport/version-specific; the
# authoritative server is valid as soon as it owns a peer and the target
# appears in get_peers(). Clients, however, must not dispatch while their
# connection is still handshaking.
if not is_server and _peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED:
return false
return peer_id in multiplayer.get_peers()
func available_transports() -> PackedStringArray:
var transports := PackedStringArray([TRANSPORT_ENET])
if SteamTransportScript.new().is_available():
@@ -185,6 +207,7 @@ func shutdown() -> void:
peer.close()
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
_peer = null
_invalidated_peer_ids.clear()
active_transport = ""
is_server = false
is_client = false
+99 -17
View File
@@ -124,7 +124,8 @@ class SlotInfo:
# §6.4 (tasks 5.6/5.7). A ship is NEVER despawned on disconnect — the slot
# keeps its ship and swaps the controller, so body order (and therefore
# every snapshot index) stays stable for the whole match.
var player_name := "" # identity key for reconnect; peer_id changes across a reconnect
var player_name := "" # display name only; never authoritative for allocated reclaim
var player_identity := "" # signed allocation identity; peer_id changes across a reconnect
var disconnected := false
var reserved_until_tick := -1 # server only: slot held for this player until here
var interpolator := NetInterpolator.new() # client only
@@ -310,12 +311,20 @@ var _late_joiners: Array[Dictionary] = []
# to this scene; static because the loop cannot hold a reference to a node that
# does not exist yet, and consumed on read so it cannot leak into a later match.
static var server_arena_override := ""
# Set only by ServerMatchLoop after an allocated casual match passes the
# initial-connect policy. It is consumed once while building the authoritative
# six-slot lineup, so direct servers and ranked allocations cannot add bots.
static var server_bot_fill_override := false
# §6.3's "cap with --max-spectators". Server only; 0 disables spectating
# entirely, negative means unlimited.
var _max_spectators := -1
var _last_emitted_countdown := -1
var _in_overtime := false
var _max_overtime_seconds := 900.0
var _overtime_deadline_tick := -1
var _match_over := false
var _planned_server_shutdown := false
var _awaiting_result_submission := false
# Dedicated-export smoke hook (task 6.2). It is parsed only by the authoritative
# server, cannot be triggered by an RPC, and defaults to disabled.
var _smoke_force_goal_tick := -1
@@ -342,6 +351,7 @@ func _ready() -> void:
# FULL_TIME/OVERTIME/RESULTS, so allow an override. Server-side only —
# a client cannot shorten anyone's match.
match_length_seconds = maxf(1.0, float(config.get_value("match-length")))
_max_overtime_seconds = maxf(1.0, float(config.get_value("max-overtime-seconds")))
var smoke_after := float(config.get_value("smoke-force-goal-after"))
if smoke_after >= 0.0:
_smoke_force_goal_tick = -2 # arm when PLAYING begins; -1 remains disabled
@@ -356,6 +366,10 @@ func _ready() -> void:
_replay_log = null
else:
print("NetworkedMatch: recording replay log to %s" % replay_path)
# Result acknowledgement is relevant only to the authority. Clients move
# to their lobby on the replicated RESULTS -> LOBBY transition.
MatchNet.result_submission_accepted.connect(_on_result_submission_accepted)
MatchNet.result_submission_retrying.connect(_on_result_submission_retrying)
_start_server()
else:
for arg: String in OS.get_cmdline_user_args():
@@ -386,6 +400,7 @@ func _ready() -> void:
# is not connected" errors per run — it only ever left because a test
# timer happened to fire.
NetworkManager.disconnected_from_server.connect(_on_disconnected_from_server)
MatchNet.server_shutdown.connect(_on_server_shutdown)
_request_match_config_until_received()
@@ -450,22 +465,47 @@ func _start_server() -> void:
var teams := PackedInt32Array()
var spawn_indices := PackedInt32Array()
var team_counts := {0: 0, 1: 0}
var sorted_peer_ids: Array = MatchNet.roster.keys()
sorted_peer_ids.sort()
for peer_id in sorted_peer_ids:
var info: MatchNet.PlayerInfo = MatchNet.roster[peer_id]
var spawn_index: int = team_counts.get(info.team, 0)
team_counts[info.team] = spawn_index + 1
var config := ServerConfig.parse(OS.get_cmdline_user_args(), false)
var use_assigned_bot_fill := server_bot_fill_override and bool(config.get_value("allocated-mode"))
server_bot_fill_override = false
var spawn_entries: Array[Dictionary] = []
if use_assigned_bot_fill:
var by_identity := {}
for peer_id in MatchNet.roster.keys():
var roster_info: MatchNet.PlayerInfo = MatchNet.roster[peer_id]
by_identity[roster_info.player_identity] = {"peer_id": int(peer_id), "info": roster_info}
for assigned: Dictionary in MatchNet.assigned_player_slots():
var identity := String(assigned["player_identity"])
if by_identity.has(identity):
var human: Dictionary = by_identity[identity]
spawn_entries.append({"peer_id": human["peer_id"], "info": human["info"], "team": int(assigned["team"]), "spawn_index": int(assigned["slot"]) % 3, "bot": false})
else:
spawn_entries.append({"peer_id": -1, "info": null, "team": int(assigned["team"]), "spawn_index": int(assigned["slot"]) % 3, "bot": true})
else:
var sorted_peer_ids: Array = MatchNet.roster.keys()
sorted_peer_ids.sort()
for peer_id in sorted_peer_ids:
var info: MatchNet.PlayerInfo = MatchNet.roster[peer_id]
var spawn_index: int = info.spawn_index if info.spawn_index >= 0 else team_counts.get(info.team, 0)
if info.spawn_index < 0:
team_counts[info.team] = spawn_index + 1
spawn_entries.append({"peer_id": peer_id, "info": info, "team": info.team, "spawn_index": spawn_index, "bot": false})
for entry: Dictionary in spawn_entries:
var peer_id: int = int(entry["peer_id"])
var info: MatchNet.PlayerInfo = entry["info"]
var team: int = int(entry["team"])
var spawn_index: int = int(entry["spawn_index"])
var slot := SlotInfo.new()
slot.peer_id = peer_id
slot.team = info.team
slot.team = team
slot.spawn_index = spawn_index
slot.player_name = info.player_name
slot.controller = RLShipController.new()
slot.ship = spawn_ship(info.team, spawn_index, slot.controller)
slot.player_name = "Bot %d" % spawn_index if bool(entry["bot"]) else info.player_name
slot.player_identity = "" if bool(entry["bot"]) else MatchNet.player_identity(peer_id)
slot.controller = _build_opponent(bot_model_path, bot_reaction_ticks, bot_action_noise, "NetworkedMatch") if bool(entry["bot"]) else RLShipController.new()
slot.ship = spawn_ship(team, spawn_index, slot.controller)
_slots.append(slot)
peer_ids.append(peer_id)
teams.append(info.team)
teams.append(team)
spawn_indices.append(spawn_index)
MatchSim.send_match_config(arena_path, peer_ids, teams, spawn_indices)
@@ -648,6 +688,8 @@ func _apply_match_state(new_state: int, at_tick: int) -> void:
# Kickoff is over: bodies move again, and the clock resumes.
_pending_freeze_tick = -1
_set_bodies_frozen(false)
if new_state == MatchState.State.OVERTIME:
_overtime_deadline_tick = at_tick + int(_max_overtime_seconds * SimConstants.TICK_HZ)
# The clock only advances during live play (§6.2 step 9). Derived here
# rather than tracked separately so it cannot disagree with the state.
var was_running := _clock_running
@@ -964,12 +1006,23 @@ func _broadcast_clock_state() -> void:
func _on_disconnected_from_server() -> void:
if _planned_server_shutdown:
return
# Deferred: this arrives from inside NetworkManager's poll, and gotcha 27
# requires change_scene_to_file never run synchronously from a callback
# mid-traversal.
get_tree().change_scene_to_file.call_deferred(ScenePaths.MAIN_MENU)
func _on_server_shutdown(reason: String) -> void:
if multiplayer.is_server() or _planned_server_shutdown:
return
_planned_server_shutdown = true
print("NetworkedMatch: server shutdown notice: %s" % reason)
NetworkManager.shutdown()
get_tree().change_scene_to_file.call_deferred(ScenePaths.LOBBY)
func _on_match_bootstrap_received(state: int, at_tick: int, new_score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int, remaining_ticks: int) -> void:
score = new_score.duplicate()
score_changed.emit(score.duplicate())
@@ -1007,11 +1060,13 @@ func _update_clock() -> void:
# --- §6.2 step 10: full time, overtime, results (task 5.5) -----------------
func _enter_results(winning_team: int) -> void:
func _enter_results(winning_team: int, integrity_state := "CERTIFIED") -> void:
_match_over = true
_clock_running = false
_set_bodies_frozen(true)
match_ended.emit(winning_team, score.duplicate())
if multiplayer.is_server() and MatchNet.submit_authoritative_result(score, integrity_state):
_awaiting_result_submission = true
ServerLog.info("match_ended", {"score_0": score.get(0, 0), "score_1": score.get(1, 0), "overtime": _in_overtime})
_set_match_state(MatchState.State.RESULTS)
@@ -1053,6 +1108,12 @@ func _update_match_state() -> void:
else:
_enter_results(_winning_team())
return
if match_state == MatchState.State.OVERTIME and _overtime_deadline_tick >= 0 and now >= _overtime_deadline_tick:
# Golden goal remains clockless to players, but an operational bound is
# necessary: a stalled draw must finish while its allocated credential is
# valid. REVIEW completes lifecycle delivery without rating either side.
_enter_results(-1, "REVIEW")
return
if _state_deadline_tick < 0 or now < _state_deadline_tick:
return
match match_state:
@@ -1064,6 +1125,8 @@ func _update_match_state() -> void:
_set_match_state(MatchState.State.WARMUP)
_begin_kickoff()
MatchState.State.RESULTS:
if _awaiting_result_submission:
return
# §6.2 step 10: clients return to the LOBBY, never the main menu —
# a community server that empties every 2.5 minutes is dead on
# arrival. The state change is what moves both sides; the server
@@ -1072,6 +1135,18 @@ func _update_match_state() -> void:
get_tree().change_scene_to_file.call_deferred(ScenePaths.LOBBY)
func _on_result_submission_accepted() -> void:
if not multiplayer.is_server() or not _awaiting_result_submission:
return
_awaiting_result_submission = false
_state_deadline_tick = Engine.get_physics_frames()
func _on_result_submission_retrying(http_code: int) -> void:
if multiplayer.is_server() and _awaiting_result_submission:
ServerLog.warn("result_submission_retrying", {"http_code": http_code})
func _on_state_change_received(state: int, at_tick: int) -> void:
# Client path. MatchSim already rejected an unknown state value, and the
# server is the only peer allowed to send this (rpc "authority").
@@ -1206,12 +1281,12 @@ func _build_takeover_controller() -> ShipController:
# Called when a peer joins while this match is already running. Returns true if
# it reclaimed a reserved slot (§6.4's 30s identity-keyed reservation).
func _try_reclaim_slot(peer_id: int, player_name: String) -> bool:
func _try_reclaim_slot(peer_id: int, player_identity: String, player_name: String) -> bool:
if not multiplayer.is_server():
return false
var now := Engine.get_physics_frames()
for slot in _slots:
if not slot.disconnected or slot.player_name == "" or slot.player_name != player_name:
if not slot.disconnected or slot.player_name == "" or not MatchNet.reservation_identity_matches(slot.player_identity, player_identity, slot.player_name, player_name):
continue
if slot.reserved_until_tick >= 0 and now > slot.reserved_until_tick:
continue # reservation lapsed; this is a fresh joiner, not a return
@@ -1246,7 +1321,7 @@ func _try_reclaim_slot(peer_id: int, player_name: String) -> bool:
func _on_player_joined_midmatch(peer_id: int, player_name: String) -> void:
if not multiplayer.is_server() or _slots.is_empty():
return
if _try_reclaim_slot(peer_id, player_name):
if _try_reclaim_slot(peer_id, MatchNet.player_identity(peer_id), player_name):
return
if _max_spectators >= 0 and _spectator_count() > _max_spectators:
print("NetworkedMatch: spectator cap (%d) reached, disconnecting peer %d" % [_max_spectators, peer_id])
@@ -1262,7 +1337,7 @@ func _on_player_joined_midmatch(peer_id: int, player_name: String) -> void:
# in _promote_late_joiners(). Queued in arrival order and consumed from the
# front, so waiting is first-come-first-served rather than whichever slot
# index happens to free up first.
_late_joiners.append({"peer_id": peer_id, "player_name": player_name})
_late_joiners.append({"peer_id": peer_id, "player_name": player_name, "player_identity": MatchNet.player_identity(peer_id)})
print("NetworkedMatch: peer %d (%s) joined mid-match; spectating until the next kickoff" % [peer_id, player_name])
@@ -1302,6 +1377,7 @@ func _promote_late_joiners() -> void:
var joiner_peer := int(joiner["peer_id"])
slot.peer_id = joiner_peer
slot.player_name = String(joiner["player_name"])
slot.player_identity = String(joiner.get("player_identity", ""))
slot.disconnected = false
slot.reserved_until_tick = -1
# Same reasoning as the reclaim path: the arriving client numbers its
@@ -2140,6 +2216,12 @@ func get_net_debug_stats() -> Dictionary:
"ball_proxy_moved_before_authority": _ball_proxy_moved_before_authority_count > 0,
"ball_proxy_moved_before_authority_count": _ball_proxy_moved_before_authority_count,
"ball_authority_changed_since_contact": _ball_authority_changed_since_contact,
# p95 alongside p99. A p99 over a few hundred samples is only its worst
# handful, so on a loaded host it reports scheduling jitter as much as
# interpolation quality. p95 is stable enough to carry a tight bar,
# leaving p99 to catch genuine tail blow-ups.
"remote_residual_position_p95": _remote_percentile(_remote_position_residuals, 0.95),
"remote_residual_rotation_p95": _remote_percentile(_remote_rotation_residuals, 0.95),
"remote_residual_position_p99": _remote_percentile(_remote_position_residuals, 0.99),
"remote_residual_rotation_p99": _remote_percentile(_remote_rotation_residuals, 0.99),
"latest_prediction_error": _last_local_prediction_comparison.get("position_error", Vector3.ZERO),
+1 -1
View File
@@ -5,7 +5,7 @@ extends CanvasLayer
# Performance monitors; never touches rendering or gameplay state. Exists so
# 0.17/0.17b's graphics presets and resolution scaling are self-diagnosing —
# TIME_PROCESS vs total frame time tells the player whether they're CPU- or
# GPU-bound. See multiplayer-todo.md task 0.20.
# GPU-bound. See multiplayer-next.md task 0.20.
# ~2s of history at 60 fps; enough to make p50/p99 meaningful without the
# history itself being a rate-dependent quantity.
+22 -13
View File
@@ -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")
+104
View File
@@ -0,0 +1,104 @@
class_name RankedProfileState
extends RefCounted
# Read-only server projection. The client deliberately stores no tier bands
# or rating formula: it displays the backend's committed view verbatim after
# validating the shape and numeric safety of the response.
var available := false
var rating := 0.0
var rd := 0.0
var volatility := 0.0
var ranked_games := 0
var tier := ""
var provisional := false
var season_id := ""
var season_ends_at_unix := 0
var error_message := ""
func apply(payload: Dictionary) -> bool:
var required := ["rating", "rd", "volatility", "ranked_games", "tier", "provisional"]
for key in required:
if not payload.has(key):
return _reject("Profile response is missing " + key)
if not (payload["rating"] is int or payload["rating"] is float) or not (payload["rd"] is int or payload["rd"] is float) or not (payload["volatility"] is int or payload["volatility"] is float) or not (payload["ranked_games"] is int or payload["ranked_games"] is float) or not payload["tier"] is String or not payload["provisional"] is bool:
return _reject("Profile response contains invalid types")
var next_rating := float(payload["rating"])
var next_rd := float(payload["rd"])
var next_volatility := float(payload["volatility"])
var next_games := int(payload["ranked_games"])
var next_tier := String(payload["tier"])
if not is_finite(next_rating) or not is_finite(next_rd) or not is_finite(next_volatility) or not _valid_nonnegative_integer(payload["ranked_games"]) or next_rating < 0.0 or next_rd < 0.0 or next_volatility < 0.0 or next_games < 0 or not _valid_tier(next_tier):
return _reject("Profile response contains invalid values")
rating = next_rating
rd = next_rd
volatility = next_volatility
ranked_games = next_games
tier = next_tier
provisional = bool(payload["provisional"])
season_id = ""
if payload.has("season_id"):
if not payload["season_id"] is String or not is_valid_opaque_id(String(payload["season_id"])):
return _reject("Profile response contains invalid season identifier")
season_id = String(payload["season_id"])
season_ends_at_unix = 0
if payload.has("season_ends_at"):
if not payload["season_ends_at"] is String or not is_valid_season_timestamp(String(payload["season_ends_at"])):
return _reject("Profile response contains invalid season expiry")
var parsed_season_end := Time.get_unix_time_from_datetime_string(String(payload["season_ends_at"]))
if parsed_season_end < 0:
return _reject("Profile response contains invalid season expiry")
season_ends_at_unix = int(parsed_season_end)
available = true
error_message = ""
return true
static func is_valid_season_timestamp(value: String) -> bool:
if value.is_empty():
return false
var timestamp_pattern := RegEx.create_from_string("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$")
return timestamp_pattern.search(value) != null
static func is_valid_opaque_id(value: String) -> bool:
if value.length() < 16 or value.length() > 128:
return false
var id_pattern := RegEx.create_from_string("^[A-Za-z0-9_-]+$")
return id_pattern.search(value) != null
static func _valid_nonnegative_integer(value: Variant) -> bool:
if value is int:
return int(value) >= 0
if value is float:
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
return false
static func _valid_tier(value: String) -> bool:
return value in ["PROVISIONAL", "BRONZE", "SILVER", "GOLD", "PLATINUM", "DIAMOND"]
func set_error(reason: String) -> void:
available = false
error_message = reason
func display_text(now_unix: int = -1) -> String:
if not available:
return error_message if not error_message.is_empty() else "Ranked profile unavailable"
var status := "Provisional" if provisional else tier
var text := "%s · %d ranked game%s" % [status, ranked_games, "" if ranked_games == 1 else "s"]
if season_ends_at_unix > 0:
var current_unix := int(Time.get_unix_time_from_system()) if now_unix < 0 else now_unix
var remaining_days := maxi(0, int(ceil(float(season_ends_at_unix - current_unix) / 86400.0)))
text += " · Season ends in %dd" % remaining_days
return text
func _reject(reason: String) -> bool:
available = false
error_message = reason
return false
+1 -1
View File
@@ -1,7 +1,7 @@
class_name ReplayLog
extends RefCounted
# Append-only binary server replay log (multiplayer-todo.md task 5.10).
# Append-only binary server replay log (multiplayer-next.md task 5.10).
#
# The highest-value debuggability investment in Phase 5, and cheap precisely
# because the packets are ALREADY flat bytes: this stores them verbatim rather
+1
View File
@@ -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.
+179 -2
View File
@@ -1,5 +1,12 @@
extends Node
const NetCodec = preload("res://scripts/net_codec.gd")
const ServerControlScript = preload("res://scripts/server_control.gd")
const AgonesSDKScript = preload("res://scripts/agones_sdk.gd")
const AssignmentState = preload("res://scripts/assignment_state.gd")
const ConnectionLeaseClientScript = preload("res://scripts/connection_lease_client.gd")
const ServerResultClientScript = preload("res://scripts/server_result_client.gd")
# Headless dedicated server entry point (task 1.6). Parses CLI args, hosts
# via NetworkManager, logs structured lines, and watches for physics-tick
# overrun (§9 gotcha 9: Engine.max_physics_steps_per_frame defaults to 8;
@@ -24,6 +31,12 @@ extends Node
var _last_physics_frame := 0
var config: ServerConfig = null
var _watchdog_armed := false # skip the first _process(): engine startup scheduling can batch several physics frames before the first idle frame runs, which isn't a real overrun
var _control: ServerControl = null
var _match_loop: ServerMatchLoop = null
var _agones = null
var _connection_leases = null
var _result_client = null
var _drain_requested := false
func _ready() -> void:
@@ -51,6 +64,93 @@ func _ready() -> void:
ServerLog.configure(String(config.get_value("log-level")))
var port := int(config.get_value("port"))
var max_clients := int(config.get_value("max-clients"))
var allocated_mode := bool(config.get_value("allocated-mode"))
var assigned_transport := String(config.get_value("transport"))
# Hosted SDR is not wired into the Godot transport layer yet. Refuse the
# allocated launch rather than silently opening an ENet endpoint that does
# not match the signed assignment's transport contract.
if allocated_mode and assigned_transport != NetworkManager.TRANSPORT_ENET:
printerr("cosmic-clash-server: allocated transport '%s' is not supported by this build" % assigned_transport)
get_tree().quit(1)
return
# Agones injects its HTTP port into every managed game-server container.
# Keep lifecycle readiness and health active in the reduced kind smoke even
# though that environment intentionally omits allocation/roster semantics.
var agones_managed := not OS.get_environment("AGONES_SDK_HTTP_PORT").is_empty()
if allocated_mode or agones_managed:
_control = ServerControlScript.new()
_control.name = "ServerControl"
_control.drain_requested.connect(_on_drain_requested)
_control.initial_connect_ready.connect(_on_initial_connect_ready)
get_tree().root.add_child.call_deferred(_control)
var control_err := _control.start(int(config.get_value("readiness-port")), OS.get_environment(String(config.get_value("drain-token-env"))))
if control_err != OK:
printerr("cosmic-clash-server: refusing to start with invalid readiness control port")
get_tree().quit(1)
return
if agones_managed:
_agones = AgonesSDKScript.new()
_agones.name = "AgonesSDK"
# Configure before parenting, then request health and defer the add like
# every other node here (§9 gotcha 27: add_child() on get_tree().root
# from inside _ready() is refused because the tree is still attaching
# this very node, and the refusal is not catchable from GDScript). The
# SDK arms its own timer in _ready(), so nothing depends on the order
# these deferred calls happen to flush in.
if _agones.configure_from_environment():
_agones.start_health()
else:
# Never silent: without this the log looks identical to a healthy
# server right up until Agones recycles it.
printerr("cosmic-clash-server: AGONES_SDK_HTTP_PORT is missing or invalid; Agones health pings are disabled")
get_tree().root.add_child.call_deferred(_agones)
if allocated_mode:
var roster_file := String(config.get_value("join-authorisations-file"))
var key_file := String(config.get_value("join-authorisations-key-file"))
var roster_json := FileAccess.get_file_as_string(roster_file)
var signing_keys := _load_join_signing_keys(key_file)
var roster_tokens = JSON.parse_string(roster_json)
if not roster_tokens is Array or roster_tokens.is_empty() or signing_keys.is_empty() or not MatchNet.configure_join_authorisations(roster_tokens, {
"match_id": String(config.get_value("match-id")),
"server_id": String(config.get_value("server-id")),
"protocol": str(NetCodec.PROTOCOL_VERSION),
"protocol_version": NetCodec.PROTOCOL_VERSION,
}, signing_keys) or MatchNet.assigned_player_slots().size() != roster_tokens.size():
printerr("cosmic-clash-server: refusing to start with invalid join-authorisations-file")
get_tree().quit(1)
return
# An allocated process owns exactly the roster issued for this match.
# Never let the general-purpose direct-server default (one player) start
# an allocated match with only a partial assignment admitted.
config.values["min-players"] = required_min_players(true, roster_tokens.size(), int(config.get_value("min-players")))
_connection_leases = ConnectionLeaseClientScript.new()
_connection_leases.name = "ConnectionLeases"
var lease_url := OS.get_environment("COSMIC_CLASH_CONTROL_PLANE_URL")
var lease_token := OS.get_environment("COSMIC_CLASH_WORKLOAD_TOKEN")
if _connection_leases.configure(lease_url, lease_token, String(config.get_value("match-id")), String(config.get_value("server-id"))):
_connection_leases.reconciliation_failed.connect(_on_connection_lease_reconciliation_failed)
get_tree().root.add_child.call_deferred(_connection_leases)
MatchNet.configure_connection_lease_callbacks(_connection_leases.claim, _connection_leases.record_disconnect)
else:
_connection_leases.queue_free()
_connection_leases = null
# Allocated matches must never fall back to an in-memory connection
# generation. Doing so would admit a player without the durable fence
# that prevents a second process (or a stale peer) from owning the same
# ranked slot. Direct/community servers do not enter this branch.
printerr("cosmic-clash-server: refusing allocated startup without connection-lease configuration")
get_tree().quit(1)
return
_result_client = ServerResultClientScript.new()
_result_client.name = "ServerResults"
if not _result_client.configure(lease_url, lease_token, String(config.get_value("match-id")), String(config.get_value("server-id"))):
printerr("cosmic-clash-server: refusing allocated startup without result-submission configuration")
get_tree().quit(1)
return
_result_client.accepted.connect(func(): MatchNet.result_submission_accepted.emit())
_result_client.retrying.connect(func(http_code): MatchNet.result_submission_retrying.emit(http_code))
get_tree().root.add_child.call_deferred(_result_client)
MatchNet.configure_result_submission(_result_client.submit)
NetworkManager.client_connected.connect(_on_client_connected)
NetworkManager.client_disconnected.connect(_on_client_disconnected)
@@ -62,12 +162,19 @@ func _ready() -> void:
ServerLog.error("server_boot_failed", {"port": port, "error": error_string(err)})
get_tree().quit(1)
return
if _control != null:
_control.set_process_ready(true)
_install_match_loop()
ServerLog.info("server_started", {
"port": port, "max_clients": max_clients, "log_level": ServerLog.level_name(),
"min_players": int(config.get_value("min-players")),
"max_matches": int(config.get_value("max-matches")),
"max_matches": 1 if allocated_mode else int(config.get_value("max-matches")),
"arena_rotation": String(config.get_value("arena-rotation")),
"allocated_mode": allocated_mode,
"match_id": String(config.get_value("match-id")) if allocated_mode else "",
"server_id": String(config.get_value("server-id")) if allocated_mode else "",
"region": String(config.get_value("region")) if allocated_mode else "",
"transport": assigned_transport if allocated_mode else NetworkManager.TRANSPORT_ENET,
})
_last_physics_frame = Engine.get_physics_frames()
@@ -78,16 +185,29 @@ func _ready() -> void:
# it started. Same constraint the smoke-test hooks document.
func _install_match_loop() -> void:
var loop := ServerMatchLoop.new()
_match_loop = loop
loop.name = "ServerMatchLoop"
loop.min_players = int(config.get_value("min-players"))
loop.start_countdown_seconds = float(config.get_value("start-countdown"))
loop.max_matches = int(config.get_value("max-matches"))
loop.max_matches = 1 if bool(config.get_value("allocated-mode")) else int(config.get_value("max-matches"))
loop.rotation_mode = String(config.get_value("arena-rotation"))
loop.allocated_mode = bool(config.get_value("allocated-mode"))
loop.allocated_playlist = String(config.get_value("playlist"))
loop.allocated_roster_size = MatchNet.assigned_player_slots().size() if loop.allocated_mode else 0
loop.allocated_arena_path = String(config.get_value("arena-path"))
# The backend's fair timeout starts only after durable assignment-ready.
# When a control plane is present, the supervisor arms this loop through
# the authenticated local control endpoint after that transition commits.
loop.allocated_admission_armed = not loop.allocated_mode or OS.get_environment("COSMIC_CLASH_INITIAL_CONNECT_SIGNAL_REQUIRED") != "1"
get_tree().root.add_child.call_deferred(loop)
func _process(_delta: float) -> void:
NetworkManager.poll()
if _drain_requested:
var scene := get_tree().current_scene
if not (is_instance_valid(scene) and scene.is_in_group("game")) and MatchNet.roster.is_empty():
get_tree().quit(0)
var current := Engine.get_physics_frames()
var steps := current - _last_physics_frame
_last_physics_frame = current
@@ -119,3 +239,60 @@ func _on_player_joined(peer_id: int, player_name: String) -> void:
func _on_player_left(peer_id: int) -> void:
ServerLog.info("player_left", {"peer_id": peer_id, "roster": MatchNet.roster.size()})
func _on_drain_requested() -> void:
_drain_requested = true
MatchNet.admissions_open = false
MatchNet.broadcast_server_shutdown("server_draining")
ServerLog.info("server_draining", {"reason": "control_request"})
func _on_initial_connect_ready() -> void:
if _match_loop != null and is_instance_valid(_match_loop):
_match_loop.arm_allocated_admission()
ServerLog.info("initial_connect_window_started", {"match_id": String(config.get_value("match-id"))})
func _on_connection_lease_reconciliation_failed(reason: String) -> void:
# A durable/local divergence means this process can no longer prove that a
# future generation is globally current. Preserve the live match but close
# admission so it cannot mint additional ambiguous leases.
MatchNet.admissions_open = false
ServerLog.error("connection_lease_reconciliation_failed", {"reason": reason})
static func valid_connection_report_configuration(base_url: String, workload_token: String, match_id: String, server_id: String, player_id: String) -> bool:
return ConnectionLeaseClientScript.valid_configuration(base_url, workload_token, match_id, server_id) and AssignmentState.is_valid_opaque_id(player_id)
static func required_min_players(allocated: bool, roster_size: int, configured: int) -> int:
if allocated and roster_size > 0:
return roster_size
return configured
# The join-signing key file maps key ID -> base64 raw key, so the allocator can
# rotate the signing key without invalidating authorisations already issued for
# in-flight matches: a rotation publishes the new key alongside the old, and the
# old one is dropped only once no live match can still reference it.
#
# A file containing raw key bytes (no JSON object) is accepted as a single key
# under the empty ID, which is what an unrotated deployment and the local smoke
# fixtures use.
static func _load_join_signing_keys(key_file: String) -> Dictionary:
var raw := FileAccess.get_file_as_bytes(key_file)
if raw.is_empty():
return {}
var parsed = JSON.parse_string(raw.get_string_from_utf8())
if not parsed is Dictionary or (parsed as Dictionary).is_empty():
return {"": raw}
var keys := {}
for key_id in parsed:
var encoded = parsed[key_id]
if not encoded is String or String(encoded).is_empty():
return {}
var decoded := Marshalls.base64_to_raw(String(encoded))
if decoded.is_empty():
return {}
keys[str(key_id)] = decoded
return keys
+72 -2
View File
@@ -1,7 +1,7 @@
class_name ServerConfig
extends RefCounted
# Dedicated-server configuration (multiplayer-todo.md task 6.3): one
# Dedicated-server configuration (multiplayer-next.md task 6.3): one
# declaration of every server flag, one parser, one `--help`.
#
# Standalone RefCounted with no scene or RPC dependency — same reason as
@@ -56,14 +56,32 @@ static func specs() -> Array[Spec]:
out.append(Spec.new("log-level", Kind.STRING, "info", "logging", "One of debug, info, warn, error"))
out.append(Spec.new("replay-log", Kind.STRING, "", "logging", "Path to record a binary replay log to; empty disables (see tools/replay_dump.gd)"))
out.append(Spec.new("match-length", Kind.FLOAT, 150.0, "match", "Regulation length in seconds"))
out.append(Spec.new("max-overtime-seconds", Kind.FLOAT, 900.0, "match", "Safety cap for sudden death; expiry records a REVIEW result without rating changes"))
out.append(Spec.new("max-matches", Kind.INT, 0, "match", "Exit cleanly after this many completed matches; 0 runs forever"))
out.append(Spec.new("min-players", Kind.INT, 1, "match", "Players required before a match starts"))
out.append(Spec.new("start-countdown", Kind.FLOAT, 5.0, "match", "Seconds to wait after min-players is met before starting"))
out.append(Spec.new("arena-rotation", Kind.STRING, "sequential", "match", "How the next arena is picked: sequential or random"))
out.append(Spec.new("arena-path", Kind.STRING, "", "match", "Allocated arena scene path; empty uses rotation"))
out.append(Spec.new("smoke-force-goal-after", Kind.FLOAT, -1.0, "match", "LOCAL TEST ONLY: force one server-authoritative goal this many seconds after play starts; -1 disables"))
out.append(Spec.new("fill-bots", Kind.BOOL, false, "match", "Give a disconnected player's ship to a bot instead of leaving it inert"))
out.append(Spec.new("slot-reservation-seconds", Kind.FLOAT, 30.0, "match", "How long a departed player's slot is held for their return"))
out.append(Spec.new("config", Kind.STRING, "", "general", "Path to a config file supplying defaults for any flag above"))
# Allocated-mode fields are opt-in. Empty defaults intentionally preserve
# the direct-IP/community-server path and its existing CLI/config surface.
out.append(Spec.new("allocated-mode", Kind.BOOL, false, "allocation", "Enable match-scoped allocation admission and lifecycle"))
out.append(Spec.new("match-id", Kind.STRING, "", "allocation", "Opaque allocated match identifier"))
out.append(Spec.new("server-id", Kind.STRING, "", "allocation", "Opaque allocated server identifier"))
out.append(Spec.new("playlist-version", Kind.STRING, "", "allocation", "Matchmaking playlist contract version"))
out.append(Spec.new("playlist", Kind.STRING, "", "allocation", "Allocated playlist: casual or ranked"))
out.append(Spec.new("client-build", Kind.STRING, "", "allocation", "Expected immutable client build identifier"))
out.append(Spec.new("assignment-expiry-unix", Kind.INT, 0, "allocation", "Unix expiry for the allocated assignment; must be in the future"))
out.append(Spec.new("server-image-digest", Kind.STRING, "", "allocation", "Expected immutable server image digest (sha256:...)"))
out.append(Spec.new("transport", Kind.STRING, "", "allocation", "Assigned transport: steam_sdr or enet"))
out.append(Spec.new("region", Kind.STRING, "", "allocation", "Assigned region: EU or NA"))
out.append(Spec.new("join-authorisations-file", Kind.STRING, "", "allocation", "JSON array of control-plane signed join envelopes mounted for this match"))
out.append(Spec.new("join-authorisations-key-file", Kind.STRING, "", "allocation", "HMAC-SHA256 key file for verifying mounted join envelopes"))
out.append(Spec.new("readiness-port", Kind.INT, 7780, "allocation", "Loopback HTTP port for allocated process-ready and drain control"))
out.append(Spec.new("drain-token-env", Kind.STRING, "COSMIC_CLASH_DRAIN_TOKEN", "allocation", "Environment variable containing the allocated drain bearer token"))
return out
@@ -231,10 +249,15 @@ func _validate() -> void:
var port := int(values["port"])
if port < 1 or port > 65535:
errors.append("--port must be 1-65535, got %d" % port)
var readiness_port := int(values["readiness-port"])
if readiness_port < 1 or readiness_port > 65535:
errors.append("--readiness-port must be 1-65535, got %d" % readiness_port)
if int(values["max-clients"]) < 1:
errors.append("--max-clients must be at least 1, got %d" % int(values["max-clients"]))
if float(values["match-length"]) <= 0.0:
errors.append("--match-length must be positive, got %s" % str(values["match-length"]))
if float(values["max-overtime-seconds"]) <= 0.0:
errors.append("--max-overtime-seconds must be positive, got %s" % str(values["max-overtime-seconds"]))
if int(values["max-matches"]) < 0:
errors.append("--max-matches must be 0 or more, got %d" % int(values["max-matches"]))
if int(values["min-players"]) < 1:
@@ -249,6 +272,53 @@ func _validate() -> void:
var rotation := String(values["arena-rotation"])
if not rotation in ["sequential", "random"]:
errors.append("--arena-rotation must be sequential or random, got '%s'" % rotation)
var arena_path := String(values["arena-path"])
if not arena_path.is_empty() and not arena_path in ArenaRegistry.rotation_paths():
errors.append("--arena-path must be a ranked-eligible ArenaRegistry path, got '%s'" % arena_path)
if bool(values["allocated-mode"]):
for key in ["match-id", "server-id", "playlist-version", "playlist", "client-build", "assignment-expiry-unix", "server-image-digest", "transport", "region"]:
if str(values[key]).is_empty():
errors.append("--allocated-mode requires --%s" % key)
if not _is_opaque_id(String(values["match-id"])):
errors.append("--match-id must be an opaque ID of 16-128 safe characters")
if not _is_opaque_id(String(values["server-id"])):
errors.append("--server-id must be an opaque ID of 16-128 safe characters")
if int(values["assignment-expiry-unix"]) <= int(Time.get_unix_time_from_system()):
errors.append("--assignment-expiry-unix must be in the future")
if String(values["join-authorisations-file"]).is_empty():
errors.append("--join-authorisations-file is required in allocated mode")
if String(values["join-authorisations-key-file"]).is_empty():
errors.append("--join-authorisations-key-file is required in allocated mode")
var digest := String(values["server-image-digest"])
if not _is_sha256_digest(digest):
errors.append("--server-image-digest must be sha256:<64 hex characters>")
var transport := String(values["transport"])
if not transport in ["steam_sdr", "enet"]:
errors.append("--transport must be steam_sdr or enet, got '%s'" % transport)
var region := String(values["region"])
if not region in ["EU", "NA"]:
errors.append("--region must be EU or NA, got '%s'" % region)
var playlist := String(values["playlist"])
if not playlist in ["casual", "ranked"]:
errors.append("--playlist must be casual or ranked, got '%s'" % playlist)
if playlist == "ranked" and arena_path.is_empty():
errors.append("--allocated-mode ranked matches require --arena-path")
static func _is_sha256_digest(value: String) -> bool:
if not value.begins_with("sha256:") or value.length() != 71:
return false
for c in value.substr(7):
if not c.to_lower() in "0123456789abcdef":
return false
return true
static func _is_opaque_id(value: String) -> bool:
if value.length() < 16 or value.length() > 128:
return false
var resource_pattern := RegEx.create_from_string("^[A-Za-z0-9_-]+$")
return resource_pattern.search(value) != null
static func _kind_name(kind: int) -> String:
@@ -276,7 +346,7 @@ static func help_text() -> String:
lines.append("")
lines.append("The command line overrides the config file, which overrides the defaults")
lines.append("shown below. An unknown flag is an error, not a warning.")
var sections := ["general", "network", "match", "logging"]
var sections := ["general", "network", "match", "logging", "allocation"]
var all := specs()
for section in sections:
lines.append("")
+120
View File
@@ -0,0 +1,120 @@
class_name ServerControl
extends Node
# Small loopback HTTP control surface for lifecycle-managed servers. The Go
# supervisor uses GET /ready as the explicit process-ready probe and POST
# /drain during a controlled termination. Direct/community servers outside
# Agones do not start this node.
signal drain_requested
signal initial_connect_ready
var _listener := TCPServer.new()
var _peers: Array = []
var _ready_for_connections := false
var _draining := false
var _drain_token := ""
func start(port: int, drain_token: String = "") -> Error:
if port < 1 or port > 65535:
return ERR_INVALID_PARAMETER
_drain_token = drain_token
return _listener.listen(port, "127.0.0.1")
func stop() -> void:
_listener.stop()
for peer in _peers:
if is_instance_valid(peer):
peer.disconnect_from_host()
_peers.clear()
func set_process_ready(value: bool) -> void:
_ready_for_connections = value and not _draining
func is_draining() -> bool:
return _draining
func _exit_tree() -> void:
stop()
func _process(_delta: float) -> void:
while _listener.is_connection_available():
_peers.append(_listener.take_connection())
for i in range(_peers.size() - 1, -1, -1):
var peer: StreamPeerTCP = _peers[i]
if peer.get_status() != StreamPeerTCP.STATUS_CONNECTED:
_peers.remove_at(i)
continue
var available := peer.get_available_bytes()
if available <= 0:
continue
var request := peer.get_utf8_string(available)
if "\r\n\r\n" not in request:
continue
_respond(peer, request)
_peers.remove_at(i)
func _respond(peer: StreamPeerTCP, request: String) -> void:
var lines := request.split("\r\n")
var first := lines[0].split(" ") if not lines.is_empty() else PackedStringArray()
var method := String(first[0]) if first.size() > 0 else ""
var path := String(first[1]) if first.size() > 1 else ""
var status := 404
var reason := "Not Found"
var body := ""
if method in ["GET", "POST"] and path == "/ready":
status = 200 if _ready_for_connections else 503
reason = "OK" if status == 200 else "Service Unavailable"
elif method in ["GET", "POST"] and path == "/health":
status = 200
reason = "OK"
elif method == "POST" and path == "/drain":
var supplied := ""
for line in lines:
if line.begins_with("Authorization: Bearer "):
supplied = line.substr("Authorization: Bearer ".length())
if _drain_token.is_empty() or not _constant_time_equal(supplied, _drain_token):
status = 401
reason = "Unauthorized"
else:
_draining = true
_ready_for_connections = false
drain_requested.emit()
status = 202
reason = "Accepted"
elif method == "POST" and path == "/initial-connect-ready":
var supplied := ""
for line in lines:
if line.begins_with("Authorization: Bearer "):
supplied = line.substr("Authorization: Bearer ".length())
if _drain_token.is_empty() or not _constant_time_equal(supplied, _drain_token):
status = 401
reason = "Unauthorized"
else:
initial_connect_ready.emit()
status = 202
reason = "Accepted"
else:
status = 405 if method in ["GET", "POST"] else 400
reason = "Method Not Allowed" if status == 405 else "Bad Request"
body = "{\"status\":\"%s\"}" % ("ready" if status == 200 else "not_ready")
var response := "HTTP/1.1 %d %s\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s" % [status, reason, body.to_utf8_buffer().size(), body]
peer.put_data(response.to_utf8_buffer())
peer.disconnect_from_host()
func _constant_time_equal(a: String, b: String) -> bool:
var left := a.to_utf8_buffer()
var right := b.to_utf8_buffer()
var difference := left.size() ^ right.size()
var length := mini(left.size(), right.size())
for i in length:
difference |= left[i] ^ right[i]
return difference == 0
+1 -1
View File
@@ -1,7 +1,7 @@
class_name ServerLog
extends RefCounted
# Structured server logging (multiplayer-todo.md task 6.4).
# Structured server logging (multiplayer-next.md task 6.4).
#
# Extracted from server_boot.gd's private `_log`, which could only ever see
# what the boot scene itself observed: connects, disconnects, roster changes
+76 -2
View File
@@ -1,7 +1,7 @@
class_name ServerMatchLoop
extends Node
# The dedicated server's match loop (multiplayer-todo.md task 6.5).
# The dedicated server's match loop (multiplayer-next.md task 6.5).
#
# THIS CLOSES A GAP NO TASK OWNED. Task 6.2 asks for "the exported binary runs
# a full match headless", but nothing in the product ever started a match:
@@ -35,17 +35,27 @@ extends Node
signal match_starting(arena_path: String, match_index: int)
const POLL_INTERVAL_MS := 250
const ALLOCATED_WAIT := "WAIT"
const ALLOCATED_READY := "READY"
const ALLOCATED_CANCEL := "CANCEL"
const ALLOCATED_START_WITH_BOTS := "START_WITH_BOTS"
var min_players := 1
var start_countdown_seconds := 5.0
var max_matches := 0 # 0 = run forever
var rotation_mode := "sequential"
var allocated_mode := false
var allocated_playlist := ""
var allocated_roster_size := 0
var allocated_arena_path := ""
var allocated_admission_armed := true
var matches_completed := 0
var _countdown_started_ms := -1
var _match_active := false
var _next_poll_ms := 0
var _shutting_down := false
var _allocated_connect_started_ms := -1
func _process(_delta: float) -> void:
@@ -58,7 +68,71 @@ func _process(_delta: float) -> void:
if _match_active:
_poll_match_end()
else:
if allocated_mode:
_poll_allocated_match_start(now)
else:
_poll_match_start(now)
func _poll_allocated_match_start(now: int) -> void:
if not allocated_admission_armed:
return
if _allocated_connect_started_ms < 0:
_allocated_connect_started_ms = now
var connected := MatchNet.roster.size()
var has_team_zero := false
var has_team_one := false
for info: MatchNet.PlayerInfo in MatchNet.roster.values():
has_team_zero = has_team_zero or info.team == 0
has_team_one = has_team_one or info.team == 1
var action := allocated_initial_connect_action(allocated_playlist, now - _allocated_connect_started_ms, connected, allocated_roster_size, has_team_zero, has_team_one)
if action == ALLOCATED_READY:
_poll_match_start(now)
return
if action == ALLOCATED_CANCEL:
var reason := "ranked_initial_connect_timeout" if allocated_playlist == "ranked" else "casual_initial_connect_ineligible"
_cancel_allocated_no_show(reason, connected)
return
if action == ALLOCATED_START_WITH_BOTS:
NetworkedMatch.server_bot_fill_override = true
_poll_match_start(now)
func arm_allocated_admission() -> void:
allocated_admission_armed = true
_allocated_connect_started_ms = -1
static func allocated_initial_connect_action(playlist: String, elapsed_ms: int, connected: int, expected: int, has_team_zero: bool, has_team_one: bool) -> String:
if elapsed_ms < 0 or connected < 0 or expected < 1:
return ALLOCATED_CANCEL
if playlist == "ranked":
if expected != 6:
return ALLOCATED_CANCEL
if connected >= expected:
return ALLOCATED_READY
return ALLOCATED_CANCEL if elapsed_ms >= 30000 else ALLOCATED_WAIT
if playlist == "casual":
if expected < 2 or expected > 6:
return ALLOCATED_CANCEL
if connected >= expected:
if expected == 6:
return ALLOCATED_READY
return ALLOCATED_START_WITH_BOTS if has_team_zero and has_team_one else ALLOCATED_CANCEL
if elapsed_ms < 60000:
return ALLOCATED_WAIT
return ALLOCATED_START_WITH_BOTS if connected >= 2 and has_team_zero and has_team_one else ALLOCATED_CANCEL
return ALLOCATED_CANCEL
func _cancel_allocated_no_show(reason: String, connected: int) -> void:
if _shutting_down:
return
_shutting_down = true
ServerLog.info("initial_connect_cancelled", {"reason": reason, "connected": connected, "expected": allocated_roster_size})
MatchNet.broadcast_server_shutdown(reason)
await get_tree().create_timer(0.3).timeout
NetworkManager.shutdown()
get_tree().quit(0)
# A match is over when the match scene is gone. NetworkedMatch returns both
@@ -108,7 +182,7 @@ func _poll_match_start(now: int) -> void:
func _start_match() -> void:
var arena_path := ArenaRegistry.path_for_match(matches_completed, rotation_mode)
var arena_path := allocated_arena_path if allocated_mode and not allocated_arena_path.is_empty() else ArenaRegistry.path_for_match(matches_completed, rotation_mode)
# The match scene picks its own arena at random by default. Handing it one
# explicitly is what makes rotation a rotation rather than a coincidence.
NetworkedMatch.server_arena_override = arena_path
+91
View File
@@ -0,0 +1,91 @@
class_name ServerResultClient
extends Node
# The allocated server is the sole authority able to finish a match. Keep the
# match in RESULTS until the control plane has durably acknowledged this exact,
# idempotent payload: exiting first would strand the match in LIVE forever.
signal accepted
signal retrying(http_code: int)
const RETRY_SECONDS := 1.0
var _base_url := ""
var _workload_token := ""
var _match_id := ""
var _server_id := ""
var _submitting := false
func configure(base_url: String, workload_token: String, match_id: String, server_id: String) -> bool:
base_url = base_url.strip_edges().trim_suffix("/")
workload_token = workload_token.strip_edges()
if not valid_configuration(base_url, workload_token, match_id, server_id):
return false
_base_url = base_url
_workload_token = workload_token
_match_id = match_id
_server_id = server_id
return true
func submit(team_0: int, team_1: int, integrity_state := "CERTIFIED") -> void:
if _submitting or team_0 < 0 or team_1 < 0 or not integrity_state in ["CERTIFIED", "REVIEW"]:
return
_submitting = true
var nonce := result_nonce(_match_id, _server_id, team_0, team_1, integrity_state)
var key := "server-result-" + nonce
var payload := {
"match_id": _match_id,
"result_nonce": nonce,
"score": {"team_0": team_0, "team_1": team_1},
"integrity_state": integrity_state,
}
while is_inside_tree():
var response := await _send(payload, key)
if response_is_accepted(int(response.get("code", 0))):
_submitting = false
accepted.emit()
return
retrying.emit(int(response.get("code", 0)))
await get_tree().create_timer(RETRY_SECONDS).timeout
_submitting = false
func _send(payload: Dictionary, key: String) -> Dictionary:
var request := HTTPRequest.new()
request.timeout = 5.0
add_child(request)
var err := request.request("%s/v1/servers/%s/result" % [_base_url, _server_id.uri_encode()], [
"Authorization: Bearer " + _workload_token,
"Content-Type: application/json",
"Idempotency-Key: " + key,
], HTTPClient.METHOD_POST, JSON.stringify(payload))
if err != OK:
request.queue_free()
return {"code": 0}
var raw: Array = await request.request_completed
request.queue_free()
if int(raw[0]) != HTTPRequest.RESULT_SUCCESS:
return {"code": 0}
return {"code": int(raw[1])}
static func result_nonce(match_id: String, server_id: String, team_0: int, team_1: int, integrity_state: String) -> String:
# Result score is immutable once NetworkedMatch enters RESULTS. A deterministic
# nonce makes retries after a lost response provably the same submission.
return "result-" + (match_id + "\n" + server_id + "\n" + str(team_0) + "\n" + str(team_1) + "\n" + integrity_state).sha256_text()
static func response_is_accepted(http_code: int) -> bool:
# The documented endpoint acknowledges only after its serializable result
# transaction commits. Do not treat a generic 2xx as proof of completion.
return http_code == 202
static func valid_configuration(base_url: String, workload_token: String, match_id: String, server_id: String) -> bool:
if not (base_url.begins_with("http://") or base_url.begins_with("https://")) or base_url.contains("\n") or base_url.contains("\r") or base_url.contains("?") or base_url.contains("#") or base_url.contains("@"):
return false
if workload_token.is_empty() or workload_token.contains("\n") or workload_token.contains("\r"):
return false
return match_id.length() >= 8 and server_id.length() >= 8 and not match_id.contains("/") and not server_id.contains("/")
+10 -1
View File
@@ -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
@@ -48,6 +52,7 @@ var _populating := false
func _ready() -> void:
AudioManager.bind_tree_buttons(self)
# An idle settings screen has no reason to render past the display's own
# refresh rate; _on_back_pressed only returns to another capped menu, so
# no uncap is needed there (contrast main_menu.gd's _leave_to_gameplay).
@@ -204,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)
+23 -11
View File
@@ -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
@@ -145,7 +145,7 @@ func queue_teleport_with_velocity(to: Transform3D, new_linear_velocity: Vector3,
_has_pending_teleport = true
# --- Netcode correction hooks (Phase 4; see multiplayer-todo.md §4.4) ---
# --- Netcode correction hooks (Phase 4; see MULTIPLAYER_SPEC.md §4.4) ---
# Both stay zero until Phase 4 wires a reconciliation pass in, so the guarded
# hook in _integrate_forces below is a no-op today.
# Velocity delta from a soft correction, consumed once then cleared —
@@ -197,6 +197,7 @@ signal thrust_changed(thrust_percent: float)
signal angular_velocity_changed(angular_speed: float)
signal heading_changed(heading_degrees: float)
signal ball_contact(intensity: float, world_position: Vector3)
signal wall_contact(intensity: float)
# Performance optimization - track last emitted values to avoid unnecessary signals
var _last_speed: float = -1.0
@@ -222,7 +223,7 @@ var _engine_lights: Array[OmniLight3D] = []
# All rendered geometry (hull, canopy, engine cores/flames/lights, Nose,
# TailFin) parents under this instead of the RigidBody3D directly, so a
# future prediction correction (task 0.14) can offset the visual without
# moving the collider — see multiplayer-todo.md task 0.2. CollisionShape3D
# moving the collider — see multiplayer-next.md task 0.2. CollisionShape3D
# and the controller child correctly stay on the body itself.
@onready var visual: Node3D = $Visual
@@ -415,6 +416,8 @@ func is_turbo_active() -> bool:
func _on_body_entered(body: Node) -> void:
if body is StaticBody3D:
wall_contact.emit(clampf(linear_velocity.length() / maxf(max_speed, 0.001), 0.0, 1.0))
if not body is Ball:
return
var relative_speed := (linear_velocity - (body as Ball).linear_velocity).length()
@@ -554,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)
+16
View File
@@ -38,6 +38,10 @@ extends AIController3D
# stone, matching ball_touch_cooldown_ticks's existing "stepping-stone, not
# the objective" framing.
@export_range(0.0, 1.0) var ball_touch_direction_floor := 0.3
# Fraction of a touch payout shared with teammates. Zero preserves all
# existing 1v1/curriculum reward functions; in teamplay the shared amount is
# divided across teammates and never exceeds the touching ship's payout.
@export_range(0.0, 1.0) var team_touch_credit_weight := 0.0
@export var velocity_to_ball_weight := 0.02
# Dense reward for approaching the ball *nose first* near the floor. Unlike
# velocity_to_ball_weight, sideways/reverse closing velocity earns nothing:
@@ -606,6 +610,12 @@ func _on_ship_body_entered(body: Node) -> void:
if air_touch_bonus_weight > 0.0 and ball.global_position.y > AIR_TOUCH_HEIGHT:
touch_payout += air_touch_bonus_weight * alignment
reward += touch_payout
if team_touch_credit_weight > 0.0 and not teammates.is_empty():
var teammate_credit := team_touch_credit(touch_payout, team_touch_credit_weight, teammates.size())
for teammate in teammates:
var teammate_agent := teammate.get_node_or_null("ShipAIController") as ShipAIController
if is_instance_valid(teammate_agent):
teammate_agent.reward += teammate_credit
_ticks_since_ball_touch = 0
# air_touch_fraction/productive_air_touch_fraction (see get_info) share
@@ -617,3 +627,9 @@ func _on_ship_body_entered(body: Node) -> void:
_air_touches += 1
if alignment >= PRODUCTIVE_AIR_TOUCH_ALIGNMENT:
_productive_air_touches += 1
static func team_touch_credit(touch_payout: float, weight: float, teammate_count: int) -> float:
if touch_payout <= 0.0 or weight <= 0.0 or teammate_count <= 0:
return 0.0
return touch_payout * clampf(weight, 0.0, 1.0) / teammate_count
+14 -1
View File
@@ -97,15 +97,22 @@ func _connect_target() -> void:
return
if not target.ball_contact.is_connected(_on_target_ball_contact):
target.ball_contact.connect(_on_target_ball_contact)
if not target.wall_contact.is_connected(_on_target_wall_contact):
target.wall_contact.connect(_on_target_wall_contact)
func _exit_tree() -> void:
AudioManager.stop_engine()
if is_instance_valid(target) and target.ball_contact.is_connected(_on_target_ball_contact):
target.ball_contact.disconnect(_on_target_ball_contact)
if is_instance_valid(target) and target.wall_contact.is_connected(_on_target_wall_contact):
target.wall_contact.disconnect(_on_target_wall_contact)
func _input(event):
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)
@@ -218,6 +225,8 @@ func _smooth_look_at(point: Vector3, delta: float) -> void:
func _update_speed_feel(delta: float) -> void:
var feel_t := 1.0 - exp(-feel_smoothing * delta)
var turbo_target := 1.0 if target.is_turbo_active() else 0.0
var engine_action := target.get_current_action_copy()
AudioManager.set_engine_state(maxf(engine_action.thrust.z, 0.0), turbo_target > 0.5)
_turbo_blend = lerpf(_turbo_blend, turbo_target, feel_t)
_speed_blend = lerpf(_speed_blend, target.get_speed_ratio(), feel_t)
var target_fov := base_fov + _speed_blend * speed_fov_add + _turbo_blend * turbo_fov_kick
@@ -240,6 +249,10 @@ func _on_target_ball_contact(intensity: float, _world_position: Vector3) -> void
impact_feedback.emit(intensity)
func _on_target_wall_contact(intensity: float) -> void:
AudioManager.play_wall_scrape(intensity)
func _apply_shake(delta: float) -> void:
if _shake_strength <= 0.001:
_shake_strength = 0.0
+1 -1
View File
@@ -4,7 +4,7 @@ class_name SimConstants
# constant derived from "60 Hz" (Ship._tick_scaled's decay reference,
# reaction_ticks' export range, TrainingMode.TICKS_PER_SIM_SECOND) reads this
# instead of restating the literal, so changing it changes every derived
# constant coherently — see multiplayer-todo.md §5.6 on why a future 120 Hz
# constant coherently — see MULTIPLAYER_SPEC.md §5.6 on why a future 120 Hz
# simulation needs to be a config change plus a retrain, not a protocol
# rewrite hunting down bare 60s.
#
+50
View File
@@ -40,3 +40,53 @@ static func initialize() -> Dictionary:
if result is Dictionary and bool(result.get("status", false)):
return {"error": OK, "app_id": app_id()}
return {"error": ERR_CANT_CONNECT, "reason": "Steam initialization failed for App ID %d" % app_id()}
# Web-API auth ticket acquisition (task 7.6). The control plane exchanges this
# ticket with Valve's publisher API for a verified Steam identity; the client
# never chooses its own identity, which is what makes this the fix for slot
# reclaim being keyed on a display name.
#
# GodotSteam delivers the ticket asynchronously through the
# `get_auth_ticket_for_web_api` signal, because the ticket is not usable until
# Steam has confirmed it with its backend. Requesting one and reading the
# return value alone yields a handle, not a ticket.
#
# Everything here is called dynamically so stock Godot, which has no GodotSteam
# symbols, can still parse and run the project.
const WEB_API_IDENTITY := "cosmicclash"
static func supports_web_api_ticket() -> bool:
if not is_runtime_available():
return false
var steam := Engine.get_singleton("Steam")
return steam.has_signal("get_auth_ticket_for_web_api") and steam.has_method("getAuthTicketForWebApi")
# Returns the request handle, or 0 when unavailable. The caller must await the
# `get_auth_ticket_for_web_api` signal for the ticket itself.
static func request_web_api_ticket() -> int:
if not supports_web_api_ticket():
return 0
var steam := Engine.get_singleton("Steam")
var handle = steam.call("getAuthTicketForWebApi", WEB_API_IDENTITY)
return int(handle) if handle is int or handle is float else 0
static func cancel_web_api_ticket(handle: int) -> void:
if handle <= 0 or not is_runtime_available():
return
var steam := Engine.get_singleton("Steam")
if steam.has_method("cancelAuthTicket"):
steam.call("cancelAuthTicket", handle)
# GodotSteam hands back raw ticket bytes; the Web API expects them hex encoded.
static func encode_web_api_ticket(buffer: PackedByteArray) -> String:
if buffer.is_empty():
return ""
var encoded := ""
for byte in buffer:
encoded += "%02x" % int(byte)
return encoded
+1
View File
@@ -0,0 +1 @@
uid://itgcxadtf1wd
+1
View File
@@ -0,0 +1 @@
uid://c117m546w6u30
+65 -10
View File
@@ -70,6 +70,11 @@ const SimConstants = preload("res://scripts/sim_constants.gd")
# a real goal and ships start low behind/lateral to it, so a useful touch is
# naturally reinforced by the existing goal-directed ball rewards.
@export_range(0.0, 1.0) var air_intercept_chance := 0.0
# Wall-play and rebound starts are separate: wall-play begins beside a wall
# with the ball travelling inward, while rebound begins just before an
# outward wall impact. Both default off to preserve existing distributions.
@export_range(0.0, 1.0) var wall_play_chance := 0.0
@export_range(0.0, 1.0) var rebound_chance := 0.0
# Ground-start branch for the generation-5 handling stage: ships spawn level
# and resting on the floor with a low, floor-level ball. Every other branch
# samples ship Y uniformly across the full 18m volume (see _random_position),
@@ -82,8 +87,8 @@ const SimConstants = preload("res://scripts/sim_constants.gd")
# Ships per team. Default 1 preserves every existing curriculum script's 1v1
# behaviour unchanged; up to 5 matches ShipObservations.MAX_TEAMMATES/
# MAX_OPPONENTS. Plumbing only for this pass — no 2v2+ curriculum/reward
# design has been done, so a run above 1 is untested territory.
# MAX_OPPONENTS. Team-credit reward and paired 2v2 evaluation are opt-in;
# no teamplay training stage is enabled by default.
@export_range(1, 5) var team_size: int = 1
# Placement bounds for randomized episode starts, derived from the standard
@@ -100,6 +105,9 @@ const FIELD_MIN_Y := 1.5
# spawning interpenetrated with it.
const GROUND_START_Y := 0.35
const GROUND_START_BALL_Y := 0.55
const WALL_PLAY_BALL_CLEARANCE := 1.0
const REBOUND_BALL_CLEARANCE := 0.75
const WALL_PLAY_SPEED := Vector2(4.0, 9.0)
const FIELD_MAX_Y := ArenaBoundary.INNER_HEIGHT - SPAWN_INSET
# The corner curves reach at most their chord plane |x| + |z| = INNER_HALF_X
# + INNER_HALF_Z - CORNER_RADIUS; spawns keep the same SPAWN_INSET clearance
@@ -141,6 +149,7 @@ var _eval_goals := {0: 0, 1: 0}
var _eval_draws := 0
var _eval_episodes_done := 0
var _episode_ticks := 0
var _eval_team_size := 1
# Curriculum mode state (see _parse_curriculum_args). "self_play" (default)
# is today's only historical behaviour: both ships are live trainees sharing
@@ -176,11 +185,12 @@ func _start() -> void:
spawn_ball()
if _eval:
for team in [0, 1]:
var bot := AIShipController.new()
bot.model_path = _eval_models[team]
bot.allow_vertical = _eval_allow_vertical[team]
bot.allow_pitch_roll = _eval_allow_pitch_roll[team]
spawn_ship(team, 0, bot)
for spawn_index in _eval_team_size:
var bot := AIShipController.new()
bot.model_path = _eval_models[team]
bot.allow_vertical = _eval_allow_vertical[team]
bot.allow_pitch_roll = _eval_allow_pitch_roll[team]
spawn_ship(team, spawn_index, bot)
return
var team0_ships: Array[Ship] = []
@@ -248,6 +258,7 @@ func _parse_eval_args() -> void:
_eval_models[0] = args["eval_model_a"]
_eval_models[1] = args["eval_model_b"]
_eval_episodes = int(args.get("eval_episodes", str(_eval_episodes)))
_eval_team_size = clampi(int(args.get("eval_team_size", str(_eval_team_size))), 1, 2)
_eval_allow_vertical[0] = _typed_like(args.get("eval_allow_vertical_a", "true"), true)
_eval_allow_vertical[1] = _typed_like(args.get("eval_allow_vertical_b", "true"), true)
_eval_allow_pitch_roll[0] = _typed_like(args.get("eval_allow_pitch_roll_a", "true"), true)
@@ -261,11 +272,12 @@ const TRAINING_MODE_OVERRIDES := [
"goal_reward", "draw_penalty", "kickoff_state_chance",
"ball_near_goal_chance", "attack_goal_bias", "air_drill_chance",
"air_intercept_chance", "ground_start_chance", "team_size",
"wall_play_chance", "rebound_chance",
]
# ShipAIController @export names a curriculum run may override, read as
# --ai_<name>=<value> to avoid colliding with the names above.
const SHIP_AI_OVERRIDES := [
"ball_touch_reward", "ball_touch_cooldown_ticks", "ball_touch_direction_floor",
"ball_touch_reward", "ball_touch_cooldown_ticks", "ball_touch_direction_floor", "team_touch_credit_weight",
"velocity_to_ball_weight", "ball_velocity_to_goal_weight", "ball_distance_penalty",
"forward_velocity_to_ball_weight", "air_approach_weight", "air_touch_bonus_weight", "wall_contact_penalty", "tilt_penalty",
"ground_tilt_penalty", "non_forward_penalty", "grounded_upright_reward",
@@ -289,8 +301,8 @@ func _parse_curriculum_args() -> void:
if args.has(name):
set(name, _typed_like(args[name], get(name)))
var start_probability := kickoff_state_chance + ball_near_goal_chance \
+ air_drill_chance + air_intercept_chance
start_probability += ground_start_chance
+ air_drill_chance + air_intercept_chance + ground_start_chance \
+ wall_play_chance + rebound_chance
if start_probability > 1.0:
push_error("TrainingMode: episode-start probabilities sum to %.3f (> 1.0)" % start_probability)
@@ -323,6 +335,7 @@ func _ai_default(name: String) -> Variant:
"ball_touch_reward": return 0.4
"ball_touch_cooldown_ticks": return 60
"ball_touch_direction_floor": return 0.3
"team_touch_credit_weight": return 0.0
"velocity_to_ball_weight": return 0.02
"forward_velocity_to_ball_weight": return 0.0
"air_approach_weight": return 0.0
@@ -451,6 +464,12 @@ func _reset_episode() -> void:
elif roll < kickoff_state_chance + ball_near_goal_chance + air_drill_chance \
+ air_intercept_chance + ground_start_chance:
_place_ground_start()
elif roll < kickoff_state_chance + ball_near_goal_chance + air_drill_chance \
+ air_intercept_chance + ground_start_chance + wall_play_chance:
_place_wall_state(false)
elif roll < kickoff_state_chance + ball_near_goal_chance + air_drill_chance \
+ air_intercept_chance + ground_start_chance + wall_play_chance + rebound_chance:
_place_wall_state(true)
else:
_place_ships_random()
_place_ball_random()
@@ -560,6 +579,42 @@ func _place_ground_start() -> void:
)
# Wall-play/rebound states (see wall_play_chance/rebound_chance). The ball is
# placed against a side wall, never in a corner or goal sensor. A wall-play
# state starts after the bounce and sends the ball inward; a rebound state
# starts before contact and sends it outward so the physics engine supplies
# the reflected trajectory. Ships use the ordinary randomized placement, so
# the policy has to read the wall/rebound context instead of memorising a
# fixed attacker spawn.
func _place_wall_state(rebound: bool) -> void:
_place_ships_random()
var side := -1.0 if randf() < 0.5 else 1.0
var clearance := REBOUND_BALL_CLEARANCE if rebound else WALL_PLAY_BALL_CLEARANCE
var ball_position := Vector3(
side * (ArenaBoundary.INNER_HALF_X - clearance),
randf_range(1.0, minf(FIELD_MAX_Y, 7.0)),
randf_range(-FIELD_HALF_Z, FIELD_HALF_Z)
)
var velocity := wall_state_velocity(
rebound, side, randf_range(WALL_PLAY_SPEED.x, WALL_PLAY_SPEED.y),
randf_range(-0.15, 0.15), randf_range(-0.15, 0.15)
)
_place_body(ball, Transform3D(Basis.IDENTITY, ball_position), velocity, Vector3.ZERO)
# Pure geometry seam for adversarial tests. `side` identifies the selected
# wall (+1 or -1); a wall-play vector points into the field and a rebound
# vector points into that wall. Normalize the perturbed normal before applying
# speed so random tangential components cannot accidentally change the speed
# distribution between the two state types.
static func wall_state_velocity(rebound: bool, side: float, speed: float, vertical: float, lateral: float) -> Vector3:
if speed < 0.0:
return Vector3.ZERO
var wall_side := -1.0 if side < 0.0 else 1.0
var toward_field := Vector3(-wall_side, vertical, lateral).normalized()
return (-toward_field if rebound else toward_field) * speed
# Air-intercept drill geometry. These six ranges are not free tuning knobs —
# together they decide whether the drill is solvable at all, and the original
# values made it arithmetically impossible (see the Round 9 note in
+4 -4
View File
@@ -24,7 +24,7 @@ extends Node
# independently of stretch mode, since it scales the 3D viewport's own internal
# resolution before this blit rather than the window itself. Task 0.15b also
# found an unexplained ~6% non-uniform width scaling on this project's one
# tested (Mac/Retina) machine — see multiplayer-todo.md §5.5.1 — which needs
# tested (Mac/Retina) machine — see MULTIPLAYER_SPEC.md §5.5.1 — which needs
# understanding before stretch mode is touched, not blindly carrying into a
# resolution-dependent change.
#
@@ -49,7 +49,7 @@ const SETTINGS_PATH := "user://settings.cfg"
# preset -> bundle applied to the individual fields below. CUSTOM has no
# bundle: selecting it just stops future preset changes from overwriting
# whatever the individual fields currently hold. Task 0.15b's measured
# per-effect costs (multiplayer-todo.md §5.5.1) were too noisy to rank these
# per-effect costs (MULTIPLAYER_SPEC.md §5.5.1) were too noisy to rank these
# against each other, so each rung is "meaningfully fewer full-screen passes
# than the one above it" rather than a precisely tuned ladder.
const PRESET_BUNDLES := {
@@ -78,7 +78,7 @@ var shadows_enabled: bool = true
var glow_enabled: bool = true
# FXAA alone, not MSAA_FXAA: 4x MSAA *and* FXAA stacked is redundant blur for
# most scenes and costs more than either alone (see multiplayer-todo.md 0.19).
# most scenes and costs more than either alone (see multiplayer-next.md 0.19).
var aa_mode: AAMode = AAMode.FXAA
var glow_scale: float = 1.0
var brightness: float = 1.0
@@ -252,7 +252,7 @@ func apply_fps_cap() -> void:
# Called once by each arena's _ready() (and again on settings_changed, so an
# already-loaded arena updates live) to fold the user's glow/brightness
# preference into that arena's own baked Environment tuning, and to gate the
# preset-controlled full-screen passes (§5.5 of multiplayer-todo.md).
# preset-controlled full-screen passes (§5.5 of MULTIPLAYER_SPEC.md).
func apply_to_environment(env: Environment) -> void:
if env == null:
return
+130
View File
@@ -0,0 +1,130 @@
extends SceneTree
# Headless smoke for the Agones SDK bridge. Run by
# scripts/verify_multiplayer_local.sh:
# godot --headless --path Game --script res://tests/agones_sdk_smoke.gd
#
# Phase 1 drives each REST call directly. Phase 2 covers what phase 1 cannot:
# that start_health() produces a *repeating* ping. That is the property Agones
# actually enforces -- one ping proves nothing, because the Fleet recycles any
# GameServer that stops pinging for periodSeconds * failureThreshold -- and its
# absence is what silently recycled every allocated server.
const ServerControlScript = preload("res://scripts/server_control.gd")
const AgonesSDKScript = preload("res://scripts/agones_sdk.gd")
const PORT := 18081
const HEALTH_PORT := 18082
# start_health() pings every 2s, so three seconds must contain at least two.
const HEALTH_OBSERVATION_SECONDS := 3.0
const MINIMUM_EXPECTED_PINGS := 2
# Counting stand-in for the Agones sidecar. ServerControl answers /health but
# cannot report how often it was called, and asserting repetition is the whole
# point here, so this counts rather than changing production code for a test.
class CountingSidecar extends Node:
var health_pings := 0
var _listener := TCPServer.new()
var _peers: Array = []
func start(port: int) -> Error:
return _listener.listen(port, "127.0.0.1")
func stop() -> void:
_listener.stop()
for peer in _peers:
if is_instance_valid(peer):
peer.disconnect_from_host()
_peers.clear()
func _process(_delta: float) -> void:
while _listener.is_connection_available():
_peers.append(_listener.take_connection())
for i in range(_peers.size() - 1, -1, -1):
var peer: StreamPeerTCP = _peers[i]
if peer.get_status() != StreamPeerTCP.STATUS_CONNECTED:
_peers.remove_at(i)
continue
var available := peer.get_available_bytes()
if available <= 0:
continue
var request := peer.get_utf8_string(available)
if "\r\n\r\n" not in request:
continue
if request.begins_with("POST /health"):
health_pings += 1
var body := "{}"
peer.put_data(("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s" % [body.length(), body]).to_utf8_buffer())
peer.disconnect_from_host()
_peers.remove_at(i)
func _init() -> void:
if not await _direct_calls_smoke():
quit(1)
return
if not await _repeating_health_smoke():
quit(1)
return
print("Agones SDK smoke passed")
quit(0)
func _direct_calls_smoke() -> bool:
var fake_sidecar = ServerControlScript.new()
root.add_child(fake_sidecar)
if fake_sidecar.start(PORT) != OK:
printerr("fake sidecar failed to bind")
return false
fake_sidecar.set_process_ready(true)
var sdk = AgonesSDKScript.new()
root.add_child(sdk)
if not sdk.configure_for_testing("http://127.0.0.1:%d" % PORT):
printerr("SDK test configuration failed")
return false
await process_frame
var health_status := await sdk.health()
var ready_status := await sdk.mark_ready()
var annotation_status := await sdk.set_annotation("match", "result")
var shutdown_status := await sdk.shutdown()
fake_sidecar.stop()
fake_sidecar.queue_free()
sdk.queue_free()
if health_status != 200 or ready_status != 200 or annotation_status < 400 or shutdown_status < 400:
printerr("Agones SDK smoke statuses health=%d ready=%d annotation=%d shutdown=%d" % [health_status, ready_status, annotation_status, shutdown_status])
return false
return true
func _repeating_health_smoke() -> bool:
var sidecar := CountingSidecar.new()
root.add_child(sidecar)
if sidecar.start(HEALTH_PORT) != OK:
printerr("counting sidecar failed to bind")
return false
# Configure before parenting and let the node arm its own timer on _ready(),
# which is exactly how server_boot.gd wires it in an allocated pod.
var sdk = AgonesSDKScript.new()
if not sdk.configure_for_testing("http://127.0.0.1:%d" % HEALTH_PORT):
printerr("health SDK configuration failed")
return false
if sdk.start_health():
printerr("start_health() reported success while the node was outside the tree")
return false
root.add_child(sdk)
await process_frame
if not sdk.health_is_running():
printerr("health loop did not arm once the node entered the tree")
return false
await create_timer(HEALTH_OBSERVATION_SECONDS).timeout
var observed := sidecar.health_pings
sdk.stop_health()
sidecar.stop()
sdk.queue_free()
sidecar.queue_free()
if observed < MINIMUM_EXPECTED_PINGS:
printerr("Agones health pings in %.1fs = %d, want at least %d; the health loop is not repeating" % [HEALTH_OBSERVATION_SECONDS, observed, MINIMUM_EXPECTED_PINGS])
return false
return true
+56
View File
@@ -0,0 +1,56 @@
extends "res://tests/test_case.gd"
const AgonesSDKScript = preload("res://scripts/agones_sdk.gd")
func test_sdk_requires_loopback_sidecar_url() -> void:
var sdk = AgonesSDKScript.new()
assert_true(not sdk.configure_for_testing("https://agones.example"), "remote sidecar URL is rejected")
assert_true(not sdk.is_available(), "rejected sidecar is unavailable")
assert_true(sdk.configure_for_testing("http://127.0.0.1:9358"), "loopback sidecar URL is accepted")
assert_true(sdk.is_available(), "accepted sidecar is available")
sdk.queue_free()
func test_annotation_validation_rejects_header_injection_and_oversized_values() -> void:
assert_true(AgonesSDKScript.annotation_is_valid("match", "result"), "ordinary annotation is accepted")
assert_true(not AgonesSDKScript.annotation_is_valid("bad\nkey", "value"), "annotation key newline is rejected")
assert_true(not AgonesSDKScript.annotation_is_valid("key", "bad\rvalue"), "annotation value newline is rejected")
assert_true(not AgonesSDKScript.annotation_is_valid("key", "x".repeat(4097)), "oversized annotation is rejected")
# Regression: every allocated GameServer reached Ready and was then recycled by
# Agones ~20s later, because start_health() armed a Timer on a node that was
# never parented. A Timer only ticks inside the SceneTree, so the process
# reported healthy while sending no pings at all, and nothing said so.
#
# These are deliberately synchronous: test_runner.gd calls test methods without
# awaiting, so anything needing a live tree or an HTTP round trip belongs in
# tests/agones_sdk_smoke.gd instead. What is asserted here is the contract that
# makes the silent case impossible.
func test_start_health_refuses_when_not_configured() -> void:
var sdk = AgonesSDKScript.new()
assert_true(not sdk.start_health(), "health cannot start before a sidecar URL is known")
assert_true(not sdk.health_is_running(), "no timer is armed without configuration")
sdk.queue_free()
func test_start_health_reports_failure_when_outside_the_tree() -> void:
# The exact shape of the production bug: configured, so is_available() is
# true and the node looks ready to work, but unparented.
var sdk = AgonesSDKScript.new()
assert_true(sdk.configure_for_testing("http://127.0.0.1:9358"), "fixture configures")
assert_true(sdk.is_available(), "an unparented node still reports available")
assert_true(not sdk.start_health(), "start_health() must not claim success outside the tree")
assert_true(not sdk.health_is_running(), "no health loop is running outside the tree")
sdk.queue_free()
func test_health_is_not_running_until_a_timer_exists() -> void:
# health_is_running() is what a caller should trust, rather than
# is_available(), which only says a URL was parsed.
var sdk = AgonesSDKScript.new()
assert_true(not sdk.health_is_running(), "a fresh SDK is not pinging")
sdk.configure_for_testing("http://127.0.0.1:9358")
assert_true(not sdk.health_is_running(), "configuration alone does not start pinging")
sdk.queue_free()
@@ -0,0 +1 @@
uid://cklv8t2htg2gf
+36
View File
@@ -0,0 +1,36 @@
extends "res://tests/test_case.gd"
const AssignmentState = preload("res://scripts/assignment_state.gd")
func test_assignment_projection_accepts_verified_enet_manifest() -> void:
var assignment := AssignmentState.new()
assert_true(assignment.apply({"match_id": "match_1234567890", "server_id": "server_123456789", "player_id": "player_123456789", "slot": 2, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "enet", "endpoint": "127.0.0.1:30001", "join_authorisation": "signed"}, "player_123456789"), "valid assignment applies")
assert_true(assignment.available, "assignment becomes available only after validation")
assert_eq(assignment.transport, "enet", "transport is explicit")
assert_eq(assignment.slot, 2, "slot is preserved")
assert_eq(assignment.endpoint, "127.0.0.1:30001", "endpoint is preserved")
func test_assignment_projection_rejects_wrong_shape_or_unsafe_transport() -> void:
var assignment := AssignmentState.new()
var valid := {"match_id": "match_1234567890", "server_id": "server_123456789", "player_id": "player_123456789", "slot": 0, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "enet", "endpoint": "127.0.0.1:30001", "join_authorisation": "signed"}
var out_of_range := valid.duplicate()
out_of_range["slot"] = 6
assert_true(not assignment.apply(out_of_range), "out-of-range slot is rejected")
var short_id := valid.duplicate()
short_id["match_id"] = "match-1"
assert_true(not assignment.apply(short_id), "short opaque assignment id is rejected")
var fractional_slot := valid.duplicate()
fractional_slot["slot"] = 1.5
assert_true(not assignment.apply(fractional_slot), "fractional slot is rejected")
var fractional_protocol := valid.duplicate()
fractional_protocol["protocol_version"] = 1.5
assert_true(not assignment.apply(fractional_protocol), "fractional protocol version is rejected")
assert_true(not assignment.available, "invalid assignment is not exposed")
assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 0, "expires_at": "future", "protocol_version": 1, "transport": "udp", "join_authorisation": "signed"}), "unknown transport is rejected")
assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 0, "expires_at": "future", "protocol_version": 1, "transport": "steam_sdr", "join_authorisation": ""}), "empty authorisation is rejected")
assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-2", "slot": 0, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "steam_sdr", "join_authorisation": "signed"}, "player-1"), "wrong player assignment is rejected")
assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 0, "expires_at": "2000-08-31T12:00:00Z", "protocol_version": 1, "transport": "steam_sdr", "join_authorisation": "signed"}, "player-1"), "expired assignment is rejected")
assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 0, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "steam_sdr", "endpoint": "127.0.0.1", "join_authorisation": "signed"}, "player-1"), "unsafe endpoint is rejected")
assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 0, "expires_at": "not-a-timestamp", "protocol_version": 1, "transport": "enet", "endpoint": "127.0.0.1:30001", "join_authorisation": "signed"}, "player-1"), "malformed assignment expiry is rejected")
+46
View File
@@ -0,0 +1,46 @@
extends "res://tests/test_case.gd"
const AudioManager = preload("res://scripts/audio_manager.gd")
func test_audio_intensity_fails_closed_and_clamps() -> void:
assert_eq(AudioManager.clamp_intensity(-1.0), 0.0, "negative impact is silent")
assert_eq(AudioManager.clamp_intensity(INF), 0.0, "infinite impact is silent")
assert_eq(AudioManager.clamp_intensity(0.5), 0.5, "normal impact is retained")
assert_eq(AudioManager.clamp_intensity(4.0), 1.0, "oversized impact is capped")
func test_countdown_frequency_has_bounded_monotonic_mapping() -> void:
assert_eq(AudioManager.countdown_frequency(0), 495.0, "zero uses the first safe tone")
assert_eq(AudioManager.countdown_frequency(3), 605.0, "countdown tone is deterministic")
assert_eq(AudioManager.countdown_frequency(99), 935.0, "large countdown values are capped")
assert_true(AudioManager.countdown_frequency(2) < AudioManager.countdown_frequency(3), "countdown tones rise predictably")
func test_button_binding_is_idempotent() -> void:
var manager := AudioManager.new()
var button := Button.new()
manager.bind_button(button)
manager.bind_button(button)
assert_eq(button.pressed.get_connections().size(), 1, "UI click hook is not duplicated")
button.free()
manager.free()
func test_engine_mix_is_bounded_and_turbo_is_audible() -> void:
assert_eq(AudioManager.engine_pitch(-1.0, false), 0.75, "negative thrust uses the idle pitch")
assert_true(AudioManager.engine_pitch(1.0, true) > AudioManager.engine_pitch(1.0, false), "turbo raises engine pitch")
assert_true(AudioManager.engine_volume(1.0, true) > AudioManager.engine_volume(1.0, false), "turbo raises engine volume")
assert_true(AudioManager.engine_volume(100.0, true) <= 0.1, "engine volume remains bounded")
func test_turbo_state_is_only_a_rising_edge_for_the_engine_cue() -> void:
assert_true(AudioManager.should_play_turbo_cue(false, true, 0.8), "turbo engagement emits a cue")
assert_true(not AudioManager.should_play_turbo_cue(true, true, 0.8), "held turbo does not retrigger")
assert_true(not AudioManager.should_play_turbo_cue(false, true, 0.0), "turbo at idle thrust is silent")
assert_true(not AudioManager.should_play_turbo_cue(false, false, 0.8), "ordinary thrust emits no turbo cue")
func test_wall_scrape_intensity_reuses_the_same_safe_bounds() -> void:
assert_eq(AudioManager.clamp_intensity(-2.0), 0.0, "reverse wall intensity is silent")
assert_eq(AudioManager.clamp_intensity(2.0), 1.0, "wall intensity is capped")
@@ -0,0 +1,37 @@
extends "res://tests/test_case.gd"
const LeaseClient = preload("res://scripts/connection_lease_client.gd")
func test_connection_lease_response_classification_is_fail_closed() -> void:
var success_body := JSON.stringify({"generation": 2}).to_utf8_buffer()
assert_eq(LeaseClient.classify_response("connect", 1, HTTPRequest.RESULT_SUCCESS, 200, success_body), {"status": "claimed", "generation": 2}, "exact next generation is accepted")
assert_eq(LeaseClient.classify_response("connect", 1, HTTPRequest.RESULT_SUCCESS, 200, JSON.stringify({"generation": 3}).to_utf8_buffer())["status"], "rejected", "skipped generation is rejected")
assert_eq(LeaseClient.classify_response("connect", 0, HTTPRequest.RESULT_SUCCESS, 200, JSON.stringify({"generation": 3}).to_utf8_buffer(), true), {"status": "claimed", "generation": 3}, "a fresh process accepts a durable recovery generation")
assert_eq(LeaseClient.classify_response("connect", 0, HTTPRequest.RESULT_SUCCESS, 200, JSON.stringify({"generation": 3}).to_utf8_buffer())["status"], "rejected", "queued outage reconciliation cannot skip generations")
assert_eq(LeaseClient.classify_response("connect", 1, HTTPRequest.RESULT_SUCCESS, 200, JSON.stringify({"generation": "2"}).to_utf8_buffer())["status"], "rejected", "string generation is not coerced")
assert_eq(LeaseClient.classify_response("connect", 1, HTTPRequest.RESULT_CANT_CONNECT, 0, PackedByteArray())["status"], "unavailable", "transport outage permits bounded local fallback")
assert_eq(LeaseClient.classify_response("connect", 1, HTTPRequest.RESULT_SUCCESS, 503, PackedByteArray())["status"], "unavailable", "service outage permits bounded local fallback")
assert_eq(LeaseClient.classify_response("connect", 1, HTTPRequest.RESULT_SUCCESS, 409, PackedByteArray())["status"], "rejected", "durable conflict is terminal")
assert_eq(LeaseClient.classify_response("disconnect", 2, HTTPRequest.RESULT_SUCCESS, 204, PackedByteArray()), {"status": "claimed", "generation": 2}, "disconnect acknowledgement preserves exact generation")
func test_connection_lease_configuration_and_keys_are_bound() -> void:
assert_true(LeaseClient.valid_configuration("https://control.invalid", "workload-token", "match-1234567890", "server-123456789"), "valid workload configuration is accepted")
assert_true(not LeaseClient.valid_configuration("https://control.invalid?token=leak", "workload-token", "match-1234567890", "server-123456789"), "query-bearing endpoint is rejected")
assert_true(not LeaseClient.valid_configuration("https://control@evil.invalid", "workload-token", "match-1234567890", "server-123456789"), "userinfo-bearing endpoint is rejected")
assert_true(not LeaseClient.valid_configuration("https://control.invalid", "bad\ntoken", "match-1234567890", "server-123456789"), "header injection is rejected")
var initial := LeaseClient.event_key("match-123456789", "player-12345678", "connect", 0)
assert_true(initial != LeaseClient.event_key("match-123456789", "player-12345678", "disconnect", 1), "operation and generation bind the key")
assert_true(initial != LeaseClient.event_key("match-000000000", "player-12345678", "connect", 0), "match identity binds the key")
func test_match_net_rejects_malformed_or_skipped_backend_generations() -> void:
assert_eq(MatchNet.lease_claim_generation({"status": "claimed", "generation": 2}, 1), 2, "exact backend generation is accepted")
assert_eq(MatchNet.lease_claim_generation({"status": "unavailable", "generation": 2}, 1), 2, "local fallback retains the exact next generation")
assert_eq(MatchNet.lease_claim_generation({"status": "unavailable", "generation": 1}, 0), -1, "a fresh process cannot guess a generation during an outage")
assert_eq(MatchNet.lease_claim_generation({"status": "rejected", "generation": 2}, 1), -1, "backend conflict rejects admission")
assert_eq(MatchNet.lease_claim_generation({"status": "claimed", "generation": 3}, 1), -1, "generation skips are fenced")
assert_eq(MatchNet.lease_claim_generation({"status": "claimed", "generation": 3}, 0), 3, "fresh process adopts durable recovery generation")
assert_eq(MatchNet.lease_claim_generation({"status": "unavailable", "generation": 3}, 0), -1, "offline fallback cannot invent a skipped generation")
assert_eq(MatchNet.lease_claim_generation({"status": "claimed", "generation": "2"}, 1), -1, "string generation is fenced")
@@ -0,0 +1,582 @@
extends "res://tests/test_case.gd"
const ControlPlaneClient = preload("res://scripts/control_plane_client.gd")
const RankedProfileState = preload("res://scripts/ranked_profile_state.gd")
func test_base_url_validation_rejects_ambiguous_or_insecure_values() -> void:
assert_true(ControlPlaneClient.is_valid_base_url("http://127.0.0.1:8080"), "local HTTP endpoint is valid")
assert_true(ControlPlaneClient.is_valid_base_url("https://match.example"), "HTTPS endpoint is valid")
assert_true(not ControlPlaneClient.is_valid_base_url("match.example"), "scheme is required")
assert_true(not ControlPlaneClient.is_valid_base_url("http://match.example/"), "trailing slash is normalized before validation")
assert_true(not ControlPlaneClient.is_valid_base_url("http://match example"), "whitespace is rejected")
assert_true(not ControlPlaneClient.is_valid_base_url("https://user:pass@match.example"), "userinfo is rejected")
assert_true(not ControlPlaneClient.is_valid_base_url("https://match.example?token=secret"), "query strings are rejected")
var client := ControlPlaneClient.new()
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "safe access token configures")
assert_true(not client.configure("https://match.example", "token\nforged-header"), "header injection is rejected")
assert_true(ControlPlaneClient.is_valid_web_api_ticket("ticket-value"), "ordinary Steam Web API ticket is accepted")
assert_true(not ControlPlaneClient.is_valid_web_api_ticket("ticket\nforged"), "ticket header characters are rejected")
assert_true(not ControlPlaneClient.is_valid_web_api_ticket(""), "empty Steam ticket is rejected")
assert_true(ControlPlaneClient.is_valid_access_token("session-id:opaque-token"), "opaque session format is accepted")
assert_true(not ControlPlaneClient.is_valid_access_token(":opaque-token"), "missing session identifier is rejected")
assert_true(not ControlPlaneClient.is_valid_access_token("session-id:token\nforged"), "session header injection is rejected")
assert_eq(ControlPlaneClient.websocket_url("https://match.example"), "wss://match.example", "TLS control plane uses secure WebSocket")
assert_eq(ControlPlaneClient.websocket_url("http://127.0.0.1:8080"), "ws://127.0.0.1:8080", "local control plane uses WebSocket")
assert_eq(ControlPlaneClient.websocket_url("match.example"), "", "unscoped URL cannot become a WebSocket URL")
var unconfigured := ControlPlaneClient.new()
assert_eq(unconfigured.connect_event_stream(), ERR_UNAUTHORIZED, "event stream requires an authenticated session")
func test_ticket_normalization_preserves_payload_and_derives_expiry() -> void:
var payload := {"ticket_id": "ticket-1", "state": "QUEUED", "expires_at": "2026-08-31T12:00:00Z"}
var normalized := ControlPlaneClient.normalize_ticket(payload)
assert_eq(normalized["ticket_id"], "ticket-1", "normalization preserves ticket identity")
assert_true(normalized.has("expires_at_unix"), "RFC3339 expiry is available to the projection")
assert_true(int(normalized["expires_at_unix"]) > 0, "expiry is converted to a positive epoch")
assert_true(not payload.has("expires_at_unix"), "normalization does not mutate the HTTP payload")
func test_ticket_normalization_derives_authoritative_enqueue_time() -> void:
var normalized := ControlPlaneClient.normalize_ticket({"enqueued_at": "1970-01-01T00:16:40Z"})
assert_eq(int(normalized["enqueued_at_unix"]), 1000, "RFC3339 enqueue time is converted to epoch")
assert_eq(ControlPlaneClient.normalize_ticket({"enqueued_at": "not-a-timestamp"})["enqueued_at_unix"], -1, "malformed enqueue time remains visibly invalid")
assert_eq(ControlPlaneClient.normalize_ticket({"expires_at": 123})["expires_at_unix"], -1, "non-string expiry remains visibly invalid")
func test_session_expiry_is_checked_at_the_boundary_and_fails_closed() -> void:
assert_true(not ControlPlaneClient.is_session_expired("", 1000), "legacy sessions without an expiry remain compatible")
assert_true(not ControlPlaneClient.is_session_expired("1970-01-01T00:16:40Z", 999), "session remains valid before expiry")
assert_true(ControlPlaneClient.is_session_expired("1970-01-01T00:16:40Z", 1000), "session expires at the exact boundary")
assert_true(ControlPlaneClient.is_session_expired("not-a-timestamp", 1000), "malformed non-empty expiry fails closed")
assert_true(ControlPlaneClient.is_valid_rfc3339_timestamp("2026-08-31T12:00:00.123Z"), "fractional RFC3339 timestamp is accepted")
assert_true(not ControlPlaneClient.is_valid_rfc3339_timestamp("2026-02-30T12:00:00Z"), "impossible calendar date is rejected")
assert_true(not ControlPlaneClient.is_valid_rfc3339_timestamp("2026-13-01T12:00:00Z"), "impossible month is rejected")
assert_true(not ControlPlaneClient.is_valid_rfc3339_timestamp("2026-08-31 12:00:00Z"), "space-separated timestamp is rejected")
assert_true(not ControlPlaneClient.is_valid_rfc3339_timestamp("2026-08-31T12:00:00"), "timezone-less timestamp is rejected")
var valid_session := {"player_id": "player_1234567890", "access_token": "session-id:opaque-token", "expires_at": "2099-08-31T12:00:00Z"}
assert_true(ControlPlaneClient.is_valid_session_response(valid_session), "future session response is accepted")
var missing_expiry := valid_session.duplicate()
missing_expiry.erase("expires_at")
assert_true(not ControlPlaneClient.is_valid_session_response(missing_expiry), "session without expiry is rejected")
var malformed_expiry := valid_session.duplicate()
malformed_expiry["expires_at"] = "tomorrow"
assert_true(not ControlPlaneClient.is_valid_session_response(malformed_expiry), "malformed session expiry is rejected")
var expired_session := valid_session.duplicate()
expired_session["expires_at"] = "2000-01-01T00:00:00Z"
assert_true(not ControlPlaneClient.is_valid_session_response(expired_session), "expired session response is rejected")
func test_reconfiguration_discards_the_previous_session_expiry() -> void:
var client := ControlPlaneClient.new()
client.session_expires_at = "1970-01-01T00:00:01Z"
assert_true(client.configure("https://match.example", "new-session:opaque-token"), "new session configures successfully")
assert_eq(client.session_expires_at, "", "new credentials do not inherit the old expiry")
func test_websocket_event_validation_requires_contract_specific_fields() -> void:
var envelope := {"event": "state_changed", "revision": 1, "resource_id": "ticket_123456789", "occurred_at": "2026-08-31T12:00:00Z", "state": "QUEUED"}
assert_true(ControlPlaneClient._valid_websocket_event(envelope), "valid state event is accepted")
var accepted := envelope.duplicate()
accepted["state"] = "ACCEPTED"
assert_true(ControlPlaneClient._valid_websocket_event(accepted), "authoritative accepted queue event is accepted")
for phase in ["ASSIGNED", "RESULT_PENDING", "COMPLETED"]:
var lifecycle := envelope.duplicate()
lifecycle["state"] = phase
assert_true(ControlPlaneClient._valid_websocket_event(lifecycle), "post-match queue event is accepted: " + phase)
var bad_state := envelope.duplicate()
bad_state["state"] = "SECRET"
assert_true(not ControlPlaneClient._valid_websocket_event(bad_state), "unknown state event is rejected")
var assignment := {"event": "assignment_changed", "revision": 0, "resource_id": "match_1234567890", "occurred_at": "2026-08-31T12:00:00Z", "match_id": "match_1234567890", "server_id": "server_123456789"}
assert_true(ControlPlaneClient._valid_websocket_event(assignment), "complete assignment event is accepted")
var short_assignment_id := assignment.duplicate()
short_assignment_id["server_id"] = "server-1"
assert_true(not ControlPlaneClient._valid_websocket_event(short_assignment_id), "short assignment server id is rejected")
assignment.erase("server_id")
assert_true(not ControlPlaneClient._valid_websocket_event(assignment), "incomplete assignment event is rejected")
var fractional := envelope.duplicate()
fractional["revision"] = 1.5
assert_true(not ControlPlaneClient._valid_websocket_event(fractional), "fractional event revision is rejected")
var negative := envelope.duplicate()
negative["revision"] = -1
assert_true(not ControlPlaneClient._valid_websocket_event(negative), "negative event revision is rejected")
var malformed_time := envelope.duplicate()
malformed_time["occurred_at"] = "yesterday"
assert_true(not ControlPlaneClient._valid_websocket_event(malformed_time), "malformed event timestamp is rejected")
var short_resource := envelope.duplicate()
short_resource["resource_id"] = "short"
assert_true(not ControlPlaneClient._valid_websocket_event(short_resource), "short resource identifier is rejected")
var unsafe_resource := envelope.duplicate()
unsafe_resource["resource_id"] = "ticket_123456789/secret"
assert_true(not ControlPlaneClient._valid_websocket_event(unsafe_resource), "resource identifier with separators is rejected")
var match_state := {"event": "state_changed", "revision": 4, "resource_id": "match_1234567890", "occurred_at": "2026-08-31T12:00:00Z", "state": "ASSIGNMENT_READY", "match_id": "match_1234567890"}
assert_true(ControlPlaneClient._valid_websocket_event(match_state), "match-scoped lifecycle event is accepted")
match_state["match_id"] = "different_match_123"
assert_true(not ControlPlaneClient._valid_websocket_event(match_state), "match lifecycle identity must equal its resource identity")
func test_match_assignment_ready_event_recovers_ticket_and_schedules_assignment_fetch() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.state.begin_queue("ticket_assignment_1", "casual"), "queue setup succeeds")
client._operation = "queue_heartbeat"
var event := {"event": "state_changed", "revision": 4, "resource_id": "match_assignment_1", "occurred_at": "2026-08-31T12:00:00Z", "state": "ASSIGNMENT_READY", "match_id": "match_assignment_1"}
client._handle_websocket_packet(JSON.stringify(event).to_utf8_buffer())
assert_eq(client._pending_resync_resource_id, "ticket_assignment_1", "match event requests authoritative ticket recovery")
assert_eq(client._pending_assignment_match_id, "match_assignment_1", "assignment lookup no longer depends on a prior assignment GET")
assert_eq(client.state.ticket_id, "ticket_assignment_1", "match resource is never projected as a ticket identity")
client.free()
func test_recovered_assignment_ready_ticket_schedules_fetch_after_missed_revisions() -> void:
var client := ControlPlaneClient.new()
client._ready()
client.player_id = "player_1234567890"
client.state.begin_queue("ticket_assignment_1", "casual")
assert_true(client.state.apply_ticket_update({"ticket_id": "ticket_assignment_1", "revision": 1, "state": "PROPOSED", "playlist": "casual"}), "proposal setup applies")
client._operation = "queue_recover"
var recovered := {"ticket_id": "ticket_assignment_1", "player_id": "player_1234567890", "match_id": "match_assignment_1", "playlist": "casual", "state": "ASSIGNMENT_READY", "revision": 5, "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2026-08-31T12:01:00Z"}
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(recovered).to_utf8_buffer())
assert_eq(client.state.phase, MatchmakingState.ASSIGNMENT_READY, "REST recovery applies a forward authoritative snapshot")
assert_eq(client._pending_assignment_match_id, "match_assignment_1", "recovered snapshot supplies the assignment lookup key")
client.free()
func test_websocket_reconnect_defers_recovery_while_http_mutation_is_in_flight() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.state.begin_queue("ticket-reconnect", "casual"), "queue setup succeeds")
client._operation = "queue_heartbeat"
client._set_websocket_status("CONNECTED")
assert_eq(client._pending_resync_resource_id, "ticket-reconnect", "reconnect recovery is retained until the mutation completes")
client.free()
func test_transient_rest_recovery_failure_does_not_end_matchmaking() -> void:
var client := ControlPlaneClient.new()
client._ready()
client.state.begin_queue("ticket_recovery_123", "casual")
client._operation = "queue_recover"
client._on_request_completed(HTTPRequest.RESULT_CANT_CONNECT, 0, PackedStringArray(), PackedByteArray())
assert_eq(client.state.phase, MatchmakingState.QUEUED, "network failure during recovery keeps the active search")
assert_true(client.state.message.contains("retrying"), "recovery failure remains visible and retryable")
client._operation = "proposal_recover"
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), "[]".to_utf8_buffer())
assert_eq(client.state.phase, MatchmakingState.QUEUED, "malformed transient recovery response does not become terminal")
client.free()
func test_resync_of_terminal_proposal_recovers_the_ticket() -> void:
assert_eq(ControlPlaneClient.resync_target("proposal-terminal-resync", "ticket-terminal-resync", "proposal-terminal-resync", false), "ticket-terminal-resync", "terminal proposal resync targets the requeued ticket")
assert_eq(ControlPlaneClient.resync_target("proposal-terminal-resync", "ticket-terminal-resync", "proposal-terminal-resync", true), "proposal-terminal-resync", "open proposal resync retains the proposal target")
func test_retryable_mutation_policy_only_retries_safe_failures() -> void:
assert_true(ControlPlaneClient.is_retryable_mutation_response(0), "transport failure is retryable")
assert_true(ControlPlaneClient.is_retryable_mutation_response(408), "request timeout is retryable")
assert_true(ControlPlaneClient.is_retryable_mutation_response(429), "rate limit is retryable")
assert_true(ControlPlaneClient.is_retryable_mutation_response(503), "server failure is retryable")
assert_true(not ControlPlaneClient.is_retryable_mutation_response(401), "authentication failure is not blindly replayed")
assert_true(not ControlPlaneClient.is_retryable_mutation_response(409), "revision/idempotency conflict is not blindly replayed")
# multiplayer-next.md 8.43 named "duplicate-action recovery beyond proposals"
# and "regional outage retry UI" as remaining. Both mechanisms (can_retry_last_mutation /
# retry_last_mutation, and matchmaking.gd's queue button falling back to them)
# already existed in the client, but had no test coverage proving the
# generic (non-proposal) mutation path actually recovers end to end -- only
# is_retryable_mutation_response's pure classification was covered above.
func test_generic_mutation_retry_recovers_after_a_transient_failure() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
assert_true(client.state.begin_queue("ticket-retry-generic", "casual"), "queue setup succeeds")
# Simulate what _start_request itself would already have recorded before
# a real network call was in flight, the same way the pre-existing
# conflict-handler tests above set _operation directly.
client._operation = "queue_heartbeat"
client._last_mutation = {"operation": "queue_heartbeat", "method": HTTPClient.METHOD_POST, "path": "/v1/queue/ticket-retry-generic/heartbeat", "payload": {"revision": 0}, "key": "heartbeat-retry-key-123456", "expected_revision": 0}
assert_true(not client.can_retry_last_mutation(), "a mutation still in flight is never offered as retryable")
# A regional outage: the transport itself failed rather than returning a
# decoded HTTP status -- exactly the "regional outage retry" case. This
# transition is the actual previously-uncovered boundary: nothing tested
# that a generic (non-proposal) mutation ever becomes retryable at all,
# only is_retryable_mutation_response's pure classification above.
# retry_last_mutation's own dispatch is not exercised here: it reaches
# HTTPRequest.request(), which needs the node inside a live SceneTree,
# and test_runner.tscn runs every test method from within its own
# _ready() while the tree is still being built, so that is out of reach
# for this harness -- the "not offered at all" boundary below covers the
# part of retry_last_mutation this environment can exercise safely.
client._on_request_completed(HTTPRequest.RESULT_CANT_CONNECT, 0, PackedStringArray(), PackedByteArray())
assert_true(client.can_retry_last_mutation(), "a transport failure on a non-proposal mutation is offered as retryable")
client.free()
func test_generic_mutation_retry_is_not_offered_for_unsafe_failures() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
assert_true(client.state.begin_queue("ticket-retry-unsafe", "casual"), "queue setup succeeds")
client._operation = "queue_cancel"
client._last_mutation = {"operation": "queue_cancel", "method": HTTPClient.METHOD_POST, "path": "/v1/queue/ticket-retry-unsafe/cancel", "payload": {}, "key": "cancel-retry-key-123456", "expected_revision": 0}
# A 409 is a revision/idempotency conflict, not a transient failure --
# should_recover_queue_after_conflict owns recovering it instead, and a
# blind resend would replay a mutation whose precondition already failed.
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 409, PackedStringArray(), JSON.stringify({"error": "revision conflict"}).to_utf8_buffer())
assert_true(not client.can_retry_last_mutation(), "a conflict response is never offered as a blind retry")
assert_eq(client.retry_last_mutation(), ERR_INVALID_DATA, "retrying when not offered fails closed rather than resending a stale mutation")
client.free()
# connect_to_assignment() already existed, fully validated, with its own
# assignment_connection_started/assignment_connection_failed signals -- but
# nothing anywhere in the client ever called it. A player reaching the
# ASSIGNED phase (server confirms the complete roster) with a fetched, fresh
# assignment would simply sit on "Your match server is ready" forever,
# because the transport was never actually started. This is the wiring fix,
# not just new test coverage for existing behavior.
func test_client_starts_the_transport_once_the_ticket_reaches_assigned() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
client.player_id = "player_1234567890"
assert_true(client.state.begin_queue("ticket-connect-ready", "casual"), "queue setup succeeds")
# The assignment fetch (triggered independently, earlier, by
# ASSIGNMENT_READY) has already completed by the time ASSIGNED arrives --
# the common case.
client._operation = "assignment"
var assignment_payload := {"match_id": "match_connect_1234567890", "server_id": "server_connect_1234567890", "player_id": "player_1234567890", "slot": 0, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "enet", "endpoint": "127.0.0.1:65500", "join_authorisation": "opaque-join-token"}
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(assignment_payload).to_utf8_buffer())
assert_true(client.assignment.available, "assignment fetch applies")
var connect_started := [false]
var connect_failed := [false]
client.assignment_connection_started.connect(func(_a): connect_started[0] = true)
client.assignment_connection_failed.connect(func(_d): connect_failed[0] = true)
client._operation = "queue_recover"
var ticket_payload := {"ticket_id": "ticket-connect-ready", "player_id": "player_1234567890", "playlist": "casual", "revision": 5, "state": "ASSIGNED", "match_id": "match_connect_1234567890", "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2099-08-31T12:00:00Z"}
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(ticket_payload).to_utf8_buffer())
# connect_to_assignment() itself calls state.mark_connecting() as part of a
# successful attempt, so by the time control returns here phase has
# already advanced past ASSIGNED to CONNECTING -- that advancement is
# itself the proof the connect was actually attempted.
assert_eq(client.state.phase, MatchmakingState.CONNECTING, "reaching ASSIGNED with a ready assignment actually started the transport, rather than sitting idle")
assert_true(connect_started[0] or connect_failed[0], "connect_to_assignment's own signal fired")
assert_true(client._pending_connect_match_id.is_empty(), "an attempted connect is not left pending")
# A duplicate/replayed ASSIGNED event for the same match (e.g. an
# at-least-once outbox redelivery) must not fire a second connection
# attempt. Called directly against the guarded function rather than
# through another full _on_request_completed round-trip: phase has
# already moved on to CONNECTING, so both of _connect_when_assigned's own
# guards (phase != ASSIGNED, and the _connect_attempted_match_id match)
# now independently refuse a second attempt for this match.
connect_started[0] = false
connect_failed[0] = false
client._connect_when_assigned("match_connect_1234567890")
assert_true(not connect_started[0] and not connect_failed[0], "a duplicate connect attempt for an already-attempted match is not reattempted")
NetworkManager.shutdown()
client.free()
func test_client_defers_the_connect_until_the_assignment_fetch_completes() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
assert_true(client.state.begin_queue("ticket-connect-deferred", "casual"), "queue setup succeeds")
var connect_started := [false]
var connect_failed := [false]
client.assignment_connection_started.connect(func(_a): connect_started[0] = true)
client.assignment_connection_failed.connect(func(_d): connect_failed[0] = true)
# ASSIGNED arrives before the assignment fetch (triggered earlier by
# ASSIGNMENT_READY) has actually completed -- the ordering the deferred
# path exists for. client.assignment is still the default, unavailable one.
client._operation = "queue_recover"
var ticket_payload := {"ticket_id": "ticket-connect-deferred", "player_id": "player_1234567890", "playlist": "casual", "revision": 5, "state": "ASSIGNED", "match_id": "match_deferred_1234567890", "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2099-08-31T12:00:00Z"}
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(ticket_payload).to_utf8_buffer())
assert_eq(client.state.phase, MatchmakingState.ASSIGNED, "ticket state machine still reaches ASSIGNED")
assert_eq(client._pending_connect_match_id, "match_deferred_1234567890", "the connect attempt is deferred until the assignment is actually available")
assert_true(not connect_started[0] and not connect_failed[0], "no connection attempt is made before the assignment is ready -- nothing to connect to yet")
client.free()
# Covers §8.43's "failed reconnect UX": connect_to_assignment()'s own
# synchronous failures previously only emitted assignment_connection_failed,
# a signal nothing in the client listened to -- state.phase stayed stuck at
# ASSIGNED, the UI kept showing "Your match server is ready" forever, and
# there was no way back to a fresh search.
func test_synchronous_assignment_connection_failure_surfaces_as_a_failed_search() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.state.begin_queue("ticket-connect-unavailable", "casual"), "queue setup succeeds")
# client.assignment is still the default, unavailable one.
var err := client.connect_to_assignment()
assert_eq(err, ERR_UNAUTHORIZED, "connect fails closed when the assignment isn't ready")
assert_eq(client.state.phase, MatchmakingState.FAILED, "the failure is surfaced as a failed search rather than leaving the UI stuck at ASSIGNED")
assert_true(client.state.message.to_lower().contains("unavailable") or client.state.message.to_lower().contains("expired"), "the failure detail is retained: %s" % client.state.message)
client.free()
# The likelier real-world failure than the synchronous one above:
# NetworkManager.join() returns OK immediately (the attempt started), but the
# actual ENet handshake fails asynchronously later -- unreachable server,
# refused connection, ENet's own ~5s connect timeout. This is exactly the gap
# main_menu.gd's own _on_connection_failed exists to cover for the
# direct-join flow; nothing covered it for a matchmaking-driven connect.
func test_async_network_connection_failure_after_assignment_ready_surfaces_as_a_failed_search() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
client.player_id = "player_1234567890"
assert_true(client.state.begin_queue("ticket-connect-asyncfail", "casual"), "queue setup succeeds")
client._operation = "assignment"
var assignment_payload := {"match_id": "match_asyncfail_1234567890", "server_id": "server_asyncfail_1234567890", "player_id": "player_1234567890", "slot": 0, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "enet", "endpoint": "127.0.0.1:65501", "join_authorisation": "opaque-join-token"}
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(assignment_payload).to_utf8_buffer())
client._operation = "queue_recover"
var ticket_payload := {"ticket_id": "ticket-connect-asyncfail", "player_id": "player_1234567890", "playlist": "casual", "revision": 5, "state": "ASSIGNED", "match_id": "match_asyncfail_1234567890", "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2099-08-31T12:00:00Z"}
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(ticket_payload).to_utf8_buffer())
assert_eq(client.state.phase, MatchmakingState.CONNECTING, "the transport attempt started")
NetworkManager.connection_failed.emit()
assert_eq(client.state.phase, MatchmakingState.FAILED, "the async handshake failure is surfaced rather than leaving CONNECTING stuck forever")
NetworkManager.shutdown()
client.free()
func test_network_connection_failure_is_ignored_outside_a_matchmaking_driven_connect() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.state.begin_queue("ticket-unrelated-failure", "casual"), "queue setup succeeds")
# state.phase is QUEUED, not CONNECTING -- this connection_failed belongs
# to something else (e.g. main_menu.gd's own direct-join flow) and must
# not be misattributed to matchmaking.
NetworkManager.connection_failed.emit()
assert_eq(client.state.phase, MatchmakingState.QUEUED, "an unrelated connection_failed does not fail an active queue search")
client.free()
func test_rest_resource_identifiers_use_the_opaque_contract_shape() -> void:
assert_true(ControlPlaneClient.is_valid_resource_id("ticket_1234567890"), "contract-sized resource id is accepted")
assert_true(not ControlPlaneClient.is_valid_resource_id("ticket-1"), "short resource id is rejected")
assert_true(not ControlPlaneClient.is_valid_resource_id("ticket_1234567890/path"), "path separator is rejected")
func test_queue_revision_conflicts_schedule_authoritative_recovery() -> void:
assert_true(ControlPlaneClient.should_recover_queue_after_conflict("queue_heartbeat", 409, "ticket-1"), "stale heartbeat recovers the queue ticket")
assert_true(ControlPlaneClient.should_recover_queue_after_conflict("queue_cancel", 409, "ticket-1"), "stale cancellation recovers the queue ticket")
assert_true(not ControlPlaneClient.should_recover_queue_after_conflict("queue_create", 409, "ticket-1"), "create conflict uses its own idempotency path")
assert_true(not ControlPlaneClient.should_recover_queue_after_conflict("queue_heartbeat", 503, "ticket-1"), "transient outage remains retryable instead of being treated as a revision conflict")
assert_true(not ControlPlaneClient.should_recover_queue_after_conflict("queue_cancel", 409, ""), "missing ticket cannot trigger recovery")
func test_queue_conflict_response_handler_defers_ticket_recovery() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.state.begin_queue("ticket-handler", "casual"), "queue setup succeeds")
client._operation = "queue_heartbeat"
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 409, PackedStringArray(), JSON.stringify({"error": "revision conflict"}).to_utf8_buffer())
assert_eq(client._pending_resync_resource_id, "ticket-handler", "heartbeat conflict queues ticket recovery")
client._operation = "queue_cancel"
client._pending_resync_resource_id = ""
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 409, PackedStringArray(), JSON.stringify({"error": "revision conflict"}).to_utf8_buffer())
assert_eq(client._pending_resync_resource_id, "ticket-handler", "cancel conflict queues ticket recovery")
client.free()
# Covers §8.43's "version-mismatch-specific client messaging": a 426 Upgrade
# Required on queue_create (the server-side floor added alongside this test)
# must surface a distinct, actionable message rather than the server's raw
# generic error string, and must not offer a futile "Retry Search" -- the
# same client build will fail again identically every time.
func test_outdated_client_receives_a_distinct_message_and_no_retry_offer() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
client._operation = "queue_create"
client._last_queue_create = {"ticket_id": "ticket-outdated", "playlist": "casual", "client_build": "build-1", "protocol_version": 4, "key": "outdated-key-123456"}
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, HTTPClient.RESPONSE_UPGRADE_REQUIRED, PackedStringArray(), JSON.stringify({"error": "client_outdated"}).to_utf8_buffer())
assert_eq(client.state.phase, MatchmakingState.FAILED, "outdated client fails the search")
assert_true(client.state.message.to_lower().contains("update"), "message tells the player to update rather than repeating the raw server error: %s" % client.state.message)
assert_true(not client.can_retry_queue_create(), "retrying with the same outdated client build is never offered")
client.free()
func test_rest_responses_reject_malformed_resource_identifiers() -> void:
var client := ControlPlaneClient.new()
client._ready()
client._operation = "queue_recover"
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify({"ticket_id": "short", "playlist": "casual", "revision": 0, "state": "QUEUED"}).to_utf8_buffer())
assert_eq(client.state.phase, MatchmakingState.FAILED, "malformed queue response is not projected")
client._operation = "proposal_recover"
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify({"proposal_id": "proposal/unsafe", "revision": 0, "state": "OPEN"}).to_utf8_buffer())
assert_eq(client.state.phase, MatchmakingState.FAILED, "malformed proposal response is not projected")
client.free()
func test_queue_response_requires_the_complete_contract_shape() -> void:
var valid := {"ticket_id": "ticket_1234567890", "player_id": "player_1234567890", "playlist": "casual", "state": "QUEUED", "revision": 0, "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2026-08-31T12:01:00Z"}
assert_true(ControlPlaneClient._valid_queue_response(valid), "complete queue response is accepted")
var missing_expiry := valid.duplicate()
missing_expiry.erase("expires_at")
assert_true(not ControlPlaneClient._valid_queue_response(missing_expiry), "queue response without expiry is rejected")
var fractional_revision := valid.duplicate()
fractional_revision["revision"] = 1.5
assert_true(not ControlPlaneClient._valid_queue_response(fractional_revision), "fractional queue revision is rejected")
var malformed_player := valid.duplicate()
malformed_player["player_id"] = "player/unsafe"
assert_true(not ControlPlaneClient._valid_queue_response(malformed_player), "unsafe queue player id is rejected")
var assigned := valid.duplicate()
assigned["state"] = "ASSIGNMENT_READY"
assigned["match_id"] = "match_1234567890"
assert_true(ControlPlaneClient._valid_queue_response(assigned), "recovered assignment-ready ticket carries its match lookup identity")
var premature_match := valid.duplicate()
premature_match["match_id"] = "match_1234567890"
assert_true(not ControlPlaneClient._valid_queue_response(premature_match), "pre-match ticket cannot smuggle a match identity")
assigned["match_id"] = "match/unsafe"
assert_true(not ControlPlaneClient._valid_queue_response(assigned), "unsafe recovered match identity is rejected")
var proposed := valid.duplicate()
proposed["state"] = "PROPOSED"
proposed["proposal_id"] = "proposal_12345678"
assert_true(ControlPlaneClient._valid_queue_response(proposed), "recovered proposed ticket carries its proposal lookup identity")
var client := ControlPlaneClient.new()
client._ready()
client.state.begin_queue("ticket_1234567890", "casual")
client._queue_proposal_if_ready(proposed)
assert_eq(client._pending_proposal_id, "proposal_12345678", "recovered proposal is queued for authoritative fetch")
assert_eq(client.state.proposal_id, "proposal_12345678", "recovered proposal identity becomes the active projection")
client.free()
func test_proposal_response_requires_structured_unique_participants() -> void:
var base := {"proposal_id": "proposal_1234567890", "expires_at": "2099-08-31T12:00:00Z", "participants": [
{"player_id": "player_1234567890", "response": "PENDING", "team": 0, "slot": 0},
{"player_id": "player_1234567891", "response": "PENDING", "team": 1, "slot": 3}
]}
assert_true(ControlPlaneClient._valid_proposal_response(base), "structured proposal participants are accepted")
var duplicate := base.duplicate(true)
duplicate["participants"][1]["player_id"] = "player_1234567890"
assert_true(not ControlPlaneClient._valid_proposal_response(duplicate), "duplicate participant identity is rejected")
var fractional_slot := base.duplicate(true)
fractional_slot["participants"][0]["slot"] = 0.5
assert_true(not ControlPlaneClient._valid_proposal_response(fractional_slot), "fractional participant slot is rejected")
var malformed_expiry := base.duplicate(true)
malformed_expiry["expires_at"] = "tomorrow"
assert_true(not ControlPlaneClient._valid_proposal_response(malformed_expiry), "malformed proposal expiry is rejected")
var missing_expiry := base.duplicate(true)
missing_expiry.erase("expires_at")
assert_true(not ControlPlaneClient._valid_proposal_response(missing_expiry), "missing proposal expiry is rejected")
assert_true(int(ControlPlaneClient.normalize_proposal(base)["expires_at_unix"]) > 0, "proposal expiry is normalized")
func test_assignment_endpoint_split_never_accepts_url_or_bad_port() -> void:
var endpoint := ControlPlaneClient._split_assignment_endpoint("127.0.0.1:31001")
assert_eq(endpoint["host"], "127.0.0.1", "assignment host is separated from the port")
assert_eq(endpoint["port"], 31001, "assignment port is parsed as an integer")
for unsafe in ["127.0.0.1", "127.0.0.1:0", "127.0.0.1:65536", "127.0.0.1:31001/path", "https://127.0.0.1:31001"]:
assert_true(ControlPlaneClient._split_assignment_endpoint(unsafe).is_empty(), "unsafe endpoint is rejected: %s" % unsafe)
func test_ranked_profile_is_backend_display_data_and_rejects_unsafe_values() -> void:
var profile := RankedProfileState.new()
assert_true(profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "GOLD", "provisional": true, "season_id": "season_1234567890"}), "valid profile applies")
assert_eq(profile.display_text(), "Provisional · 3 ranked games", "provisional status overrides tier presentation")
assert_true(not profile.apply({"rating": -1.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "GOLD", "provisional": false}), "negative rating is rejected")
assert_true(not profile.available, "unsafe response is not displayed")
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "", "provisional": false}), "empty tier is rejected")
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "MASTER", "provisional": false}), "unknown tier is rejected")
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "GOLD", "provisional": "false"}), "string boolean is rejected")
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3.5, "tier": "GOLD", "provisional": false}), "fractional ranked games is rejected")
func test_ranked_profile_projects_and_bounds_season_countdown() -> void:
var profile := RankedProfileState.new()
assert_true(profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_id": "season_1234567890", "season_ends_at": "1970-01-03T00:00:00Z"}), "season end applies")
assert_true(profile.display_text(1000).contains("Season ends in 2d"), "countdown rounds up remaining season time")
assert_true(profile.display_text(300000).contains("Season ends in 0d"), "expired season countdown is clamped")
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_ends_at": "not-a-timestamp"}), "malformed season expiry is rejected")
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_ends_at": 123}), "non-string season expiry is rejected")
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_id": "short"}), "short season identifier is rejected")
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_id": 123}), "non-string season identifier is rejected")
# The client had no probe support at all, so even with the backend wired a real
# player could never acquire the RTT evidence the matcher requires.
func test_probe_region_validation_rejects_unknown_regions() -> void:
assert_true(ControlPlaneClient.is_valid_probe_region("EU"), "EU is a placement region")
assert_true(ControlPlaneClient.is_valid_probe_region("NA"), "NA is a placement region")
for region in ["", "eu", "APAC", "EU/NA", "../EU"]:
assert_true(not ControlPlaneClient.is_valid_probe_region(region), "rejects %s" % region)
func test_probe_requests_require_a_session() -> void:
var client = ControlPlaneClient.new()
client.base_url = "http://127.0.0.1:8080"
client.access_token = ""
assert_eq(client.request_probe_challenge("EU"), ERR_UNAUTHORIZED, "probing without a session is refused")
assert_eq(client.submit_probe_answer("EU", "bm9uY2U=", "bG9j"), ERR_UNAUTHORIZED, "answering without a session is refused")
client.free()
func test_probe_answer_rejects_empty_nonce_or_location() -> void:
var client = ControlPlaneClient.new()
client.base_url = "http://127.0.0.1:8080"
client.access_token = "session-1234567890:token-1234567890"
assert_eq(client.submit_probe_answer("EU", "", "bG9j"), ERR_INVALID_PARAMETER, "an empty nonce is refused")
assert_eq(client.submit_probe_answer("EU", "bm9uY2U=", ""), ERR_INVALID_PARAMETER, "an empty location is refused")
assert_eq(client.request_probe_challenge("APAC"), ERR_INVALID_PARAMETER, "an unknown region is refused")
client.free()
func test_opaque_location_payload_is_never_empty() -> void:
# The backend rejects an empty opaque location, and without a Steam runtime
# there is nothing real to report -- but the RTT the backend measures is
# what actually drives placement, so the probe must still be answerable.
var payload := ControlPlaneClient.opaque_location_payload()
assert_true(not payload.is_empty(), "a probe answer always carries a location blob")
assert_true(not Marshalls.base64_to_raw(payload).is_empty(), "the location blob is valid base64")
func test_probe_challenge_response_without_a_nonce_is_a_failure() -> void:
var client = ControlPlaneClient.new()
client.base_url = "http://127.0.0.1:8080"
client.access_token = "session-1234567890:token-1234567890"
var failures: Array = []
client.request_failed.connect(func(operation: String, _code: int, detail: String): failures.append([operation, detail]))
client._operation = "probe_challenge_EU"
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 201, PackedStringArray(), JSON.stringify({"region": "EU"}).to_utf8_buffer())
assert_eq(failures.size(), 1, "a challenge with no nonce is reported as a failure")
client.free()
# The game started with an empty token against a loopback default and no
# production code ever called configure() or login_steam(), so every
# matchmaking request failed ERR_UNAUTHORIZED before reaching the network.
func test_has_session_reflects_token_and_expiry() -> void:
var client = ControlPlaneClient.new()
assert_true(not client.has_session(), "a fresh client has no session")
client.access_token = "session-1234567890:token-1234567890"
client.session_expires_at = "2099-01-01T00:00:00Z"
assert_true(client.has_session(), "a valid unexpired token is a session")
client.session_expires_at = "2000-01-01T00:00:00Z"
assert_true(not client.has_session(), "an expired token is not a session")
client.free()
func test_configured_base_url_falls_back_to_the_development_default() -> void:
# Release builds set COSMIC_CLASH_CONTROL_PLANE_URL; without it the
# loopback default keeps local development working.
var resolved := ControlPlaneClient.configured_base_url()
assert_true(ControlPlaneClient.is_valid_base_url(resolved), "the resolved endpoint is always usable")
if OS.get_environment(ControlPlaneClient.BASE_URL_ENV).strip_edges().is_empty():
assert_eq(resolved, ControlPlaneClient.DEFAULT_BASE_URL, "falls back to the development default")
+450
View File
@@ -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
+190
View File
@@ -33,7 +33,197 @@ func test_empty_or_whitespace_only_falls_back_to_default() -> void:
assert_eq(MatchNet._sanitize_player_name(""), "Player", "empty string falls back")
assert_eq(MatchNet._sanitize_player_name(" "), "Player", "whitespace-only falls back")
assert_eq(MatchNet._sanitize_player_name("\n\t\r"), "Player", "control-characters-only falls back")
assert_eq(MatchNet._sanitize_shutdown_reason("\n maintenance \t"), "maintenance", "shutdown reason strips controls")
assert_eq(MatchNet._sanitize_shutdown_reason(""), "server_shutdown", "empty shutdown reason gets a safe fallback")
func test_leading_trailing_whitespace_trimmed() -> void:
assert_eq(MatchNet._sanitize_player_name(" Bob "), "Bob", "surrounding whitespace trimmed")
func test_server_shutdown_message_is_bounded_and_emitted() -> void:
var instance = MatchNet.new()
var received := [""]
var callback := func(reason: String) -> void: received[0] = reason
instance.server_shutdown.connect(callback)
instance._server_shutdown(" planned maintenance " + "x".repeat(200))
instance.server_shutdown.disconnect(callback)
assert_eq(received[0].length(), 96, "shutdown reason is bounded before presentation")
assert_eq(instance.last_server_shutdown_reason.length(), 96, "bounded shutdown reason is retained for UI")
func test_drain_fences_new_hello_admissions() -> void:
assert_eq(MatchNet.admission_rejection(true), "", "an active server accepts new hello requests")
assert_eq(MatchNet.admission_rejection(false), "server is draining", "a draining server rejects new hello requests")
func test_draining_disconnect_still_releases_roster_and_join_token() -> void:
var match_net := MatchNet.new()
var token := "opaque-join-token"
match_net.admissions_open = false
match_net.roster[42] = MatchNet.PlayerInfo.new(42, "Alice", 0, false, "player-1")
match_net._active_join_peers[token] = 42
match_net._join_history[token] = {"generation": 1}
match_net._cleanup_disconnected_peer(42)
assert_true(not match_net.roster.has(42), "drain does not retain a disconnected roster entry")
assert_true(not match_net._active_join_peers.has(token), "drain releases the disconnected peer's join token")
assert_true(float(match_net._join_history[token].get("lost_at", 0.0)) > 0.0, "disconnect records the reclaim boundary during drain")
func test_signed_assignment_locks_team_and_spawn_slot_together() -> void:
var match_net := MatchNet.new()
var info := MatchNet.PlayerInfo.new(42, "Alice", 0, true, "player-1")
info.spawn_index = 2
match_net.roster[42] = info
match_net.require_join_authorisation = true
assert_true(not match_net._apply_team_change(42, 1), "allocated clients cannot override their signed team")
assert_eq(info.team, 0, "signed team is unchanged")
assert_eq(info.spawn_index, 2, "signed spawn index remains paired with its team")
assert_true(info.ready, "rejected mutation does not alter readiness")
match_net.require_join_authorisation = false
assert_true(match_net._apply_team_change(42, 1), "direct lobbies retain team switching")
assert_eq(info.team, 1, "direct team switch applies")
assert_true(not info.ready, "direct team switch still clears readiness")
func test_reservation_reclaim_requires_stable_identity() -> void:
assert_true(MatchNet.reservation_identity_matches("player-a", "player-a", "Alice", "Impostor"), "the verified identity can reclaim despite a changed display name")
assert_true(not MatchNet.reservation_identity_matches("player-a", "player-b", "Alice", "Alice"), "a same-name peer cannot reclaim another identity's slot")
assert_true(not MatchNet.reservation_identity_matches("player-a", "", "Alice", "Alice"), "an unauthenticated peer cannot reclaim an allocated slot")
assert_true(MatchNet.reservation_identity_matches("", "", "Alice", "Alice"), "direct servers retain the legacy display-name fallback")
func test_allocated_join_authorisation_is_allowlisted_and_bound_to_server() -> void:
var claims := {
"MatchID": "match-1", "ServerID": "server-1", "PlayerID": "player-1",
"SteamID": "steam-1", "Slot": 5, "Team": 1, "Protocol": "1",
"Generation": 1, "ExpiresAt": "2099-08-31T12:00:00Z",
}
var token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": claims, "Signature": "trusted-signature"}).to_utf8_buffer())
var match_net := MatchNet.new()
assert_true(match_net.configure_join_authorisations([token], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}), "valid roster configures")
var assigned := match_net.assigned_player_slots()
assert_eq(assigned.size(), 1, "configured roster exposes one assigned player")
assert_eq(assigned[0]["player_identity"], "player-1", "assigned roster preserves player identity")
assert_eq(assigned[0]["team"], 1, "assigned roster preserves team")
assert_eq(assigned[0]["slot"], 5, "assigned roster preserves slot")
assert_true(match_net._valid_join_authorisation(token), "allowlisted matching token is accepted")
var malformed_claims := claims.duplicate()
malformed_claims["ExpiresAt"] = "tomorrow"
var malformed_token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": malformed_claims, "Signature": "trusted-signature"}).to_utf8_buffer())
assert_true(not match_net._valid_join_authorisation(malformed_token), "malformed expiry claim is rejected before admission")
var string_slot_claims := claims.duplicate()
string_slot_claims["Slot"] = "5"
var string_slot_token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": string_slot_claims, "Signature": "trusted-signature"}).to_utf8_buffer())
assert_true(not match_net._valid_join_authorisation(string_slot_token), "string slot claim is rejected instead of coerced")
assert_true(not match_net._valid_join_authorisation(token + "tampered"), "token mutation is rejected")
var wrong_claims := claims.duplicate()
wrong_claims["ServerID"] = "other-server"
var wrong_token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": wrong_claims, "Signature": "trusted-signature"}).to_utf8_buffer())
assert_true(not match_net._valid_join_authorisation(wrong_token), "wrong server claim is rejected")
assert_eq(match_net._reserve_join_authorisation(token, 42), 1, "first admission receives generation one")
assert_true(match_net.is_join_authorisation_active(token), "admitted token is active")
assert_eq(match_net._reserve_join_authorisation(token, 43), -1, "active token cannot be admitted concurrently")
match_net._remove_player(42)
assert_true(not match_net.is_join_authorisation_active(token), "disconnect releases active token")
assert_eq(match_net._reserve_join_authorisation(token, 43), 2, "reclaim receives the next server-owned generation")
match_net._remove_player(43)
match_net._join_history[token]["lost_at"] = Time.get_unix_time_from_system() - MatchNet.RECONNECT_GRACE_SECONDS - 1.0
assert_eq(match_net._reserve_join_authorisation(token, 44), -1, "reclaim after the grace window is fenced")
match_net._join_history[token]["lost_at"] = Time.get_unix_time_from_system() + 60.0
assert_eq(match_net._reserve_join_authorisation(token, 44), -1, "clock-reversed reclaim is fenced")
var malformed_context := {"match_id": 123, "server_id": "server-1", "protocol": "1", "protocol_version": 1}
assert_true(not match_net.configure_join_authorisations([token], malformed_context), "numeric context identity is rejected")
malformed_context = {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1.5}
assert_true(not match_net.configure_join_authorisations([token], malformed_context), "fractional context protocol is rejected")
func test_allocated_join_authorisation_rejects_inconsistent_team_and_slot() -> void:
var claims := {
"MatchID": "match-1", "ServerID": "server-1", "PlayerID": "player-1",
"SteamID": "steam-1", "Slot": 3, "Team": 0, "Protocol": "1",
"Generation": 1, "ExpiresAt": "2099-08-31T12:00:00Z",
}
var token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": claims, "Signature": "trusted-signature"}).to_utf8_buffer())
var match_net := MatchNet.new()
assert_true(match_net.configure_join_authorisations([token], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}), "fixture configures")
assert_true(not match_net._valid_join_authorisation(token), "a slot assigned to team 1 cannot claim team 0")
func test_assigned_roster_rejects_duplicate_identity_or_slot_shape() -> void:
var claims := {
"MatchID": "match-1", "ServerID": "server-1", "PlayerID": "player-1",
"SteamID": "steam-1", "Slot": 0, "Team": 0, "Protocol": "1",
"Generation": 1, "ExpiresAt": "2099-08-31T12:00:00Z",
}
var first := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": claims, "Signature": "one"}).to_utf8_buffer())
var duplicate := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": claims, "Signature": "two"}).to_utf8_buffer())
var match_net := MatchNet.new()
assert_true(match_net.configure_join_authorisations([first, duplicate], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}), "duplicate fixture configures for structural inspection")
assert_eq(match_net.assigned_player_slots().size(), 0, "duplicate identity/slot roster fails closed")
func test_allocated_join_authorisation_verifies_canonical_hmac() -> void:
# This envelope is generated from server/domain.JoinAuthorisationBytes with
# HMAC-SHA256(test-key), proving the Godot verifier agrees with the Go
# canonical representation rather than merely checking token membership.
# Regenerate it whenever JoinAuthorisationBytes changes; a stale token here
# is exactly how a silent cross-language format drift would be caught.
var token := "eyJBdXRob3Jpc2F0aW9uIjp7Ik1hdGNoSUQiOiJtYXRjaC0xIiwiU2VydmVySUQiOiJzZXJ2ZXItMSIsIlBsYXllcklEIjoicGxheWVyLTEiLCJTdGVhbUlEIjoic3RlYW0tMSIsIlNsb3QiOjUsIlRlYW0iOjEsIlByb3RvY29sIjoiMSIsIkdlbmVyYXRpb24iOjEsIkV4cGlyZXNBdCI6IjIwOTktMDgtMzFUMTI6MDA6MDBaIiwiS2V5SUQiOiJrZXktMjAyNi0wOSJ9LCJTaWduYXR1cmUiOiJ4bWw1a09qdjltVURab256bHVFcitCM2wyQ0c4THBOUisxd0tpV1VkMjFrPSJ9"
var match_net := MatchNet.new()
assert_true(match_net.configure_join_authorisations([token], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}, {"key-2026-09": "test-key".to_utf8_buffer()}), "HMAC roster configures")
assert_true(match_net._valid_join_authorisation(token), "Go-compatible canonical HMAC is accepted")
var tampered_payload: Dictionary = JSON.parse_string(Marshalls.base64_to_raw(token).get_string_from_utf8())
tampered_payload["Signature"] = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
var tampered_token := Marshalls.raw_to_base64(JSON.stringify(tampered_payload).to_utf8_buffer())
var tampered_match_net := MatchNet.new()
assert_true(tampered_match_net.configure_join_authorisations([tampered_token], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}, {"key-2026-09": "test-key".to_utf8_buffer()}), "tampered roster fixture configures")
assert_true(not tampered_match_net._valid_join_authorisation(tampered_token), "allowlisted but forged signature is rejected")
# Rotation contract: the allocator signs with one key while allocated servers
# accept the set of currently-valid keys, so rotating does not invalidate
# authorisations already issued for in-flight matches. All three envelopes are
# generated from server/domain.JoinAuthorisationBytes.
const ROTATION_CONTEXT := {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}
const TOKEN_SIGNED_WITH_OLD_KEY := "eyJBdXRob3Jpc2F0aW9uIjp7Ik1hdGNoSUQiOiJtYXRjaC0xIiwiU2VydmVySUQiOiJzZXJ2ZXItMSIsIlBsYXllcklEIjoicGxheWVyLTEiLCJTdGVhbUlEIjoic3RlYW0tMSIsIlNsb3QiOjUsIlRlYW0iOjEsIlByb3RvY29sIjoiMSIsIkdlbmVyYXRpb24iOjEsIkV4cGlyZXNBdCI6IjIwOTktMDgtMzFUMTI6MDA6MDBaIiwiS2V5SUQiOiJrZXktMjAyNi0wOCJ9LCJTaWduYXR1cmUiOiI5TW42eldERGNwR1pmblY2NXdreXNCYTduUnk3OG1QQkZPT29JN2F1UkdJPSJ9"
const TOKEN_SIGNED_WITH_NEW_KEY := "eyJBdXRob3Jpc2F0aW9uIjp7Ik1hdGNoSUQiOiJtYXRjaC0xIiwiU2VydmVySUQiOiJzZXJ2ZXItMSIsIlBsYXllcklEIjoicGxheWVyLTEiLCJTdGVhbUlEIjoic3RlYW0tMSIsIlNsb3QiOjUsIlRlYW0iOjEsIlByb3RvY29sIjoiMSIsIkdlbmVyYXRpb24iOjEsIkV4cGlyZXNBdCI6IjIwOTktMDgtMzFUMTI6MDA6MDBaIiwiS2V5SUQiOiJrZXktMjAyNi0wOSJ9LCJTaWduYXR1cmUiOiJ4bWw1a09qdjltVURab256bHVFcitCM2wyQ0c4THBOUisxd0tpV1VkMjFrPSJ9"
const TOKEN_SIGNED_WITH_RETIRED_KEY := "eyJBdXRob3Jpc2F0aW9uIjp7Ik1hdGNoSUQiOiJtYXRjaC0xIiwiU2VydmVySUQiOiJzZXJ2ZXItMSIsIlBsYXllcklEIjoicGxheWVyLTEiLCJTdGVhbUlEIjoic3RlYW0tMSIsIlNsb3QiOjUsIlRlYW0iOjEsIlByb3RvY29sIjoiMSIsIkdlbmVyYXRpb24iOjEsIkV4cGlyZXNBdCI6IjIwOTktMDgtMzFUMTI6MDA6MDBaIiwiS2V5SUQiOiJrZXktMjAyNy0wMSJ9LCJTaWduYXR1cmUiOiJRemFYLzB5T0pObE1oRXRPZ1BBcUpRNGJueHZRb1BVU09CR0p2Mm9nQVdnPSJ9"
func test_join_authorisation_accepts_every_key_in_the_rotation_set() -> void:
# Mid-rotation: both keys are published, so authorisations issued before
# and after the switch must both still admit their player.
var keys := {
"key-2026-08": "old-key".to_utf8_buffer(),
"key-2026-09": "test-key".to_utf8_buffer(),
}
for token in [TOKEN_SIGNED_WITH_OLD_KEY, TOKEN_SIGNED_WITH_NEW_KEY]:
var match_net := MatchNet.new()
assert_true(match_net.configure_join_authorisations([token], ROTATION_CONTEXT, keys), "rotation fixture configures")
assert_true(match_net._valid_join_authorisation(token), "a token signed by any currently-valid key is accepted")
func test_join_authorisation_rejects_a_key_id_outside_the_set() -> void:
# Rotation completed: the retired key is dropped, so anything still signed
# with it must stop being admitted.
var keys := {"key-2026-09": "test-key".to_utf8_buffer()}
var match_net := MatchNet.new()
assert_true(match_net.configure_join_authorisations([TOKEN_SIGNED_WITH_RETIRED_KEY], ROTATION_CONTEXT, keys), "retired-key fixture configures")
assert_true(not match_net._valid_join_authorisation(TOKEN_SIGNED_WITH_RETIRED_KEY), "a token naming a key outside the set is rejected")
func test_join_authorisation_key_id_cannot_be_repointed_at_another_key() -> void:
# KeyID is inside the signed bytes, so swapping it to name a key the server
# does hold must fail verification rather than selecting that key.
var payload: Dictionary = JSON.parse_string(Marshalls.base64_to_raw(TOKEN_SIGNED_WITH_OLD_KEY).get_string_from_utf8())
payload["Authorisation"]["KeyID"] = "key-2026-09"
var repointed := Marshalls.raw_to_base64(JSON.stringify(payload).to_utf8_buffer())
var keys := {
"key-2026-08": "old-key".to_utf8_buffer(),
"key-2026-09": "test-key".to_utf8_buffer(),
}
var match_net := MatchNet.new()
assert_true(match_net.configure_join_authorisations([repointed], ROTATION_CONTEXT, keys), "repointed fixture configures")
assert_true(not match_net._valid_join_authorisation(repointed), "the key ID is covered by the signature")
+207
View File
@@ -0,0 +1,207 @@
extends "res://tests/test_case.gd"
const MatchmakingState = preload("res://scripts/matchmaking_state.gd")
func test_ticket_projection_accepts_ordered_updates_and_exposes_cancel() -> void:
var state := MatchmakingState.new()
assert_true(state.begin_queue("ticket-1", "ranked"), "valid queue starts in QUEUED")
assert_true(state.apply_ticket_update({"ticket_id": "ticket-1", "revision": 1, "state": "PROPOSED", "playlist": "ranked"}), "next revision applies")
assert_eq(state.phase, MatchmakingState.PROPOSED, "proposal is visible")
assert_true(state.can_cancel(), "authoritative cancel remains available before allocation")
func test_ticket_projection_accepts_authoritative_accepted_phase() -> void:
var state := MatchmakingState.new()
assert_true(state.begin_queue("ticket-accepted", "ranked"), "queue setup succeeds")
assert_true(state.apply_ticket_update({"ticket_id": "ticket-accepted", "revision": 1, "state": "PROPOSED", "playlist": "ranked"}), "proposal phase applies")
assert_true(state.apply_ticket_update({"ticket_id": "ticket-accepted", "revision": 2, "state": "ACCEPTED", "playlist": "ranked"}), "accepted queue phase is valid")
assert_eq(state.phase, MatchmakingState.ACCEPTED, "accepted phase remains visible instead of forcing resync")
assert_true(not state.can_cancel(), "accepted match cannot be cancelled as a queue ticket")
func test_ticket_projection_accepts_post_match_lifecycle_states() -> void:
for phase in ["ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED"]:
var state := MatchmakingState.new()
assert_true(state.begin_queue("ticket-" + phase, "casual"), "queue setup succeeds for " + phase)
var revision := 1
for next_phase in ["PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED"]:
assert_true(state.apply_ticket_update({"ticket_id": "ticket-" + phase, "revision": revision, "state": next_phase, "playlist": "casual"}), "lifecycle phase applies: " + next_phase)
revision += 1
if next_phase == phase:
break
assert_eq(state.phase, phase, "post-match phase remains visible: " + phase)
assert_true(not state.can_cancel(), "post-match phase cannot cancel: " + phase)
func test_ticket_projection_rejects_gap_and_wrong_ticket_without_mutation() -> void:
var state := MatchmakingState.new()
assert_true(state.begin_queue("ticket-1", "casual"), "queue setup succeeds")
assert_eq(state.ticket_id, "ticket-1", "queue setup retains ticket identity")
var resync_ids: Array[String] = [""]
state.resync_required.connect(func(id: String) -> void: resync_ids[0] = id)
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-2", "revision": 1, "state": "PROPOSED"}), "another player's ticket is rejected")
assert_eq(resync_ids[0], "ticket-1", "wrong resource requests recovery for current ticket")
assert_eq(state.phase, MatchmakingState.QUEUED, "invalid update cannot mutate phase")
assert_true(state.needs_resync, "invalid identity is visible to recovery")
state.needs_resync = false
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-1", "revision": 3, "state": "ALLOCATING"}), "revision gap is rejected")
assert_eq(state.phase, MatchmakingState.QUEUED, "gap cannot skip authoritative state")
func test_duplicate_conflict_and_stale_updates_are_safe() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-1", "casual")
var update := {"ticket_id": "ticket-1", "revision": 1, "state": "PROPOSED", "playlist": "casual", "expires_at_unix": 100}
assert_true(state.apply_ticket_update(update), "first update applies")
assert_true(state.apply_ticket_update(update), "identical duplicate is idempotent")
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-1", "revision": 1, "state": "QUEUED", "playlist": "casual", "expires_at_unix": 100}), "same-revision conflict requests recovery")
assert_eq(state.phase, MatchmakingState.PROPOSED, "conflicting replay cannot rewind state")
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-1", "revision": 0, "state": "QUEUED"}), "stale update is ignored")
assert_eq(state.phase, MatchmakingState.PROPOSED, "stale update cannot mutate state")
func test_higher_revision_cannot_jump_or_rewind_the_authoritative_lifecycle() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-transition", "casual")
assert_true(state.apply_ticket_update({"ticket_id": "ticket-transition", "revision": 1, "state": "PROPOSED", "playlist": "casual"}), "legal transition applies")
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-transition", "revision": 2, "state": "LIVE", "playlist": "casual"}), "higher revision cannot jump phases")
assert_eq(state.phase, MatchmakingState.PROPOSED, "illegal jump cannot mutate phase")
state.needs_resync = false
assert_true(state.apply_ticket_update({"ticket_id": "ticket-transition", "revision": 2, "state": "ACCEPTED", "playlist": "casual"}), "legal next transition applies")
state.needs_resync = false
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-transition", "revision": 3, "state": "PROPOSED", "playlist": "casual"}), "higher revision cannot rewind after acceptance")
assert_eq(state.phase, MatchmakingState.ACCEPTED, "illegal rewind cannot mutate phase")
func test_authoritative_ticket_snapshot_can_cross_missed_forward_revisions_but_not_rewind() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-snapshot", "casual")
assert_true(state.apply_ticket_update({"ticket_id": "ticket-snapshot", "revision": 1, "state": "PROPOSED", "playlist": "casual"}), "incremental proposal applies")
assert_true(state.apply_ticket_update({"ticket_id": "ticket-snapshot", "revision": 5, "state": "ASSIGNMENT_READY", "playlist": "casual"}, true), "owner-scoped REST snapshot crosses missed forward states")
assert_eq(state.phase, MatchmakingState.ASSIGNMENT_READY, "authoritative recovery reaches assignment readiness")
state.needs_resync = false
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-snapshot", "revision": 6, "state": "QUEUED", "playlist": "casual"}, true), "authoritative snapshot cannot rewind an assigned match")
assert_eq(state.phase, MatchmakingState.ASSIGNMENT_READY, "rejected snapshot cannot mutate phase")
func test_ticket_and_proposal_revisions_must_be_nonnegative_integers() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-revision", "casual")
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-revision", "revision": 1.5, "state": "PROPOSED"}), "fractional ticket revision is rejected")
assert_true(not state.apply_proposal_update({"proposal_id": "proposal-revision", "revision": -1, "state": "OPEN"}), "negative proposal revision is rejected")
func test_ticket_update_rejects_invalid_playlist_without_mutation() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-playlist", "casual")
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-playlist", "revision": 1, "state": "PROPOSED", "playlist": "admin"}), "unknown playlist is rejected")
assert_eq(state.phase, MatchmakingState.QUEUED, "invalid playlist cannot change phase")
assert_eq(state.playlist, "casual", "invalid playlist cannot change playlist")
func test_ticket_and_proposal_epoch_metadata_rejects_malformed_values() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-epoch", "casual")
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-epoch", "revision": 1, "state": "PROPOSED", "expires_at_unix": "not-a-time"}), "malformed ticket expiry is rejected")
assert_eq(state.phase, MatchmakingState.QUEUED, "malformed ticket expiry cannot change phase")
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-epoch", "revision": 1, "state": "PROPOSED", "enqueued_at_unix": -1}), "negative enqueue time is rejected")
assert_true(not state.apply_proposal_update({"proposal_id": "proposal-epoch", "revision": 1, "state": "OPEN", "expires_at_unix": 1.25}), "fractional proposal expiry is rejected")
func test_proposal_terminal_states_are_visible_and_not_cancellable() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-1", "casual")
assert_true(state.apply_proposal_update({"proposal_id": "proposal-1", "revision": 1, "state": "OPEN"}), "open proposal applies")
assert_eq(state.phase, MatchmakingState.PROPOSED, "open proposal is visible")
assert_true(state.apply_proposal_update({"proposal_id": "proposal-1", "revision": 2, "state": "DECLINED"}), "declined proposal applies")
assert_eq(state.phase, MatchmakingState.QUEUED, "decline returns the requeued ticket to search")
assert_true(state.can_cancel(), "requeued ticket can be cancelled")
var expired := MatchmakingState.new()
expired.begin_queue("ticket-2", "casual")
assert_true(expired.apply_proposal_update({"proposal_id": "proposal-2", "revision": 1, "state": "OPEN"}), "second proposal opens")
assert_true(expired.apply_proposal_update({"proposal_id": "proposal-2", "revision": 2, "state": "EXPIRED"}), "expired proposal applies")
assert_eq(expired.phase, MatchmakingState.QUEUED, "expiry returns the requeued ticket to search")
assert_true(expired.can_cancel(), "requeued ticket can be cancelled")
var cancelled := MatchmakingState.new()
cancelled.begin_queue("ticket-3", "casual")
assert_true(cancelled.apply_proposal_update({"proposal_id": "proposal-3", "revision": 1, "state": "OPEN"}), "third proposal opens")
cancelled.phase = MatchmakingState.CANCELLED
assert_true(cancelled.apply_proposal_update({"proposal_id": "proposal-3", "revision": 2, "state": "DECLINED"}), "decline after ticket cancellation is accepted")
assert_eq(cancelled.phase, MatchmakingState.CANCELLED, "proposal decline cannot resurrect a cancelled ticket")
func test_proposal_projection_rejects_illegal_higher_revision_transitions() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-proposal-transition", "casual")
assert_true(state.apply_proposal_update({"proposal_id": "proposal-transition", "revision": 1, "state": "OPEN"}), "proposal opens")
assert_true(state.apply_proposal_update({"proposal_id": "proposal-transition", "revision": 2, "state": "ACCEPTED"}), "proposal accepts")
assert_true(not state.apply_proposal_update({"proposal_id": "proposal-transition", "revision": 3, "state": "OPEN"}), "accepted proposal cannot reopen")
assert_eq(state.proposal_state, "ACCEPTED", "illegal proposal transition cannot mutate state")
state.needs_resync = false
var declined := MatchmakingState.new()
declined.begin_queue("ticket-proposal-declined", "casual")
assert_true(declined.apply_proposal_update({"proposal_id": "proposal-declined", "revision": 1, "state": "OPEN"}), "second proposal opens")
assert_true(declined.apply_proposal_update({"proposal_id": "proposal-declined", "revision": 2, "state": "DECLINED"}), "second proposal declines")
assert_true(not declined.apply_proposal_update({"proposal_id": "proposal-declined", "revision": 3, "state": "ACCEPTED"}), "declined proposal cannot accept")
func test_terminal_proposal_is_not_an_active_recovery_target() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-terminal-proposal", "casual")
assert_true(state.apply_proposal_update({"proposal_id": "proposal-terminal", "revision": 1, "state": "OPEN"}), "proposal opens")
assert_true(state.has_open_proposal(), "open proposal is an active recovery target")
assert_true(state.apply_proposal_update({"proposal_id": "proposal-terminal", "revision": 2, "state": "EXPIRED"}), "proposal expires")
assert_true(not state.has_open_proposal(), "terminal proposal uses ticket recovery instead")
assert_true(state.prepare_proposal_recovery("proposal_second_123"), "a later proposal can replace a terminal proposal identity")
assert_true(state.apply_proposal_update({"proposal_id": "proposal_second_123", "revision": 4, "state": "OPEN"}), "recovered later proposal accepts its authoritative revision")
assert_true(state.has_open_proposal(), "later proposal becomes the active recovery target")
assert_true(not state.prepare_proposal_recovery("proposal_third_1234"), "an open proposal cannot be replaced by another identity")
func test_assignment_lifecycle_has_explicit_connecting_and_live_states() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-1", "ranked")
state.mark_assignment_ready()
assert_eq(state.phase, MatchmakingState.ASSIGNMENT_READY, "assignment readiness is visible")
state.mark_connecting()
assert_eq(state.phase, MatchmakingState.CONNECTING, "transport connection is visible")
state.mark_live()
assert_eq(state.phase, MatchmakingState.LIVE, "live match is visible")
func test_expiry_is_distinct_from_generic_failure_and_remains_visible() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-1", "casual")
state.expire("Queue ticket expired")
assert_eq(state.phase, MatchmakingState.EXPIRED, "expired ticket has a terminal expiry state")
assert_eq(state.message, "Queue ticket expired", "expiry reason is visible")
assert_true(not state.can_cancel(), "expired ticket cannot be cancelled")
func test_restart_restore_requires_valid_identity_and_requests_authoritative_recovery() -> void:
var state := MatchmakingState.new()
assert_true(state.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket_1234567890", "playlist": "casual", "revision": 2}), "valid active snapshot restores")
assert_true(state.needs_resync, "restored active state must recover from the server")
assert_eq(state.revision, 2, "revision is retained for diagnostics")
assert_true(not state.restore_snapshot({"phase": "QUEUED", "ticket_id": "", "playlist": "casual"}), "missing ticket identity is rejected")
assert_eq(state.phase, MatchmakingState.IDLE, "invalid restore cannot leave stale active state")
assert_true(not state.restore_snapshot({"phase": "NOT_A_STATE", "ticket_id": "ticket-1", "playlist": "casual"}), "unknown state is rejected")
assert_true(not state.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket-1", "playlist": "casual", "revision": "2"}), "string revision is rejected")
assert_true(not state.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket-1", "playlist": "casual", "enqueued_at_unix": 1.5}), "fractional epoch is rejected")
assert_true(not state.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket-1", "playlist": "casual", "proposal_state": "OPEN"}), "proposal state without identity is rejected")
func test_authoritative_enqueue_time_survives_wait_projection_and_restore() -> void:
var state := MatchmakingState.new()
assert_true(state.begin_queue("ticket_wait_123456", "casual"), "queue setup succeeds")
assert_true(state.apply_ticket_update({"ticket_id": "ticket_wait_123456", "revision": 0, "state": "QUEUED", "playlist": "casual", "enqueued_at_unix": 1000}), "server enqueue timestamp applies")
assert_eq(state.waited_seconds(1065), 65, "wait uses server enqueue time")
var restored := MatchmakingState.new()
assert_true(restored.restore_snapshot(state.snapshot()), "snapshot restores")
assert_eq(restored.waited_seconds(1065), 65, "authoritative wait survives restore")
assert_true(not restored.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket-short", "playlist": "casual"}), "short snapshot ticket id is rejected")
assert_true(not restored.restore_snapshot({"phase": "PROPOSED", "ticket_id": "ticket_wait_123456", "playlist": "casual", "proposal_id": "proposal/unsafe", "proposal_state": "OPEN"}), "unsafe snapshot proposal id is rejected")
+46
View File
@@ -0,0 +1,46 @@
extends "res://tests/test_case.gd"
const Matchmaking = preload("res://scripts/matchmaking.gd")
const MatchmakingState = preload("res://scripts/matchmaking_state.gd")
func test_every_backend_phase_has_a_nonempty_user_message() -> void:
for phase in [MatchmakingState.IDLE, MatchmakingState.QUEUED, MatchmakingState.PROPOSED, MatchmakingState.ACCEPTED, MatchmakingState.ALLOCATING, MatchmakingState.PROCESS_READY, MatchmakingState.ASSIGNMENT_READY, MatchmakingState.ASSIGNED, MatchmakingState.CONNECTING, MatchmakingState.LIVE, MatchmakingState.RESULT_PENDING, MatchmakingState.COMPLETED, MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED]:
assert_true(not Matchmaking.phase_label(phase).is_empty(), "phase %s has visible copy" % phase)
func test_terminal_state_policy_does_not_leave_cancel_or_proposal_actions_enabled() -> void:
for phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE, MatchmakingState.COMPLETED]:
assert_true(Matchmaking._is_terminal(phase), "phase %s is terminal" % phase)
assert_true(not Matchmaking._is_terminal(MatchmakingState.QUEUED), "queued search remains active")
assert_true(not Matchmaking._is_terminal(MatchmakingState.PROPOSED), "proposal remains actionable")
assert_true(Matchmaking._can_start_new_search(MatchmakingState.FAILED), "failed search can be retried")
assert_true(not Matchmaking._can_start_new_search(MatchmakingState.LIVE), "live match cannot start a second search")
assert_true(Matchmaking._can_start_new_search(MatchmakingState.COMPLETED), "completed match can start a new search")
func test_proposal_countdown_uses_authoritative_expiry_and_clamps_after_expiry() -> void:
assert_eq(Matchmaking.proposal_countdown_text(1100, 1000), "Review proposal · 100s remaining", "proposal countdown uses server expiry")
assert_eq(Matchmaking.proposal_countdown_text(1000, 1001), "Review proposal · 0s remaining", "expired proposal countdown clamps to zero")
assert_eq(Matchmaking.proposal_countdown_text(0, 1000), "Review the proposal before the countdown expires", "missing expiry retains compatible copy")
func test_allocation_lifecycle_phases_have_specific_detail_copy() -> void:
for phase in [MatchmakingState.ACCEPTED, MatchmakingState.ALLOCATING, MatchmakingState.PROCESS_READY, MatchmakingState.ASSIGNMENT_READY, MatchmakingState.ASSIGNED, MatchmakingState.CONNECTING, MatchmakingState.LIVE]:
assert_true(not Matchmaking.phase_detail_label(phase).is_empty(), "phase %s has lifecycle detail copy" % phase)
assert_true(Matchmaking.phase_detail_label(MatchmakingState.ALLOCATING).contains("dedicated"), "allocation explains dedicated server provisioning")
assert_true(Matchmaking.phase_detail_label(MatchmakingState.CONNECTING).contains("Connecting"), "connecting explains the active transport step")
func test_queue_wait_copy_explains_progress_without_trusting_negative_input() -> void:
assert_eq(Matchmaking.queue_wait_detail_text(-4, -2), "Waiting 0s · looking for compatible players · revision 0", "negative metadata is clamped")
assert_true(Matchmaking.queue_wait_detail_text(10, 3).contains("skill and latency"), "mid-wait explains the compatibility search")
assert_true(Matchmaking.queue_wait_detail_text(30, 4).contains("keeping latency limits"), "long waits explain bounded widening")
func test_latency_copy_fails_closed_and_explains_quality_boundaries() -> void:
assert_eq(Matchmaking.latency_detail_text(-1.0), "Latency: measuring", "missing latency remains honest")
assert_eq(Matchmaking.latency_detail_text(INF), "Latency: measuring", "infinite latency fails closed")
assert_eq(Matchmaking.latency_detail_text(50.0), "Latency: 50ms · excellent", "excellent boundary is inclusive")
assert_eq(Matchmaking.latency_detail_text(100.0), "Latency: 100ms · good", "good boundary is inclusive")
assert_eq(Matchmaking.latency_detail_text(100.1), "Latency: 100ms · high", "high latency is surfaced")
+136
View File
@@ -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)]
)
+1
View File
@@ -0,0 +1 @@
uid://bs8c31fs0tfhe
+1 -1
View File
@@ -97,7 +97,7 @@ func test_snapshot_roundtrip_seven_bodies() -> void:
var packet := NetCodec.pack_snapshot(555, -2, 1234, segment)
assert_eq(packet.size(), NetCodec.SNAPSHOT_CLIENT_HEADER_SIZE + segment.size(), "full packet size")
assert_eq(packet.size(), 169, "matches multiplayer-todo.md §2.4's 169 B payload figure for 7 bodies")
assert_eq(packet.size(), 169, "matches MULTIPLAYER_SPEC.md §2.4's 169 B payload figure for 7 bodies")
var decoded := NetCodec.unpack_snapshot(packet)
assert_eq(decoded["last_input_seq"], 555, "last_input_seq")
@@ -99,6 +99,14 @@ func test_genuine_missing_history_is_still_a_hard_snap() -> void:
assert_eq(decision["mode"], "hard", "%s must still hard-correct" % status)
func test_warmup_ack_before_first_prediction_is_skipped() -> void:
var history := LocalPredictionHistory.new()
var authority := _authoritative()
var comparison := history.compare_authoritative(0, authority)
assert_eq(comparison["status"], "warmup_not_recorded", "pre-history acknowledgement is startup, not loss")
assert_eq(NetShipPredictor.decide(comparison, false, false)["mode"], "skip", "startup acknowledgement must not hard-snap")
func test_a_reset_still_wins_over_an_unsimulated_gap() -> void:
# Ordering guard: reset_gen is an epoch boundary and outranks everything,
# including the new skip path — otherwise a gap landing on the reset
@@ -0,0 +1 @@
uid://dtqbio4ob00hq
@@ -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
+13 -2
View File
@@ -71,19 +71,30 @@ func test_physics_engine_is_jolt() -> void:
func test_required_autoloads_are_registered() -> void:
# NetworkManager in particular is reached by name from many scripts; losing
# it from [autoload] fails only at the point of use, deep in a smoke test.
for autoload_name in ["GameSettings", "VideoSettings", "NetworkManager", "MatchNet", "MatchSim"]:
for autoload_name in ["GameSettings", "ControlPlaneClient", "VideoSettings", "InputSettings", "NetworkManager", "MatchNet", "MatchSim"]:
assert_true(
ProjectSettings.has_setting("autoload/" + autoload_name),
"autoload/%s registered" % autoload_name
)
func test_matchmaking_scene_is_the_control_plane_entry_point() -> void:
var scene := load("res://scenes/matchmaking.tscn")
assert_true(scene != null, "matchmaking scene exists")
assert_true(FileAccess.file_exists("res://scripts/matchmaking.gd"), "matchmaking controller exists")
func test_test_hook_autoloads_are_not_shipped() -> void:
# main_menu_test_hooks / lobby_test_hooks are added to [autoload] by hand
# when running those scene-level smoke tests, and must be removed again —
# 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 @@
uid://dvwivtlgx2f34
+60
View File
@@ -26,6 +26,7 @@ func test_defaults_apply_when_nothing_is_given() -> void:
assert_true(config.is_valid(), "an empty command line is valid")
assert_eq(config.get_value("port"), 7777, "default port")
assert_eq(config.get_value("max-matches"), 0, "0 means run forever")
assert_eq(config.get_value("max-overtime-seconds"), 900.0, "allocated sudden death has a finite safety cap")
assert_eq(config.get_value("log-level"), "info", "default log level")
@@ -98,8 +99,11 @@ func test_out_of_range_values_are_rejected_with_their_own_message() -> void:
assert_true(not _parse(["--port=70000"]).is_valid(), "port 70000 is out of range")
assert_true(not _parse(["--max-clients=0"]).is_valid(), "a server for nobody is rejected")
assert_true(not _parse(["--match-length=0"]).is_valid(), "a zero-length match is rejected")
assert_true(not _parse(["--max-overtime-seconds=0"]).is_valid(), "an unbounded allocated overtime cap is rejected")
assert_true(not _parse(["--log-level=chatty"]).is_valid(), "an undefined log level is rejected")
assert_true(not _parse(["--arena-rotation=spiral"]).is_valid(), "an undefined rotation mode is rejected")
assert_true(not _parse(["--arena-path=res://scenes/arena_01_elevated.tscn"]).is_valid(), "an elevated arena cannot be selected for allocated ranked play")
assert_true(_parse(["--arena-path=res://scenes/arena_01.tscn"]).is_valid(), "a ranked-eligible arena path is accepted")
assert_true(not _parse(["--smoke-force-goal-after=-2"]).is_valid(), "only -1 disables the deterministic smoke goal")
# Control: the same flags at legal values all pass together.
var ok = _parse(["--port=7000", "--max-clients=6", "--match-length=90", "--log-level=warn", "--arena-rotation=random"])
@@ -126,3 +130,59 @@ func test_help_is_requested_without_needing_a_valid_command_line() -> void:
assert_true(config.help_requested, "--help is recognised")
var short = _parse(["-h"])
assert_true(short.help_requested, "-h too")
func test_allocated_mode_is_opt_in_and_requires_compatibility_manifest() -> void:
var community = _parse([])
assert_true(community.is_valid(), "community defaults remain valid")
assert_eq(community.get_value("allocated-mode"), false, "allocation is opt-in")
var incomplete = _parse(["--allocated-mode", "--transport=enet"])
assert_true(not incomplete.is_valid(), "allocated mode cannot start without its manifest")
var valid = _parse([
"--allocated-mode", "--match-id=match_1234567890123456", "--server-id=server_1234567890123456",
"--playlist-version=2026-08-31", "--client-build=client-2026-08-31", "--assignment-expiry-unix=%d" % (Time.get_unix_time_from_system() + 3600), "--server-image-digest=sha256:" + "a".repeat(64),
"--playlist=casual", "--transport=enet", "--region=EU", "--join-authorisations-file=/run/secrets/join-authorisations.json", "--join-authorisations-key-file=/run/secrets/join-authorisations.key"
])
assert_true(valid.is_valid(), "a complete allocated compatibility manifest is accepted: %s" % str(valid.errors))
func test_allocated_mode_rejects_invalid_transport_region_or_digest() -> void:
var args := [
"--allocated-mode", "--match-id=m", "--server-id=s", "--playlist-version=v",
"--client-build=client", "--assignment-expiry-unix=%d" % (Time.get_unix_time_from_system() + 3600),
"--server-image-digest=sha256:" + "g".repeat(64), "--transport=udp", "--region=AP"
]
var config = _parse(args)
assert_true(not config.is_valid(), "invalid compatibility values are rejected")
var unsafe_id = _parse([
"--allocated-mode", "--match-id=short", "--server-id=server/unsafe", "--playlist-version=v",
"--client-build=client", "--assignment-expiry-unix=%d" % (Time.get_unix_time_from_system() + 3600),
"--server-image-digest=sha256:" + "a".repeat(64), "--playlist=casual", "--transport=enet", "--region=EU",
"--join-authorisations-file=/run/secrets/join-authorisations.json", "--join-authorisations-key-file=/run/secrets/join-authorisations.key"
])
assert_true(not unsafe_id.is_valid(), "short or unsafe allocated identifiers are rejected")
func test_allocated_mode_rejects_missing_or_expired_assignment_manifest_fields() -> void:
var missing = _parse(["--allocated-mode", "--match-id=m", "--server-id=s", "--playlist-version=v", "--server-image-digest=sha256:" + "a".repeat(64), "--transport=enet", "--region=EU"])
assert_true(not missing.is_valid(), "client build and expiry are required")
var expired = _parse(["--allocated-mode", "--match-id=m", "--server-id=s", "--playlist-version=v", "--client-build=client", "--assignment-expiry-unix=1", "--server-image-digest=sha256:" + "a".repeat(64), "--transport=enet", "--region=EU"])
assert_true(not expired.is_valid(), "expired assignment is rejected")
func test_allocated_start_floor_is_the_verified_roster_size() -> void:
var boot = preload("res://scripts/server_boot.gd")
assert_eq(boot.required_min_players(true, 6, 1), 6, "allocated six-player roster cannot start with one player")
assert_eq(boot.required_min_players(true, 2, 6), 2, "allocated casual roster uses its complete size")
assert_eq(boot.required_min_players(false, 1, 1), 1, "direct server keeps its configured floor")
func test_connection_reporting_requires_safe_workload_configuration() -> void:
var boot = preload("res://scripts/server_boot.gd")
assert_true(boot.valid_connection_report_configuration("http://control-plane:8080", "signed-token", "match-1234567890", "server-123456789", "player-123456789"), "allocated workload configuration is accepted")
assert_true(not boot.valid_connection_report_configuration("", "signed-token", "match-1234567890", "server-123456789", "player-123456789"), "allocated startup fails closed without a control-plane lease URL")
assert_true(not boot.valid_connection_report_configuration("http://control-plane:8080", "", "match-1234567890", "server-123456789", "player-123456789"), "allocated startup fails closed without a workload lease credential")
assert_true(not boot.valid_connection_report_configuration("http://control-plane:8080?token=leak", "signed-token", "match-1234567890", "server-123456789", "player-123456789"), "query-bearing control-plane URL is rejected")
assert_true(not boot.valid_connection_report_configuration("http://control-plane:8080", "token\nforged", "match-1234567890", "server-123456789", "player-123456789"), "header injection token is rejected")
assert_true(not boot.valid_connection_report_configuration("http://control-plane:8080", "signed-token", "short", "server-123456789", "player-123456789"), "non-opaque match identity is rejected")
assert_true(not boot.valid_connection_report_configuration("http://control-plane:8080", "signed-token", "match-1234567890", "server-123456789", "short"), "non-opaque player identity is rejected")
+21
View File
@@ -0,0 +1,21 @@
extends "res://tests/test_case.gd"
const ServerControlScript = preload("res://scripts/server_control.gd")
func test_control_rejects_invalid_port_and_starts_loopback_listener() -> void:
var control = ServerControlScript.new()
assert_eq(control.start(0), ERR_INVALID_PARAMETER, "control rejects port zero")
var port := 18000 + (Time.get_ticks_usec() % 1000)
assert_eq(control.start(port, "drain-secret"), OK, "control starts on a valid loopback port")
control.stop()
control.queue_free()
func test_process_ready_and_drain_state_are_monotonic() -> void:
var control = ServerControlScript.new()
assert_true(not control.is_draining(), "control starts non-draining")
control.set_process_ready(true)
assert_true(not control.is_draining(), "process readiness does not imply draining")
control.stop()
control.queue_free()
@@ -0,0 +1,19 @@
extends "res://tests/test_case.gd"
func test_allocated_initial_connect_policy_has_explicit_boundaries() -> void:
var loop = preload("res://scripts/server_match_loop.gd")
assert_eq(loop.allocated_initial_connect_action("ranked", 29999, 5, 6, true, true), loop.ALLOCATED_WAIT, "ranked waits before 30 seconds")
assert_eq(loop.allocated_initial_connect_action("ranked", 30000, 5, 6, true, true), loop.ALLOCATED_CANCEL, "ranked cancels at 30 seconds")
assert_eq(loop.allocated_initial_connect_action("casual", 59999, 2, 6, true, true), loop.ALLOCATED_WAIT, "casual waits before 60 seconds")
assert_eq(loop.allocated_initial_connect_action("casual", 60000, 2, 6, true, true), loop.ALLOCATED_START_WITH_BOTS, "casual starts with bots when both teams are represented")
assert_eq(loop.allocated_initial_connect_action("casual", 1000, 2, 2, true, true), loop.ALLOCATED_START_WITH_BOTS, "complete relaxed casual roster starts with disclosed bots immediately")
assert_eq(loop.allocated_initial_connect_action("casual", 1000, 2, 2, true, false), loop.ALLOCATED_CANCEL, "malformed relaxed casual roster fails closed")
assert_eq(loop.allocated_initial_connect_action("casual", 60000, 2, 6, true, false), loop.ALLOCATED_CANCEL, "casual cancels when one team is empty")
assert_eq(loop.allocated_initial_connect_action("casual", 1000, 6, 6, true, true), loop.ALLOCATED_READY, "complete roster is ready immediately")
assert_eq(loop.allocated_initial_connect_action("ranked", 1000, 5, 5, true, true), loop.ALLOCATED_CANCEL, "ranked cannot shrink its expected roster")
assert_eq(loop.allocated_initial_connect_action("other", 0, 1, 6, true, true), loop.ALLOCATED_CANCEL, "unknown allocated playlist fails closed")
var instance = loop.new()
instance.allocated_admission_armed = false
instance.arm_allocated_admission()
assert_true(instance.allocated_admission_armed, "durable readiness signal arms the local timeout")
instance.free()
@@ -0,0 +1,41 @@
extends "res://tests/test_case.gd"
const Client = preload("res://scripts/server_result_client.gd")
func test_result_nonce_is_deterministic_and_score_bound() -> void:
var first := Client.result_nonce("match-123456789", "server-123456789", 3, 2, "CERTIFIED")
assert_eq(first, Client.result_nonce("match-123456789", "server-123456789", 3, 2, "CERTIFIED"), "retry keeps the exact nonce")
assert_true(first != Client.result_nonce("match-123456789", "server-123456789", 2, 3, "CERTIFIED"), "a conflicting score cannot reuse the nonce")
assert_true(first.length() >= 16, "nonce satisfies the control-plane minimum")
func test_result_configuration_fails_closed() -> void:
assert_true(Client.valid_configuration("https://control.invalid", "token", "match-123456789", "server-123456789"), "valid result reporter configuration is accepted")
assert_true(not Client.valid_configuration("https://control.invalid?token=leak", "token", "match-123456789", "server-123456789"), "query-bearing endpoint is rejected")
assert_true(not Client.valid_configuration("https://control@evil.invalid", "token", "match-123456789", "server-123456789"), "userinfo-bearing endpoint is rejected")
assert_true(not Client.valid_configuration("https://control.invalid", "", "match-123456789", "server-123456789"), "empty bearer is rejected")
func test_only_a_committed_result_acknowledgement_releases_the_match() -> void:
assert_true(Client.response_is_accepted(202), "the endpoint's accepted response releases RESULTS")
assert_true(not Client.response_is_accepted(200), "an unexpected generic success cannot lose the result")
assert_true(not Client.response_is_accepted(422), "validation failure remains held for operator-visible retry")
assert_true(not Client.response_is_accepted(503), "outage remains held for retry")
func test_review_results_are_permitted_but_forged_states_are_not() -> void:
var client := Client.new()
assert_true(client.configure("https://control.invalid", "token", "match-123456789", "server-123456789"), "test client configures")
# submit itself is asynchronous; the pure configuration boundary proves the
# reporter can carry the REVIEW state selected by bounded overtime.
assert_true(Client.result_nonce("match-123456789", "server-123456789", 1, 1, "REVIEW") != Client.result_nonce("match-123456789", "server-123456789", 1, 1, "CERTIFIED"), "integrity state binds the receipt identity")
func test_match_net_forwards_review_integrity_to_the_reporter() -> void:
var received: Array = []
MatchNet.configure_result_submission(func(team_0: int, team_1: int, integrity: String): received.append_array([team_0, team_1, integrity]))
assert_true(MatchNet.submit_authoritative_result({0: 1, 1: 1}, "REVIEW"), "configured reporter accepts the bounded-overtime outcome")
assert_eq(received, [1, 1, "REVIEW"], "review state reaches the reporter and cannot become a rated result")
MatchNet.configure_result_submission(Callable())
assert_true(not MatchNet.submit_authoritative_result({0: 1, 1: 1}, "CERTIFIED"), "cleared reporter cannot silently claim result delivery")
+29
View File
@@ -0,0 +1,29 @@
extends "res://tests/test_case.gd"
const SteamBootstrap = preload("res://scripts/steam_bootstrap.gd")
# Web-API ticket acquisition (task 7.6). Nothing in the project could obtain a
# ticket before, so ControlPlaneClient.login_steam() had no production caller.
# These run on stock Godot, which has no GodotSteam symbols, so they cover the
# pure encoding and the unavailable path rather than a live Steam session.
func test_web_api_ticket_is_unsupported_without_the_steam_runtime() -> void:
if SteamBootstrap.is_runtime_available():
return
assert_true(not SteamBootstrap.supports_web_api_ticket(), "no ticket support without the custom build")
assert_eq(SteamBootstrap.request_web_api_ticket(), 0, "requesting a ticket yields no handle")
# Must not throw on stock Godot; cancelling a handle we never got is a no-op.
SteamBootstrap.cancel_web_api_ticket(0)
SteamBootstrap.cancel_web_api_ticket(17)
func test_web_api_ticket_encoding_is_lowercase_hex() -> void:
# The publisher Web API expects the raw ticket bytes hex encoded; the
# backend rejects anything non-hex before it forwards a ticket to Valve.
assert_eq(SteamBootstrap.encode_web_api_ticket(PackedByteArray()), "", "an empty ticket encodes to nothing")
assert_eq(SteamBootstrap.encode_web_api_ticket(PackedByteArray([0x00, 0x0f, 0xa5, 0xff])), "000fa5ff", "bytes are zero-padded lowercase hex")
var encoded := SteamBootstrap.encode_web_api_ticket(PackedByteArray([1, 2, 3, 4, 250]))
assert_eq(encoded.length(), 10, "each byte becomes exactly two characters")
assert_eq(encoded, encoded.to_lower(), "encoding is lowercase")
+18
View File
@@ -0,0 +1,18 @@
extends "res://tests/test_case.gd"
const ShipAIControllerScript = preload("res://scripts/ship_ai_controller.gd")
func test_team_touch_credit_is_split_across_teammates() -> void:
assert_eq(ShipAIControllerScript.team_touch_credit(0.8, 0.5, 2), 0.2, "half-weight touch is split between two teammates")
func test_team_touch_credit_never_exceeds_touch_payout() -> void:
var credit := ShipAIControllerScript.team_touch_credit(0.8, 1.0, 1)
assert_eq(credit, 0.8, "one teammate receives at most the touch payout")
func test_team_touch_credit_rejects_invalid_inputs() -> void:
assert_eq(ShipAIControllerScript.team_touch_credit(0.8, 0.0, 2), 0.0, "zero weight is disabled")
assert_eq(ShipAIControllerScript.team_touch_credit(0.8, 0.5, 0), 0.0, "no teammates receive no credit")
assert_eq(ShipAIControllerScript.team_touch_credit(-1.0, 0.5, 2), 0.0, "negative payout cannot mint reward")
func test_team_touch_credit_clamps_weight() -> void:
assert_eq(ShipAIControllerScript.team_touch_credit(0.8, 2.0, 2), 0.4, "weight above one is clamped")

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