Commit Graph

248 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 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 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 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 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 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 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 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 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 61daf139c5 fix(multiplayer): fence workload control URLs 2026-09-03 21:31:09 +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 a4de140424 fix(multiplayer): fail closed without durable leases 2026-09-03 21:04:07 +01:00
Josh Creek aac81c89b6 feat(multiplayer): bind admissions to durable leases 2026-09-03 13:45:39 +01:00
Josh Creek 8c28374eb4 fix(multiplayer): harden reconnect lifecycle fencing 2026-09-03 13:24:52 +01:00
Josh Creek aa446cfbfe fix(multiplayer): reconcile authoritative initial connections 2026-09-03 00:02:04 +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 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 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 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 b15d19eec2 feat(multiplayer): explain queue and connection health 2026-09-01 23:11:37 +01:00
Josh Creek bfaf5d40ff fix(multiplayer): validate timestamp calendar 2026-09-01 23:02:31 +01:00
Josh Creek f7ab77dec5 fix(multiplayer): validate queue response contract 2026-09-01 22:56:33 +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 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