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:
@@ -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.
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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