diff --git a/multiplayer-todo.md b/multiplayer-todo.md index ecdf3f07..9b2d1269 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 under concurrent mutation, 10 s heartbeat/30 s expiry, retry-safe create/heartbeat/cancel, owner-only recovery reads and deterministic candidate projection; store layer adds a rebuildable candidate-cache boundary and authenticated HTTP queue adapter with playlist/build/protocol compatibility metadata | `server/domain/queue.go`, `server/store/candidates.go`, `server/store/queue_sql.go` and `server/api/service.go` cover ownership/expiry/idempotency, concurrent create fencing, candidate/player ownership binding, owner/revision-scoped SQL heartbeat/cancel/recovery, expired recovery as a terminal error, server-owned candidate resolution, strict compatibility metadata and cache loss/atomic rebuild; live PostgreSQL row execution, real Redis index/TTLs and restart/failover integration remain | +| 8.14 `[D:8.4,8.5,8.8]` | **IN PROGRESS.** Pure Go queue domain enforces one active ticket per verified player under concurrent mutation, 10 s heartbeat/30 s expiry, retry-safe create/heartbeat/cancel, owner-only recovery reads and deterministic candidate projection; store layer adds a rebuildable candidate-cache boundary and authenticated HTTP queue adapter with playlist/build/protocol compatibility metadata | `server/domain/queue.go`, `server/store/candidates.go`, `server/store/queue_sql.go` and `server/api/service.go` cover ownership/expiry/idempotency, concurrent create fencing, candidate/player ownership binding, owner/revision-scoped SQL heartbeat/cancel/recovery, authoritative queue-to-cache rebuild, expired recovery as a terminal error, server-owned candidate resolution, strict compatibility metadata and cache loss/atomic rebuild; live PostgreSQL row execution, 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; authenticated HTTP now accepts only opaque location/nonce input through a server-owned probe provider | `server/domain/probes.go`, adversarial fixtures and `server/api/service.go` cover stale/wrong/forged evidence, the 25 ms/30% threshold, three-sample quarantine, five-clean release, authenticated provider arguments and rejection of client RTT fields; 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; queue-backed formation now consumes the server-owned projection, fences duplicate player identities and rejects playlist/build/protocol mixing | `server/domain/matcher.go`, `teams.go` and adversarial fixtures cover no-common-region, tolerance boundaries, lexical ties, mean-rating balance, malformed candidates, duplicate identities, compatibility mismatches and queue-backed oldest-anchor formation; full population fixtures and durable matcher claim integration 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; authenticated API exposes revisioned accept/decline mutations; formed matches now pass through a playlist-aware proposal boundary | `server/domain/proposal.go`, `formation.go` and `server/api/service.go` plus adversarial fixtures cover partial/unanimous response, expiry, replay/conflict, stale API revision, casual lineup preparation and ranked metadata validation; queue precedence and allocation integration remain | diff --git a/server/store/candidates.go b/server/store/candidates.go index f98ea1e2..b0520eef 100644 --- a/server/store/candidates.go +++ b/server/store/candidates.go @@ -20,6 +20,17 @@ func NewCandidateCache() *CandidateCache { return &CandidateCache{candidates: make(map[string]domain.Candidate)} } +// RebuildFromQueue is the safe restart/failover path for the cache. Queue +// expiry and state filtering happen at the authoritative source before the +// cache is atomically replaced; callers never have to reconstruct those +// rules from a stale Redis index. +func RebuildFromQueue(cache *CandidateCache, queue *domain.Queue, now time.Time) error { + if cache == nil || queue == nil || now.IsZero() { + return fmt.Errorf("invalid candidate rebuild arguments") + } + return cache.Rebuild(queue.Candidates(now)) +} + func (c *CandidateCache) Upsert(candidate domain.Candidate) error { if candidate.TicketID == "" || candidate.PlayerID == "" || candidate.EnqueuedAt.IsZero() { return fmt.Errorf("invalid candidate") diff --git a/server/store/candidates_test.go b/server/store/candidates_test.go index 9191aeb3..990299c7 100644 --- a/server/store/candidates_test.go +++ b/server/store/candidates_test.go @@ -37,3 +37,30 @@ func TestCandidateCacheRejectsInvalidOrDuplicateDurableProjection(t *testing.T) t.Fatal("duplicate candidate accepted") } } + +func TestRebuildFromQueueUsesAuthoritativeExpiryAndState(t *testing.T) { + now := time.Unix(1000, 0) + queue := domain.NewQueue() + active := domain.Candidate{TicketID: "ticket-active", PlayerID: "player-active", EnqueuedAt: now} + stale := domain.Candidate{TicketID: "ticket-stale", PlayerID: "player-stale", EnqueuedAt: now} + if _, err := queue.Create(active.PlayerID, active.TicketID, "create-active-123456", active, now); err != nil { + t.Fatal(err) + } + if _, err := queue.Create(stale.PlayerID, stale.TicketID, "create-stale-123456", stale, now); err != nil { + t.Fatal(err) + } + if _, err := queue.Cancel(stale.PlayerID, stale.TicketID, "cancel-stale-123456", 0, now); err != nil { + t.Fatal(err) + } + cache := NewCandidateCache() + if err := cache.Upsert(domain.Candidate{TicketID: "obsolete", PlayerID: "obsolete", EnqueuedAt: now}); err != nil { + t.Fatal(err) + } + if err := RebuildFromQueue(cache, queue, now); err != nil { + t.Fatal(err) + } + got := cache.Snapshot(now) + if len(got) != 1 || got[0].TicketID != active.TicketID { + t.Fatalf("authoritative rebuild = %+v", got) + } +}