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
+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 {