From 9ae01ecc5a74372f4d6bf52666cdbed05229090e Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:00:37 +0100 Subject: [PATCH] fix(multiplayer): propagate allocated playlist --- multiplayer-next.md | 2 ++ server/agones/allocation.go | 3 +++ server/allocator/worker.go | 6 +++++- server/allocator/worker_test.go | 7 +++++++ server/domain/allocator.go | 1 + server/store/allocation_match_sql.go | 8 ++++---- server/store/allocation_match_sql_test.go | 2 +- server/supervisor/supervisor.go | 1 + 8 files changed, 24 insertions(+), 6 deletions(-) diff --git a/multiplayer-next.md b/multiplayer-next.md index 3ab79fac..98bfcb12 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1436,3 +1436,5 @@ The workload-authenticated `POST /servers/{serverId}/shutdown` contract is now e The allocated supervisor now calls that shutdown acknowledgment during signal-bound controlled drain, using the same workload credential and a deterministic idempotency key after the local drain request succeeds. The lifecycle test verifies the drain-before-ack ordering, credential separation, and bounded graceful child exit; live pod termination and control-plane outage behavior remain deployment gates. Allocator-selected region, build, protocol, and transport now travel with the allocation as Agones annotations and override stale child launch flags immediately before an allocated process starts. The overlay rejects control characters and preserves direct-server command behavior; focused supervisor/allocator tests cover precedence and annotation payloads, while live Agones passthrough remains an infrastructure gate. + +The same allocation path now carries the matcher-selected playlist, preventing a ranked match from inheriting the Fleet’s casual default. Durable allocation claims return the playlist, the worker includes it in Fleet selection metadata, Agones copies it to the allocated GameServer, and the supervisor overrides `--playlist` before launch; the existing compatibility tests remain green. diff --git a/server/agones/allocation.go b/server/agones/allocation.go index bfb0cbd5..3dcab86a 100644 --- a/server/agones/allocation.go +++ b/server/agones/allocation.go @@ -177,6 +177,9 @@ func (c Client) Allocate(ctx context.Context, request domain.AllocationRequest, "cosmic-clash.io/protocol": strconv.Itoa(request.Protocol), "cosmic-clash.io/transport": request.Transport, } + if playlist := labels["cosmic-clash.io/playlist"]; playlist == string(domain.Casual) || playlist == string(domain.Ranked) { + body.Spec.Metadata.Annotations["cosmic-clash.io/playlist"] = playlist + } if len(c.WorkloadSecret) > 0 { ttl := c.WorkloadTokenTTL if ttl <= 0 { diff --git a/server/allocator/worker.go b/server/allocator/worker.go index bf86de21..3cca20da 100644 --- a/server/allocator/worker.go +++ b/server/allocator/worker.go @@ -59,10 +59,14 @@ func (w Worker) RunOnce(ctx context.Context) (bool, error) { // template. They are derived only from the durable match plan, never client // input or mutable worker configuration. func AllocationLabels(request domain.AllocationRequest) map[string]string { - return map[string]string{ + labels := map[string]string{ "cosmic-clash.io/region": request.Region, "cosmic-clash.io/build": request.Build, "cosmic-clash.io/protocol": strconv.Itoa(request.Protocol), "cosmic-clash.io/transport": request.Transport, } + if request.Playlist != "" { + labels["cosmic-clash.io/playlist"] = string(request.Playlist) + } + return labels } diff --git a/server/allocator/worker_test.go b/server/allocator/worker_test.go index 1c35251a..45c400ad 100644 --- a/server/allocator/worker_test.go +++ b/server/allocator/worker_test.go @@ -85,3 +85,10 @@ func TestAllocationLabelsMirrorFleetCompatibilityTuple(t *testing.T) { t.Fatalf("labels=%v want=%v", got, want) } } + +func TestAllocationLabelsCarryPlaylistWhenKnown(t *testing.T) { + labels := AllocationLabels(domain.AllocationRequest{Playlist: domain.Ranked, Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet"}) + if labels["cosmic-clash.io/playlist"] != string(domain.Ranked) { + t.Fatalf("playlist label = %q, want %q", labels["cosmic-clash.io/playlist"], domain.Ranked) + } +} diff --git a/server/domain/allocator.go b/server/domain/allocator.go index 78f92f08..6c64d1bb 100644 --- a/server/domain/allocator.go +++ b/server/domain/allocator.go @@ -27,6 +27,7 @@ type ReadyServer struct { type AllocationRequest struct { AllocationID string MatchID string + Playlist Playlist Region string Build string Protocol int diff --git a/server/store/allocation_match_sql.go b/server/store/allocation_match_sql.go index f24b931f..937eb199 100644 --- a/server/store/allocation_match_sql.go +++ b/server/store/allocation_match_sql.go @@ -30,7 +30,7 @@ UPDATE matches m SET allocation_id = 'allocation-' || candidate.match_id, allocation_claimed_at = $2 FROM candidate WHERE m.match_id = candidate.match_id -RETURNING m.match_id, m.region, m.protocol_version, m.allocation_id` +RETURNING m.match_id, m.playlist, m.region, m.protocol_version, m.allocation_id` const AllocatingMatchBuildSQL = `SELECT client_build FROM queue_tickets q @@ -200,10 +200,10 @@ func ClaimAllocatingMatch(ctx context.Context, db *sql.DB, transport string, now var item PendingAllocation found := false err := RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error { - var matchID, region string + var matchID, playlist, region string var protocol int var claimedID string - err := tx.QueryRowContext(ctx, ClaimAllocatingMatchSQL, now.Add(-AllocationClaimLease), now).Scan(&matchID, ®ion, &protocol, &claimedID) + err := tx.QueryRowContext(ctx, ClaimAllocatingMatchSQL, now.Add(-AllocationClaimLease), now).Scan(&matchID, &playlist, ®ion, &protocol, &claimedID) if err == sql.ErrNoRows { return nil } @@ -233,7 +233,7 @@ func ClaimAllocatingMatch(ctx context.Context, db *sql.DB, transport string, now if build == "" { return fmt.Errorf("allocating match has no participants") } - item.Request = domain.AllocationRequest{AllocationID: claimedID, MatchID: matchID, Region: region, Build: build, Protocol: protocol, Transport: transport} + item.Request = domain.AllocationRequest{AllocationID: claimedID, MatchID: matchID, Playlist: domain.Playlist(playlist), Region: region, Build: build, Protocol: protocol, Transport: transport} found = true return nil }) diff --git a/server/store/allocation_match_sql_test.go b/server/store/allocation_match_sql_test.go index b66d70b3..b4f92d65 100644 --- a/server/store/allocation_match_sql_test.go +++ b/server/store/allocation_match_sql_test.go @@ -9,7 +9,7 @@ import ( func TestAllocationMatchClaimSQLFencesConcurrentWorkers(t *testing.T) { checks := map[string][]string{ - ClaimAllocatingMatchSQL: {"FOR UPDATE SKIP LOCKED", "allocation_id = 'allocation-' || candidate.match_id", "allocation_claimed_at <= $1", "ORDER BY created_at, match_id"}, + ClaimAllocatingMatchSQL: {"FOR UPDATE SKIP LOCKED", "allocation_id = 'allocation-' || candidate.match_id", "allocation_claimed_at <= $1", "ORDER BY created_at, match_id", "m.playlist"}, AllocatingMatchBuildSQL: {"match_participants", "queue_tickets", "ORDER BY q.client_build"}, BindAllocatedMatchParticipantsSQL: {"allocation_id = $2", "server_id IS NULL", "SET server_id = $3", "FROM allocations", "state = 'ALLOCATING'", "revision = revision + 1"}, ReleaseAllocatedMatchClaimSQL: {"allocation_id = $2", "allocation_id = NULL", "allocation_claimed_at = NULL"}, diff --git a/server/supervisor/supervisor.go b/server/supervisor/supervisor.go index 7e7657fb..fd87b381 100644 --- a/server/supervisor/supervisor.go +++ b/server/supervisor/supervisor.go @@ -343,6 +343,7 @@ func withAllocatedCompatibility(command []string, gameServer GameServer) ([]stri return command, nil } for annotation, flag := range map[string]string{ + "cosmic-clash.io/playlist": "playlist", "cosmic-clash.io/region": "region", "cosmic-clash.io/build": "client-build", "cosmic-clash.io/protocol": "protocol-version",