diff --git a/multiplayer-todo.md b/multiplayer-todo.md index da435ffd..4fab8dc4 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1240,7 +1240,7 @@ the local/CI/community transport, not a silent production fallback. | 8.45 `[D:8.2,8.44]` | **IN PROGRESS.** Go observability package turns the documented RTT, allocation/connect latency, result-success, API-latency and tick/headroom thresholds into executable window checks | `server/observability/slo.go` covers healthy/violating/empty windows; production metrics export, dashboards, alert routing, wait/MMR/proposal/flood/cost series and runbooks remain | | 8.46 `[D:8.5,8.7,8.9,8.10,8.14,8.18,8.21,8.23,8.25]` | **IN PROGRESS.** Go unit/race coverage spans the current domain/store/supervisor policies, and fuzz targets now exercise queue input, result payload hashing and revision events | `server/domain/*_test.go`, `server/store/*_test.go`, `server/supervisor/*_test.go` and `server/domain/fuzz_test.go` pass normal/race suites; `go test -fuzz`, PostgreSQL concurrency/migration execution, fake Steam/allocator and full lost-Redis/transaction fixtures remain | | 8.47 `[D:8.7,8.30]` | **IN PROGRESS.** Offline testkit provides deterministic fake Steam verification and fake allocation with forced failure injection | `server/testkit/` covers verified identity/replay and cloud-free forced allocation failure; API/Compose integration and exhaustive success/failure matrix remain | -| 8.48 `[D:8.10,8.14,8.17,8.18,8.27,8.31,8.35,8.47]` | Second Compose flow: fake backend → queue/proposal → process-ready/allocation/assignment-ready → ENet roster → result ack → shutdown; do not edit Phase 6 fixture | Both server models have independent green gates; existing Make invocations remain unchanged | +| 8.48 `[D:8.10,8.14,8.17,8.18,8.27,8.31,8.35,8.47]` | **IN PROGRESS.** Offline testkit exercises verified queue projection → ranked six-player proposal → ENet allocation → assignment-ready manifest → certified durable result receipt | `server/testkit/pipeline_test.go` covers the cross-domain success path without Steam/cloud secrets; independent Compose fixture, process shutdown, result ack over HTTP and legacy fixture non-regression remain | | 8.49 `[D:8.25,8.26,8.28,8.29,8.30,8.31,8.35,8.36]` | Disposable `kind` + Agones integration gate | CI covers dynamic ports, both readiness stages, roster/no-show, races, multi-match node, result-pending reconciliation, drain and rollback | | 8.50 `[D:8.25,8.37,8.43,8.49]` | Network/chaos suite: 100 ms RTT, jitter/loss, client/API/matcher restart, game-pod death, node drain, Redis failover and control-plane loss | System recovers to a defined state; infrastructure-caused cases cannot penalise affected players | | 8.51 `[D:8.17,8.18,8.30,8.31,8.45]` | Load test >=10,000 queued clients, >=100 proposals/s and forecast launch concurrency x2 | API p95 <=250 ms, durable matcher fence holds, both readiness/allocation SLOs are met and replicas scale without duplicate claims | diff --git a/server/domain/proposal.go b/server/domain/proposal.go index 5ad102d8..81d4a182 100644 --- a/server/domain/proposal.go +++ b/server/domain/proposal.go @@ -147,7 +147,15 @@ func (p *Proposal) allAccepted() bool { func (p *Proposal) copy() Proposal { clone := *p clone.Participants = append([]ProposalParticipant(nil), p.Participants...) - clone.idempotent = nil + if p.idempotent != nil { + clone.idempotent = make(map[string]proposalMutation, len(p.idempotent)) + for key, mutation := range p.idempotent { + prior := mutation.proposal + prior.Participants = append([]ProposalParticipant(nil), prior.Participants...) + prior.idempotent = nil + clone.idempotent[key] = proposalMutation{digest: mutation.digest, proposal: prior} + } + } return clone } diff --git a/server/domain/proposal_test.go b/server/domain/proposal_test.go index dd40578c..8edbf40f 100644 --- a/server/domain/proposal_test.go +++ b/server/domain/proposal_test.go @@ -45,6 +45,23 @@ func TestProposalResponseReplayIsStableAndPayloadReuseConflicts(t *testing.T) { } } +func TestReturnedProposalRetainsIdempotencyStateForChainedResponses(t *testing.T) { + now := time.Unix(1000, 0) + p, err := NewProposal("proposal-123456789", Ranked, []string{"a", "b", "c", "d", "e", "f"}, now) + if err != nil { + t.Fatal(err) + } + for _, playerID := range []string{"a", "b", "c", "d", "e", "f"} { + p, err = p.Respond(playerID, "accept-"+playerID+"-123456", true, p.Revision, now) + if err != nil { + t.Fatal(err) + } + } + if p.State != Accepted || p.Revision != 6 { + t.Fatalf("chained responses = %+v", p) + } +} + func TestProposalExpiryTimesOutPendingParticipantsAndClosesRace(t *testing.T) { now := time.Unix(1000, 0) p, err := NewProposal("proposal-123456789", Ranked, []string{"a", "b", "c", "d", "e", "f"}, now) diff --git a/server/testkit/pipeline_test.go b/server/testkit/pipeline_test.go new file mode 100644 index 00000000..e8e45940 --- /dev/null +++ b/server/testkit/pipeline_test.go @@ -0,0 +1,69 @@ +package testkit + +import ( + "testing" + "time" + + "github.com/cosmic-clash/cosmic-clash/server/domain" +) + +func TestOfflineMatchmakingPipelineReachesDurableResult(t *testing.T) { + now := time.Unix(1000, 0) + queue := domain.NewQueue() + for i := 0; i < 6; i++ { + playerID := string(rune('a' + i)) + ticketID := "ticket-" + playerID + candidate := domain.Candidate{TicketID: ticketID, PlayerID: playerID, Rating: 1500 + float64(i), EnqueuedAt: now, PredictedRTT: map[string]float64{"EU": 30}} + if _, err := queue.Create(playerID, ticketID, "create-key-"+playerID+"-123456", candidate, now); err != nil { + t.Fatal(err) + } + } + candidates := queue.Candidates(now) + selection, err := domain.SelectCandidates(candidates[0], candidates[1:], 6, now) + if err != nil || len(selection.Players) != 6 || selection.Region != "EU" { + t.Fatalf("selection = %+v err=%v", selection, err) + } + + playerIDs := make([]string, 0, len(selection.Players)) + for _, candidate := range selection.Players { + playerIDs = append(playerIDs, candidate.PlayerID) + } + proposal, err := domain.NewProposal("proposal-1234567890123456", domain.Ranked, playerIDs, now) + if err != nil { + t.Fatal(err) + } + for _, participant := range proposal.Participants { + proposal, err = proposal.Respond(participant.PlayerID, "accept-"+participant.PlayerID+"-123456", true, proposal.Revision, now) + if err != nil { + t.Fatal(err) + } + } + if proposal.State != domain.Accepted { + t.Fatalf("proposal did not accept: %+v", proposal) + } + + allocator, err := domain.NewAllocator([]domain.ReadyServer{{ServerID: "server-1", Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet", State: domain.ServerReady}}) + if err != nil { + t.Fatal(err) + } + allocation, err := allocator.Allocate(domain.AllocationRequest{AllocationID: "allocation-1234567890123456", MatchID: "match-1234567890123456", Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet"}, now) + if err != nil { + t.Fatal(err) + } + manifest := domain.AllocationManifest{AllocationID: allocation.AllocationID, MatchID: allocation.MatchID, ServerID: allocation.ServerID, Region: allocation.Region, Build: allocation.Build, Protocol: allocation.Protocol, Transport: allocation.Transport, RosterDigest: "roster-digest"} + digest := domain.ManifestDigest(manifest) + assignment, err := domain.VerifyAssignment(allocation, manifest, "127.0.0.1:31001", digest[:], func(_, signature []byte) bool { return string(signature) == string(digest[:]) }) + if err != nil || assignment.Endpoint == "" { + t.Fatalf("assignment = %+v err=%v", assignment, err) + } + + store, err := domain.NewResultStore(domain.WorkloadBinding{Issuer: "issuer", Audience: "audience", Namespace: "games", ServiceAcct: "match-server", PodUID: "pod-1", GameServerUID: "gs-1", MatchID: allocation.MatchID, ServerID: allocation.ServerID}) + if err != nil { + t.Fatal(err) + } + result := domain.MatchResult{MatchID: allocation.MatchID, ServerID: allocation.ServerID, ResultNonce: "result-nonce-123456", Team0Score: 3, Team1Score: 2, IntegrityState: domain.IntegrityCertified} + receipt, created, err := store.Submit("result-1234567890123456", result, domain.WorkloadBinding{Issuer: "issuer", Audience: "audience", Namespace: "games", ServiceAcct: "match-server", PodUID: "pod-1", GameServerUID: "gs-1", MatchID: allocation.MatchID, ServerID: allocation.ServerID}, now) + if err != nil || !created || !domain.RatingEligible(receipt) { + t.Fatalf("receipt = %+v created=%v err=%v", receipt, created, err) + } +}