diff --git a/multiplayer-next.md b/multiplayer-next.md index fd475e0b..dac1fba7 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1460,7 +1460,7 @@ Observability redaction now adds content-aware protection on top of denylisted f ### Current local completion index (2026-09-01) -The following Phase 8 slices have local implementation and verification evidence in this document: 8.29 dynamic allocated launch flags and endpoint handling; 8.30 allocator claim/reconciliation; 8.31 signed assignment/roster validation; 8.35 initial-connect no-show and casual bot policy; 8.36 controlled drain and shutdown acknowledgment; 8.39–8.43 client state, assignment, profile, recovery, and idempotent action retry; 8.44 structured observability and content-aware redaction; 8.45 bounded API metrics export plus optional Prometheus scrape/alert rules; 8.46 normal/race/vet/fuzz coverage; and 8.47–8.48 offline/testkit/Compose coverage. Their remaining acceptance text is infrastructure or production dependent where explicitly noted below the corresponding row. +The following Phase 8 slices have local implementation and verification evidence in this document: 8.29 dynamic allocated launch flags and endpoint handling; 8.30 allocator claim/reconciliation including provider-outcome recovery fencing; 8.31 signed assignment/roster validation; 8.35 initial-connect no-show and casual bot policy; 8.36 controlled drain and shutdown acknowledgment; 8.39–8.43 client state, assignment, profile, recovery, and idempotent action retry; 8.44 structured observability and content-aware redaction; 8.45 bounded API metrics export plus optional Prometheus scrape/alert rules; 8.46 normal/race/vet/fuzz coverage; and 8.47–8.48 offline/testkit/Compose coverage. Their remaining acceptance text is infrastructure or production dependent where explicitly noted below the corresponding row. The following are not locally certifiable from this workspace and remain open prerequisites rather than silently “done”: Valve/GodotSteam credentials and hosted SDR (7.1–7.8), live PostgreSQL/Redis execution where Docker is unavailable, live Agones/kind lifecycle (8.30–8.38, 8.49), public-network chaos/load/cost/release gates (8.50–8.53), and real-hardware graphics profiling (0.15b onward). `make verify-kind-agones` is the committed runner for 8.49; it requires a running Docker daemon plus kind, kubectl, and Helm. `TODO.md`’s AI-training and presentation tasks remain separate from multiplayer and are not marked by this index. diff --git a/server/allocator/worker.go b/server/allocator/worker.go index 291f4a01..4f1248eb 100644 --- a/server/allocator/worker.go +++ b/server/allocator/worker.go @@ -6,6 +6,7 @@ import ( "strconv" "time" + "github.com/cosmic-clash/cosmic-clash/server/agones" "github.com/cosmic-clash/cosmic-clash/server/domain" ) @@ -49,6 +50,9 @@ func (w Worker) RunOnce(ctx context.Context) (bool, error) { return true, fmt.Errorf("recover provider allocation for match %s: %w", request.MatchID, err) } if found { + if err := validateRecoveredAllocation(request, recovered); err != nil { + return true, fmt.Errorf("recovered provider allocation for match %s: %w", request.MatchID, err) + } if _, err := w.Service.RecordProviderAllocation(ctx, recovered, w.Now()); err != nil { return true, fmt.Errorf("record recovered allocation for match %s: %w", request.MatchID, err) } @@ -74,6 +78,14 @@ func (w Worker) RunOnce(ctx context.Context) (bool, error) { return true, nil } +func validateRecoveredAllocation(request domain.AllocationRequest, result agones.AllocatedServer) error { + allocation := result.Allocation + if result.Endpoint == "" || allocation.State != domain.ServerAllocated || allocation.AllocationID != request.AllocationID || allocation.MatchID != request.MatchID || allocation.ServerID == "" || allocation.Region != request.Region || allocation.Build != request.Build || allocation.Protocol != request.Protocol || allocation.Transport != request.Transport { + return fmt.Errorf("recovered allocation does not match request") + } + return nil +} + // AllocationLabels are the compatibility selectors shared with the Fleet // template. They are derived only from the durable match plan, never client // input or mutable worker configuration. diff --git a/server/allocator/worker_test.go b/server/allocator/worker_test.go index 2b35e10b..eab873ba 100644 --- a/server/allocator/worker_test.go +++ b/server/allocator/worker_test.go @@ -93,6 +93,18 @@ func TestWorkerRecoversProviderAllocationBeforeIssuingSecondAllocation(t *testin } } +func TestWorkerRejectsRecoveredAllocationForDifferentCompatibility(t *testing.T) { + request := domain.AllocationRequest{AllocationID: "allocation-1", MatchID: "match-1", Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet"} + claims := &matchClaimSpy{request: request, found: true} + provider := &recoverableProviderSpy{recovered: agones.AllocatedServer{Allocation: domain.Allocation{AllocationID: request.AllocationID, MatchID: request.MatchID, ServerID: "server-1", Region: "NA", Build: request.Build, Protocol: request.Protocol, Transport: request.Transport, State: domain.ServerAllocated}, Endpoint: "127.0.0.1:31001"}, found: true} + durable := &durableSpy{} + worker := Worker{Claims: claims, Service: Service{Provider: provider, Durable: durable, Now: func() time.Time { return time.Unix(1_000, 0) }}, Now: func() time.Time { return time.Unix(1_000, 0) }} + processed, err := worker.RunOnce(context.Background()) + if err == nil || !processed || durable.calls != 0 || claims.bound != (domain.Allocation{}) { + t.Fatalf("processed=%t err=%v durable_calls=%d bound=%+v", processed, err, durable.calls, claims.bound) + } +} + func TestWorkerDoesNothingWhenNoDurableMatchIsAvailable(t *testing.T) { claims := &matchClaimSpy{} worker := Worker{Claims: claims, Now: func() time.Time { return time.Unix(1_000, 0) }}