feat: redesign match play and add broadcast tooling

This commit is contained in:
2026-09-29 02:52:56 +00:00
parent f417a831f7
commit 9b9aafefc1
348 changed files with 31588 additions and 11094 deletions

View File

@@ -0,0 +1,47 @@
-- Platform columns and tables added in the 2026 rewrite. Idempotent: safe on
-- databases where the old migrate-platform script already ran.
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS osu_id integer;
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS country_code text;
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS discord_id text;
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS banned_at timestamptz;
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS timezone text;
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS available_hours jsonb;
CREATE UNIQUE INDEX IF NOT EXISTS user_osu_id_idx ON "user" (osu_id) WHERE osu_id IS NOT NULL;
ALTER TABLE player_rating ADD COLUMN IF NOT EXISTS provisional boolean NOT NULL DEFAULT true;
UPDATE player_rating SET provisional = false WHERE wins + losses >= 5 AND provisional;
CREATE TABLE IF NOT EXISTS team_rating (
team_id uuid PRIMARY KEY REFERENCES team(id) ON DELETE CASCADE,
elo integer NOT NULL DEFAULT 1000,
wins integer NOT NULL DEFAULT 0,
losses integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE mappool ADD COLUMN IF NOT EXISTS origin text;
ALTER TABLE mappool ADD COLUMN IF NOT EXISTS rating real;
ALTER TABLE match ADD COLUMN IF NOT EXISTS game text NOT NULL DEFAULT 'osu';
ALTER TABLE match ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'custom';
ALTER TABLE match ADD COLUMN IF NOT EXISTS procedure jsonb;
ALTER TABLE match ADD COLUMN IF NOT EXISTS deadline_at timestamptz;
ALTER TABLE match ADD COLUMN IF NOT EXISTS elo_applied boolean NOT NULL DEFAULT false;
ALTER TABLE match_queue ADD COLUMN IF NOT EXISTS format integer NOT NULL DEFAULT 1;
CREATE TABLE IF NOT EXISTS match_event (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
match_id uuid NOT NULL REFERENCES match(id) ON DELETE CASCADE,
type text NOT NULL,
detail text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_match_event_match ON match_event (match_id, created_at);
CREATE TABLE IF NOT EXISTS system_state (
id text PRIMARY KEY,
draining boolean NOT NULL DEFAULT false,
reason text,
updated_at timestamptz NOT NULL DEFAULT now()
);

View File

@@ -0,0 +1,37 @@
-- Additive migration: old releases can still read their original email column.
-- Runtime identity is always account(provider_id, account_id), never an email pattern.
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS contact_email text;
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS root_admin boolean NOT NULL DEFAULT false;
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS merged_into_user_id text;
ALTER TABLE account ADD COLUMN IF NOT EXISTS profile jsonb;
UPDATE "user" SET contact_email = lower(email)
WHERE contact_email IS NULL AND email !~* '@(osu\.local|discord\.local|identity\.invalid)$';
CREATE UNIQUE INDEX IF NOT EXISTS user_contact_email_unique ON "user" (contact_email);
-- Fail the transaction for conflicting connections; never guess which user owns one.
CREATE UNIQUE INDEX IF NOT EXISTS account_provider_identity_idx ON account (provider_id, account_id);
CREATE UNIQUE INDEX IF NOT EXISTS account_user_provider_idx ON account (user_id, provider_id);
-- Profile backfill is best effort; OAuth refreshes it on the next successful sign-in.
UPDATE account a SET profile = jsonb_build_object(
'username', u.name, 'image', u.image, 'email', null, 'emailVerified', false,
'countryCode', u.country_code, 'globalRank', r.osu_rank_at_seed
)
FROM "user" u LEFT JOIN player_rating r ON r.user_id = u.id
WHERE a.user_id = u.id AND a.provider_id IN ('osu', 'discord') AND a.profile IS NULL;
-- Repair solo roster snapshots only where ownership is unambiguous. Match discovery
-- and authorization can then use the same persisted participant roster.
INSERT INTO team_member (team_id, user_id, role)
SELECT t.id, t.owner_id, 'owner' FROM team t
JOIN "user" u ON u.id = t.owner_id
WHERE t.is_personal AND NOT EXISTS (
SELECT 1 FROM team_member tm WHERE tm.team_id = t.id AND tm.user_id = t.owner_id
);
INSERT INTO match_participant_player (participant_id, user_id)
SELECT p.id, t.owner_id FROM match_participant p JOIN team t ON t.id = p.team_id
JOIN "user" u ON u.id = t.owner_id
WHERE t.is_personal AND NOT EXISTS (
SELECT 1 FROM match_participant_player pp WHERE pp.participant_id = p.id
);

View File

@@ -0,0 +1,20 @@
-- Optimistic locking for the match actor, the hosting IRC account for
-- rejoining after a restart, and an undo log for rating changes.
ALTER TABLE match ADD COLUMN IF NOT EXISTS revision integer NOT NULL DEFAULT 0;
ALTER TABLE match ADD COLUMN IF NOT EXISTS irc_account text;
CREATE TABLE IF NOT EXISTS rating_change (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
match_id uuid NOT NULL REFERENCES match(id) ON DELETE CASCADE,
subject text NOT NULL,
subject_id text NOT NULL,
before integer NOT NULL,
after integer NOT NULL,
won boolean NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_rating_change_match ON rating_change (match_id);
CREATE INDEX IF NOT EXISTS idx_rating_change_subject ON rating_change (subject_id);
-- Matches the previous process left mid-flight are resumed by the runtime.
CREATE INDEX IF NOT EXISTS idx_match_game_match_state ON match_game (match_id, state);

View File

@@ -0,0 +1,7 @@
-- Tournament pages carry their own identity: icon, accent, forum post, links, prizes.
ALTER TABLE tournament ADD COLUMN IF NOT EXISTS tagline text;
ALTER TABLE tournament ADD COLUMN IF NOT EXISTS icon_url text;
ALTER TABLE tournament ADD COLUMN IF NOT EXISTS accent_color text;
ALTER TABLE tournament ADD COLUMN IF NOT EXISTS forum_post text;
ALTER TABLE tournament ADD COLUMN IF NOT EXISTS links jsonb;
ALTER TABLE tournament ADD COLUMN IF NOT EXISTS prizes jsonb;

View File

@@ -0,0 +1,6 @@
-- OpenSkill ratings: elo becomes mu, and sigma holds the uncertainty.
-- Existing rows keep sigma NULL; the app estimates it from games played.
ALTER TABLE player_rating ADD COLUMN IF NOT EXISTS sigma real;
ALTER TABLE team_rating ADD COLUMN IF NOT EXISTS sigma real;
ALTER TABLE rating_change ADD COLUMN IF NOT EXISTS sigma_before real;
ALTER TABLE rating_change ADD COLUMN IF NOT EXISTS sigma_after real;

View File

@@ -0,0 +1,18 @@
-- Spectator predictions and tournament pick'ems.
CREATE TABLE IF NOT EXISTS prediction (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id text NOT NULL,
match_id uuid REFERENCES match(id) ON DELETE CASCADE,
tournament_match_id uuid REFERENCES tournament_match(id) ON DELETE CASCADE,
winner_team_id uuid NOT NULL REFERENCES team(id) ON DELETE CASCADE,
winner_score integer,
loser_score integer,
points integer,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT prediction_one_subject CHECK ((match_id IS NULL) <> (tournament_match_id IS NULL))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_prediction_user_match ON prediction (user_id, match_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_prediction_user_tmatch ON prediction (user_id, tournament_match_id);
CREATE INDEX IF NOT EXISTS idx_prediction_match ON prediction (match_id);
CREATE INDEX IF NOT EXISTS idx_prediction_tmatch ON prediction (tournament_match_id);

View File

@@ -0,0 +1,15 @@
-- Drizzle's jsonb with Bun.SQL stored objects as JSON strings ("{\"a\":1}")
-- until src/lib/server/db/jsonb.ts. Turn those strings back into objects.
DO $$
DECLARE r record;
BEGIN
FOR r IN
SELECT table_name, column_name FROM information_schema.columns
WHERE table_schema = 'public' AND data_type = 'jsonb'
LOOP
EXECUTE format(
'UPDATE %I SET %I = (%I #>> ''{}'')::jsonb WHERE jsonb_typeof(%I) = ''string'' AND left(%I #>> ''{}'', 1) IN (''{'', ''['')',
r.table_name, r.column_name, r.column_name, r.column_name, r.column_name
);
END LOOP;
END $$;

View File

@@ -0,0 +1,13 @@
-- Stream channels: what an overlay shows and how it is laid out.
CREATE TABLE IF NOT EXISTS broadcast_channel (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
mode text NOT NULL DEFAULT 'auto',
match_id uuid REFERENCES match(id) ON DELETE SET NULL,
tournament_id uuid REFERENCES tournament(id) ON DELETE CASCADE,
layout jsonb NOT NULL,
agent_token_hash text,
created_by text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);

View File

@@ -0,0 +1,4 @@
-- Matches now remember where their lobby lives. Simulated lobbies are numbered
-- from 2,090,000,000 up, far above real osu! match ids.
UPDATE match SET config = config || '{"lobby":"simulator"}'::jsonb
WHERE osu_lobby_id >= 2000000000 AND NOT (config ? 'lobby');

View File

@@ -0,0 +1,30 @@
-- Streaming to platforms, recordings and on-site playback.
ALTER TABLE broadcast_channel ADD COLUMN IF NOT EXISTS format text NOT NULL DEFAULT 'landscape';
ALTER TABLE broadcast_channel ADD COLUMN IF NOT EXISTS quality text NOT NULL DEFAULT '1080p30';
ALTER TABLE broadcast_channel ADD COLUMN IF NOT EXISTS live boolean NOT NULL DEFAULT false;
ALTER TABLE broadcast_channel ADD COLUMN IF NOT EXISTS record boolean NOT NULL DEFAULT true;
CREATE TABLE IF NOT EXISTS broadcast_destination (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id uuid NOT NULL REFERENCES broadcast_channel(id) ON DELETE CASCADE,
platform text NOT NULL,
label text NOT NULL,
ingest_url text NOT NULL,
stream_key text NOT NULL,
enabled boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_broadcast_destination_channel ON broadcast_destination (channel_id);
CREATE TABLE IF NOT EXISTS broadcast_session (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id uuid NOT NULL REFERENCES broadcast_channel(id) ON DELETE CASCADE,
started_at timestamptz NOT NULL DEFAULT now(),
ended_at timestamptz,
seen_at timestamptz NOT NULL DEFAULT now(),
storage_prefix text,
duration_sec integer NOT NULL DEFAULT 0,
chapters jsonb NOT NULL DEFAULT '[]',
destinations jsonb NOT NULL DEFAULT '[]'
);
CREATE INDEX IF NOT EXISTS idx_broadcast_session_channel ON broadcast_session (channel_id, started_at);