diff --git a/multiplayer-next.md b/multiplayer-next.md index d348a292..7aef3d9f 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -103,6 +103,8 @@ product policy are in [`docs/MATCHMAKING.md`](docs/MATCHMAKING.md). persisted tier configuration remain. - [ ] **IN PROGRESS:** Add ranked-only exactly-once 12-week soft seasons; distinguish retryable result-delivery outages from match-integrity failures and rating exemptions. + A durable per-player/per-season rollover marker and SERIALIZABLE rating update + boundary now exist; live scheduler/DB execution remains. ## Phase 8 — Agones and regional server capacity diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 1d143f73..947661d6 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1200,7 +1200,7 @@ the local/CI/community transport, not a silent production fallback. | 8.20 `[D:8.18]` | **IN PROGRESS.** Pure Go ranked admission requires six unique verified solo humans, rejects bots/backfill/parties, and allows only random-enabled non-elevated arenas; proposal preparation requires matching metadata for every formed player | `server/domain/ranked.go`, `formation.go` cover count, identity, party, bot/backfill, arena eligibility and formed-player metadata rejection; `ArenaRegistry` integration, allocation wiring and innocent-ticket restoration remain | | 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 | `server/domain/rating.go` has canonical/inactivity/weight/invalid-input plus draw/OT/abandon fixtures; PostgreSQL snapshot locking, rating transaction integration, seasons and concurrent result transaction tests remain | | 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 | `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; 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 | `server/domain/rating.go` and `season_test.go` cover compression, floor/cap, duplicate replay, window boundary and completed-season idempotence; PostgreSQL locking, persisted rollover transaction and maintenance scheduler 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 | `server/domain/rating.go`, `season_test.go`, `server/migrations/0001_initial.sql` and `server/store/season_sql.go` cover compression, floor/cap, duplicate replay, window boundary, completed-season idempotence, row locking and conflict-safe rollover markers; live PostgreSQL execution and maintenance scheduler remain | | 8.24 `[D:8.9,8.20,8.21]` | **IN PROGRESS.** Pure Go ranked connection policy binds match/server/player/team/slot/protocol, supports 60 s reclaim with server-owned generations, fences old connections, and applies the rolling 7-day 5 m/15 m/1 h/24 h abandon ladder | `server/domain/reconnect.go` covers repeated backend-independent reclaim, binding rejection, old-generation fencing, grace boundary and deterministic cooldown audit ordering; signed authorisations, persistent lease fencing, join transport and full match/result integration remain | | 8.25 `[D:8.10,8.24]` | **IN PROGRESS.** Pure Go result policy binds match/server/workload identity, hashes canonical payloads, makes identical retries idempotent, leaves conflicts inert, separates integrity eligibility, classifies roster/simulation/result/fairness evidence, validates annotation signatures/digests, and exposes 5 m alert/30 m review delivery thresholds; Go store SQL defines conflict-safe receipt insert, deterministic match/rating locks and atomic completion/outbox boundaries | `server/domain/result.go`, `server/domain/workload.go` and `server/store/result_sql.go` plus adversarial fixtures cover credential binding, duplicate/conflict, annotation forgery, delivery-outage-versus-integrity classification, commit and lock ordering; production credential verification, Agones annotation persistence/reconciliation, live PostgreSQL execution and integrity evidence adapters remain | diff --git a/server/migrations/0001_initial.sql b/server/migrations/0001_initial.sql index 42f7b80e..49ae52e0 100644 --- a/server/migrations/0001_initial.sql +++ b/server/migrations/0001_initial.sql @@ -112,6 +112,17 @@ CREATE TABLE seasons ( rolled_over_at TIMESTAMPTZ ); +CREATE TABLE ranked_season_rollovers ( + player_id TEXT NOT NULL REFERENCES identities(player_id), + season_id TEXT NOT NULL REFERENCES seasons(season_id), + rating DOUBLE PRECISION NOT NULL, + deviation DOUBLE PRECISION NOT NULL, + volatility DOUBLE PRECISION NOT NULL, + ranked_games INTEGER NOT NULL CHECK (ranked_games >= 0), + rolled_over_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (player_id, season_id) +); + CREATE TABLE penalties ( penalty_id TEXT PRIMARY KEY, player_id TEXT NOT NULL REFERENCES identities(player_id), diff --git a/server/migrations/test_migration.py b/server/migrations/test_migration.py index d50bad9b..f1b74574 100644 --- a/server/migrations/test_migration.py +++ b/server/migrations/test_migration.py @@ -38,6 +38,8 @@ class MigrationTest(unittest.TestCase): def test_seasons_are_ranked_only_and_penalties_are_durable(self): self.assertIn("CHECK (playlist = 'ranked')", SQL) self.assertIn("CREATE TABLE penalties", SQL) + self.assertIn("CREATE TABLE ranked_season_rollovers", SQL) + self.assertIn("PRIMARY KEY (player_id, season_id)", SQL) self.assertIn("REFERENCES identities(player_id)", SQL) self.assertIn("REFERENCES matches(match_id)", SQL) diff --git a/server/store/season_sql.go b/server/store/season_sql.go new file mode 100644 index 00000000..21c6fd3f --- /dev/null +++ b/server/store/season_sql.go @@ -0,0 +1,64 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/cosmic-clash/cosmic-clash/server/domain" +) + +const SeasonRatingLockSQL = `SELECT player_id, rating, deviation, volatility, ranked_games, revision +FROM ratings +WHERE player_id = $1 +FOR UPDATE` + +const SeasonRolloverInsertSQL = `INSERT INTO ranked_season_rollovers + (player_id, season_id, rating, deviation, volatility, ranked_games, rolled_over_at) +VALUES ($1, $2, $3, $4, $5, $6, $7) +ON CONFLICT (player_id, season_id) DO NOTHING` + +const SeasonRatingUpdateSQL = `UPDATE ratings +SET rating = $2, deviation = $3, volatility = $4, revision = revision + 1, updated_at = $5 +WHERE player_id = $1` + +// ApplyRankedSeasonRollover persists the domain rollover exactly once. The +// marker insert and rating update share one SERIALIZABLE transaction, so a +// retry after a worker failure cannot apply compression twice or leave a +// marker without its corresponding rating snapshot. +func ApplyRankedSeasonRollover(ctx context.Context, db *sql.DB, playerID, seasonID string, profile domain.RankedProfile, now time.Time) (domain.RankedProfile, bool, error) { + if playerID == "" { + return domain.RankedProfile{}, false, fmt.Errorf("player ID is required") + } + updated, _, err := domain.ApplySeasonRollover(profile, seasonID) + if err != nil { + return domain.RankedProfile{}, false, err + } + applied := false + err = RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, SeasonRatingLockSQL, playerID); err != nil { + return err + } + result, err := tx.ExecContext(ctx, SeasonRolloverInsertSQL, playerID, seasonID, updated.Value, updated.RD, updated.Volatility, updated.RankedGames, now) + if err != nil { + return err + } + changed, err := result.RowsAffected() + if err != nil { + return err + } + if changed == 0 { + return nil + } + if _, err := tx.ExecContext(ctx, SeasonRatingUpdateSQL, playerID, updated.Value, updated.RD, updated.Volatility, now); err != nil { + return err + } + applied = true + return nil + }) + if err != nil { + return domain.RankedProfile{}, false, err + } + return updated, applied, nil +} diff --git a/server/store/serializable_test.go b/server/store/serializable_test.go index 91813b03..d2621306 100644 --- a/server/store/serializable_test.go +++ b/server/store/serializable_test.go @@ -19,7 +19,7 @@ func TestRetryableRecognisesPostgresSerializationAndDeadlockErrors(t *testing.T) } func TestClaimSQLContainsDurableOwnershipFences(t *testing.T) { - for _, fragment := range []string{"FOR UPDATE SKIP LOCKED", "state = 'QUEUED'", "proposal_participants", "revision = revision + 1", "INSERT INTO proposals"} { + for _, fragment := range []string{"FOR UPDATE SKIP LOCKED", "state = 'QUEUED'", "proposal_participants", "revision = revision + 1", "INSERT INTO proposals", "ranked_season_rollovers", "ON CONFLICT (player_id, season_id) DO NOTHING"} { if !containsAnySQL(fragment) { t.Fatalf("claim boundary missing %q", fragment) } @@ -27,7 +27,7 @@ func TestClaimSQLContainsDurableOwnershipFences(t *testing.T) { } func containsAnySQL(fragment string) bool { - return index(CandidateClaimSQL, fragment) >= 0 || index(ProposalParticipantInsertSQL, fragment) >= 0 || index(QueueTicketProposeSQL, fragment) >= 0 || index(ProposalInsertSQL, fragment) >= 0 + return index(CandidateClaimSQL, fragment) >= 0 || index(ProposalParticipantInsertSQL, fragment) >= 0 || index(QueueTicketProposeSQL, fragment) >= 0 || index(ProposalInsertSQL, fragment) >= 0 || index(SeasonRolloverInsertSQL, fragment) >= 0 } func index(s, fragment string) int {