package store import ( "bytes" "context" "crypto/sha256" "database/sql" "encoding/json" "fmt" "time" "github.com/cosmic-clash/cosmic-clash/server/domain" ) const ServerConnectionIdempotencyScope = "server.connection" const ServerConnectionIdempotencyInsertSQL = `INSERT INTO idempotency_keys (scope, idempotency_key, payload_digest, result) VALUES ($1, $2, $3, '{}'::jsonb) ON CONFLICT (scope, idempotency_key) DO NOTHING` const ServerConnectionIdempotencySelectSQL = `SELECT payload_digest FROM idempotency_keys WHERE scope = $1 AND idempotency_key = $2 FOR UPDATE` const ServerConnectionParticipantSQL = `UPDATE match_participants mp SET connected_at = COALESCE(mp.connected_at, $5) FROM matches m, allocations a, assignments assn WHERE mp.match_id = $1 AND mp.player_id = $4 AND mp.participation_active AND m.match_id = mp.match_id AND m.server_id = $2 AND m.state IN ('ASSIGNMENT_READY', 'ASSIGNED', 'CONNECTING', 'LIVE') AND a.allocation_id = $3 AND a.match_id = m.match_id AND a.server_id = m.server_id AND a.state = 'ALLOCATED' AND assn.match_id = mp.match_id AND assn.player_id = mp.player_id AND assn.allocation_id = a.allocation_id AND assn.server_id = m.server_id AND assn.expires_at > $5 RETURNING mp.connected_at` // RecordPlayerConnected persists authoritative admission observed by the // allocated game server. The workload allocation, match/server binding, // active participant, and still-live assignment must all agree. func RecordPlayerConnected(ctx context.Context, db *sql.DB, binding domain.WorkloadBinding, playerID, idempotencyKey string, now time.Time) error { if db == nil || binding.AllocationID == "" || binding.MatchID == "" || binding.ServerID == "" || playerID == "" || len(idempotencyKey) < 16 || len(idempotencyKey) > 128 || now.IsZero() { return fmt.Errorf("invalid server connection receipt") } digest := sha256.Sum256([]byte(binding.AllocationID + "\x00" + binding.MatchID + "\x00" + binding.ServerID + "\x00" + playerID)) return RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error { inserted, err := tx.ExecContext(ctx, ServerConnectionIdempotencyInsertSQL, ServerConnectionIdempotencyScope, idempotencyKey, digest[:]) if err != nil { return err } changed, err := inserted.RowsAffected() if err != nil { return err } if changed == 0 { var prior []byte if err := tx.QueryRowContext(ctx, ServerConnectionIdempotencySelectSQL, ServerConnectionIdempotencyScope, idempotencyKey).Scan(&prior); err != nil { return err } if !bytes.Equal(prior, digest[:]) { return domain.ErrConflict } return nil } var connectedAt time.Time if err := tx.QueryRowContext(ctx, ServerConnectionParticipantSQL, binding.MatchID, binding.ServerID, binding.AllocationID, playerID, now).Scan(&connectedAt); err != nil { if err == sql.ErrNoRows { return domain.ErrConflict } return err } result, err := json.Marshal(map[string]any{"match_id": binding.MatchID, "player_id": playerID, "connected_at": connectedAt}) if err != nil { return err } _, err = tx.ExecContext(ctx, `UPDATE idempotency_keys SET result = $3 WHERE scope = $1 AND idempotency_key = $2`, ServerConnectionIdempotencyScope, idempotencyKey, result) return err }) }