diff --git a/multiplayer-next.md b/multiplayer-next.md index 52a6a511..35939f76 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1414,3 +1414,5 @@ The backend roster persistence boundary now enforces the same duplicate-player, The no-show policy now has an explicit domain translation layer (`PlanInitialConnect`): `WAIT` remains non-mutating, ranked no-shows produce a `CANCELLED` match plan with innocent-player IDs, and eligible casual play produces a `LIVE` plan plus the complete bot-filled six-slot lineup. Normal/race domain tests cover both branches; applying the plan transactionally to durable tickets/matches and wiring it into the allocated server lifecycle remain task 8.35 work. The durable no-show boundary is now implemented by `ApplyInitialConnectPlan`: it locks the match and roster, validates that the plan covers every active participant, records deterministic no-show cooldown penalties, fails no-show tickets, requeues innocent tickets on cancellation or advances connected tickets to `LIVE` for eligible casual bot start, and emits a replayable state-change outbox event under the same serializable transaction. Idempotency keys reject conflicting retries. Focused store tests, race tests, and vet pass; the real PostgreSQL integration remains an environment-dependent gate. + +The maintenance command now invokes a bounded `ReconcileInitialConnect` sweep for `ASSIGNMENT_READY`/`ASSIGNED`/`CONNECTING` matches, carrying ranked no-show history into the domain ladder and skipping non-actionable WAIT plans. This closes the local control-plane trigger for task 8.35; actual allocated-server bot spawning, shutdown signaling, and live Agones integration remain separate gates. diff --git a/server/cmd/maintenance/main.go b/server/cmd/maintenance/main.go index e533d82d..b46ff1eb 100644 --- a/server/cmd/maintenance/main.go +++ b/server/cmd/maintenance/main.go @@ -23,6 +23,7 @@ func main() { batch := flag.Int("batch", 100, "maximum player rollovers per pass") stalledAllocationDeadline := flag.Duration("stalled-allocation-deadline", 2*time.Minute, "reclaim a match stuck in ALLOCATING/PROCESS_READY/ASSIGNMENT_READY (server crashed or was reclaimed before registering) after this long, requeuing every participant without penalty") stalledAllocationBatch := flag.Int("stalled-allocation-batch", 100, "maximum stalled matches reclaimed per pass") + initialConnectBatch := flag.Int("initial-connect-batch", 100, "maximum pre-live matches evaluated per pass") flag.Parse() if *dsn == "" { fatalf("--dsn or COSMIC_CLASH_POSTGRES_DSN is required") @@ -33,6 +34,9 @@ func main() { if *stalledAllocationDeadline <= 0 || *stalledAllocationBatch < 1 || *stalledAllocationBatch > 1000 { fatalf("invalid stalled-allocation deadline or batch") } + if *initialConnectBatch < 1 || *initialConnectBatch > 1000 { + fatalf("invalid initial-connect batch") + } db, err := sql.Open("pgx", *dsn) if err != nil { fatalf("open PostgreSQL: %v", err) @@ -64,6 +68,13 @@ func main() { if reclaimed > 0 { log.Printf("reclaimed %d stalled allocations, requeuing their participants", reclaimed) } + reconciled, err := store.ReconcileInitialConnect(ctx, db, now, *initialConnectBatch) + if err != nil { + fatalf("initial-connect maintenance: %v", err) + } + if reconciled > 0 { + log.Printf("reconciled %d initial-connect outcomes", reconciled) + } timer := time.NewTimer(*interval) select { case <-ctx.Done(): diff --git a/server/store/initial_connect_maintenance.go b/server/store/initial_connect_maintenance.go new file mode 100644 index 00000000..a0d8e87a --- /dev/null +++ b/server/store/initial_connect_maintenance.go @@ -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 +} diff --git a/server/store/initial_connect_sql_test.go b/server/store/initial_connect_sql_test.go index aeb795da..552179f7 100644 --- a/server/store/initial_connect_sql_test.go +++ b/server/store/initial_connect_sql_test.go @@ -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"},