Files
CosmicClash/server/migrations/0001_initial.sql
T
2026-08-31 21:43:07 +01:00

178 lines
6.8 KiB
SQL

-- Cosmic Clash matchmaking control plane, migration 0001.
-- PostgreSQL is the durable authority. Redis indexes are rebuildable and do
-- not appear in this schema or in any ownership constraint.
CREATE TABLE identities (
player_id TEXT PRIMARY KEY,
steam_id TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
banned_until TIMESTAMPTZ,
ban_reason TEXT
);
CREATE TABLE sessions (
session_id TEXT PRIMARY KEY,
player_id TEXT NOT NULL REFERENCES identities(player_id),
token_digest BYTEA NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE idempotency_keys (
scope TEXT NOT NULL,
idempotency_key TEXT NOT NULL CHECK (char_length(idempotency_key) BETWEEN 16 AND 128),
payload_digest BYTEA NOT NULL,
result JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (scope, idempotency_key)
);
CREATE TABLE queue_tickets (
ticket_id TEXT PRIMARY KEY,
player_id TEXT NOT NULL REFERENCES identities(player_id),
playlist TEXT NOT NULL CHECK (playlist IN ('casual', 'ranked')),
state TEXT NOT NULL CHECK (state IN ('QUEUED', 'PROPOSED', 'ACCEPTED', 'ALLOCATING', 'PROCESS_READY', 'ASSIGNMENT_READY', 'ASSIGNED', 'CONNECTING', 'LIVE', 'RESULT_PENDING', 'COMPLETED', 'CANCELLED', 'EXPIRED', 'FAILED')),
client_build TEXT NOT NULL,
protocol_version INTEGER NOT NULL CHECK (protocol_version > 0),
enqueued_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revision BIGINT NOT NULL DEFAULT 0 CHECK (revision >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX queue_tickets_one_active_per_player
ON queue_tickets (player_id)
WHERE state IN ('QUEUED', 'PROPOSED', 'ACCEPTED', 'ALLOCATING', 'PROCESS_READY', 'ASSIGNMENT_READY', 'ASSIGNED', 'CONNECTING', 'LIVE', 'RESULT_PENDING');
CREATE TABLE proposals (
proposal_id TEXT PRIMARY KEY,
playlist TEXT NOT NULL CHECK (playlist IN ('casual', 'ranked')),
state TEXT NOT NULL CHECK (state IN ('OPEN', 'ACCEPTED', 'DECLINED', 'EXPIRED', 'CANCELLED')),
expires_at TIMESTAMPTZ NOT NULL,
revision BIGINT NOT NULL DEFAULT 0 CHECK (revision >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE proposal_participants (
proposal_id TEXT NOT NULL REFERENCES proposals(proposal_id),
player_id TEXT NOT NULL REFERENCES identities(player_id),
ticket_id TEXT NOT NULL REFERENCES queue_tickets(ticket_id),
response TEXT NOT NULL CHECK (response IN ('PENDING', 'ACCEPTED', 'DECLINED', 'TIMED_OUT')),
responded_at TIMESTAMPTZ,
PRIMARY KEY (proposal_id, player_id),
UNIQUE (proposal_id, ticket_id)
);
CREATE TABLE matches (
match_id TEXT PRIMARY KEY,
playlist TEXT NOT NULL CHECK (playlist IN ('casual', 'ranked')),
state TEXT NOT NULL CHECK (state IN ('ALLOCATING', 'PROCESS_READY', 'ASSIGNMENT_READY', 'ASSIGNED', 'CONNECTING', 'LIVE', 'RESULT_PENDING', 'COMPLETED', 'CANCELLED', 'FAILED')),
region TEXT NOT NULL CHECK (region IN ('EU', 'NA')),
protocol_version INTEGER NOT NULL CHECK (protocol_version > 0),
server_id TEXT,
revision BIGINT NOT NULL DEFAULT 0 CHECK (revision >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ
);
CREATE TABLE match_participants (
match_id TEXT NOT NULL REFERENCES matches(match_id),
player_id TEXT NOT NULL REFERENCES identities(player_id),
ticket_id TEXT NOT NULL REFERENCES queue_tickets(ticket_id),
slot INTEGER NOT NULL CHECK (slot BETWEEN 0 AND 5),
team INTEGER NOT NULL CHECK (team IN (0, 1)),
connection_generation BIGINT NOT NULL DEFAULT 0 CHECK (connection_generation >= 0),
connected_at TIMESTAMPTZ,
abandoned_at TIMESTAMPTZ,
participation_active BOOLEAN NOT NULL DEFAULT TRUE,
PRIMARY KEY (match_id, player_id),
UNIQUE (match_id, slot)
);
CREATE UNIQUE INDEX match_participants_one_active_match
ON match_participants (player_id)
WHERE participation_active;
CREATE TABLE ratings (
player_id TEXT PRIMARY KEY REFERENCES identities(player_id),
rating DOUBLE PRECISION NOT NULL DEFAULT 1500,
deviation DOUBLE PRECISION NOT NULL DEFAULT 350,
volatility DOUBLE PRECISION NOT NULL DEFAULT 0.06,
ranked_games INTEGER NOT NULL DEFAULT 0 CHECK (ranked_games >= 0),
revision BIGINT NOT NULL DEFAULT 0 CHECK (revision >= 0),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE seasons (
season_id TEXT PRIMARY KEY,
playlist TEXT NOT NULL CHECK (playlist = 'ranked'),
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL CHECK (ends_at > starts_at),
rolled_over_at TIMESTAMPTZ
);
CREATE TABLE ranked_season_rollovers (
player_id TEXT NOT NULL REFERENCES identities(player_id),
season_id TEXT NOT NULL REFERENCES seasons(season_id),
rating DOUBLE PRECISION NOT NULL,
deviation DOUBLE PRECISION NOT NULL,
volatility DOUBLE PRECISION NOT NULL,
ranked_games INTEGER NOT NULL CHECK (ranked_games >= 0),
rolled_over_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (player_id, season_id)
);
CREATE TABLE penalties (
penalty_id TEXT PRIMARY KEY,
player_id TEXT NOT NULL REFERENCES identities(player_id),
match_id TEXT REFERENCES matches(match_id),
playlist TEXT NOT NULL CHECK (playlist IN ('casual', 'ranked')),
kind TEXT NOT NULL,
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL CHECK (ends_at > starts_at),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE result_receipts (
result_id TEXT PRIMARY KEY,
match_id TEXT NOT NULL UNIQUE REFERENCES matches(match_id),
result_nonce TEXT NOT NULL UNIQUE,
payload_digest BYTEA NOT NULL,
integrity_state TEXT NOT NULL CHECK (integrity_state IN ('CERTIFIED', 'SUPPRESSED', 'REVIEW')),
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
committed_at TIMESTAMPTZ
);
CREATE TABLE outbox (
event_id TEXT PRIMARY KEY,
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
revision BIGINT NOT NULL CHECK (revision >= 0),
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ,
UNIQUE (aggregate_type, aggregate_id, revision)
);
CREATE TABLE audit_events (
audit_id BIGSERIAL PRIMARY KEY,
actor_type TEXT NOT NULL CHECK (actor_type IN ('PLAYER', 'SERVER', 'SYSTEM', 'ADMIN')),
actor_id TEXT,
action TEXT NOT NULL,
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
request_id TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX queue_tickets_candidate_order
ON queue_tickets (playlist, enqueued_at, ticket_id)
WHERE state = 'QUEUED';
CREATE INDEX outbox_unpublished_order
ON outbox (created_at, event_id)
WHERE published_at IS NULL;