mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 00:14:00 +00:00
fix(multiplayer): reconcile authoritative initial connections
This commit is contained in:
@@ -18,7 +18,7 @@ const InitialConnectIdempotencyScope = "match.initial_connect"
|
||||
const initialConnectMatchLockSQL = `SELECT playlist, state, revision
|
||||
FROM matches WHERE match_id = $1 FOR UPDATE`
|
||||
|
||||
const initialConnectParticipantsSQL = `SELECT player_id, ticket_id, team, connected_at,
|
||||
const initialConnectParticipantsSQL = `SELECT player_id, ticket_id, team, slot, connected_at,
|
||||
participation_active
|
||||
FROM match_participants WHERE match_id = $1 ORDER BY player_id FOR UPDATE`
|
||||
|
||||
@@ -76,6 +76,7 @@ type initialConnectParticipant struct {
|
||||
PlayerID string
|
||||
TicketID string
|
||||
Team int
|
||||
Slot int
|
||||
ConnectedAt sql.NullTime
|
||||
Active bool
|
||||
}
|
||||
@@ -84,7 +85,8 @@ type initialConnectParticipant struct {
|
||||
// It is deliberately a store operation: no-show penalties and innocent-ticket
|
||||
// requeue must commit with the match transition or neither may commit.
|
||||
func ApplyInitialConnectPlan(ctx context.Context, db *sql.DB, matchID, idempotencyKey string, plan domain.InitialConnectPlan, now time.Time) error {
|
||||
if db == nil || matchID == "" || len(idempotencyKey) < 16 || len(idempotencyKey) > 128 || now.IsZero() || plan.Action == domain.InitialConnectWait || (plan.Action != domain.InitialConnectCancel && plan.Action != domain.InitialConnectStartWithBot) || plan.MatchState == domain.Live && plan.Action != domain.InitialConnectStartWithBot || plan.MatchState == domain.Cancelled && plan.Action != domain.InitialConnectCancel {
|
||||
validActionState := (plan.Action == domain.InitialConnectStart || plan.Action == domain.InitialConnectStartWithBot) && plan.MatchState == domain.Live || plan.Action == domain.InitialConnectCancel && plan.MatchState == domain.Cancelled
|
||||
if db == nil || matchID == "" || len(idempotencyKey) < 16 || len(idempotencyKey) > 128 || now.IsZero() || !validActionState {
|
||||
return fmt.Errorf("invalid initial-connect transaction arguments")
|
||||
}
|
||||
digest, err := initialConnectDigest(matchID, plan)
|
||||
@@ -107,7 +109,7 @@ func ApplyInitialConnectPlan(ctx context.Context, db *sql.DB, matchID, idempoten
|
||||
return err
|
||||
}
|
||||
if !bytes.Equal(prior, digest[:]) {
|
||||
return fmt.Errorf("conflicting initial-connect request")
|
||||
return fmt.Errorf("%w: conflicting initial-connect request", domain.ErrConflict)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -117,14 +119,14 @@ func ApplyInitialConnectPlan(ctx context.Context, db *sql.DB, matchID, idempoten
|
||||
return err
|
||||
}
|
||||
if state != string(domain.AssignmentReady) && state != string(domain.Assigned) && state != string(domain.Connecting) {
|
||||
return fmt.Errorf("match is not awaiting initial connect: %s", state)
|
||||
return fmt.Errorf("%w: match is not awaiting initial connect: %s", domain.ErrConflict, state)
|
||||
}
|
||||
participants, err := loadInitialConnectParticipants(ctx, tx, matchID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateInitialConnectPlan(plan, participants, domain.Playlist(playlist)); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("%w: %v", domain.ErrConflict, err)
|
||||
}
|
||||
if plan.Action == domain.InitialConnectCancel {
|
||||
if _, err := tx.ExecContext(ctx, initialConnectReleaseAllSQL, matchID); err != nil {
|
||||
@@ -195,7 +197,7 @@ func loadInitialConnectParticipants(ctx context.Context, tx *sql.Tx, matchID str
|
||||
var result []initialConnectParticipant
|
||||
for rows.Next() {
|
||||
var p initialConnectParticipant
|
||||
if err := rows.Scan(&p.PlayerID, &p.TicketID, &p.Team, &p.ConnectedAt, &p.Active); err != nil {
|
||||
if err := rows.Scan(&p.PlayerID, &p.TicketID, &p.Team, &p.Slot, &p.ConnectedAt, &p.Active); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, p)
|
||||
@@ -204,15 +206,17 @@ func loadInitialConnectParticipants(ctx context.Context, tx *sql.Tx, matchID str
|
||||
}
|
||||
|
||||
func validateInitialConnectPlan(plan domain.InitialConnectPlan, participants []initialConnectParticipant, playlist domain.Playlist) error {
|
||||
if len(participants) == 0 || (plan.Action == domain.InitialConnectStartWithBot && playlist != domain.Casual) || (plan.Action == domain.InitialConnectCancel && plan.MatchState != domain.Cancelled) {
|
||||
if len(participants) == 0 || (plan.Action == domain.InitialConnectStartWithBot && playlist != domain.Casual) || (plan.Action == domain.InitialConnectCancel && plan.MatchState != domain.Cancelled) || (plan.Action == domain.InitialConnectStart && (plan.MatchState != domain.Live || len(plan.NoShows) != 0 || len(plan.CasualLineup) != 0)) {
|
||||
return fmt.Errorf("invalid initial-connect plan")
|
||||
}
|
||||
known, connected, missing := map[string]bool{}, map[string]bool{}, map[string]bool{}
|
||||
stored := make(map[string]initialConnectParticipant, len(participants))
|
||||
for _, p := range participants {
|
||||
if p.PlayerID == "" || !p.Active || known[p.PlayerID] {
|
||||
if p.PlayerID == "" || !p.Active || p.Team < 0 || p.Team > 1 || p.Slot < 0 || p.Slot > 5 || p.Slot/3 != p.Team || known[p.PlayerID] {
|
||||
return fmt.Errorf("invalid stored participant roster")
|
||||
}
|
||||
known[p.PlayerID] = true
|
||||
stored[p.PlayerID] = p
|
||||
if p.ConnectedAt.Valid {
|
||||
connected[p.PlayerID] = true
|
||||
}
|
||||
@@ -232,6 +236,9 @@ func validateInitialConnectPlan(plan domain.InitialConnectPlan, participants []i
|
||||
if len(missing) != len(known) {
|
||||
return fmt.Errorf("initial-connect plan does not cover roster")
|
||||
}
|
||||
if plan.Action == domain.InitialConnectStart && len(connected) != len(known) {
|
||||
return fmt.Errorf("initial-connect start requires complete connected roster")
|
||||
}
|
||||
if plan.Action == domain.InitialConnectStartWithBot {
|
||||
if len(plan.CasualLineup) != 6 {
|
||||
return fmt.Errorf("casual bot lineup must contain six players")
|
||||
@@ -239,7 +246,7 @@ func validateInitialConnectPlan(plan domain.InitialConnectPlan, participants []i
|
||||
lineupSlots := make(map[int]bool, 6)
|
||||
lineupPlayers := make(map[string]bool, 6)
|
||||
for _, slot := range plan.CasualLineup {
|
||||
if slot.Slot < 0 || slot.Slot > 5 || slot.Team != slot.Slot%2 || lineupSlots[slot.Slot] || slot.PlayerID == "" || lineupPlayers[slot.PlayerID] {
|
||||
if slot.Slot < 0 || slot.Slot > 5 || slot.Team != slot.Slot/3 || lineupSlots[slot.Slot] || slot.PlayerID == "" || lineupPlayers[slot.PlayerID] {
|
||||
return fmt.Errorf("invalid casual bot lineup")
|
||||
}
|
||||
lineupSlots[slot.Slot] = true
|
||||
@@ -250,6 +257,10 @@ func validateInitialConnectPlan(plan domain.InitialConnectPlan, participants []i
|
||||
if !connected[slot.PlayerID] {
|
||||
return fmt.Errorf("lineup contains non-connected human")
|
||||
}
|
||||
participant := stored[slot.PlayerID]
|
||||
if participant.Slot != slot.Slot || participant.Team != slot.Team {
|
||||
return fmt.Errorf("lineup moves connected human from assigned slot")
|
||||
}
|
||||
}
|
||||
for id := range connected {
|
||||
if !lineupPlayers[id] {
|
||||
|
||||
Reference in New Issue
Block a user