880 Commits

Author SHA1 Message Date
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