Compare commits

..

6 Commits

Author SHA1 Message Date
6ff5fe53c5 fix: deploy with only sudo tee and docker
Some checks failed
Test and deploy staging / staging (push) Failing after 16s
The deploy user has passwordless sudo for tee and docker only, so the
script stopped at its first sudo install. release.env is rewritten with
tee, and the backup container creates the backup directory and sets the
file modes itself. HOST_PORT comes from the target, not release.env.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:37:50 +00:00
d5448d9a94 feat!: upgrade to SvelteKit 3 and adapter-bun
Some checks failed
Test and deploy staging / staging (push) Failing after 14s
- run sv migrate sveltekit-3: config moves into vite.config.ts, $lib becomes
  #lib, $env/dynamic becomes $app/env/private via src/env.ts, $app/environment
  becomes $app/env, hook types move to @sveltejs/kit/hooks
- env vars stay optional (the migrator defaulted them to ''), and BANCHO_MODE,
  which it missed, is declared and passed to simulatorEnabled
- handleError only logs and masks unknown errors; Kit 3 now routes 404s and
  error(...) through it too
- json()/text() become Response.json()/new Response(); goto keepFocus/noScroll
  becomes reset: false; invalidateAll becomes refreshAll; page.url is copied
  before mutating
- replace adapter-node with @sveltejs/adapter-bun; prod-server.ts wraps
  Bun.serve to add native WebSockets (bunSockets) for /ws paths, since the
  generated server has no websocket option
- drop the Kit readdir patch: Kit 3 sorts route entries itself
- match view and broadcast pages use the live score modules

BREAKING CHANGE: requires SvelteKit 3, TypeScript 6 and Bun 1.4; the build
output is build/index.js from adapter-bun, started through prod-server.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:02:53 +00:00
54444a77f0 feat: add native Windows broadcaster and live score modules
- streamer/windows: vash-broadcast.exe (setup/login/start/stop/status/doctor)
  with per-instance OBS, tosu and FFmpeg, a supervised worker, OCR room
  selection and OBS scene reconciliation; built and tested on Linux only
- streamer/relay.ts shared with Vash TV; Cloudflare Access proxy for dev/staging
- worker status validation and live score helpers for the site
- STREAMING_HANDOFF.md and streamer/ALPHA_TEST.md record what is unverified

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:02:25 +00:00
9d5a046b40 fix: resume saved lobbies when host capacity is full 2026-09-29 02:58:48 +00:00
9b9aafefc1 feat: redesign match play and add broadcast tooling 2026-09-29 02:52:56 +00:00
f417a831f7 update git link
Some checks failed
CI / Lint, Check, Build & Test (push) Failing after 40s
2026-09-14 04:04:48 +00:00
418 changed files with 39861 additions and 11526 deletions

View File

@@ -2,3 +2,5 @@ node_modules
.svelte-kit
.git
build
dist
streamer

View File

@@ -1,52 +1,89 @@
# Copy this file to .env and fill in the values before running the application.
# The origin URL of the frontend application, used for CORS and constructing links
# In production, set it to your frontend URL, e.g. https://esports.vash.software
ORIGIN="http://localhost:5173"
# Public origin. Also decides the environment when APP_ENV is not set:
# dev http://localhost:3102 or https://esports-dev.vash.software
# staging https://esports-staging.vash.software
# prod https://esports.vash.software
ORIGIN="http://localhost:3102"
# dev | staging | prod. Dev and staging prefix page titles, show an environment
# badge, and enable the simulation lab. Leave unset to derive it from ORIGIN.
APP_ENV=""
# The email of the root admin user, with global full permissions.
ROOT_ADMIN_EMAIL=""
# Postgres in the shared instance, one database and login role per environment:
# vash (prod), vash_staging, vash_dev on 127.0.0.1:5440.
DB_URL="postgresql://vash_dev:password@127.0.0.1:5440/vash_dev"
DATABASE_URL="postgresql://vash_dev:password@127.0.0.1:5440/vash_dev"
# Docker Compose database password (used in docker-compose.yml)
DB_PASSWORD=""
DATABASE_URL="postgres://user:password@host:port/db-name"
# Lobbies: simulator | irc. Unset means irc in production and simulator
# everywhere else, so dev and staging never log production's osu! account out.
BANCHO_MODE="simulator"
# Better Auth secret key, used for signing JWTs and other cryptographic operations.
# For production use 32 characters and generated with high entropy
# https://www.better-auth.com/docs/installation
# One object per osu! account. A normal account hosts 4 tournament lobbies;
# add friends' accounts to host more at once. botAccount raises rate limits.
# OSU_IRC_ACCOUNTS='[{"id":"main","username":"player","password":"irc-password","maxLobbies":4}]'
# Single-account shorthand (https://osu.ppy.sh/home/account/edit#legacy-api):
OSU_IRC_USERNAME=""
OSU_IRC_PASSWORD=""
# osu! account signed in to the autonomous tourney streamer; Vash adds it as a room referee.
OSU_TOURNEY_ACCOUNT=""
# Another IRC server, such as a private Bancho or a local fake. Defaults to irc.ppy.sh:6667.
# OSU_IRC_HOST=""
# OSU_IRC_PORT=""
# Stable Better Auth user ID of the root admin. The migration stores this role
# in the database; no OAuth or email-shaped identity is used for authorization.
ROOT_ADMIN_USER_ID=""
# Better Auth secret: 32+ random characters, e.g. `openssl rand -hex 32`.
BETTER_AUTH_SECRET=""
# Cloudflare R2 image storage
# Create a new bucket and credentials at https://dash.cloudflare.com/r2
# Make sure to set the bucket has public access
R2_ACCOUNT_ID="" # Your Cloudflare Account ID
R2_ACCESS_KEY_ID="" # R2 API token Access Key ID
R2_SECRET_ACCESS_KEY="" # R2 API token Secret Access Key
R2_BUCKET="vash-esports-images" # Bucket name, e.g. vash-esports-images
R2_PUBLIC_URL="https://cdn.esports.vash.software" # Public URL for the bucket, e.g. https://cdn.esports.vash.software
# Email registration, verification, recovery, and email changes become available
# when both are set. Social sign-in does not require either value.
RESEND_API_KEY=""
EMAIL_FROM="Vash Esports <auth@esports.vash.software>"
# osu!-related
# Create an osu! API application here: https://osu.ppy.sh/home/account/edit#oauth
# Cloudflare R2 image storage (team avatars, tournament icons and banners).
# The bucket needs public access at R2_PUBLIC_URL.
R2_ACCOUNT_ID=""
R2_ACCESS_KEY_ID=""
R2_SECRET_ACCESS_KEY=""
R2_BUCKET="vash-esports-images"
R2_PUBLIC_URL="https://cdn.esports.vash.software"
# osu! OAuth application: https://osu.ppy.sh/home/account/edit#oauth
# Callback: <ORIGIN>/api/auth/oauth2/callback/osu
OSU_CLIENT_ID=""
OSU_CLIENT_SECRET=""
# Discord-related
# Create a Discord application here: https://discord.com/developers/applications
# "Sign in with Discord" application (not the bot).
DISCORD_CLIENT_ID=""
DISCORD_CLIENT_SECRET=""
# The notification bot. Several environments may share one token: the bot only
# posts. Point dev and staging at a private channel such as #dev-channel.
DISCORD_BOT_TOKEN=""
# Optional; read from the token when unset. Used for the "Add bot" link on /admin.
DISCORD_BOT_CLIENT_ID=""
DISCORD_GUILD_ID=""
# Match results (no pings) and stream announcements (@everyone in a channel that
# members with the "no stream notifications" role cannot read).
DISCORD_MATCH_CHANNEL_ID=""
DISCORD_STREAM_CHANNEL_ID=""
# GitHub-related
# Create a GitHub OAuth app here: https://github.com/settings/developers
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
# Google-related
# Create a Google OAuth app here: https://console.cloud.google.com/apis/credentials
# Google OAuth: https://console.cloud.google.com/apis/credentials
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
# Sentry error tracking (https://sentry.io)
PUBLIC_SENTRY_DSN="" # DSN is write-only, safe to expose client-side
SENTRY_AUTH_TOKEN="" # Only needed in CI for source map uploads
PUBLIC_SENTRY_DSN=""
# Only CI needs this, to upload source maps. Keep it out of local builds.
SENTRY_AUTH_TOKEN=""
# Fraction of requests traced. Defaults to 0.1 in prod and 0 elsewhere.
SENTRY_TRACES_SAMPLE_RATE=""
# Logging: LOG_LEVEL=debug for more detail, LOG_SQL=1 to log every query.
LOG_LEVEL="info"
LOG_SQL=""
# Encrypts streaming platform keys (Broadcast → Stream). Defaults to BETTER_AUTH_SECRET;
# set it separately to rotate the auth secret without re-entering stream keys.
# STREAM_KEY_SECRET=""

View File

@@ -0,0 +1,42 @@
name: Test and deploy staging
on:
push:
branches: [staging]
jobs:
staging:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/build-push-action@v5
with:
context: .
push: false
load: true
tags: git.stanrunge.dev/stan/esports:${{ github.sha }}
build-args: |
GIT_SHA=${{ github.sha }}
- name: Configure deployment SSH key
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
run: |
set -euo pipefail
install -d -m 700 ~/.ssh
printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H 172.17.0.1 >> ~/.ssh/known_hosts
- name: Transfer tested image
run: |
set -euo pipefail
image="git.stanrunge.dev/stan/esports:${{ github.sha }}"
docker save "$image" | ssh -o BatchMode=yes deploy@172.17.0.1 'sudo docker load'
- name: Deploy staging
run: |
set -euo pipefail
ssh -o BatchMode=yes deploy@172.17.0.1 \
"bash -s -- '${{ github.sha }}' '/opt/esports-staging' 'https://esports-staging.vash.software'" \
< scripts/deploy-remote.sh

View File

@@ -0,0 +1,30 @@
name: Deploy production
on:
workflow_dispatch:
jobs:
promote:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: staging
- name: Configure deployment SSH key
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
run: |
set -euo pipefail
install -d -m 700 ~/.ssh
printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H 172.17.0.1 >> ~/.ssh/known_hosts
- name: Promote the exact staging image
run: |
set -euo pipefail
commit=$(git rev-parse HEAD)
ssh -o BatchMode=yes deploy@172.17.0.1 "grep -qx IMAGE_TAG=$commit /opt/esports-staging/release.env"
ssh -o BatchMode=yes deploy@172.17.0.1 \
"bash -s -- '$commit' '/opt/esports' 'https://esports.vash.software'" \
< scripts/deploy-remote.sh

View File

@@ -1,64 +0,0 @@
name: CI
on:
push:
branches: [dev, staging, prod]
pull_request:
branches: [dev, staging, prod]
jobs:
ci:
name: Lint, Check, Build & Test
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: esports_test
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U test"
--health-interval=5s
--health-timeout=5s
--health-retries=5
env:
DATABASE_URL: postgres://test:test@localhost:5432/esports_test
BETTER_AUTH_SECRET: ci-test-secret
OSU_CLIENT_ID: '0'
OSU_CLIENT_SECRET: ci-test-secret
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Lint
run: bun run lint
- name: Type check
run: bun run check
- name: Push DB schema
run: bunx drizzle-kit push --force
- name: Build
run: bun run build
- name: Install Playwright browsers
run: bunx playwright install --with-deps chromium
- name: Unit tests
run: bun run test:unit --run
- name: E2E tests
run: bun run test:e2e

View File

@@ -1,43 +0,0 @@
name: Deploy
on:
push:
branches: [staging, prod]
jobs:
deploy:
name: Deploy to ${{ github.ref_name }}
runs-on: ubuntu-latest
environment: ${{ github.ref_name == 'prod' && 'production' || 'staging' }}
concurrency:
group: deploy-${{ github.ref_name }}
cancel-in-progress: false
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -euo pipefail
if [ "${{ github.ref_name }}" = "prod" ]; then
cd /opt/esports
git fetch origin prod
git checkout prod
git reset --hard origin/prod
docker compose build
docker compose up -d --wait
docker compose exec app bunx drizzle-kit push --force
else
cd /opt/esports-staging
git fetch origin staging
git checkout staging
git reset --hard origin/staging
docker compose -f docker-compose.staging.yml --env-file .env.staging -p esports-staging build
docker compose -f docker-compose.staging.yml --env-file .env.staging -p esports-staging up -d --wait
docker compose -f docker-compose.staging.yml --env-file .env.staging -p esports-staging exec staging-app bunx drizzle-kit push --force
fi

1
.gitignore vendored
View File

@@ -29,3 +29,4 @@ test-results
dozzle-users.yml
nvim.log
/dist

11
.vscode/settings.json vendored
View File

@@ -1,5 +1,14 @@
{
"files.associations": {
"*.css": "tailwindcss"
}
},
"files.exclude": {
"**/.git": true,
"**/.svn": true,
"**/.hg": true,
"**/.jj": true,
"**/.DS_Store": true,
"**/Thumbs.db": true
},
"hide-files.files": []
}

View File

@@ -1,31 +1,25 @@
FROM oven/bun AS base
FROM oven/bun:1.4.0 AS builder
WORKDIR /app
# Install dependencies (cached unless package.json/bun.lock change)
FROM base AS deps
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
# Build (mount cache so SvelteKit reuses previous build artifacts)
FROM base AS build
COPY --from=deps /app/node_modules node_modules
COPY . .
ENV NODE_ENV=production
ENV BETTER_AUTH_SECRET=build-placeholder
RUN --mount=type=cache,target=/app/.svelte-kit bun run build
ENV BETTER_AUTH_SECRET=build-placeholder-at-least-32-characters
ENV ORIGIN=http://localhost:3000
RUN bun --bun x vitest --run
RUN bun run build
# Runtime — prod deps + drizzle-kit for migrations
FROM base AS runtime
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production --ignore-scripts && bun add drizzle-kit drizzle-orm --ignore-scripts
COPY --from=build /app/build build
COPY drizzle.config.ts ./
COPY src/lib/server/db/schema.ts src/lib/server/db/auth.schema.ts src/lib/server/db/
USER bun
FROM oven/bun:1.4.0
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/build ./build
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/prod-server.ts ./prod-server.ts
COPY --from=builder /app/src/lib/server/ws-attach.ts ./src/lib/server/ws-attach.ts
COPY --from=builder /app/scripts ./scripts
ENV NODE_ENV=production
ENV PORT=3000
ENV HOST=0.0.0.0
ENV BODY_SIZE_LIMIT=6M
EXPOSE 3000
CMD ["bun", "run", "start"]
CMD ["bun", "prod-server.ts"]

222
README.md
View File

@@ -1,175 +1,129 @@
# Vash Esports
<div align="center">
[![CI](https://github.com/VashSoftware/esports/actions/workflows/ci.yml/badge.svg)](https://github.com/VashSoftware/esports/actions/workflows/ci.yml)
[![Deploy](https://github.com/VashSoftware/esports/actions/workflows/deploy.yml/badge.svg)](https://github.com/VashSoftware/esports/actions/workflows/deploy.yml)
[![Discord](https://img.shields.io/discord/639921094974898176?color=5865F2&logo=discord&logoColor=white&label=discord)](https://discord.gg/n3mZgWk)
</div>
An automated osu! matchmaking platform. Players queue up, get matched by ELO, and play fully automated multiplayer matches — lobby creation, map picking, score tracking, and rating updates all happen without referees.
Automated osu! matches and tournaments. Players queue or sign up, and Vash hosts the osu! lobby over IRC: it invites players, runs the roll, bans, picks, and tiebreakers, starts maps, reads the results, updates ratings, and closes the lobby. Referees can step in at any point, but nothing waits on them.
## Features
- **Ranked queue** — ELO-based matchmaking with automatic mappool selection by skill level
- **Automated matches** — lobbies are created in osu! via IRC, maps are picked/banned through chat commands, scores are collected automatically
- **Match engine** — full state machine (lobby → rolling → picking → playing → results) with configurable best-of formats and team sizes
- **Beatmap pools** — create and manage mappools with standard categories (NM, HD, HR, DT, FM, TB)
- **Teams** — create teams, manage rosters, track team stats
- **Leaderboard** — player and team rankings with high score tracking
- **Admin panel** — user management, role assignment, match oversight
- **Discord notifications** — match results posted to your Discord server
- **Live match dashboard.** A second-screen page with a live scoreboard, the map pool board (bans, picks, winners), turn prompts that pop up with a countdown, live scores while a map plays, a result reveal after every map, the lobby chat, and a timeline. Alt-tabbed players get a chime, a blinking tab, and an optional browser notification when it is their turn.
- **Configurable match procedure.** Best of any length, bans per team (also split into rounds, alternate or snake order), pick-or-ban choice for the roll winner, tiebreaker selection (ban down, random, re-roll, opening roll), freemod score multipliers, join windows, action timeouts with auto-roll / random pick / forfeited or random ban, no-show concede or cancel, early-disconnect restarts, and ties for even series.
- **Referee controls.** Pause and resume (the timer keeps its remaining time), force start, abort and replay or return the map to the pool, undo a pick, skip or extend a timer, act for a team, correct a map winner (ratings follow), forfeit, and cancel.
- **Spectating.** Anyone can watch any match live, signed in or not. The **Live** page shows every match being played, updating in place, with what is coming up and recent results.
- **Predictions and pick'ems.** Spectators call the winner (10 points) and the exact score (5 more) until the first map starts, and see the crowd split and the rating odds. Tournaments get a pick'em page per bracket with a leaderboard, and the site leaderboard has a Predictors tab.
- **OpenSkill ratings.** Every rating has an uncertainty. New players settle within a few matches, and in team matches each player moves by how much the system knows about them. Corrections undo a match's rating changes exactly.
- **Stream overlays and an auto-director.** Broadcast channels follow one match, a tournament, or whatever is most exciting, and render a customizable 1920×1080 overlay made of drag-and-drop components. A Windows agent adds live scores from the osu! tourney client through tosu and drives OBS. A container streams a channel to Twitch, YouTube, Facebook, X, Instagram and TikTok at once (or relays OBS), records every broadcast to storage, and plays it live and afterwards on the site with chapters per match. See [streamer/README.md](streamer/README.md).
- **Quick queue** for 1v1 to 4v4, matched by rating, with a pool picked by rating.
- **Tournaments** with registration, qualifiers, groups, single and double elimination, scheduling, and full branding: banner, icon, accent color, the osu! forum post (BBCode), prizes, links, and a player showcase.
- **Test lab** in dev and staging: play or watch full matches against simulated players, in a simulated lobby or a real osu! lobby over IRC with 0, 1 or 2 real sides (bots play inside Vash and never appear in osu!).
- **Several osu! accounts** host lobbies together; capacity is four lobbies per normal account.
- **Discord** result posts and stream announcements.
## Tech Stack
## How a match runs
- **Runtime:** [Bun](https://bun.sh)
- **Framework:** [SvelteKit](https://svelte.dev) + [Svelte 5](https://svelte.dev/docs/svelte/overview)
- **Database:** PostgreSQL + [Drizzle ORM](https://orm.drizzle.team)
- **Auth:** [Better Auth](https://better-auth.com) with osu! OAuth
- **Styling:** [Tailwind CSS 4](https://tailwindcss.com)
- **osu! Integration:** [bancho.js](https://bancho.js.org) (IRC) + osu! API v2
- **Discord:** [discord.js](https://discord.js.org)
- **Testing:** [Vitest](https://vitest.dev) (unit) + [Playwright](https://playwright.dev) (e2e)
- **Deploy:** Docker Compose on a single VM, CI/CD via GitHub Actions
```
web actions ─┐
IRC events ──┼─▶ match actor ─▶ reduce(state, action) ─▶ commit to Postgres ─▶ publish snapshot (websocket)
deadlines ───┘ (one per live match, pure, one transaction, └▶ lobby commands
actions run in order) no I/O revision-checked (!mp map, chat, …)
```
## Getting Started
- `src/lib/server/osu/procedure.ts` is the whole match procedure as a pure reducer: state + action → new state + lobby effects. Timers are deadlines in the state, so a restart resumes the same decision. It is exhaustively unit tested.
- `src/lib/server/match/runtime.ts` keeps one actor per live match. It turns BanchoBot lines, web requests, and deadlines into actions, commits, publishes, and runs the effects. When IRC misses the finish line, it recovers the result from the osu! API.
- `src/lib/server/osu/irc.ts` is real Bancho IRC, one connection per account, on a small client over Bun's TCP sockets (`irc-client.ts`: login, pings, rate limits, reconnect and rejoin). `src/lib/server/osu/simulator.ts` implements the same lobby interface in-process, with BanchoBot's exact wording, so the same parser and runtime are exercised in tests and dev. `fake-bancho.ts` puts an IRC server in front of the simulator, so tests also play whole matches over real sockets, with split packets and dropped connections.
- `src/lib/server/match/store.ts` loads a match in a handful of queries and commits each change in one transaction, with a revision check.
- `src/lib/match-view.ts` is the snapshot the match page receives on load and on every change. The page never refetches.
### Prerequisites
- `src/lib/server/broadcast.ts` runs broadcast channels: the director (`src/lib/broadcast/director.ts`) picks each channel's match, and overlays get one payload per change on the `channel:<id>` topic. Streamer agents connect to `/ws/agent`.
- [Bun](https://bun.sh) (v1.3+)
- PostgreSQL 16+ (or Docker)
- An [osu! OAuth application](https://osu.ppy.sh/home/account/edit#new-oauth-application) with callback URL `http://localhost:5173/api/auth/oauth2/callback/osu`
- [osu! IRC credentials](https://osu.ppy.sh/p/irc)
- A [Discord bot](https://discord.com/developers/applications) with a token and channel ID
- [Cloudflare R2](https://developers.cloudflare.com/r2/) bucket for image storage
The server leans on Bun instead of packages: `Bun.SQL` under Drizzle, `Bun.S3Client` for R2, Bun TCP sockets for IRC, and `bun build --compile` for the streamer agent. One websocket endpoint (`/ws`) carries every live topic: `match:<id>`, `live`, and `channel:<id>`.
### Setup
Long-lived state (IRC connections, the simulator, actors, timers) lives in `src/lib/server/singleton.ts` and `src/lib/server/services.ts`. A Vite reload replaces services and resumes match actors. **Restart `bun run dev` after changing the IRC client or provider:** those connection objects survive HMR and keep their original methods until a process restart.
For a streaming PC that connects to the existing site, follow the [Windows + WSL setup](streamer/WSL.md).
## Getting started
Prerequisites: [Bun](https://bun.sh) 1.3+, PostgreSQL 16+.
```sh
git clone https://github.com/VashSoftware/esports.git
git clone https://git.stanrunge.dev/stan/esports.git
cd esports
cp .env.example .env
```
Edit `.env` with your credentials:
```sh
# Database — use Docker or your own Postgres instance
DATABASE_URL="postgresql://vash:password@localhost:5432/vash"
# App
ORIGIN="http://localhost:5173"
BETTER_AUTH_SECRET="<run: openssl rand -hex 32>"
ROOT_ADMIN_EMAIL="<your-osu-user-id>@osu.local"
# osu!
OSU_CLIENT_ID="<from osu settings>"
OSU_CLIENT_SECRET="<from osu settings>"
OSU_IRC_USERNAME="<your osu username>"
OSU_IRC_PASSWORD="<from osu IRC settings>"
# Discord
DISCORD_BOT_TOKEN="<your bot token>"
DISCORD_MATCH_CHANNEL_ID="<channel id for match notifications>"
# Cloudflare R2
R2_ACCOUNT_ID="<cloudflare account id>"
R2_ACCESS_KEY_ID="<r2 api token access key>"
R2_SECRET_ACCESS_KEY="<r2 api token secret>"
R2_BUCKET="vash-esports-images"
R2_PUBLIC_URL="https://cdn.esports.vash.software"
```
If you don't have Postgres installed, start one with Docker:
```sh
docker run -d --name esports-db \
-e POSTGRES_DB=vash \
-e POSTGRES_USER=vash \
-e POSTGRES_PASSWORD=password \
-p 5432:5432 \
postgres:17
```
Install dependencies and push the database schema:
```sh
cp .env.example .env # fill in DB_URL, BETTER_AUTH_SECRET, OSU_CLIENT_ID/SECRET at least
bun install
bunx drizzle-kit push
bun run db:migrate # apply scripts/migrations in order
bun run db:seed # dev only: seeded players, teams, pools, a branded tournament
bun run dev # http://127.0.0.1:3102
```
Start the dev server:
On an empty database, create the base tables first with `bun run db:push`, then run the migrations.
### Testing without osu!
With `BANCHO_MODE=simulator` (the default outside production):
1. Open the dashboard. The **Simulation lab** card starts a 1v1 against a bot, or a bot-vs-bot match from 1v1 to 4v4.
2. On the match page, **Sim lab** controls map speed, network chaos (split lines, delayed scores, a dropped finish line), which teams the bots play for, and each player: join, leave or disconnect, ready, `!roll`, next score, or type anything in the lobby (`!pick NM1`, `!ban HD2`, `!choose ban`).
3. **Play as** copies a 10-minute link that signs a private window in as a seeded player, so you can play both sides yourself. From the terminal: `bun scripts/dev-login.ts dev-player-03`.
Automated tests run the reducer, the parser, the simulator, whole matches through the runtime with fake timers, and whole matches over IRC against the fake Bancho:
```sh
bun run dev
bun run test:unit --run
```
The app will be running at [http://localhost:5173](http://localhost:5173).
## Scripts
| Command | Description |
| ------------------- | ---------------------------------- |
| `bun run dev` | Start dev server |
| `bun run build` | Production build |
| `bun run check` | Type-check with svelte-check |
| `bun run lint` | Lint with Prettier + ESLint |
| `bun run test:unit` | Run unit tests (Vitest) |
| `bun run test:e2e` | Run e2e tests (Playwright) |
| `bun run test` | Run all tests |
| `bun run db:push` | Push schema changes to database |
| `bun run db:studio` | Open Drizzle Studio (database GUI) |
| Command | Description |
| ------------------------ | ------------------------------------------------------- |
| `bun run dev` | Dev server with HMR on 127.0.0.1:3102 |
| `bun run build` | Production build (`SENTRY_AUTH_TOKEN=` to skip uploads) |
| `bun run start` | Production server with the match websocket |
| `bun run check` | Type-check with svelte-check |
| `bun run test:unit` | Unit tests (Vitest) |
| `bun run test:streamer` | Streamer agent tests (bun test) |
| `bun run streamer` | Run the streamer agent from source |
| `bun run streamer:build` | Build `dist/vash-streamer(.exe)` for Linux and Windows |
| `bun run db:migrate` | Apply pending SQL migrations |
| `bun run db:migrate:dev` | Back up vash_dev, then migrate it |
| `bun run db:seed` | Seed the dev database |
| `bun run db:studio` | Drizzle Studio |
## Project Structure
## Database changes
```
src/
lib/
server/
match/
engine.ts # Match state machine and ELO calculations
orchestrator.ts # IRC lobby lifecycle (create, pick, play, results)
queue.ts # Ranked queue and matchmaking logic
rating.ts # ELO rating system
db/
schema.ts # Drizzle database schema
osu.ts # osu! API v2 client
auth.ts # Better Auth configuration
routes/
(app)/
matches/ # Match browser and live match view
mappools/ # Mappool creation and management
teams/ # Team browser and management
leaderboard/ # Player and team rankings
admin/ # Admin panel
```
Schema lives in `src/lib/server/db/schema.ts`. Every change also gets an idempotent SQL file in `scripts/migrations/` (`0005-…sql`, and so on). `bun scripts/migrate.ts` applies the pending files in order, in one transaction, and records them in `schema_migration`; `--status` lists them. The staging and production deploys take a verified `pg_dump` and then run it. Migrations only add: they never drop or rewrite existing users, teams, pools, or match history.
## Environments
| | dev | staging | prod |
| --------------- | ---------------------------- | ------------------------------------------------- | --------------------------- |
| URL | esports-dev.vash.software | esports-staging.vash.software | esports.vash.software |
| Runs as | `bun run dev` in tmux, :3102 | Docker, /opt/esports-staging, :3101 | Docker, /opt/esports, :3100 |
| Database | vash_dev | vash_staging | vash |
| Lobbies | simulator | simulator (set `BANCHO_MODE=irc` to use real IRC) | osu! IRC |
| Title and badge | `(DEV)` | `(STAGING)` | none |
The environment comes from `APP_ENV`, or from `ORIGIN` when that is unset. Dev and staging sit behind Cloudflare Access and are marked noindex.
## Deployment
The app deploys via Docker Compose to a single VM. Merges to `staging` and `prod` branches trigger automatic deployments through GitHub Actions.
Work on `staging` and push. Gitea runs the unit tests, builds `git.stanrunge.dev/stan/esports:<sha>`, backs up the staging database, migrates it, and starts the image at `/opt/esports-staging`. After checking staging, run the **Deploy production** action: it backs up and migrates the production database and starts the same image at `/opt/esports`.
```sh
docker compose build
docker compose up -d
docker compose exec app bunx drizzle-kit push --force
```
Deploys refuse to switch images while `/api/health` reports live matches, unless `FORCE_DEPLOY=1`. Before a deploy, use **Drain before restart** on /admin: no new lobbies open, the queue pauses, and live matches play to the end. After a restart, the runtime rejoins the lobbies it was hosting and continues; a lobby it cannot find pauses its match for a referee.
See `docker-compose.yml` for the full production setup and `docker-compose.staging.yml` for the staging environment.
Environment files are `/etc/esports-staging/staging.env` and `/etc/esports/prod.env`.
## Discord
The bot ("Vash Esports", CesarBot) only posts: results to `DISCORD_MATCH_CHANNEL_ID` with no pings, and stream announcements to `DISCORD_STREAM_CHANNEL_ID`. /admin shows whether it is connected, whether it can post in each channel, an **Add bot to server** link, and a **Send test message** button. Several environments can share the token; give dev and staging a private channel.
## Contributing
This project uses [conventional commits](https://www.conventionalcommits.org/) enforced by git hooks:
```
feat: add new feature
fix: fix a bug
chore: maintenance task
refactor: code restructuring
docs: documentation changes
```
Pre-commit hooks run Prettier and ESLint on staged files. Pre-push hooks run type-checking and unit tests.
Conventional commits are enforced by git hooks (`feat:`, `fix:`, `chore:`, `refactor:`, `docs:`). Pre-commit runs Prettier and ESLint on staged files; pre-push runs the type check and unit tests.
## License

497
STREAMING_HANDOFF.md Normal file
View File

@@ -0,0 +1,497 @@
# Vash Esports: unattended broadcast setup and project handoff
Updated: 2026-09-29. This document records the work completed so far, the user's latest requirements, and the work still needed. **The unattended native Windows broadcaster is now implemented (`streamer/windows`, `vash-broadcast.exe`) but has not yet run on the Windows PC.** Section 0 describes it; sections 1–10 are the original handoff and still hold as requirements.
## 0. Implementation status (second session, 2026-09-29)
### What exists now
`bun run broadcast:build` produces `dist/vash-broadcast.exe` (cross-compiled from Linux). One program, driven over SSH:
```text
vash-broadcast setup --instance prod --server <url> --channel <id> --token <token> [--access-id … --access-secret …]
vash-broadcast login --instance prod # one-time spectator sign-in, via an SSH-tunnelled screen viewer
vash-broadcast login --instance dev --copy-from prod
vash-broadcast start|stop|restart|status|logs|snapshot|doctor --instance prod
vash-broadcast list; vash-broadcast remove --instance prod --yes
```
Design, as built (details in `streamer/README.md` section 3):
- **Per-instance everything** under `%LOCALAPPDATA%\Vash\Broadcast\instances\<name>`: portable OBS 32.2.2, tosu 4.26.2, FFmpeg 9.0.2 (all pinned by SHA-256 in `streamer/windows/install.ts`), a separate osu! folder (copied `osu!.exe`), own ports (block of 10 from 47100), logs, state. Stopping an instance kills only processes whose executable is inside its folder.
- **Desktop launch:** one scheduled task per instance (`Vash Broadcast (<name>)`, LogonType Interactive, normal priority, optional at-logon trigger) runs `bin\vash-broadcast-worker.exe task --instance <name>`. The worker copy is the same exe with the PE subsystem patched to GUI (no console window). `task` is a watchdog that restarts `worker` on crashes. CLI talks to the worker over `127.0.0.1:<control port>` with a per-run token (`runtime.json`).
- **Desktop helper:** `streamer/windows/desktop.ps1`, a long-running Windows PowerShell 5.1 process (C# via Add-Type): window enumeration by executable folder, focus, move, set title, screen capture, SendInput clicks/typing, Windows.Media.Ocr, locked-desktop detection, keep-awake.
- **Room selection:** OCR of osu!tourney's control panel (inverted, upscaled), click the line with the lobby's match id (room-name fallback only when unique), then confirm through tosu's `tourney.team.left/right`. Gameplay capture stays hidden in OBS until confirmed. The wiki screenshot shows panel lines as `<match id> : <room name> () n/16`.
- **OBS:** config files written before launch (user.ini FirstRun, websocket config.json, profile with Simple output `StreamEncoder=amd` → `h264_texture_amf`, service.json → local relay, crash sentinel cleared); scene reconciled over obs-websocket (window capture by exact unique title with `capture_audio`, browser overlay on top, desktop/mic muted, control panel cropped by 16:9 geometry). Encoder actually used is read from OBS's log after the stream starts; OBS's silent x264 fallback is reported as a warning.
- **Broadcast lifecycle:** `streamer/relay.ts` (extracted from Vash TV, now shared): Go live → session start → local RTMP listener → OBS StartStream; destinations + HLS recording/upload; End stream → OBS StopStream, final upload, session end.
- **Isolation limit found:** tosu scans every osu! process and uses the first tourney manager, so only one instance can run osu!tourney + tosu per PC. A host lock (`tourney.lock`) enforces it; others report `blocked`.
- **dev/staging behind Cloudflare Access:** Access service-token headers on API/websocket calls, plus a local overlay proxy (adds headers, injects `no-referrer` for the CDN's hotlink protection) because OBS's browser source cannot send headers.
- **Site:** agents may send `{type:'status', …}`; `src/lib/broadcast/worker.ts` validates it; the Broadcast list and Stream page show the worker state. The Stream page's Windows section now shows the `vash-broadcast` commands.
### Verified here (Linux)
- `bun run test:streamer`: 38 tests (room matching/OCR handling/tosu confirmation and launch rules against a fake desktop; OBS scene reconciliation, auth failure and timeouts against a fake obs-websocket; config isolation, ports, lock, supervisor backoff, PE patch, pinned download verification; relay lifecycle; VashLink; Access proxy; the worker's start → report → Go live → stop against a fake Vash).
- App: svelte-check 0/0, vitest 248 passed / 6 skipped, production build OK.
- Vash TV refactor run inside the existing `vash-tv` image: screen mode, relay mode, and the recording path (HLS segments, live + VOD playlists, poster, session start/status/end) all worked.
- The helper's C# compiled with Mono at `-langversion:5`; all PowerShell (helper + task scripts) parsed with PowerShell's parser. That is syntax only.
### Not verified (needs the Windows PC)
Everything Windows-specific has never run: PowerShell helper behaviour, scheduled-task start from SSH, tar/Expand-Archive extraction, OBS portable first start without dialogs, window capture of osu!tourney (WGC) with `capture_audio`, osu!tourney window layout/title stability, OCR accuracy on the real panel, clicks reaching osu!, AMD AMF, sign-in viewer, stay-awake, and the multi-hour soak. Also unconfirmed: osu! stable's login keys (`Username`/`Password` in `osu!.<user>.cfg`, used to detect a saved login), and whether `--copy-from` logins work across folders.
### Next steps on the real host
1. Build and copy: `bun run broadcast:build`, then `scp dist/vash-broadcast.exe stan@stan-win11:`.
2. On the PC over SSH: `setup` for one instance (prod needs no Access token; dev/staging need a Cloudflare Access service token). Set `OSU_TOURNEY_ACCOUNT` on the matching server to the spectator username.
3. `login`, then `doctor` (runs desktop checks through the task), then `start`, `status`, `snapshot`.
4. Run a real match; compare `snapshots/tourney-panel.png/.json` with what OCR read; fix whatever the first run shows, and record the results here.
## 1. The outcome the user wants
The user wants to run a program on their Windows PC, let it set up the broadcasting tools, and have it work automatically after that. They can start, stop, or restart it over SSH. They cannot spend time clicking through OBS, selecting osu! rooms, or repairing configuration for each stream.
Latest requirements, in the user's words:
> “I want a solution that I can just kinda run a program and it sets everything up.”
>
> “Once its started it needs to just work. This is also important once I move it over to the cloud.”
>
> “I dont mind having 3 instances to manage for dev, staging and prod. But I cant do any manual work per stream or anythign like that.”
**One-time setup is acceptable. Manual work per stream is not.** Do not continue by asking the user to manually create OBS scenes, select capture windows, switch tournament rooms, or start multiple helper applications for every match. Implement the missing automation.
The preferred operating model is:
1. Install/setup an environment once.
2. Supply its Vash server, channel, and agent token once.
3. Complete the spectator account's initial sign-in once if it cannot be automated appropriately.
4. Start one supervised worker for that environment.
5. The worker follows Vash, launches/restarts its dependencies, selects the correct lobby, supplies live scores, and handles the broadcast lifecycle automatically.
6. Operator controls and diagnostics are available through SSH and the Vash website.
The user is comfortable managing separate dev, staging, and production instances. This should mean separate instances of the same software with isolated configuration, rather than three bespoke setup procedures.
## 2. Hardware, access, and account decisions
### Streaming PC
| Item | Confirmed information |
| ------------------------ | ---------------------------------------------------------------------------- |
| OS | Windows 11, latest build according to the user; exact build was not supplied |
| CPU | Intel Core i9-12900KF |
| GPU | AMD Radeon RX 7900 XT |
| Hostname | `STAN-WIN11` / `stan-win11` |
| Desktop user | `STAN-WIN11\stan` |
| Desktop session | A user is signed in; confirmed using `Get-CimInstance Win32_ComputerSystem` |
| Current access | User is on a MacBook and can SSH into Windows |
| Graphical remote access | None currently available |
| Installed | OBS and normal osu! stable |
| Not installed/configured | A separate osu!tourney installation and tosu |
| Spectator account | **Use a separate spectator login**, explicitly chosen by the user |
The user declined WSL for this setup because they want straightforward access to their AMD GPU. **Native Windows is the current target.** OBS's AMD AMF hardware encoder is the intended starting point; verify actual encoder availability and successful output on the PC. Do not assume NVIDIA/CUDA tooling applies to this machine.
The desktop being signed in does not prove it is unlocked, has an active display, or can currently produce valid captures. Those conditions need a real host check. Launching GUI applications directly inside an SSH service session is not a substitute for launching them in the intended desktop session.
No working OBS launch, scene configuration, osu!tourney login, or gameplay capture on this Windows machine has been confirmed. A PowerShell example using an interactive scheduled task was provided, but the user has not reported running it successfully.
The user has not supplied the separate spectator account name or credentials. Do not copy their existing playing account's saved login into the broadcaster by default. osu!tourney needs an account with osu!supporter. Resolve the supported spectator-login arrangement for concurrent dev/staging/prod workers; do not assume the same account can stay independently logged into all three workers.
### Existing application server and repository
- Workspace: `/home/stan/dev/esports`.
- Branch: `dev`.
- Origin: `https://git.stanrunge.dev/stan/esports`.
- Linux server was renamed from `vash-prod` to `stan-server-hetzner`.
- User explicitly authorized `ssh ai@localhost` for Docker/sudo operations on this Linux server. This is **not** an SSH connection to the Windows PC.
- We do not currently have a confirmed route or login for connecting directly from this workspace to `stan-win11`. The user has Windows access from their MacBook.
- The development application runs with `bun run dev` on `127.0.0.1:3102`; it is not the production Docker container.
- During debugging, its terminal was stan's tmux session `main`, pane `%1`. Recheck before using this operational detail again.
| Environment | Website | Database |
| ----------- | --------------------------------------- | -------------- |
| Dev | `https://esports-dev.vash.software` | `vash_dev` |
| Staging | `https://esports-staging.vash.software` | `vash_staging` |
| Production | `https://esports.vash.software` | `vash` |
The streaming PC should connect to the selected existing website. It does not need a second copy of the Vash web server, the server's `.env`, or its IRC host credentials just to broadcast.
## 3. How we arrived here
### Match-playing page redesign
The original request was to take the lead on redesigning the match-playing page for a standard 1920×1080 desktop browser: clean, intuitive, playful, live, and clear about what each player should do.
Completed work includes:
- A compact desktop dashboard with a scoreboard, central phase-specific guidance, and progress through the match.
- Sidebar tabs for activity, maps, results, and picks, with less frequently needed controls behind other UI components.
- More compact map pool, match feed, and history components.
- Better final-result layout and winner wrapping.
- WebSocket-driven updates, visible connection status, and five-second snapshot polling while the socket is unavailable.
- Attention cues for a player's turn, including existing sound/notification behavior.
The desktop layout was visually checked in a 1920×1080 headless Chromium viewport earlier in this conversation and fit that viewport. Preserve this work while implementing broadcasting.
Key files include `src/lib/components/match/MatchDashboard.svelte`, its `Stage*` components, `Scoreboard.svelte`, `MapPool.svelte`, `MatchFeed.svelte`, `MapHistory.svelte`, `live.svelte.ts`, and `src/routes/(app)/matches/[id]/+page.svelte`.
### Streaming and cloud discussion
The user tried starting a stream and saw it waiting for a Docker container that never appeared in `docker ps`.
The important finding: the website's **Go live** action changes channel state; it does not provision a Docker container. Stream setup text was updated to explain this. Existing Windows/OBS and manual Docker paths were documented, but they still involve too much manual operation for the user's actual requirement.
Cloud hosting was explored, including Google Cloud, AWS, and Hetzner. The user pointed out that an osu! match can run for approximately five hours, so a short-lived 30-minute assumption or a one-hour GPU runtime limit is unsuitable. They then explicitly deferred cloud hosting and chose to use their Windows PC first.
Do not resume cloud provisioning now. Build an unattended worker with a reusable configuration and lifecycle so it can move to cloud infrastructure later. No cloud worker provisioning was completed. A preliminary Cloud Run integration was removed earlier in the work.
### Windows access clarification
Initial instructions assumed the user could operate the Windows desktop. They clarified they are on a MacBook with SSH access only. They confirmed `STAN-WIN11\stan` is signed in, then clarified the true requirement: a program that provisions and runs the broadcast stack automatically, with at most one-time manual setup.
The user has now explicitly chosen a **separate spectator login**. This is the latest account decision.
## 4. Repeated lobby creation: diagnosed and fixed
The user repeatedly created a real-lobby match against a bot. Bancho made multiple rooms until the account hit its four-room limit, while the match page continued saying it was waiting to open a lobby. Cancelling in Vash did not reliably clean up every room.
### Actual root cause
Logs showed successful room creation followed by `Joining #mp_<id> timed out`.
A controlled live Bancho probe established the exact ordering:
1. Vash sends `!mp make`.
2. Bancho automatically joins the host to the new `#mp_...` channel.
3. Bancho sends JOIN/NAMES responses.
4. Bancho sends the private “Created the tournament match” confirmation.
5. The old client discarded the earlier JOIN, sent another JOIN, and waited for a new echo.
6. Bancho ignores a redundant JOIN to a channel already joined, so the client timed out.
7. The old provider converted that failure into a capacity wait and the runtime created another room on retry.
An earlier partial fix also did not replace the live provider's old methods: the IRC provider is a process-wide singleton that survives Vite HMR. A real dev-server restart was required.
### Fixes now in the repository
- Track confirmed channel membership and remember Bancho's automatic JOIN.
- Make repeated/concurrent JOIN requests reuse the joined state or pending request.
- Accept end-of-NAMES as confirmation for an expected channel; clear membership appropriately on leave/disconnect.
- Make the fake Bancho server reproduce automatic JOIN before the creation PM and ignore redundant JOINs.
- Serialize `!mp make` per IRC account so one confirmation cannot resolve multiple concurrent requests.
- Reserve capacity for pending creation requests.
- Start the creation-response timeout after a queued command is actually sent.
- Stop further creation attempts on an uncertain result instead of making more rooms blindly.
- Preserve the configured room limit when Bancho reports full capacity; temporarily back off instead of permanently reducing the configured maximum.
- Reuse an already created/persisted room when saving or resuming setup is retried.
- Close a late-arriving room when cancellation wins the race with room creation.
- Wait for in-flight opening work when replacing a runtime during reload.
- Allow the due-match loop to resume a saved room even when no new room slots are free.
- Log actual opening errors distinctly from ordinary capacity waits.
### A second live bug: room closure
A real test found that sending `!mp close` and immediately leaving the IRC channel could leave the Bancho room open. The close path now waits for Bancho's closure acknowledgement before forgetting/leaving the room. An externally confirmed closure also releases local capacity.
The earlier broad “close every apparently orphaned room during runtime boot” cleanup was removed. Do not reintroduce it without handling startup/load failures and in-flight creation correctly; a failed actor load is not proof that its lobby is abandoned.
### Verification completed
- The updated provider opened a real Bancho test room in approximately 361 ms.
- Bancho acknowledged that room's closure.
- A fresh connection confirmed the closed room no longer existed.
- Known rooms from the cancelled test attempts were cleaned up during the controlled checks.
- The dev server was restarted and reported connected IRC, zero active matches, and four free slots on the configured account.
- Final pre-push suite: **246 passed, 6 skipped**, across 19 passing test files and one skipped file. The skipped tests were the database-backed auth integration suite.
- Streamer tests: **3 passed**.
- Svelte check: **0 errors, 0 warnings**.
- Production build passed; Sentry uploads were disabled for this local build.
- Windows and Linux streamer binaries compiled successfully.
These checks verify the lobby fix and existing build/tests. They do **not** constitute a real Windows gameplay/OBS broadcast test.
Relevant files:
- `src/lib/server/osu/irc-client.ts` and `irc-client.test.ts`
- `src/lib/server/osu/irc.ts` and `irc.test.ts`
- `src/lib/server/osu/fake-bancho.ts`
- `src/lib/server/match/runtime.ts`, `runtime.test.ts`, and `runtime.irc.test.ts`
- `src/lib/server/match/service.ts` and `create.ts`
**Restart the dev process after changing IRC client/provider methods. HMR does not refresh methods on an existing singleton instance.** This is documented in `README.md`.
## 5. Git state and completed commits
The user's request to commit all accumulated changes was completed. The broad change set included the match UI/runtime, broadcast functionality, authentication/account work, database migrations, development tools, and deployment configuration that were already in the workspace.
Both commits were pushed to `dev`:
| Commit | Description |
| --------- | ------------------------------------------------------------------------------------------------------------------------- |
| `9b9aafe` | `feat: redesign match play and add broadcast tooling` — broad accumulated change set, including the principal lobby fixes |
| `9d5a046` | `fix: resume saved lobbies when host capacity is full` — final recovery guard and regression test |
The workspace was clean and `dev` matched `origin/dev` immediately before adding this handoff document.
The initial HTTPS push failed because Git tried to use an unavailable VS Code askpass socket. The hostname change was not an obstacle to the successful SSH push. Gitea's SSH service is exposed on the Linux server at localhost port 2222. The successful push used:
```bash
git -c core.sshCommand='ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new' \
push ssh://git@localhost:2222/stan/esports.git dev
```
That localhost URL is specific to the existing Linux server. Do not tell the user to clone `localhost:2222` on their Windows PC or Mac. Their clone URL is:
```text
https://git.stanrunge.dev/stan/esports.git
```
Pushing `dev` did not deploy production. The current Gitea workflow deploys pushes to `staging`; production promotion is a separate manual workflow.
## 6. Existing broadcasting pieces and their limitations
### Vash website and director
- Broadcast channels follow one match, one tournament, or automatic match selection.
- Layouts and overlays are configurable; overlays are 1920×1080 browser pages.
- The director sends scene, match ID, lobby ID, room name, and team size to authenticated agents over `/ws/agent`.
- Agent tokens are generated from **Agent token on the Broadcast list page**. They are shown once; rotating one invalidates the old token.
- `src/lib/server/broadcast.ts` handles channels, agent connections, live score ingestion, and broadcast sessions.
- The API at `src/routes/api/broadcast/[id]/stream/+server.ts` provides desired live state, video settings, overlay URL, destinations, and recording configuration to an authorized worker.
- That API also accepts session start/status/end reports. Related endpoints handle recording uploads.
- `OSU_TOURNEY_ACCOUNT` in the server environment grants the configured spectator referee access during new-lobby setup. It has not been confirmed configured for the new separate spectator account.
### Existing Windows/Linux agent: `streamer/agent.ts`
What it currently does:
- Authenticates to a channel's `/ws/agent` connection.
- Receives which match/scene/lobby to follow.
- Reads tosu v2 WebSocket scores and forwards them to Vash, at most four times per second.
- Optionally switches OBS scenes through OBS WebSocket.
- `--setup-obs` creates a scene, an unconfigured window capture source, and an overlay browser source, then exits.
- Compiles into `dist/vash-streamer.exe` and `dist/vash-streamer` with `bun run streamer:build`.
What it **does not** currently do:
- Install tosu or provision an osu!tourney installation.
- Launch itself or dependencies into the Windows desktop from SSH.
- Automatically select the Windows osu!tourney room.
- Automatically choose the capture window created by `--setup-obs`.
- Configure OBS GPU output/streaming end to end.
- Start or stop OBS streaming based on the website's Go live state.
- Supervise/restart the entire native Windows process stack.
- Isolate three native Windows environments.
Do not advertise this executable as the finished unattended worker.
### OBS client: `streamer/obs.ts`
Implements OBS WebSocket 5 authentication and generic requests. It is small and reusable, but needs stronger lifecycle handling for unattended operation: authentication failure/close handling, connect/request timeouts, reliable readiness state, and reconnection. Currently `connected` is based on the underlying socket state rather than completed authentication.
### Tournament automation: `streamer/tourney.ts`
Currently **Wine/X11-specific**:
- Writes `tournament.cfg` with `TeamSize`, `acronym = VASH`, and capture height while the client is stopped.
- Launches osu! with Wine.
- Captures the lower room-selection panel using FFmpeg/X11.
- Uses Tesseract OCR to find the expected room name and xdotool to click it.
- Restarts the client when team size changes.
Its current process management, including `wineserver -k`, is not suitable for blindly managing multiple isolated workers on one host. Native Windows support and safe ownership of individual worker processes remain to be designed/implemented.
### Vash TV: `streamer/tv/main.ts`
- Existing standalone broadcast runner polls channel settings and follows Go live/End stream.
- Renders the Vash overlay through Chromium/Xvfb, or accepts an OBS RTMP ingest stream in relay mode.
- Encodes the rendered mode with CPU `libx264`; relay mode copies the incoming encoded stream.
- Sends to multiple destinations, reports status, and creates/uploads HLS recordings for site playback.
- The relay could be reused behind native OBS, but a native packaging/lifecycle path is not yet implemented.
- Linux assumptions such as `/tmp/vash-hls`, command names, process handling, and ingest binding need review before reuse on Windows or for multiple simultaneous instances.
### Docker images and docs
- `streamer/tv/Dockerfile`: Chromium/Xvfb/FFmpeg data-only renderer or OBS relay.
- `streamer/tourney/Dockerfile` and `streamer/tourney/start.sh`: Wine osu!tourney, tosu, virtual displays, VNC setup, agent, and compositor.
- The tourney container still needs a prepared persistent osu! installation, login, and tosu binary. Its complete real-gameplay path has not been validated on the user's host.
- These images currently have no configured AMD GPU acceleration path. Do not assume merely running the existing container on a GPU machine makes it use that GPU.
- `streamer/README.md`: existing manual Windows and Docker documentation.
- `streamer/WSL.md`: an earlier WSL-assisted setup guide. It remains in the repo but **is not the user's chosen path now**.
## 7. What must be built next
### A. One entry point for an environment
Provide a packaged native Windows launcher/worker with an understandable CLI. An illustrative interface is below; **these commands do not exist yet**:
```text
vash-broadcast.exe setup --instance dev --server <url> --channel <id>
vash-broadcast.exe login --instance dev
vash-broadcast.exe start --instance dev
vash-broadcast.exe status --instance dev
vash-broadcast.exe logs --instance dev
vash-broadcast.exe restart --instance dev
vash-broadcast.exe stop --instance dev
```
The exact names are a design choice. The requirement is a single runnable program, persistent setup, repeatable starts, actionable status, and no per-stream desktop work. Avoid making Bun/Git and a project build permanent operational requirements if a downloadable compiled release can do the job.
### B. Repeatable setup
- Detect OBS and the existing osu! stable installation, accepting explicit paths when detection fails.
- Provision a **separate** managed osu!tourney installation. Do not convert or overwrite the user's personal playing installation.
- Download/install a verified tosu release automatically.
- Install/package any additional capture, OCR, or relay dependencies actually needed by the selected implementation.
- Configure a managed OBS profile/scene collection or separate portable OBS installation without changing unrelated personal OBS settings.
- Set up authenticated local OBS control and persist per-instance settings/secrets locally.
- Create the overlay source and exact game capture, including audio, geometry/cropping, and output settings automatically.
- Select and verify a working hardware encoder on the RX 7900 XT. Report failure explicitly rather than silently claiming GPU encoding while using a software fallback.
- Re-running setup must preserve successful login/configuration and avoid duplicate scenes, tasks, sources, or processes.
### C. Resolve the one-time osu! login boundary
The user accepted a separate spectator login and tolerates genuinely one-time interaction. Official osu!tourney setup calls for signing into a separate stable installation, remembering the login, then adding `tournament.cfg` while osu! is stopped.
Determine how to offer that first sign-in from the user's current SSH-only position. Possibilities need evaluation; do not promise an unsupported fully headless sign-in. Provide a clearly bounded one-time remote setup path if graphical interaction is unavoidable. After sign-in, preserve the installation and login state between restarts.
Do not invent an osu! OAuth-to-stable-client login mechanism, store a spectator password in the Vash web server, or silently reuse the personal playing account.
### D. Desktop launch and supervision
- Use a supported interactive desktop launch mechanism, such as Task Scheduler with `LogonType Interactive`, for the signed-in Windows user.
- Make start/restart commands callable through SSH. Launch dependencies in the appropriate session with the correct working directories.
- Track processes owned by each instance. Stop/restart only those processes and their managed descendants.
- Restart failed OBS, tosu, tourney, agent, and relay components with bounded backoff.
- Detect missing/locked/unavailable desktop capture and present a useful status instead of looping forever or showing a false success.
- Ensure SSH disconnection does not stop the broadcast.
- Handle update/reboot recovery explicitly. “Auto-start at login” is not the same as “works after reboot before anyone logs in”; document and validate the chosen behavior.
### E. Automatic lobby following on Windows
This is a core missing capability. Investigate a supported room-selection/control path before choosing image-based automation. Initial research looked for tosu/tourney IPC or command support but did not establish such a path.
If Windows UI/capture/OCR automation is required:
- Target the managed osu!tourney window and the exact requested match.
- Use lobby ID when a supported interface exposes it. Room-name fallback must handle ambiguity; do not click the first available room.
- Handle room-list refreshes, startup delays, team-size changes, DPI scaling, window placement, and client restarts.
- Verify that the requested match is actually being spectated before reporting success.
- Retry with bounded, meaningful states and expose diagnostics/snapshots through the CLI.
- Do not make the user click a room for each match.
Vash already supplies `lobbyId`, `roomName`, and `teamSize` in the agent follow message. All Vash room names use `VASH: (Red) vs (Blue)` with each side's name truncated to 20 characters, matching the server's creation logic.
### F. Automatic OBS configuration and broadcast lifecycle
- Finish programmatic scene/input setup: select the right capture window, crop out the tourney control panel, position gameplay correctly under the overlay, and capture the intended game audio.
- Account for osu!tourney's multiple client windows and team sizes; verify the capture is the actual match layout, not just its controller window.
- Configure output profiles and the AMD encoder automatically once, then reconcile settings safely on later starts.
- Respect the channel's desired live state and existing stream configuration. Establish an explicit start/stop contract for the worker and the website.
- Support the existing recording/site playback/multistream requirements using a managed relay or another concrete implementation. A Windows agent connected to `/ws/agent` alone does not provide video to the site.
- Report worker readiness and failures clearly on the site. Distinguish worker offline, login required, waiting for lobby, spectating, streaming, reconnecting, and error.
- Handle End stream and process shutdown cleanly, preserving recordings and releasing resources.
The user should operate normal broadcasts through Vash; opening OBS should be a diagnostic or truly one-time action.
### G. Dev/staging/production isolation
Separate instances must have separate:
- Server origins, channel IDs, and agent tokens.
- Configuration/state directories and installation ownership.
- OBS profiles/collections or managed installations and WebSocket ports/passwords.
- tosu ports and reliable association with the intended osu! processes.
- Ingest endpoints if relays are used, recording folders, logs, process identifiers, and scheduled tasks.
- Restart/stop behavior that cannot affect another instance or the user's personal osu!/OBS session.
Do not assume giving tosu three different ports is sufficient. Verify that each tosu instance can be scoped to its corresponding tournament clients rather than reading every osu! process and mixing environments.
Concurrent spectator logins, matching lobby names across environments, and aggregate GPU/CPU/display requirements also need validation. Three separate workers are acceptable to the user; simultaneous operation must not be falsely advertised before these interactions are tested.
### H. Cloud migration later
Keep the desired-state protocol, channel authorization, status reporting, recording, and supervisor behavior portable. Keep platform-specific desktop/process/encoder handling behind a clear boundary.
Support matches lasting at least five hours without an arbitrary short timeout. Preserve setup and spectator login state appropriately. A future cloud VM may need a GPU driver, an interactive/virtual display strategy, and persistent state; containers alone do not solve those issues.
Choose cloud hosting and provisioning later. The immediate acceptance target is this native Windows AMD PC.
## 8. Useful research and verified interfaces
### Official references
- [osu!tourney setup](https://osu.ppy.sh/wiki/en/osu!_tournament_client/osu!tourney/Setup): separate installation, remembered login, supporter requirement, `tournament.cfg`, `TeamSize`, `acronym`, and capture height.
- [osu!tourney multiplayer usage](https://osu.ppy.sh/wiki/en/osu!_tournament_client/osu!tourney/Multiplayer_usage): room naming and client behavior.
- [OBS WebSocket protocol](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.json).
- [OBS WebSocket README](https://github.com/obsproject/obs-websocket/blob/master/README.md): authentication and command-line overrides.
- [OBS launch parameters](https://obsproject.com/kb/launch-parameters).
- [OBS hardware encoding](https://obsproject.com/kb/hardware-encoding).
- [Microsoft scheduled task principals](https://learn.microsoft.com/en-us/powershell/module/scheduledtasks/new-scheduledtaskprincipal): `Interactive` logon type.
- [tosu repository](https://github.com/tosuapp/tosu) and [wiki](https://github.com/tosuapp/tosu/wiki/).
OBS protocol requests checked during research include:
- `GetInputPropertiesListPropertyItems` for enumerating selectable capture targets.
- `SetInputSettings`, scene creation/item APIs, and `SetSceneItemTransform` for automatic capture/overlay setup.
- `SetProfileParameter` and `SetVideoSettings` for output configuration; validate when a restart/reload is required rather than assuming every setting applies immediately.
- `SetStreamServiceSettings` for RTMP service settings, and stream start/stop/status requests.
- `GetSourceScreenshot` for remote diagnostics.
- `GetVersion` for capability checks.
Do not assume these APIs alone solve room selection, stable account login, session isolation, or GPU readiness.
### tosu release observed during research
The official GitHub release API returned `v4.26.2` during this session:
- Windows asset: `tosu-windows-v4.26.2.zip`.
- URL: `https://github.com/tosuapp/tosu/releases/download/v4.26.2/tosu-windows-v4.26.2.zip`.
- Reported SHA-256: `d34c2bfc495959fcc79ff01f10b7dc9aea77eb508ed23c8f0d0fb8342773faa3`.
This is a research observation, not a completed installer implementation. Revalidate the chosen release when implementing, then pin and verify it rather than downloading an unexamined changing binary on every start.
Potential upstream source locations identified but not yet investigated fully:
- `packages/tosu/src/states/tourney.ts`
- `packages/server/utils/commands.ts`
- `packages/common/utils/config.ts`
- `packages/common/utils/config.types.ts`
Temporary downloaded reference files existed at `/tmp/vash-tosu-tree.json`, `/tmp/vash-tosu-release.json`, and `/tmp/vash-obs-protocol.json`. Treat these as disposable research artifacts, not project dependencies.
## 9. Acceptance criteria for the next implementation
The work is complete only when the actual Windows host demonstrates the following:
1. A repeatable setup command provisions missing dependencies and a separate tourney installation, with a clearly bounded one-time spectator sign-in if needed.
2. One SSH start command starts the intended worker in the correct desktop session and survives disconnecting SSH.
3. A real match gets exactly one lobby, and the broadcaster automatically selects it.
4. OBS captures the correct gameplay and audio, layers the Vash overlay, and actually uses the RX 7900 XT's hardware encoder.
5. Live scores reach Vash through tosu and the agent.
6. Starting/ending a broadcast follows the selected website control without desktop interaction.
7. The next match, including a different team size, is followed without manual room/window selection.
8. Component crashes and network interruptions recover automatically without duplicate workers or wrong-room capture.
9. Recording, destination status, and site playback work for the supported output path.
10. Dev/staging/prod instances do not share state, capture the wrong clients, or stop each other.
11. A multi-hour soak test covers the expected match duration and records actual CPU/GPU/memory behavior.
12. Status/logs clearly identify one-time setup requirements and recoverable failures through SSH or the website.
Useful existing verification commands:
```bash
bun run check
bun --bun vitest run
bun run test:streamer
bun run streamer:build
SENTRY_AUTH_TOKEN= bun run build
```
IRC tests open local TCP sockets. In the restricted agent sandbox, Vitest hung; running the tests with the proper host permissions worked. Git hooks run formatting/lint on commit and type checking/unit tests on push.
Use focused new tests for configuration isolation, recovery, process ownership, OBS request sequencing/timeouts, and room-selection confirmation. A successful Linux cross-compilation is not evidence that Windows desktop capture and GPU encoding work. Record exactly what was tested on the real host and what remains unverified.
## 10. Immediate next step
Resume implementation of the unattended Windows launcher and worker, starting with a concrete design for per-instance state, one-time spectator login, desktop launch, automatic room selection, and managed OBS output. Reuse the existing director, agent protocol, tosu parser, OBS client, and recording pipeline where appropriate.
At the point the user requested this handoff, research had begun, but **no new Windows launcher, bootstrap installer, native room automation, or process supervisor had been written**. Preserve the committed lobby/UI fixes and make the missing setup actually runnable. Avoid another sequence of instructions that leaves the user doing manual work for each stream.

966
bun.lock

File diff suppressed because it is too large Load Diff

20
components.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/routes/(app)/layout.css",
"baseColor": "neutral"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry",
"style": "vega",
"iconLibrary": "lucide",
"menuColor": "default",
"menuAccent": "subtle"
}

22
compose.prod.yml Normal file
View File

@@ -0,0 +1,22 @@
services:
app:
image: git.stanrunge.dev/stan/esports:${IMAGE_TAG}
restart: unless-stopped
stop_grace_period: 30s
env_file:
- ${ENV_FILE}
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

View File

@@ -1,83 +0,0 @@
# Staging environment — runs alongside production on the same VM.
# Completely independent: own database, own app container, different ports.
#
# Usage:
# docker compose -f docker-compose.staging.yml -p esports-staging up -d --build
#
# Prerequisites (one-time server setup):
# 1. DNS: A record for staging.esports.vash.software → VM IP
# 2. SSL: sudo certbot certonly --nginx -d staging.esports.vash.software
# 3. nginx: reverse proxy staging.esports.vash.software → 127.0.0.1:3002
# 4. osu! OAuth: register a separate app at https://osu.ppy.sh/home/account/edit
# with callback URL: https://staging.esports.vash.software/api/auth/oauth2/callback/osu
# 5. osu! IRC: get a separate IRC password from https://osu.ppy.sh/p/irc
# 6. Copy .env.staging.example → .env.staging and fill in values
services:
staging-db:
image: postgres:17
container_name: esports-staging-db
restart: unless-stopped
environment:
POSTGRES_DB: vash_staging
POSTGRES_USER: vash_staging
POSTGRES_PASSWORD: ${STAGING_DB_PASSWORD}
env_file:
- .env.staging
volumes:
- pgdata-staging:/var/lib/postgresql/data
ports:
- '127.0.0.1:5433:5432'
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U vash_staging -d vash_staging']
interval: 2s
timeout: 3s
retries: 10
start_period: 2s
staging-app:
build: .
container_name: esports-staging-app
restart: unless-stopped
stop_grace_period: 2s
depends_on:
staging-db:
condition: service_healthy
ports:
- '127.0.0.1:3002:3000'
environment:
DATABASE_URL: postgresql://vash_staging:${STAGING_DB_PASSWORD}@staging-db:5432/vash_staging
ORIGIN: https://esports-staging.vash.software
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
OSU_CLIENT_ID: ${OSU_CLIENT_ID}
OSU_CLIENT_SECRET: ${OSU_CLIENT_SECRET}
OSU_IRC_USERNAME: ${OSU_IRC_USERNAME}
OSU_IRC_PASSWORD: ${OSU_IRC_PASSWORD}
DISCORD_BOT_TOKEN: ${DISCORD_BOT_TOKEN}
DISCORD_MATCH_CHANNEL_ID: ${DISCORD_MATCH_CHANNEL_ID}
R2_ACCOUNT_ID: ${R2_ACCOUNT_ID}
R2_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID}
R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY}
R2_BUCKET: ${R2_BUCKET:-vash-esports-images}
R2_PUBLIC_URL: ${R2_PUBLIC_URL:-https://cdn.esports.vash.software}
ROOT_ADMIN_EMAIL: ${ROOT_ADMIN_EMAIL}
PUBLIC_SENTRY_DSN: ${PUBLIC_SENTRY_DSN}
ADDRESS_HEADER: CF-Connecting-IP
NODE_ENV: production
env_file:
- .env.staging
healthcheck:
test:
[
'CMD',
'bun',
'-e',
"fetch('http://localhost:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
]
interval: 5s
timeout: 3s
retries: 3
start_period: 10s
volumes:
pgdata-staging:

View File

@@ -1,106 +1,4 @@
services:
db:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_DB: vash
POSTGRES_USER: vash
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- '127.0.0.1:5432:5432' # Only accessible from host
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U vash -d vash']
interval: 2s
timeout: 3s
retries: 10
start_period: 2s
app:
build: .
restart: unless-stopped
stop_grace_period: 2s
depends_on:
db:
condition: service_healthy
ports:
- '127.0.0.1:3000:3000' # Only accessible from host (nginx proxies)
environment:
DATABASE_URL: postgresql://vash:${DB_PASSWORD}@db:5432/vash
ORIGIN: ${ORIGIN:-https://esports.vash.software}
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
OSU_CLIENT_ID: ${OSU_CLIENT_ID}
OSU_CLIENT_SECRET: ${OSU_CLIENT_SECRET}
OSU_IRC_USERNAME: ${OSU_IRC_USERNAME}
OSU_IRC_PASSWORD: ${OSU_IRC_PASSWORD}
PUBLIC_SENTRY_DSN: ${PUBLIC_SENTRY_DSN}
ADDRESS_HEADER: CF-Connecting-IP
NODE_ENV: production
env_file:
- .env
healthcheck:
test:
[
'CMD',
'bun',
'-e',
"fetch('http://localhost:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
]
interval: 5s
timeout: 3s
retries: 3
start_period: 10s
umami-db:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_DB: umami
POSTGRES_USER: umami
POSTGRES_PASSWORD: ${UMAMI_DB_PASSWORD}
volumes:
- umamidata:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U umami -d umami']
interval: 2s
timeout: 3s
retries: 10
start_period: 2s
umami:
image: ghcr.io/umami-software/umami:postgresql-latest
restart: unless-stopped
depends_on:
umami-db:
condition: service_healthy
ports:
- '127.0.0.1:3001:3000' # Proxy via nginx at e.g. analytics.vash.software
environment:
DATABASE_URL: postgresql://umami:${UMAMI_DB_PASSWORD}@umami-db:5432/umami
APP_SECRET: ${UMAMI_APP_SECRET}
healthcheck:
test:
[
'CMD-SHELL',
'node -e "fetch(\"http://localhost:3000/api/heartbeat\").then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"'
]
interval: 15s
timeout: 5s
retries: 5
start_period: 30s
dozzle:
image: amir20/dozzle:latest
restart: unless-stopped
ports:
- '127.0.0.1:8080:8080' # Proxy via nginx at logs.vash.software
environment:
DOZZLE_AUTH_PROVIDER: simple
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./dozzle-users.yml:/data/users.yml:ro
volumes:
pgdata:
umamidata:
# Vash Esports does not run its own Postgres container.
# Production and staging use compose.prod.yml: one app container on the
# external Docker network "backend", talking to the central database.
# Dev is `bun run dev` on 127.0.0.1:3102.

View File

@@ -1,11 +1,12 @@
import { defineConfig } from 'drizzle-kit';
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
const databaseUrl = process.env.DATABASE_URL || process.env.DB_URL;
if (!databaseUrl) throw new Error('DATABASE_URL or DB_URL is not set');
export default defineConfig({
schema: './src/lib/server/db/schema.ts',
dialect: 'postgresql',
dbCredentials: { url: process.env.DATABASE_URL },
dbCredentials: { url: databaseUrl },
verbose: true,
strict: true
});

View File

@@ -1,237 +0,0 @@
CREATE TABLE "mappool" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"created_by" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"verified_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "mappool_slot" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"mappool_id" uuid NOT NULL,
"category" text NOT NULL,
"order_in_category" integer NOT NULL,
"beatmap_id" text NOT NULL,
"star_rating" real,
"bpm" real,
"total_length" integer,
"mods" text[] DEFAULT '{}' NOT NULL,
"title" text,
"artist" text,
"version" text,
"cover_url" text,
"list_cover_url" text
);
--> statement-breakpoint
CREATE TABLE "match" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text,
"state" text DEFAULT 'CREATED' NOT NULL,
"config" jsonb NOT NULL,
"mappool_id" uuid,
"osu_lobby_id" integer,
"scheduled_at" timestamp with time zone,
"started_at" timestamp with time zone,
"finished_at" timestamp with time zone,
"winner_id" uuid,
"created_by" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "match_game" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"match_id" uuid NOT NULL,
"game_number" integer NOT NULL,
"mappool_slot_id" uuid NOT NULL,
"picked_by_participant_id" uuid,
"winner_participant_id" uuid,
"state" text DEFAULT 'PENDING' NOT NULL,
"started_at" timestamp with time zone,
"finished_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "match_game_score" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"match_game_id" uuid NOT NULL,
"player_id" uuid NOT NULL,
"score" integer DEFAULT 0 NOT NULL,
"accuracy" real DEFAULT 0 NOT NULL,
"max_combo" integer DEFAULT 0 NOT NULL,
"count_300" integer DEFAULT 0 NOT NULL,
"count_100" integer DEFAULT 0 NOT NULL,
"count_50" integer DEFAULT 0 NOT NULL,
"count_miss" integer DEFAULT 0 NOT NULL,
"mods" text[] DEFAULT '{}' NOT NULL,
"passed" boolean DEFAULT false NOT NULL,
"pp" real
);
--> statement-breakpoint
CREATE TABLE "match_invite" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"created_by" text NOT NULL,
"creator_team_id" uuid NOT NULL,
"invited_team_id" uuid NOT NULL,
"config" jsonb NOT NULL,
"mappool_id" uuid NOT NULL,
"name" text,
"message" text,
"scheduled_at" timestamp with time zone,
"expires_at" timestamp with time zone NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"match_id" uuid,
"responded_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "match_participant" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"match_id" uuid NOT NULL,
"team_id" uuid NOT NULL,
"slot" integer NOT NULL,
"score" integer DEFAULT 0 NOT NULL,
"roll_value" integer,
"pick_order" integer
);
--> statement-breakpoint
CREATE TABLE "match_participant_player" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"participant_id" uuid NOT NULL,
"user_id" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "match_queue" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"team_id" uuid NOT NULL,
"elo" integer DEFAULT 1000 NOT NULL,
"joined_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "notification" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"type" text NOT NULL,
"title" text NOT NULL,
"message" text,
"reference_id" text,
"read" boolean DEFAULT false NOT NULL,
"actioned_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "player_rating" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"elo" integer DEFAULT 1000 NOT NULL,
"wins" integer DEFAULT 0 NOT NULL,
"losses" integer DEFAULT 0 NOT NULL,
"initial_elo" integer,
"osu_rank_at_seed" integer,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "player_rating_user_id_unique" UNIQUE("user_id")
);
--> statement-breakpoint
CREATE TABLE "team" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"is_personal" boolean DEFAULT false NOT NULL,
"owner_id" text,
"avatar_url" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "team_member" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"team_id" uuid NOT NULL,
"user_id" text NOT NULL,
"role" text DEFAULT 'member',
"joined_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"role" text DEFAULT 'player' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "mappool_slot" ADD CONSTRAINT "mappool_slot_mappool_id_mappool_id_fk" FOREIGN KEY ("mappool_id") REFERENCES "public"."mappool"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "match" ADD CONSTRAINT "match_mappool_id_mappool_id_fk" FOREIGN KEY ("mappool_id") REFERENCES "public"."mappool"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "match" ADD CONSTRAINT "match_winner_id_team_id_fk" FOREIGN KEY ("winner_id") REFERENCES "public"."team"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "match_game" ADD CONSTRAINT "match_game_match_id_match_id_fk" FOREIGN KEY ("match_id") REFERENCES "public"."match"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "match_game" ADD CONSTRAINT "match_game_mappool_slot_id_mappool_slot_id_fk" FOREIGN KEY ("mappool_slot_id") REFERENCES "public"."mappool_slot"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "match_game" ADD CONSTRAINT "match_game_picked_by_participant_id_match_participant_id_fk" FOREIGN KEY ("picked_by_participant_id") REFERENCES "public"."match_participant"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "match_game" ADD CONSTRAINT "match_game_winner_participant_id_match_participant_id_fk" FOREIGN KEY ("winner_participant_id") REFERENCES "public"."match_participant"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "match_game_score" ADD CONSTRAINT "match_game_score_match_game_id_match_game_id_fk" FOREIGN KEY ("match_game_id") REFERENCES "public"."match_game"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "match_game_score" ADD CONSTRAINT "match_game_score_player_id_match_participant_player_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."match_participant_player"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "match_invite" ADD CONSTRAINT "match_invite_creator_team_id_team_id_fk" FOREIGN KEY ("creator_team_id") REFERENCES "public"."team"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "match_invite" ADD CONSTRAINT "match_invite_invited_team_id_team_id_fk" FOREIGN KEY ("invited_team_id") REFERENCES "public"."team"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "match_invite" ADD CONSTRAINT "match_invite_mappool_id_mappool_id_fk" FOREIGN KEY ("mappool_id") REFERENCES "public"."mappool"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "match_invite" ADD CONSTRAINT "match_invite_match_id_match_id_fk" FOREIGN KEY ("match_id") REFERENCES "public"."match"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "match_participant" ADD CONSTRAINT "match_participant_match_id_match_id_fk" FOREIGN KEY ("match_id") REFERENCES "public"."match"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "match_participant" ADD CONSTRAINT "match_participant_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "match_participant_player" ADD CONSTRAINT "match_participant_player_participant_id_match_participant_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."match_participant"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "match_queue" ADD CONSTRAINT "match_queue_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "team_member" ADD CONSTRAINT "team_member_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_match_state" ON "match" USING btree ("state");--> statement-breakpoint
CREATE INDEX "idx_match_game_match" ON "match_game" USING btree ("match_id");--> statement-breakpoint
CREATE INDEX "idx_mgs_game" ON "match_game_score" USING btree ("match_game_id");--> statement-breakpoint
CREATE INDEX "idx_invite_invited_team" ON "match_invite" USING btree ("invited_team_id");--> statement-breakpoint
CREATE INDEX "idx_invite_created_by" ON "match_invite" USING btree ("created_by");--> statement-breakpoint
CREATE INDEX "idx_invite_status" ON "match_invite" USING btree ("status");--> statement-breakpoint
CREATE INDEX "idx_mp_match_team" ON "match_participant" USING btree ("match_id","team_id");--> statement-breakpoint
CREATE UNIQUE INDEX "idx_mp_match_slot" ON "match_participant" USING btree ("match_id","slot");--> statement-breakpoint
CREATE INDEX "idx_mp_match" ON "match_participant" USING btree ("match_id");--> statement-breakpoint
CREATE UNIQUE INDEX "idx_mpp_participant_user" ON "match_participant_player" USING btree ("participant_id","user_id");--> statement-breakpoint
CREATE INDEX "idx_mpp_participant" ON "match_participant_player" USING btree ("participant_id");--> statement-breakpoint
CREATE UNIQUE INDEX "idx_queue_user" ON "match_queue" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_queue_elo" ON "match_queue" USING btree ("elo");--> statement-breakpoint
CREATE INDEX "idx_notification_user" ON "notification" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_notification_user_read" ON "notification" USING btree ("user_id","read");--> statement-breakpoint
CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");

View File

@@ -1,3 +0,0 @@
CREATE INDEX "idx_mappool_slot_mappool" ON "mappool_slot" USING btree ("mappool_id");--> statement-breakpoint
CREATE INDEX "idx_match_created_at" ON "match" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "idx_mpp_user" ON "match_participant_player" USING btree ("user_id");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +0,0 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1773082177642,
"tag": "0000_tricky_jane_foster",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1773426173223,
"tag": "0001_swift_miek",
"breakpoints": true
}
]
}

View File

@@ -6,7 +6,9 @@ import svelte from 'eslint-plugin-svelte';
import { defineConfig } from 'eslint/config';
import globals from 'globals';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
import { loadConfig } from '@sveltejs/load-config';
const svelteConfig = (await loadConfig('./', { traverse: false }))?.config;
const gitignorePath = path.resolve(import.meta.dirname, '.gitignore');

View File

@@ -4,64 +4,77 @@
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "bun --bun vite dev",
"dev": "bun --bun vite dev --host 127.0.0.1 --port 3102",
"build": "bun --bun vite build",
"start": "bun ./build/index.js",
"start": "bun ./prod-server.ts",
"preview": "bun --bun vite preview",
"prepare": "svelte-kit sync || echo '' && husky",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"test:unit": "vitest",
"test": "bun run test:unit -- --run && bun run test:e2e",
"test:unit": "bun --bun vitest",
"test": "bun --bun vitest --run && bun run test:e2e",
"test:e2e": "playwright test",
"test:streamer": "bun test streamer/",
"streamer": "bun streamer/agent.ts",
"broadcast:build": "bun build streamer/windows/cli.ts --compile --minify --target=bun-windows-x64 --outfile dist/vash-broadcast.exe",
"streamer:build": "bun build streamer/agent.ts --compile --minify --target=bun-windows-x64 --outfile dist/vash-streamer.exe && bun build streamer/agent.ts --compile --minify --target=bun-linux-x64 --outfile dist/vash-streamer",
"db:seed": "bun scripts/seed-dev.ts",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:migrate": "bun scripts/migrate.ts",
"db:migrate:dev": "bun scripts/migrate-dev.ts",
"db:studio": "drizzle-kit studio",
"auth:schema": "bun x @better-auth/cli generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes"
},
"devDependencies": {
"@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.5.0",
"@eslint/compat": "^2.0.3",
"@commitlint/cli": "^20.5.3",
"@commitlint/config-conventional": "^20.5.3",
"@eslint/compat": "^2.1.1",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.58.2",
"@sentry/sveltekit": "^10.46.0",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.55.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.2",
"@types/bun": "^1.3.11",
"@types/node": "^25.5.0",
"@vitest/browser-playwright": "^4.1.1",
"better-auth": "^1.5.6",
"drizzle-kit": "^0.31.10",
"drizzle-orm": "^0.45.1",
"eslint": "^10.1.0",
"@lucide/svelte": "^1.48.0",
"@playwright/test": "^1.63.0",
"@sentry/sveltekit": "^10.75.3",
"@sveltejs/adapter-bun": "^1.0.0",
"@sveltejs/kit": "^3.0.0",
"@sveltejs/load-config": "^0.2.2",
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3",
"@types/bun": "^1.4.2",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^25.9.8",
"better-auth": "~1.5.6",
"bits-ui": "^2.19.3",
"canvas-confetti": "^1.9.4",
"clsx": "^2.1.1",
"drizzle-kit": "^0.31.11",
"drizzle-orm": "^0.45.3",
"eslint": "^10.11.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.16.0",
"globals": "^17.4.0",
"eslint-plugin-svelte": "^3.23.0",
"globals": "^17.12.0",
"hls.js": "^1.7.3",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"playwright": "^1.58.2",
"postgres": "^3.4.8",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.5.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.55.0",
"svelte-check": "^4.4.5",
"tailwindcss": "^4.2.2",
"typescript": "~5.8",
"typescript-eslint": "^8.57.2",
"vite": "^8.0.2",
"vite-plugin-devtools-json": "^1.0.0",
"vitest": "^4.1.1",
"vitest-browser-svelte": "^2.1.0"
"prettier": "^3.9.9",
"prettier-plugin-svelte": "^3.5.2",
"prettier-plugin-tailwindcss": "^0.7.4",
"shadcn-svelte": "^1.7.0",
"svelte": "^5.57.1",
"svelte-check": "^4.7.6",
"svelte-sonner": "^1.2.1",
"tailwind-merge": "^3.7.0",
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.0",
"typescript-eslint": "^8.70.1",
"vite": "^8.3.1",
"vite-plugin-devtools-json": "^1.1.0",
"vitest": "^4.1.11"
},
"lint-staged": {
"*.{js,ts,svelte}": [
@@ -73,8 +86,12 @@
]
},
"dependencies": {
"bancho.js": "^0.12.3",
"discord.js": "^14.25.1",
"zod": "^4.3.6"
"@bbob/parser": "^4.4.1",
"discord.js": "^14.27.0",
"zod": "^4.6.5"
},
"imports": {
"#lib": "./src/lib/index.js",
"#lib/*": "./src/lib/*"
}
}

13
prod-server.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* Production entry: the server adapter-bun generates, plus the app's WebSockets.
* The generated server calls Bun.serve itself and has no websocket option, so
* Bun.serve is wrapped before it loads to upgrade /ws paths first. Everything
* else (static assets, HOST/PORT/*_HEADER env, graceful shutdown) is the adapter's.
*/
import { bunSockets } from './src/lib/server/ws-attach.ts';
const serve = Bun.serve;
Bun.serve = ((options: Parameters<typeof serve>[0] & { fetch: Parameters<typeof bunSockets>[0] }) =>
serve({ ...options, ...bunSockets(options.fetch) })) as typeof serve;
await import('./build/index.js');

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;

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

@@ -0,0 +1,130 @@
#!/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
# The deploy user only has passwordless sudo for tee and docker.
if [ ! -d "$directory" ]; then
echo "$directory does not exist; create it once on the host" >&2
exit 1
fi
previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/release.env" 2>/dev/null | head -n 1 || true)
write_release() {
printf 'IMAGE_TAG=%s\nHOST_PORT=%s\n' "$1" "$host_port" | sudo tee "$directory/release.env" >/dev/null
}
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
switched=0
rollback() {
if [ "$switched" -eq 1 ] && [ -n "$previous_tag" ]; then
echo "Health check failed; restoring prior image tag $previous_tag" >&2
write_release "$previous_tag"
sudo docker compose --env-file "$directory/release.env" -f "$compose" up -d
fi
}
trap rollback ERR
port=$host_port
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"
# The container runs as root, so it creates the directory and sets the modes.
sudo docker run --rm --network backend --env-file "$environment" \
--mount "type=bind,source=$(dirname "$backup_dir"),target=/backups" \
--env "BACKUP_FILE=$(basename "$backup_dir")/$backup_name" "$backup_image" \
sh -eu -c 'mkdir -p -m 0700 "/backups/${BACKUP_FILE%/*}"
pg_dump --format=custom --no-owner --no-acl --file="/backups/$BACKUP_FILE" "${DB_URL:-$DATABASE_URL}"
chmod 0600 "/backups/$BACKUP_FILE"
pg_restore --list "/backups/$BACKUP_FILE" >/dev/null'
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
write_release "$sha"
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);

18
src/app.d.ts vendored
View File

@@ -1,10 +1,24 @@
// src/app.d.ts
import type { User, Session } from 'better-auth/minimal';
import type { Session } from 'better-auth/minimal';
import type { user } from '#lib/server/db/auth.schema.js';
import type { ConnectionProfile } from '#lib/auth-identity.js';
declare global {
namespace App {
interface Locals {
user?: User & { role: string };
user?: Pick<
typeof user.$inferSelect,
| 'id'
| 'name'
| 'email'
| 'emailVerified'
| 'image'
| 'createdAt'
| 'updatedAt'
| 'role'
| 'rootAdmin'
>;
oauthProfile?: { providerId: string; accountId: string; profile: ConnectionProfile };
session?: Session;
requestId?: string;
}

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -9,7 +9,6 @@
href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700;800&family=Geist+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<title>Vash Esports</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

41
src/env.ts Normal file
View File

@@ -0,0 +1,41 @@
import { defineEnvVars } from '@sveltejs/kit/env';
/** Every variable is optional and read at startup, like the old `$env/dynamic` modules. */
const optional = (input: string | undefined) => input;
export const variables = defineEnvVars({
PUBLIC_UMAMI_URL: { public: true, schema: optional },
PUBLIC_UMAMI_WEBSITE_ID: { public: true, schema: optional },
ORIGIN: { schema: optional },
APP_ENV: { schema: optional },
RESEND_API_KEY: { schema: optional },
EMAIL_FROM: { schema: optional },
OSU_CLIENT_ID: { schema: optional },
OSU_CLIENT_SECRET: { schema: optional },
DISCORD_CLIENT_ID: { schema: optional },
DISCORD_CLIENT_SECRET: { schema: optional },
GOOGLE_CLIENT_ID: { schema: optional },
GOOGLE_CLIENT_SECRET: { schema: optional },
BETTER_AUTH_SECRET: { schema: optional },
R2_PUBLIC_URL: { schema: optional },
STREAM_KEY_SECRET: { schema: optional },
R2_ACCESS_KEY_ID: { schema: optional },
R2_SECRET_ACCESS_KEY: { schema: optional },
R2_ACCOUNT_ID: { schema: optional },
R2_BUCKET: { schema: optional },
ROOT_ADMIN_USER_ID: { schema: optional },
BANCHO_MODE: { schema: optional },
OSU_IRC_ACCOUNTS: { schema: optional },
OSU_IRC_USERNAME: { schema: optional },
OSU_IRC_PASSWORD: { schema: optional },
OSU_IRC_HOST: { schema: optional },
OSU_IRC_PORT: { schema: optional },
DISCORD_BOT_CLIENT_ID: { schema: optional },
DISCORD_BOT_TOKEN: { schema: optional },
DISCORD_GUILD_ID: { schema: optional },
DISCORD_MATCH_CHANNEL_ID: { schema: optional },
DISCORD_STREAM_CHANNEL_ID: { schema: optional },
DATABASE_URL: { schema: optional },
DB_URL: { schema: optional },
LOG_SQL: { schema: optional }
});

View File

@@ -1,6 +1,7 @@
import type { HandleClientError } from '@sveltejs/kit/hooks';
// src/hooks.client.ts
import * as Sentry from '@sentry/sveltekit';
import type { HandleClientError } from '@sveltejs/kit';
Sentry.init({
dsn: import.meta.env.PUBLIC_SENTRY_DSN,

View File

@@ -1,53 +1,44 @@
// src/hooks.server.ts
import type { Handle, HandleServerError } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import { building } from '$app/environment';
import { sequence, type Handle, type HandleServerError } from '@sveltejs/kit/hooks';
import { building, dev } from '$app/env';
import * as Sentry from '@sentry/sveltekit';
import { appEnv, titlePrefix } from '#lib/server/app-env.js';
if (!building) {
Sentry.init({
dsn: process.env.PUBLIC_SENTRY_DSN,
tracesSampleRate: 1.0, // TODO: lower to 0.2–0.3 once past alpha
environment: process.env.NODE_ENV ?? 'development',
sendDefaultPii: true
environment: appEnv(),
tracesSampleRate: Number(
process.env.SENTRY_TRACES_SAMPLE_RATE ?? (appEnv() === 'prod' ? 0.1 : 0)
),
sendDefaultPii: false
});
}
import { auth } from '$lib/server/auth';
import { auth } from '#lib/server/auth.js';
import { svelteKitHandler } from 'better-auth/svelte-kit';
import { db } from '$lib/server/db';
import { user as userTable } from '$lib/server/db/auth.schema';
import { db } from '#lib/server/db/index.js';
import { user as userTable } from '#lib/server/db/auth.schema.js';
import { eq } from 'drizzle-orm';
import { ensureRootAdminRole } from '$lib/server/permissions';
import { checkRateLimit } from '$lib/server/rate-limit';
import { log } from '$lib/server/logger';
import { metrics } from '$lib/server/metrics';
import { ensureRootAdminRole } from '#lib/server/permissions/index.js';
import { checkRateLimit } from '#lib/server/rate-limit.js';
import { log } from '#lib/server/logger.js';
import { metrics } from '#lib/server/metrics.js';
import { startServices } from '#lib/server/services.js';
// ── Initialize the IRC DM handler once on server startup ──
if (!building) {
import('$lib/server/bancho/dm-handler')
.then(({ initDMHandler }) => initDMHandler())
.catch((err) => log.dm.warn({ err: err.message }, 'DM handler init skipped'));
import('$lib/server/tournament/scheduler')
.then(({ startTournamentScheduler }) => startTournamentScheduler())
.catch((err) => log.tournament.warn({ err: err.message }, 'Tournament scheduler init skipped'));
import('$lib/server/discord/client')
.then(({ initDiscordClient }) => initDiscordClient())
.catch((err) => log.discord.warn({ err: err.message }, 'Discord client init skipped'));
process.on('unhandledRejection', (reason) => {
log.http.error({ err: reason }, 'Unhandled rejection');
});
process.on('uncaughtException', (err) => {
log.http.fatal({ err }, 'Uncaught exception');
});
startServices().catch((err) => log.http.fatal({ err }, 'background services failed to start'));
}
/** Safe IP getter — returns null for internal requests missing the ADDRESS_HEADER */
/** Returns null for internal requests that lack the client address header. */
function getIp(event: Parameters<Handle>[0]['event']): string | null {
// The Vite dev server sits behind Caddy and only sees 127.0.0.1. The built
// server reads the real address itself through ADDRESS_HEADER.
if (dev) {
const forwarded = event.request.headers.get('cf-connecting-ip');
if (forwarded) return forwarded;
}
try {
return event.getClientAddress();
} catch {
@@ -55,116 +46,99 @@ function getIp(event: Parameters<Handle>[0]['event']): string | null {
}
}
// ── Request logging middleware ──
// Paths that are too noisy to log every time (healthchecks, polling)
// Health checks and polling would drown out everything else.
const SILENT_PATHS = new Set(['/api/health', '/api/queue']);
const handleRequestLogging: Handle = async ({ event, resolve }) => {
const requestId = crypto.randomUUID();
const start = performance.now();
event.locals.requestId = requestId;
const response = await resolve(event);
const prefix = titlePrefix();
const response = await resolve(event, {
transformPageChunk: prefix
? ({ html }) => html.replace('<title>', `<title>${prefix}`)
: undefined
});
const duration = Math.round(performance.now() - start);
const path = event.url.pathname;
const status = response.status;
// Always count metrics, but only log interesting requests
metrics.requestCount++;
metrics.totalResponseTime += duration;
const isSilent = SILENT_PATHS.has(path) && status < 400;
if (!isSilent) {
const userId = event.locals.user?.id;
if (!(SILENT_PATHS.has(path) && response.status < 400)) {
log.http.info(
{
requestId,
method: event.request.method,
path,
status,
status: response.status,
duration,
userId: userId ?? null,
userId: event.locals.user?.id ?? null,
ip: getIp(event)
},
`${event.request.method} ${path} ${status} ${duration}ms`
`${event.request.method} ${path} ${response.status} ${duration}ms`
);
}
if (duration > 1000) {
log.http.warn({ requestId, path, duration }, 'Slow request');
log.http.warn({ requestId, path, duration }, 'slow request');
metrics.slowRequests++;
}
response.headers.set('X-Request-Id', requestId);
return response;
};
// ── Auth + rate limiting ──
const handleBetterAuth: Handle = async ({ event, resolve }) => {
// Rate limiting for API routes
if (event.url.pathname.startsWith('/api/')) {
const tooMany = (retryAfter?: number) =>
new Response('Too Many Requests', {
status: 429,
headers: { 'Retry-After': String(retryAfter ?? 60), 'Content-Type': 'text/plain' }
});
const handleAuth: Handle = async ({ event, resolve }) => {
const path = event.url.pathname;
if (path.startsWith('/api/')) {
const ip = getIp(event) ?? 'unknown';
// Stricter limit for auth endpoints: 10 req/min per IP
if (event.url.pathname.startsWith('/api/auth/')) {
const { ok, retryAfter } = checkRateLimit(`auth:${ip}`, 10, 60_000);
if (!ok) {
return new Response('Too Many Requests', {
status: 429,
headers: { 'Retry-After': String(retryAfter ?? 60), 'Content-Type': 'text/plain' }
});
}
}
// General API limit: 600 req/min per IP
const { ok, retryAfter } = checkRateLimit(`api:${ip}`, 600, 60_000);
if (!ok) {
return new Response('Too Many Requests', {
status: 429,
headers: { 'Retry-After': String(retryAfter ?? 60), 'Content-Type': 'text/plain' }
});
if (path.startsWith('/api/auth/')) {
const auth = checkRateLimit(`auth:${ip}`, 10, 60_000);
if (!auth.ok) return tooMany(auth.retryAfter);
}
const api = checkRateLimit(`api:${ip}`, 600, 60_000);
if (!api.ok) return tooMany(api.retryAfter);
}
// Caddy terminates HTTPS, so the request Vite sees is http while ORIGIN is
// https. Better Auth's helper skips its handler when those differ, and
// SvelteKit would 404 the OAuth callback. Route auth paths directly.
if (!building && path.startsWith('/api/auth/')) return auth.handler(event.request);
const session = await auth.api.getSession({ headers: event.request.headers });
if (session) {
event.locals.session = session.session;
const dbUser = await db.query.user.findFirst({
where: eq(userTable.id, session.user.id)
});
let role = dbUser?.role ?? 'player';
if (dbUser) {
role = await ensureRootAdminRole({
id: dbUser.id,
email: dbUser.email,
role
});
const dbUser = await db.query.user.findFirst({ where: eq(userTable.id, session.user.id) });
if (!dbUser || dbUser.bannedAt || dbUser.mergedIntoUserId) {
return new Response('Account unavailable', { status: 403 });
}
event.locals.session = session.session;
event.locals.user = {
...session.user,
role
email: dbUser.email ?? null,
image: dbUser.image,
emailVerified: dbUser.emailVerified,
rootAdmin: dbUser.rootAdmin,
role: await ensureRootAdminRole({
id: dbUser.id,
rootAdmin: dbUser.rootAdmin,
role: dbUser.role
})
};
}
return svelteKitHandler({ event, resolve, auth, building });
};
export const handle: Handle = sequence(
Sentry.sentryHandle(),
handleRequestLogging,
handleBetterAuth
);
export const handle: Handle = sequence(Sentry.sentryHandle(), handleRequestLogging, handleAuth);
// ── Global server error handler ──
export const handleError: HandleServerError = Sentry.handleErrorWithSentry(({ error, event }) => {
export const handleError: HandleServerError = Sentry.handleErrorWithSentry((input) => {
// Expected errors (error(...), 404s, validation) keep their own status and message.
if (input.kind !== 'unknown') return;
const { error, event } = input;
const err = error instanceof Error ? error : new Error(String(error));
log.http.error(
{
@@ -173,7 +147,7 @@ export const handleError: HandleServerError = Sentry.handleErrorWithSentry(({ er
path: event.url.pathname,
method: event.request.method
},
'Unhandled server error'
'unhandled server error'
);
return { message: 'An unexpected error occurred.' };
});

View File

@@ -0,0 +1,11 @@
import { expect, test } from 'vitest';
import { contactEmail, newAuthIdentity } from './auth-identity';
test('internal identities are never contact email addresses', () => {
expect(contactEmail('123@osu.local')).toBeNull();
expect(contactEmail('123@discord.local')).toBeNull();
expect(contactEmail(newAuthIdentity())).toBeNull();
expect(contactEmail(null)).toBeNull();
expect(contactEmail(' Person@Example.com ')).toBe('person@example.com');
expect(newAuthIdentity()).not.toBe(newAuthIdentity());
});

21
src/lib/auth-identity.ts Normal file
View File

@@ -0,0 +1,21 @@
/** Better Auth requires an email internally; this is never a contact address. */
export const AUTH_EMAIL_DOMAIN = 'identity.invalid';
export function contactEmail(value: string | null | undefined): string | null {
const email = value?.trim().toLowerCase();
if (!email || /@(?:osu\.local|discord\.local|identity\.invalid)$/.test(email)) return null;
return email;
}
export function newAuthIdentity(): string {
return `${crypto.randomUUID()}@${AUTH_EMAIL_DOMAIN}`;
}
export interface ConnectionProfile {
username: string;
image: string | null;
email: string | null;
emailVerified: boolean;
countryCode?: string | null;
globalRank?: number | null;
}

View File

@@ -0,0 +1,29 @@
import { describe, expect, test } from 'vitest';
import { hoursToRanges, rangesToHours, isTimezone } from './availability';
describe('availability', () => {
test('round trips split windows and midnight', () => {
const hours = [0, 1, 9, 10, 18, 19, 20, 22, 23];
expect(rangesToHours(hoursToRanges(hours))).toEqual(hours);
});
test('supports an overnight range and deduplicates overlaps', () => {
expect(
rangesToHours([
{ start: '22:00', end: '02:00' },
{ start: '23:00', end: '01:00' }
])
).toEqual([0, 1, 22, 23]);
});
test('no ranges means no preference', () => {
expect(rangesToHours([])).toEqual([]);
});
test('rejects malformed times and ambiguous full-day ranges', () => {
for (const start of ['18:30', '24:00', '-1:00', 'abc', '18:00'])
expect(() => rangesToHours([{ start, end: '18:00' }])).toThrow();
});
test('validates IANA time zones', () => {
expect(isTimezone('Europe/Amsterdam')).toBe(true);
expect(isTimezone('UTC')).toBe(true);
expect(isTimezone('Mars/Olympus')).toBe(false);
});
});

43
src/lib/availability.ts Normal file
View File

@@ -0,0 +1,43 @@
export interface TimeRange {
start: string;
end: string;
}
const hourLabel = (hour: number) => `${String(hour % 24).padStart(2, '0')}:00`;
export function hoursToRanges(hours: number[]): TimeRange[] {
const sorted = [...new Set(hours)].sort((a, b) => a - b);
if (sorted.length === 24) return [];
const ranges: TimeRange[] = [];
for (const hour of sorted) {
const previous = ranges.at(-1);
if (previous?.end === hourLabel(hour)) previous.end = hourLabel(hour + 1);
else ranges.push({ start: hourLabel(hour), end: hourLabel(hour + 1) });
}
return ranges;
}
export function rangesToHours(ranges: TimeRange[]): number[] {
const hours = new Set<number>();
for (const range of ranges) {
if (!/^([01]\d|2[0-3]):00$/.test(range.start) || !/^([01]\d|2[0-3]):00$/.test(range.end)) {
throw new Error('Choose times on the hour, such as 18:00');
}
const start = Number(range.start.slice(0, 2));
const end = Number(range.end.slice(0, 2));
if (start === end)
throw new Error(
'Start and end times must be different. Remove all ranges for no preference.'
);
for (let hour = start; hour !== end; hour = (hour + 1) % 24) hours.add(hour);
}
return [...hours].sort((a, b) => a - b);
}
export function isTimezone(value: string): boolean {
try {
new Intl.DateTimeFormat('en', { timeZone: value }).format();
return !!value;
} catch {
return false;
}
}

84
src/lib/bbcode.test.ts Normal file
View File

@@ -0,0 +1,84 @@
import { describe, expect, test } from 'vitest';
import { renderBBCode } from './bbcode';
describe('renderBBCode', () => {
test('formatting, sizes, colors, and centre', () => {
const html = renderBBCode(
'[centre][size=150][b]CES2[/b][/size] [color=#ff66aa]pink[/color] [i]x[/i][/centre]'
);
expect(html).toBe(
'<div class="bb-centre"><span class="bb-size-150"><strong>CES2</strong></span> <span style="color:#ff66aa">pink</span> <em>x</em></div>'
);
});
test('links, profiles, and images keep to http(s)', () => {
expect(renderBBCode('[url=https://discord.gg/x]Discord[/url]')).toContain(
'href="https://discord.gg/x"'
);
expect(renderBBCode('[url]https://osu.ppy.sh[/url]')).toContain('>https://osu.ppy.sh</a>');
expect(renderBBCode('[profile=2]peppy[/profile]')).toContain(
'href="https://osu.ppy.sh/users/2"'
);
expect(renderBBCode('[img]https://i.imgur.com/a.png[/img]')).toContain(
'<img class="bb-img" src="https://i.imgur.com/a.png"'
);
});
test('script and javascript: never survive', () => {
const html = renderBBCode(
'<script>alert(1)</script> [url=javascript:alert(1)]x[/url] [img]javascript:alert(1)[/img] [color=red;background:url(x)]c[/color] [img]https://a.png" onerror="alert(1)[/img]'
);
expect(html).not.toContain('<script');
expect(html).not.toContain('javascript:');
expect(html).not.toContain('background');
expect(html).not.toMatch(/onerror="/);
expect(html).toContain('&lt;script&gt;');
});
test('lists group [*] items, ordered lists use list=1', () => {
expect(renderBBCode('[list][*]one [b]two[/b]\n[*]three[/list]')).toBe(
'<ul class="bb-list"><li>one <strong>two</strong></li><li>three</li></ul>'
);
expect(renderBBCode('[list=1][*]a[*]b[/list]')).toContain('<ol class="bb-list">');
});
test('boxes, notices, headings, and quotes', () => {
expect(renderBBCode('[box=Rules]\nNo cheating\n[/box]')).toBe(
'<details class="bb-box"><summary>Rules</summary><div class="bb-box-body">No cheating</div></details>'
);
expect(renderBBCode('[notice]Heads up[/notice]')).toBe('<div class="bb-notice">Heads up</div>');
expect(renderBBCode('[heading]Schedule[/heading]')).toBe(
'<h3 class="bb-heading">Schedule</h3>'
);
expect(renderBBCode('[quote="Stan"]gl hf[/quote]')).toContain('<cite>Stan wrote:</cite>');
});
test('imagemap areas become positioned links', () => {
const html = renderBBCode(
'[imagemap]\nhttps://i.imgur.com/banner.png\n0 0 50 100 https://discord.gg/x Discord\n50 0 50 100 # Nothing\n[/imagemap]'
);
expect(html).toContain('<div class="bb-imagemap"><img src="https://i.imgur.com/banner.png"');
expect(html).toContain(
'style="left:0%;top:0%;width:50%;height:100%" href="https://discord.gg/x" title="Discord"'
);
expect(html).toContain(
'<span class="bb-area" style="left:50%;top:0%;width:50%;height:100%" title="Nothing">'
);
});
test('youtube uses the no-cookie embed and rejects odd ids', () => {
expect(renderBBCode('[youtube]dQw4w9WgXcQ[/youtube]')).toContain(
'youtube-nocookie.com/embed/dQw4w9WgXcQ'
);
expect(renderBBCode('[youtube]"><script>[/youtube]')).toBe('');
});
test('code keeps its text verbatim, unknown tags stay readable, newlines become breaks', () => {
expect(renderBBCode('[code][b]not bold[/b][/code]')).toBe(
'<pre class="bb-code"><code>[b]not bold[/b]</code></pre>'
);
expect(renderBBCode('[glow]hi[/glow]')).toBe('[glow]hi[/glow]');
expect(renderBBCode('a\nb')).toBe('a<br>b');
expect(renderBBCode('')).toBe('');
});
});

257
src/lib/bbcode.ts Normal file
View File

@@ -0,0 +1,257 @@
/**
* osu! forum BBCode to safe HTML.
*
* Tournament hosts paste the forum post they already wrote. @bbob/parser
* builds the tree; this renderer knows osu!'s tags ([centre], [box],
* [notice], [imagemap], [profile], [spoiler], …) and emits only a fixed set
* of elements. All text is escaped, links must be http(s), colors and sizes
* are validated, and the only embed is youtube-nocookie. Anything unknown is
* shown as the literal text the host typed.
*/
import { parse } from '@bbob/parser';
type Node =
| string
| {
tag: string;
attrs: Record<string, string>;
content: Node[] | null;
start?: { to: number };
end?: { from: number };
};
/** The source being rendered, for tags whose content must stay verbatim. */
let source = '';
const BLOCK = new Set([
'centre',
'center',
'box',
'spoilerbox',
'notice',
'list',
'heading',
'quote',
'code',
'imagemap',
'youtube',
'left',
'right'
]);
export function renderBBCode(input: string | null | undefined): string {
if (!input?.trim()) return '';
source = input.replace(/\r\n?/g, '\n');
return renderNodes(parse(source) as Node[]);
}
function renderNodes(nodes: Node[] | null): string {
if (!nodes) return '';
let html = '';
for (let index = 0; index < nodes.length; index++) {
const node = nodes[index];
if (typeof node === 'string') {
// A newline right after a block tag is formatting, not content.
const previous = nodes[index - 1];
if (
node === '\n' &&
previous &&
typeof previous !== 'string' &&
BLOCK.has(previous.tag.toLowerCase())
)
continue;
html += escape(node).replace(/\n/g, '<br>');
} else {
html += renderTag(node);
}
}
return html;
}
function renderTag(node: Exclude<Node, string>): string {
const tag = node.tag.toLowerCase();
const value = attr(node);
const inner = () => renderNodes(trimNewlines(node.content));
switch (tag) {
case 'b':
return `<strong>${inner()}</strong>`;
case 'i':
return `<em>${inner()}</em>`;
case 'u':
return `<u>${inner()}</u>`;
case 's':
case 'strike':
return `<s>${inner()}</s>`;
case 'color': {
const color = safeColor(value);
return color ? `<span style="color:${color}">${inner()}</span>` : inner();
}
case 'size':
return `<span class="bb-size-${sizeClass(value)}">${inner()}</span>`;
case 'centre':
case 'center':
return `<div class="bb-centre">${inner()}</div>`;
case 'left':
case 'right':
return `<div style="text-align:${tag}">${inner()}</div>`;
case 'heading':
return `<h3 class="bb-heading">${inner()}</h3>`;
case 'notice':
return `<div class="bb-notice">${inner()}</div>`;
case 'spoiler':
return `<span class="bb-spoiler">${inner()}</span>`;
case 'box':
case 'spoilerbox':
return `<details class="bb-box"><summary>${escape(value || (tag === 'spoilerbox' ? 'Spoiler' : 'Show'))}</summary><div class="bb-box-body">${inner()}</div></details>`;
case 'quote':
return `<blockquote class="bb-quote">${value ? `<cite>${escape(value)} wrote:</cite>` : ''}${inner()}</blockquote>`;
case 'code':
return `<pre class="bb-code"><code>${escape(verbatim(node).replace(/^\n|\n$/g, ''))}</code></pre>`;
case 'c':
return `<code class="bb-inline-code">${escape(verbatim(node))}</code>`;
case 'url': {
const href = safeUrl(value || text(node.content));
return href
? `<a href="${escape(href)}" target="_blank" rel="nofollow noopener noreferrer">${inner()}</a>`
: inner();
}
case 'email': {
const address = (value || text(node.content)).trim();
return /^[^\s@<>"]+@[^\s@<>"]+\.[a-z]{2,}$/i.test(address)
? `<a href="mailto:${escape(address)}">${inner()}</a>`
: inner();
}
case 'profile': {
const name = text(node.content).trim();
const id = (value || name).trim();
return `<a class="bb-profile" href="https://osu.ppy.sh/users/${encodeURIComponent(id)}" target="_blank" rel="noopener">${escape(name || id)}</a>`;
}
case 'img': {
const src = safeUrl(text(node.content).trim());
return src
? `<img class="bb-img" src="${escape(src)}" alt="" loading="lazy" referrerpolicy="no-referrer">`
: '';
}
case 'imagemap':
return renderImagemap(text(node.content));
case 'youtube': {
const id = text(node.content).trim();
return /^[A-Za-z0-9_-]{11}$/.test(id)
? `<div class="bb-video"><iframe src="https://www.youtube-nocookie.com/embed/${id}" title="YouTube video" loading="lazy" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe></div>`
: '';
}
case 'audio': {
const src = safeUrl(text(node.content).trim());
return src
? `<audio class="bb-audio" controls preload="none" src="${escape(src)}"></audio>`
: '';
}
case 'list':
return renderList(node, value);
case '*':
return `<li>${inner()}</li>`;
default:
// Not an osu! tag: show what the host typed.
return `${escape(`[${node.tag}${value ? `=${value}` : ''}]`)}${inner()}${node.content ? escape(`[/${node.tag}]`) : ''}`;
}
}
/** [list] children arrive flat, with empty [*] markers between the items. */
function renderList(node: Exclude<Node, string>, value: string): string {
const items: Node[][] = [];
for (const child of node.content ?? []) {
if (typeof child !== 'string' && child.tag === '*') {
items.push([...(child.content ?? [])]);
} else if (items.length) {
items[items.length - 1].push(child);
} else if (typeof child !== 'string' || child.trim()) {
items.push([child]);
}
}
const kind = value === '1' || value === 'a' ? 'ol' : 'ul';
return `<${kind} class="bb-list">${items.map((item) => `<li>${renderNodes(trimNewlines(item))}</li>`).join('')}</${kind}>`;
}
/** First line is the image; each further line is "x y width height link title", in percent. */
function renderImagemap(body: string): string {
const lines = body
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
const src = safeUrl(lines.shift() ?? '');
if (!src) return '';
const areas = lines
.map((line) => {
const [x, y, width, height, link, ...title] = line.split(/\s+/);
const box = [x, y, width, height].map(Number);
if (box.some((n) => !Number.isFinite(n) || n < 0 || n > 100)) return '';
const style = `left:${box[0]}%;top:${box[1]}%;width:${box[2]}%;height:${box[3]}%`;
const href = link && link !== '#' ? safeUrl(link) : null;
const label = escape(title.join(' '));
return href
? `<a class="bb-area" style="${style}" href="${escape(href)}" title="${label}" target="_blank" rel="nofollow noopener noreferrer"></a>`
: `<span class="bb-area" style="${style}" title="${label}"></span>`;
})
.join('');
return `<div class="bb-imagemap"><img src="${escape(src)}" alt="" loading="lazy" referrerpolicy="no-referrer">${areas}</div>`;
}
function attr(node: Exclude<Node, string>): string {
const keys = Object.keys(node.attrs ?? {});
if (keys.length === 0) return '';
// [tag=value] arrives as { value: value }; [quote="Name"] keeps its quotes out.
const value = node.attrs[keys[0]];
return String(value ?? keys[0]).replace(/^"(.*)"$/, '$1');
}
/** The exact text between a tag's opening and closing, tags included. */
function verbatim(node: Exclude<Node, string>): string {
if (node.start && node.end) return source.slice(node.start.to, node.end.from);
return text(node.content);
}
function text(nodes: Node[] | null): string {
return (nodes ?? [])
.map((node) => (typeof node === 'string' ? node : text(node.content)))
.join('');
}
function trimNewlines(nodes: Node[] | null): Node[] {
const list = [...(nodes ?? [])];
while (list[0] === '\n') list.shift();
while (list.at(-1) === '\n') list.pop();
return list;
}
function safeUrl(raw: string): string | null {
const value = raw.trim();
try {
const url = new URL(value.startsWith('//') ? `https:${value}` : value);
return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : null;
} catch {
return null;
}
}
function safeColor(raw: string): string | null {
const value = raw.trim();
if (/^#[0-9a-f]{3,8}$/i.test(value)) return value;
if (/^[a-z]{3,20}$/i.test(value)) return value.toLowerCase();
return null;
}
function sizeClass(raw: string): number {
const size = Number(raw);
if (!Number.isFinite(size)) return 100;
if (size <= 60) return 50;
if (size <= 92) return 85;
if (size <= 120) return 100;
return 150;
}
function escape(value: string): string {
return value.replace(
/[&<>"']/g,
(char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]!
);
}

View File

@@ -0,0 +1,29 @@
/**
* What an overlay receives for its channel: the layout, the scene to show,
* and everything its components draw from. One payload, pushed on every change.
*/
import type { LiveMatch } from '#lib/live.js';
import type { MatchView } from '#lib/match-view.js';
import type { Crowd } from '#lib/predictions.js';
import type { Layout, SceneId } from './layout';
export type ChannelMode = 'auto' | 'match' | 'tournament';
export interface UpcomingMatch {
id: string;
scheduledAt: string;
round: string | null;
tournament: string | null;
teams: [{ name: string; avatarUrl: string | null }, { name: string; avatarUrl: string | null }];
}
export interface ChannelPayload {
channel: { id: string; name: string; mode: ChannelMode; layout: Layout; accent: string };
scene: SceneId;
view: MatchView | null;
prediction: { crowd: Crowd; odds: Record<string, number> | null } | null;
/** Every live match, for the ticker. */
live: LiveMatch[];
upcoming: UpcomingMatch[];
serverNow: number;
}

247
src/lib/broadcast/demo.ts Normal file
View File

@@ -0,0 +1,247 @@
/**
* A made-up match in progress, so the layout editor can show every component
* even when nothing is live.
*/
import type { MatchView, PlayerView, SideView, SlotView } from '#lib/match-view.js';
import type { ChannelPayload } from './channel';
import type { Layout, SceneId } from './layout';
const MAPS: [string, string, string, string, number, number, number][] = [
['NM1', 'NM', 'Camellia', 'Exit This Earth’s Atomosphere', 6.12, 200, 188],
['NM2', 'NM', 'xi', 'Blue Zenith', 6.43, 200, 173],
['NM3', 'NM', 'Nekomata Master', 'Far East Nightbird', 5.87, 186, 153],
['HD1', 'HD', 'DragonForce', 'Through the Fire and Flames', 6.02, 200, 254],
['HD2', 'HD', 'Kurokotei', 'Galaxy Collapse', 6.66, 272, 190],
['HR1', 'HR', 'Imperial Circus Dead Decadence', 'Uta', 6.31, 220, 226],
['HR2', 'HR', 'Team Grimoire', 'C18H27NO3', 5.95, 215, 162],
['DT1', 'DT', 'Shoji Meguro', 'When Mother Was There', 6.55, 240, 131],
['DT2', 'DT', 'Yooh', 'Ice Angel', 6.21, 230, 118],
['TB', 'TB', 'Kobaryo', 'Speed Complex', 7.05, 222, 241]
];
function player(
id: string,
name: string,
country: string,
rating: number,
score: number | null
): PlayerView {
return {
id,
userId: id,
name,
osuName: name,
osuId: null,
avatarUrl: null,
countryCode: country,
rating,
inLobby: true,
live:
score == null
? null
: { score, accuracy: 97.8 - score / 1e6, combo: Math.round(score / 900), progress: 0.62 }
};
}
export function demoView(scene: SceneId, now = Date.now()): MatchView {
const slots: SlotView[] = MAPS.map(
([label, category, artist, title, stars, bpm, length], index) => ({
id: `slot-${index}`,
label,
category,
tiebreaker: category === 'TB',
mods: category === 'NM' || category === 'TB' ? [] : [category],
freemod: category === 'TB',
beatmapId: String(100_000 + index),
title,
artist,
version: 'Tournament',
starRating: stars,
bpm,
lengthSec: length,
coverUrl: null,
status:
index === 2
? 'banned'
: index === 6
? 'banned'
: index === 0 || index === 3 || index === 5
? 'played'
: index === 7
? 'live'
: 'open',
bannedBy: index === 2 ? 'red' : index === 6 ? 'blue' : null,
pickedBy: index === 0 || index === 5 || index === 7 ? 'red' : index === 3 ? 'blue' : null,
winner: index === 0 || index === 5 ? 'red' : index === 3 ? 'blue' : null
})
);
const playing = scene === 'gameplay';
const sides: [SideView, SideView] = [
{
id: 'red',
teamId: 'team-red',
name: 'Crimson Tide',
color: 'red',
isPersonal: false,
avatarUrl: null,
rating: 2140,
points: scene === 'results' ? 4 : 2,
roll: 71,
serverRoll: false,
joined: true,
players: [
player('r1', 'Mrekk_fan', 'AU', 2210, playing ? 612_340 : null),
player('r2', 'Kimi', 'KR', 2075, playing ? 548_900 : null)
]
},
{
id: 'blue',
teamId: 'team-blue',
name: 'Deep Blue',
color: 'blue',
isPersonal: false,
avatarUrl: null,
rating: 2088,
points: scene === 'results' ? 2 : 1,
roll: 42,
serverRoll: false,
joined: true,
players: [
player('b1', 'Aqua', 'NL', 2130, playing ? 590_115 : null),
player('b2', 'Mizu', 'JP', 2040, playing ? 501_420 : null)
]
}
];
const phase =
scene === 'lobby'
? 'rolling'
: scene === 'pickban'
? 'picking'
: scene === 'gameplay'
? 'playing'
: scene === 'results'
? 'finished'
: 'waiting';
return {
id: 'demo',
name: 'Demo match',
kind: 'tournament',
createdAt: new Date(now - 40 * 60_000).toISOString(),
scheduledAt: null,
startedAt: new Date(now - 35 * 60_000).toISOString(),
finishedAt: scene === 'results' ? new Date(now).toISOString() : null,
rules: {
bestOf: 7,
winsNeeded: 4,
teamSize: 2,
winCondition: 'score_v2',
bansPerTeam: 1,
tiebreaker: { enabled: true, selection: 'ban_then_resolve' },
actionTimeoutMs: 120_000,
joinWindowMs: 300_000,
freemodMultipliers: {}
},
phase,
state: phase.toUpperCase(),
paused: null,
deadlineAt: scene === 'pickban' ? now + 74_000 : null,
turn: scene === 'pickban' ? 'blue' : null,
prompt: {
kind: scene === 'pickban' ? 'pick' : scene === 'lobby' ? 'roll' : null,
side: scene === 'pickban' ? 'blue' : null,
deadlineAt: null,
slotIds: []
},
rollFor: 'order',
pickFirst: 'red',
banFirst: 'blue',
highRoll: 'red',
sides,
slots,
live: playing
? {
slotId: 'slot-7',
pickedBy: 'red',
startedAt: now - 70_000,
startRequestedAt: now - 75_000,
attempt: 1,
tiebreaker: false
}
: null,
maps: [0, 3, 5].map((slotIndex, index) => ({
index,
slotId: `slot-${slotIndex}`,
pickedBy: slotIndex === 3 ? 'blue' : 'red',
winner: slotIndex === 3 ? 'blue' : 'red',
totals: { red: 1_120_000 - index * 90_000, blue: 980_000 + index * 120_000 },
tiebreaker: false,
scores: []
})),
winner: scene === 'results' ? 'red' : null,
endReason: null,
log: [],
chat: [
{ t: now - 50_000, from: 'Kimi', text: 'glhf!', role: 'player' },
{ t: now - 40_000, from: 'Aqua', text: 'gl hf', role: 'player' },
{ t: now - 30_000, from: 'Vash', text: 'Crimson Tide picked DT1.', role: 'host' },
{ t: now - 20_000, from: 'Mizu', text: 'this map is so fun', role: 'player' }
],
pool: { id: 'demo-pool', name: 'Demo pool', owner: null },
lobby: { id: 123456, provider: 'irc', connected: true },
tournament: {
id: 'demo-tournament',
name: 'Vash Summer Cup',
round: 'Grand Final',
accentColor: null
},
sim: null,
serverNow: now
};
}
export function demoPayload(
channel: ChannelPayload['channel'],
scene: SceneId,
now = Date.now()
): ChannelPayload {
const view = scene === 'idle' ? null : demoView(scene, now);
return {
channel,
scene,
view,
prediction: view
? {
crowd: { counts: { 'team-red': 38, 'team-blue': 25 }, total: 63 },
odds: { 'team-red': 0.58, 'team-blue': 0.42 }
}
: null,
live: [],
upcoming: [
{
id: 'u1',
scheduledAt: new Date(now + 25 * 60_000).toISOString(),
round: 'Semifinals',
tournament: 'Vash Summer Cup',
teams: [
{ name: 'Night Owls', avatarUrl: null },
{ name: 'Pink Panthers', avatarUrl: null }
]
},
{
id: 'u2',
scheduledAt: new Date(now + 85 * 60_000).toISOString(),
round: 'Semifinals',
tournament: 'Vash Summer Cup',
teams: [
{ name: 'Crimson Tide', avatarUrl: null },
{ name: 'Deep Blue', avatarUrl: null }
]
}
],
serverNow: now
};
}
export function demoChannel(layout: Layout, accent = '#ff66aa'): ChannelPayload['channel'] {
return { id: 'demo', name: 'Demo', mode: 'auto', layout, accent };
}

View File

@@ -0,0 +1,124 @@
import { describe, expect, test } from 'vitest';
import type { LiveMatch } from '#lib/live.js';
import { interest, pickMatch, RESULT_LINGER_MS } from './director';
function live(
id: string,
overrides: Partial<LiveMatch> = {},
points: [number, number] = [0, 0]
): LiveMatch {
return {
id,
name: id,
kind: 'quick',
phase: 'picking',
paused: false,
bestOf: 7,
winsNeeded: 4,
teamSize: 1,
tournament: null,
sides: [
{
id: `${id}-a`,
teamId: `${id}-ta`,
name: 'A',
color: 'red',
avatarUrl: null,
points: points[0],
liveScore: null
},
{
id: `${id}-b`,
teamId: `${id}-tb`,
name: 'B',
color: 'blue',
avatarUrl: null,
points: points[1],
liveScore: null
}
],
map: null,
winner: null,
viewers: 0,
simulated: false,
serverNow: 0,
...overrides
};
}
const base = {
mode: 'auto' as const,
matchId: null,
tournamentId: null,
current: null,
decidedSince: null,
now: 1_000_000
};
describe('director', () => {
test('a fixed channel always shows its match', () => {
expect(pickMatch({ ...base, mode: 'match', matchId: 'm1', live: [live('m2')] })).toBe('m1');
});
test('idle when nothing is live, and tournament channels only see their tournament', () => {
expect(pickMatch({ ...base, live: [] })).toBeNull();
const cup = { id: 'cup', name: 'Cup', round: 'Final', accentColor: null };
expect(
pickMatch({
...base,
mode: 'tournament',
tournamentId: 'cup',
live: [live('quick', { viewers: 50 }), live('final', { tournament: cup })]
})
).toBe('final');
expect(
pickMatch({
...base,
mode: 'tournament',
tournamentId: 'other',
live: [live('final', { tournament: cup })]
})
).toBeNull();
});
test('prefers tournament matches, then close series near match point', () => {
const cup = { id: 'cup', name: 'Cup', round: null, accentColor: null };
expect(
pickMatch({
...base,
live: [live('quick', { viewers: 3 }), live('cup', { tournament: cup })]
})
).toBe('cup');
expect(
pickMatch({ ...base, live: [live('blowout', {}, [3, 0]), live('close', {}, [3, 3])] })
).toBe('close');
expect(interest(live('tb', { phase: 'tb' }, [3, 3]))).toBeGreaterThan(
interest(live('x', {}, [3, 3]))
);
});
test('never cuts away during a map', () => {
const cup = { id: 'cup', name: 'Cup', round: null, accentColor: null };
const input = {
...base,
current: 'quick',
live: [live('quick', { phase: 'playing' }), live('cup', { tournament: cup }, [3, 3])]
};
expect(pickMatch(input)).toBe('quick');
// Between maps it may cut to something much better.
expect(
pickMatch({ ...input, live: [live('quick'), live('cup', { tournament: cup }, [3, 3])] })
).toBe('cup');
});
test('lingers on a result, then moves on', () => {
const input = {
...base,
current: 'done',
decidedSince: base.now - 5_000,
live: [live('done', { phase: 'closing' }), live('next')]
};
expect(pickMatch(input)).toBe('done');
expect(pickMatch({ ...input, decidedSince: base.now - RESULT_LINGER_MS - 1 })).toBe('next');
});
});

View File

@@ -0,0 +1,68 @@
/**
* The director: which match a channel shows. Pure, so it is easy to test.
*
* It never cuts away from a map in progress, lingers on a result long enough
* for it to be seen, and otherwise goes where it is most exciting: tournament
* matches first, then close series near match point, tiebreakers, and crowds.
*/
import { DECIDED, type LiveMatch } from '#lib/live.js';
import type { ChannelMode } from './channel';
export const RESULT_LINGER_MS = 30_000;
export interface DirectorInput {
mode: ChannelMode;
matchId: string | null;
tournamentId: string | null;
current: string | null;
/** When the current match was first seen decided. */
decidedSince: number | null;
live: LiveMatch[];
now: number;
}
export function pickMatch(input: DirectorInput): string | null {
if (input.mode === 'match') return input.matchId;
const candidates = input.live.filter(
(match) =>
input.mode === 'auto' ||
(input.tournamentId != null && match.tournament?.id === input.tournamentId)
);
const current = candidates.find((match) => match.id === input.current);
if (current) {
const decided = DECIDED.includes(current.phase);
if (!decided) {
// Stay through a map; between maps, only leave for a much better match.
const best = best_(candidates);
if (current.phase === 'playing' || !best || best.id === current.id) return current.id;
return interest(best) > interest(current) + 60 ? best.id : current.id;
}
if (input.decidedSince != null && input.now - input.decidedSince < RESULT_LINGER_MS)
return current.id;
}
return best_(candidates)?.id ?? null;
}
function best_(candidates: LiveMatch[]): LiveMatch | null {
return (
candidates
.filter((match) => !DECIDED.includes(match.phase))
.sort((a, b) => interest(b) - interest(a) || a.id.localeCompare(b.id))[0] ?? null
);
}
/** How much a match is worth watching right now. */
export function interest(match: LiveMatch): number {
const [a, b] = match.sides;
const needed = Math.max(1, match.winsNeeded);
const closeness = 1 - Math.abs(a.points - b.points) / needed;
const progress = (a.points + b.points) / (2 * needed - 1);
return (
(match.tournament ? 100 : 0) +
(match.simulated ? 0 : 40) +
Math.min(80, match.viewers * 4) +
closeness * progress * 60 +
(match.phase === 'tb' || match.map?.category === 'TB' ? 50 : 0) +
(match.phase === 'playing' ? 15 : 0)
);
}

471
src/lib/broadcast/layout.ts Normal file
View File

@@ -0,0 +1,471 @@
/**
* Stream layouts: which predefined components sit where, per scene.
*
* A layout is plain JSON, stored on the broadcast channel and edited in the
* layout editor. Coordinates are pixels on a 1920×1080 stage; the overlay
* scales the stage to whatever size the browser source is.
*/
import type { MatchView, Phase } from '#lib/match-view.js';
export const STAGE = { width: 1920, height: 1080 } as const;
export type SceneId = 'idle' | 'lobby' | 'pickban' | 'gameplay' | 'results';
export const SCENES: { id: SceneId; label: string; description: string }[] = [
{
id: 'idle',
label: 'Starting soon',
description: 'No match to show: schedule, other live matches.'
},
{ id: 'lobby', label: 'Lobby', description: 'Players join and roll.' },
{ id: 'pickban', label: 'Pick & ban', description: 'Choosing, banning, picking.' },
{ id: 'gameplay', label: 'Gameplay', description: 'A map is being played.' },
{ id: 'results', label: 'Results', description: 'The match is decided.' }
];
const SCENE_BY_PHASE: Record<Phase, SceneId> = {
created: 'lobby',
waiting: 'lobby',
rolling: 'lobby',
choosing: 'pickban',
banning: 'pickban',
picking: 'pickban',
tb: 'pickban',
playing: 'gameplay',
closing: 'results',
finished: 'results',
cancelled: 'idle'
};
export function stageOf(layout: Pick<Layout, 'stage'>): { width: number; height: number } {
return layout.stage ?? STAGE;
}
export function sceneFor(view: MatchView | null): SceneId {
return view ? SCENE_BY_PHASE[view.phase] : 'idle';
}
export type ComponentType =
| 'scoreboard'
| 'livescore'
| 'nowplaying'
| 'mappool'
| 'turn'
| 'history'
| 'chat'
| 'predictions'
| 'players'
| 'winner'
| 'gameplay'
| 'ticker'
| 'schedule'
| 'text'
| 'image'
| 'clock';
export interface PlacedComponent {
id: string;
type: ComponentType;
x: number;
y: number;
w: number;
h: number;
props: Record<string, string | number | boolean>;
}
export interface SceneLayout {
/** What is behind the components: nothing (OBS shows what is under the browser source), the theme background, or an image. */
background: 'transparent' | 'solid' | 'image';
image?: string;
components: PlacedComponent[];
}
export interface Layout {
version: 1;
/** Stage size; 1920×1080 unless set (portrait channels use 1080×1920). */
stage?: { width: number; height: number };
theme: {
/** Accent color; empty uses the tournament's accent. */
accent: string;
background: string;
/** Corner radius of panels, px. */
radius: number;
};
scenes: Record<SceneId, SceneLayout>;
}
export interface PropSpec {
key: string;
label: string;
kind: 'text' | 'number' | 'boolean' | 'select' | 'url';
options?: string[];
default: string | number | boolean;
}
export const COMPONENTS: Record<
ComponentType,
{ label: string; description: string; size: [number, number]; props: PropSpec[] }
> = {
scoreboard: {
label: 'Scoreboard',
description: 'Team names, avatars and maps won.',
size: [1920, 130],
props: [
{ key: 'showRatings', label: 'Show ratings', kind: 'boolean', default: false },
{ key: 'showRound', label: 'Show round name', kind: 'boolean', default: true }
]
},
livescore: {
label: 'Live score',
description: 'Both sides’ scores on the current map, with the lead.',
size: [1920, 90],
props: [{ key: 'showPlayers', label: 'Show each player', kind: 'boolean', default: true }]
},
nowplaying: {
label: 'Now playing',
description: 'The map being played or just picked.',
size: [620, 150],
props: [
{ key: 'showStats', label: 'Show stars, BPM and length', kind: 'boolean', default: true }
]
},
mappool: {
label: 'Mappool',
description: 'Every map with its pick, ban and winner.',
size: [1800, 560],
props: [{ key: 'columns', label: 'Columns', kind: 'number', default: 5 }]
},
turn: {
label: 'Turn',
description: 'Who acts now, and the countdown.',
size: [900, 120],
props: []
},
history: {
label: 'Map history',
description: 'Maps played so far and who won them.',
size: [480, 520],
props: []
},
chat: {
label: 'Lobby chat',
description: 'The latest lines from the osu! lobby.',
size: [480, 420],
props: [
{ key: 'lines', label: 'Lines', kind: 'number', default: 10 },
{ key: 'showBancho', label: 'Show BanchoBot', kind: 'boolean', default: false }
]
},
predictions: {
label: 'Predictions',
description: 'How the crowd called it, and the rating odds.',
size: [620, 140],
props: [{ key: 'showOdds', label: 'Show rating odds', kind: 'boolean', default: true }]
},
players: {
label: 'Players',
description: 'Each side’s players with flags and ratings.',
size: [1600, 420],
props: []
},
winner: {
label: 'Winner',
description: 'The winning side, big.',
size: [1200, 420],
props: [{ key: 'headline', label: 'Headline', kind: 'text', default: 'WINNER' }]
},
gameplay: {
label: 'Gameplay window',
description: 'A see-through frame for the tourney client capture under the overlay.',
size: [1920, 860],
props: [
{ key: 'label', label: 'Label when empty', kind: 'text', default: '' },
{ key: 'border', label: 'Border', kind: 'boolean', default: false }
]
},
ticker: {
label: 'Live ticker',
description: 'Scores of every other live match, scrolling.',
size: [1920, 48],
props: [{ key: 'label', label: 'Label', kind: 'text', default: 'AROUND VASH' }]
},
schedule: {
label: 'Schedule',
description: 'Matches coming up.',
size: [720, 440],
props: [{ key: 'title', label: 'Title', kind: 'text', default: 'Up next' }]
},
text: {
label: 'Text',
description: 'Any text: a title, a sponsor line, a hashtag.',
size: [800, 120],
props: [
{ key: 'text', label: 'Text', kind: 'text', default: 'Vash Esports' },
{ key: 'size', label: 'Size (px)', kind: 'number', default: 64 },
{
key: 'align',
label: 'Align',
kind: 'select',
options: ['left', 'center', 'right'],
default: 'center'
},
{ key: 'accent', label: 'Accent color', kind: 'boolean', default: false }
]
},
image: {
label: 'Image',
description: 'A logo, banner or sponsor image.',
size: [300, 300],
props: [
{ key: 'src', label: 'Image URL', kind: 'url', default: '/logo.png' },
{
key: 'fit',
label: 'Fit',
kind: 'select',
options: ['contain', 'cover'],
default: 'contain'
}
]
},
clock: {
label: 'Clock',
description: 'The time, or a countdown to the next match.',
size: [360, 120],
props: [
{
key: 'mode',
label: 'Show',
kind: 'select',
options: ['countdown', 'time'],
default: 'countdown'
}
]
}
};
export function defaultProps(type: ComponentType): PlacedComponent['props'] {
return Object.fromEntries(COMPONENTS[type].props.map((prop) => [prop.key, prop.default]));
}
export function place(
type: ComponentType,
x: number,
y: number,
w?: number,
h?: number,
props: PlacedComponent['props'] = {}
): PlacedComponent {
const [dw, dh] = COMPONENTS[type].size;
return {
id: `${type}-${Math.random().toString(36).slice(2, 8)}`,
type,
x,
y,
w: w ?? dw,
h: h ?? dh,
props: { ...defaultProps(type), ...props }
};
}
// ── Presets ─────────────────────────────────────────────────────────────
const theme: Layout['theme'] = { accent: '', background: '#09090b', radius: 18 };
/** Full broadcast around a tourney client capture: the gameplay scene leaves a see-through window. */
function tourney(): Layout {
return {
version: 1,
theme,
scenes: {
idle: {
background: 'solid',
components: [
place('text', 160, 140, 1600, 140, { text: 'Starting soon', size: 110, align: 'left' }),
place('clock', 160, 300, 520, 140),
place('schedule', 160, 480, 820, 480),
place('image', 1360, 440, 400, 400),
place('ticker', 0, 1032)
]
},
lobby: {
background: 'solid',
components: [
place('scoreboard', 0, 0),
place('players', 160, 220, 1600, 520),
place('predictions', 650, 800),
place('ticker', 0, 1032)
]
},
pickban: {
background: 'solid',
components: [
place('scoreboard', 0, 0),
place('turn', 510, 150),
place('mappool', 60, 300, 1800, 560),
place('chat', 60, 880, 900, 140, { lines: 4 }),
place('predictions', 1240, 880)
]
},
gameplay: {
background: 'transparent',
components: [
place('scoreboard', 0, 0),
place('gameplay', 0, 130, 1920, 800),
place('livescore', 0, 930),
place('nowplaying', 0, 1020, 1920, 60)
]
},
results: {
background: 'solid',
components: [
place('winner', 360, 140),
place('history', 360, 600, 1200, 400),
place('ticker', 0, 1032)
]
}
}
};
}
/** No capture at all: everything from Vash's own data. What Vash TV streams. */
function vashTv(): Layout {
const layout = tourney();
layout.scenes.gameplay = {
background: 'solid',
components: [
place('scoreboard', 0, 0),
place('nowplaying', 160, 200, 1000, 220),
place('predictions', 1220, 200, 540, 220),
place('livescore', 0, 480, 1920, 200),
place('history', 160, 720, 800, 290),
place('chat', 1000, 720, 760, 290, { lines: 6 }),
place('ticker', 0, 1032)
]
};
return layout;
}
/** Just a slim scoreboard and live score over the capture; nothing else. */
function minimal(): Layout {
const empty = (): SceneLayout => ({ background: 'transparent', components: [] });
return {
version: 1,
theme,
scenes: {
idle: empty(),
lobby: { background: 'transparent', components: [place('scoreboard', 0, 0, 1920, 110)] },
pickban: {
background: 'transparent',
components: [place('scoreboard', 0, 0, 1920, 110), place('turn', 510, 950)]
},
gameplay: {
background: 'transparent',
components: [
place('scoreboard', 0, 0, 1920, 110),
place('livescore', 0, 1000, 1920, 80, { showPlayers: false })
]
},
results: { background: 'transparent', components: [place('winner', 360, 330)] }
}
};
}
/** Upright, for phones: TikTok, Instagram, YouTube Shorts. Everything stacked. */
function vertical(): Layout {
const W = 1080;
const scenes: Layout['scenes'] = {
idle: {
background: 'solid',
components: [
place('text', 80, 220, 920, 200, { text: 'Starting soon', size: 120, align: 'left' }),
place('clock', 80, 440, 920, 160),
place('schedule', 60, 660, 960, 700),
place('image', 340, 1420, 400, 400),
place('ticker', 0, 1872, W, 48)
]
},
lobby: {
background: 'solid',
components: [
place('scoreboard', 0, 0, W, 130),
place('players', 40, 200, 1000, 900),
place('predictions', 40, 1160, 1000, 180),
place('ticker', 0, 1872, W, 48)
]
},
pickban: {
background: 'solid',
components: [
place('scoreboard', 0, 0, W, 130),
place('turn', 40, 160, 1000, 120),
place('mappool', 40, 320, 1000, 1100, { columns: 3 }),
place('predictions', 40, 1450, 1000, 170),
place('chat', 40, 1640, 1000, 220, { lines: 5 })
]
},
gameplay: {
background: 'solid',
components: [
place('scoreboard', 0, 0, W, 130),
place('nowplaying', 40, 170, 1000, 220),
place('livescore', 0, 430, W, 240),
place('predictions', 40, 700, 1000, 180),
place('history', 40, 910, 1000, 520),
place('chat', 40, 1460, 1000, 380, { lines: 8 }),
place('ticker', 0, 1872, W, 48)
]
},
results: {
background: 'solid',
components: [
place('winner', 40, 280, 1000, 700),
place('history', 40, 1040, 1000, 760),
place('ticker', 0, 1872, W, 48)
]
}
};
return { version: 1, stage: { width: 1080, height: 1920 }, theme, scenes };
}
export const PRESETS: Record<
string,
{
label: string;
description: string;
when: string;
format: 'landscape' | 'portrait';
make: () => Layout;
}
> = {
tourney: {
label: 'Over osu! gameplay',
description:
'A full overlay with a see-through window in the middle for the players’ osu! screens.',
when: 'You stream from OBS with the osu! tourney client captured underneath.',
format: 'landscape',
make: tourney
},
vashtv: {
label: 'Vash only',
description:
'Everything comes from Vash: scoreboard, live scores, map pool, predictions, chat. No osu! needed.',
when: 'The automatic stream (Vash TV container), or any stream without an osu! capture.',
format: 'landscape',
make: vashTv
},
minimal: {
label: 'Scoreboard only',
description: 'A slim scoreboard and live score bar, nothing else.',
when: 'You already have your own OBS scenes and just want the score on top.',
format: 'landscape',
make: minimal
},
vertical: {
label: 'Phone (portrait)',
description:
'Everything stacked in a 9:16 frame: scoreboard, live score, pool, predictions, chat.',
when: 'TikTok, Instagram or YouTube Shorts. Makes a portrait channel.',
format: 'portrait',
make: vertical
}
};

View File

@@ -0,0 +1,106 @@
/** Where a channel can stream to, and what each platform needs. */
export interface Platform {
id: string;
label: string;
/** Default ingest server; empty means the platform gives you one per stream. */
ingest: string;
/** Where to find the stream key. */
keyHelp: string;
/** Shown 9:16 on phones: use a portrait channel. */
vertical: boolean;
/** How the platform keeps the recording. */
vods: string;
/** Brand color, for its badge. */
color: string;
}
export const PLATFORMS: Platform[] = [
{
id: 'twitch',
label: 'Twitch',
color: '#9146FF',
ingest: 'rtmp://ingest.global-contribute.live-video.net/app',
keyHelp: 'Twitch Creator Dashboard → Settings → Stream → Primary stream key.',
vertical: false,
vods: 'Turn on “Store past broadcasts” in Twitch → Settings → Stream. Twitch keeps them 7 days, or 60 for Partners, Turbo and Prime.'
},
{
id: 'youtube',
label: 'YouTube',
color: '#FF0033',
ingest: 'rtmps://a.rtmps.youtube.com/live2',
keyHelp:
'YouTube Studio → Create → Go live → Stream → Stream key. A persistent key works for every stream.',
vertical: false,
vods: 'YouTube keeps every stream as a video on the channel by itself.'
},
{
id: 'facebook',
label: 'Facebook',
color: '#1877F2',
ingest: 'rtmps://live-api-s.facebook.com:443/rtmp',
keyHelp: 'Facebook Live Producer → Streaming software → “Use a persistent stream key”.',
vertical: false,
vods: 'Facebook posts the stream as a video when it ends.'
},
{
id: 'x',
label: 'X (Twitter)',
color: '#e7e9ea',
ingest: '',
keyHelp: 'X Media Studio → Producer → Sources: copy the RTMP URL and the stream key.',
vertical: false,
vods: 'X keeps the broadcast as a post.'
},
{
id: 'instagram',
label: 'Instagram',
color: '#E1306C',
ingest: '',
keyHelp:
'instagram.com → Create → Live video: copy the stream URL and key. Instagram makes a new key for every broadcast.',
vertical: true,
vods: 'Share the live video to your profile when it ends.'
},
{
id: 'tiktok',
label: 'TikTok',
color: '#25F4EE',
ingest: '',
keyHelp:
'TikTok LIVE Center (or LIVE Studio) → Stream key: copy the server URL and key. Needs an account that can go LIVE.',
vertical: true,
vods: 'TikTok offers a replay for a while after the stream.'
},
{
id: 'custom',
label: 'Other RTMP server',
color: '#a1a1aa',
ingest: '',
keyHelp: 'Any RTMP or RTMPS server: its URL, and the stream key if it has one.',
vertical: false,
vods: 'Up to the server.'
}
];
export const platform = (id: string) =>
PLATFORMS.find((each) => each.id === id) ?? PLATFORMS[PLATFORMS.length - 1];
export const QUALITIES: Record<
string,
{ label: string; height: number; fps: number; bitrate: number }
> = {
'720p30': { label: '720p, 30 fps (3 Mbps)', height: 720, fps: 30, bitrate: 3000 },
'1080p30': { label: '1080p, 30 fps (4.5 Mbps)', height: 1080, fps: 30, bitrate: 4500 },
'1080p60': { label: '1080p, 60 fps (6 Mbps)', height: 1080, fps: 60, bitrate: 6000 }
};
/** Output size for a channel: 16:9, or 9:16 for portrait. */
export function outputSize(format: string, quality: string) {
const q = QUALITIES[quality] ?? QUALITIES['1080p30'];
const long = Math.round((q.height * 16) / 9);
const { height: short, ...rest } = q;
return format === 'portrait'
? { ...rest, width: short, height: long }
: { ...rest, width: long, height: short };
}

View File

@@ -0,0 +1,13 @@
import { expect, test } from 'vitest';
import { agentScoresSchema } from './scores';
test('accepts old agent frames and named clients with no profile id, but rejects malformed scores', () => {
const client = { osuId: 0, name: 'Ocean Player0', score: 50, accuracy: 99, combo: 2 };
const frame = { type: 'tosu', clients: [client], progress: 0.5 };
expect(agentScoresSchema.safeParse(frame).success).toBe(true);
expect(agentScoresSchema.safeParse({ ...frame, matchId: 'm1', beatmapId: 75 }).success).toBe(
true
);
for (const clients of [[null], [{ ...client, score: Infinity }], [{ ...client, accuracy: 500 }]])
expect(agentScoresSchema.safeParse({ ...frame, clients }).success).toBe(false);
});

View File

@@ -0,0 +1,20 @@
import * as z from 'zod';
/** The existing agent protocol, with optional context for newer workers. */
export const agentScoresSchema = z.object({
type: z.literal('tosu'),
matchId: z.string().optional(),
beatmapId: z.number().int().positive().nullable().optional(),
progress: z.number().min(0).max(1).nullable().optional(),
clients: z
.array(
z.object({
osuId: z.number().int().nonnegative(),
name: z.string().trim().max(80).optional(),
score: z.number().nonnegative(),
accuracy: z.number().min(0).max(100),
combo: z.number().nonnegative()
})
)
.max(16)
});

View File

@@ -0,0 +1,42 @@
import { describe, expect, test } from 'vitest';
import { parseWorkerStatus, workerTone } from './worker';
describe('parseWorkerStatus', () => {
test('keeps known states and trims what a worker sends', () => {
expect(
parseWorkerStatus(
{
state: 'spectating',
detail: ' room selected ',
onAir: true,
encoder: 'AMD AMF (hardware)',
instance: 'prod',
version: '0.1.0',
extra: 'ignored'
},
1000
)
).toEqual({
state: 'spectating',
detail: 'room selected',
onAir: true,
encoder: 'AMD AMF (hardware)',
instance: 'prod',
version: '0.1.0',
at: 1000
});
});
test('an unknown state shows as needing attention, and nothing unbounded gets through', () => {
const status = parseWorkerStatus(
{ state: 'toString', detail: 'x'.repeat(1000), onAir: 'yes' },
1
);
expect(status.state).toBe('error');
expect(status.detail).toHaveLength(300);
expect(status.onAir).toBe(false);
expect(parseWorkerStatus({ state: 'weird' }, 1).detail).toBe('Unknown state "weird"');
expect(workerTone('login-required')).toBe('bad');
expect(workerTone('spectating')).toBe('ok');
});
});

View File

@@ -0,0 +1,65 @@
/**
* What an unattended broadcast worker (streamer/windows) reports over
* /ws/agent, and how the site words it.
*/
export interface WorkerStatus {
state: WorkerState;
detail: string | null;
onAir: boolean;
encoder: string | null;
instance: string | null;
version: string | null;
at: number;
}
export const WORKER_STATES = {
starting: 'Starting',
blocked: 'Waiting for another instance',
'setup-required': 'Setup needed',
'login-required': 'Spectator sign-in needed',
'desktop-locked': 'Windows is locked',
idle: 'Ready, nothing to show',
'waiting-for-lobby': 'Waiting for the lobby',
selecting: 'Selecting the room',
spectating: 'Spectating',
reconnecting: 'Reconnecting',
stopping: 'Stopping',
error: 'Needs attention'
} as const;
export type WorkerState = keyof typeof WORKER_STATES;
/** Colour of a state's badge: fine, busy, or needs someone. */
export function workerTone(state: WorkerState): 'ok' | 'busy' | 'bad' {
if (state === 'spectating' || state === 'idle') return 'ok';
if (
state === 'error' ||
state === 'login-required' ||
state === 'desktop-locked' ||
state === 'setup-required'
)
return 'bad';
return 'busy';
}
const text = (value: unknown, max: number) =>
typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null;
/** Validates a status message; an unknown state reads as an error so it is never hidden. */
export function parseWorkerStatus(
message: Record<string, unknown>,
now = Date.now()
): WorkerStatus {
const raw = typeof message.state === 'string' ? message.state : '';
const known = Object.hasOwn(WORKER_STATES, raw);
return {
state: known ? (raw as WorkerState) : 'error',
detail: text(message.detail, 300) ?? (known ? null : `Unknown state "${raw.slice(0, 40)}"`),
onAir: message.onAir === true,
encoder: text(message.encoder, 80),
instance: text(message.instance, 40),
version: text(message.version, 40),
at: now
};
}

View File

@@ -0,0 +1,130 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import { Label } from '#lib/components/ui/label/index.js';
import { hoursToRanges, type TimeRange } from '#lib/availability.js';
import { Clock3, Plus, Trash2, LocateFixed } from '@lucide/svelte';
const {
timezone,
hours,
message,
error
}: { timezone: string; hours: number[]; message?: string; error?: string } = $props();
let zone = $state('');
let ranges = $state<TimeRange[]>([]);
let saving = $state(false);
$effect(() => {
zone = timezone;
ranges = hoursToRanges(hours);
});
const zones = Intl.supportedValuesOf('timeZone');
</script>
<section class="rounded-xl border border-border bg-surface-800 p-5 sm:p-6">
<div class="flex items-center gap-2">
<Clock3 class="size-5 text-accent" />
<h2 class="text-lg font-semibold">Preferred playing times</h2>
</div>
<p class="mt-1 text-sm text-text-secondary">
Choose your usual availability in your local time. Tournament scheduling can use these
preferences.
</p>
<form
method="post"
action="?/availability"
class="mt-5 space-y-5"
use:enhance={() => {
saving = true;
return async ({ update }) => {
await update({ reset: false });
saving = false;
};
}}
>
<div class="space-y-2">
<Label for="timezone">Time zone</Label>
<div class="flex flex-wrap gap-2">
<Input
id="timezone"
name="timezone"
list="timezones"
bind:value={zone}
required
placeholder="Europe/Amsterdam"
class="min-w-48 flex-1"
/>
<datalist id="timezones"
><option value="UTC"></option>{#each zones as value}<option {value}
></option>{/each}</datalist
>
<Button
type="button"
variant="outline"
onclick={() => {
zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
}}><LocateFixed class="size-4" />Use my time zone</Button
>
</div>
</div>
<fieldset class="space-y-3">
<legend class="mb-2 text-sm font-medium">Daily availability</legend>
{#each ranges as range, i}
<div class="flex flex-wrap items-end gap-2 rounded-lg bg-surface-700/50 p-3">
<div class="min-w-28 flex-1 space-y-1.5">
<Label for={`start-${i}`}>From</Label><Input
id={`start-${i}`}
name="start"
type="time"
step="3600"
bind:value={range.start}
required
/>
</div>
<div class="min-w-28 flex-1 space-y-1.5">
<Label for={`end-${i}`}>Until</Label><Input
id={`end-${i}`}
name="end"
type="time"
step="3600"
bind:value={range.end}
required
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`Remove time range ${i + 1}`}
onclick={() => {
ranges = ranges.filter((_, index) => index !== i);
}}><Trash2 class="size-4" /></Button
>
{#if range.end < range.start}<p class="w-full text-xs text-text-secondary">
Ends the following day.
</p>{/if}
</div>
{:else}
<p
class="rounded-lg border border-dashed border-border px-4 py-5 text-sm text-text-secondary"
>
No time preference. Matches may be scheduled at any hour.
</p>
{/each}
<Button
type="button"
variant="outline"
size="sm"
disabled={ranges.length >= 12}
onclick={() => {
ranges = [...ranges, { start: '18:00', end: '22:00' }];
}}><Plus class="size-4" />Add time range</Button
>
</fieldset>
<div class="flex flex-wrap items-center gap-3">
<Button type="submit" disabled={saving}>{saving ? 'Saving…' : 'Save availability'}</Button>
{#if message}<p role="status" class="text-sm text-green-400">{message}</p>{/if}
{#if error}<p role="alert" class="text-sm text-red-400">{error}</p>{/if}
</div>
</form>
</section>

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import { gradientFor, initials } from '#lib/format.js';
/** A picture, or the initials on a gradient that is always the same for the same name. */
const {
src,
name,
size = 32,
shape = 'circle',
class: className = ''
}: {
src: string | null | undefined;
name: string;
size?: number;
shape?: 'circle' | 'rounded' | 'square';
class?: string;
} = $props();
let failed = $state(false);
$effect(() => {
void src;
failed = false;
});
const radius = $derived(
shape === 'circle' ? '9999px' : shape === 'rounded' ? `${Math.max(4, size * 0.22)}px` : '4px'
);
</script>
{#if src && !failed}
<img
{src}
alt=""
loading="lazy"
class="shrink-0 bg-white/5 object-cover {className}"
style="width:{size}px;height:{size}px;border-radius:{radius}"
onerror={() => (failed = true)}
/>
{:else}
<span
class="grid shrink-0 place-items-center font-bold text-white/90 select-none {className}"
style="width:{size}px;height:{size}px;border-radius:{radius};background:{gradientFor(
name
)};font-size:{Math.max(9, size * 0.38)}px"
aria-hidden="true">{initials(name)}</span
>
{/if}

View File

@@ -2,7 +2,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/state';
import type { Snippet } from 'svelte';
import type { TableMeta } from '$lib/server/table';
import type { TableMeta } from '#lib/server/table.js';
interface Column {
key: string;
@@ -16,13 +16,16 @@
meta,
columns,
row,
searchPlaceholder = 'Search...'
searchPlaceholder = 'Search...',
layout = 'table'
}: {
data: T[];
meta: TableMeta;
columns: Column[];
row: Snippet<[T, number]>;
searchPlaceholder?: string;
/** `list` renders rows as cards (the row snippet gives each card), with sort buttons instead of a header. */
layout?: 'table' | 'list';
} = $props();
let searchInput = $derived.by(() => meta.search);
@@ -52,7 +55,7 @@
});
function updateParams(updates: Record<string, string>) {
const url = new URL(page.url);
const url = new URL(page.url.href);
for (const [key, value] of Object.entries(updates)) {
if (value) {
url.searchParams.set(key, value);
@@ -64,7 +67,7 @@
if (!('page' in updates)) {
url.searchParams.set('page', '1');
}
goto(url.toString(), { keepFocus: true, noScroll: true });
goto(url.toString(), { reset: false });
}
function handleSearch() {
@@ -115,72 +118,104 @@
>
</div>
<!-- table -->
<div class="overflow-x-auto rounded-lg border border-border">
<table class="w-full">
<thead>
<tr class="border-b border-border bg-surface-800">
{#each columns as col}
<th
class="{col.class ??
'px-4 text-left'} py-3 text-[11px] font-semibold tracking-wider text-text-secondary uppercase"
>
{#if col.sortable}
<button
class="inline-flex items-center gap-1 transition-colors hover:text-text-primary"
onclick={() => toggleSort(col.key)}
>
{col.label}
{#if meta.sortBy === col.key}
<svg
class="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="3"
>
{#if meta.sortDir === 'asc'}
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
{:else}
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
{/if}
</svg>
{:else}
<svg
class="h-3 w-3 opacity-30"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"
/>
</svg>
{/if}
</button>
{:else}
{col.label}
{/if}
</th>
{/each}
</tr>
</thead>
<tbody class="divide-y divide-border/50">
{#each data as item, i}
{@render row(item, i)}
{:else}
<tr>
<td colspan={columns.length} class="px-4 py-8 text-center text-sm text-text-secondary">
{meta.search ? `No results for "${meta.search}"` : 'No data'}
</td>
</tr>
{#if layout === 'list'}
{#if columns.some((col) => col.sortable)}
<div class="mb-3 flex flex-wrap items-center gap-1.5 text-xs text-text-secondary">
<span>Sort by</span>
{#each columns.filter((col) => col.sortable) as col (col.key)}
<button
class="rounded-md border px-2 py-1 transition-colors {meta.sortBy === col.key
? 'border-white/20 bg-white/5 text-text-primary'
: 'border-transparent hover:text-text-primary'}"
onclick={() => toggleSort(col.key)}
>{col.label}{meta.sortBy === col.key
? meta.sortDir === 'asc'
? ' ↑'
: ' ↓'
: ''}</button
>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
<div class="flex flex-col gap-2">
{#each data as item, i}
{@render row(item, i)}
{:else}
<p
class="rounded-xl border border-dashed border-white/10 px-4 py-8 text-center text-sm text-text-secondary"
>
{meta.search ? `No results for "${meta.search}"` : 'Nothing here yet'}
</p>
{/each}
</div>
{:else}
<!-- table -->
<div class="overflow-x-auto rounded-lg border border-border">
<table class="w-full">
<thead>
<tr class="border-b border-border bg-surface-800">
{#each columns as col}
<th
class="{col.class ??
'px-4 text-left'} py-3 text-[11px] font-semibold tracking-wider text-text-secondary uppercase"
>
{#if col.sortable}
<button
class="inline-flex items-center gap-1 transition-colors hover:text-text-primary"
onclick={() => toggleSort(col.key)}
>
{col.label}
{#if meta.sortBy === col.key}
<svg
class="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="3"
>
{#if meta.sortDir === 'asc'}
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
{:else}
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
{/if}
</svg>
{:else}
<svg
class="h-3 w-3 opacity-30"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"
/>
</svg>
{/if}
</button>
{:else}
{col.label}
{/if}
</th>
{/each}
</tr>
</thead>
<tbody class="divide-y divide-border/50">
{#each data as item, i}
{@render row(item, i)}
{:else}
<tr>
<td colspan={columns.length} class="px-4 py-8 text-center text-sm text-text-secondary">
{meta.search ? `No results for "${meta.search}"` : 'No data'}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<!-- pagination -->
{#if meta.totalPages > 1}

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import Avatar from '#lib/components/Avatar.svelte';
import { page } from '$app/state';
import { enhance } from '$app/forms';
@@ -47,9 +48,7 @@
{badge.label}
</span>
{/if}
{#if user.image}
<img src={user.image} alt={user.name} class="h-7 w-7 rounded-full" />
{/if}
<Avatar src={user.image} name={user.name} size={28} />
<span class="font-500 hidden text-sm text-text-primary sm:inline">{user.name}</span>
<svg
class="h-3.5 w-3.5 text-text-secondary transition-transform {menuOpen

View File

@@ -0,0 +1,71 @@
<script lang="ts">
/**
* Plays an HLS stream or recording: natively where the browser can (Safari,
* iOS), otherwise with hls.js, loaded only when needed.
*/
let {
src,
poster = null,
live = false,
startAt = 0,
video = $bindable<HTMLVideoElement | null>(null)
}: {
src: string;
poster?: string | null;
live?: boolean;
startAt?: number;
video?: HTMLVideoElement | null;
} = $props();
let failed = $state(false);
$effect(() => {
const element = video;
if (!element) return;
failed = false;
let destroy = () => {};
const seek = () => {
if (startAt > 0) element.currentTime = startAt;
};
if (element.canPlayType('application/vnd.apple.mpegurl')) {
element.src = src;
element.addEventListener('loadedmetadata', seek, { once: true });
} else {
void import('hls.js').then(({ default: Hls }) => {
if (!Hls.isSupported()) {
failed = true;
return;
}
const hls = new Hls({ lowLatencyMode: live, liveSyncDurationCount: 3 });
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) failed = true;
});
hls.on(Hls.Events.MANIFEST_PARSED, seek);
hls.loadSource(src);
hls.attachMedia(element);
destroy = () => hls.destroy();
});
}
return () => destroy();
});
</script>
<div class="relative aspect-video overflow-hidden rounded-2xl bg-black">
<!-- svelte-ignore a11y_media_has_caption -->
<video
bind:this={video}
class="size-full"
controls
autoplay={live}
muted={live}
playsinline
poster={poster ?? undefined}
></video>
{#if failed}
<div
class="absolute inset-0 grid place-items-center bg-black/80 p-6 text-center text-sm text-zinc-300"
>
This video could not be loaded. If the stream just started, give it a few seconds and reload.
</div>
{/if}
</div>

View File

@@ -0,0 +1,158 @@
<script lang="ts">
import Avatar from '#lib/components/Avatar.svelte';
import { LIVE_STATES, type MatchSummary } from '#lib/match-summary.js';
import { timeAgo } from '#lib/format.js';
import Ban from '@lucide/svelte/icons/ban';
import Crown from '@lucide/svelte/icons/crown';
import Disc3 from '@lucide/svelte/icons/disc-3';
import Flag from '@lucide/svelte/icons/flag';
import Swords from '@lucide/svelte/icons/swords';
import Trophy from '@lucide/svelte/icons/trophy';
/**
* One match in a list; the whole row opens the match. Profiles are linked from the match page.
* With `perspective` (a team id, or every team a player played for), the row shows a win or loss.
*/
const {
match,
perspective = null
}: { match: MatchSummary; perspective?: string | string[] | null } = $props();
const live = $derived(LIVE_STATES.has(match.state));
const [a, b] = $derived([match.sides[0], match.sides[1]]);
const mine = $derived.by(() => {
if (!perspective) return null;
const ids = Array.isArray(perspective) ? perspective : [perspective];
return match.sides.find((side) => ids.includes(side.teamId)) ?? null;
});
const outcome = $derived(match.state !== 'FINISHED' || !mine ? null : mine.won ? 'W' : 'L');
const STATE_LABELS: Record<string, string> = {
CREATED: 'Starting',
LOBBY: 'In lobby',
ROLLING: 'Rolling',
CHOOSING: 'Choosing',
BANNING: 'Banning',
PICKING: 'Picking',
TB_SELECT: 'Tiebreaker',
PLAYING: 'Playing',
CLOSING: 'Wrapping up',
INTERRUPTED: 'Paused'
};
const badge = $derived(match.tournament ?? match.pool?.owner ?? null);
</script>
{#snippet side(entry: MatchSummary['sides'][number] | undefined, align: 'left' | 'right')}
{#if entry}
<span
class="flex min-w-0 items-center gap-2.5 py-1 {align === 'right'
? 'flex-row-reverse text-right'
: ''}"
>
<span class="relative">
<Avatar src={entry.avatarUrl} name={entry.name} size={32} shape="rounded" />
{#if entry.won}
<Crown
class="absolute -top-2 {align === 'left'
? '-left-1.5 -rotate-12'
: '-right-1.5 rotate-12'} size-3.5 fill-amber-300 text-amber-300"
/>
{/if}
</span>
<span
class="truncate text-sm {entry.won
? 'font-semibold text-white'
: match.state === 'FINISHED'
? 'text-zinc-400'
: 'text-zinc-200'}">{entry.name}</span
>
</span>
{:else}
<span class="text-sm text-zinc-500">TBD</span>
{/if}
{/snippet}
<a
href="/matches/{match.id}"
class="group relative block overflow-hidden rounded-xl border border-white/8 bg-zinc-900/60 transition hover:border-white/20 hover:bg-zinc-900"
>
{#if match.pool?.coverUrl}
<img
src={match.pool.coverUrl}
alt=""
loading="lazy"
class="pointer-events-none absolute inset-y-0 right-0 h-full w-1/2 object-cover opacity-[0.12] transition group-hover:opacity-20"
style="mask-image:linear-gradient(to right, transparent, black)"
/>
{/if}
<div
class="relative grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 px-3 pt-2.5 pb-1 sm:px-4"
>
{@render side(a, 'left')}
<div class="flex items-center gap-1.5 font-bold tabular-nums">
{#if match.state === 'CANCELLED'}
<span class="text-xs font-medium text-zinc-500">vs</span>
{:else}
<span class="text-lg {a?.won ? 'text-white' : 'text-zinc-400'}">{a?.score ?? 0}</span>
<span class="text-zinc-600">:</span>
<span class="text-lg {b?.won ? 'text-white' : 'text-zinc-400'}">{b?.score ?? 0}</span>
{/if}
</div>
{@render side(b, 'right')}
</div>
<div
class="relative flex flex-wrap items-center gap-x-3 gap-y-1 px-3 pb-2.5 text-xs text-zinc-500 sm:px-4"
>
{#if live}
<span class="flex items-center gap-1.5 font-semibold text-red-300"
><span class="size-1.5 animate-pulse rounded-full bg-red-500"></span>{STATE_LABELS[
match.state
] ?? 'Live'}</span
>
{:else if match.state === 'CANCELLED'}
<span class="flex items-center gap-1"><Ban class="size-3" />Cancelled</span>
{:else}
<span class="flex items-center gap-1"><Flag class="size-3" />Final</span>
{/if}
{#if outcome}
<span
class="rounded px-1.5 py-px text-[11px] font-bold {outcome === 'W'
? 'bg-emerald-400/15 text-emerald-300'
: 'bg-red-400/15 text-red-300'}">{outcome === 'W' ? 'Win' : 'Loss'}</span
>
{/if}
{#if match.bestOf}
<span class="flex items-center gap-1"
><Swords class="size-3" />BO{match.bestOf}{match.teamSize
? ` · ${match.teamSize}v${match.teamSize}`
: ''}</span
>
{/if}
{#if badge}
<span
class="flex min-w-0 items-center gap-1.5"
style="color:{match.tournament ? (badge.accentColor ?? 'inherit') : 'inherit'}"
title={match.tournament ? undefined : `Mappool from ${badge.name}`}
>
{#if badge.iconUrl}
<img src={badge.iconUrl} alt="" class="size-3.5 rounded-sm object-cover" />
{:else if match.tournament}
<Trophy class="size-3" />
{:else}
<Disc3 class="size-3" />
{/if}
<span class="truncate"
>{match.tournament
? `${match.tournament.name}${match.tournament.round ? ` · ${match.tournament.round}` : ''}`
: (match.pool?.name ?? badge.name)}</span
>
</span>
{:else if match.pool}
<span class="flex min-w-0 items-center gap-1"
><Disc3 class="size-3" /><span class="truncate">{match.pool.name}</span></span
>
{/if}
<span class="ml-auto shrink-0">{timeAgo(match.finishedAt ?? match.createdAt)}</span>
</div>
</a>

View File

@@ -0,0 +1,210 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { SIDE_COLORS, initials } from '#lib/components/match/theme.js';
import {
POINTS,
crowdShare,
possibleScores,
scorePrediction,
type Crowd,
type PredictionResult,
type PredictionState
} from '#lib/predictions.js';
import Check from '@lucide/svelte/icons/check';
import Lock from '@lucide/svelte/icons/lock';
import Sparkles from '@lucide/svelte/icons/sparkles';
import Users from '@lucide/svelte/icons/users';
import X from '@lucide/svelte/icons/x';
const {
state: initial,
crowd: liveCrowd = null,
open: liveOpen = null,
result: liveResult = null,
compact = false
}: {
state: PredictionState;
/** Newer values from the match socket, when the page has one. */
crowd?: Crowd | null;
open?: boolean | null;
result?: PredictionResult | null;
compact?: boolean;
} = $props();
// svelte-ignore state_referenced_locally
let current = $state(initial);
let busy = $state(false);
const crowd = $derived(liveCrowd ?? current.crowd);
const open = $derived((liveOpen ?? true) && current.open);
const result = $derived(liveResult ?? current.result);
const mine = $derived(current.mine);
const earned = $derived(mine && result ? scorePrediction(mine, result) : (mine?.points ?? null));
const colors = [SIDE_COLORS.red, SIDE_COLORS.blue];
const canPick = $derived(open && current.blocked == null);
async function send(method: 'POST' | 'DELETE', body: Record<string, unknown>) {
busy = true;
try {
const response = await fetch('/api/predictions', {
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
...(current.tournamentMatchId
? { tournamentMatchId: current.tournamentMatchId }
: { matchId: current.matchId }),
...body
})
});
const data = await response.json().catch(() => null);
if (!response.ok) {
toast.error(data?.message ?? 'That did not work. Try again.');
return;
}
current = data;
} catch {
toast.error('Could not reach the server.');
} finally {
busy = false;
}
}
const pickTeam = (teamId: string) =>
send('POST', {
winnerTeamId: teamId,
// Keep the called score when the winner stays the same.
score: mine?.winnerTeamId === teamId ? mine.score : null
});
const pickScore = (score: [number, number] | null) =>
mine && send('POST', { winnerTeamId: mine.winnerTeamId, score });
const pct = (value: number) => `${Math.round(value * 100)}%`;
const favourite = $derived(
current.odds
? current.teams.reduce((best, team) =>
(current.odds![team.id] ?? 0) > (current.odds![best.id] ?? 0) ? team : best
)
: null
);
</script>
<section
class="rounded-2xl border border-white/8 bg-zinc-900/60 {compact ? 'p-3' : 'p-4'}"
aria-label="Predictions"
>
{#if !compact}
<header class="mb-3 flex items-center gap-2 text-sm">
<Sparkles class="size-4 text-amber-300" />
<h2 class="font-semibold">Who takes it?</h2>
<span class="ml-auto flex items-center gap-1 text-xs text-zinc-500"
><Users class="size-3" />{crowd.total} prediction{crowd.total === 1 ? '' : 's'}</span
>
</header>
{/if}
<div class="flex items-stretch gap-2">
{#each current.teams as team, index (team.id)}
{@const color = colors[index]}
{@const chosen = mine?.winnerTeamId === team.id}
{@const won = result?.winnerTeamId === team.id}
<button
type="button"
disabled={!canPick || busy}
onclick={() => pickTeam(team.id)}
class="group relative flex min-w-0 flex-1 items-center gap-2 overflow-hidden rounded-xl border px-3 py-2 text-left transition {index ===
1
? 'flex-row-reverse text-right'
: ''} {canPick ? 'hover:bg-white/5 active:scale-[0.98]' : 'cursor-default'}"
style="border-color:{chosen ? color.solid : 'rgb(255 255 255 / 0.08)'};background:{chosen
? color.soft
: 'transparent'}"
aria-pressed={chosen}
>
{#if team.avatarUrl}
<img src={team.avatarUrl} alt="" class="size-8 shrink-0 rounded-lg object-cover" />
{:else}
<span
class="grid size-8 shrink-0 place-items-center rounded-lg text-xs font-bold"
style="background:{color.soft};color:{color.text}">{initials(team.name)}</span
>
{/if}
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold">{team.name}</span>
<span
class="block text-xs tabular-nums"
style="color:{color.text}"
title="Share of the crowd">{crowd.total ? pct(crowdShare(crowd, team.id)) : '–'}</span
>
</span>
{#if chosen}<Check class="size-4 shrink-0" style="color:{color.text}" />{/if}
{#if won}<span
class="absolute top-1 {index === 1
? 'left-1'
: 'right-1'} text-[10px] font-bold text-amber-300">WIN</span
>{/if}
</button>
{/each}
</div>
<!-- Crowd split -->
<div class="mt-2 flex h-1.5 overflow-hidden rounded-full bg-white/5" aria-hidden="true">
<div
class="transition-[width] duration-700"
style="width:{pct(crowdShare(crowd, current.teams[0].id))};background:{colors[0].solid}"
></div>
<div class="flex-1" style="background:{crowd.total ? colors[1].solid : 'transparent'}"></div>
</div>
{#if mine && canPick}
<div class="mt-3 flex flex-wrap items-center gap-1.5 text-xs">
<span class="text-zinc-500">Exact score, +{POINTS.exactScore}:</span>
{#each possibleScores(current.winsNeeded) as score (score.join('-'))}
{@const on = mine.score?.[0] === score[0] && mine.score?.[1] === score[1]}
<button
type="button"
disabled={busy}
onclick={() => pickScore(on ? null : score)}
class="rounded-md border px-2 py-0.5 tabular-nums transition {on
? 'border-amber-300/60 bg-amber-300/15 text-amber-200'
: 'border-white/10 text-zinc-300 hover:bg-white/5'}">{score[0]}–{score[1]}</button
>
{/each}
<button
type="button"
disabled={busy}
onclick={() => send('DELETE', {})}
class="ml-auto flex items-center gap-1 text-zinc-500 hover:text-zinc-300"
><X class="size-3" />Withdraw</button
>
</div>
{/if}
<footer class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-500">
{#if result && mine}
{#if earned}
<span class="vash-pop font-semibold text-emerald-300"
>You called it! +{earned}{earned > POINTS.winner ? ', exact score' : ''}</span
>
{:else}
<span class="text-zinc-400">Not this time.</span>
{/if}
{:else if result}
<span>{pct(crowdShare(crowd, result.winnerTeamId))} of the crowd called it.</span>
{:else if current.blocked === 'sign-in'}
<a href="/login" class="text-zinc-300 underline-offset-2 hover:underline">Sign in</a> to
predict, +{POINTS.winner} for the winner.
{:else if current.blocked === 'playing'}
<span>You are playing in this one. Good luck!</span>
{:else if !open}
<span class="flex items-center gap-1"><Lock class="size-3" />Predictions are locked.</span>
{:else if mine}
<span>Locked in. You can change it until the first map starts.</span>
{:else}
<span>+{POINTS.winner} for the winner. Closes when the first map starts.</span>
{/if}
{#if favourite && current.odds && !compact}
<span class="ml-auto" title="From both sides' ratings"
>Ratings favour {favourite.name}, {pct(current.odds[favourite.id])}</span
>
{/if}
</footer>
</section>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import Avatar from '#lib/components/Avatar.svelte';
import { enhance } from '$app/forms';
import { page } from '$app/state';
@@ -48,11 +49,7 @@
class="mt-3"
>
<div class="flex gap-3">
{#if user.image}
<img src={user.image} alt="" class="h-8 w-8 shrink-0 rounded-full" />
{:else}
<div class="h-8 w-8 shrink-0 rounded-full bg-surface-600"></div>
{/if}
<Avatar src={user.image} name={user.name ?? '?'} size={32} />
<div class="flex-1">
<textarea
name="content"
@@ -81,19 +78,9 @@
<div class="mt-4 flex flex-col gap-3">
{#each comments as comment}
<div class="flex gap-3">
{#if comment.author.image}
<a href="/users/{comment.author.id}" class="shrink-0">
<img src={comment.author.image} alt="" class="h-8 w-8 rounded-full" />
</a>
{:else}
<a href="/users/{comment.author.id}" class="shrink-0">
<div
class="font-700 flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs text-text-secondary"
>
{comment.author.name.charAt(0).toUpperCase()}
</div>
</a>
{/if}
<a href="/users/{comment.author.id}" class="shrink-0">
<Avatar src={comment.author.image} name={comment.author.name} size={32} />
</a>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<a href="/users/{comment.author.id}" class="font-600 text-sm hover:text-accent"

View File

@@ -0,0 +1,29 @@
<script lang="ts">
const { provider }: { provider: string } = $props();
</script>
{#if provider === 'osu'}
<img src="/osu.png" alt="" class="size-6 object-contain" />
{:else if provider === 'discord'}
<svg class="size-6 text-[#5865f2]" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"
><path
d="M20.317 4.37a19.79 19.79 0 0 0-4.885-1.515c-.211.375-.458.88-.628 1.282a18.27 18.27 0 0 0-5.417 0 12.64 12.64 0 0 0-.635-1.282A19.74 19.74 0 0 0 3.864 4.37C.773 8.943-.065 13.402.354 17.798a19.92 19.92 0 0 0 5.993 3.03c.483-.66.914-1.36 1.286-2.095a12.9 12.9 0 0 1-2.027-.98c.17-.124.337-.254.499-.387 3.91 1.807 8.158 1.807 12.023 0 .163.133.33.263.499.387a12.88 12.88 0 0 1-2.031.982c.372.735.802 1.436 1.286 2.096a19.87 19.87 0 0 0 5.997-3.033c.492-5.096-.84-9.514-3.562-13.428ZM8.02 15.108c-1.174 0-2.138-1.077-2.138-2.397 0-1.32.943-2.398 2.138-2.398 1.195 0 2.16 1.078 2.138 2.398 0 1.32-.943 2.397-2.138 2.397Zm7.9 0c-1.174 0-2.138-1.077-2.138-2.397 0-1.32.943-2.398 2.138-2.398 1.195 0 2.16 1.078 2.138 2.398 0 1.32-.943 2.397-2.138 2.397Z"
/></svg
>
{:else}
<svg class="size-6" viewBox="0 0 24 24" aria-hidden="true"
><path
fill="#4285F4"
d="M21.6 12.23c0-.71-.06-1.39-.18-2.05H12v3.88h5.38a4.6 4.6 0 0 1-1.99 3.02v2.51h3.23c1.89-1.74 2.98-4.31 2.98-7.36Z"
/><path
fill="#34A853"
d="M12 22c2.7 0 4.96-.9 6.62-2.41l-3.23-2.51c-.9.6-2.05.96-3.39.96-2.6 0-4.81-1.76-5.6-4.12H3.07v2.59A10 10 0 0 0 12 22Z"
/><path
fill="#FBBC05"
d="M6.4 13.92a6 6 0 0 1 0-3.84V7.49H3.07a10 10 0 0 0 0 9.02l3.33-2.59Z"
/><path
fill="#EA4335"
d="M12 5.96c1.47 0 2.79.5 3.82 1.49l2.86-2.86A9.6 9.6 0 0 0 12 2a10 10 0 0 0-8.93 5.49l3.33 2.59A6 6 0 0 1 12 5.96Z"
/></svg
>
{/if}

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import Avatar from '#lib/components/Avatar.svelte';
import { goto } from '$app/navigation';
interface SearchResult {
@@ -145,6 +146,7 @@
<div
class="fixed top-[15%] left-1/2 z-[60] w-full max-w-lg -translate-x-1/2 rounded-xl border border-border bg-surface-800 shadow-2xl"
role="dialog"
tabindex="-1"
aria-label="Search"
onkeydown={handleModalKeydown}
>
@@ -219,7 +221,7 @@
<path stroke-linecap="round" stroke-linejoin="round" d={typeIcons[type] ?? ''} />
</svg>
{#if item.image || item.avatarUrl}
<img src={item.image ?? item.avatarUrl} alt="" class="h-5 w-5 rounded-full" />
<Avatar src={item.image ?? item.avatarUrl} name={item.name ?? '?'} size={20} />
{/if}
<span class="flex-1 truncate">{item.name ?? 'Unnamed'}</span>
{#if item.state}

View File

@@ -1,10 +1,12 @@
<script lang="ts">
import { page } from '$app/state';
import { queue, joinQueue, leaveQueue } from '$lib/stores/queue.svelte';
import { queue, joinQueue, leaveQueue } from '#lib/stores/queue.svelte.js';
import { Button } from '#lib/components/ui/button/index.js';
const user = $derived(page.data?.user);
const activeMatch = $derived(page.data?.activeMatch);
const unreadCount = $derived((page.data as any)?.unreadNotificationCount ?? 0);
const liveCount = $derived((page.data as { liveCount?: number })?.liveCount ?? 0);
let { open = $bindable(false), guest = false } = $props();
let notifOpen = $state(false);
@@ -17,6 +19,7 @@
}
const isAdmin = $derived(user?.role === 'admin');
const isStaff = $derived(user?.role === 'admin' || user?.role === 'referee');
// Close sidebar on navigation (mobile)
$effect(() => {
@@ -89,7 +92,23 @@
</script>
{#snippet navIcon(name: string)}
{#if name === 'dashboard'}
{#if name === 'live'}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9" /><path
d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"
/><circle cx="12" cy="12" r="2" /><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5" /><path
d="M19.1 4.9C23 8.8 23 15.1 19.1 19"
/></svg
>
{:else if name === 'dashboard'}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
@@ -351,20 +370,18 @@
<span class="h-1.5 w-1.5 animate-pulse rounded-full bg-yellow-400"></span>
In Queue ({queue.status.queueSize})
</div>
<button
<Button
variant="destructive"
size="sm"
class="w-full"
onclick={leaveQueue}
disabled={queue.loading}
class="flex w-full items-center justify-center rounded-md px-3 py-1.5 text-xs text-red-400 transition-colors hover:bg-red-500/10 disabled:opacity-50"
>
Leave Queue
</button>
Leave queue
</Button>
</div>
{:else if !activeMatch}
<button
onclick={joinQueue}
disabled={queue.loading}
class="font-600 flex w-full items-center justify-center gap-2 rounded-lg bg-accent px-3 py-2.5 text-sm text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
>
<Button class="w-full" onclick={() => joinQueue()} disabled={queue.loading}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
@@ -378,15 +395,15 @@
d="m16.5 22 5-5"
/><path d="M10 5.5 6 2H3v3l4 4" /></svg
>
{queue.loading ? 'Joining...' : 'Find Match'}
</button>
{queue.loading ? 'Joining...' : 'Find 1v1'}
</Button>
{/if}
</div>
{/if}
<!-- Nav -->
<nav class="mt-0 flex flex-1 flex-col gap-0.5 px-3">
{#each [{ href: '/', label: 'Dashboard', icon: 'dashboard' }, { href: '/tournaments', label: 'Tournaments', icon: 'tournaments' }, { href: '/matches', label: 'Matches', icon: 'matches' }, { href: '/mappools', label: 'Mappools', icon: 'mappools' }, { href: '/teams', label: 'Teams', icon: 'teams' }, { href: '/leaderboard', label: 'Leaderboard', icon: 'leaderboard' }] as item (item.href)}
{#each [{ href: '/', label: 'Dashboard', icon: 'dashboard' }, { href: '/live', label: 'Live', icon: 'live' }, { href: '/tournaments', label: 'Tournaments', icon: 'tournaments' }, { href: '/matches', label: 'Matches', icon: 'matches' }, { href: '/mappools', label: 'Mappools', icon: 'mappools' }, { href: '/teams', label: 'Teams', icon: 'teams' }, { href: '/leaderboard', label: 'Leaderboard', icon: 'leaderboard' }] as item (item.href)}
<a
href={item.href}
class="font-500 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors {isActive(
@@ -397,13 +414,31 @@
>
{@render navIcon(item.icon)}
{item.label}
{#if item.icon === 'live' && liveCount > 0}
<span class="ml-auto flex items-center gap-1.5 text-xs text-red-400 tabular-nums">
<span class="size-1.5 animate-pulse rounded-full bg-red-500"></span>{liveCount}
</span>
{/if}
</a>
{/each}
{#if isAdmin}
{#if isStaff}
<div class="mt-4 mb-1 px-3">
<p class="font-600 text-[10px] tracking-widest text-text-secondary uppercase">Staff</p>
</div>
<a
href="/broadcast"
class="font-500 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors {isActive(
'/broadcast'
)
? 'bg-fuchsia-500/10 text-fuchsia-300'
: 'text-text-secondary hover:bg-surface-700 hover:text-text-primary'}"
>
{@render navIcon('live')}
Broadcast
</a>
{/if}
{#if isAdmin}
<a
href="/admin"
class="font-500 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors {isActive(
@@ -423,7 +458,7 @@
<!-- Social links -->
<div class="mb-1 flex items-center gap-1 px-2 py-2">
<a
href="https://github.com/VashSoftware/esports"
href="https://git.stanrunge.dev/stan/esports"
target="_blank"
rel="noopener"
aria-label="GitHub"

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import type { OverlayProps } from './context';
import Panel from './Panel.svelte';
const { data, props, accent }: OverlayProps = $props();
const lines = $derived(
(data.view?.chat ?? [])
.filter((line) => props.showBancho || line.role !== 'bancho')
.filter((line) => !line.text.startsWith('!mp '))
.slice(-(Number(props.lines) || 10))
);
</script>
{#if data.view}
<Panel {accent} edge="left">
<div
class="flex h-full flex-col justify-end gap-1.5 overflow-hidden p-5 text-xl"
style="mask-image:linear-gradient(to bottom, transparent, black 25%)"
>
{#each lines as line (`${line.t}|${line.from}|${line.text}`)}
<div in:fly={{ y: 16, duration: 300 }} class="leading-snug">
<span
class="font-bold {line.role === 'host'
? ''
: line.role === 'bancho'
? 'text-pink-300'
: 'text-sky-300'}"
style={line.role === 'host' ? `color:${accent}` : ''}
>{line.role === 'host' ? 'Vash' : line.from}</span
>
<span class="text-zinc-200">{line.text}</span>
</div>
{/each}
</div>
</Panel>
{/if}

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import type { OverlayProps } from './context';
const { data, props, now, h, accent }: OverlayProps = $props();
const next = $derived(data.upcoming.find((match) => Date.parse(match.scheduledAt) > now) ?? null);
const text = $derived.by(() => {
if (props.mode === 'countdown' && next) {
const seconds = Math.max(0, Math.floor((Date.parse(next.scheduledAt) - now) / 1000));
const hours = Math.floor(seconds / 3600);
const mm = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0');
const ss = String(seconds % 60).padStart(2, '0');
return hours ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`;
}
return (
new Intl.DateTimeFormat('en-GB', {
hour: '2-digit',
minute: '2-digit',
timeZone: 'UTC'
}).format(new Date(now)) + ' UTC'
);
});
</script>
<div
class="flex size-full items-center font-black tabular-nums"
style="font-size:{h * 0.6}px;color:{accent}"
>
{text}
</div>

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import type { OverlayProps } from './context';
/** See-through: OBS shows the osu! tourney client capture underneath. */
const { props, accent }: OverlayProps = $props();
</script>
<div
class="grid size-full place-items-center"
style={props.border ? `box-shadow:inset 0 0 0 3px ${accent}` : ''}
>
{#if props.label}<span class="text-2xl font-semibold text-white/40">{props.label}</span>{/if}
</div>

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import { SIDE_COLORS, modColor } from '#lib/components/match/theme.js';
import { numberFormat, type OverlayProps } from './context';
import Panel from './Panel.svelte';
const { data, accent }: OverlayProps = $props();
const view = $derived(data.view);
</script>
{#if view}
<Panel {accent} edge="top">
<div class="flex h-full flex-col gap-2 overflow-hidden p-5">
<div class="text-sm font-bold tracking-[0.25em] text-zinc-400 uppercase">Maps</div>
{#each view.maps as map (map.index)}
{@const slot = view.slots.find((each) => each.id === map.slotId)}
{@const winner = view.sides.find((side) => side.id === map.winner)}
<div
in:fly={{ x: -30, duration: 400 }}
class="flex items-center gap-3 rounded-lg bg-white/5 px-3 py-2 text-lg"
>
<span
class="rounded px-2 text-base font-black text-zinc-950"
style="background:{modColor(slot?.category ?? '')}">{slot?.label ?? '?'}</span
>
<span class="min-w-0 flex-1 truncate text-zinc-300">{slot?.title ?? ''}</span>
{#each view.sides as side (side.id)}
<span
class="w-28 text-right tabular-nums {map.winner === side.id
? 'font-bold'
: 'text-zinc-500'}"
style={map.winner === side.id ? `color:${SIDE_COLORS[side.color].text}` : ''}
>{numberFormat.format(map.totals[side.id] ?? 0)}</span
>
{/each}
{#if winner}<span
class="size-3 rounded-full"
style="background:{SIDE_COLORS[winner.color].solid}"
></span>{/if}
</div>
{:else}
<div class="text-lg text-zinc-500">No maps played yet.</div>
{/each}
</div>
</Panel>
{/if}

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import type { OverlayProps } from './context';
const { props }: OverlayProps = $props();
</script>
{#if props.src}
<img
src={String(props.src)}
alt=""
class="size-full"
style="object-fit:{props.fit === 'cover' ? 'cover' : 'contain'}"
/>
{/if}

View File

@@ -0,0 +1,77 @@
<script lang="ts">
import { Tween } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
import { SIDE_COLORS } from '#lib/components/match/theme.js';
import { numberFormat, type OverlayProps } from './context';
const { data, props, h }: OverlayProps = $props();
const view = $derived(data.view);
const totals = $derived(
view?.sides.map((side) =>
side.players.reduce((sum, player) => sum + (player.live?.score ?? 0), 0)
) ?? [0, 0]
);
const left = new Tween(0, { duration: 600, easing: cubicOut });
const right = new Tween(0, { duration: 600, easing: cubicOut });
$effect(() => {
left.target = totals[0];
right.target = totals[1];
});
const share = $derived(
left.current + right.current > 0 ? left.current / (left.current + right.current) : 0.5
);
const diff = $derived(Math.round(Math.abs(left.current - right.current)));
const leader = $derived(
left.current === right.current ? null : left.current > right.current ? 0 : 1
);
const big = $derived(Math.min(64, h * 0.45));
</script>
{#if view}
{@const [a, b] = view.sides}
<div class="flex size-full flex-col justify-center gap-2 bg-zinc-950/85 px-10 backdrop-blur-md">
<div class="flex items-end gap-6">
<div
class="flex-1 font-black tabular-nums"
style="font-size:{big}px;line-height:1;color:{SIDE_COLORS[a.color].text}"
>
{numberFormat.format(Math.round(left.current))}
</div>
{#if leader != null && diff > 0}
<div
class="pb-1 text-2xl font-bold tabular-nums"
style="color:{SIDE_COLORS[view.sides[leader].color].text}"
>
{leader === 0 ? '◀' : ''} +{numberFormat.format(diff)}
{leader === 1 ? '▶' : ''}
</div>
{/if}
<div
class="flex-1 text-right font-black tabular-nums"
style="font-size:{big}px;line-height:1;color:{SIDE_COLORS[b.color].text}"
>
{numberFormat.format(Math.round(right.current))}
</div>
</div>
<div class="flex h-3 overflow-hidden rounded-full bg-white/5">
<div style="width:{share * 100}%;background:{SIDE_COLORS[a.color].solid}"></div>
<div class="flex-1" style="background:{SIDE_COLORS[b.color].solid}"></div>
</div>
{#if props.showPlayers && view.rules.teamSize > 1 && h >= 150}
<div class="flex justify-between gap-6 text-lg text-zinc-300">
{#each view.sides as side, index (side.id)}
<div class="flex gap-5 {index === 1 ? 'flex-row-reverse' : ''}">
{#each side.players.filter((player) => player.live) as player (player.id)}
<span class="tabular-nums"
><span class="text-zinc-500">{player.osuName ?? player.name}</span>
{numberFormat.format(player.live!.score)} · {player.live!.accuracy.toFixed(
2
)}%</span
>
{/each}
</div>
{/each}
</div>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,74 @@
<script lang="ts">
import { flip } from 'svelte/animate';
import { SIDE_COLORS, modColor } from '#lib/components/match/theme.js';
import type { OverlayProps } from './context';
const { data, props, w, h }: OverlayProps = $props();
const view = $derived(data.view);
const columns = $derived(Math.max(1, Number(props.columns) || 5));
const rows = $derived(view ? Math.ceil(view.slots.length / columns) : 1);
const cellH = $derived((h - (rows - 1) * 12) / rows);
const colorOf = (sideId: string | null) => {
const side = view?.sides.find((each) => each.id === sideId);
return side ? SIDE_COLORS[side.color] : null;
};
</script>
{#if view}
<div
class="grid size-full gap-3"
style="grid-template-columns:repeat({columns}, minmax(0, 1fr));grid-auto-rows:{cellH}px"
>
{#each view.slots as slot (slot.id)}
{@const by = colorOf(slot.status === 'banned' ? slot.bannedBy : slot.pickedBy)}
{@const winner = colorOf(slot.winner)}
<div
animate:flip
class="relative overflow-hidden border-2 bg-zinc-950 transition-all duration-500 {slot.status ===
'banned'
? 'opacity-40 grayscale'
: ''}"
style="border-radius:var(--radius);border-color:{slot.status === 'live'
? '#fff'
: (by?.solid ?? 'rgb(255 255 255 / 0.1)')};{slot.status === 'live'
? 'box-shadow:0 0 30px rgb(255 255 255 / 0.35)'
: ''}"
>
{#if slot.coverUrl}<img
src={slot.coverUrl}
alt=""
class="absolute inset-0 size-full object-cover opacity-50"
/>{/if}
<div
class="absolute inset-0 bg-gradient-to-t from-zinc-950 via-zinc-950/60 to-transparent"
></div>
<div class="relative flex h-full flex-col justify-between p-3">
<div class="flex items-center gap-2">
<span
class="rounded-md px-2 py-0.5 text-lg font-black text-zinc-950"
style="background:{modColor(slot.category)}">{slot.label}</span
>
{#if slot.status === 'banned'}<span
class="ml-auto text-sm font-black tracking-widest text-red-300">BANNED</span
>{/if}
{#if slot.status === 'live'}<span
class="ml-auto animate-pulse text-sm font-black tracking-widest">PLAYING</span
>{/if}
{#if winner}<span
class="ml-auto size-4 rounded-full"
style="background:{winner.solid};box-shadow:0 0 12px {winner.solid}"
></span>{/if}
</div>
{#if cellH > 70 && w / columns > 180}
<div class="min-w-0">
<div class="truncate text-lg leading-tight font-bold">
{slot.title ?? slot.beatmapId}
</div>
<div class="truncate text-sm text-zinc-400">{slot.artist ?? ''}</div>
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import { modColor } from '#lib/components/match/theme.js';
import type { OverlayProps } from './context';
const { data, props, h, accent }: OverlayProps = $props();
const view = $derived(data.view);
// The map being played, or the last one picked.
const slot = $derived.by(() => {
if (!view) return null;
const id = view.live?.slotId ?? view.maps.at(-1)?.slotId;
return view.slots.find((each) => each.id === id) ?? null;
});
const picker = $derived(
view?.sides.find((side) => side.id === (view.live?.pickedBy ?? null)) ?? null
);
const slim = $derived(h < 100);
const length = (sec: number | null) =>
sec == null ? '' : `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`;
</script>
{#if slot}
<div
class="relative flex size-full items-center gap-5 overflow-hidden border border-white/10 bg-zinc-950/85 {slim
? 'px-6'
: 'px-6'}"
style="border-radius:{slim ? 0 : 'var(--radius)'}"
>
{#if slot.coverUrl}
<img src={slot.coverUrl} alt="" class="absolute inset-0 size-full object-cover opacity-30" />
<div
class="absolute inset-0 bg-gradient-to-r from-zinc-950 via-zinc-950/70 to-transparent"
></div>
{/if}
<span
class="relative shrink-0 rounded-lg px-3 py-1 font-black text-zinc-950"
style="background:{modColor(slot.category)};font-size:{slim ? 22 : 30}px">{slot.label}</span
>
<div class="relative min-w-0 flex-1">
<div class="truncate font-bold" style="font-size:{slim ? 24 : Math.min(40, h * 0.24)}px">
{slot.artist ? `${slot.artist} - ` : ''}{slot.title ?? `Beatmap ${slot.beatmapId}`}
</div>
{#if !slim}
<div class="truncate text-xl text-zinc-400">
[{slot.version ?? ''}]{picker ? ` · picked by ${picker.name}` : ''}
</div>
{/if}
</div>
{#if props.showStats}
<div class="relative flex shrink-0 gap-5 text-xl font-semibold text-zinc-200 tabular-nums">
{#if slot.starRating}<span>★ {slot.starRating.toFixed(2)}</span>{/if}
{#if slot.bpm}<span>{Math.round(slot.bpm)} BPM</span>{/if}
{#if slot.lengthSec}<span>{length(slot.lengthSec)}</span>{/if}
</div>
{/if}
<div class="absolute inset-x-0 bottom-0 h-1" style="background:{accent}"></div>
</div>
{/if}

View File

@@ -0,0 +1,116 @@
<script lang="ts">
import type { Component } from 'svelte';
import { fade } from 'svelte/transition';
import type { ChannelPayload } from '#lib/broadcast/channel.js';
import { stageOf, type ComponentType, type SceneId } from '#lib/broadcast/layout.js';
import type { OverlayProps } from './context';
import Scoreboard from './Scoreboard.svelte';
import LiveScore from './LiveScore.svelte';
import NowPlaying from './NowPlaying.svelte';
import Mappool from './Mappool.svelte';
import Turn from './Turn.svelte';
import History from './History.svelte';
import Chat from './Chat.svelte';
import Predictions from './Predictions.svelte';
import Players from './Players.svelte';
import Winner from './Winner.svelte';
import Gameplay from './Gameplay.svelte';
import Ticker from './Ticker.svelte';
import Schedule from './Schedule.svelte';
import Text from './Text.svelte';
import Image from './Image.svelte';
import Clock from './Clock.svelte';
const REGISTRY: Record<ComponentType, Component<OverlayProps>> = {
scoreboard: Scoreboard,
livescore: LiveScore,
nowplaying: NowPlaying,
mappool: Mappool,
turn: Turn,
history: History,
chat: Chat,
predictions: Predictions,
players: Players,
winner: Winner,
gameplay: Gameplay,
ticker: Ticker,
schedule: Schedule,
text: Text,
image: Image,
clock: Clock
};
let {
payload,
scene: forcedScene = null,
solid = false,
key = false,
scale = $bindable(1)
}: {
payload: ChannelPayload;
/** Show this scene whatever the match is doing (the editor). */
scene?: SceneId | null;
/** Paint see-through scenes too, for a stream without anything underneath. */
solid?: boolean;
/** Fill transparent areas with the encoder's chroma key colour. */
key?: boolean;
scale?: number;
} = $props();
let width = $state(0);
let height = $state(0);
const stage = $derived(stageOf(payload.channel.layout));
$effect(() => {
if (width && height) scale = Math.min(width / stage.width, height / stage.height);
});
// Server time, corrected for this machine's clock, for countdowns.
let now = $state(Date.now());
const offset = $derived(payload.serverNow - Date.now());
$effect(() => {
const timer = setInterval(() => (now = Date.now() + offset), 250);
return () => clearInterval(timer);
});
const sceneId = $derived(forcedScene ?? payload.scene);
const layout = $derived(payload.channel.layout);
const scene = $derived(layout.scenes[sceneId]);
const accent = $derived(payload.channel.accent);
const background = $derived(
scene.background === 'image' && scene.image
? `center / cover no-repeat url("${scene.image}"), ${layout.theme.background}`
: scene.background === 'solid' || solid
? `radial-gradient(ellipse at top, color-mix(in srgb, ${accent} 14%, ${layout.theme.background}), ${layout.theme.background} 70%)`
: key
? '#00ff00'
: 'transparent'
);
</script>
<div class="relative size-full overflow-hidden" bind:clientWidth={width} bind:clientHeight={height}>
<div
class="absolute top-1/2 left-1/2 text-zinc-50"
style="width:{stage.width}px;height:{stage.height}px;transform:translate(-50%, -50%) scale({scale});--radius:{layout
.theme.radius}px"
>
{#key sceneId}
<div
class="absolute inset-0"
style="background:{background}"
transition:fade={{ duration: 450 }}
>
{#each scene.components as item (item.id)}
{@const Render = REGISTRY[item.type]}
<div
class="absolute"
style="left:{item.x}px;top:{item.y}px;width:{item.w}px;height:{item.h}px"
>
{#if Render}
<Render data={payload} props={item.props} w={item.w} h={item.h} {now} {accent} />
{/if}
</div>
{/each}
</div>
{/key}
</div>
</div>

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import type { Snippet } from 'svelte';
/** The glassy card most overlay components sit on. */
const {
accent,
children,
class: className = '',
edge = 'none'
}: {
accent: string;
children: Snippet;
class?: string;
edge?: 'top' | 'bottom' | 'left' | 'none';
} = $props();
</script>
<div
class="relative size-full overflow-hidden border border-white/10 bg-zinc-950/80 backdrop-blur-md {className}"
style="border-radius:var(--radius)"
>
{#if edge !== 'none'}
<div
class="absolute {edge === 'top'
? 'inset-x-0 top-0 h-1'
: edge === 'bottom'
? 'inset-x-0 bottom-0 h-1'
: 'inset-y-0 left-0 w-1'}"
style="background:{accent}"
></div>
{/if}
{@render children()}
</div>

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import { SIDE_COLORS, flagUrl, initials } from '#lib/components/match/theme.js';
import type { OverlayProps } from './context';
const { data }: OverlayProps = $props();
const view = $derived(data.view);
</script>
{#if view}
<div class="grid size-full grid-cols-2 gap-10">
{#each view.sides as side, index (side.id)}
{@const colors = SIDE_COLORS[side.color]}
<div class="flex flex-col gap-4 {index === 1 ? 'items-end text-right' : ''}">
<div class="text-5xl font-black tracking-tight" style="color:{colors.text}">
{side.name}
</div>
{#if side.roll != null}<div class="text-2xl text-zinc-300">
rolled <span class="font-black tabular-nums">{side.roll}</span>
</div>{/if}
<div class="flex flex-col gap-3 {index === 1 ? 'items-end' : ''}">
{#each side.players as player, n (player.id)}
<div
in:fly={{ x: index === 0 ? -60 : 60, delay: n * 120, duration: 500 }}
class="flex items-center gap-4 rounded-2xl border border-white/10 bg-zinc-950/80 p-3 pr-6 {index ===
1
? 'flex-row-reverse pr-3 pl-6'
: ''}"
style="box-shadow:inset {index === 0 ? '5px' : '-5px'} 0 0 {colors.solid}"
>
{#if player.avatarUrl}
<img src={player.avatarUrl} alt="" class="size-16 rounded-xl object-cover" />
{:else}
<div
class="grid size-16 place-items-center rounded-xl text-xl font-bold"
style="background:{colors.soft}"
>
{initials(player.name)}
</div>
{/if}
<div>
<div
class="flex items-center gap-2 text-2xl font-bold {index === 1
? 'flex-row-reverse'
: ''}"
>
{#if flagUrl(player.countryCode)}<img
src={flagUrl(player.countryCode)}
alt=""
class="h-4"
/>{/if}
{player.osuName ?? player.name}
</div>
<div class="text-lg text-zinc-400 tabular-nums">
{player.rating ?? ''}{player.inLobby ? ' · in lobby' : ''}
</div>
</div>
</div>
{/each}
</div>
</div>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import { SIDE_COLORS } from '#lib/components/match/theme.js';
import { crowdShare } from '#lib/predictions.js';
import type { OverlayProps } from './context';
import Panel from './Panel.svelte';
const { data, props, accent }: OverlayProps = $props();
const view = $derived(data.view);
const crowd = $derived(data.prediction?.crowd ?? { counts: {}, total: 0 });
const pct = (value: number) => `${Math.round(value * 100)}%`;
</script>
{#if view}
{@const [a, b] = view.sides}
{@const share = crowdShare(crowd, a.teamId)}
<Panel {accent} edge="top">
<div class="flex h-full flex-col justify-center gap-3 px-6">
<div
class="flex items-baseline justify-between text-sm font-bold tracking-[0.2em] text-zinc-400 uppercase"
>
<span>Crowd prediction</span><span class="tabular-nums"
>{crowd.total} vote{crowd.total === 1 ? '' : 's'}</span
>
</div>
<div class="flex items-center gap-4 text-3xl font-black tabular-nums">
<span style="color:{SIDE_COLORS[a.color].text}">{crowd.total ? pct(share) : '–'}</span>
<div class="flex h-4 flex-1 overflow-hidden rounded-full bg-white/5">
{#if crowd.total}
<div
class="transition-[width] duration-700"
style="width:{share * 100}%;background:{SIDE_COLORS[a.color].solid}"
></div>
<div class="flex-1" style="background:{SIDE_COLORS[b.color].solid}"></div>
{:else}
<div
class="flex flex-1 items-center justify-center text-[10px] font-bold tracking-widest text-zinc-500"
>
PREDICT ON VASH
</div>
{/if}
</div>
<span style="color:{SIDE_COLORS[b.color].text}">{crowd.total ? pct(1 - share) : '–'}</span>
</div>
{#if props.showOdds && data.prediction?.odds}
{@const odds = data.prediction.odds}
{@const fav = (odds[a.teamId] ?? 0) >= (odds[b.teamId] ?? 0) ? a : b}
<div class="text-lg text-zinc-400">
Ratings favour <span class="font-semibold text-zinc-200">{fav.name}</span>, {pct(
odds[fav.teamId] ?? 0.5
)}
</div>
{/if}
</div>
</Panel>
{/if}

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import Overlay from './Overlay.svelte';
import { demoChannel, demoPayload } from '#lib/broadcast/demo.js';
import type { Layout, SceneId } from '#lib/broadcast/layout.js';
/** A small live render of a layout with the demo match; the checkerboard is where OBS shows through. */
const { layout, scene = 'gameplay' }: { layout: Layout; scene?: SceneId } = $props();
const payload = $derived(demoPayload(demoChannel(layout), scene));
</script>
<div
class="relative aspect-video overflow-hidden rounded-lg bg-[repeating-conic-gradient(#27272a_0_25%,#18181b_0_50%)] bg-[length:12px_12px]"
>
<Overlay {payload} {scene} />
</div>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import type { OverlayProps } from './context';
import Panel from './Panel.svelte';
const { data, props, now, accent }: OverlayProps = $props();
const time = new Intl.DateTimeFormat('en-GB', {
hour: '2-digit',
minute: '2-digit',
timeZone: 'UTC'
});
const until = (iso: string) => {
const minutes = Math.round((Date.parse(iso) - now) / 60_000);
return minutes <= 0
? 'now'
: minutes < 60
? `in ${minutes}m`
: `${time.format(new Date(iso))} UTC`;
};
</script>
<Panel {accent} edge="left">
<div class="flex h-full flex-col gap-3 p-6">
<div class="text-sm font-bold tracking-[0.25em] text-zinc-400 uppercase">
{props.title || 'Up next'}
</div>
{#each data.upcoming as match (match.id)}
<div class="flex items-center gap-4 text-2xl">
<span class="w-32 shrink-0 text-lg tabular-nums" style="color:{accent}"
>{until(match.scheduledAt)}</span
>
<span class="min-w-0 flex-1 truncate font-bold"
>{match.teams[0].name} <span class="text-zinc-500">vs</span> {match.teams[1].name}</span
>
{#if match.round}<span class="shrink-0 text-base text-zinc-500">{match.round}</span>{/if}
</div>
{:else}
<div class="text-xl text-zinc-500">
Nothing scheduled. Matches go live as players queue up.
</div>
{/each}
</div>
</Panel>

View File

@@ -0,0 +1,93 @@
<script lang="ts">
import { PHASE_LABELS } from '#lib/match-view.js';
import { SIDE_COLORS, initials } from '#lib/components/match/theme.js';
import type { OverlayProps } from './context';
const { data, props, h, accent }: OverlayProps = $props();
const view = $derived(data.view);
const size = $derived(Math.max(40, h - 30));
</script>
{#if view}
{@const needed = view.rules.winsNeeded}
<div
class="flex size-full items-stretch overflow-hidden border-b border-white/10 bg-zinc-950/85 backdrop-blur-md"
>
{#each view.sides as side, index (side.id)}
{@const colors = SIDE_COLORS[side.color]}
<div
class="flex min-w-0 flex-1 items-center gap-5 px-8 {index === 1
? 'order-3 flex-row-reverse text-right'
: ''}"
style="background:linear-gradient({index === 0
? '90deg'
: '270deg'}, {colors.soft}, transparent 75%)"
>
{#if side.avatarUrl ?? (side.isPersonal ? side.players[0]?.avatarUrl : null)}
<img
src={side.avatarUrl ?? side.players[0]?.avatarUrl}
alt=""
class="shrink-0 object-cover ring-4"
style="width:{size}px;height:{size}px;border-radius:calc(var(--radius) * 0.8);--tw-ring-color:{colors.ring}"
/>
{:else}
<div
class="grid shrink-0 place-items-center font-black"
style="width:{size}px;height:{size}px;border-radius:calc(var(--radius) * 0.8);background:{colors.soft};color:{colors.text};font-size:{size *
0.4}px"
>
{initials(side.name)}
</div>
{/if}
<div class="min-w-0">
<div
class="truncate font-black tracking-tight"
style="font-size:{Math.min(56, size * 0.5)}px;line-height:1.05"
>
{side.name}
</div>
{#if props.showRatings && side.rating}
<div class="text-xl font-semibold tabular-nums" style="color:{colors.text}">
{side.rating}
</div>
{/if}
</div>
<div
class="ml-auto flex shrink-0 gap-2 {index === 1 ? 'mr-auto ml-0 flex-row-reverse' : ''}"
>
{#each Array.from({ length: needed }, (_, n) => n) as n (n)}
<span
class="size-5 rotate-45 border-2 transition-all duration-500"
style="border-color:{colors.solid};background:{n < side.points
? colors.solid
: 'transparent'};box-shadow:{n < side.points ? `0 0 16px ${colors.solid}` : 'none'}"
></span>
{/each}
</div>
</div>
{#if index === 0}
<div
class="order-2 flex w-72 shrink-0 flex-col items-center justify-center gap-1 border-x border-white/10 bg-black/40"
>
<div
class="font-black tabular-nums"
style="font-size:{Math.min(72, size * 0.75)}px;line-height:1"
>
<span style="color:{SIDE_COLORS[view.sides[0].color].text}">{view.sides[0].points}</span
><span class="mx-3 text-zinc-600">–</span><span
style="color:{SIDE_COLORS[view.sides[1].color].text}">{view.sides[1].points}</span
>
</div>
<div class="text-sm font-bold tracking-[0.2em] text-zinc-400 uppercase">
{#if props.showRound && view.tournament?.round}{view.tournament.round} ·
{/if}BO{view.rules.bestOf}
</div>
<div class="h-0.5 w-16" style="background:{accent}"></div>
<div class="text-xs font-semibold tracking-widest text-zinc-500 uppercase">
{PHASE_LABELS[view.phase]}
</div>
</div>
{/if}
{/each}
</div>
{/if}

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { OverlayProps } from './context';
const { props, accent }: OverlayProps = $props();
const align = $derived(
props.align === 'left' || props.align === 'right' ? props.align : 'center'
);
</script>
<div
class="flex size-full items-center font-black tracking-tight"
style="justify-content:{align === 'left'
? 'flex-start'
: align === 'right'
? 'flex-end'
: 'center'};text-align:{align};font-size:{Number(props.size) ||
64}px;line-height:1.05;color:{props.accent ? accent : '#fafafa'}"
>
{props.text}
</div>

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import { SIDE_COLORS } from '#lib/components/match/theme.js';
import { DECIDED } from '#lib/live.js';
import type { OverlayProps } from './context';
const { data, props, accent }: OverlayProps = $props();
const others = $derived(
data.live.filter((match) => match.id !== data.view?.id && !DECIDED.includes(match.phase))
);
// Scroll speed stays constant however many matches there are.
const duration = $derived(Math.max(20, others.length * 9));
</script>
<div
class="flex size-full items-center overflow-hidden border-t border-white/10 bg-zinc-950/90 text-xl"
>
<div
class="z-10 flex h-full shrink-0 items-center px-6 font-black tracking-[0.2em] text-zinc-950"
style="background:{accent}"
>
{props.label || 'LIVE'}
</div>
{#if others.length}
<div class="relative flex-1 overflow-hidden">
<div
class="flex w-max gap-16 pl-8 whitespace-nowrap"
style="animation:vash-ticker {duration}s linear infinite"
>
{#each [0, 1] as copy (copy)}
{#each others as match (`${copy}-${match.id}`)}
<span class="flex items-center gap-3">
{#if match.tournament}<span class="text-base text-zinc-500"
>{match.tournament.name}</span
>{/if}
<span style="color:{SIDE_COLORS[match.sides[0].color].text}"
>{match.sides[0].name}</span
>
<span class="font-black tabular-nums"
>{match.sides[0].points}–{match.sides[1].points}</span
>
<span style="color:{SIDE_COLORS[match.sides[1].color].text}"
>{match.sides[1].name}</span
>
{#if match.map}<span class="text-base text-zinc-500">{match.map.label}</span>{/if}
</span>
{/each}
{/each}
</div>
</div>
{:else}
<span class="px-8 text-zinc-500"
>vash esports · osu! tournaments, run by a bot that never sleeps</span
>
{/if}
</div>
<style>
@keyframes -global-vash-ticker {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
</style>

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import { SIDE_COLORS } from '#lib/components/match/theme.js';
import type { OverlayProps } from './context';
const { data, now, h, accent }: OverlayProps = $props();
const view = $derived(data.view);
const side = $derived(view?.sides.find((each) => each.id === view.prompt.side) ?? null);
const verb = $derived(
view?.prompt.kind === 'ban'
? 'bans'
: view?.prompt.kind === 'pick'
? 'picks'
: view?.prompt.kind === 'choose'
? 'chooses order'
: view?.prompt.kind === 'roll'
? 'rolls'
: null
);
const seconds = $derived(
view?.deadlineAt ? Math.max(0, Math.ceil((view.deadlineAt - now) / 1000)) : null
);
</script>
{#if view && verb}
{@const colors = side ? SIDE_COLORS[side.color] : null}
<div
class="flex size-full items-center gap-6 border border-white/10 bg-zinc-950/85 px-8 backdrop-blur-md"
style="border-radius:var(--radius);box-shadow:inset 6px 0 0 {colors?.solid ?? accent}"
>
<div
class="min-w-0 flex-1 truncate font-black tracking-tight"
style="font-size:{Math.min(56, h * 0.42)}px"
>
{#if side}<span style="color:{colors?.text}">{side.name}</span> {verb}{:else}Both teams {verb.replace(
/s$/,
''
)}{/if}
</div>
{#if seconds != null}
<div
class="shrink-0 font-black tabular-nums {seconds <= 10
? 'animate-pulse text-red-400'
: 'text-zinc-300'}"
style="font-size:{Math.min(56, h * 0.42)}px"
>
{Math.floor(seconds / 60)}:{String(seconds % 60).padStart(2, '0')}
</div>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import { scale } from 'svelte/transition';
import { elasticOut } from 'svelte/easing';
import { SIDE_COLORS, initials } from '#lib/components/match/theme.js';
import type { OverlayProps } from './context';
const { data, props, h, accent }: OverlayProps = $props();
const view = $derived(data.view);
const winner = $derived(view?.sides.find((side) => side.id === view.winner) ?? null);
const loser = $derived(view?.sides.find((side) => side.id !== view?.winner) ?? null);
</script>
{#if view && winner && loser}
{@const colors = SIDE_COLORS[winner.color]}
{@const avatar = winner.avatarUrl ?? (winner.isPersonal ? winner.players[0]?.avatarUrl : null)}
<div class="flex size-full flex-col items-center justify-center gap-4 text-center">
<div class="text-2xl font-black tracking-[0.5em]" style="color:{accent}">
{props.headline || 'WINNER'}
</div>
{#key winner.id}
<div
in:scale={{ start: 0.4, duration: 1200, easing: elasticOut }}
class="flex items-center gap-8"
>
{#if avatar}
<img
src={avatar}
alt=""
class="rounded-3xl object-cover ring-8"
style="width:{h * 0.4}px;height:{h *
0.4}px;--tw-ring-color:{colors.ring};box-shadow:0 0 80px {colors.solid}"
/>
{:else}
<div
class="grid place-items-center rounded-3xl font-black"
style="width:{h * 0.4}px;height:{h *
0.4}px;background:{colors.soft};color:{colors.text};font-size:{h * 0.15}px"
>
{initials(winner.name)}
</div>
{/if}
<div
class="font-black tracking-tight"
style="font-size:{Math.min(
140,
h * 0.28
)}px;line-height:1;color:{colors.text};text-shadow:0 0 50px {colors.solid}"
>
{winner.name}
</div>
</div>
{/key}
<div class="text-4xl font-bold text-zinc-300 tabular-nums">
{winner.points} – {loser.points} <span class="text-zinc-500">vs {loser.name}</span>
</div>
</div>
{/if}

View File

@@ -0,0 +1,16 @@
import type { ChannelPayload } from '#lib/broadcast/channel.js';
import type { PlacedComponent } from '#lib/broadcast/layout.js';
/** What every overlay component gets. */
export interface OverlayProps {
data: ChannelPayload;
props: PlacedComponent['props'];
/** The component's box on the 1920×1080 stage. */
w: number;
h: number;
/** Server time, ticking. */
now: number;
accent: string;
}
export const numberFormat = new Intl.NumberFormat('en-US');

View File

@@ -0,0 +1,156 @@
<script lang="ts">
import { PHASE_LABELS } from '#lib/match-view.js';
import { DECIDED, type LiveMatch } from '#lib/live.js';
import { SIDE_COLORS, initials, modColor } from '#lib/components/match/theme.js';
import Eye from '@lucide/svelte/icons/eye';
import Crown from '@lucide/svelte/icons/crown';
const { match, featured = false }: { match: LiveMatch; featured?: boolean } = $props();
const decided = $derived(DECIDED.includes(match.phase));
const playing = $derived(match.phase === 'playing' && match.map?.startedAt != null);
const [left, right] = $derived(match.sides);
const lead = $derived.by(() => {
const a = left.liveScore ?? 0;
const b = right.liveScore ?? 0;
return a + b > 0 ? a / (a + b) : 0.5;
});
const status = $derived(
match.paused
? 'Paused'
: match.phase === 'playing' && !playing
? 'Readying up'
: PHASE_LABELS[match.phase]
);
const numberFormat = new Intl.NumberFormat('en-US');
</script>
<a
href="/matches/{match.id}"
class="group relative block overflow-hidden rounded-2xl border border-white/8 bg-zinc-950 transition hover:border-white/20 {featured
? 'min-h-64'
: ''}"
>
{#if match.map?.coverUrl}
<img
src={match.map.coverUrl}
alt=""
class="absolute inset-0 size-full object-cover opacity-25 transition duration-700 group-hover:scale-105 group-hover:opacity-35"
/>
{/if}
<div class="absolute inset-0 bg-gradient-to-t from-zinc-950 via-zinc-950/80 to-zinc-950/40"></div>
<div class="relative flex h-full flex-col gap-3 {featured ? 'p-5 sm:p-7' : 'p-4'}">
<div class="flex items-center gap-2 text-xs">
{#if decided}
<span class="rounded-full bg-white/10 px-2 py-0.5 font-semibold text-zinc-300">Final</span>
{:else}
<span
class="flex items-center gap-1.5 rounded-full bg-red-500/15 px-2 py-0.5 font-bold text-red-300"
>
<span class="size-1.5 animate-pulse rounded-full bg-red-500"></span>LIVE
</span>
{/if}
{#if match.tournament}
<span class="truncate font-medium" style="color:{match.tournament.accentColor ?? '#e4e4e7'}"
>{match.tournament.name}{match.tournament.round
? ` · ${match.tournament.round}`
: ''}</span
>
{:else}
<span class="truncate text-zinc-400 capitalize"
>{match.kind} · {match.teamSize}v{match.teamSize}</span
>
{/if}
{#if match.simulated}<span class="text-fuchsia-400">sim</span>{/if}
<span class="ml-auto flex items-center gap-1 text-zinc-400"
><Eye class="size-3" />{match.viewers}</span
>
</div>
<div class="flex items-center gap-3 {featured ? 'my-auto sm:gap-6' : ''}">
{#each [left, right] as side, index (side.id)}
{@const colors = SIDE_COLORS[side.color]}
{@const won = match.winner === side.id}
<div
class="flex min-w-0 flex-1 items-center gap-2.5 {index === 1
? 'order-3 flex-row-reverse text-right'
: ''}"
>
<div class="relative shrink-0">
{#if side.avatarUrl}
<img
src={side.avatarUrl}
alt=""
class="{featured ? 'size-16 sm:size-20' : 'size-10'} rounded-xl object-cover ring-2"
style="--tw-ring-color:{colors.ring}"
/>
{:else}
<div
class="{featured
? 'size-16 text-xl sm:size-20'
: 'size-10 text-sm'} grid place-items-center rounded-xl font-bold"
style="background:{colors.soft};color:{colors.text}"
>
{initials(side.name)}
</div>
{/if}
{#if won}<Crown
class="absolute -top-2.5 -right-1.5 size-5 rotate-12 fill-amber-300 text-amber-300"
/>{/if}
</div>
<div class="min-w-0">
<div class="truncate font-bold {featured ? 'text-lg sm:text-2xl' : 'text-sm'}">
{side.name}
</div>
{#if playing && side.liveScore != null}
<div class="text-xs tabular-nums" style="color:{colors.text}">
{numberFormat.format(side.liveScore)}
</div>
{/if}
</div>
</div>
{#if index === 0}
<div class="order-2 shrink-0 text-center">
<div class="font-black tabular-nums {featured ? 'text-5xl sm:text-6xl' : 'text-2xl'}">
<span style="color:{SIDE_COLORS[left.color].text}">{left.points}</span><span
class="mx-1 text-zinc-600">:</span
><span style="color:{SIDE_COLORS[right.color].text}">{right.points}</span>
</div>
<div class="text-[10px] tracking-wider text-zinc-500 uppercase">BO{match.bestOf}</div>
</div>
{/if}
{/each}
</div>
{#if playing}
<!-- Who is ahead on this map, and how far through it they are -->
<div class="flex h-1.5 overflow-hidden rounded-full bg-white/5">
<div
class="transition-[width] duration-700"
style="width:{lead * 100}%;background:{SIDE_COLORS[left.color].solid}"
></div>
<div class="flex-1" style="background:{SIDE_COLORS[right.color].solid}"></div>
</div>
{/if}
<div class="flex items-center gap-2 text-xs text-zinc-400">
{#if match.map && !decided}
<span
class="rounded px-1.5 py-0.5 text-[10px] font-bold text-zinc-950"
style="background:{modColor(match.map.category)}">{match.map.label}</span
>
<span class="min-w-0 truncate"
>{match.map.artist ? `${match.map.artist} - ` : ''}{match.map.title ?? ''}</span
>
{:else}
<span>{status}</span>
{/if}
{#if playing && match.map?.progress != null}
<span class="ml-auto shrink-0 tabular-nums">{Math.round(match.map.progress * 100)}%</span>
{:else if !decided && match.map}
<span class="ml-auto shrink-0">{status}</span>
{/if}
</div>
</div>
</a>

View File

@@ -0,0 +1,173 @@
<script lang="ts">
import * as Dialog from '#lib/components/ui/dialog/index.js';
import { Button } from '#lib/components/ui/button/index.js';
import type { SlotView } from '#lib/match-view.js';
import type { MatchLive } from './live.svelte';
import MapCard from './MapCard.svelte';
import CountdownRing from './CountdownRing.svelte';
import { SIDE_COLORS, categoryRank } from './theme';
import Dice from '@lucide/svelte/icons/dices';
import Crosshair from '@lucide/svelte/icons/crosshair';
import Ban from '@lucide/svelte/icons/ban';
let { live, open = $bindable(false) }: { live: MatchLive; open?: boolean } = $props();
const view = $derived(live.view);
const prompt = $derived(view.prompt);
const me = $derived(live.mySide);
const colors = $derived(me ? SIDE_COLORS[me.color] : SIDE_COLORS.red);
let selected = $state<string | null>(null);
const options = $derived(new Set(prompt.slotIds));
const shown = $derived(
view.slots
.filter((slot) => (view.phase === 'tb' ? slot.tiebreaker : !slot.tiebreaker))
.sort(
(a, b) =>
categoryRank(a.category) - categoryRank(b.category) || a.label.localeCompare(b.label)
)
);
const title = $derived(
prompt.kind === 'roll'
? 'Roll for it'
: prompt.kind === 'choose'
? 'You won the roll'
: prompt.kind === 'ban'
? view.phase === 'tb'
? 'Ban a tiebreaker'
: 'Ban a map'
: view.phase === 'tb'
? 'Pick the tiebreaker'
: 'Pick a map'
);
// Clear a stale selection when the prompt moves on.
$effect(() => {
void prompt.kind;
void prompt.side;
if (selected && !options.has(selected)) selected = null;
});
async function act(run: () => Promise<boolean>) {
if (await run()) {
open = false;
selected = null;
}
}
function choose(slot: SlotView) {
selected = selected === slot.id ? null : slot.id;
}
const chosen = $derived(live.slot(selected));
</script>
<Dialog.Root bind:open>
<Dialog.Content
class="max-h-[92dvh] overflow-y-auto border-2 sm:max-w-4xl"
style="border-color:{colors.ring}"
>
<Dialog.Header>
<div class="flex items-center justify-between gap-4 pr-6">
<div>
<Dialog.Title class="text-2xl font-black tracking-tight" style="color:{colors.text}"
>{title}</Dialog.Title
>
<Dialog.Description class="text-base">
{#if prompt.kind === 'choose'}
{view.rules.bansPerTeam > 0
? 'Whoever picks first bans second.'
: 'Decide who picks the first map.'}
{:else if prompt.kind === 'ban'}
Take a map out of this match. Choose one, then confirm.
{:else if prompt.kind === 'pick'}
Everyone plays the map you pick. Choose one, then confirm.
{:else}
A random number from 1 to 100. Higher wins.
{/if}
</Dialog.Description>
</div>
<CountdownRing
deadline={prompt.deadlineAt}
now={live.now}
total={view.rules.actionTimeoutMs}
size={52}
/>
</div>
</Dialog.Header>
{#if prompt.kind === 'roll'}
<div class="grid place-items-center py-6">
<Button
size="lg"
class="vash-glow h-16 px-12 text-xl font-black"
style="--glow:{colors.solid}"
disabled={live.busy}
onclick={() => act(() => live.send({ type: 'roll' }))}
>
<Dice class="size-6" /> Roll
</Button>
</div>
{:else if prompt.kind === 'choose'}
<div class="grid gap-3 py-2 sm:grid-cols-2">
<button
class="group rounded-2xl border-2 border-white/10 bg-white/5 p-5 text-left transition hover:-translate-y-0.5 hover:border-white/40"
disabled={live.busy}
onclick={() => act(() => live.send({ type: 'choose', choice: 'pick' }))}
>
<Crosshair class="mb-2 size-7" style="color:{colors.text}" />
<div class="text-lg font-bold">Pick first</div>
<div class="text-sm text-zinc-400">
{view.rules.bansPerTeam > 0
? 'The other team bans first.'
: 'You choose the first map.'}
</div>
</button>
<button
class="group rounded-2xl border-2 border-white/10 bg-white/5 p-5 text-left transition hover:-translate-y-0.5 hover:border-white/40"
disabled={live.busy}
onclick={() => act(() => live.send({ type: 'choose', choice: 'ban' }))}
>
<Ban class="mb-2 size-7" style="color:{colors.text}" />
<div class="text-lg font-bold">
{view.rules.bansPerTeam > 0 ? 'Ban first' : 'Pick second'}
</div>
<div class="text-sm text-zinc-400">
{view.rules.bansPerTeam > 0
? 'The other team picks first.'
: 'The other team picks the first map.'}
</div>
</button>
</div>
{:else if prompt.kind === 'ban' || prompt.kind === 'pick'}
<div class="grid gap-2 py-1 sm:grid-cols-2 lg:grid-cols-3">
{#each shown as slot (slot.id)}
<MapCard
{slot}
sides={view.sides}
size="sm"
interactive={options.has(slot.id) && !live.busy}
dimmed={!options.has(slot.id)}
selected={selected === slot.id}
onselect={choose}
/>
{/each}
</div>
<div
class="sticky bottom-0 flex items-center justify-between gap-3 border-t border-white/10 bg-zinc-950/95 pt-3 backdrop-blur"
>
<span class="text-sm text-zinc-400"
>{chosen ? `${chosen.label} · ${chosen.title ?? ''}` : 'Nothing selected'}</span
>
<Button
size="lg"
class="font-bold"
style={chosen ? `background:${colors.solid};color:white` : ''}
disabled={!chosen || live.busy}
onclick={() =>
act(() => live.send({ type: prompt.kind as 'ban' | 'pick', slotId: selected! }))}
>
{prompt.kind === 'ban' ? 'Ban' : 'Pick'}
{chosen?.label ?? ''}
</Button>
</div>
{/if}
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,71 @@
<script lang="ts">
/** A ring that drains toward a deadline and turns red in the last seconds. */
const {
deadline,
now,
total,
size = 44,
label = true
}: {
deadline: number | null;
now: number;
total: number;
size?: number;
label?: boolean;
} = $props();
const left = $derived(deadline == null ? null : Math.max(0, deadline - now));
const fraction = $derived(left == null || total <= 0 ? 0 : Math.min(1, left / total));
const seconds = $derived(left == null ? null : Math.ceil(left / 1000));
const urgent = $derived(seconds != null && seconds <= 15);
const radius = $derived(size / 2 - 3);
const circumference = $derived(2 * Math.PI * radius);
const text = $derived(
seconds == null
? ''
: seconds >= 60
? `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
: `${seconds}`
);
</script>
{#if deadline != null}
<div
class="relative inline-grid place-items-center"
style="width:{size}px;height:{size}px"
class:animate-pulse={urgent && seconds! <= 5}
>
<svg width={size} height={size} class="-rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
stroke-width="3"
class="text-white/10"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke-width="3"
stroke-linecap="round"
stroke={urgent ? '#f87171' : '#e4e4e7'}
stroke-dasharray={circumference}
stroke-dashoffset={circumference * (1 - fraction)}
style="transition: stroke-dashoffset 0.25s linear, stroke 0.3s"
/>
</svg>
{#if label}
<span
class="absolute font-mono font-semibold tabular-nums {size >= 48
? 'text-base'
: size < 40 && text.length > 2
? 'text-[10px]'
: 'text-xs'} {urgent ? 'text-red-300' : 'text-zinc-200'}">{text}</span
>
{/if}
</div>
{/if}

Some files were not shown because too many files have changed in this diff Show More