From 950e8798613624f4e94bf91a59ee9a4f4d24ebd3 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:41:44 +0100 Subject: [PATCH] fix(multiplayer): bind allocation to accepted proposal --- multiplayer-next.md | 2 ++ server/allocator/service.go | 2 +- server/allocator/service_test.go | 30 ++++++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/multiplayer-next.md b/multiplayer-next.md index 03bfe01c..daa19f48 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1459,6 +1459,8 @@ The production ranked-profile adapter now projects the active ranked season ID f Allocator quota accounting now charges only fresh provider attempts; recovery of a provider result after an ambiguous durable write does not consume the same regional quota a second time. +Accepted-proposal allocation now binds the request back to the proposal’s playlist, arena, region, and protocol before any provider call; adversarial mismatches fail closed. + Ranked proposal admission no longer trusts the matcher’s `--ranked-random-arena` boolean. The Go domain now owns a named allowlist for the three floor-goal `ArenaRegistry` entries, and rejects unknown and elevated IDs before any proposal is created. The arena hand-off is now durable: the matcher deterministically selects an eligible floor-goal arena from the proposal ID, migration 0008 stores that path on proposals and matches and enforces it for new direct SQL writes, migration 0009 retains it on provider allocations, domain/store/provider boundaries and recovery lookups recheck the same allowlist, allocation claims and idempotency digests retain it, Agones applies it as a match-scoped annotation, and the supervisor overlays the allocated child’s `--arena-path`. Godot accepts only the same floor-goal `ArenaRegistry` paths and requires one for allocated ranked matches, so a stale Fleet default, an elevated variant, or an altered retry cannot substitute a ranked arena. The recovery worker also rejects a provider-recovered allocation whose arena differs from the durable request before recording or binding it. diff --git a/server/allocator/service.go b/server/allocator/service.go index b2d0bbfa..6ac88276 100644 --- a/server/allocator/service.go +++ b/server/allocator/service.go @@ -56,7 +56,7 @@ type Service struct { // from allocating capacity for an OPEN/DECLINED proposal or for a request // whose playlist does not match the proposal that produced it. func (s Service) AllocateAcceptedProposal(ctx context.Context, proposal domain.Proposal, request domain.AllocationRequest, playlist domain.Playlist, labels map[string]string) (agones.AllocatedServer, error) { - if proposal.State != domain.Accepted || proposal.Playlist != playlist || len(proposal.Participants) == 0 { + if proposal.State != domain.Accepted || proposal.Playlist != playlist || len(proposal.Participants) == 0 || (request.Playlist != "" && request.Playlist != playlist) || (proposal.Region != "" && request.Region != proposal.Region) || (proposal.Protocol > 0 && request.Protocol != proposal.Protocol) || request.ArenaPath != proposal.ArenaPath { return agones.AllocatedServer{}, domain.ErrAllocationInput } if proposal.Playlist == domain.Ranked && len(proposal.Participants) != 6 { diff --git a/server/allocator/service_test.go b/server/allocator/service_test.go index 50ee0e87..60f546b2 100644 --- a/server/allocator/service_test.go +++ b/server/allocator/service_test.go @@ -166,6 +166,36 @@ func TestServiceAllocatesOnlyUnanimouslyAcceptedMatchingProposal(t *testing.T) { } } +func TestServiceRejectsAllocationRequestThatDoesNotMatchAcceptedProposal(t *testing.T) { + proposal := domain.Proposal{ + ProposalID: "proposal-ranked", Playlist: domain.Ranked, State: domain.Accepted, + Region: "EU", Protocol: 1, ArenaPath: "res://scenes/arena_01.tscn", + Participants: []domain.ProposalParticipant{ + {PlayerID: "player-a", Response: domain.AcceptedResponse}, {PlayerID: "player-b", Response: domain.AcceptedResponse}, + {PlayerID: "player-c", Response: domain.AcceptedResponse}, {PlayerID: "player-d", Response: domain.AcceptedResponse}, + {PlayerID: "player-e", Response: domain.AcceptedResponse}, {PlayerID: "player-f", Response: domain.AcceptedResponse}, + }, + } + provider := &providerSpy{} + service := Service{Provider: provider, Durable: &durableSpy{}, Now: func() time.Time { return time.Unix(1000, 0) }} + request := domain.AllocationRequest{AllocationID: "a", MatchID: "m", Playlist: domain.Ranked, Region: "EU", Build: "b", Protocol: 1, ArenaPath: proposal.ArenaPath, Transport: "enet"} + for name, mutate := range map[string]func(*domain.AllocationRequest){ + "playlist": func(r *domain.AllocationRequest) { r.Playlist = domain.Casual }, + "region": func(r *domain.AllocationRequest) { r.Region = "NA" }, + "protocol": func(r *domain.AllocationRequest) { r.Protocol = 2 }, + "arena": func(r *domain.AllocationRequest) { r.ArenaPath = "res://scenes/arena_02.tscn" }, + } { + candidate := request + mutate(&candidate) + if _, err := service.AllocateAcceptedProposal(context.Background(), proposal, candidate, domain.Ranked, map[string]string{"region": "EU"}); err == nil { + t.Fatalf("%s mismatch was accepted", name) + } + } + if provider.calls != 0 { + t.Fatalf("provider calls=%d, want 0", provider.calls) + } +} + func TestServicePublishesRosterOnlyForAllocatedAssignment(t *testing.T) { roster := &rosterSpy{} service := Service{Roster: roster}