mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 08:23:45 +00:00
fix(multiplayer): reconcile authoritative initial connections
This commit is contained in:
@@ -66,7 +66,9 @@ WHERE match_id = $1 AND state = 'ALLOCATING' AND allocation_id = $2 AND server_i
|
||||
|
||||
const AdvanceServerRegistrationSQL = `WITH matched AS (
|
||||
UPDATE matches
|
||||
SET state = $4, revision = revision + 1
|
||||
SET state = $4,
|
||||
initial_connect_ready_at = CASE WHEN $4 = 'ASSIGNMENT_READY' THEN $6 ELSE initial_connect_ready_at END,
|
||||
revision = revision + 1
|
||||
WHERE match_id = $1 AND server_id = $2 AND state = $3 AND protocol_version = $7
|
||||
AND EXISTS (SELECT 1 FROM allocations WHERE match_id = $1 AND server_id = $2 AND allocation_id = $5 AND protocol_version = $7 AND state = 'ALLOCATED')
|
||||
AND ($4 <> 'ASSIGNMENT_READY' OR (SELECT count(*) FROM assignments WHERE match_id = $1 AND expires_at > $6) = (SELECT count(*) FROM match_participants WHERE match_id = $1))
|
||||
|
||||
@@ -13,7 +13,7 @@ func TestAllocationMatchClaimSQLFencesConcurrentWorkers(t *testing.T) {
|
||||
AllocatingMatchBuildSQL: {"match_participants", "queue_tickets", "ORDER BY q.client_build"},
|
||||
BindAllocatedMatchParticipantsSQL: {"allocation_id = $2", "server_id IS NULL", "SET server_id = $3", "FROM allocations", "state = 'ALLOCATING'", "revision = revision + 1", "SELECT revision FROM bound"},
|
||||
ReleaseAllocatedMatchClaimSQL: {"allocation_id = $2", "allocation_id = NULL", "allocation_claimed_at = NULL"},
|
||||
AdvanceServerRegistrationSQL: {"state = $4", "protocol_version = $7", "ASSIGNMENT_READY", "revision = revision + 1"},
|
||||
AdvanceServerRegistrationSQL: {"state = $4", "initial_connect_ready_at", "$6", "protocol_version = $7", "ASSIGNMENT_READY", "revision = revision + 1"},
|
||||
ServerRegistrationIdempotencyInsertSQL: {"idempotency_keys", "ON CONFLICT (scope, idempotency_key) DO NOTHING", "payload_digest"},
|
||||
ServerRegistrationIdempotencySelectSQL: {"scope = $1", "idempotency_key = $2", "FOR UPDATE"},
|
||||
}
|
||||
|
||||
@@ -63,11 +63,13 @@ WHERE assignments.allocation_id = EXCLUDED.allocation_id
|
||||
AND assignments.expires_at = EXCLUDED.expires_at
|
||||
AND assignments.revision = EXCLUDED.revision`
|
||||
|
||||
const AssignmentSelectSQL = `SELECT match_id, player_id, allocation_id, server_id,
|
||||
slot, region, client_build, protocol_version, transport, endpoint,
|
||||
join_authorisation, manifest_digest, expires_at, revision
|
||||
FROM assignments
|
||||
WHERE match_id = $1 AND player_id = $2 AND expires_at > $3`
|
||||
const AssignmentSelectSQL = `SELECT a.match_id, a.player_id, a.allocation_id, a.server_id,
|
||||
a.slot, a.region, a.client_build, a.protocol_version, a.transport, a.endpoint,
|
||||
a.join_authorisation, a.manifest_digest, a.expires_at, a.revision
|
||||
FROM assignments a
|
||||
JOIN matches m ON m.match_id = a.match_id AND m.server_id = a.server_id
|
||||
WHERE a.match_id = $1 AND a.player_id = $2 AND a.expires_at > $3
|
||||
AND m.state IN ('ASSIGNMENT_READY', 'ASSIGNED', 'CONNECTING', 'LIVE')`
|
||||
|
||||
const AssignmentRosterSelectSQL = `SELECT allocation_id, server_id, join_authorisation
|
||||
FROM assignments
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
func TestAssignmentSQLBindsPlayerAndPreservesIdenticalReplay(t *testing.T) {
|
||||
for query, fragments := range map[string][]string{
|
||||
AssignmentUpsertSQL: {"ON CONFLICT (match_id, player_id)", "WHERE assignments.allocation_id = EXCLUDED.allocation_id", "join_authorisation", "manifest_digest"},
|
||||
AssignmentSelectSQL: {"match_id = $1", "player_id = $2", "expires_at > $3"},
|
||||
AssignmentSelectSQL: {"a.match_id = $1", "a.player_id = $2", "a.expires_at > $3", "JOIN matches", "ASSIGNMENT_READY", "m.server_id = a.server_id"},
|
||||
} {
|
||||
for _, fragment := range fragments {
|
||||
if !contains(query, fragment) {
|
||||
|
||||
@@ -3,16 +3,18 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
||||
)
|
||||
|
||||
const initialConnectCandidatesSQL = `SELECT match_id, playlist, created_at
|
||||
const initialConnectCandidatesSQL = `SELECT match_id, playlist, initial_connect_ready_at
|
||||
FROM matches
|
||||
WHERE state IN ('ASSIGNMENT_READY', 'ASSIGNED', 'CONNECTING')
|
||||
ORDER BY created_at, match_id
|
||||
AND initial_connect_ready_at IS NOT NULL
|
||||
ORDER BY initial_connect_ready_at, match_id
|
||||
LIMIT $1`
|
||||
|
||||
const initialConnectHistorySQL = `SELECT starts_at
|
||||
@@ -71,6 +73,12 @@ func ReconcileInitialConnect(ctx context.Context, db *sql.DB, now time.Time, lim
|
||||
continue
|
||||
}
|
||||
if err := ApplyInitialConnectPlan(ctx, db, candidate.matchID, "initial-connect:"+candidate.matchID, plan, now); err != nil {
|
||||
// A connection receipt or another maintenance replica may have
|
||||
// changed the locked roster/state after our snapshot. Re-evaluate on
|
||||
// the next bounded pass instead of killing the maintenance process.
|
||||
if errors.Is(err, domain.ErrConflict) {
|
||||
continue
|
||||
}
|
||||
return count, err
|
||||
}
|
||||
count++
|
||||
@@ -79,7 +87,7 @@ func ReconcileInitialConnect(ctx context.Context, db *sql.DB, now time.Time, lim
|
||||
}
|
||||
|
||||
func loadInitialConnectSnapshot(ctx context.Context, db *sql.DB, matchID string) ([]domain.ConnectParticipant, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT player_id, team, connected_at
|
||||
rows, err := db.QueryContext(ctx, `SELECT player_id, team, slot, connected_at
|
||||
FROM match_participants WHERE match_id = $1 AND participation_active ORDER BY player_id`, matchID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -88,12 +96,12 @@ FROM match_participants WHERE match_id = $1 AND participation_active ORDER BY pl
|
||||
var participants []domain.ConnectParticipant
|
||||
for rows.Next() {
|
||||
var playerID string
|
||||
var team int
|
||||
var team, slot int
|
||||
var connectedAt sql.NullTime
|
||||
if err := rows.Scan(&playerID, &team, &connectedAt); err != nil {
|
||||
if err := rows.Scan(&playerID, &team, &slot, &connectedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
participants = append(participants, domain.ConnectParticipant{PlayerID: playerID, Team: team, Connected: connectedAt.Valid})
|
||||
participants = append(participants, domain.ConnectParticipant{PlayerID: playerID, Team: team, Slot: slot, Connected: connectedAt.Valid})
|
||||
}
|
||||
return participants, rows.Err()
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ const InitialConnectIdempotencyScope = "match.initial_connect"
|
||||
const initialConnectMatchLockSQL = `SELECT playlist, state, revision
|
||||
FROM matches WHERE match_id = $1 FOR UPDATE`
|
||||
|
||||
const initialConnectParticipantsSQL = `SELECT player_id, ticket_id, team, connected_at,
|
||||
const initialConnectParticipantsSQL = `SELECT player_id, ticket_id, team, slot, connected_at,
|
||||
participation_active
|
||||
FROM match_participants WHERE match_id = $1 ORDER BY player_id FOR UPDATE`
|
||||
|
||||
@@ -76,6 +76,7 @@ type initialConnectParticipant struct {
|
||||
PlayerID string
|
||||
TicketID string
|
||||
Team int
|
||||
Slot int
|
||||
ConnectedAt sql.NullTime
|
||||
Active bool
|
||||
}
|
||||
@@ -84,7 +85,8 @@ type initialConnectParticipant struct {
|
||||
// It is deliberately a store operation: no-show penalties and innocent-ticket
|
||||
// requeue must commit with the match transition or neither may commit.
|
||||
func ApplyInitialConnectPlan(ctx context.Context, db *sql.DB, matchID, idempotencyKey string, plan domain.InitialConnectPlan, now time.Time) error {
|
||||
if db == nil || matchID == "" || len(idempotencyKey) < 16 || len(idempotencyKey) > 128 || now.IsZero() || plan.Action == domain.InitialConnectWait || (plan.Action != domain.InitialConnectCancel && plan.Action != domain.InitialConnectStartWithBot) || plan.MatchState == domain.Live && plan.Action != domain.InitialConnectStartWithBot || plan.MatchState == domain.Cancelled && plan.Action != domain.InitialConnectCancel {
|
||||
validActionState := (plan.Action == domain.InitialConnectStart || plan.Action == domain.InitialConnectStartWithBot) && plan.MatchState == domain.Live || plan.Action == domain.InitialConnectCancel && plan.MatchState == domain.Cancelled
|
||||
if db == nil || matchID == "" || len(idempotencyKey) < 16 || len(idempotencyKey) > 128 || now.IsZero() || !validActionState {
|
||||
return fmt.Errorf("invalid initial-connect transaction arguments")
|
||||
}
|
||||
digest, err := initialConnectDigest(matchID, plan)
|
||||
@@ -107,7 +109,7 @@ func ApplyInitialConnectPlan(ctx context.Context, db *sql.DB, matchID, idempoten
|
||||
return err
|
||||
}
|
||||
if !bytes.Equal(prior, digest[:]) {
|
||||
return fmt.Errorf("conflicting initial-connect request")
|
||||
return fmt.Errorf("%w: conflicting initial-connect request", domain.ErrConflict)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -117,14 +119,14 @@ func ApplyInitialConnectPlan(ctx context.Context, db *sql.DB, matchID, idempoten
|
||||
return err
|
||||
}
|
||||
if state != string(domain.AssignmentReady) && state != string(domain.Assigned) && state != string(domain.Connecting) {
|
||||
return fmt.Errorf("match is not awaiting initial connect: %s", state)
|
||||
return fmt.Errorf("%w: match is not awaiting initial connect: %s", domain.ErrConflict, state)
|
||||
}
|
||||
participants, err := loadInitialConnectParticipants(ctx, tx, matchID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateInitialConnectPlan(plan, participants, domain.Playlist(playlist)); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("%w: %v", domain.ErrConflict, err)
|
||||
}
|
||||
if plan.Action == domain.InitialConnectCancel {
|
||||
if _, err := tx.ExecContext(ctx, initialConnectReleaseAllSQL, matchID); err != nil {
|
||||
@@ -195,7 +197,7 @@ func loadInitialConnectParticipants(ctx context.Context, tx *sql.Tx, matchID str
|
||||
var result []initialConnectParticipant
|
||||
for rows.Next() {
|
||||
var p initialConnectParticipant
|
||||
if err := rows.Scan(&p.PlayerID, &p.TicketID, &p.Team, &p.ConnectedAt, &p.Active); err != nil {
|
||||
if err := rows.Scan(&p.PlayerID, &p.TicketID, &p.Team, &p.Slot, &p.ConnectedAt, &p.Active); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, p)
|
||||
@@ -204,15 +206,17 @@ func loadInitialConnectParticipants(ctx context.Context, tx *sql.Tx, matchID str
|
||||
}
|
||||
|
||||
func validateInitialConnectPlan(plan domain.InitialConnectPlan, participants []initialConnectParticipant, playlist domain.Playlist) error {
|
||||
if len(participants) == 0 || (plan.Action == domain.InitialConnectStartWithBot && playlist != domain.Casual) || (plan.Action == domain.InitialConnectCancel && plan.MatchState != domain.Cancelled) {
|
||||
if len(participants) == 0 || (plan.Action == domain.InitialConnectStartWithBot && playlist != domain.Casual) || (plan.Action == domain.InitialConnectCancel && plan.MatchState != domain.Cancelled) || (plan.Action == domain.InitialConnectStart && (plan.MatchState != domain.Live || len(plan.NoShows) != 0 || len(plan.CasualLineup) != 0)) {
|
||||
return fmt.Errorf("invalid initial-connect plan")
|
||||
}
|
||||
known, connected, missing := map[string]bool{}, map[string]bool{}, map[string]bool{}
|
||||
stored := make(map[string]initialConnectParticipant, len(participants))
|
||||
for _, p := range participants {
|
||||
if p.PlayerID == "" || !p.Active || known[p.PlayerID] {
|
||||
if p.PlayerID == "" || !p.Active || p.Team < 0 || p.Team > 1 || p.Slot < 0 || p.Slot > 5 || p.Slot/3 != p.Team || known[p.PlayerID] {
|
||||
return fmt.Errorf("invalid stored participant roster")
|
||||
}
|
||||
known[p.PlayerID] = true
|
||||
stored[p.PlayerID] = p
|
||||
if p.ConnectedAt.Valid {
|
||||
connected[p.PlayerID] = true
|
||||
}
|
||||
@@ -232,6 +236,9 @@ func validateInitialConnectPlan(plan domain.InitialConnectPlan, participants []i
|
||||
if len(missing) != len(known) {
|
||||
return fmt.Errorf("initial-connect plan does not cover roster")
|
||||
}
|
||||
if plan.Action == domain.InitialConnectStart && len(connected) != len(known) {
|
||||
return fmt.Errorf("initial-connect start requires complete connected roster")
|
||||
}
|
||||
if plan.Action == domain.InitialConnectStartWithBot {
|
||||
if len(plan.CasualLineup) != 6 {
|
||||
return fmt.Errorf("casual bot lineup must contain six players")
|
||||
@@ -239,7 +246,7 @@ func validateInitialConnectPlan(plan domain.InitialConnectPlan, participants []i
|
||||
lineupSlots := make(map[int]bool, 6)
|
||||
lineupPlayers := make(map[string]bool, 6)
|
||||
for _, slot := range plan.CasualLineup {
|
||||
if slot.Slot < 0 || slot.Slot > 5 || slot.Team != slot.Slot%2 || lineupSlots[slot.Slot] || slot.PlayerID == "" || lineupPlayers[slot.PlayerID] {
|
||||
if slot.Slot < 0 || slot.Slot > 5 || slot.Team != slot.Slot/3 || lineupSlots[slot.Slot] || slot.PlayerID == "" || lineupPlayers[slot.PlayerID] {
|
||||
return fmt.Errorf("invalid casual bot lineup")
|
||||
}
|
||||
lineupSlots[slot.Slot] = true
|
||||
@@ -250,6 +257,10 @@ func validateInitialConnectPlan(plan domain.InitialConnectPlan, participants []i
|
||||
if !connected[slot.PlayerID] {
|
||||
return fmt.Errorf("lineup contains non-connected human")
|
||||
}
|
||||
participant := stored[slot.PlayerID]
|
||||
if participant.Slot != slot.Slot || participant.Team != slot.Team {
|
||||
return fmt.Errorf("lineup moves connected human from assigned slot")
|
||||
}
|
||||
}
|
||||
for id := range connected {
|
||||
if !lineupPlayers[id] {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
func TestInitialConnectSQLPreservesAtomicNoShowReconciliation(t *testing.T) {
|
||||
if !contains(initialConnectCandidatesSQL, "ASSIGNMENT_READY") || !contains(initialConnectCandidatesSQL, "LIMIT $1") {
|
||||
if !contains(initialConnectCandidatesSQL, "ASSIGNMENT_READY") || !contains(initialConnectCandidatesSQL, "initial_connect_ready_at") || !contains(initialConnectCandidatesSQL, "LIMIT $1") {
|
||||
t.Fatal("initial-connect sweep is not bounded to pre-live matches")
|
||||
}
|
||||
for query, fragments := range map[string][]string{
|
||||
@@ -31,28 +31,45 @@ func TestInitialConnectSQLPreservesAtomicNoShowReconciliation(t *testing.T) {
|
||||
|
||||
func TestInitialConnectPlanValidationRejectsIncompleteOrForgedPlans(t *testing.T) {
|
||||
participants := []initialConnectParticipant{
|
||||
{PlayerID: "p0", TicketID: "t0", Team: 0, ConnectedAt: validTime(100), Active: true},
|
||||
{PlayerID: "p1", TicketID: "t1", Team: 1, Active: true},
|
||||
{PlayerID: "p0", TicketID: "t0", Team: 0, Slot: 0, ConnectedAt: validTime(100), Active: true},
|
||||
{PlayerID: "p1", TicketID: "t1", Team: 1, Slot: 3, Active: true},
|
||||
}
|
||||
plan := domain.InitialConnectPlan{
|
||||
Action: domain.InitialConnectStartWithBot, MatchState: domain.Live,
|
||||
Connected: []string{"p0"},
|
||||
NoShows: []domain.Abandonment{{PlayerID: "p1", Cooldown: time.Minute, AbandonedAt: time.Unix(100, 0)}},
|
||||
CasualLineup: []domain.CasualSlot{
|
||||
{Slot: 0, Team: 0, PlayerID: "p0"}, {Slot: 1, Team: 1, PlayerID: "bot-1", IsBot: true},
|
||||
{Slot: 0, Team: 0, PlayerID: "p0"}, {Slot: 1, Team: 0, PlayerID: "bot-1", IsBot: true},
|
||||
{Slot: 2, Team: 0, PlayerID: "bot-2", IsBot: true}, {Slot: 3, Team: 1, PlayerID: "bot-3", IsBot: true},
|
||||
{Slot: 4, Team: 0, PlayerID: "bot-4", IsBot: true}, {Slot: 5, Team: 1, PlayerID: "bot-5", IsBot: true},
|
||||
{Slot: 4, Team: 1, PlayerID: "bot-4", IsBot: true}, {Slot: 5, Team: 1, PlayerID: "bot-5", IsBot: true},
|
||||
},
|
||||
}
|
||||
if err := validateInitialConnectPlan(plan, participants, domain.Casual); err != nil {
|
||||
t.Fatalf("valid plan rejected: %v", err)
|
||||
}
|
||||
plan.CasualLineup[1].Team = 0
|
||||
plan.CasualLineup[1].Team = 1
|
||||
if err := validateInitialConnectPlan(plan, participants, domain.Casual); err == nil {
|
||||
t.Fatal("team-swapped lineup accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialConnectPlanValidationRequiresCompleteRosterToStart(t *testing.T) {
|
||||
participants := []initialConnectParticipant{
|
||||
{PlayerID: "p0", TicketID: "t0", Team: 0, Slot: 0, ConnectedAt: validTime(100), Active: true},
|
||||
{PlayerID: "p1", TicketID: "t1", Team: 1, Slot: 3, ConnectedAt: validTime(100), Active: true},
|
||||
}
|
||||
plan := domain.InitialConnectPlan{
|
||||
Action: domain.InitialConnectStart, MatchState: domain.Live, Connected: []string{"p0", "p1"},
|
||||
}
|
||||
if err := validateInitialConnectPlan(plan, participants, domain.Ranked); err != nil {
|
||||
t.Fatalf("valid complete start rejected: %v", err)
|
||||
}
|
||||
participants[1].ConnectedAt = sql.NullTime{}
|
||||
if err := validateInitialConnectPlan(plan, participants, domain.Ranked); err == nil {
|
||||
t.Fatal("start with disconnected participant accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func validTime(unix int64) (result sql.NullTime) {
|
||||
result.Time = time.Unix(unix, 0)
|
||||
result.Valid = true
|
||||
|
||||
@@ -468,7 +468,7 @@ func TestPostgreSQLAssignmentPersistenceIsPlayerScopedAndExpiryBound(t *testing.
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO queue_tickets (ticket_id, player_id, playlist, state, client_build, protocol_version, enqueued_at, expires_at) VALUES ('assignment-ticket', 'assignment-player', 'casual', 'ASSIGNED', 'integration-build', 1, $1, $2)`, now, now.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO matches (match_id, playlist, state, region, protocol_version, server_id) VALUES ('assignment-match', 'casual', 'ASSIGNED', 'EU', 1, 'assignment-server')`); err != nil {
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO matches (match_id, playlist, state, region, protocol_version, server_id, initial_connect_ready_at) VALUES ('assignment-match', 'casual', 'ASSIGNED', 'EU', 1, 'assignment-server', $1)`, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO match_participants (match_id, player_id, ticket_id, slot, team) VALUES ('assignment-match', 'assignment-player', 'assignment-ticket', 0, 0)`); err != nil {
|
||||
@@ -493,6 +493,71 @@ func TestPostgreSQLAssignmentPersistenceIsPlayerScopedAndExpiryBound(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgreSQLConnectionReceiptsStartCompleteRelaxedCasualRoster(t *testing.T) {
|
||||
db := openIntegrationPostgres(t)
|
||||
applyIntegrationMigrations(t, db)
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
playerID := fmt.Sprintf("connect-player-%d", i)
|
||||
ticketID := fmt.Sprintf("connect-ticket-%d", i)
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO identities (player_id, steam_id) VALUES ($1, $1)`, playerID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO queue_tickets (ticket_id, player_id, playlist, state, client_build, protocol_version, enqueued_at, expires_at) VALUES ($1, $2, 'casual', 'ASSIGNMENT_READY', 'integration-build', 1, $3, $4)`, ticketID, playerID, now, now.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO game_servers (server_id, region, build, protocol_version, transport, state) VALUES ('connect-server', 'EU', 'integration-build', 1, 'enet', 'ALLOCATED')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO matches (match_id, playlist, state, region, protocol_version, server_id, allocation_id, initial_connect_ready_at) VALUES ('connect-match', 'casual', 'ASSIGNMENT_READY', 'EU', 1, 'connect-server', 'connect-allocation', $1)`, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO allocations (allocation_id, match_id, server_id, region, build, protocol_version, transport, request_digest, state, allocated_at) VALUES ('connect-allocation', 'connect-match', 'connect-server', 'EU', 'integration-build', 1, 'enet', $1, 'ALLOCATED', $2)`, []byte("request"), now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
playerID := fmt.Sprintf("connect-player-%d", i)
|
||||
ticketID := fmt.Sprintf("connect-ticket-%d", i)
|
||||
slot := i * 3
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO match_participants (match_id, player_id, ticket_id, slot, team) VALUES ('connect-match', $1, $2, $3, $4)`, playerID, ticketID, slot, i); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO assignments (match_id, player_id, allocation_id, server_id, slot, region, client_build, protocol_version, transport, endpoint, join_authorisation, manifest_digest, expires_at) VALUES ('connect-match', $1, 'connect-allocation', 'connect-server', $2, 'EU', 'integration-build', 1, 'enet', '127.0.0.1:7777', 'join-token', $3, $4)`, playerID, slot, []byte("manifest"), now.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
binding := domain.WorkloadBinding{AllocationID: "connect-allocation", MatchID: "connect-match", ServerID: "connect-server"}
|
||||
if err := RecordPlayerConnected(ctx, db, binding, "connect-player-0", "connect-receipt-key-0000", now); err != nil {
|
||||
t.Fatalf("first receipt: %v", err)
|
||||
}
|
||||
if err := RecordPlayerConnected(ctx, db, domain.WorkloadBinding{AllocationID: "forged-allocation", MatchID: "connect-match", ServerID: "connect-server"}, "connect-player-1", "connect-receipt-key-forged", now); !errors.Is(err, domain.ErrConflict) {
|
||||
t.Fatalf("forged binding err=%v, want conflict", err)
|
||||
}
|
||||
if err := RecordPlayerConnected(ctx, db, binding, "connect-player-1", "connect-receipt-key-0001", now); err != nil {
|
||||
t.Fatalf("second receipt: %v", err)
|
||||
}
|
||||
reconciled, err := ReconcileInitialConnect(ctx, db, now.Add(time.Second), 10)
|
||||
if err != nil || reconciled != 1 {
|
||||
t.Fatalf("reconcile count=%d err=%v", reconciled, err)
|
||||
}
|
||||
var state string
|
||||
if err := db.QueryRowContext(ctx, `SELECT state FROM matches WHERE match_id = 'connect-match'`).Scan(&state); err != nil || state != string(domain.Live) {
|
||||
t.Fatalf("match state=%q err=%v", state, err)
|
||||
}
|
||||
var liveTickets int
|
||||
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM queue_tickets WHERE state = 'LIVE'`).Scan(&liveTickets); err != nil || liveTickets != 2 {
|
||||
t.Fatalf("live tickets=%d err=%v", liveTickets, err)
|
||||
}
|
||||
// A lost 204 can be retried after assignment expiry because the exact
|
||||
// durable receipt is replayed before checking the now-expired assignment.
|
||||
if err := RecordPlayerConnected(ctx, db, binding, "connect-player-0", "connect-receipt-key-0000", now.Add(2*time.Minute)); err != nil {
|
||||
t.Fatalf("durable receipt replay: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgreSQLProposalClaimAndResponseAreAtomic(t *testing.T) {
|
||||
db := openIntegrationPostgres(t)
|
||||
applyIntegrationMigrations(t, db)
|
||||
@@ -1333,8 +1398,19 @@ func TestPostgreSQLMigrationsRollBackAndReapplyCleanly(t *testing.T) {
|
||||
// Roll back every migration one at a time, in reverse, checking each
|
||||
// down file actually undoes what its forward file created — not just
|
||||
// that Rollback returns nil.
|
||||
if err := migrations.Rollback(context.Background(), db, dir, 2); err != nil {
|
||||
t.Fatalf("rollback 0007 and 0006: %v", err)
|
||||
if err := migrations.Rollback(context.Background(), db, dir, 4); err != nil {
|
||||
t.Fatalf("rollback 0010 through 0007: %v", err)
|
||||
}
|
||||
var hasInitialConnectReadyColumn bool
|
||||
if err := db.QueryRow(`SELECT count(*) > 0 FROM information_schema.columns WHERE table_name = 'matches' AND column_name = 'initial_connect_ready_at'`).Scan(&hasInitialConnectReadyColumn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hasInitialConnectReadyColumn {
|
||||
t.Fatal("0010 rollback did not drop matches.initial_connect_ready_at")
|
||||
}
|
||||
|
||||
if err := migrations.Rollback(context.Background(), db, dir, 1); err != nil {
|
||||
t.Fatalf("rollback 0006: %v", err)
|
||||
}
|
||||
var hasAllocationClaimColumn bool
|
||||
if err := db.QueryRow(`SELECT count(*) > 0 FROM information_schema.columns WHERE table_name = 'matches' AND column_name = 'allocation_id'`).Scan(&hasAllocationClaimColumn); err != nil {
|
||||
@@ -1345,7 +1421,7 @@ func TestPostgreSQLMigrationsRollBackAndReapplyCleanly(t *testing.T) {
|
||||
}
|
||||
|
||||
if err := migrations.Rollback(context.Background(), db, dir, 4); err != nil {
|
||||
t.Fatalf("rollback remaining down to 0001: %v", err)
|
||||
t.Fatalf("rollback 0005 through 0002: %v", err)
|
||||
}
|
||||
if tableExists("assignments") || tableExists("allocations") || tableExists("game_servers") {
|
||||
t.Fatal("rollback left later-migration tables behind")
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
||||
)
|
||||
|
||||
func TestServerConnectionSQLBindsWorkloadParticipantAndLiveAssignment(t *testing.T) {
|
||||
for _, fragment := range []string{
|
||||
"connected_at = COALESCE", "mp.participation_active", "m.server_id = $2",
|
||||
"a.allocation_id = $3", "a.state = 'ALLOCATED'", "assn.player_id = mp.player_id",
|
||||
"assn.expires_at > $5", "RETURNING mp.connected_at",
|
||||
} {
|
||||
if !strings.Contains(ServerConnectionParticipantSQL, fragment) {
|
||||
t.Fatalf("connection SQL missing %q: %s", fragment, ServerConnectionParticipantSQL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordPlayerConnectedRejectsInvalidArguments(t *testing.T) {
|
||||
binding := domain.WorkloadBinding{AllocationID: "allocation-1", MatchID: "match-1", ServerID: "server-1"}
|
||||
if err := RecordPlayerConnected(context.Background(), (*sql.DB)(nil), binding, "player-1", "connection-key-123456", time.Unix(1000, 0)); err == nil {
|
||||
t.Fatal("nil database accepted")
|
||||
}
|
||||
if err := RecordPlayerConnected(context.Background(), &sql.DB{}, domain.WorkloadBinding{}, "player-1", "connection-key-123456", time.Unix(1000, 0)); err == nil {
|
||||
t.Fatal("empty workload binding accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user