mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-13 12:02:02 +00:00
feat: promote accepted proposals from API
This commit is contained in:
@@ -63,6 +63,44 @@ 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
|
||||
FROM proposals
|
||||
WHERE proposal_id = $1 AND state = 'ACCEPTED'`
|
||||
|
||||
const StoredProposalMatchPlayersSQL = `SELECT player_id, team, slot
|
||||
FROM proposal_participants
|
||||
WHERE proposal_id = $1 AND response = 'ACCEPTED'
|
||||
ORDER BY player_id`
|
||||
|
||||
// PromoteStoredAcceptedProposal materializes the exact topology persisted by
|
||||
// the matcher once every player has accepted. The deterministic match ID makes
|
||||
// a request retry converge after an API/worker interruption.
|
||||
func PromoteStoredAcceptedProposal(ctx context.Context, db *sql.DB, proposalID string, now time.Time) error {
|
||||
if db == nil || proposalID == "" || now.IsZero() {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, StoredProposalMatchPlayersSQL, proposalID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var player MatchPlayer
|
||||
if err := rows.Scan(&player.PlayerID, &player.Team, &player.Slot); err != nil {
|
||||
return err
|
||||
}
|
||||
plan.Players = append(plan.Players, player)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return CreateMatchFromAcceptedProposal(ctx, db, plan, now)
|
||||
}
|
||||
|
||||
// CreateMatchFromAcceptedProposal atomically promotes the exact accepted
|
||||
// roster into an ALLOCATING match. An existing match ID is an idempotent retry
|
||||
// only if every durable field and participant assignment matches the request.
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
)
|
||||
|
||||
const ProposalInsertSQL = `INSERT INTO proposals
|
||||
(proposal_id, playlist, state, expires_at, revision)
|
||||
VALUES ($1, $2, 'OPEN', $3, 0)`
|
||||
(proposal_id, playlist, state, expires_at, revision, match_region, match_protocol)
|
||||
VALUES ($1, $2, 'OPEN', $3, 0, NULLIF($4, ''), NULLIF($5, 0))`
|
||||
|
||||
// CreateProposal atomically claims the queue tickets and creates the proposal.
|
||||
// Every statement runs inside the same SERIALIZABLE retry callback; callers
|
||||
@@ -20,8 +20,11 @@ func CreateProposal(ctx context.Context, db *sql.DB, proposal domain.Proposal, t
|
||||
if proposal.ProposalID == "" || len(proposal.Participants) == 0 {
|
||||
return fmt.Errorf("invalid proposal transaction")
|
||||
}
|
||||
if !validProposalMatchPlan(proposal) {
|
||||
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); err != nil {
|
||||
if _, err := tx.ExecContext(ctx, ProposalInsertSQL, proposal.ProposalID, proposal.Playlist, proposal.ExpiresAt, proposal.Region, proposal.Protocol); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, participant := range proposal.Participants {
|
||||
@@ -29,7 +32,7 @@ func CreateProposal(ctx context.Context, db *sql.DB, proposal domain.Proposal, t
|
||||
if participant.PlayerID == "" || ticketID == "" {
|
||||
return fmt.Errorf("missing proposal ticket mapping")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, ProposalParticipantInsertSQL, proposal.ProposalID, participant.PlayerID, ticketID); err != nil {
|
||||
if _, err := tx.ExecContext(ctx, ProposalParticipantInsertSQL, proposal.ProposalID, participant.PlayerID, ticketID, nullablePlanField(proposal.Region != "", participant.Team), nullablePlanField(proposal.Region != "", participant.Slot)); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, QueueTicketProposeSQL, ticketID, participant.PlayerID, string(proposal.Playlist), now)
|
||||
@@ -47,3 +50,32 @@ func CreateProposal(ctx context.Context, db *sql.DB, proposal domain.Proposal, t
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func validProposalMatchPlan(proposal domain.Proposal) bool {
|
||||
if proposal.Region == "" && proposal.Protocol == 0 {
|
||||
return true // Legacy/direct callers have no matcher formation to persist.
|
||||
}
|
||||
if (proposal.Region != "EU" && proposal.Region != "NA") || proposal.Protocol < 1 {
|
||||
return false
|
||||
}
|
||||
seenSlots := make(map[int]struct{}, len(proposal.Participants))
|
||||
teams := [2]int{}
|
||||
for _, participant := range proposal.Participants {
|
||||
if participant.Team < 0 || participant.Team > 1 || participant.Slot < 0 || participant.Slot > 5 {
|
||||
return false
|
||||
}
|
||||
if _, exists := seenSlots[participant.Slot]; exists {
|
||||
return false
|
||||
}
|
||||
seenSlots[participant.Slot] = struct{}{}
|
||||
teams[participant.Team]++
|
||||
}
|
||||
return teams[0] > 0 && teams[1] > 0
|
||||
}
|
||||
|
||||
func nullablePlanField(enabled bool, value int) any {
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -8,14 +8,16 @@ import (
|
||||
|
||||
func TestQueueSQLUsesDurableIdempotencyAndOwnerScopedRecovery(t *testing.T) {
|
||||
for query, fragments := range map[string][]string{
|
||||
QueueIdempotencyInsertSQL: {"idempotency_keys", "ON CONFLICT (scope, idempotency_key) DO NOTHING", "payload_digest"},
|
||||
QueueIdempotencySelectSQL: {"scope = $1", "idempotency_key = $2", "FOR UPDATE"},
|
||||
QueueTicketSelectSQL: {"ticket_id = $1", "player_id = $2"},
|
||||
QueueTicketInsertSQL: {"player_id", "playlist", "client_build", "protocol_version"},
|
||||
QueueTicketHeartbeatSQL: {"player_id = $2", "revision = $3", "expires_at > $4", "RETURNING"},
|
||||
QueueTicketCancelSQL: {"player_id = $2", "revision = $3", "state NOT IN", "RETURNING"},
|
||||
QueueCandidateProjectionSQL: {"playlist = $1", "predicted_rtt", "expires_at > $2", "LIMIT $3"},
|
||||
RankedParticipantSQL: {"steam_id", "player_id = ANY($1)", "ORDER BY player_id"},
|
||||
QueueIdempotencyInsertSQL: {"idempotency_keys", "ON CONFLICT (scope, idempotency_key) DO NOTHING", "payload_digest"},
|
||||
QueueIdempotencySelectSQL: {"scope = $1", "idempotency_key = $2", "FOR UPDATE"},
|
||||
QueueTicketSelectSQL: {"ticket_id = $1", "player_id = $2"},
|
||||
QueueTicketInsertSQL: {"player_id", "playlist", "client_build", "protocol_version"},
|
||||
QueueTicketHeartbeatSQL: {"player_id = $2", "revision = $3", "expires_at > $4", "RETURNING"},
|
||||
QueueTicketCancelSQL: {"player_id = $2", "revision = $3", "state NOT IN", "RETURNING"},
|
||||
QueueCandidateProjectionSQL: {"playlist = $1", "predicted_rtt", "expires_at > $2", "LIMIT $3"},
|
||||
RankedParticipantSQL: {"steam_id", "player_id = ANY($1)", "ORDER BY player_id"},
|
||||
ProposalInsertSQL: {"match_region", "match_protocol", "NULLIF($4, '')"},
|
||||
ProposalParticipantInsertSQL: {"team", "slot", "'PENDING'"},
|
||||
} {
|
||||
for _, fragment := range fragments {
|
||||
if !contains(query, fragment) {
|
||||
|
||||
@@ -75,8 +75,8 @@ ORDER BY enqueued_at, ticket_id
|
||||
LIMIT $2
|
||||
FOR UPDATE SKIP LOCKED`
|
||||
|
||||
ProposalParticipantInsertSQL = `INSERT INTO proposal_participants (proposal_id, player_id, ticket_id, response)
|
||||
VALUES ($1, $2, $3, 'PENDING')`
|
||||
ProposalParticipantInsertSQL = `INSERT INTO proposal_participants (proposal_id, player_id, ticket_id, response, team, slot)
|
||||
VALUES ($1, $2, $3, 'PENDING', $4, $5)`
|
||||
|
||||
QueueTicketProposeSQL = `UPDATE queue_tickets SET state = 'PROPOSED', revision = revision + 1
|
||||
WHERE ticket_id = $1 AND player_id = $2 AND playlist = $3 AND state = 'QUEUED' AND expires_at > $4`
|
||||
|
||||
Reference in New Issue
Block a user