diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 716fbced..ec74c02e 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1191,7 +1191,7 @@ the local/CI/community transport, not a silent production fallback. | # | Task | Acceptance | |---|---|---| -| 8.14 `[D:8.4,8.5,8.8]` | **IN PROGRESS.** Pure Go queue domain enforces one active ticket per verified player, 10 s heartbeat/30 s expiry, retry-safe create/heartbeat/cancel and deterministic candidate projection | `server/domain/queue.go` has adversarial ownership/expiry/idempotency tests; PostgreSQL transaction adapter, Redis candidate index and cache-loss repair remain | +| 8.14 `[D:8.4,8.5,8.8]` | **IN PROGRESS.** Pure Go queue domain enforces one active ticket per verified player, 10 s heartbeat/30 s expiry, retry-safe create/heartbeat/cancel and deterministic candidate projection; store layer adds a rebuildable candidate-cache boundary | `server/domain/queue.go` and `server/store/candidates.go` cover ownership/expiry/idempotency, deterministic projection, cache loss and atomic rebuild; PostgreSQL row adapter, real Redis index/TTLs and restart/failover integration remain | | 8.15 `[D:7.8,8.3]` | **IN PROGRESS.** Pure Go probe validation treats Steam location as opaque, requires nonce/freshness/region and server-computed RTT, and implements discrepancy quarantine/release | `server/domain/probes.go` and adversarial fixtures cover stale/wrong/forged evidence, the 25 ms/30% threshold, three-sample quarantine and five-clean release; Steam coordinator and regional probe adapters remain | | 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 | diff --git a/server/store/candidates.go b/server/store/candidates.go new file mode 100644 index 00000000..f98ea1e2 --- /dev/null +++ b/server/store/candidates.go @@ -0,0 +1,75 @@ +package store + +import ( + "fmt" + "sort" + "sync" + "time" + + "github.com/cosmic-clash/cosmic-clash/server/domain" +) + +// CandidateCache is intentionally rebuildable. A real Redis implementation +// can satisfy this interface, but no cache operation is an ownership fence. +type CandidateCache struct { + mu sync.RWMutex + candidates map[string]domain.Candidate +} + +func NewCandidateCache() *CandidateCache { + return &CandidateCache{candidates: make(map[string]domain.Candidate)} +} + +func (c *CandidateCache) Upsert(candidate domain.Candidate) error { + if candidate.TicketID == "" || candidate.PlayerID == "" || candidate.EnqueuedAt.IsZero() { + return fmt.Errorf("invalid candidate") + } + c.mu.Lock() + c.candidates[candidate.TicketID] = candidate + c.mu.Unlock() + return nil +} + +func (c *CandidateCache) Remove(ticketID string) { + c.mu.Lock() + delete(c.candidates, ticketID) + c.mu.Unlock() +} + +func (c *CandidateCache) Snapshot(now time.Time) []domain.Candidate { + c.mu.RLock() + result := make([]domain.Candidate, 0, len(c.candidates)) + for _, candidate := range c.candidates { + if !candidate.EnqueuedAt.After(now) { + result = append(result, candidate) + } + } + c.mu.RUnlock() + sort.Slice(result, func(i, j int) bool { + if !result[i].EnqueuedAt.Equal(result[j].EnqueuedAt) { + return result[i].EnqueuedAt.Before(result[j].EnqueuedAt) + } + return result[i].TicketID < result[j].TicketID + }) + return result +} + +// Rebuild replaces the cache atomically with the authoritative queue view. +// Callers should invoke this after Redis restart, failover, or a cache miss; +// the supplied candidates must already have passed durable queue checks. +func (c *CandidateCache) Rebuild(candidates []domain.Candidate) error { + rebuilt := make(map[string]domain.Candidate, len(candidates)) + for _, candidate := range candidates { + if candidate.TicketID == "" || candidate.PlayerID == "" || candidate.EnqueuedAt.IsZero() { + return fmt.Errorf("invalid candidate in rebuild") + } + if _, exists := rebuilt[candidate.TicketID]; exists { + return fmt.Errorf("duplicate candidate in rebuild") + } + rebuilt[candidate.TicketID] = candidate + } + c.mu.Lock() + c.candidates = rebuilt + c.mu.Unlock() + return nil +} diff --git a/server/store/candidates_test.go b/server/store/candidates_test.go new file mode 100644 index 00000000..9191aeb3 --- /dev/null +++ b/server/store/candidates_test.go @@ -0,0 +1,39 @@ +package store + +import ( + "testing" + "time" + + "github.com/cosmic-clash/cosmic-clash/server/domain" +) + +func TestCandidateCacheRebuildRepairsLossAndKeepsDeterministicOrder(t *testing.T) { + now := time.Unix(1000, 0) + cache := NewCandidateCache() + candidates := []domain.Candidate{{TicketID: "ticket-b", PlayerID: "player-b", EnqueuedAt: now}, {TicketID: "ticket-a", PlayerID: "player-a", EnqueuedAt: now}} + if err := cache.Rebuild(candidates); err != nil { + t.Fatal(err) + } + cache.Remove("ticket-a") + if got := cache.Snapshot(now); len(got) != 1 || got[0].TicketID != "ticket-b" { + t.Fatalf("stale cache snapshot = %+v", got) + } + if err := cache.Rebuild(candidates); err != nil { + t.Fatal(err) + } + got := cache.Snapshot(now) + if len(got) != 2 || got[0].TicketID != "ticket-a" || got[1].TicketID != "ticket-b" { + t.Fatalf("repaired order = %+v", got) + } +} + +func TestCandidateCacheRejectsInvalidOrDuplicateDurableProjection(t *testing.T) { + cache := NewCandidateCache() + if err := cache.Upsert(domain.Candidate{TicketID: "", PlayerID: "p", EnqueuedAt: time.Unix(1000, 0)}); err == nil { + t.Fatal("invalid candidate accepted") + } + candidate := domain.Candidate{TicketID: "ticket-a", PlayerID: "player-a", EnqueuedAt: time.Unix(1000, 0)} + if err := cache.Rebuild([]domain.Candidate{candidate, candidate}); err == nil { + t.Fatal("duplicate candidate accepted") + } +}