diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 80f37707..bbd0384a 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1196,7 +1196,7 @@ the local/CI/community transport, not a silent production fallback. | 8.16 `[D:8.14,8.15]` | **IN PROGRESS.** Pure Go candidate/team selection implements the <=100 ms ceiling, pairwise widening tolerance, anchor inclusion, deterministic set/region scoring and balanced team partitioning | `server/domain/matcher.go`, `teams.go` and adversarial fixtures cover no-common-region, tolerance boundaries, lexical ties, mean-rating balance and input-order independence; queue-backed candidate loading and full population fixtures remain | | 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]` | Casual policy: proposal composition above; >=1 human/team, exhaustive rating-balanced teams, bots after 60 s, opt-in kickoff-only backfill, 30 s reconnect and defined backfill/casual penalties | Every 2–6-human shape is tested; no mid-play replacement; declined/backfill participant gets no excluded rating/cooldown; original leaver gets only documented outcome/cooldown | +| 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.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 | diff --git a/server/domain/casual.go b/server/domain/casual.go new file mode 100644 index 00000000..eaf9d8c1 --- /dev/null +++ b/server/domain/casual.go @@ -0,0 +1,59 @@ +package domain + +import ( + "fmt" + "time" +) + +type CasualPhase string + +const ( + CasualKickoff CasualPhase = "KICKOFF" + CasualLive CasualPhase = "LIVE" +) + +type CasualSlot struct { + Slot int + Team int + PlayerID string + IsBot bool +} + +// BuildCasualLineup freezes the live six-slot shape. Missing humans become +// explicit server bots; no human is inserted after live play begins by this +// function. +func BuildCasualLineup(participants []ConnectParticipant) ([]CasualSlot, error) { + if len(participants) < 2 || len(participants) > 6 { + return nil, fmt.Errorf("casual lineup needs 2 through 6 humans") + } + seen := make(map[string]bool, len(participants)) + teamHuman := map[int]bool{} + lineup := make([]CasualSlot, 6) + usedSlots := make(map[int]bool) + for i, participant := range participants { + if participant.PlayerID == "" || participant.Team < 0 || participant.Team > 1 || seen[participant.PlayerID] || usedSlots[i] { + return nil, fmt.Errorf("invalid casual participant") + } + seen[participant.PlayerID] = true + usedSlots[i] = true + teamHuman[participant.Team] = true + lineup[i] = CasualSlot{Slot: i, Team: participant.Team, PlayerID: participant.PlayerID} + } + if !teamHuman[0] || !teamHuman[1] { + return nil, fmt.Errorf("casual lineup requires one human on each team") + } + for i := range lineup { + if lineup[i].PlayerID == "" { + lineup[i] = CasualSlot{Slot: i, Team: i % 2, PlayerID: fmt.Sprintf("bot-slot-%d", i), IsBot: true} + } + } + return lineup, nil +} + +func CanCasualBackfill(phase CasualPhase, slot CasualSlot) bool { + return phase == CasualKickoff && slot.IsBot +} + +// CasualBackfillPenalty is intentionally zero: a kickoff-only backfill does +// not receive a hidden-rating update or an abandon/decline cooldown. +func CasualBackfillPenalty() time.Duration { return 0 } diff --git a/server/domain/casual_test.go b/server/domain/casual_test.go new file mode 100644 index 00000000..64505aa3 --- /dev/null +++ b/server/domain/casual_test.go @@ -0,0 +1,23 @@ +package domain + +import "testing" + +func TestCasualLineupUsesBotsOnlyForMissingSlotsAndRequiresBothTeams(t *testing.T) { + lineup, err := BuildCasualLineup([]ConnectParticipant{{PlayerID: "p2", Team: 1}, {PlayerID: "p1", Team: 0}}) + if err != nil || len(lineup) != 6 || lineup[0].IsBot || lineup[1].IsBot || !lineup[2].IsBot || lineup[2].Team != 0 { + t.Fatalf("casual lineup = %+v err=%v", lineup, err) + } + if _, err := BuildCasualLineup([]ConnectParticipant{{PlayerID: "p1", Team: 0}, {PlayerID: "p2", Team: 0}}); err == nil { + t.Fatal("lineup without a human on team 1 was accepted") + } +} + +func TestCasualBackfillIsKickoffOnlyAndUnrated(t *testing.T) { + slot := CasualSlot{Slot: 2, Team: 0, PlayerID: "bot-slot-2", IsBot: true} + if !CanCasualBackfill(CasualKickoff, slot) || CanCasualBackfill(CasualLive, slot) || CasualBackfillPenalty() != 0 { + t.Fatal("casual backfill policy is incorrect") + } + if CanCasualBackfill(CasualKickoff, CasualSlot{Slot: 2, Team: 0, PlayerID: "human", IsBot: false}) { + t.Fatal("human slot was treated as backfillable") + } +}