diff --git a/multiplayer-next.md b/multiplayer-next.md index 9999a215..e3eada70 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1212,7 +1212,7 @@ production fallback. | 8.21 `[D:8.5,8.20]` | **IN PROGRESS.** Pure Go rating core implements canonical Glicko-2, daily inactivity, ranked 1/3 and casual 1/N human-opponent weights, deterministic opponent ordering, and authoritative draw/overtime/abandon scoring; certified result completion now applies per-player updates inside the durable transaction with lexical row locks and revision increments | `server/domain/rating.go`, `server/store/result_sql.go` and tests cover canonical/inactivity/weight/invalid-input, draw/OT/abandon, ordered participant snapshots, lock/value re-read and rating update SQL; live PostgreSQL rating and seasons execution now covered (§8.23), and concurrent result transaction cases are covered: `TestPostgreSQLConcurrentIdenticalResultSubmissionAppliesRatingsExactlyOnce` races 5 identical submissions and confirms one rating application, while `TestPostgreSQLConcurrentConflictingResultSubmissionsKeepOneReceipt` races different payloads and confirms exactly one winner, one conflict, one receipt and one completion event; live maintenance/DB execution remains | | 8.22 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked profile exposes the first ten games as provisional, derives tiers only through validated backend-owned rating bands, and keeps casual ratings outside the API; authenticated HTTP now returns the authoritative ranked view. **"Authoritative ranked view" was durable-adapter-shaped but had no durable adapter**: `rankedProfile`/`profile` only ever read an in-memory map, so every real `GET /v1/profile/ranked` 404'd regardless of a player's actual rating. `RankedProfileProvider` (interface) + `store.PostgresRankedProfiles` close it, preferred over the map when set so existing tests/literals are unaffected; `LastSeasonID`/`SeasonHistory` deliberately left unset (no season pointer on `ratings`, needs its own query/semantics) | `server/domain/rating.go`, `tier_test.go` and `server/api/service.go` cover provisional override, exact band boundaries, malformed policy rejection, session authentication and ranked-only response fields; `server/store/ranked_profile_sql.go`, verified against real PostgreSQL via curl (a fresh identity correctly 404s through the real adapter) and via §8.40's integration test (`control_plane_smoke.gd` now asserts this exact 404 round-trips before queueing); persisted tier policy, client UI and reconnect transport remain | | 8.23 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked-only season policy compresses 25% toward 1500, clamps RD to 200–350, preserves volatility/history, is idempotent by season ID, and defines exact 12-week windows/due detection; migration and Go store now persist a per-player/per-season marker and rating update atomically; `cmd/maintenance` runs bounded due-season batches with signal-bound shutdown | `server/domain/rating.go`, `season_test.go`, `server/migrations/0001_initial.sql` and `server/store/maintenance_sql.go` cover compression, floor/cap, duplicate replay, window boundary, completed-season idempotence, bounded enumeration, row locking and conflict-safe rollover markers; opt-in PostgreSQL execution now covers the durable rating update, marker creation and duplicate replay without a second compression — re-run live for the first time as part of the wider integration-suite verification below, after fixing a test setup gap (a missing `seasons` row tripped the `ranked_season_rollovers` foreign key before the rollover logic itself ran); live maintenance/DB execution remains | -| 8.24 `[D:8.9,8.20,8.21]` | **IN PROGRESS.** Ranked connection policy binds match/server/player/Steam identity/team/global slot/protocol/expiry, permits a 60-second same-token reclaim with monotonically increasing server-owned generations, and applies the rolling 7-day 5 m/15 m/1 h/24 h abandon ladder. Admission rejects active duplicates, zero/reversed clocks, disconnect-before-admit, and duplicate disconnects. PostgreSQL persists generation/disconnect leases with serializable CAS: stale disconnects cannot evict newer generations, active leases cannot be reclaimed, initial admission requires an unexpired assignment, and later reconnects use the durable grace boundary. Godot consumes that lease before admission, reconciles known-generation outage events in order, closes future admissions on reconciliation divergence, and rejects unsafe generation-zero outage fallback. Maintenance now turns a ranked `LIVE` lease expired beyond 60 seconds into `abandoned_at`, a durable `MATCH_ABANDONED` cooldown, and a revisioned outbox event without releasing the participant or `LIVE` ticket needed by the result transaction | Go/store/API/Godot adversarial fixtures cover signature tampering, every binding, replay/conflict semantics, active duplicate admission, repeated valid reclaim, stale-generation fencing, grace boundaries, process recovery, expiry, zero/reversed clocks, malformed JSON generations, deterministic cooldown ordering, legacy-row migration, rolling-upgrade 204 compatibility, result-roster retention, and cursor-pool safety. The 207-test Godot harness, focused Go suites, and PostgreSQL-tagged abandonment regression compile. Live PostgreSQL/process-restart/outage execution remains blocked by Docker storage | +| 8.24 `[D:8.9,8.20,8.21]` | **IN PROGRESS.** Ranked connection policy binds match/server/player/Steam identity/team/global slot/protocol/expiry, permits a 60-second same-token reclaim with monotonically increasing server-owned generations, and applies the rolling 7-day 5 m/15 m/1 h/24 h abandon ladder. Admission rejects active duplicates, zero/reversed clocks, disconnect-before-admit, and duplicate disconnects. PostgreSQL persists generation/disconnect leases with serializable CAS: stale disconnects cannot evict newer generations, active leases cannot be reclaimed, initial admission requires an unexpired assignment, and later reconnects use the durable grace boundary. Godot consumes that lease before admission, reconciles known-generation outage events in order, closes future admissions on reconciliation divergence, and rejects unsafe generation-zero outage fallback. Maintenance turns a ranked `LIVE` lease expired beyond 60 seconds into `abandoned_at`, a durable `MATCH_ABANDONED` cooldown, and a revisioned, targeted `state_changed` outbox event without releasing the participant or `LIVE` ticket needed by the result transaction | Go/store/API/Godot adversarial fixtures cover signature tampering, every binding, replay/conflict semantics, active duplicate admission, repeated valid reclaim, stale-generation fencing, grace boundaries, process recovery, expiry, zero/reversed clocks, malformed JSON generations, deterministic cooldown ordering, legacy-row migration, rolling-upgrade 204 compatibility, result-roster retention, outbox dispatch compatibility, and cursor-pool safety. The 207-test Godot harness, focused Go suites, and PostgreSQL-tagged abandonment regression compile. Live PostgreSQL/process-restart/outage execution remains blocked by Docker storage | | 8.25 `[D:8.10,8.24]` | **IN PROGRESS.** Workload-bound result policy validates match/server identity, nonce, scores, integrity state, canonical digest, and idempotent receipt conflicts. Result ingestion now owns the complete serializable production transition: a `LIVE` match and its active tickets advance through `RESULT_PENDING`, certified ratings are computed from locked authoritative participant rows, then match/tickets become `COMPLETED`, the receipt is acknowledged, and one revisioned outbox event is inserted. Inactive pre-match no-shows are excluded; an active participant's durable `abandoned_at` forces loss scoring. Missing rating rows or ticket-count divergence fail the whole transaction. Zero-time/incomplete receipts fail before database use and conflicts wrap `ErrResultConflict` | Domain/store/API/outbox tests cover workload and digest binding, identical/conflicting concurrency, direct `LIVE` completion, active-ticket completion, abandonment rating input, incomplete roster failure, integrity suppression, ordered rating locks, receipt/outbox atomicity, fan-out retry/ack ordering, and delivery health. PostgreSQL-tagged regressions compile; prior live result/rating/race/fan-out runs remain valid, while the direct-live lifecycle change awaits a live database rerun. Production credentials, Agones annotation persistence/reconciliation, and integrity-evidence adapters remain | #### 8D — Agones, allocation and regional scaling diff --git a/server/api/outbox_test.go b/server/api/outbox_test.go index 0ae840b1..a8d00fda 100644 --- a/server/api/outbox_test.go +++ b/server/api/outbox_test.go @@ -80,3 +80,26 @@ func TestDeliverStateOutboxEventValidatesRevisionAndTargets(t *testing.T) { t.Fatal("revision-mismatched state event accepted") } } + +func TestDeliverStateOutboxEventRoutesLiveAbandonmentLifecycle(t *testing.T) { + service := &Service{} + first := service.getEventHub().subscribe("player-a") + second := service.getEventHub().subscribe("player-b") + defer service.getEventHub().unsubscribe(first) + defer service.getEventHub().unsubscribe(second) + payload := []byte(`{"event":"state_changed","revision":9,"resource_id":"match_1234567890","occurred_at":"1970-01-01T00:16:40Z","state":"LIVE","match_id":"match_1234567890","player_ids":["player-a","player-b"],"abandoned_player_ids":["player-a"]}`) + event := store.OutboxEvent{EventType: "state_changed", AggregateID: "match_1234567890", Revision: 9, Payload: payload} + if err := deliverStateOutboxEvent(context.Background(), event, service); err != nil { + t.Fatalf("live abandonment event rejected: %v", err) + } + for playerID, subscriber := range map[string]*eventSubscriber{"player-a": first, "player-b": second} { + select { + case packet := <-subscriber.queue: + if !json.Valid(packet) { + t.Fatalf("%s received malformed lifecycle packet %q", playerID, packet) + } + case <-time.After(time.Second): + t.Fatalf("%s did not receive live-abandonment lifecycle event", playerID) + } + } +} diff --git a/server/store/live_abandonment_sql.go b/server/store/live_abandonment_sql.go index 21680564..089b2ff4 100644 --- a/server/store/live_abandonment_sql.go +++ b/server/store/live_abandonment_sql.go @@ -53,9 +53,14 @@ SET revision = revision + 1 WHERE match_id = $1 AND state = 'LIVE' RETURNING revision` +const liveAbandonmentTargetsSQL = `SELECT player_id +FROM match_participants +WHERE match_id = $1 AND participation_active +ORDER BY player_id` + const liveAbandonmentOutboxSQL = `INSERT INTO outbox (event_id, aggregate_type, aggregate_id, revision, event_type, payload) -VALUES ($1, 'match', $2, $3, 'participant_abandoned', $4)` +VALUES ($1, 'match', $2, $3, 'state_changed', $4)` // ReconcileLiveAbandonments applies a bounded, durable reconnect-grace sweep. // It does not deactivate participants or alter LIVE tickets: an abandonment @@ -148,7 +153,18 @@ func ApplyLiveAbandonments(ctx context.Context, db *sql.DB, matchID string, now if err := tx.QueryRowContext(ctx, liveAbandonmentRevisionSQL, matchID).Scan(&revision); err != nil { return err } - payload, err := json.Marshal(map[string]any{"match_id": matchID, "abandoned_player_ids": abandonmentIDs(planned)}) + targets, err := loadLiveAbandonmentTargets(ctx, tx, matchID) + if err != nil { + return err + } + if len(targets) == 0 { + return fmt.Errorf("%w: live match has no active event targets", domain.ErrConflict) + } + payload, err := json.Marshal(map[string]any{ + "event": "state_changed", "revision": revision, "resource_id": matchID, + "occurred_at": now, "state": domain.Live, "match_id": matchID, + "player_ids": targets, "abandoned_player_ids": abandonmentIDs(planned), + }) if err != nil { return err } @@ -204,6 +220,23 @@ func loadLiveAbandonmentHistory(ctx context.Context, tx *sql.Tx, participants [] return history, nil } +func loadLiveAbandonmentTargets(ctx context.Context, tx *sql.Tx, matchID string) ([]string, error) { + rows, err := tx.QueryContext(ctx, liveAbandonmentTargetsSQL, matchID) + if err != nil { + return nil, err + } + defer rows.Close() + var players []string + for rows.Next() { + var playerID string + if err := rows.Scan(&playerID); err != nil { + return nil, err + } + players = append(players, playerID) + } + return players, rows.Err() +} + func abandonmentIDs(abandonments []domain.Abandonment) []string { ids := make([]string, len(abandonments)) for i := range abandonments { diff --git a/server/store/live_abandonment_sql_test.go b/server/store/live_abandonment_sql_test.go index b40f3f80..b9025e16 100644 --- a/server/store/live_abandonment_sql_test.go +++ b/server/store/live_abandonment_sql_test.go @@ -13,7 +13,8 @@ func TestLiveAbandonmentSQLPreservesResultRosterAndReconnectFences(t *testing.T) liveAbandonmentParticipantSQL: {"SET abandoned_at", "participation_active", "abandoned_at IS NULL", "RETURNING"}, liveAbandonmentPenaltySQL: {"MATCH_ABANDONED", "ON CONFLICT"}, liveAbandonmentRevisionSQL: {"state = 'LIVE'", "revision = revision + 1"}, - liveAbandonmentOutboxSQL: {"participant_abandoned", "revision"}, + liveAbandonmentOutboxSQL: {"state_changed", "revision"}, + liveAbandonmentTargetsSQL: {"participation_active", "ORDER BY player_id"}, } { for _, fragment := range fragments { if !strings.Contains(query, fragment) { diff --git a/server/store/postgres_integration_test.go b/server/store/postgres_integration_test.go index b07935e5..d19972cb 100644 --- a/server/store/postgres_integration_test.go +++ b/server/store/postgres_integration_test.go @@ -689,7 +689,7 @@ func TestPostgreSQLLiveReconnectGraceExpiryPersistsAbandonmentWithoutReleasingRe t.Fatalf("penalty ends=%v err=%v", endsAt, err) } var outboxCount, revision int - if err := db.QueryRowContext(ctx, `SELECT count(*) FROM outbox WHERE aggregate_id = 'live-abandon-match' AND event_type = 'participant_abandoned'`).Scan(&outboxCount); err != nil || outboxCount != 1 { + if err := db.QueryRowContext(ctx, `SELECT count(*) FROM outbox WHERE aggregate_id = 'live-abandon-match' AND event_type = 'state_changed'`).Scan(&outboxCount); err != nil || outboxCount != 1 { t.Fatalf("outbox=%d err=%v", outboxCount, err) } if err := db.QueryRowContext(ctx, `SELECT revision FROM matches WHERE match_id = 'live-abandon-match'`).Scan(&revision); err != nil || revision != 1 {