Compare commits
17 Commits
bb6111e64f
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 331507ee40 | |||
| 6ff5fe53c5 | |||
| d5448d9a94 | |||
| 54444a77f0 | |||
| 9d5a046b40 | |||
| 9b9aafefc1 | |||
| f417a831f7 | |||
| ac890b8e55 | |||
| baff05a37b | |||
| 3a5c8570ee | |||
| 1ea168807f | |||
| 8d71480e9a | |||
| ae4a77f5dd | |||
| 25d2d1af4a | |||
| d76e8922cc | |||
| cd050f9974 | |||
| fec1e7c2f9 |
@@ -2,3 +2,5 @@ node_modules
|
||||
.svelte-kit
|
||||
.git
|
||||
build
|
||||
dist
|
||||
streamer
|
||||
|
||||
92
.env.example
92
.env.example
@@ -1,31 +1,89 @@
|
||||
ROOT_ADMIN_EMAIL=""
|
||||
# Copy this file to .env and fill in the values before running the application.
|
||||
|
||||
# Docker Compose database password (used in docker-compose.yml)
|
||||
DB_PASSWORD=""
|
||||
# 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=""
|
||||
|
||||
DATABASE_URL="postgres://user:password@host:port/db-name"
|
||||
# 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"
|
||||
|
||||
ORIGIN=""
|
||||
# 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
|
||||
# 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=""
|
||||
|
||||
# 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>"
|
||||
|
||||
# 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 notifications
|
||||
# "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=""
|
||||
|
||||
# Cloudflare R2 image storage
|
||||
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
|
||||
# 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=""
|
||||
|
||||
42
.gitea/workflows/build.yml
Normal file
42
.gitea/workflows/build.yml
Normal 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
|
||||
30
.gitea/workflows/deploy-production.yml
Normal file
30
.gitea/workflows/deploy-production.yml
Normal 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
|
||||
64
.github/workflows/ci.yml
vendored
64
.github/workflows/ci.yml
vendored
@@ -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
|
||||
43
.github/workflows/deploy.yml
vendored
43
.github/workflows/deploy.yml
vendored
@@ -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
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,3 +27,6 @@ test-results
|
||||
|
||||
# Dozzle
|
||||
dozzle-users.yml
|
||||
|
||||
nvim.log
|
||||
/dist
|
||||
|
||||
11
.vscode/settings.json
vendored
11
.vscode/settings.json
vendored
@@ -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": []
|
||||
}
|
||||
|
||||
36
Dockerfile
36
Dockerfile
@@ -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"]
|
||||
|
||||
216
README.md
216
README.md
@@ -2,174 +2,128 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/VashSoftware/esports/actions/workflows/ci.yml)
|
||||
[](https://github.com/VashSoftware/esports/actions/workflows/deploy.yml)
|
||||
[](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 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 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) |
|
||||
| `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
497
STREAMING_HANDOFF.md
Normal 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.
|
||||
20
components.json
Normal file
20
components.json
Normal 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
22
compose.prod.yml
Normal 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
|
||||
@@ -1,82 +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}
|
||||
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:
|
||||
@@ -1,105 +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}
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
|
||||
104
package.json
104
package.json
@@ -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.4.4",
|
||||
"@commitlint/config-conventional": "^20.4.4",
|
||||
"@eslint/compat": "^2.0.2",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@playwright/test": "^1.58.1",
|
||||
"@sentry/sveltekit": "^10.43.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.1",
|
||||
"@types/bun": "^1.3.9",
|
||||
"@types/node": "^22",
|
||||
"@vitest/browser-playwright": "^4.1.0",
|
||||
"better-auth": "^1.4.18",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"eslint": "^9.39.2",
|
||||
"@commitlint/cli": "^20.5.3",
|
||||
"@commitlint/config-conventional": "^20.5.3",
|
||||
"@eslint/compat": "^2.1.1",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@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.14.0",
|
||||
"globals": "^17.3.0",
|
||||
"eslint-plugin-svelte": "^3.23.0",
|
||||
"globals": "^17.12.0",
|
||||
"hls.js": "^1.7.3",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.3.3",
|
||||
"lint-staged": "^16.4.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"playwright": "^1.58.1",
|
||||
"postgres": "^3.4.8",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.4.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"svelte": "^5.51.0",
|
||||
"svelte-check": "^4.4.5",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.54.0",
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-devtools-json": "^1.0.0",
|
||||
"vitest": "^4.1.0",
|
||||
"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,7 +86,12 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"bancho.js": "^0.12.3",
|
||||
"discord.js": "^14.25.1"
|
||||
"@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
13
prod-server.ts
Normal 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');
|
||||
21
scripts/bootstrap-dev-database.sql
Normal file
21
scripts/bootstrap-dev-database.sql
Normal 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;
|
||||
135
scripts/deploy-remote.sh
Executable file
135
scripts/deploy-remote.sh
Executable file
@@ -0,0 +1,135 @@
|
||||
#!/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
|
||||
migration_environment=/etc/esports/prod-migrations.env
|
||||
host_port=3100 ;;
|
||||
/opt/esports-staging:https://esports-staging.vash.software)
|
||||
environment=/etc/esports-staging/staging.env
|
||||
migration_environment=/etc/esports/staging-migrations.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
|
||||
health=$(mktemp)
|
||||
if [ "${FORCE_DEPLOY:-}" != 1 ] && curl --fail --silent --show-error "http://127.0.0.1:${port}/api/health" > "$health"; then
|
||||
HEALTH_FILE=$health python3 - <<'PY'
|
||||
import json, os
|
||||
data = json.load(open(os.environ["HEALTH_FILE"]))
|
||||
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"
|
||||
|
||||
# The app's database role cannot create tables; the migrations file overrides
|
||||
# DATABASE_URL with the owner role and keeps the rest (ROOT_ADMIN_USER_ID).
|
||||
sudo docker run --rm --network backend --env-file "$environment" \
|
||||
--env-file "$migration_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
22
scripts/dev-login.ts
Normal 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());
|
||||
44
scripts/fixtures/dev-forum-post.bbcode
Normal file
44
scripts/fixtures/dev-forum-post.bbcode
Normal 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
30
scripts/migrate-dev.ts
Normal 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
51
scripts/migrate.ts
Normal 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();
|
||||
}
|
||||
47
scripts/migrations/0001-platform.sql
Normal file
47
scripts/migrations/0001-platform.sql
Normal 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()
|
||||
);
|
||||
37
scripts/migrations/0002-auth-connections.sql
Normal file
37
scripts/migrations/0002-auth-connections.sql
Normal 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
|
||||
);
|
||||
20
scripts/migrations/0003-match-runtime.sql
Normal file
20
scripts/migrations/0003-match-runtime.sql
Normal 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);
|
||||
7
scripts/migrations/0004-tournament-branding.sql
Normal file
7
scripts/migrations/0004-tournament-branding.sql
Normal 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;
|
||||
6
scripts/migrations/0005-openskill.sql
Normal file
6
scripts/migrations/0005-openskill.sql
Normal 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;
|
||||
18
scripts/migrations/0006-predictions.sql
Normal file
18
scripts/migrations/0006-predictions.sql
Normal 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);
|
||||
15
scripts/migrations/0007-jsonb-repair.sql
Normal file
15
scripts/migrations/0007-jsonb-repair.sql
Normal 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 $$;
|
||||
13
scripts/migrations/0008-broadcast.sql
Normal file
13
scripts/migrations/0008-broadcast.sql
Normal 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()
|
||||
);
|
||||
4
scripts/migrations/0009-match-lobby-kind.sql
Normal file
4
scripts/migrations/0009-match-lobby-kind.sql
Normal 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');
|
||||
30
scripts/migrations/0010-streaming.sql
Normal file
30
scripts/migrations/0010-streaming.sql
Normal 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);
|
||||
35
scripts/sanitize-dev-copy.sql
Normal file
35
scripts/sanitize-dev-copy.sql
Normal 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;
|
||||
87
scripts/seed-dev-superset.ts
Normal file
87
scripts/seed-dev-superset.ts
Normal 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
517
scripts/seed-dev.ts
Normal 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);
|
||||
});
|
||||
@@ -898,12 +898,10 @@ async function seedMappools(users: UserData[]): Promise<PoolData[]> {
|
||||
|
||||
function selectPoolForElo(pools: PoolData[], avgElo: number): PoolData {
|
||||
const targetSr = Math.max(2, Math.min(8, 2 + avgElo / 700));
|
||||
let best = pools[0];
|
||||
let bestDist = Math.abs(best.avgSr - targetSr);
|
||||
let bestDist = Math.abs(pools[0].avgSr - targetSr);
|
||||
for (const p of pools) {
|
||||
const dist = Math.abs(p.avgSr - targetSr);
|
||||
if (dist < bestDist) {
|
||||
best = p;
|
||||
bestDist = dist;
|
||||
}
|
||||
}
|
||||
@@ -996,8 +994,8 @@ async function seedMatches(
|
||||
let matchState: string;
|
||||
const winnerId: string | null = null;
|
||||
let finishedAt: Date | null = null;
|
||||
let startedAt: Date | null = null;
|
||||
let gamesToPlay = 0;
|
||||
let startedAt: Date | null;
|
||||
let gamesToPlay: number;
|
||||
|
||||
if (state === 'FINISHED') {
|
||||
matchState = 'FINISHED';
|
||||
|
||||
16
scripts/test-auth.ts
Normal file
16
scripts/test-auth.ts
Normal 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
18
src/app.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
41
src/env.ts
Normal 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 }
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -1,140 +1,144 @@
|
||||
// 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'));
|
||||
|
||||
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'));
|
||||
}
|
||||
|
||||
// ── Request logging middleware ──
|
||||
// Paths that are too noisy to log every time (healthchecks, polling)
|
||||
/** 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 {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
ip: event.getClientAddress()
|
||||
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: 600 req/min per IP
|
||||
if (event.url.pathname.startsWith('/api/')) {
|
||||
const ip = event.getClientAddress();
|
||||
const { ok, retryAfter } = checkRateLimit(`api:${ip}`, 600, 60_000);
|
||||
if (!ok) {
|
||||
return new Response('Too Many Requests', {
|
||||
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';
|
||||
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(
|
||||
{
|
||||
@@ -143,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.' };
|
||||
});
|
||||
|
||||
11
src/lib/auth-identity.test.ts
Normal file
11
src/lib/auth-identity.test.ts
Normal 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
21
src/lib/auth-identity.ts
Normal 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;
|
||||
}
|
||||
29
src/lib/availability.test.ts
Normal file
29
src/lib/availability.test.ts
Normal 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
43
src/lib/availability.ts
Normal 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
84
src/lib/bbcode.test.ts
Normal 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('<script>');
|
||||
});
|
||||
|
||||
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
257
src/lib/bbcode.ts
Normal 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]!
|
||||
);
|
||||
}
|
||||
29
src/lib/broadcast/channel.ts
Normal file
29
src/lib/broadcast/channel.ts
Normal 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
247
src/lib/broadcast/demo.ts
Normal 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 };
|
||||
}
|
||||
124
src/lib/broadcast/director.test.ts
Normal file
124
src/lib/broadcast/director.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
68
src/lib/broadcast/director.ts
Normal file
68
src/lib/broadcast/director.ts
Normal 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
471
src/lib/broadcast/layout.ts
Normal 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
|
||||
}
|
||||
};
|
||||
106
src/lib/broadcast/platforms.ts
Normal file
106
src/lib/broadcast/platforms.ts
Normal 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 };
|
||||
}
|
||||
13
src/lib/broadcast/scores.test.ts
Normal file
13
src/lib/broadcast/scores.test.ts
Normal 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);
|
||||
});
|
||||
20
src/lib/broadcast/scores.ts
Normal file
20
src/lib/broadcast/scores.ts
Normal 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)
|
||||
});
|
||||
42
src/lib/broadcast/worker.test.ts
Normal file
42
src/lib/broadcast/worker.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
65
src/lib/broadcast/worker.ts
Normal file
65
src/lib/broadcast/worker.ts
Normal 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
|
||||
};
|
||||
}
|
||||
130
src/lib/components/AvailabilityEditor.svelte
Normal file
130
src/lib/components/AvailabilityEditor.svelte
Normal 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>
|
||||
46
src/lib/components/Avatar.svelte
Normal file
46
src/lib/components/Avatar.svelte
Normal 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}
|
||||
257
src/lib/components/DataTable.svelte
Normal file
257
src/lib/components/DataTable.svelte
Normal file
@@ -0,0 +1,257 @@
|
||||
<script lang="ts" generics="T">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { TableMeta } from '#lib/server/table.js';
|
||||
|
||||
interface Column {
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
data,
|
||||
meta,
|
||||
columns,
|
||||
row,
|
||||
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);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const offset = $derived((meta.page - 1) * meta.limit);
|
||||
const showingStart = $derived(meta.total === 0 ? 0 : offset + 1);
|
||||
const showingEnd = $derived(Math.min(offset + meta.limit, meta.total));
|
||||
|
||||
const pageButtons = $derived.by(() => {
|
||||
const total = meta.totalPages;
|
||||
const current = meta.page;
|
||||
const pages: (number | '...')[] = [];
|
||||
|
||||
if (total <= 7) {
|
||||
for (let i = 1; i <= total; i++) pages.push(i);
|
||||
} else {
|
||||
pages.push(1);
|
||||
if (current > 3) pages.push('...');
|
||||
for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
if (current < total - 2) pages.push('...');
|
||||
pages.push(total);
|
||||
}
|
||||
return pages;
|
||||
});
|
||||
|
||||
function updateParams(updates: Record<string, string>) {
|
||||
const url = new URL(page.url.href);
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value) {
|
||||
url.searchParams.set(key, value);
|
||||
} else {
|
||||
url.searchParams.delete(key);
|
||||
}
|
||||
}
|
||||
// reset to page 1 when changing search/sort/filter
|
||||
if (!('page' in updates)) {
|
||||
url.searchParams.set('page', '1');
|
||||
}
|
||||
goto(url.toString(), { reset: false });
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
updateParams({ search: searchInput });
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function toggleSort(key: string) {
|
||||
if (meta.sortBy === key) {
|
||||
updateParams({ sortBy: key, sortDir: meta.sortDir === 'asc' ? 'desc' : 'asc' });
|
||||
} else {
|
||||
updateParams({ sortBy: key, sortDir: 'desc' });
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(p: number) {
|
||||
updateParams({ page: String(p) });
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- search bar -->
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div class="relative flex-1">
|
||||
<svg
|
||||
class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-text-secondary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
bind:value={searchInput}
|
||||
oninput={handleSearch}
|
||||
placeholder={searchPlaceholder}
|
||||
class="w-full rounded-md border border-border bg-surface-800 py-2 pr-3 pl-9 text-sm text-text-primary transition-colors outline-none placeholder:text-text-secondary focus:border-text-secondary"
|
||||
/>
|
||||
</div>
|
||||
<span class="shrink-0 text-xs text-text-secondary"
|
||||
>{meta.total} result{meta.total !== 1 ? 's' : ''}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
{#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}
|
||||
</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}
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<span class="text-xs text-text-secondary">
|
||||
Showing {showingStart}–{showingEnd} of {meta.total}
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
disabled={meta.page <= 1}
|
||||
onclick={() => goToPage(meta.page - 1)}
|
||||
class="rounded-md px-2.5 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-700 hover:text-text-primary disabled:pointer-events-none disabled:opacity-30"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
{#each pageButtons as p}
|
||||
{#if p === '...'}
|
||||
<span class="px-1 text-xs text-text-secondary">...</span>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => goToPage(p as number)}
|
||||
class="rounded-md px-2.5 py-1.5 text-xs transition-colors {p === meta.page
|
||||
? 'bg-accent font-semibold text-surface-900'
|
||||
: 'text-text-secondary hover:bg-surface-700 hover:text-text-primary'}"
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
<button
|
||||
disabled={meta.page >= meta.totalPages}
|
||||
onclick={() => goToPage(meta.page + 1)}
|
||||
class="rounded-md px-2.5 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-700 hover:text-text-primary disabled:pointer-events-none disabled:opacity-30"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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
|
||||
|
||||
71
src/lib/components/HlsPlayer.svelte
Normal file
71
src/lib/components/HlsPlayer.svelte
Normal 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>
|
||||
158
src/lib/components/MatchRow.svelte
Normal file
158
src/lib/components/MatchRow.svelte
Normal 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>
|
||||
210
src/lib/components/PredictionCard.svelte
Normal file
210
src/lib/components/PredictionCard.svelte
Normal 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>
|
||||
101
src/lib/components/ProfileComments.svelte
Normal file
101
src/lib/components/ProfileComments.svelte
Normal file
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import Avatar from '#lib/components/Avatar.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import { page } from '$app/state';
|
||||
|
||||
type Comment = {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string | Date;
|
||||
author: { id: string; name: string; image: string | null };
|
||||
};
|
||||
|
||||
const { comments, actionUrl }: { comments: Comment[]; actionUrl: string } = $props();
|
||||
|
||||
const user = $derived(page.data?.user);
|
||||
let content = $state('');
|
||||
let posting = $state(false);
|
||||
|
||||
function timeAgo(date: string | Date) {
|
||||
const d = new Date(date);
|
||||
const diff = Date.now() - d.getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mt-6">
|
||||
<h2 class="font-600 text-sm text-text-secondary">Comments</h2>
|
||||
|
||||
{#if user}
|
||||
<form
|
||||
method="POST"
|
||||
action={actionUrl}
|
||||
use:enhance={() => {
|
||||
posting = true;
|
||||
return async ({ result, update }) => {
|
||||
posting = false;
|
||||
if (result.type === 'success') {
|
||||
content = '';
|
||||
await update();
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="mt-3"
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
<Avatar src={user.image} name={user.name ?? '?'} size={32} />
|
||||
<div class="flex-1">
|
||||
<textarea
|
||||
name="content"
|
||||
bind:value={content}
|
||||
placeholder="Leave a comment..."
|
||||
rows="2"
|
||||
class="w-full resize-none rounded-lg border border-border bg-surface-800 px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
|
||||
></textarea>
|
||||
<div class="mt-1.5 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={posting || !content.trim()}
|
||||
class="font-600 rounded-md bg-accent px-3 py-1.5 text-xs text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{posting ? 'Posting...' : 'Post'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<p class="mt-3 text-sm text-text-secondary">Sign in to leave a comment.</p>
|
||||
{/if}
|
||||
|
||||
{#if comments.length > 0}
|
||||
<div class="mt-4 flex flex-col gap-3">
|
||||
{#each comments as comment}
|
||||
<div class="flex gap-3">
|
||||
<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"
|
||||
>{comment.author.name}</a
|
||||
>
|
||||
<span class="text-[10px] text-text-secondary/50">{timeAgo(comment.createdAt)}</span>
|
||||
</div>
|
||||
<p class="mt-0.5 text-sm break-words whitespace-pre-wrap text-text-secondary">
|
||||
{comment.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if !user}
|
||||
<p class="mt-4 text-sm text-text-secondary/50">No comments yet.</p>
|
||||
{/if}
|
||||
</div>
|
||||
29
src/lib/components/ProviderIcon.svelte
Normal file
29
src/lib/components/ProviderIcon.svelte
Normal 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}
|
||||
255
src/lib/components/SearchModal.svelte
Normal file
255
src/lib/components/SearchModal.svelte
Normal file
@@ -0,0 +1,255 @@
|
||||
<script lang="ts">
|
||||
import Avatar from '#lib/components/Avatar.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
name: string | null;
|
||||
type: string;
|
||||
href: string;
|
||||
image?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
state?: string | null;
|
||||
}
|
||||
|
||||
let { open = $bindable(false) }: { open: boolean } = $props();
|
||||
let query = $state('');
|
||||
let results = $state<Record<string, SearchResult[]>>({});
|
||||
let loading = $state(false);
|
||||
let selectedIndex = $state(0);
|
||||
let inputRef = $state<HTMLInputElement | null>(null);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const flatResults = $derived(Object.values(results).flat());
|
||||
|
||||
const typeIcons: Record<string, string> = {
|
||||
users: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
|
||||
teams:
|
||||
'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
|
||||
mappools:
|
||||
'M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3',
|
||||
tournaments:
|
||||
'M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z',
|
||||
matches:
|
||||
'M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
|
||||
};
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
users: 'Users',
|
||||
teams: 'Teams',
|
||||
mappools: 'Mappools',
|
||||
tournaments: 'Tournaments',
|
||||
matches: 'Matches'
|
||||
};
|
||||
|
||||
function openModal() {
|
||||
open = true;
|
||||
query = '';
|
||||
results = {};
|
||||
selectedIndex = 0;
|
||||
}
|
||||
|
||||
// Focus input whenever modal opens (whether via cmd+k or click)
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
// wait for DOM to render the modal
|
||||
setTimeout(() => inputRef?.focus(), 10);
|
||||
}
|
||||
});
|
||||
|
||||
function closeModal() {
|
||||
open = false;
|
||||
query = '';
|
||||
results = {};
|
||||
}
|
||||
|
||||
function handleGlobalKeydown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
if (open) {
|
||||
closeModal();
|
||||
} else {
|
||||
openModal();
|
||||
}
|
||||
} else if (e.key === 'Escape' && open) {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
function handleModalKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
selectedIndex = Math.min(selectedIndex + 1, flatResults.length - 1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
selectedIndex = Math.max(selectedIndex - 1, 0);
|
||||
} else if (e.key === 'Enter' && flatResults[selectedIndex]) {
|
||||
e.preventDefault();
|
||||
navigateTo(flatResults[selectedIndex].href);
|
||||
}
|
||||
}
|
||||
|
||||
function navigateTo(href: string) {
|
||||
closeModal();
|
||||
goto(href);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (query.length < 2) {
|
||||
results = {};
|
||||
return;
|
||||
}
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(async () => {
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
results = data.results;
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
selectedIndex = 0;
|
||||
}, 250);
|
||||
});
|
||||
|
||||
// compute a flat index for each item to support keyboard selection
|
||||
function getFlatIndex(type: string, itemIndex: number): number {
|
||||
const types = Object.keys(results);
|
||||
let idx = 0;
|
||||
for (const t of types) {
|
||||
if (t === type) return idx + itemIndex;
|
||||
idx += results[t].length;
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleGlobalKeydown} />
|
||||
|
||||
{#if open}
|
||||
<!-- backdrop -->
|
||||
<button
|
||||
class="fixed inset-0 z-[60] bg-black/60 backdrop-blur-sm"
|
||||
onclick={closeModal}
|
||||
aria-label="Close search"
|
||||
tabindex="-1"
|
||||
></button>
|
||||
|
||||
<!-- modal -->
|
||||
<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}
|
||||
>
|
||||
<!-- search input -->
|
||||
<div class="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||
<svg
|
||||
class="h-5 w-5 shrink-0 text-text-secondary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
bind:this={inputRef}
|
||||
bind:value={query}
|
||||
placeholder="Search users, teams, tournaments..."
|
||||
class="flex-1 bg-transparent text-sm text-text-primary outline-none placeholder:text-text-secondary"
|
||||
/>
|
||||
{#if loading}
|
||||
<div
|
||||
class="h-4 w-4 animate-spin rounded-full border-2 border-text-secondary border-t-transparent"
|
||||
></div>
|
||||
{:else}
|
||||
<kbd
|
||||
class="rounded bg-surface-700 px-1.5 py-0.5 text-[10px] font-medium text-text-secondary"
|
||||
>ESC</kbd
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- results -->
|
||||
<div class="max-h-80 overflow-y-auto">
|
||||
{#if query.length < 2}
|
||||
<div class="px-4 py-8 text-center text-sm text-text-secondary">
|
||||
Type at least 2 characters to search
|
||||
</div>
|
||||
{:else if !loading && flatResults.length === 0 && query.length >= 2}
|
||||
<div class="px-4 py-8 text-center text-sm text-text-secondary">
|
||||
No results for "{query}"
|
||||
</div>
|
||||
{:else}
|
||||
{#each Object.entries(results) as [type, items]}
|
||||
{#if items.length > 0}
|
||||
<div class="px-4 pt-3 pb-1">
|
||||
<span class="text-[10px] font-semibold tracking-wider text-text-secondary uppercase"
|
||||
>{typeLabels[type] ?? type}</span
|
||||
>
|
||||
</div>
|
||||
{#each items as item, i}
|
||||
{@const flatIdx = getFlatIndex(type, i)}
|
||||
<button
|
||||
class="flex w-full items-center gap-3 px-4 py-2 text-left text-sm transition-colors {flatIdx ===
|
||||
selectedIndex
|
||||
? 'bg-surface-700 text-text-primary'
|
||||
: 'text-text-secondary hover:bg-surface-700 hover:text-text-primary'}"
|
||||
onclick={() => navigateTo(item.href)}
|
||||
onmouseenter={() => (selectedIndex = flatIdx)}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d={typeIcons[type] ?? ''} />
|
||||
</svg>
|
||||
{#if item.image || item.avatarUrl}
|
||||
<Avatar src={item.image ?? item.avatarUrl} name={item.name ?? '?'} size={20} />
|
||||
{/if}
|
||||
<span class="flex-1 truncate">{item.name ?? 'Unnamed'}</span>
|
||||
{#if item.state}
|
||||
<span
|
||||
class="rounded bg-surface-600 px-1.5 py-0.5 text-[10px] font-medium text-text-secondary"
|
||||
>{item.state}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- footer -->
|
||||
<div
|
||||
class="flex items-center gap-4 border-t border-border px-4 py-2 text-[10px] text-text-secondary"
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
<kbd class="rounded bg-surface-700 px-1 py-0.5 font-mono">↑↓</kbd> navigate
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<kbd class="rounded bg-surface-700 px-1 py-0.5 font-mono">↵</kbd> select
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<kbd class="rounded bg-surface-700 px-1 py-0.5 font-mono">esc</kbd> close
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,12 +1,14 @@
|
||||
<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) } = $props();
|
||||
let { open = $bindable(false), guest = false } = $props();
|
||||
let notifOpen = $state(false);
|
||||
let notifications = $state<any[]>([]);
|
||||
let loadingNotifs = $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"
|
||||
@@ -106,6 +125,22 @@
|
||||
height="7"
|
||||
/><rect x="3" y="14" width="7" height="7" /></svg
|
||||
>
|
||||
{:else if name === 'tournaments'}
|
||||
<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="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" /><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" /><path
|
||||
d="M4 22h16"
|
||||
/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22" /><path
|
||||
d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"
|
||||
/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" /></svg
|
||||
>
|
||||
{:else if name === 'matches'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -216,7 +251,7 @@
|
||||
<span class="font-700 text-base font-bold tracking-tight text-text-primary">Vash Esports</span
|
||||
>
|
||||
</a>
|
||||
{#if user}
|
||||
{#if user && !guest}
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={toggleNotifications}
|
||||
@@ -314,6 +349,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Queue Button -->
|
||||
{#if !guest}
|
||||
<div class="px-3 pb-4">
|
||||
{#if activeMatch && page.url.pathname !== `/matches/${activeMatch.id}`}
|
||||
<a
|
||||
@@ -334,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"
|
||||
@@ -361,14 +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: '/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(
|
||||
@@ -379,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(
|
||||
@@ -405,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"
|
||||
@@ -471,6 +524,7 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if !guest}
|
||||
<a
|
||||
href="/settings"
|
||||
class="font-500 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors {isActive(
|
||||
@@ -482,6 +536,7 @@
|
||||
{@render navIcon('settings')}
|
||||
Settings
|
||||
</a>
|
||||
{/if}
|
||||
<div class="mt-2 flex gap-3 px-3 pb-1">
|
||||
<a
|
||||
href="/terms"
|
||||
|
||||
37
src/lib/components/broadcast/Chat.svelte
Normal file
37
src/lib/components/broadcast/Chat.svelte
Normal 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}
|
||||
29
src/lib/components/broadcast/Clock.svelte
Normal file
29
src/lib/components/broadcast/Clock.svelte
Normal 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>
|
||||
13
src/lib/components/broadcast/Gameplay.svelte
Normal file
13
src/lib/components/broadcast/Gameplay.svelte
Normal 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>
|
||||
46
src/lib/components/broadcast/History.svelte
Normal file
46
src/lib/components/broadcast/History.svelte
Normal 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}
|
||||
14
src/lib/components/broadcast/Image.svelte
Normal file
14
src/lib/components/broadcast/Image.svelte
Normal 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}
|
||||
77
src/lib/components/broadcast/LiveScore.svelte
Normal file
77
src/lib/components/broadcast/LiveScore.svelte
Normal 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}
|
||||
74
src/lib/components/broadcast/Mappool.svelte
Normal file
74
src/lib/components/broadcast/Mappool.svelte
Normal 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}
|
||||
57
src/lib/components/broadcast/NowPlaying.svelte
Normal file
57
src/lib/components/broadcast/NowPlaying.svelte
Normal 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}
|
||||
116
src/lib/components/broadcast/Overlay.svelte
Normal file
116
src/lib/components/broadcast/Overlay.svelte
Normal 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>
|
||||
33
src/lib/components/broadcast/Panel.svelte
Normal file
33
src/lib/components/broadcast/Panel.svelte
Normal 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>
|
||||
64
src/lib/components/broadcast/Players.svelte
Normal file
64
src/lib/components/broadcast/Players.svelte
Normal 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}
|
||||
55
src/lib/components/broadcast/Predictions.svelte
Normal file
55
src/lib/components/broadcast/Predictions.svelte
Normal 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}
|
||||
15
src/lib/components/broadcast/PresetPreview.svelte
Normal file
15
src/lib/components/broadcast/PresetPreview.svelte
Normal 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>
|
||||
42
src/lib/components/broadcast/Schedule.svelte
Normal file
42
src/lib/components/broadcast/Schedule.svelte
Normal 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>
|
||||
93
src/lib/components/broadcast/Scoreboard.svelte
Normal file
93
src/lib/components/broadcast/Scoreboard.svelte
Normal 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}
|
||||
20
src/lib/components/broadcast/Text.svelte
Normal file
20
src/lib/components/broadcast/Text.svelte
Normal 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>
|
||||
66
src/lib/components/broadcast/Ticker.svelte
Normal file
66
src/lib/components/broadcast/Ticker.svelte
Normal 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>
|
||||
50
src/lib/components/broadcast/Turn.svelte
Normal file
50
src/lib/components/broadcast/Turn.svelte
Normal 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}
|
||||
57
src/lib/components/broadcast/Winner.svelte
Normal file
57
src/lib/components/broadcast/Winner.svelte
Normal 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}
|
||||
16
src/lib/components/broadcast/context.ts
Normal file
16
src/lib/components/broadcast/context.ts
Normal 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');
|
||||
156
src/lib/components/live/LiveCard.svelte
Normal file
156
src/lib/components/live/LiveCard.svelte
Normal 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>
|
||||
173
src/lib/components/match/ActionDialog.svelte
Normal file
173
src/lib/components/match/ActionDialog.svelte
Normal 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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user