feat(multiplayer): persist ranked arena allocations

This commit is contained in:
Josh Creek
2026-09-01 21:04:55 +01:00
parent 5630c5c8dc
commit 84372204fd
22 changed files with 100 additions and 33 deletions
+3
View File
@@ -242,6 +242,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 request.ArenaPath != "" {
body.Spec.Metadata.Annotations["cosmic-clash.io/arena-path"] = request.ArenaPath
}
if playlist := labels["cosmic-clash.io/playlist"]; playlist == string(domain.Casual) || playlist == string(domain.Ranked) {
body.Spec.Metadata.Annotations["cosmic-clash.io/playlist"] = playlist
}
+6 -1
View File
@@ -38,11 +38,16 @@ func TestAllocateBuildsStrictGameServerAllocationAndEndpoint(t *testing.T) {
t.Fatalf("annotation %s = %q, want %q", key, body.Spec.Metadata.Annotations[key], value)
}
}
if body.Spec.Metadata.Annotations["cosmic-clash.io/arena-path"] != "res://scenes/arena_01.tscn" {
t.Fatalf("arena annotation = %q", body.Spec.Metadata.Annotations["cosmic-clash.io/arena-path"])
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":{"state":"Allocated","gameServerName":"gs-a","address":"2001:db8::1","ports":[{"name":"default","port":7777}]}}`))
}))
defer server.Close()
got, err := (Client{BaseURL: server.URL, Namespace: "games", HTTP: server.Client()}).Allocate(context.Background(), request(), map[string]string{"cosmic-clash/region": "EU", "cosmic-clash/build": "build-1"}, time.Unix(1000, 0))
allocation := request()
allocation.ArenaPath = "res://scenes/arena_01.tscn"
got, err := (Client{BaseURL: server.URL, Namespace: "games", HTTP: server.Client()}).Allocate(context.Background(), allocation, map[string]string{"cosmic-clash/region": "EU", "cosmic-clash/build": "build-1"}, time.Unix(1000, 0))
if err != nil {
t.Fatal(err)
}
+2 -1
View File
@@ -31,6 +31,7 @@ type AllocationRequest struct {
Region string
Build string
Protocol int
ArenaPath string
Transport string
}
@@ -142,5 +143,5 @@ func validateAllocationRequest(request AllocationRequest) error {
}
func allocationDigest(request AllocationRequest) [32]byte {
return sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%s\x00%s\x00%d\x00%s", request.AllocationID, request.MatchID, request.Region, request.Build, request.Protocol, request.Transport)))
return sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%s\x00%s\x00%d\x00%s\x00%s", request.AllocationID, request.MatchID, request.Region, request.Build, request.Protocol, request.Transport, request.ArenaPath)))
}
+3
View File
@@ -74,6 +74,9 @@ func PrepareProposal(id string, playlist Playlist, formation MatchFormation, ran
}
proposal.Region = formation.Selection.Region
proposal.Protocol = formation.Selection.Players[0].ProtocolVersion
if playlist == Ranked {
proposal.ArenaPath = arena.Path
}
for _, player := range formation.Selection.Players {
if player.ProtocolVersion != proposal.Protocol {
return PreparedProposal{}, fmt.Errorf("formed match has mixed protocols")
+1
View File
@@ -43,6 +43,7 @@ type Proposal struct {
Playlist Playlist
Region string
Protocol int
ArenaPath string
Participants []ProposalParticipant
State State
Revision uint64
+5 -4
View File
@@ -11,7 +11,8 @@ type RankedParticipant struct {
}
type RankedArena struct {
ID string
ID string
Path string
}
// rankedArenas is the server-owned eligibility registry for launch ranked
@@ -19,9 +20,9 @@ type RankedArena struct {
// project, but deliberately excludes every elevated-goal variant until a
// policy trained for that geometry is promoted.
var rankedArenas = map[string]RankedArena{
"arena_01": {ID: "arena_01"},
"arena_02": {ID: "arena_02"},
"arena_03": {ID: "arena_03"},
"arena_01": {ID: "arena_01", Path: "res://scenes/arena_01.tscn"},
"arena_02": {ID: "arena_02", Path: "res://scenes/arena_02.tscn"},
"arena_03": {ID: "arena_03", Path: "res://scenes/arena_03.tscn"},
}
// DefaultRankedArena supplies a safe server-owned eligibility decision while
@@ -0,0 +1,8 @@
-- Persist the matcher-selected arena through acceptance and allocation. NULL
-- remains valid for legacy/casual rows while ranked proposals always write a
-- server-owned floor-goal path.
ALTER TABLE proposals
ADD COLUMN match_arena_path TEXT;
ALTER TABLE matches
ADD COLUMN arena_path TEXT;
@@ -0,0 +1,4 @@
ALTER TABLE matches
DROP COLUMN IF EXISTS arena_path;
ALTER TABLE proposals
DROP COLUMN IF EXISTS match_arena_path;
+7 -3
View File
@@ -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.playlist, m.region, m.protocol_version, m.allocation_id`
RETURNING m.match_id, m.playlist, m.region, m.protocol_version, m.arena_path, m.allocation_id`
const AllocatingMatchBuildSQL = `SELECT client_build
FROM queue_tickets q
@@ -202,8 +202,9 @@ func ClaimAllocatingMatch(ctx context.Context, db *sql.DB, transport string, now
err := RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error {
var matchID, playlist, region string
var protocol int
var arenaPath sql.NullString
var claimedID string
err := tx.QueryRowContext(ctx, ClaimAllocatingMatchSQL, now.Add(-AllocationClaimLease), now).Scan(&matchID, &playlist, &region, &protocol, &claimedID)
err := tx.QueryRowContext(ctx, ClaimAllocatingMatchSQL, now.Add(-AllocationClaimLease), now).Scan(&matchID, &playlist, &region, &protocol, &arenaPath, &claimedID)
if err == sql.ErrNoRows {
return nil
}
@@ -233,7 +234,10 @@ 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, Playlist: domain.Playlist(playlist), Region: region, Build: build, Protocol: protocol, Transport: transport}
if domain.Playlist(playlist) == domain.Ranked && !arenaPath.Valid {
return fmt.Errorf("ranked allocating match has no arena")
}
item.Request = domain.AllocationRequest{AllocationID: claimedID, MatchID: matchID, Playlist: domain.Playlist(playlist), Region: region, Build: build, Protocol: protocol, ArenaPath: arenaPath.String, Transport: transport}
found = true
return nil
})
+1 -1
View File
@@ -144,5 +144,5 @@ func validAllocationInput(db *sql.DB, request domain.AllocationRequest, now time
}
func allocationRequestDigest(request domain.AllocationRequest) [32]byte {
return sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%s\x00%s\x00%d\x00%s", request.AllocationID, request.MatchID, request.Region, request.Build, request.Protocol, request.Transport)))
return sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%s\x00%s\x00%d\x00%s\x00%s", request.AllocationID, request.MatchID, request.Region, request.Build, request.Protocol, request.Transport, request.ArenaPath)))
}
+13 -8
View File
@@ -19,6 +19,7 @@ type AcceptedMatchPlan struct {
ProposalID string
Region string
Protocol int
ArenaPath string
Players []MatchPlayer
}
@@ -40,11 +41,11 @@ ORDER BY player_id
FOR UPDATE`
const AcceptedMatchInsertSQL = `INSERT INTO matches
(match_id, playlist, state, region, protocol_version)
VALUES ($1, $2, 'ALLOCATING', $3, $4)
(match_id, playlist, state, region, protocol_version, arena_path)
VALUES ($1, $2, 'ALLOCATING', $3, $4, NULLIF($5, ''))
ON CONFLICT (match_id) DO NOTHING`
const AcceptedMatchSelectSQL = `SELECT playlist, state, region, protocol_version, server_id
const AcceptedMatchSelectSQL = `SELECT playlist, state, region, protocol_version, arena_path, server_id
FROM matches
WHERE match_id = $1
FOR UPDATE`
@@ -63,7 +64,7 @@ const AcceptedMatchParticipantInsertSQL = `INSERT INTO match_participants
(match_id, player_id, ticket_id, slot, team)
VALUES ($1, $2, $3, $4, $5)`
const StoredProposalMatchPlanSQL = `SELECT match_region, match_protocol
const StoredProposalMatchPlanSQL = `SELECT match_region, match_protocol, match_arena_path
FROM proposals
WHERE proposal_id = $1 AND state = 'ACCEPTED'`
@@ -80,7 +81,7 @@ func PromoteStoredAcceptedProposal(ctx context.Context, db *sql.DB, proposalID s
return fmt.Errorf("invalid stored proposal promotion arguments")
}
plan := AcceptedMatchPlan{MatchID: "match-" + proposalID, ProposalID: proposalID}
if err := db.QueryRowContext(ctx, StoredProposalMatchPlanSQL, proposalID).Scan(&plan.Region, &plan.Protocol); err != nil {
if err := db.QueryRowContext(ctx, StoredProposalMatchPlanSQL, proposalID).Scan(&plan.Region, &plan.Protocol, &plan.ArenaPath); err != nil {
return err
}
rows, err := db.QueryContext(ctx, StoredProposalMatchPlayersSQL, proposalID)
@@ -119,11 +120,14 @@ func CreateMatchFromAcceptedProposal(ctx context.Context, db *sql.DB, plan Accep
if !validAcceptedPlaylistCount(domain.Playlist(playlist), len(plan.Players)) {
return fmt.Errorf("accepted proposal playlist does not match player count")
}
if domain.Playlist(playlist) == domain.Ranked && plan.ArenaPath == "" {
return fmt.Errorf("ranked accepted match plan has no arena")
}
participants, err := acceptedProposalParticipants(ctx, tx, plan)
if err != nil {
return err
}
inserted, err := tx.ExecContext(ctx, AcceptedMatchInsertSQL, plan.MatchID, playlist, plan.Region, plan.Protocol)
inserted, err := tx.ExecContext(ctx, AcceptedMatchInsertSQL, plan.MatchID, playlist, plan.Region, plan.Protocol, plan.ArenaPath)
if err != nil {
return err
}
@@ -216,11 +220,12 @@ func acceptedProposalParticipants(ctx context.Context, tx *sql.Tx, plan Accepted
func verifyAcceptedMatchReplay(ctx context.Context, tx *sql.Tx, plan AcceptedMatchPlan, playlist domain.Playlist, tickets map[string]string) error {
var existingPlaylist, state, region string
var protocol int
var arenaPath sql.NullString
var serverID sql.NullString
if err := tx.QueryRowContext(ctx, AcceptedMatchSelectSQL, plan.MatchID).Scan(&existingPlaylist, &state, &region, &protocol, &serverID); err != nil {
if err := tx.QueryRowContext(ctx, AcceptedMatchSelectSQL, plan.MatchID).Scan(&existingPlaylist, &state, &region, &protocol, &arenaPath, &serverID); err != nil {
return err
}
if existingPlaylist != string(playlist) || state != string(domain.Allocating) || region != plan.Region || protocol != plan.Protocol || serverID.Valid {
if existingPlaylist != string(playlist) || state != string(domain.Allocating) || region != plan.Region || protocol != plan.Protocol || arenaPath.String != plan.ArenaPath || arenaPath.Valid != (plan.ArenaPath != "") || serverID.Valid {
return domain.ErrConflict
}
rows, err := tx.QueryContext(ctx, AcceptedMatchParticipantsSQL, plan.MatchID)
+13
View File
@@ -59,6 +59,19 @@ func TestAcceptedMatchPromotionHonoursPlaylistSizeInvariant(t *testing.T) {
}
}
func TestRankedAcceptedMatchPlanRequiresArenaAfterPlaylistResolution(t *testing.T) {
plan := AcceptedMatchPlan{MatchID: "match-1", ProposalID: "proposal-1", Region: "EU", Protocol: 1, Players: []MatchPlayer{{PlayerID: "player-a", Team: 0, Slot: 0}, {PlayerID: "player-b", Team: 1, Slot: 3}}}
if !validAcceptedMatchPlan(plan) {
t.Fatal("test plan should reach the database playlist guard")
}
// CreateMatchFromAcceptedProposal owns the playlist lookup, so a nil DB is
// the only no-database check available here; the integration suite exercises
// the resolved ranked branch against PostgreSQL.
if err := CreateMatchFromAcceptedProposal(nil, nil, plan, time.Now()); err == nil {
t.Fatal("nil database accepted")
}
}
func TestMatchPlayersFromTeamsUsesDeterministicTeamSlots(t *testing.T) {
teams := domain.Teams{
Team0: []domain.Candidate{{PlayerID: "bravo"}, {PlayerID: "alpha"}},
+6 -3
View File
@@ -11,8 +11,8 @@ import (
)
const ProposalInsertSQL = `INSERT INTO proposals
(proposal_id, playlist, state, expires_at, revision, match_region, match_protocol)
VALUES ($1, $2, 'OPEN', $3, 0, NULLIF($4, ''), NULLIF($5, 0))`
(proposal_id, playlist, state, expires_at, revision, match_region, match_protocol, match_arena_path)
VALUES ($1, $2, 'OPEN', $3, 0, NULLIF($4, ''), NULLIF($5, 0), NULLIF($6, ''))`
const ProposalOutboxInsertSQL = `INSERT INTO outbox
(event_id, aggregate_type, aggregate_id, revision, event_type, payload)
@@ -29,7 +29,7 @@ func CreateProposal(ctx context.Context, db *sql.DB, proposal domain.Proposal, t
return fmt.Errorf("invalid proposal match plan")
}
return RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, ProposalInsertSQL, proposal.ProposalID, proposal.Playlist, proposal.ExpiresAt, proposal.Region, proposal.Protocol); err != nil {
if _, err := tx.ExecContext(ctx, ProposalInsertSQL, proposal.ProposalID, proposal.Playlist, proposal.ExpiresAt, proposal.Region, proposal.Protocol, proposal.ArenaPath); err != nil {
return err
}
players := make([]string, 0, len(proposal.Participants))
@@ -75,6 +75,9 @@ func validProposalMatchPlan(proposal domain.Proposal) bool {
if (proposal.Region != "EU" && proposal.Region != "NA") || proposal.Protocol < 1 {
return false
}
if proposal.Playlist == domain.Ranked && proposal.ArenaPath == "" {
return false
}
seenSlots := make(map[int]struct{}, len(proposal.Participants))
teams := [2]int{}
for _, participant := range proposal.Participants {
+6 -5
View File
@@ -343,11 +343,12 @@ 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",
"cosmic-clash.io/transport": "transport",
"cosmic-clash.io/arena-path": "arena-path",
"cosmic-clash.io/playlist": "playlist",
"cosmic-clash.io/region": "region",
"cosmic-clash.io/build": "client-build",
"cosmic-clash.io/protocol": "protocol-version",
"cosmic-clash.io/transport": "transport",
} {
value := annotations[annotation]
if value == "" {
+3 -2
View File
@@ -35,17 +35,18 @@ func TestWithAllocatedConfigOverridesAuthoritativeChildFlags(t *testing.T) {
}
func TestWithAllocatedCompatibilityOverridesStaleFlagsAndRejectsUnsafeValues(t *testing.T) {
command := []string{"game-server", "--region=EU", "--client-build=stale", "--protocol-version=1", "--transport=enet", "--custom=keep"}
command := []string{"game-server", "--region=EU", "--client-build=stale", "--protocol-version=1", "--transport=enet", "--arena-path=res://stale.tscn", "--custom=keep"}
gameServer := GameServer{}
gameServer.ObjectMeta.Annotations = map[string]string{
"cosmic-clash.io/region": "NA", "cosmic-clash.io/build": "build-live",
"cosmic-clash.io/protocol": "12", "cosmic-clash.io/transport": "steam_sdr",
"cosmic-clash.io/arena-path": "res://scenes/arena_01.tscn",
}
got, err := withAllocatedCompatibility(command, gameServer)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"--region=NA", "--client-build=build-live", "--protocol-version=12", "--transport=steam_sdr", "--custom=keep"} {
for _, want := range []string{"--region=NA", "--client-build=build-live", "--protocol-version=12", "--transport=steam_sdr", "--arena-path=res://scenes/arena_01.tscn", "--custom=keep"} {
found := false
for _, arg := range got {
if arg == want {