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,21 @@
-- Dev database for Vash Esports, matching the personal-website pattern:
-- one login role owns one database on the central Postgres instance.
-- Run as the postgres superuser:
-- psql -h 127.0.0.1 -p 5440 -U postgres -d postgres -v password='choose-one' -f scripts/bootstrap-dev-database.sql
--
-- Production stays in database "vash" (owner role vash, app role vash_app).
-- Staging stays in database "vash_staging" (owner role vash_staging, app role vash_staging_app).
SELECT format('CREATE ROLE vash_dev LOGIN PASSWORD %L', :'password')
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'vash_dev') \gexec
SELECT 'CREATE DATABASE vash_dev OWNER vash_dev'
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'vash_dev') \gexec
REVOKE ALL ON DATABASE vash_dev FROM PUBLIC;
GRANT CONNECT, TEMPORARY ON DATABASE vash_dev TO vash_dev;
\connect vash_dev
REVOKE ALL ON SCHEMA public FROM PUBLIC;
GRANT ALL ON SCHEMA public TO vash_dev;
ALTER SCHEMA public OWNER TO vash_dev;

129
scripts/deploy-remote.sh Executable file
View File

@@ -0,0 +1,129 @@
#!/usr/bin/env bash
set -euo pipefail
sha=${1:?commit SHA required}
directory=${2:?deployment directory required}
origin=${3:?public origin required}
case "$sha" in
*[!0-9a-f]*|'') echo 'Invalid image tag' >&2; exit 2 ;;
esac
case "$directory:$origin" in
/opt/esports:https://esports.vash.software)
environment=/etc/esports/prod.env
host_port=3100 ;;
/opt/esports-staging:https://esports-staging.vash.software)
environment=/etc/esports-staging/staging.env
host_port=3101 ;;
*) echo 'Unrecognized deployment target' >&2; exit 2 ;;
esac
image="git.stanrunge.dev/stan/esports:$sha"
compose="$directory/compose.prod.yml"
if [ "$directory" = /opt/esports-staging ]; then
environment_name=staging
else
environment_name=production
fi
sudo install -d -m 0755 "$directory"
if [ ! -f "$directory/release.env" ]; then
printf 'IMAGE_TAG=%s\nHOST_PORT=%s\n' "$sha" "$host_port" | sudo tee "$directory/release.env" >/dev/null
fi
if ! grep -q '^HOST_PORT=' "$directory/release.env"; then
echo "HOST_PORT=$host_port" | sudo tee -a "$directory/release.env" >/dev/null
fi
sudo tee "$compose" >/dev/null <<EOF
services:
app:
image: git.stanrunge.dev/stan/esports:\${IMAGE_TAG}
restart: unless-stopped
stop_grace_period: 30s
env_file:
- path: $environment
format: raw
environment:
PORT: 3000
HOST: 0.0.0.0
BODY_SIZE_LIMIT: 6M
PROTOCOL_HEADER: x-forwarded-proto
HOST_HEADER: x-forwarded-host
ADDRESS_HEADER: CF-Connecting-IP
ports:
- 127.0.0.1:\${HOST_PORT}:3000
networks:
- backend
networks:
backend:
external: true
EOF
previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/release.env" | head -n 1)
switched=0
rollback() {
if [ "$switched" -eq 1 ] && [ -n "$previous_tag" ]; then
echo "Health check failed; restoring prior image tag $previous_tag" >&2
sudo sed -i "s/^IMAGE_TAG=.*/IMAGE_TAG=$previous_tag/" "$directory/release.env"
sudo docker compose --env-file "$directory/release.env" -f "$compose" up -d
fi
}
trap rollback ERR
port=$(sed -n 's/^HOST_PORT=//p' "$directory/release.env" | head -n 1)
if [ "${FORCE_DEPLOY:-}" != 1 ] && curl --fail --silent --show-error "http://127.0.0.1:${port}/api/health" > /tmp/esports-health.json; then
python3 - <<'PY'
import json
data = json.load(open("/tmp/esports-health.json"))
active = int(data.get("activeMatches") or data.get("irc", {}).get("activeLobbies") or 0)
if active:
raise SystemExit(f"{active} matches are still live. Drain them from the admin page, or set FORCE_DEPLOY=1.")
PY
fi
# The old release reports lobbies rather than active matches, and its health
# endpoint may be unavailable. The database check covers both releases.
if [ "${FORCE_DEPLOY:-}" != 1 ]; then
active_count=$(sudo docker run --rm --network backend --env-file "$environment" "$image" \
bun -e 'const sql = new Bun.SQL(process.env.DB_URL || process.env.DATABASE_URL, { max: 1 }); const states = ["LOBBY", "ROLLING", "CHOOSING", "BANNING", "PICKING", "TB_SELECT", "PLAYING", "CLOSING"]; const [row] = await sql`select count(*)::int as count from match where state in ${sql(states)}`; console.log(row.count); await sql.end();')
if [ "$active_count" -gt 0 ]; then
echo "$active_count matches are live in the database. Drain them before deploying." >&2
exit 1
fi
fi
db_version=$(sudo docker run --rm --network backend --env-file "$environment" "$image" \
bun -e 'const sql = new Bun.SQL(process.env.DB_URL || process.env.DATABASE_URL, { max: 1 }); const [row] = await sql`show server_version_num`; console.log(row.server_version_num); await sql.end();')
case "$db_version" in
*[!0-9]*|'') echo 'Could not determine the PostgreSQL server version' >&2; exit 1 ;;
esac
if [ "$db_version" -ge 100000 ]; then
db_major=$((db_version / 10000))
else
db_major="$((db_version / 10000)).$(((db_version / 100) % 100))"
fi
backup_image="postgres:${db_major}-alpine"
backup_dir=/var/backups/esports
backup_stamp=$(date -u +%Y%m%dT%H%M%SZ)
backup_name="${environment_name}-${backup_stamp}-${sha}.dump"
sudo docker pull "$backup_image"
sudo install -d -m 0700 "$backup_dir"
sudo docker run --rm --network backend --env-file "$environment" \
--mount "type=bind,source=$backup_dir,target=/backups" \
--env "BACKUP_FILE=$backup_name" "$backup_image" \
sh -eu -c 'pg_dump --format=custom --no-owner --no-acl --file="/backups/$BACKUP_FILE" "${DB_URL:-$DATABASE_URL}"'
sudo docker run --rm --mount "type=bind,source=$backup_dir,target=/backups" \
"$backup_image" pg_restore --list "/backups/$backup_name" >/dev/null
sudo chmod 0600 "$backup_dir/$backup_name"
echo "Verified $environment_name database backup at $backup_dir/$backup_name"
sudo docker run --rm --network backend --env-file "$environment" "$image" \
bun scripts/migrate.ts
sudo sed -i "s/^IMAGE_TAG=.*/IMAGE_TAG=$sha/" "$directory/release.env"
switched=1
sudo docker compose --env-file "$directory/release.env" -f "$compose" up -d
curl --retry 12 --retry-delay 2 --retry-all-errors --fail --silent --show-error \
"http://127.0.0.1:${port}/api/health" >/dev/null
trap - ERR
echo "Deployed $sha to $origin"

22
scripts/dev-login.ts Normal file
View File

@@ -0,0 +1,22 @@
/**
* Prints a 10-minute link that signs a browser in as a seeded test player.
* Works only against dev and staging servers, and only for seeded players.
*
* bun scripts/dev-login.ts dev-player-03 [https://esports-dev.vash.software] [/matches/…]
*/
import { makeSignature } from 'better-auth/crypto';
const [user, origin = process.env.ORIGIN ?? 'http://127.0.0.1:3102', to] = process.argv.slice(2);
if (!user || !(user.startsWith('dev-player-') || user.startsWith('seed-user-'))) {
console.error('Usage: bun scripts/dev-login.ts <dev-player-NN|seed-user-NNN> [origin] [path]');
process.exit(1);
}
const secret = process.env.BETTER_AUTH_SECRET;
if (!secret) throw new Error('BETTER_AUTH_SECRET is not set');
const expires = Date.now() + 10 * 60_000;
const url = new URL('/dev/login-as', origin);
url.searchParams.set('user', user);
url.searchParams.set('expires', String(expires));
url.searchParams.set('sig', await makeSignature(`dev-login:${user}:${expires}`, secret));
if (to) url.searchParams.set('to', to);
console.log(url.toString());

View File

@@ -0,0 +1,44 @@
[centre][imagemap]
https://assets.ppy.sh/beatmaps/2566536/covers/cover@2x.jpg
0 0 50 100 https://discord.gg/n3mZgWk Join the Discord
50 0 50 100 https://esports.vash.software Vash Esports
[/imagemap]
[size=150][b]Dev Weekly[/b][/size]
[color=#ff66aa]An international 1v1 double-elimination osu! tournament[/color], run by Vash Esports.[/centre]
[notice]Registration closes in a week. Every match is refereed by the Vash bot, so matches start on time and nobody waits for a referee.[/notice]
[heading]Tournament schedule[/heading]
[list]
[*][b]Registration[/b] · this week
[*][b]Qualifiers[/b] · the weekend after
[*][b]Round of 16 → Grand Finals[/b] · one round per weekend
[/list]
[heading]Prizes[/heading]
[list=1]
[*]Gold crown: profile banner, $130 (or 5 years of osu!supporter)
[*]Silver crown: profile banner, $60
[*]Bronze crown: profile banner, $30
[/list]
[box=Ruleset]
[b]Winning conditions[/b]
[list]
[*]Round of 16 is BO7, first to 4 points.
[*]Quarterfinals and Semifinals are BO9. Finals and Grand Finals are BO13.
[/list]
[b]Match proceedings[/b]
[list]
[*]At the start, both players [c]!roll[/c]. The higher roll picks first or bans first.
[*]One ban per player up to the Round of 16, two from the Quarterfinals on.
[*]Tiebreaker: each player bans one of three; the remaining one is played.
[*]Mods allowed in the tiebreaker (Freemod): Hidden, HardRock, Easy (2x multiplier).
[/list]
[/box]
[box=Scheduling]
All times are UTC. The site schedules matches around both players' preferred hours. Both players must agree to a reschedule on the match page.
[/box]
[quote="Stan"]gl hf, and have fun![/quote]

30
scripts/migrate-dev.ts Normal file
View File

@@ -0,0 +1,30 @@
/** Back up the shared PostgreSQL container before migrating development. */
import { SQL } from 'bun';
const connection = process.env.DB_URL || process.env.DATABASE_URL;
if (!connection) throw new Error('DB_URL or DATABASE_URL is required');
const sql = new SQL(connection, { max: 1 });
let databaseName: string;
try {
const [row] = await sql`SELECT current_database() AS name`;
databaseName = row.name;
if (!['vash_dev', 'vash_esports_dev'].includes(databaseName))
throw new Error('Refusing to migrate a non-development database');
} finally {
await sql.end();
}
const container = process.env.POSTGRES_CONTAINER || 'postgres-db-1';
if (!/^[a-zA-Z0-9_.-]+$/.test(container)) throw new Error('Invalid POSTGRES_CONTAINER');
const host = process.env.DB_BACKUP_SSH || 'ai@localhost';
if (!/^[a-zA-Z0-9_.@-]+$/.test(host)) throw new Error('Invalid DB_BACKUP_SSH');
const backup = `/home/ai/esports-${databaseName}-before-migration-${Date.now()}.dump`;
const remote = `umask 077 && docker exec -i ${container} pg_dump -U postgres -d ${databaseName} -Fc > ${backup} && docker exec -i ${container} pg_restore -l < ${backup} >/dev/null && test -s ${backup}`;
const job = Bun.spawn(['ssh', '-o', 'BatchMode=yes', host, remote], {
stdout: 'inherit',
stderr: 'inherit'
});
if ((await job.exited) !== 0)
throw new Error('Backup failed or could not be verified; migration was not run');
console.log(`Verified development backup on ${host}: ${backup}`);
await import('./migrate');

51
scripts/migrate.ts Normal file
View File

@@ -0,0 +1,51 @@
/**
* Applies scripts/migrations/*.sql in name order, once each, inside one
* transaction. Applied names are recorded in schema_migration.
*
* bun scripts/migrate.ts apply pending migrations
* bun scripts/migrate.ts --status list applied and pending migrations
*
* Every migration is additive and idempotent, so a database that was set up
* with drizzle-kit push or the old migrate-platform script is safe to run.
*/
import { SQL } from 'bun';
import { readdir, readFile } from 'node:fs/promises';
const databaseUrl = process.env.DB_URL || process.env.DATABASE_URL;
if (!databaseUrl) throw new Error('DB_URL or DATABASE_URL is required');
const directory = new URL('./migrations/', import.meta.url);
const files = (await readdir(directory)).filter((name) => name.endsWith('.sql')).sort();
const sql = new SQL(databaseUrl, { max: 1 });
try {
await sql`CREATE TABLE IF NOT EXISTS schema_migration (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`;
const applied = new Set(
((await sql`SELECT name FROM schema_migration`) as { name: string }[]).map((row) => row.name)
);
const pending = files.filter((name) => !applied.has(name));
if (process.argv.includes('--status')) {
for (const name of files) console.log(`${applied.has(name) ? 'applied' : 'pending'} ${name}`);
} else if (pending.length === 0) {
console.log('Database is up to date');
} else {
await sql.begin(async (tx) => {
for (const name of pending) {
await tx.unsafe(await readFile(new URL(name, directory), 'utf8'));
await tx`INSERT INTO schema_migration (name) VALUES (${name})`;
console.log(`applied ${name}`);
}
// The root administrator is configured per environment, never guessed.
const rootId = process.env.ROOT_ADMIN_USER_ID;
const legacyRoot = process.env.ROOT_ADMIN_EMAIL;
if (rootId)
await tx`UPDATE "user" SET root_admin = true, role = 'admin' WHERE id = ${rootId}`;
else if (legacyRoot)
await tx`UPDATE "user" SET root_admin = true, role = 'admin' WHERE lower(email) = ${legacyRoot.toLowerCase()}`;
});
console.log(`${pending.length} migration(s) applied atomically`);
}
} finally {
await sql.end();
}

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);

View File

@@ -0,0 +1,35 @@
-- Run only after restoring a production snapshot into an isolated dev database.
-- Preserve public osu identities, matches, mappools, and tournaments while
-- preventing copied credentials, sessions, and contact addresses from working.
BEGIN;
DO $$
BEGIN
IF current_database() !~ '^vash_dev(_refresh_[0-9]+)?$' THEN
RAISE EXCEPTION 'Refusing to sanitize non-dev database %', current_database();
END IF;
END $$;
DELETE FROM session;
DELETE FROM verification;
UPDATE account
SET access_token = NULL,
refresh_token = NULL,
id_token = NULL,
access_token_expires_at = NULL,
refresh_token_expires_at = NULL,
password = NULL,
profile = CASE WHEN jsonb_typeof(profile) = 'object'
THEN profile - 'email' - 'emailVerified' ELSE profile END;
UPDATE "user"
SET email = 'dev+' || md5(id) || '@identity.invalid',
contact_email = NULL,
email_verified = false;
DELETE FROM broadcast_destination;
UPDATE broadcast_channel SET agent_token_hash = NULL, live = false;
DELETE FROM broadcast_session;
COMMIT;

View File

@@ -0,0 +1,87 @@
/** A small, labeled dev-only showcase for features absent from the live archive. */
import { SQL } from 'bun';
import { PRESETS } from '../src/lib/broadcast/layout';
const databaseUrl = process.env.DB_URL || process.env.DATABASE_URL;
if (!databaseUrl) throw new Error('DB_URL or DATABASE_URL is required');
const sql = new SQL(databaseUrl, { max: 1 });
const ids = {
tournament: '00000000-0000-4000-8000-00000000d101',
round: '00000000-0000-4000-8000-00000000d102',
registration: [
'00000000-0000-4000-8000-00000000d111',
'00000000-0000-4000-8000-00000000d112',
'00000000-0000-4000-8000-00000000d113',
'00000000-0000-4000-8000-00000000d114'
],
match: ['00000000-0000-4000-8000-00000000d121', '00000000-0000-4000-8000-00000000d122'],
prediction: ['00000000-0000-4000-8000-00000000d131', '00000000-0000-4000-8000-00000000d132'],
channel: '00000000-0000-4000-8000-00000000d141'
} as const;
try {
const [{ name }] = await sql`SELECT current_database() AS name`;
if (name !== 'vash_dev' && name !== process.env.SEED_TEST_DATABASE)
throw new Error(`Refusing to seed ${name}; only a dev database is allowed`);
await sql.begin(async (tx) => {
const teams = await tx`
SELECT t.id AS team_id, t.owner_id AS user_id
FROM team t JOIN "user" u ON u.id = t.owner_id
WHERE t.is_personal AND t.owner_id IS NOT NULL
ORDER BY t.owner_id, t.id LIMIT 5
`;
if (teams.length < 5 || new Set(teams.map((team) => team.user_id)).size < 5)
throw new Error('The dev showcase needs five distinct users with personal teams');
const [pool] = await tx`SELECT id FROM mappool ORDER BY created_at, id LIMIT 1`;
if (!pool) throw new Error('The dev showcase needs one copied mappool');
await tx`
INSERT INTO tournament (id, name, description, format, state, config, max_slots, created_by, tagline, accent_color)
VALUES (
${ids.tournament}, 'Dev Showcase',
'A dev-only bracket built from copied players and a copied mappool.',
'single_elim', 'IN_PROGRESS',
${{ teamSize: 1, scoringType: 'score_v2', bans: 1, bestOf: 7 }},
4, ${teams[0].user_id}, 'Explore the bracket and predictions', '#ff66aa'
) ON CONFLICT (id) DO NOTHING
`;
await tx`
INSERT INTO tournament_round (id, tournament_id, name, abbreviation, round_order, best_of, mappool_id, bracket_type)
VALUES (${ids.round}, ${ids.tournament}, 'Semifinals', 'SF', 1, 7, ${pool.id}, 'winners')
ON CONFLICT (id) DO NOTHING
`;
for (let i = 0; i < 4; i++) {
await tx`
INSERT INTO tournament_registration (id, tournament_id, team_id, user_id, status, seed)
VALUES (${ids.registration[i]}, ${ids.tournament}, ${teams[i].team_id}, ${teams[i].user_id}, 'registered', ${i + 1})
ON CONFLICT (id) DO NOTHING
`;
}
for (let i = 0; i < 2; i++) {
await tx`
INSERT INTO tournament_match (id, tournament_id, round_id, bracket_position, team1_id, team2_id, state)
VALUES (${ids.match[i]}, ${ids.tournament}, ${ids.round}, ${i + 1},
${teams[i * 2].team_id}, ${teams[i * 2 + 1].team_id}, 'PENDING')
ON CONFLICT (id) DO NOTHING
`;
await tx`
INSERT INTO prediction (id, user_id, tournament_match_id, winner_team_id, winner_score, loser_score)
VALUES (${ids.prediction[i]}, ${teams[4].user_id}, ${ids.match[i]}, ${teams[i * 2].team_id}, 4, 2)
ON CONFLICT (id) DO NOTHING
`;
}
await tx`
INSERT INTO broadcast_channel (id, name, mode, tournament_id, layout, created_by, live, record)
VALUES (${ids.channel}, 'Dev Showcase TV', 'tournament', ${ids.tournament},
${PRESETS.tourney.make()}, ${teams[0].user_id}, false, true)
ON CONFLICT (id) DO NOTHING
`;
});
console.log(
'Seeded dev showcase: 1 tournament, 1 round, 4 registrations, 2 bracket matches, 2 predictions, 1 broadcast channel'
);
} finally {
await sql.end();
}

517
scripts/seed-dev.ts Normal file
View File

@@ -0,0 +1,517 @@
/**
* Small fixture set for the dev database: players, teams, real ranked maps,
* a few finished matches, one open tournament, and one player waiting in the 1v1 queue.
*
* Refuses to run unless the database name is vash_dev.
* bun scripts/seed-dev.ts
*/
import { SQL } from 'bun';
const databaseUrl = process.env.DATABASE_URL || process.env.DB_URL;
if (!databaseUrl) throw new Error('DATABASE_URL or DB_URL is not set');
const sql = new SQL(databaseUrl, { max: 1 });
const PLAYERS = [
{
id: 'dev-player-01',
name: 'saltine',
country: 'US',
osuId: 9100001,
elo: 2100,
wins: 40,
losses: 12,
rank: 800
},
{
id: 'dev-player-02',
name: 'ricebowl',
country: 'KR',
osuId: 9100002,
elo: 1950,
wins: 32,
losses: 14,
rank: 2500
},
{
id: 'dev-player-03',
name: 'pebble',
country: 'NL',
osuId: 9100003,
elo: 1800,
wins: 28,
losses: 16,
rank: 6000
},
{
id: 'dev-player-04',
name: 'nightbus',
country: 'GB',
osuId: 9100004,
elo: 1700,
wins: 22,
losses: 18,
rank: 12000
},
{
id: 'dev-player-05',
name: 'teacup',
country: 'DE',
osuId: 9100005,
elo: 1600,
wins: 18,
losses: 15,
rank: 20000
},
{
id: 'dev-player-06',
name: 'lantern',
country: 'JP',
osuId: 9100006,
elo: 1500,
wins: 16,
losses: 16,
rank: 35000
},
{
id: 'dev-player-07',
name: 'moss',
country: 'CA',
osuId: 9100007,
elo: 1400,
wins: 14,
losses: 17,
rank: 50000
},
{
id: 'dev-player-08',
name: 'keypad',
country: 'FR',
osuId: 9100008,
elo: 1300,
wins: 11,
losses: 14,
rank: 80000
},
{
id: 'dev-player-09',
name: 'river',
country: 'AU',
osuId: 9100009,
elo: 1200,
wins: 9,
losses: 12,
rank: 110000
},
{
id: 'dev-player-10',
name: 'comet',
country: 'PL',
osuId: 9100010,
elo: 1100,
wins: 7,
losses: 11,
rank: 150000
},
{
id: 'dev-player-11',
name: 'soda',
country: 'BR',
osuId: 9100011,
elo: 1050,
wins: 5,
losses: 9,
rank: 200000
},
{
id: 'dev-player-12',
name: 'wool',
country: 'SE',
osuId: 9100012,
elo: 1000,
wins: 3,
losses: 6,
rank: 280000
}
] as const;
type Beatmap = {
id: string;
title: string;
artist: string;
version: string;
sr: number;
bpm: number;
length: number;
cover: string | null;
list: string | null;
};
const FALLBACK: Beatmap[] = [
['75', 'DISCO PRINCE', 'Kenji Ninuma', 'Normal', 2.4],
['163', 'Crazy Noisy Bizarre Town', 'The DU', 'Normal', 3.1],
['250', 'Sakura Kagetsu', 'Mutsuhiko Izumi', 'Normal', 3.6],
['306', 'Airman ga Taosenai', 'Team.NEKOKAN', 'Normal', 4.4],
['390', 'Kira Kira Days', 'MOSAIC.WAV', 'Normal', 4.2],
['455', 'FREEDOM DiVE', 'xi', 'FOUR DIMENSIONS', 6.2],
['500', 'Blue Zenith', 'xi', 'FOUR DIMENSIONS', 6.6],
['1001', 'Pretender', 'Official HIGE DANdism', 'Insane', 5.1],
['1002', 'Highscore', 'Panda Eyes', 'Extra', 5.4],
['1003', 'Everything will freeze', 'UNDEAD CORPORATION', 'Extra', 5.8],
['1004', 'Sidetracked Day', 'namirin', 'Insane', 4.8],
['1005', 'Hitorigoto', 'ClariS', 'Insane', 4.6]
].map(([id, title, artist, version, sr]) => ({
id,
title,
artist,
version,
sr: Number(sr),
bpm: 180,
length: 180,
cover: null,
list: null
}));
const DEV_LINKS = [
{ label: 'Discord', url: 'https://discord.gg/n3mZgWk' },
{ label: 'Main sheet', url: 'https://docs.google.com/spreadsheets/' },
{ label: 'Livestream', url: 'https://twitch.tv/' }
];
const DEV_PRIZES = [
{ place: 'Gold crown', prize: 'Profile banner, $130 (or 5 years of osu!supporter)' },
{ place: 'Silver crown', prize: 'Profile banner, $60' },
{ place: 'Bronze crown', prize: 'Profile banner, $30' }
];
const SLOT_PLAN = [
['NM', 1],
['NM', 2],
['NM', 3],
['NM', 4],
['HD', 1],
['HD', 2],
['HR', 1],
['HR', 2],
['DT', 1],
['DT', 2],
['FM', 1],
['TB', 1]
] as const;
async function main() {
const [{ name }] = await sql`SELECT current_database() AS name`;
// SEED_TEST_DATABASE lets a throwaway database be seeded too, for testing this script.
if (name !== 'vash_dev' && name !== process.env.SEED_TEST_DATABASE) {
throw new Error(`Refusing to seed ${name}. This script only runs against vash_dev.`);
}
await sql.begin(async (tx) => {
// Matches anyone played with the seeded players or pools go too; they would
// block deleting what they reference. Deleting a match cascades to its rows.
await tx`UPDATE tournament_match SET match_id = NULL WHERE match_id IN (SELECT id FROM match WHERE mappool_id IN (SELECT id FROM mappool WHERE created_by = 'dev-seed') OR id IN (SELECT match_id FROM match_participant WHERE team_id IN (SELECT id FROM team WHERE owner_id LIKE 'dev-player-%')))`;
await tx`DELETE FROM match WHERE mappool_id IN (SELECT id FROM mappool WHERE created_by = 'dev-seed') OR id IN (SELECT match_id FROM match_participant WHERE team_id IN (SELECT id FROM team WHERE owner_id LIKE 'dev-player-%'))`;
await tx`DELETE FROM match_game_score WHERE match_game_id IN (SELECT id FROM match_game WHERE match_id IN (SELECT id FROM match WHERE created_by = 'dev-seed'))`;
await tx`DELETE FROM match_game WHERE match_id IN (SELECT id FROM match WHERE created_by = 'dev-seed')`;
await tx`DELETE FROM match_participant_player WHERE participant_id IN (SELECT id FROM match_participant WHERE match_id IN (SELECT id FROM match WHERE created_by = 'dev-seed'))`;
await tx`DELETE FROM match_participant WHERE match_id IN (SELECT id FROM match WHERE created_by = 'dev-seed')`;
await tx`DELETE FROM match_event WHERE match_id IN (SELECT id FROM match WHERE created_by = 'dev-seed')`;
await tx`DELETE FROM match_invite WHERE created_by = 'dev-seed' OR created_by LIKE 'dev-player-%'`;
await tx`DELETE FROM match WHERE created_by = 'dev-seed'`;
await tx`DELETE FROM tournament_registration WHERE tournament_id IN (SELECT id FROM tournament WHERE created_by = 'dev-seed')`;
await tx`DELETE FROM tournament_staff WHERE tournament_id IN (SELECT id FROM tournament WHERE created_by = 'dev-seed')`;
await tx`DELETE FROM tournament_round WHERE tournament_id IN (SELECT id FROM tournament WHERE created_by = 'dev-seed')`;
await tx`DELETE FROM tournament WHERE created_by = 'dev-seed'`;
await tx`DELETE FROM mappool_slot WHERE mappool_id IN (SELECT id FROM mappool WHERE created_by = 'dev-seed')`;
await tx`DELETE FROM mappool WHERE created_by = 'dev-seed'`;
await tx`DELETE FROM match_queue WHERE user_id LIKE 'dev-player-%'`;
await tx`DELETE FROM notification WHERE user_id LIKE 'dev-player-%'`;
await tx`DELETE FROM player_rating WHERE user_id LIKE 'dev-player-%'`;
await tx`DELETE FROM team_member WHERE user_id LIKE 'dev-player-%' OR team_id IN (SELECT id FROM team WHERE owner_id LIKE 'dev-player-%')`;
await tx`DELETE FROM team WHERE owner_id LIKE 'dev-player-%'`;
await tx`DELETE FROM session WHERE user_id LIKE 'dev-player-%'`;
await tx`DELETE FROM account WHERE user_id LIKE 'dev-player-%'`;
await tx`DELETE FROM "user" WHERE id LIKE 'dev-player-%'`;
});
for (const player of PLAYERS) {
await sql`
INSERT INTO "user" (id, name, email, email_verified, role, country_code, osu_id, created_at, updated_at)
VALUES (
${player.id},
${player.name},
${`${player.osuId}@osu.local`},
true,
'player',
${player.country},
${player.osuId},
now(),
now()
)
`;
await sql`
INSERT INTO account (id, account_id, provider_id, user_id, created_at, updated_at)
VALUES (${`dev-account-${player.osuId}`}, ${String(player.osuId)}, 'osu', ${player.id}, now(), now())
`;
await sql`
INSERT INTO player_rating (user_id, elo, wins, losses, initial_elo, osu_rank_at_seed, provisional, updated_at)
VALUES (${player.id}, ${player.elo}, ${player.wins}, ${player.losses}, ${player.elo}, ${player.rank}, false, now())
`;
const [personal] = await sql`
INSERT INTO team (name, is_personal, owner_id)
VALUES (${player.name}, true, ${player.id})
RETURNING id
`;
await sql`
INSERT INTO team_member (team_id, user_id, role)
VALUES (${personal.id}, ${player.id}, 'owner')
`;
}
const teams = [
{
name: 'Night Shift',
members: ['dev-player-01', 'dev-player-02', 'dev-player-03', 'dev-player-04']
},
{ name: 'Late Entry', members: ['dev-player-05', 'dev-player-06'] }
];
for (const group of teams) {
const [team] = await sql`
INSERT INTO team (name, is_personal, owner_id)
VALUES (${group.name}, false, ${group.members[0]})
RETURNING id
`;
for (const [index, member] of group.members.entries()) {
await sql`
INSERT INTO team_member (team_id, user_id, role)
VALUES (${team.id}, ${member}, ${index === 0 ? 'owner' : 'member'})
`;
}
}
const maps = await loadBeatmaps();
const pools = [
{ name: 'Dev Cup', min: 4.2, max: 5.2 },
{ name: 'Dev Finals', min: 5.4, max: 6.6 },
{ name: 'Dev Qualifiers', min: 3.2, max: 4.4 }
];
const poolIds: string[] = [];
for (const pool of pools) {
const chosen = pickBand(maps, pool.min, pool.max);
const [row] = await sql`
INSERT INTO mappool (name, created_by, verified_at, origin, rating)
VALUES (${pool.name}, 'dev-seed', now(), 'Vash dev fixtures', ${(pool.min + pool.max) / 2})
RETURNING id
`;
poolIds.push(row.id);
for (let i = 0; i < SLOT_PLAN.length; i++) {
const [category, order] = SLOT_PLAN[i];
const map = chosen[i % chosen.length];
const mods =
category === 'NM' || category === 'TB' ? [] : [category === 'FM' ? 'FM' : category];
await sql`
INSERT INTO mappool_slot (
mappool_id, category, order_in_category, beatmap_id, star_rating, bpm, total_length,
mods, title, artist, version, cover_url, list_cover_url
) VALUES (
${row.id}, ${category}, ${order}, ${map.id}, ${map.sr}, ${map.bpm}, ${map.length},
${sql.array(mods, 'TEXT')}, ${map.title}, ${map.artist}, ${map.version}, ${map.cover}, ${map.list}
)
`;
}
}
const personalTeams = await sql`
SELECT id, owner_id, name FROM team WHERE owner_id LIKE 'dev-player-%' AND is_personal
ORDER BY owner_id
`;
for (let i = 0; i < personalTeams.length - 1; i += 2) {
const left = personalTeams[i];
const right = personalTeams[i + 1];
const leftPlayer = PLAYERS.find((player) => player.id === left.owner_id)!;
const rightPlayer = PLAYERS.find((player) => player.id === right.owner_id)!;
const leftWins = leftPlayer.elo >= rightPlayer.elo;
const [created] = await sql`
INSERT INTO match (name, state, config, mappool_id, winner_id, created_by, finished_at, kind, game, elo_applied)
VALUES (
${`${left.name} vs ${right.name}`},
'FINISHED',
${{ bestOf: 7, teamSize: 1, scoringType: 'score_v2', bans: 1, kind: 'quick' }},
${poolIds[i % poolIds.length]},
${leftWins ? left.id : right.id},
'dev-seed',
now() - make_interval(hours => ${i}),
'quick',
'osu',
true
)
RETURNING id
`;
await addSide(created.id, left.id, left.owner_id, 0, leftWins ? 4 : 2);
await addSide(created.id, right.id, right.owner_id, 1, leftWins ? 2 : 4);
}
const [cup] =
await sql`SELECT id FROM mappool WHERE name = 'Dev Cup' AND created_by = 'dev-seed'`;
const [tournament] = await sql`
INSERT INTO tournament (
name, description, format, state, config, max_slots, created_by, registration_open_at, registration_close_at,
tagline, accent_color, banner_url, forum_post, links, prizes
) VALUES (
'Dev Weekly',
'A small open 1v1 you can sign up for while trying the site.',
'single_elim',
'REGISTRATION',
${{ teamSize: 1, scoringType: 'score_v2', bans: 1, bestOf: 7 }},
16,
'dev-seed',
now(),
now() + interval '7 days',
'The weekly 1v1 for trying out Vash Esports',
'#ff66aa',
'https://assets.ppy.sh/beatmaps/2566536/covers/cover@2x.jpg',
${await Bun.file(new URL('./fixtures/dev-forum-post.bbcode', import.meta.url)).text()},
${DEV_LINKS},
${DEV_PRIZES}
)
RETURNING id
`;
await sql`
INSERT INTO tournament_round (tournament_id, name, abbreviation, round_order, best_of, mappool_id, bracket_type)
VALUES (${tournament.id}, 'Round of 16', 'RO16', 1, 7, ${cup.id}, 'winners')
`;
const nightShift =
await sql`SELECT id FROM team WHERE name = 'Night Shift' AND owner_id = 'dev-player-01'`;
for (const player of PLAYERS.slice(0, 6)) {
const [team] = await sql`
SELECT id FROM team WHERE owner_id = ${player.id} AND is_personal
`;
await sql`
INSERT INTO tournament_registration (tournament_id, team_id, user_id, status, seed)
VALUES (${tournament.id}, ${team.id}, ${player.id}, 'registered', ${PLAYERS.indexOf(player) + 1})
`;
}
void nightShift;
const [waitingTeam] = await sql`
SELECT id FROM team WHERE owner_id = 'dev-player-06' AND is_personal
`;
await sql`
INSERT INTO match_queue (user_id, team_id, elo, format)
VALUES ('dev-player-06', ${waitingTeam.id}, 1500, 1)
`;
const counts = await sql`
SELECT
(SELECT count(*) FROM "user" WHERE id LIKE 'dev-player-%') AS players,
(SELECT count(*) FROM team WHERE owner_id LIKE 'dev-player-%') AS teams,
(SELECT count(*) FROM mappool WHERE created_by = 'dev-seed') AS pools,
(SELECT count(*) FROM match WHERE created_by = 'dev-seed') AS matches
`;
console.log(counts[0]);
await sql.end();
}
async function addSide(
matchId: string,
teamId: string,
userId: string,
slot: number,
score: number
) {
const [participant] = await sql`
INSERT INTO match_participant (match_id, team_id, slot, score)
VALUES (${matchId}, ${teamId}, ${slot}, ${score})
RETURNING id
`;
await sql`
INSERT INTO match_participant_player (participant_id, user_id)
VALUES (${participant.id}, ${userId})
`;
}
async function loadBeatmaps(): Promise<Beatmap[]> {
const clientId = process.env.OSU_CLIENT_ID;
const clientSecret = process.env.OSU_CLIENT_SECRET;
if (!clientId || !clientSecret) return expand(FALLBACK);
const tokenResponse = await fetch('https://osu.ppy.sh/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
client_id: Number(clientId),
client_secret: clientSecret,
grant_type: 'client_credentials',
scope: 'public'
})
});
if (!tokenResponse.ok) return expand(FALLBACK);
const token = (await tokenResponse.json()) as { access_token?: string };
if (!token.access_token) return expand(FALLBACK);
const maps: Beatmap[] = [];
for (let page = 1; page <= 2 && maps.length < 80; page++) {
const response = await fetch(
`https://osu.ppy.sh/api/v2/beatmapsets/search?m=0&s=ranked&page=${page}`,
{ headers: { Authorization: `Bearer ${token.access_token}`, Accept: 'application/json' } }
);
if (!response.ok) break;
const body = (await response.json()) as {
beatmapsets?: {
title: string;
artist: string;
covers?: { cover?: string; list?: string };
beatmaps?: {
id: number;
mode: string;
version: string;
difficulty_rating: number;
bpm: number;
total_length: number;
}[];
}[];
};
for (const set of body.beatmapsets ?? []) {
for (const beatmap of set.beatmaps ?? []) {
if (beatmap.mode !== 'osu') continue;
maps.push({
id: String(beatmap.id),
title: set.title,
artist: set.artist,
version: beatmap.version,
sr: beatmap.difficulty_rating,
bpm: beatmap.bpm,
length: beatmap.total_length,
cover: set.covers?.cover ?? null,
list: set.covers?.list ?? null
});
}
}
}
return maps.length >= 12 ? maps : expand(FALLBACK);
}
function expand(maps: Beatmap[]): Beatmap[] {
const copy = [...maps];
while (copy.length < 36)
copy.push({
...maps[copy.length % maps.length],
sr: maps[copy.length % maps.length].sr + 0.15
});
return copy;
}
function pickBand(maps: Beatmap[], min: number, max: number): Beatmap[] {
const inBand = maps.filter((map) => map.sr >= min && map.sr <= max);
const source = inBand.length >= 8 ? inBand : [...maps].sort((a, b) => a.sr - b.sr);
const picked: Beatmap[] = [];
for (let i = 0; i < SLOT_PLAN.length; i++) {
picked.push(
source[Math.min(source.length - 1, Math.floor((i / SLOT_PLAN.length) * source.length))]
);
}
return picked;
}
main().catch(async (err) => {
console.error(err);
await sql.end();
process.exit(1);
});

16
scripts/test-auth.ts Normal file
View File

@@ -0,0 +1,16 @@
/** Run against an empty test database created from the current application schema. */
const base = process.env.AUTH_TEST_DATABASE_URL ?? process.env.DB_URL ?? process.env.DATABASE_URL;
if (!base) throw new Error('Provide AUTH_TEST_DATABASE_URL');
const url = new URL(base);
if (!process.env.AUTH_TEST_DATABASE_URL) url.pathname = '/esports_auth_test_20260928';
if (!url.pathname.startsWith('/esports_auth_test'))
throw new Error('Refusing to run against a non-test database');
const child = Bun.spawn(
['bun', '--bun', 'x', 'vitest', '--run', 'src/lib/server/auth.integration.test.ts'],
{
env: { ...process.env, AUTH_TEST_DATABASE_URL: url.toString() },
stdout: 'inherit',
stderr: 'inherit'
}
);
process.exit(await child.exited);