diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 6255f9f2..716fbced 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1197,7 +1197,7 @@ the local/CI/community transport, not a silent production fallback. | 8.17 `[D:8.14,8.16]` | **IN PROGRESS.** Pure Go proposal policy sends a 10-second response window to every selected human, requires unanimous acceptance, applies exact decline/timeout cooldowns and ranked escalation | `server/domain/proposal.go` and adversarial fixtures cover partial/unanimous response, expiry, replay/conflict and ranked six-player/cooldown rules; casual 6→2 composition, queue precedence and allocation integration remain | | 8.18 `[D:8.5,8.14,8.17]` | **IN PROGRESS.** Go store layer defines PostgreSQL SERIALIZABLE whole-transaction retries and queue candidate/proposal claim SQL using `FOR UPDATE SKIP LOCKED` plus durable uniqueness/revision fences | `server/store/serializable.go` and tests cover retry classification and claim-boundary invariants; live PostgreSQL adapter/row decoding, Redis candidate index/repair, worker-failure and concurrent two-matcher integration tests remain | | 8.19 `[D:8.18]` | **IN PROGRESS.** Pure Go casual lineup requires 2–6 humans with at least one per team, fills missing slots with explicit bots, permits kickoff-only bot-slot backfill and assigns no backfill penalty/rating update | `server/domain/casual.go` covers both-team minimum, bot shape, live-play rejection and zero-penalty backfill; queue candidate selection, opt-in 10 s backfill proposals, reconnect/leave penalties and live integration remain | -| 8.20 `[D:8.18]` | Ranked policy: exactly six verified solo humans, no bots/backfill, only `ArenaRegistry.random`; define initial no-show, proposal timeout and reconnect/abandon transitions | Ranked rejects parties/bots/backfill/elevated arenas; every pre-live failure returns five innocent players with original precedence and applies no rating | +| 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 | `server/domain/ranked.go` covers count, identity, party, bot/backfill and arena eligibility rejection; `ArenaRegistry` integration, proposal/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, and deterministic opponent ordering | `server/domain/rating.go` has canonical/inactivity/weight/invalid-input fixtures; PostgreSQL snapshot locking, draws/OT/abandons, 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 and keeps casual ratings outside the API | `RankedIsProvisional` covers the 0–9/10 boundary; authoritative tier derivation and UI remain | | 8.23 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked-only season rollover compresses 25% toward 1500, clamps RD to 200–350, preserves volatility/history and is idempotent by season ID | `ApplySeasonRollover` covers compression, floor/cap and duplicate replay; PostgreSQL transaction locking and 12-week scheduler remain | diff --git a/server/domain/ranked.go b/server/domain/ranked.go new file mode 100644 index 00000000..c0ae29b5 --- /dev/null +++ b/server/domain/ranked.go @@ -0,0 +1,32 @@ +package domain + +import "fmt" + +type RankedParticipant struct { + PlayerID string + SteamID string + PartyID string + IsBot bool + IsBackfill bool +} + +type RankedArena struct { + RandomEnabled bool + ElevatedGoals bool +} + +func ValidateRankedAdmission(participants []RankedParticipant, arena RankedArena) error { + if len(participants) != 6 || !arena.RandomEnabled || arena.ElevatedGoals { + return fmt.Errorf("ranked admission requirements not met") + } + seenPlayers := make(map[string]bool, len(participants)) + seenSteam := make(map[string]bool, len(participants)) + for _, participant := range participants { + if participant.PlayerID == "" || participant.SteamID == "" || participant.PartyID != "" || participant.IsBot || participant.IsBackfill || seenPlayers[participant.PlayerID] || seenSteam[participant.SteamID] { + return fmt.Errorf("ranked requires six unique verified solo humans") + } + seenPlayers[participant.PlayerID] = true + seenSteam[participant.SteamID] = true + } + return nil +} diff --git a/server/domain/ranked_test.go b/server/domain/ranked_test.go new file mode 100644 index 00000000..0f483122 --- /dev/null +++ b/server/domain/ranked_test.go @@ -0,0 +1,39 @@ +package domain + +import "testing" + +func rankedParticipants() []RankedParticipant { + result := make([]RankedParticipant, 6) + for i := range result { + result[i] = RankedParticipant{PlayerID: string(rune('a' + i)), SteamID: string(rune('A' + i))} + } + return result +} + +func TestRankedAdmissionRequiresSixUniqueVerifiedSoloHumansAndEligibleArena(t *testing.T) { + if err := ValidateRankedAdmission(rankedParticipants(), RankedArena{RandomEnabled: true}); err != nil { + t.Fatal(err) + } + cases := []struct { + name string + edit func([]RankedParticipant, *RankedArena) + }{ + {"five players", func(p []RankedParticipant, _ *RankedArena) { p[5].PlayerID = "" }}, + {"party", func(p []RankedParticipant, _ *RankedArena) { p[0].PartyID = "party-1" }}, + {"bot", func(p []RankedParticipant, _ *RankedArena) { p[0].IsBot = true }}, + {"backfill", func(p []RankedParticipant, _ *RankedArena) { p[0].IsBackfill = true }}, + {"duplicate identity", func(p []RankedParticipant, _ *RankedArena) { p[1].SteamID = p[0].SteamID }}, + {"random disabled", func(_ []RankedParticipant, a *RankedArena) { a.RandomEnabled = false }}, + {"elevated arena", func(_ []RankedParticipant, a *RankedArena) { a.ElevatedGoals = true }}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + participants := rankedParticipants() + arena := RankedArena{RandomEnabled: true} + test.edit(participants, &arena) + if err := ValidateRankedAdmission(participants, arena); err == nil { + t.Fatal("invalid ranked admission accepted") + } + }) + } +}