mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 00:14:00 +00:00
feat(multiplayer): sweep initial connect outcomes
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
||||
)
|
||||
|
||||
const initialConnectCandidatesSQL = `SELECT match_id, playlist, created_at
|
||||
FROM matches
|
||||
WHERE state IN ('ASSIGNMENT_READY', 'ASSIGNED', 'CONNECTING')
|
||||
ORDER BY created_at, match_id
|
||||
LIMIT $1`
|
||||
|
||||
const initialConnectHistorySQL = `SELECT starts_at
|
||||
FROM penalties
|
||||
WHERE player_id = $1 AND kind = 'INITIAL_CONNECT_NO_SHOW'
|
||||
ORDER BY starts_at`
|
||||
|
||||
// ReconcileInitialConnect evaluates a bounded set of matches and applies only
|
||||
// terminal or bot-start decisions. WAIT is intentionally non-mutating. A
|
||||
// concurrent allocator/server transition is harmless: ApplyInitialConnectPlan
|
||||
// locks and revalidates the match before changing anything.
|
||||
func ReconcileInitialConnect(ctx context.Context, db *sql.DB, now time.Time, limit int) (int, error) {
|
||||
if db == nil || now.IsZero() || limit < 1 || limit > 1000 {
|
||||
return 0, fmt.Errorf("invalid initial-connect maintenance arguments")
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, initialConnectCandidatesSQL, limit)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
var matchID, playlist string
|
||||
var readyAt time.Time
|
||||
if err := rows.Scan(&matchID, &playlist, &readyAt); err != nil {
|
||||
return count, err
|
||||
}
|
||||
participants, err := loadInitialConnectSnapshot(ctx, db, matchID)
|
||||
if err != nil {
|
||||
return count, err
|
||||
}
|
||||
history, err := loadInitialConnectHistory(ctx, db, participants)
|
||||
if err != nil {
|
||||
return count, err
|
||||
}
|
||||
plan, err := domain.PlanInitialConnect(domain.Playlist(playlist), readyAt, now, participants, history)
|
||||
if err != nil {
|
||||
return count, fmt.Errorf("plan initial connect %s: %w", matchID, err)
|
||||
}
|
||||
if plan.Action == domain.InitialConnectWait {
|
||||
continue
|
||||
}
|
||||
if err := ApplyInitialConnectPlan(ctx, db, matchID, "initial-connect:"+matchID, plan, now); err != nil {
|
||||
return count, err
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count, rows.Err()
|
||||
}
|
||||
|
||||
func loadInitialConnectSnapshot(ctx context.Context, db *sql.DB, matchID string) ([]domain.ConnectParticipant, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT player_id, team, connected_at
|
||||
FROM match_participants WHERE match_id = $1 AND participation_active ORDER BY player_id`, matchID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var participants []domain.ConnectParticipant
|
||||
for rows.Next() {
|
||||
var playerID string
|
||||
var team int
|
||||
var connectedAt sql.NullTime
|
||||
if err := rows.Scan(&playerID, &team, &connectedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
participants = append(participants, domain.ConnectParticipant{PlayerID: playerID, Team: team, Connected: connectedAt.Valid})
|
||||
}
|
||||
return participants, rows.Err()
|
||||
}
|
||||
|
||||
func loadInitialConnectHistory(ctx context.Context, db *sql.DB, participants []domain.ConnectParticipant) (map[string][]time.Time, error) {
|
||||
history := make(map[string][]time.Time)
|
||||
for _, participant := range participants {
|
||||
rows, err := db.QueryContext(ctx, initialConnectHistorySQL, participant.PlayerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var started time.Time
|
||||
if err := rows.Scan(&started); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
history[participant.PlayerID] = append(history[participant.PlayerID], started)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
return history, nil
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import (
|
||||
)
|
||||
|
||||
func TestInitialConnectSQLPreservesAtomicNoShowReconciliation(t *testing.T) {
|
||||
if !contains(initialConnectCandidatesSQL, "ASSIGNMENT_READY") || !contains(initialConnectCandidatesSQL, "LIMIT $1") {
|
||||
t.Fatal("initial-connect sweep is not bounded to pre-live matches")
|
||||
}
|
||||
for query, fragments := range map[string][]string{
|
||||
initialConnectIdempotencyInsertSQL: {"ON CONFLICT", "payload_digest"},
|
||||
initialConnectMatchLockSQL: {"FOR UPDATE", "match_id = $1"},
|
||||
|
||||
Reference in New Issue
Block a user