mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
79 lines
3.2 KiB
Go
79 lines
3.2 KiB
Go
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
|
|
})
|
|
}
|