feat: redesign match play and add broadcast tooling
This commit is contained in:
@@ -2,3 +2,5 @@ node_modules
|
||||
.svelte-kit
|
||||
.git
|
||||
build
|
||||
dist
|
||||
streamer
|
||||
|
||||
101
.env.example
101
.env.example
@@ -1,52 +1,89 @@
|
||||
# Copy this file to .env and fill in the values before running the application.
|
||||
|
||||
# The origin URL of the frontend application, used for CORS and constructing links
|
||||
# In production, set it to your frontend URL, e.g. https://esports.vash.software
|
||||
ORIGIN="http://localhost:5173"
|
||||
# Public origin. Also decides the environment when APP_ENV is not set:
|
||||
# dev http://localhost:3102 or https://esports-dev.vash.software
|
||||
# staging https://esports-staging.vash.software
|
||||
# prod https://esports.vash.software
|
||||
ORIGIN="http://localhost:3102"
|
||||
# dev | staging | prod. Dev and staging prefix page titles, show an environment
|
||||
# badge, and enable the simulation lab. Leave unset to derive it from ORIGIN.
|
||||
APP_ENV=""
|
||||
|
||||
# The email of the root admin user, with global full permissions.
|
||||
ROOT_ADMIN_EMAIL=""
|
||||
# Postgres in the shared instance, one database and login role per environment:
|
||||
# vash (prod), vash_staging, vash_dev on 127.0.0.1:5440.
|
||||
DB_URL="postgresql://vash_dev:password@127.0.0.1:5440/vash_dev"
|
||||
DATABASE_URL="postgresql://vash_dev:password@127.0.0.1:5440/vash_dev"
|
||||
|
||||
# Docker Compose database password (used in docker-compose.yml)
|
||||
DB_PASSWORD=""
|
||||
DATABASE_URL="postgres://user:password@host:port/db-name"
|
||||
# Lobbies: simulator | irc. Unset means irc in production and simulator
|
||||
# everywhere else, so dev and staging never log production's osu! account out.
|
||||
BANCHO_MODE="simulator"
|
||||
|
||||
# Better Auth secret key, used for signing JWTs and other cryptographic operations.
|
||||
# For production use 32 characters and generated with high entropy
|
||||
# https://www.better-auth.com/docs/installation
|
||||
# One object per osu! account. A normal account hosts 4 tournament lobbies;
|
||||
# add friends' accounts to host more at once. botAccount raises rate limits.
|
||||
# OSU_IRC_ACCOUNTS='[{"id":"main","username":"player","password":"irc-password","maxLobbies":4}]'
|
||||
# Single-account shorthand (https://osu.ppy.sh/home/account/edit#legacy-api):
|
||||
OSU_IRC_USERNAME=""
|
||||
OSU_IRC_PASSWORD=""
|
||||
# osu! account signed in to the autonomous tourney streamer; Vash adds it as a room referee.
|
||||
OSU_TOURNEY_ACCOUNT=""
|
||||
# Another IRC server, such as a private Bancho or a local fake. Defaults to irc.ppy.sh:6667.
|
||||
# OSU_IRC_HOST=""
|
||||
# OSU_IRC_PORT=""
|
||||
|
||||
# Stable Better Auth user ID of the root admin. The migration stores this role
|
||||
# in the database; no OAuth or email-shaped identity is used for authorization.
|
||||
ROOT_ADMIN_USER_ID=""
|
||||
|
||||
# Better Auth secret: 32+ random characters, e.g. `openssl rand -hex 32`.
|
||||
BETTER_AUTH_SECRET=""
|
||||
|
||||
# Cloudflare R2 image storage
|
||||
# Create a new bucket and credentials at https://dash.cloudflare.com/r2
|
||||
# Make sure to set the bucket has public access
|
||||
R2_ACCOUNT_ID="" # Your Cloudflare Account ID
|
||||
R2_ACCESS_KEY_ID="" # R2 API token Access Key ID
|
||||
R2_SECRET_ACCESS_KEY="" # R2 API token Secret Access Key
|
||||
R2_BUCKET="vash-esports-images" # Bucket name, e.g. vash-esports-images
|
||||
R2_PUBLIC_URL="https://cdn.esports.vash.software" # Public URL for the bucket, e.g. https://cdn.esports.vash.software
|
||||
# Email registration, verification, recovery, and email changes become available
|
||||
# when both are set. Social sign-in does not require either value.
|
||||
RESEND_API_KEY=""
|
||||
EMAIL_FROM="Vash Esports <auth@esports.vash.software>"
|
||||
|
||||
# osu!-related
|
||||
# Create an osu! API application here: https://osu.ppy.sh/home/account/edit#oauth
|
||||
# Cloudflare R2 image storage (team avatars, tournament icons and banners).
|
||||
# The bucket needs public access at R2_PUBLIC_URL.
|
||||
R2_ACCOUNT_ID=""
|
||||
R2_ACCESS_KEY_ID=""
|
||||
R2_SECRET_ACCESS_KEY=""
|
||||
R2_BUCKET="vash-esports-images"
|
||||
R2_PUBLIC_URL="https://cdn.esports.vash.software"
|
||||
|
||||
# osu! OAuth application: https://osu.ppy.sh/home/account/edit#oauth
|
||||
# Callback: <ORIGIN>/api/auth/oauth2/callback/osu
|
||||
OSU_CLIENT_ID=""
|
||||
OSU_CLIENT_SECRET=""
|
||||
|
||||
# Discord-related
|
||||
# Create a Discord application here: https://discord.com/developers/applications
|
||||
# "Sign in with Discord" application (not the bot).
|
||||
DISCORD_CLIENT_ID=""
|
||||
DISCORD_CLIENT_SECRET=""
|
||||
# The notification bot. Several environments may share one token: the bot only
|
||||
# posts. Point dev and staging at a private channel such as #dev-channel.
|
||||
DISCORD_BOT_TOKEN=""
|
||||
# Optional; read from the token when unset. Used for the "Add bot" link on /admin.
|
||||
DISCORD_BOT_CLIENT_ID=""
|
||||
DISCORD_GUILD_ID=""
|
||||
# Match results (no pings) and stream announcements (@everyone in a channel that
|
||||
# members with the "no stream notifications" role cannot read).
|
||||
DISCORD_MATCH_CHANNEL_ID=""
|
||||
DISCORD_STREAM_CHANNEL_ID=""
|
||||
|
||||
# GitHub-related
|
||||
# Create a GitHub OAuth app here: https://github.com/settings/developers
|
||||
GITHUB_CLIENT_ID=""
|
||||
GITHUB_CLIENT_SECRET=""
|
||||
|
||||
# Google-related
|
||||
# Create a Google OAuth app here: https://console.cloud.google.com/apis/credentials
|
||||
# Google OAuth: https://console.cloud.google.com/apis/credentials
|
||||
GOOGLE_CLIENT_ID=""
|
||||
GOOGLE_CLIENT_SECRET=""
|
||||
|
||||
# Sentry error tracking (https://sentry.io)
|
||||
PUBLIC_SENTRY_DSN="" # DSN is write-only, safe to expose client-side
|
||||
SENTRY_AUTH_TOKEN="" # Only needed in CI for source map uploads
|
||||
PUBLIC_SENTRY_DSN=""
|
||||
# Only CI needs this, to upload source maps. Keep it out of local builds.
|
||||
SENTRY_AUTH_TOKEN=""
|
||||
# Fraction of requests traced. Defaults to 0.1 in prod and 0 elsewhere.
|
||||
SENTRY_TRACES_SAMPLE_RATE=""
|
||||
|
||||
# Logging: LOG_LEVEL=debug for more detail, LOG_SQL=1 to log every query.
|
||||
LOG_LEVEL="info"
|
||||
LOG_SQL=""
|
||||
|
||||
# Encrypts streaming platform keys (Broadcast → Stream). Defaults to BETTER_AUTH_SECRET;
|
||||
# set it separately to rotate the auth secret without re-entering stream keys.
|
||||
# STREAM_KEY_SECRET=""
|
||||
|
||||
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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,3 +29,4 @@ test-results
|
||||
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": []
|
||||
}
|
||||
|
||||
38
Dockerfile
38
Dockerfile
@@ -1,31 +1,27 @@
|
||||
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 ./
|
||||
# Patched dependencies are applied during install.
|
||||
COPY patches ./patches
|
||||
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"]
|
||||
|
||||
220
README.md
220
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 check` | Type-check with svelte-check |
|
||||
| `bun run lint` | Lint with Prettier + ESLint |
|
||||
| `bun run test:unit` | Run unit tests (Vitest) |
|
||||
| `bun run test:e2e` | Run e2e tests (Playwright) |
|
||||
| `bun run test` | Run all tests |
|
||||
| `bun run db:push` | Push schema changes to database |
|
||||
| `bun run db:studio` | Open Drizzle Studio (database GUI) |
|
||||
| Command | Description |
|
||||
| ------------------------ | ------------------------------------------------------- |
|
||||
| `bun run dev` | Dev server with HMR on 127.0.0.1:3102 |
|
||||
| `bun run build` | Production build (`SENTRY_AUTH_TOKEN=` to skip uploads) |
|
||||
| `bun run start` | Production server with the match websocket |
|
||||
| `bun run check` | Type-check with svelte-check |
|
||||
| `bun run test:unit` | Unit tests (Vitest) |
|
||||
| `bun run test:streamer` | Streamer agent tests (bun test) |
|
||||
| `bun run streamer` | Run the streamer agent from source |
|
||||
| `bun run streamer:build` | Build `dist/vash-streamer(.exe)` for Linux and Windows |
|
||||
| `bun run db:migrate` | Apply pending SQL migrations |
|
||||
| `bun run db:migrate:dev` | Back up vash_dev, then migrate it |
|
||||
| `bun run db:seed` | Seed the dev database |
|
||||
| `bun run db:studio` | Drizzle Studio |
|
||||
|
||||
## Project Structure
|
||||
## Database changes
|
||||
|
||||
```
|
||||
src/
|
||||
lib/
|
||||
server/
|
||||
match/
|
||||
engine.ts # Match state machine and ELO calculations
|
||||
orchestrator.ts # IRC lobby lifecycle (create, pick, play, results)
|
||||
queue.ts # Ranked queue and matchmaking logic
|
||||
rating.ts # ELO rating system
|
||||
db/
|
||||
schema.ts # Drizzle database schema
|
||||
osu.ts # osu! API v2 client
|
||||
auth.ts # Better Auth configuration
|
||||
routes/
|
||||
(app)/
|
||||
matches/ # Match browser and live match view
|
||||
mappools/ # Mappool creation and management
|
||||
teams/ # Team browser and management
|
||||
leaderboard/ # Player and team rankings
|
||||
admin/ # Admin panel
|
||||
```
|
||||
Schema lives in `src/lib/server/db/schema.ts`. Every change also gets an idempotent SQL file in `scripts/migrations/` (`0005-…sql`, and so on). `bun scripts/migrate.ts` applies the pending files in order, in one transaction, and records them in `schema_migration`; `--status` lists them. The staging and production deploys take a verified `pg_dump` and then run it. Migrations only add: they never drop or rewrite existing users, teams, pools, or match history.
|
||||
|
||||
## Environments
|
||||
|
||||
| | dev | staging | prod |
|
||||
| --------------- | ---------------------------- | ------------------------------------------------- | --------------------------- |
|
||||
| URL | esports-dev.vash.software | esports-staging.vash.software | esports.vash.software |
|
||||
| Runs as | `bun run dev` in tmux, :3102 | Docker, /opt/esports-staging, :3101 | Docker, /opt/esports, :3100 |
|
||||
| Database | vash_dev | vash_staging | vash |
|
||||
| Lobbies | simulator | simulator (set `BANCHO_MODE=irc` to use real IRC) | osu! IRC |
|
||||
| Title and badge | `(DEV)` | `(STAGING)` | none |
|
||||
|
||||
The environment comes from `APP_ENV`, or from `ORIGIN` when that is unset. Dev and staging sit behind Cloudflare Access and are marked noindex.
|
||||
|
||||
## Deployment
|
||||
|
||||
The app deploys via Docker Compose to a single VM. Merges to `staging` and `prod` branches trigger automatic deployments through GitHub Actions.
|
||||
Work on `staging` and push. Gitea runs the unit tests, builds `git.stanrunge.dev/stan/esports:<sha>`, backs up the staging database, migrates it, and starts the image at `/opt/esports-staging`. After checking staging, run the **Deploy production** action: it backs up and migrates the production database and starts the same image at `/opt/esports`.
|
||||
|
||||
```sh
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
docker compose exec app bunx drizzle-kit push --force
|
||||
```
|
||||
Deploys refuse to switch images while `/api/health` reports live matches, unless `FORCE_DEPLOY=1`. Before a deploy, use **Drain before restart** on /admin: no new lobbies open, the queue pauses, and live matches play to the end. After a restart, the runtime rejoins the lobbies it was hosting and continues; a lobby it cannot find pauses its match for a referee.
|
||||
|
||||
See `docker-compose.yml` for the full production setup and `docker-compose.staging.yml` for the staging environment.
|
||||
Environment files are `/etc/esports-staging/staging.env` and `/etc/esports/prod.env`.
|
||||
|
||||
## Discord
|
||||
|
||||
The bot ("Vash Esports", CesarBot) only posts: results to `DISCORD_MATCH_CHANNEL_ID` with no pings, and stream announcements to `DISCORD_STREAM_CHANNEL_ID`. /admin shows whether it is connected, whether it can post in each channel, an **Add bot to server** link, and a **Send test message** button. Several environments can share the token; give dev and staging a private channel.
|
||||
|
||||
## Contributing
|
||||
|
||||
This project uses [conventional commits](https://www.conventionalcommits.org/) enforced by git hooks:
|
||||
|
||||
```
|
||||
feat: add new feature
|
||||
fix: fix a bug
|
||||
chore: maintenance task
|
||||
refactor: code restructuring
|
||||
docs: documentation changes
|
||||
```
|
||||
|
||||
Pre-commit hooks run Prettier and ESLint on staged files. Pre-push hooks run type-checking and unit tests.
|
||||
Conventional commits are enforced by git hooks (`feat:`, `fix:`, `chore:`, `refactor:`, `docs:`). Pre-commit runs Prettier and ESLint on staged files; pre-push runs the type check and unit tests.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
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,83 +0,0 @@
|
||||
# Staging environment — runs alongside production on the same VM.
|
||||
# Completely independent: own database, own app container, different ports.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.staging.yml -p esports-staging up -d --build
|
||||
#
|
||||
# Prerequisites (one-time server setup):
|
||||
# 1. DNS: A record for staging.esports.vash.software → VM IP
|
||||
# 2. SSL: sudo certbot certonly --nginx -d staging.esports.vash.software
|
||||
# 3. nginx: reverse proxy staging.esports.vash.software → 127.0.0.1:3002
|
||||
# 4. osu! OAuth: register a separate app at https://osu.ppy.sh/home/account/edit
|
||||
# with callback URL: https://staging.esports.vash.software/api/auth/oauth2/callback/osu
|
||||
# 5. osu! IRC: get a separate IRC password from https://osu.ppy.sh/p/irc
|
||||
# 6. Copy .env.staging.example → .env.staging and fill in values
|
||||
|
||||
services:
|
||||
staging-db:
|
||||
image: postgres:17
|
||||
container_name: esports-staging-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: vash_staging
|
||||
POSTGRES_USER: vash_staging
|
||||
POSTGRES_PASSWORD: ${STAGING_DB_PASSWORD}
|
||||
env_file:
|
||||
- .env.staging
|
||||
volumes:
|
||||
- pgdata-staging:/var/lib/postgresql/data
|
||||
ports:
|
||||
- '127.0.0.1:5433:5432'
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U vash_staging -d vash_staging']
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 2s
|
||||
|
||||
staging-app:
|
||||
build: .
|
||||
container_name: esports-staging-app
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 2s
|
||||
depends_on:
|
||||
staging-db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- '127.0.0.1:3002:3000'
|
||||
environment:
|
||||
DATABASE_URL: postgresql://vash_staging:${STAGING_DB_PASSWORD}@staging-db:5432/vash_staging
|
||||
ORIGIN: https://esports-staging.vash.software
|
||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
||||
OSU_CLIENT_ID: ${OSU_CLIENT_ID}
|
||||
OSU_CLIENT_SECRET: ${OSU_CLIENT_SECRET}
|
||||
OSU_IRC_USERNAME: ${OSU_IRC_USERNAME}
|
||||
OSU_IRC_PASSWORD: ${OSU_IRC_PASSWORD}
|
||||
DISCORD_BOT_TOKEN: ${DISCORD_BOT_TOKEN}
|
||||
DISCORD_MATCH_CHANNEL_ID: ${DISCORD_MATCH_CHANNEL_ID}
|
||||
R2_ACCOUNT_ID: ${R2_ACCOUNT_ID}
|
||||
R2_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID}
|
||||
R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY}
|
||||
R2_BUCKET: ${R2_BUCKET:-vash-esports-images}
|
||||
R2_PUBLIC_URL: ${R2_PUBLIC_URL:-https://cdn.esports.vash.software}
|
||||
ROOT_ADMIN_EMAIL: ${ROOT_ADMIN_EMAIL}
|
||||
PUBLIC_SENTRY_DSN: ${PUBLIC_SENTRY_DSN}
|
||||
ADDRESS_HEADER: CF-Connecting-IP
|
||||
NODE_ENV: production
|
||||
env_file:
|
||||
- .env.staging
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD',
|
||||
'bun',
|
||||
'-e',
|
||||
"fetch('http://localhost:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
pgdata-staging:
|
||||
@@ -1,106 +1,4 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:17
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: vash
|
||||
POSTGRES_USER: vash
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- '127.0.0.1:5432:5432' # Only accessible from host
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U vash -d vash']
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 2s
|
||||
|
||||
app:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 2s
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- '127.0.0.1:3000:3000' # Only accessible from host (nginx proxies)
|
||||
environment:
|
||||
DATABASE_URL: postgresql://vash:${DB_PASSWORD}@db:5432/vash
|
||||
ORIGIN: ${ORIGIN:-https://esports.vash.software}
|
||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
||||
OSU_CLIENT_ID: ${OSU_CLIENT_ID}
|
||||
OSU_CLIENT_SECRET: ${OSU_CLIENT_SECRET}
|
||||
OSU_IRC_USERNAME: ${OSU_IRC_USERNAME}
|
||||
OSU_IRC_PASSWORD: ${OSU_IRC_PASSWORD}
|
||||
PUBLIC_SENTRY_DSN: ${PUBLIC_SENTRY_DSN}
|
||||
ADDRESS_HEADER: CF-Connecting-IP
|
||||
NODE_ENV: production
|
||||
env_file:
|
||||
- .env
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD',
|
||||
'bun',
|
||||
'-e',
|
||||
"fetch('http://localhost:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
umami-db:
|
||||
image: postgres:17
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: umami
|
||||
POSTGRES_USER: umami
|
||||
POSTGRES_PASSWORD: ${UMAMI_DB_PASSWORD}
|
||||
volumes:
|
||||
- umamidata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U umami -d umami']
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 2s
|
||||
|
||||
umami:
|
||||
image: ghcr.io/umami-software/umami:postgresql-latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
umami-db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- '127.0.0.1:3001:3000' # Proxy via nginx at e.g. analytics.vash.software
|
||||
environment:
|
||||
DATABASE_URL: postgresql://umami:${UMAMI_DB_PASSWORD}@umami-db:5432/umami
|
||||
APP_SECRET: ${UMAMI_APP_SECRET}
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD-SHELL',
|
||||
'node -e "fetch(\"http://localhost:3000/api/heartbeat\").then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"'
|
||||
]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
dozzle:
|
||||
image: amir20/dozzle:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '127.0.0.1:8080:8080' # Proxy via nginx at logs.vash.software
|
||||
environment:
|
||||
DOZZLE_AUTH_PROVIDER: simple
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./dozzle-users.yml:/data/users.yml:ro
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
umamidata:
|
||||
# Vash Esports does not run its own Postgres container.
|
||||
# Production and staging use compose.prod.yml: one app container on the
|
||||
# external Docker network "backend", talking to the central database.
|
||||
# Dev is `bun run dev` on 127.0.0.1:3102.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
98
package.json
98
package.json
@@ -4,64 +4,75 @@
|
||||
"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",
|
||||
"streamer:build": "bun build streamer/agent.ts --compile --minify --target=bun-windows-x64 --outfile dist/vash-streamer.exe && bun build streamer/agent.ts --compile --minify --target=bun-linux-x64 --outfile dist/vash-streamer",
|
||||
"db:seed": "bun scripts/seed-dev.ts",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:migrate": "bun scripts/migrate.ts",
|
||||
"db:migrate:dev": "bun scripts/migrate-dev.ts",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"auth:schema": "bun x @better-auth/cli generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.5.0",
|
||||
"@commitlint/config-conventional": "^20.5.0",
|
||||
"@eslint/compat": "^2.0.3",
|
||||
"@commitlint/cli": "^20.5.3",
|
||||
"@commitlint/config-conventional": "^20.5.3",
|
||||
"@eslint/compat": "^2.1.1",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@sentry/sveltekit": "^10.46.0",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@sveltejs/kit": "^2.55.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/bun": "^1.3.11",
|
||||
"@types/node": "^25.5.0",
|
||||
"@vitest/browser-playwright": "^4.1.1",
|
||||
"better-auth": "^1.5.6",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"eslint": "^10.1.0",
|
||||
"@lucide/svelte": "^1.48.0",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@sentry/sveltekit": "^10.75.3",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"@sveltejs/kit": "^2.70.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/bun": "^1.4.2",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^25.9.8",
|
||||
"better-auth": "~1.5.6",
|
||||
"bits-ui": "^2.19.3",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-kit": "^0.31.11",
|
||||
"drizzle-orm": "^0.45.3",
|
||||
"eslint": "^10.11.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.16.0",
|
||||
"globals": "^17.4.0",
|
||||
"eslint-plugin-svelte": "^3.23.0",
|
||||
"globals": "^17.12.0",
|
||||
"hls.js": "^1.7.3",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.4.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"playwright": "^1.58.2",
|
||||
"postgres": "^3.4.8",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.5.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"svelte": "^5.55.0",
|
||||
"svelte-check": "^4.4.5",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "~5.8",
|
||||
"typescript-eslint": "^8.57.2",
|
||||
"vite": "^8.0.2",
|
||||
"vite-plugin-devtools-json": "^1.0.0",
|
||||
"vitest": "^4.1.1",
|
||||
"vitest-browser-svelte": "^2.1.0"
|
||||
"prettier": "^3.9.9",
|
||||
"prettier-plugin-svelte": "^3.5.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.4",
|
||||
"shadcn-svelte": "^1.7.0",
|
||||
"svelte": "^5.57.1",
|
||||
"svelte-check": "^4.7.6",
|
||||
"svelte-sonner": "^1.2.1",
|
||||
"tailwind-merge": "^3.7.0",
|
||||
"tailwind-variants": "^3.3.1",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.70.1",
|
||||
"vite": "^8.3.1",
|
||||
"vite-plugin-devtools-json": "^1.1.0",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,svelte}": [
|
||||
@@ -73,8 +84,11 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"bancho.js": "^0.12.3",
|
||||
"discord.js": "^14.25.1",
|
||||
"zod": "^4.3.6"
|
||||
"@bbob/parser": "^4.4.1",
|
||||
"discord.js": "^14.27.0",
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@sveltejs/kit@2.70.3": "patches/@sveltejs%2Fkit@2.70.3.patch"
|
||||
}
|
||||
}
|
||||
|
||||
15
patches/@sveltejs%2Fkit@2.70.3.patch
Normal file
15
patches/@sveltejs%2Fkit@2.70.3.patch
Normal file
@@ -0,0 +1,15 @@
|
||||
diff --git a/src/core/sync/create_manifest_data/index.js b/src/core/sync/create_manifest_data/index.js
|
||||
index 6e97903682f448a9c95a01527530bd0bdb545174..d233d9c10da549f31bb4d4ff48204bb9f9b4a9e9 100644
|
||||
--- a/src/core/sync/create_manifest_data/index.js
|
||||
+++ b/src/core/sync/create_manifest_data/index.js
|
||||
@@ -222,7 +222,9 @@ function create_routes_and_nodes(cwd, config, fallback) {
|
||||
|
||||
// We can't use withFileTypes because of a NodeJs bug which returns wrong results
|
||||
// with isDirectory() in case of symlinks: https://github.com/nodejs/node/issues/30646
|
||||
- const files = fs.readdirSync(dir).map((name) => ({
|
||||
+ // Sorted so route nodes get the same numbers under every runtime: Node
|
||||
+ // returns directory entries sorted, Bun returns them in on-disk order.
|
||||
+ const files = fs.readdirSync(dir).sort().map((name) => ({
|
||||
is_dir: fs.statSync(path.join(dir, name)).isDirectory(),
|
||||
name
|
||||
}));
|
||||
11
prod-server.ts
Normal file
11
prod-server.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { handler } from './build/handler.js';
|
||||
import { attachSockets } from './src/lib/server/ws-attach.ts';
|
||||
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
const host = process.env.HOST || '0.0.0.0';
|
||||
const server = createServer(handler);
|
||||
await attachSockets(server);
|
||||
server.listen(port, host, () => {
|
||||
console.log(`Vash Esports listening on ${host}:${port}`);
|
||||
});
|
||||
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;
|
||||
129
scripts/deploy-remote.sh
Executable file
129
scripts/deploy-remote.sh
Executable file
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
sha=${1:?commit SHA required}
|
||||
directory=${2:?deployment directory required}
|
||||
origin=${3:?public origin required}
|
||||
case "$sha" in
|
||||
*[!0-9a-f]*|'') echo 'Invalid image tag' >&2; exit 2 ;;
|
||||
esac
|
||||
case "$directory:$origin" in
|
||||
/opt/esports:https://esports.vash.software)
|
||||
environment=/etc/esports/prod.env
|
||||
host_port=3100 ;;
|
||||
/opt/esports-staging:https://esports-staging.vash.software)
|
||||
environment=/etc/esports-staging/staging.env
|
||||
host_port=3101 ;;
|
||||
*) echo 'Unrecognized deployment target' >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
image="git.stanrunge.dev/stan/esports:$sha"
|
||||
compose="$directory/compose.prod.yml"
|
||||
if [ "$directory" = /opt/esports-staging ]; then
|
||||
environment_name=staging
|
||||
else
|
||||
environment_name=production
|
||||
fi
|
||||
|
||||
sudo install -d -m 0755 "$directory"
|
||||
if [ ! -f "$directory/release.env" ]; then
|
||||
printf 'IMAGE_TAG=%s\nHOST_PORT=%s\n' "$sha" "$host_port" | sudo tee "$directory/release.env" >/dev/null
|
||||
fi
|
||||
if ! grep -q '^HOST_PORT=' "$directory/release.env"; then
|
||||
echo "HOST_PORT=$host_port" | sudo tee -a "$directory/release.env" >/dev/null
|
||||
fi
|
||||
|
||||
sudo tee "$compose" >/dev/null <<EOF
|
||||
services:
|
||||
app:
|
||||
image: git.stanrunge.dev/stan/esports:\${IMAGE_TAG}
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
env_file:
|
||||
- path: $environment
|
||||
format: raw
|
||||
environment:
|
||||
PORT: 3000
|
||||
HOST: 0.0.0.0
|
||||
BODY_SIZE_LIMIT: 6M
|
||||
PROTOCOL_HEADER: x-forwarded-proto
|
||||
HOST_HEADER: x-forwarded-host
|
||||
ADDRESS_HEADER: CF-Connecting-IP
|
||||
ports:
|
||||
- 127.0.0.1:\${HOST_PORT}:3000
|
||||
networks:
|
||||
- backend
|
||||
|
||||
networks:
|
||||
backend:
|
||||
external: true
|
||||
EOF
|
||||
|
||||
previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/release.env" | head -n 1)
|
||||
switched=0
|
||||
rollback() {
|
||||
if [ "$switched" -eq 1 ] && [ -n "$previous_tag" ]; then
|
||||
echo "Health check failed; restoring prior image tag $previous_tag" >&2
|
||||
sudo sed -i "s/^IMAGE_TAG=.*/IMAGE_TAG=$previous_tag/" "$directory/release.env"
|
||||
sudo docker compose --env-file "$directory/release.env" -f "$compose" up -d
|
||||
fi
|
||||
}
|
||||
trap rollback ERR
|
||||
|
||||
port=$(sed -n 's/^HOST_PORT=//p' "$directory/release.env" | head -n 1)
|
||||
if [ "${FORCE_DEPLOY:-}" != 1 ] && curl --fail --silent --show-error "http://127.0.0.1:${port}/api/health" > /tmp/esports-health.json; then
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
data = json.load(open("/tmp/esports-health.json"))
|
||||
active = int(data.get("activeMatches") or data.get("irc", {}).get("activeLobbies") or 0)
|
||||
if active:
|
||||
raise SystemExit(f"{active} matches are still live. Drain them from the admin page, or set FORCE_DEPLOY=1.")
|
||||
PY
|
||||
fi
|
||||
|
||||
# The old release reports lobbies rather than active matches, and its health
|
||||
# endpoint may be unavailable. The database check covers both releases.
|
||||
if [ "${FORCE_DEPLOY:-}" != 1 ]; then
|
||||
active_count=$(sudo docker run --rm --network backend --env-file "$environment" "$image" \
|
||||
bun -e 'const sql = new Bun.SQL(process.env.DB_URL || process.env.DATABASE_URL, { max: 1 }); const states = ["LOBBY", "ROLLING", "CHOOSING", "BANNING", "PICKING", "TB_SELECT", "PLAYING", "CLOSING"]; const [row] = await sql`select count(*)::int as count from match where state in ${sql(states)}`; console.log(row.count); await sql.end();')
|
||||
if [ "$active_count" -gt 0 ]; then
|
||||
echo "$active_count matches are live in the database. Drain them before deploying." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
db_version=$(sudo docker run --rm --network backend --env-file "$environment" "$image" \
|
||||
bun -e 'const sql = new Bun.SQL(process.env.DB_URL || process.env.DATABASE_URL, { max: 1 }); const [row] = await sql`show server_version_num`; console.log(row.server_version_num); await sql.end();')
|
||||
case "$db_version" in
|
||||
*[!0-9]*|'') echo 'Could not determine the PostgreSQL server version' >&2; exit 1 ;;
|
||||
esac
|
||||
if [ "$db_version" -ge 100000 ]; then
|
||||
db_major=$((db_version / 10000))
|
||||
else
|
||||
db_major="$((db_version / 10000)).$(((db_version / 100) % 100))"
|
||||
fi
|
||||
backup_image="postgres:${db_major}-alpine"
|
||||
backup_dir=/var/backups/esports
|
||||
backup_stamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
backup_name="${environment_name}-${backup_stamp}-${sha}.dump"
|
||||
sudo docker pull "$backup_image"
|
||||
sudo install -d -m 0700 "$backup_dir"
|
||||
sudo docker run --rm --network backend --env-file "$environment" \
|
||||
--mount "type=bind,source=$backup_dir,target=/backups" \
|
||||
--env "BACKUP_FILE=$backup_name" "$backup_image" \
|
||||
sh -eu -c 'pg_dump --format=custom --no-owner --no-acl --file="/backups/$BACKUP_FILE" "${DB_URL:-$DATABASE_URL}"'
|
||||
sudo docker run --rm --mount "type=bind,source=$backup_dir,target=/backups" \
|
||||
"$backup_image" pg_restore --list "/backups/$backup_name" >/dev/null
|
||||
sudo chmod 0600 "$backup_dir/$backup_name"
|
||||
echo "Verified $environment_name database backup at $backup_dir/$backup_name"
|
||||
|
||||
sudo docker run --rm --network backend --env-file "$environment" "$image" \
|
||||
bun scripts/migrate.ts
|
||||
|
||||
sudo sed -i "s/^IMAGE_TAG=.*/IMAGE_TAG=$sha/" "$directory/release.env"
|
||||
switched=1
|
||||
sudo docker compose --env-file "$directory/release.env" -f "$compose" up -d
|
||||
curl --retry 12 --retry-delay 2 --retry-all-errors --fail --silent --show-error \
|
||||
"http://127.0.0.1:${port}/api/health" >/dev/null
|
||||
trap - ERR
|
||||
echo "Deployed $sha to $origin"
|
||||
22
scripts/dev-login.ts
Normal file
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);
|
||||
});
|
||||
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';
|
||||
import type { ConnectionProfile } from '$lib/auth-identity';
|
||||
|
||||
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">
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
// src/hooks.server.ts
|
||||
import type { Handle, HandleServerError } from '@sveltejs/kit';
|
||||
import { sequence } from '@sveltejs/kit/hooks';
|
||||
import { building } from '$app/environment';
|
||||
import { building, dev } from '$app/environment';
|
||||
import * as Sentry from '@sentry/sveltekit';
|
||||
import { appEnv, titlePrefix } from '$lib/server/app-env';
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,32 +25,20 @@ 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 { startServices } from '$lib/server/services';
|
||||
|
||||
// ── Initialize the IRC DM handler once on server startup ──
|
||||
if (!building) {
|
||||
import('$lib/server/bancho/dm-handler')
|
||||
.then(({ initDMHandler }) => initDMHandler())
|
||||
.catch((err) => log.dm.warn({ err: err.message }, 'DM handler init skipped'));
|
||||
|
||||
import('$lib/server/tournament/scheduler')
|
||||
.then(({ startTournamentScheduler }) => startTournamentScheduler())
|
||||
.catch((err) => log.tournament.warn({ err: err.message }, 'Tournament scheduler init skipped'));
|
||||
|
||||
import('$lib/server/discord/client')
|
||||
.then(({ initDiscordClient }) => initDiscordClient())
|
||||
.catch((err) => log.discord.warn({ err: err.message }, 'Discord client init skipped'));
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
log.http.error({ err: reason }, 'Unhandled rejection');
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
log.http.fatal({ err }, 'Uncaught exception');
|
||||
});
|
||||
startServices().catch((err) => log.http.fatal({ err }, 'background services failed to start'));
|
||||
}
|
||||
|
||||
/** Safe IP getter — returns null for internal requests missing the ADDRESS_HEADER */
|
||||
/** Returns null for internal requests that lack the client address header. */
|
||||
function getIp(event: Parameters<Handle>[0]['event']): string | null {
|
||||
// The Vite dev server sits behind Caddy and only sees 127.0.0.1. The built
|
||||
// server reads the real address itself through ADDRESS_HEADER.
|
||||
if (dev) {
|
||||
const forwarded = event.request.headers.get('cf-connecting-ip');
|
||||
if (forwarded) return forwarded;
|
||||
}
|
||||
try {
|
||||
return event.getClientAddress();
|
||||
} catch {
|
||||
@@ -55,115 +46,95 @@ function getIp(event: Parameters<Handle>[0]['event']): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request logging middleware ──
|
||||
// Paths that are too noisy to log every time (healthchecks, polling)
|
||||
// Health checks and polling would drown out everything else.
|
||||
const SILENT_PATHS = new Set(['/api/health', '/api/queue']);
|
||||
|
||||
const handleRequestLogging: Handle = async ({ event, resolve }) => {
|
||||
const requestId = crypto.randomUUID();
|
||||
const start = performance.now();
|
||||
|
||||
event.locals.requestId = requestId;
|
||||
|
||||
const response = await resolve(event);
|
||||
const prefix = titlePrefix();
|
||||
const response = await resolve(event, {
|
||||
transformPageChunk: prefix
|
||||
? ({ html }) => html.replace('<title>', `<title>${prefix}`)
|
||||
: undefined
|
||||
});
|
||||
|
||||
const duration = Math.round(performance.now() - start);
|
||||
const path = event.url.pathname;
|
||||
const status = response.status;
|
||||
|
||||
// Always count metrics, but only log interesting requests
|
||||
metrics.requestCount++;
|
||||
metrics.totalResponseTime += duration;
|
||||
|
||||
const isSilent = SILENT_PATHS.has(path) && status < 400;
|
||||
|
||||
if (!isSilent) {
|
||||
const userId = event.locals.user?.id;
|
||||
if (!(SILENT_PATHS.has(path) && response.status < 400)) {
|
||||
log.http.info(
|
||||
{
|
||||
requestId,
|
||||
method: event.request.method,
|
||||
path,
|
||||
status,
|
||||
status: response.status,
|
||||
duration,
|
||||
userId: userId ?? null,
|
||||
userId: event.locals.user?.id ?? null,
|
||||
ip: getIp(event)
|
||||
},
|
||||
`${event.request.method} ${path} ${status} ${duration}ms`
|
||||
`${event.request.method} ${path} ${response.status} ${duration}ms`
|
||||
);
|
||||
}
|
||||
|
||||
if (duration > 1000) {
|
||||
log.http.warn({ requestId, path, duration }, 'Slow request');
|
||||
log.http.warn({ requestId, path, duration }, 'slow request');
|
||||
metrics.slowRequests++;
|
||||
}
|
||||
|
||||
response.headers.set('X-Request-Id', requestId);
|
||||
return response;
|
||||
};
|
||||
|
||||
// ── Auth + rate limiting ──
|
||||
const handleBetterAuth: Handle = async ({ event, resolve }) => {
|
||||
// Rate limiting for API routes
|
||||
if (event.url.pathname.startsWith('/api/')) {
|
||||
const tooMany = (retryAfter?: number) =>
|
||||
new Response('Too Many Requests', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': String(retryAfter ?? 60), 'Content-Type': 'text/plain' }
|
||||
});
|
||||
|
||||
const handleAuth: Handle = async ({ event, resolve }) => {
|
||||
const path = event.url.pathname;
|
||||
if (path.startsWith('/api/')) {
|
||||
const ip = getIp(event) ?? 'unknown';
|
||||
|
||||
// Stricter limit for auth endpoints: 10 req/min per IP
|
||||
if (event.url.pathname.startsWith('/api/auth/')) {
|
||||
const { ok, retryAfter } = checkRateLimit(`auth:${ip}`, 10, 60_000);
|
||||
if (!ok) {
|
||||
return new Response('Too Many Requests', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': String(retryAfter ?? 60), 'Content-Type': 'text/plain' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// General API limit: 600 req/min per IP
|
||||
const { ok, retryAfter } = checkRateLimit(`api:${ip}`, 600, 60_000);
|
||||
if (!ok) {
|
||||
return new Response('Too Many Requests', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': String(retryAfter ?? 60), 'Content-Type': 'text/plain' }
|
||||
});
|
||||
if (path.startsWith('/api/auth/')) {
|
||||
const auth = checkRateLimit(`auth:${ip}`, 10, 60_000);
|
||||
if (!auth.ok) return tooMany(auth.retryAfter);
|
||||
}
|
||||
const api = checkRateLimit(`api:${ip}`, 600, 60_000);
|
||||
if (!api.ok) return tooMany(api.retryAfter);
|
||||
}
|
||||
|
||||
// Caddy terminates HTTPS, so the request Vite sees is http while ORIGIN is
|
||||
// https. Better Auth's helper skips its handler when those differ, and
|
||||
// SvelteKit would 404 the OAuth callback. Route auth paths directly.
|
||||
if (!building && path.startsWith('/api/auth/')) return auth.handler(event.request);
|
||||
|
||||
const session = await auth.api.getSession({ headers: event.request.headers });
|
||||
|
||||
if (session) {
|
||||
event.locals.session = session.session;
|
||||
|
||||
const dbUser = await db.query.user.findFirst({
|
||||
where: eq(userTable.id, session.user.id)
|
||||
});
|
||||
|
||||
let role = dbUser?.role ?? 'player';
|
||||
|
||||
if (dbUser) {
|
||||
role = await ensureRootAdminRole({
|
||||
id: dbUser.id,
|
||||
email: dbUser.email,
|
||||
role
|
||||
});
|
||||
const dbUser = await db.query.user.findFirst({ where: eq(userTable.id, session.user.id) });
|
||||
if (!dbUser || dbUser.bannedAt || dbUser.mergedIntoUserId) {
|
||||
return new Response('Account unavailable', { status: 403 });
|
||||
}
|
||||
|
||||
event.locals.session = session.session;
|
||||
event.locals.user = {
|
||||
...session.user,
|
||||
role
|
||||
email: dbUser.email ?? null,
|
||||
image: dbUser.image,
|
||||
emailVerified: dbUser.emailVerified,
|
||||
rootAdmin: dbUser.rootAdmin,
|
||||
role: await ensureRootAdminRole({
|
||||
id: dbUser.id,
|
||||
rootAdmin: dbUser.rootAdmin,
|
||||
role: dbUser.role
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
return svelteKitHandler({ event, resolve, auth, building });
|
||||
};
|
||||
|
||||
export const handle: Handle = sequence(
|
||||
Sentry.sentryHandle(),
|
||||
handleRequestLogging,
|
||||
handleBetterAuth
|
||||
);
|
||||
export const handle: Handle = sequence(Sentry.sentryHandle(), handleRequestLogging, handleAuth);
|
||||
|
||||
// ── Global server error handler ──
|
||||
export const handleError: HandleServerError = Sentry.handleErrorWithSentry(({ error, event }) => {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
log.http.error(
|
||||
@@ -173,7 +144,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';
|
||||
import type { MatchView } from '$lib/match-view';
|
||||
import type { Crowd } from '$lib/predictions';
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
|
||||
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 };
|
||||
}
|
||||
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';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { hoursToRanges, type TimeRange } from '$lib/availability';
|
||||
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';
|
||||
|
||||
/** 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}
|
||||
@@ -16,13 +16,16 @@
|
||||
meta,
|
||||
columns,
|
||||
row,
|
||||
searchPlaceholder = 'Search...'
|
||||
searchPlaceholder = 'Search...',
|
||||
layout = 'table'
|
||||
}: {
|
||||
data: T[];
|
||||
meta: TableMeta;
|
||||
columns: Column[];
|
||||
row: Snippet<[T, number]>;
|
||||
searchPlaceholder?: string;
|
||||
/** `list` renders rows as cards (the row snippet gives each card), with sort buttons instead of a header. */
|
||||
layout?: 'table' | 'list';
|
||||
} = $props();
|
||||
|
||||
let searchInput = $derived.by(() => meta.search);
|
||||
@@ -115,72 +118,104 @@
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- table -->
|
||||
<div class="overflow-x-auto rounded-lg border border-border">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-border bg-surface-800">
|
||||
{#each columns as col}
|
||||
<th
|
||||
class="{col.class ??
|
||||
'px-4 text-left'} py-3 text-[11px] font-semibold tracking-wider text-text-secondary uppercase"
|
||||
>
|
||||
{#if col.sortable}
|
||||
<button
|
||||
class="inline-flex items-center gap-1 transition-colors hover:text-text-primary"
|
||||
onclick={() => toggleSort(col.key)}
|
||||
>
|
||||
{col.label}
|
||||
{#if meta.sortBy === col.key}
|
||||
<svg
|
||||
class="h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
>
|
||||
{#if meta.sortDir === 'asc'}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
|
||||
{:else}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||
{/if}
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
class="h-3 w-3 opacity-30"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
{col.label}
|
||||
{/if}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border/50">
|
||||
{#each data as item, i}
|
||||
{@render row(item, i)}
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan={columns.length} class="px-4 py-8 text-center text-sm text-text-secondary">
|
||||
{meta.search ? `No results for "${meta.search}"` : 'No data'}
|
||||
</td>
|
||||
</tr>
|
||||
{#if layout === 'list'}
|
||||
{#if columns.some((col) => col.sortable)}
|
||||
<div class="mb-3 flex flex-wrap items-center gap-1.5 text-xs text-text-secondary">
|
||||
<span>Sort by</span>
|
||||
{#each columns.filter((col) => col.sortable) as col (col.key)}
|
||||
<button
|
||||
class="rounded-md border px-2 py-1 transition-colors {meta.sortBy === col.key
|
||||
? 'border-white/20 bg-white/5 text-text-primary'
|
||||
: 'border-transparent hover:text-text-primary'}"
|
||||
onclick={() => toggleSort(col.key)}
|
||||
>{col.label}{meta.sortBy === col.key
|
||||
? meta.sortDir === 'asc'
|
||||
? ' ↑'
|
||||
: ' ↓'
|
||||
: ''}</button
|
||||
>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each data as item, i}
|
||||
{@render row(item, i)}
|
||||
{:else}
|
||||
<p
|
||||
class="rounded-xl border border-dashed border-white/10 px-4 py-8 text-center text-sm text-text-secondary"
|
||||
>
|
||||
{meta.search ? `No results for "${meta.search}"` : 'Nothing here yet'}
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- table -->
|
||||
<div class="overflow-x-auto rounded-lg border border-border">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-border bg-surface-800">
|
||||
{#each columns as col}
|
||||
<th
|
||||
class="{col.class ??
|
||||
'px-4 text-left'} py-3 text-[11px] font-semibold tracking-wider text-text-secondary uppercase"
|
||||
>
|
||||
{#if col.sortable}
|
||||
<button
|
||||
class="inline-flex items-center gap-1 transition-colors hover:text-text-primary"
|
||||
onclick={() => toggleSort(col.key)}
|
||||
>
|
||||
{col.label}
|
||||
{#if meta.sortBy === col.key}
|
||||
<svg
|
||||
class="h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
>
|
||||
{#if meta.sortDir === 'asc'}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
|
||||
{:else}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||
{/if}
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
class="h-3 w-3 opacity-30"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
{col.label}
|
||||
{/if}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border/50">
|
||||
{#each data as item, i}
|
||||
{@render row(item, i)}
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan={columns.length} class="px-4 py-8 text-center text-sm text-text-secondary">
|
||||
{meta.search ? `No results for "${meta.search}"` : 'No data'}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- pagination -->
|
||||
{#if meta.totalPages > 1}
|
||||
|
||||
@@ -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';
|
||||
import { timeAgo } from '$lib/format';
|
||||
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';
|
||||
import {
|
||||
POINTS,
|
||||
crowdShare,
|
||||
possibleScores,
|
||||
scorePrediction,
|
||||
type Crowd,
|
||||
type PredictionResult,
|
||||
type PredictionState
|
||||
} from '$lib/predictions';
|
||||
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>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import Avatar from '$lib/components/Avatar.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import { page } from '$app/state';
|
||||
|
||||
@@ -48,11 +49,7 @@
|
||||
class="mt-3"
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
{#if user.image}
|
||||
<img src={user.image} alt="" class="h-8 w-8 shrink-0 rounded-full" />
|
||||
{:else}
|
||||
<div class="h-8 w-8 shrink-0 rounded-full bg-surface-600"></div>
|
||||
{/if}
|
||||
<Avatar src={user.image} name={user.name ?? '?'} size={32} />
|
||||
<div class="flex-1">
|
||||
<textarea
|
||||
name="content"
|
||||
@@ -81,19 +78,9 @@
|
||||
<div class="mt-4 flex flex-col gap-3">
|
||||
{#each comments as comment}
|
||||
<div class="flex gap-3">
|
||||
{#if comment.author.image}
|
||||
<a href="/users/{comment.author.id}" class="shrink-0">
|
||||
<img src={comment.author.image} alt="" class="h-8 w-8 rounded-full" />
|
||||
</a>
|
||||
{:else}
|
||||
<a href="/users/{comment.author.id}" class="shrink-0">
|
||||
<div
|
||||
class="font-700 flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs text-text-secondary"
|
||||
>
|
||||
{comment.author.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
<a href="/users/{comment.author.id}" class="shrink-0">
|
||||
<Avatar src={comment.author.image} name={comment.author.name} size={32} />
|
||||
</a>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/users/{comment.author.id}" class="font-600 text-sm hover:text-accent"
|
||||
|
||||
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}
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import Avatar from '$lib/components/Avatar.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
interface SearchResult {
|
||||
@@ -145,6 +146,7 @@
|
||||
<div
|
||||
class="fixed top-[15%] left-1/2 z-[60] w-full max-w-lg -translate-x-1/2 rounded-xl border border-border bg-surface-800 shadow-2xl"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
aria-label="Search"
|
||||
onkeydown={handleModalKeydown}
|
||||
>
|
||||
@@ -219,7 +221,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d={typeIcons[type] ?? ''} />
|
||||
</svg>
|
||||
{#if item.image || item.avatarUrl}
|
||||
<img src={item.image ?? item.avatarUrl} alt="" class="h-5 w-5 rounded-full" />
|
||||
<Avatar src={item.image ?? item.avatarUrl} name={item.name ?? '?'} size={20} />
|
||||
{/if}
|
||||
<span class="flex-1 truncate">{item.name ?? 'Unnamed'}</span>
|
||||
{#if item.state}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { queue, joinQueue, leaveQueue } from '$lib/stores/queue.svelte';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
|
||||
const user = $derived(page.data?.user);
|
||||
const activeMatch = $derived(page.data?.activeMatch);
|
||||
const unreadCount = $derived((page.data as any)?.unreadNotificationCount ?? 0);
|
||||
const liveCount = $derived((page.data as { liveCount?: number })?.liveCount ?? 0);
|
||||
|
||||
let { open = $bindable(false), guest = false } = $props();
|
||||
let notifOpen = $state(false);
|
||||
@@ -17,6 +19,7 @@
|
||||
}
|
||||
|
||||
const isAdmin = $derived(user?.role === 'admin');
|
||||
const isStaff = $derived(user?.role === 'admin' || user?.role === 'referee');
|
||||
|
||||
// Close sidebar on navigation (mobile)
|
||||
$effect(() => {
|
||||
@@ -89,7 +92,23 @@
|
||||
</script>
|
||||
|
||||
{#snippet navIcon(name: string)}
|
||||
{#if name === 'dashboard'}
|
||||
{#if name === 'live'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9" /><path
|
||||
d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"
|
||||
/><circle cx="12" cy="12" r="2" /><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5" /><path
|
||||
d="M19.1 4.9C23 8.8 23 15.1 19.1 19"
|
||||
/></svg
|
||||
>
|
||||
{:else if name === 'dashboard'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
@@ -351,20 +370,18 @@
|
||||
<span class="h-1.5 w-1.5 animate-pulse rounded-full bg-yellow-400"></span>
|
||||
In Queue ({queue.status.queueSize})
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
onclick={leaveQueue}
|
||||
disabled={queue.loading}
|
||||
class="flex w-full items-center justify-center rounded-md px-3 py-1.5 text-xs text-red-400 transition-colors hover:bg-red-500/10 disabled:opacity-50"
|
||||
>
|
||||
Leave Queue
|
||||
</button>
|
||||
Leave queue
|
||||
</Button>
|
||||
</div>
|
||||
{:else if !activeMatch}
|
||||
<button
|
||||
onclick={joinQueue}
|
||||
disabled={queue.loading}
|
||||
class="font-600 flex w-full items-center justify-center gap-2 rounded-lg bg-accent px-3 py-2.5 text-sm text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
<Button class="w-full" onclick={() => joinQueue()} disabled={queue.loading}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
@@ -378,15 +395,15 @@
|
||||
d="m16.5 22 5-5"
|
||||
/><path d="M10 5.5 6 2H3v3l4 4" /></svg
|
||||
>
|
||||
{queue.loading ? 'Joining...' : 'Find Match'}
|
||||
</button>
|
||||
{queue.loading ? 'Joining...' : 'Find 1v1'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Nav -->
|
||||
<nav class="mt-0 flex flex-1 flex-col gap-0.5 px-3">
|
||||
{#each [{ href: '/', label: 'Dashboard', icon: 'dashboard' }, { href: '/tournaments', label: 'Tournaments', icon: 'tournaments' }, { href: '/matches', label: 'Matches', icon: 'matches' }, { href: '/mappools', label: 'Mappools', icon: 'mappools' }, { href: '/teams', label: 'Teams', icon: 'teams' }, { href: '/leaderboard', label: 'Leaderboard', icon: 'leaderboard' }] as item (item.href)}
|
||||
{#each [{ href: '/', label: 'Dashboard', icon: 'dashboard' }, { href: '/live', label: 'Live', icon: 'live' }, { href: '/tournaments', label: 'Tournaments', icon: 'tournaments' }, { href: '/matches', label: 'Matches', icon: 'matches' }, { href: '/mappools', label: 'Mappools', icon: 'mappools' }, { href: '/teams', label: 'Teams', icon: 'teams' }, { href: '/leaderboard', label: 'Leaderboard', icon: 'leaderboard' }] as item (item.href)}
|
||||
<a
|
||||
href={item.href}
|
||||
class="font-500 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors {isActive(
|
||||
@@ -397,13 +414,31 @@
|
||||
>
|
||||
{@render navIcon(item.icon)}
|
||||
{item.label}
|
||||
{#if item.icon === 'live' && liveCount > 0}
|
||||
<span class="ml-auto flex items-center gap-1.5 text-xs text-red-400 tabular-nums">
|
||||
<span class="size-1.5 animate-pulse rounded-full bg-red-500"></span>{liveCount}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
{#if isAdmin}
|
||||
{#if isStaff}
|
||||
<div class="mt-4 mb-1 px-3">
|
||||
<p class="font-600 text-[10px] tracking-widest text-text-secondary uppercase">Staff</p>
|
||||
</div>
|
||||
<a
|
||||
href="/broadcast"
|
||||
class="font-500 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors {isActive(
|
||||
'/broadcast'
|
||||
)
|
||||
? 'bg-fuchsia-500/10 text-fuchsia-300'
|
||||
: 'text-text-secondary hover:bg-surface-700 hover:text-text-primary'}"
|
||||
>
|
||||
{@render navIcon('live')}
|
||||
Broadcast
|
||||
</a>
|
||||
{/if}
|
||||
{#if isAdmin}
|
||||
<a
|
||||
href="/admin"
|
||||
class="font-500 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors {isActive(
|
||||
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
import { stageOf, type ComponentType, type SceneId } from '$lib/broadcast/layout';
|
||||
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';
|
||||
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';
|
||||
import { crowdShare } from '$lib/predictions';
|
||||
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';
|
||||
import type { Layout, SceneId } from '$lib/broadcast/layout';
|
||||
|
||||
/** 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';
|
||||
import { SIDE_COLORS, initials } from '$lib/components/match/theme';
|
||||
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';
|
||||
import { DECIDED } from '$lib/live';
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
import type { PlacedComponent } from '$lib/broadcast/layout';
|
||||
|
||||
/** 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';
|
||||
import { DECIDED, type LiveMatch } from '$lib/live';
|
||||
import { SIDE_COLORS, initials, modColor } from '$lib/components/match/theme';
|
||||
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';
|
||||
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-3xl"
|
||||
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>
|
||||
{#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>
|
||||
69
src/lib/components/match/CountdownRing.svelte
Normal file
69
src/lib/components/match/CountdownRing.svelte
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
/** A ring that drains toward a deadline and turns red in the last seconds. */
|
||||
const {
|
||||
deadline,
|
||||
now,
|
||||
total,
|
||||
size = 44,
|
||||
label = true
|
||||
}: {
|
||||
deadline: number | null;
|
||||
now: number;
|
||||
total: number;
|
||||
size?: number;
|
||||
label?: boolean;
|
||||
} = $props();
|
||||
|
||||
const left = $derived(deadline == null ? null : Math.max(0, deadline - now));
|
||||
const fraction = $derived(left == null || total <= 0 ? 0 : Math.min(1, left / total));
|
||||
const seconds = $derived(left == null ? null : Math.ceil(left / 1000));
|
||||
const urgent = $derived(seconds != null && seconds <= 15);
|
||||
const radius = $derived(size / 2 - 3);
|
||||
const circumference = $derived(2 * Math.PI * radius);
|
||||
const text = $derived(
|
||||
seconds == null
|
||||
? ''
|
||||
: seconds >= 60
|
||||
? `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
||||
: `${seconds}`
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if deadline != null}
|
||||
<div
|
||||
class="relative inline-grid place-items-center"
|
||||
style="width:{size}px;height:{size}px"
|
||||
class:animate-pulse={urgent && seconds! <= 5}
|
||||
>
|
||||
<svg width={size} height={size} class="-rotate-90">
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
class="text-white/10"
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke={urgent ? '#f87171' : '#e4e4e7'}
|
||||
stroke-dasharray={circumference}
|
||||
stroke-dashoffset={circumference * (1 - fraction)}
|
||||
style="transition: stroke-dashoffset 0.25s linear, stroke 0.3s"
|
||||
/>
|
||||
</svg>
|
||||
{#if label}
|
||||
<span
|
||||
class="absolute font-mono font-semibold tabular-nums {size < 48 && text.length > 2
|
||||
? 'text-[9px]'
|
||||
: 'text-xs'} {urgent ? 'text-red-300' : 'text-zinc-200'}">{text}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
161
src/lib/components/match/MapCard.svelte
Normal file
161
src/lib/components/match/MapCard.svelte
Normal file
@@ -0,0 +1,161 @@
|
||||
<script lang="ts">
|
||||
import type { SideView, SlotView } from '$lib/match-view';
|
||||
import { SIDE_COLORS, formatLength, modColor } from './theme';
|
||||
import Lock from '@lucide/svelte/icons/lock';
|
||||
import Trophy from '@lucide/svelte/icons/trophy';
|
||||
import Star from '@lucide/svelte/icons/star';
|
||||
import Clock from '@lucide/svelte/icons/clock';
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
|
||||
const {
|
||||
slot,
|
||||
sides,
|
||||
interactive = false,
|
||||
selected = false,
|
||||
dimmed = false,
|
||||
flash = false,
|
||||
size = 'md',
|
||||
onselect
|
||||
}: {
|
||||
slot: SlotView;
|
||||
sides: SideView[];
|
||||
interactive?: boolean;
|
||||
selected?: boolean;
|
||||
dimmed?: boolean;
|
||||
/** Plays the change animation (a fresh ban or result). */
|
||||
flash?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
onselect?: (slot: SlotView) => void;
|
||||
} = $props();
|
||||
|
||||
const color = $derived(modColor(slot.category));
|
||||
const banner = $derived(sides.find((side) => side.id === slot.bannedBy));
|
||||
const picker = $derived(sides.find((side) => side.id === slot.pickedBy));
|
||||
const winner = $derived(sides.find((side) => side.id === slot.winner));
|
||||
const banned = $derived(slot.status === 'banned');
|
||||
const played = $derived(slot.status === 'played');
|
||||
const locked = $derived(slot.status === 'locked');
|
||||
const heights = { sm: 'h-[68px]', md: 'h-[92px]', lg: 'h-[132px]' };
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!interactive}
|
||||
onclick={() => onselect?.(slot)}
|
||||
class="group relative block w-full overflow-hidden rounded-xl border text-left transition-all duration-300 {heights[
|
||||
size
|
||||
]}
|
||||
{interactive ? 'cursor-pointer hover:-translate-y-0.5 hover:scale-[1.015]' : 'cursor-default'}
|
||||
{selected ? 'vash-glow -translate-y-1 scale-[1.03]' : ''}
|
||||
{flash && banned ? 'vash-shake' : ''}
|
||||
{flash && played ? 'vash-pop' : ''}"
|
||||
style="--glow:{color};border-color:{selected
|
||||
? color
|
||||
: interactive
|
||||
? `${color}99`
|
||||
: `${color}33`};{dimmed ? 'opacity:0.35' : ''}"
|
||||
>
|
||||
{#if slot.coverUrl}
|
||||
<img
|
||||
src={slot.coverUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
class="absolute inset-0 size-full object-cover transition duration-700 {banned
|
||||
? 'scale-105 blur-[1px] grayscale'
|
||||
: locked
|
||||
? 'grayscale-[60%]'
|
||||
: ''} {interactive ? 'group-hover:scale-105' : ''}"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
style="background:radial-gradient(circle at 20% 20%, {color}55, transparent 60%), linear-gradient(135deg, #18181b, #09090b)"
|
||||
></div>
|
||||
{/if}
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-black/95 via-black/60 to-black/20 {banned
|
||||
? 'bg-black/60'
|
||||
: ''}"
|
||||
></div>
|
||||
{#if slot.tiebreaker}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 opacity-30 mix-blend-overlay"
|
||||
style="background:linear-gradient(120deg, #f0abfc55, #93c5fd55, #fde68a55)"
|
||||
></div>
|
||||
{/if}
|
||||
{#if interactive}<div
|
||||
class="vash-shimmer pointer-events-none absolute inset-0 opacity-60"
|
||||
></div>{/if}
|
||||
|
||||
<div class="relative flex h-full flex-col justify-between p-2.5 {banned ? 'opacity-60' : ''}">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span
|
||||
class="rounded-md px-1.5 py-0.5 font-mono text-[11px] font-black tracking-wide shadow"
|
||||
style="background:{color};color:{slot.category === 'TB' ? '#18181b' : '#0a0a0a'}"
|
||||
>{slot.label}</span
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1 rounded-md bg-black/50 px-1.5 py-0.5 text-[11px] font-semibold text-zinc-100 backdrop-blur"
|
||||
>
|
||||
{#if locked}<Lock class="size-3" />{/if}
|
||||
{#if slot.starRating}<Star
|
||||
class="size-3 fill-amber-300 text-amber-300"
|
||||
/>{slot.starRating.toFixed(2)}{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div
|
||||
class="truncate text-sm leading-tight font-semibold text-white {size === 'sm'
|
||||
? 'text-xs'
|
||||
: ''}"
|
||||
>
|
||||
{slot.title ?? `Beatmap #${slot.beatmapId}`}
|
||||
</div>
|
||||
{#if size !== 'sm'}
|
||||
<div class="truncate text-[11px] text-zinc-300/80">
|
||||
{slot.artist ?? ''}{#if slot.version}<span class="text-zinc-400">
|
||||
[{slot.version}]</span
|
||||
>{/if}
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2.5 text-[10px] text-zinc-400">
|
||||
{#if slot.bpm}<span class="flex items-center gap-0.5"
|
||||
><Music class="size-2.5" />{Math.round(slot.bpm)}</span
|
||||
>{/if}
|
||||
{#if slot.lengthSec}<span class="flex items-center gap-0.5"
|
||||
><Clock class="size-2.5" />{formatLength(slot.lengthSec)}</span
|
||||
>{/if}
|
||||
{#if slot.freemod && !slot.tiebreaker}<span>freemod</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if banned}
|
||||
<div
|
||||
class="absolute top-1/2 left-1/2 rounded-md border-2 px-2.5 py-0.5 text-sm font-black tracking-[0.2em] uppercase {flash
|
||||
? '[animation:vash-stamp_0.45s_ease-out_both]'
|
||||
: ''}"
|
||||
style="transform:translate(-50%,-50%) rotate(-14deg);color:{banner
|
||||
? SIDE_COLORS[banner.color].text
|
||||
: '#e4e4e7'};border-color:{banner
|
||||
? SIDE_COLORS[banner.color].solid
|
||||
: '#71717a'};background:rgb(0 0 0 / 0.55)"
|
||||
>
|
||||
Banned
|
||||
</div>
|
||||
{/if}
|
||||
{#if played && picker}
|
||||
<div
|
||||
class="absolute top-0 right-0 bottom-0 w-1"
|
||||
style="background:{SIDE_COLORS[picker.color].solid}"
|
||||
></div>
|
||||
{/if}
|
||||
{#if played}
|
||||
<div
|
||||
class="absolute right-2 bottom-2 flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-bold uppercase"
|
||||
style="background:{winner ? SIDE_COLORS[winner.color].solid : '#3f3f46'};color:white"
|
||||
>
|
||||
<Trophy class="size-3" />{winner ? winner.name.slice(0, 14) : 'Draw'}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
147
src/lib/components/match/MapHistory.svelte
Normal file
147
src/lib/components/match/MapHistory.svelte
Normal file
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
import { slide } from 'svelte/transition';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { MatchLive } from './live.svelte';
|
||||
import { SIDE_COLORS, formatScore, modColor } from './theme';
|
||||
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||
|
||||
const {
|
||||
live,
|
||||
isReferee,
|
||||
embedded = false
|
||||
}: { live: MatchLive; isReferee: boolean; embedded?: boolean } = $props();
|
||||
const view = $derived(live.view);
|
||||
const open = new SvelteSet<number>();
|
||||
|
||||
function name(playerId: string) {
|
||||
for (const side of view.sides) {
|
||||
const player = side.players.find((each) => each.id === playerId);
|
||||
if (player) return player.osuName ?? player.name;
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if view.maps.length}
|
||||
<section class={embedded ? 'p-2' : 'rounded-2xl border border-white/8 bg-zinc-950/60 p-3 sm:p-4'}>
|
||||
<h2 class="mb-3 text-sm font-semibold tracking-wide text-zinc-200 uppercase">Maps played</h2>
|
||||
<ol class="space-y-2">
|
||||
{#each [...view.maps].reverse() as map (map.index)}
|
||||
{@const slot = live.slot(map.slotId)}
|
||||
{@const winner = live.side(map.winner)}
|
||||
{@const red = map.totals[view.sides[0].id] ?? 0}
|
||||
{@const blue = map.totals[view.sides[1].id] ?? 0}
|
||||
{@const share = red + blue > 0 ? red / (red + blue) : 0.5}
|
||||
<li
|
||||
class="overflow-hidden rounded-xl border border-l-[3px] border-white/8 bg-white/[0.03]"
|
||||
style="border-left-color:{slot ? modColor(slot.category) : '#71717a'}"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full p-3 text-left transition hover:bg-white/[0.04]"
|
||||
aria-expanded={open.has(map.index)}
|
||||
onclick={() => (open.has(map.index) ? open.delete(map.index) : open.add(map.index))}
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="w-4 shrink-0 text-center text-[11px] text-zinc-500 tabular-nums"
|
||||
>{map.index + 1}</span
|
||||
>
|
||||
<span
|
||||
class="shrink-0 font-mono text-xs font-black"
|
||||
style="color:{slot ? modColor(slot.category) : '#fff'}">{slot?.label ?? 'Map'}</span
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium"
|
||||
>{slot?.title ?? `Beatmap #${slot?.beatmapId ?? ''}`}</span
|
||||
>
|
||||
<ChevronDown
|
||||
class="size-4 shrink-0 text-zinc-500 transition {open.has(map.index)
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center justify-between gap-2 pl-6 text-xs">
|
||||
<span
|
||||
class="min-w-0 truncate font-semibold"
|
||||
style="color:{winner ? SIDE_COLORS[winner.color].text : '#a1a1aa'}"
|
||||
>{winner?.name ?? 'Draw'}{winner ? ' won' : ''}</span
|
||||
>
|
||||
<span class="shrink-0 font-mono text-[11px] tabular-nums"
|
||||
><span style="color:{SIDE_COLORS.red.text}"
|
||||
>{formatScore(red, view.rules.winCondition)}</span
|
||||
><span class="px-1 text-zinc-600">–</span><span
|
||||
style="color:{SIDE_COLORS.blue.text}"
|
||||
>{formatScore(blue, view.rules.winCondition)}</span
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
<div class="mt-2 ml-6 flex h-1.5 overflow-hidden rounded-full bg-white/5">
|
||||
<div style="width:{share * 100}%;background:{SIDE_COLORS.red.solid}"></div>
|
||||
<div class="flex-1" style="background:{SIDE_COLORS.blue.solid}"></div>
|
||||
</div>
|
||||
</button>
|
||||
{#if open.has(map.index)}
|
||||
<div transition:slide={{ duration: 180 }} class="border-t border-white/5 px-3 py-2">
|
||||
{#if map.scores.length}
|
||||
<p class="mb-1 text-[10px] font-semibold tracking-wide text-zinc-500 uppercase">
|
||||
Player scores
|
||||
</p>
|
||||
{#each map.scores as score (score.playerId)}
|
||||
{@const side = live.side(score.side)}
|
||||
<div class="flex items-center gap-2 border-t border-white/5 py-1.5 text-xs">
|
||||
<span
|
||||
class="size-2 shrink-0 rounded-full"
|
||||
style="background:{side ? SIDE_COLORS[side.color].solid : '#71717a'}"
|
||||
></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<a
|
||||
href="/users/{score.userId}"
|
||||
class="block truncate font-medium hover:underline">{name(score.playerId)}</a
|
||||
><span class="text-[10px] text-zinc-500"
|
||||
>{score.accuracy ? `${score.accuracy.toFixed(2)}%` : '–'} · {score.combo
|
||||
? `${score.combo}x`
|
||||
: '–'} · {score.mods.filter((mod) => mod !== 'NF').join('') ||
|
||||
'NM'}{#if !score.passed}
|
||||
· <span class="text-red-400">FAIL</span>{/if}</span
|
||||
>
|
||||
</div>
|
||||
<span class="shrink-0 font-mono font-semibold tabular-nums"
|
||||
>{score.score.toLocaleString('en-US')}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="text-xs text-zinc-500">
|
||||
No per-player scores were recorded for this map.
|
||||
</p>
|
||||
{/if}
|
||||
{#if isReferee}
|
||||
<div
|
||||
class="mt-2 flex flex-wrap items-center gap-1.5 border-t border-white/5 pt-2 text-xs"
|
||||
>
|
||||
<span class="w-full text-zinc-500">Correct winner:</span>
|
||||
{#each view.sides as side (side.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-white/10 px-2 py-0.5 hover:bg-white/10"
|
||||
style="color:{SIDE_COLORS[side.color].text}"
|
||||
onclick={() =>
|
||||
live.send({ type: 'correct', mapIndex: map.index, winner: side.id })}
|
||||
>{side.name}</button
|
||||
>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-white/10 px-2 py-0.5 text-zinc-400 hover:bg-white/10"
|
||||
onclick={() =>
|
||||
live.send({ type: 'correct', mapIndex: map.index, winner: null })}
|
||||
>Draw</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
{/if}
|
||||
127
src/lib/components/match/MapPool.svelte
Normal file
127
src/lib/components/match/MapPool.svelte
Normal file
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import Avatar from '$lib/components/Avatar.svelte';
|
||||
import Disc3 from '@lucide/svelte/icons/disc-3';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { SlotView } from '$lib/match-view';
|
||||
import type { MatchLive } from './live.svelte';
|
||||
import MapCard from './MapCard.svelte';
|
||||
import { CATEGORY_NAMES, categoryRank, modColor } from './theme';
|
||||
import { receiveCard, sendCard } from './transitions';
|
||||
import Radio from '@lucide/svelte/icons/radio';
|
||||
|
||||
const { live, compact = false }: { live: MatchLive; compact?: boolean } = $props();
|
||||
const view = $derived(live.view);
|
||||
|
||||
const groups = $derived.by(() => {
|
||||
const byCategory: Record<string, SlotView[]> = {};
|
||||
for (const slot of view.slots) (byCategory[slot.category] ??= []).push(slot);
|
||||
return Object.entries(byCategory).sort(([a], [b]) => categoryRank(a) - categoryRank(b));
|
||||
});
|
||||
|
||||
// Cards whose status just changed play their animation once.
|
||||
const flashing = new SvelteSet<string>();
|
||||
let previous = new Map<string, string>();
|
||||
$effect(() => {
|
||||
const next = new Map(view.slots.map((slot) => [slot.id, slot.status]));
|
||||
if (previous.size) {
|
||||
for (const [id, status] of next) {
|
||||
if (previous.get(id) !== status && (status === 'banned' || status === 'played')) {
|
||||
flashing.add(id);
|
||||
setTimeout(() => flashing.delete(id), 1400);
|
||||
}
|
||||
}
|
||||
}
|
||||
previous = next;
|
||||
});
|
||||
|
||||
const counts = $derived({
|
||||
open: view.slots.filter((slot) => slot.status === 'open' && !slot.tiebreaker).length,
|
||||
banned: view.slots.filter((slot) => slot.status === 'banned').length,
|
||||
played: view.slots.filter((slot) => slot.status === 'played').length
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class={compact ? 'p-2' : 'rounded-2xl border border-white/8 bg-zinc-950/60 p-3 sm:p-4'}>
|
||||
<header class="mb-3 flex flex-wrap items-end justify-between gap-2">
|
||||
<a
|
||||
href={view.pool ? `/mappools/${view.pool.id}` : undefined}
|
||||
class="group flex items-center gap-3"
|
||||
>
|
||||
{#if view.pool?.owner?.iconUrl}
|
||||
<Avatar
|
||||
src={view.pool.owner.iconUrl}
|
||||
name={view.pool.owner.name}
|
||||
size={36}
|
||||
shape="rounded"
|
||||
/>
|
||||
{:else}
|
||||
<span class="grid size-9 place-items-center rounded-xl bg-white/5"
|
||||
><Disc3 class="size-4 text-zinc-300" /></span
|
||||
>
|
||||
{/if}
|
||||
<span>
|
||||
<h2 class="text-sm font-semibold tracking-wide text-zinc-200 uppercase">Map pool</h2>
|
||||
{#if view.pool}
|
||||
<span class="block text-xs text-zinc-500 group-hover:text-zinc-300"
|
||||
>{view.pool.name}{view.pool.owner ? ` · from ${view.pool.owner.name}` : ''}</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
</a>
|
||||
<div class="flex gap-3 text-[11px] text-zinc-500">
|
||||
<span><b class="text-zinc-300">{counts.open}</b> open</span>
|
||||
<span><b class="text-zinc-300">{counts.banned}</b> banned</span>
|
||||
<span><b class="text-zinc-300">{counts.played}</b> played</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each groups as [category, slots] (category)}
|
||||
<div
|
||||
class="grid gap-2 {compact
|
||||
? 'grid-cols-[2.5rem_1fr]'
|
||||
: 'grid-cols-[3.25rem_1fr] sm:grid-cols-[4.5rem_1fr]'}"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-start justify-center gap-0.5 rounded-lg border-l-2 pl-2"
|
||||
style="border-color:{modColor(category)}"
|
||||
>
|
||||
<span class="font-mono text-sm font-black" style="color:{modColor(category)}"
|
||||
>{category}</span
|
||||
>
|
||||
<span class="hidden text-[10px] leading-tight text-zinc-500 sm:block"
|
||||
>{CATEGORY_NAMES[category] ?? ''}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="grid gap-2 {compact
|
||||
? 'grid-cols-2'
|
||||
: 'grid-cols-1 min-[480px]:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'}"
|
||||
>
|
||||
{#each slots as slot (slot.id)}
|
||||
{#if slot.status === 'live'}
|
||||
<div
|
||||
class="flex items-center justify-center gap-2 rounded-xl border-2 border-dashed text-center text-xs font-semibold {compact
|
||||
? 'h-[68px]'
|
||||
: 'h-[92px]'}"
|
||||
style="border-color:{modColor(slot.category)}66;color:{modColor(slot.category)}"
|
||||
>
|
||||
<Radio class="size-4 animate-pulse" />
|
||||
{slot.label} is on stage
|
||||
</div>
|
||||
{:else}
|
||||
<div in:receiveCard={{ key: slot.id }} out:sendCard={{ key: slot.id }}>
|
||||
<MapCard
|
||||
{slot}
|
||||
sides={view.sides}
|
||||
flash={flashing.has(slot.id)}
|
||||
size={compact ? 'sm' : 'md'}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
64
src/lib/components/match/MapReveal.svelte
Normal file
64
src/lib/components/match/MapReveal.svelte
Normal file
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { Tween } from 'svelte/motion';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { onMount } from 'svelte';
|
||||
import type { MapView } from '$lib/match-view';
|
||||
import type { MatchLive } from './live.svelte';
|
||||
import { SIDE_COLORS, formatScore, modColor } from './theme';
|
||||
|
||||
const { live, map }: { live: MatchLive; map: MapView } = $props();
|
||||
const view = $derived(live.view);
|
||||
const slot = $derived(live.slot(map.slotId));
|
||||
const winner = $derived(live.side(map.winner));
|
||||
const values = [0, 1].map(() => new Tween(0, { duration: 1400, easing: cubicOut }));
|
||||
let settled = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
view.sides.forEach((side, index) => (values[index].target = map.totals[side.id] ?? 0));
|
||||
const timer = setTimeout(() => (settled = true), 1400);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="vash-rise relative overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/90 p-5 text-center shadow-2xl backdrop-blur"
|
||||
>
|
||||
{#if slot?.coverUrl}<img
|
||||
src={slot.coverUrl}
|
||||
alt=""
|
||||
class="absolute inset-0 size-full object-cover opacity-20 blur-sm"
|
||||
/>{/if}
|
||||
<div class="relative">
|
||||
<div class="text-xs tracking-widest text-zinc-400 uppercase">
|
||||
Map {map.index + 1} result ·
|
||||
<span class="font-mono font-bold" style="color:{slot ? modColor(slot.category) : '#fff'}"
|
||||
>{slot?.label}</span
|
||||
>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-2 gap-4">
|
||||
{#each view.sides as side, index (side.id)}
|
||||
{@const colors = SIDE_COLORS[side.color]}
|
||||
{@const won = map.winner === side.id}
|
||||
<div
|
||||
class="rounded-xl p-3 transition-all duration-500"
|
||||
style="background:{colors.soft};{settled && won
|
||||
? `box-shadow:0 0 40px -8px ${colors.solid};transform:scale(1.04)`
|
||||
: ''}"
|
||||
>
|
||||
<div class="truncate text-sm font-semibold" style="color:{colors.text}">{side.name}</div>
|
||||
<div class="mt-1 text-2xl font-black tabular-nums sm:text-3xl">
|
||||
{formatScore(values[index].current, view.rules.winCondition)}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if settled}
|
||||
<div
|
||||
class="vash-pop mt-4 text-2xl font-black tracking-tight uppercase"
|
||||
style="color:{winner ? SIDE_COLORS[winner.color].text : '#e4e4e7'}"
|
||||
>
|
||||
{winner ? `${winner.name} takes ${slot?.label}!` : 'A draw — no point'}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
487
src/lib/components/match/MatchDashboard.svelte
Normal file
487
src/lib/components/match/MatchDashboard.svelte
Normal file
@@ -0,0 +1,487 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { MapView, MatchView } from '$lib/match-view';
|
||||
import { MatchLive } from './live.svelte';
|
||||
import Scoreboard from './Scoreboard.svelte';
|
||||
import MapPool from './MapPool.svelte';
|
||||
import MapHistory from './MapHistory.svelte';
|
||||
import MatchFeed from './MatchFeed.svelte';
|
||||
import ActionDialog from './ActionDialog.svelte';
|
||||
import RefereePanel from './RefereePanel.svelte';
|
||||
import SimPanel from './SimPanel.svelte';
|
||||
import StageLobby from './StageLobby.svelte';
|
||||
import StageRoll from './StageRoll.svelte';
|
||||
import StageTurn from './StageTurn.svelte';
|
||||
import NowPlaying from './NowPlaying.svelte';
|
||||
import StageFinal from './StageFinal.svelte';
|
||||
import MapReveal from './MapReveal.svelte';
|
||||
import PredictionCard from '$lib/components/PredictionCard.svelte';
|
||||
import type { PredictionResult, PredictionState } from '$lib/predictions';
|
||||
import { blinkTitle, chime, notify } from './attention';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
import Bell from '@lucide/svelte/icons/bell';
|
||||
import BellOff from '@lucide/svelte/icons/bell-off';
|
||||
import Eye from '@lucide/svelte/icons/eye';
|
||||
import ExternalLink from '@lucide/svelte/icons/external-link';
|
||||
import Pause from '@lucide/svelte/icons/pause';
|
||||
import Wifi from '@lucide/svelte/icons/wifi';
|
||||
import FlaskConical from '@lucide/svelte/icons/flask-conical';
|
||||
import CirclePlay from '@lucide/svelte/icons/circle-play';
|
||||
import WifiOff from '@lucide/svelte/icons/wifi-off';
|
||||
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||
import List from '@lucide/svelte/icons/list';
|
||||
import Layers from '@lucide/svelte/icons/layers';
|
||||
import Trophy from '@lucide/svelte/icons/trophy';
|
||||
import Sparkles from '@lucide/svelte/icons/sparkles';
|
||||
import { SIDE_COLORS, modColor } from './theme';
|
||||
|
||||
const {
|
||||
view,
|
||||
prediction = null,
|
||||
videos = { live: [], recorded: [] },
|
||||
userId,
|
||||
isReferee,
|
||||
devTools
|
||||
}: {
|
||||
view: MatchView;
|
||||
prediction?: PredictionState | null;
|
||||
videos?: {
|
||||
live: { channelId: string; name: string }[];
|
||||
recorded: { channelId: string; sessionId: string; name: string; t: number }[];
|
||||
};
|
||||
userId: string | null;
|
||||
isReferee: boolean;
|
||||
devTools: boolean;
|
||||
} = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const live = new MatchLive(view, userId);
|
||||
$effect(() => live.connect());
|
||||
|
||||
const v = $derived(live.view);
|
||||
const decided = $derived(
|
||||
v.phase === 'closing' || v.phase === 'finished' || v.phase === 'cancelled'
|
||||
);
|
||||
const turnPhase = $derived(['choosing', 'banning', 'picking', 'tb'].includes(v.phase));
|
||||
// svelte-ignore state_referenced_locally
|
||||
const celebrate = !(view.phase === 'closing' || view.phase === 'finished');
|
||||
|
||||
// ── Your turn: open the dialog once per prompt, and get attention ──
|
||||
let dialogOpen = $state(false);
|
||||
let sound = $state(true);
|
||||
let lastPrompt = '';
|
||||
$effect(() => {
|
||||
try {
|
||||
sound = localStorage.getItem('vash:sound') !== 'off';
|
||||
} catch {
|
||||
/* storage blocked */
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
const key = live.myTurn ? `${v.prompt.kind}:${v.prompt.side}:${v.log.length}` : '';
|
||||
if (!key || key === lastPrompt) {
|
||||
if (!live.myTurn) dialogOpen = false;
|
||||
return;
|
||||
}
|
||||
lastPrompt = key;
|
||||
if (v.prompt.kind !== 'roll') dialogOpen = true;
|
||||
const what =
|
||||
v.prompt.kind === 'roll' ? 'roll' : v.prompt.kind === 'choose' ? 'choice' : v.prompt.kind;
|
||||
if (sound) chime();
|
||||
notify(`Your ${what}!`, `${v.sides[0].name} vs ${v.sides[1].name}`);
|
||||
if (document.hidden) return blinkTitle(`🔔 Your ${what}!`);
|
||||
});
|
||||
function toggleSound() {
|
||||
sound = !sound;
|
||||
try {
|
||||
localStorage.setItem('vash:sound', sound ? 'on' : 'off');
|
||||
} catch {
|
||||
/* storage blocked */
|
||||
}
|
||||
if (sound) {
|
||||
chime();
|
||||
if (typeof Notification !== 'undefined' && Notification.permission === 'default')
|
||||
void Notification.requestPermission();
|
||||
}
|
||||
}
|
||||
|
||||
// ── A short result reveal after each map ──
|
||||
let reveal = $state<MapView | null>(null);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let seenMaps = view.maps.length;
|
||||
let revealTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
// Snapshots arrive every second during a map, so the hide timer lives
|
||||
// outside the effect; an effect cleanup would cancel it on every update.
|
||||
$effect(() => {
|
||||
const count = v.maps.length;
|
||||
if (count > seenMaps) {
|
||||
reveal = v.maps.at(-1) ?? null;
|
||||
clearTimeout(revealTimer);
|
||||
revealTimer = setTimeout(() => (reveal = null), 6500);
|
||||
}
|
||||
seenMaps = count;
|
||||
});
|
||||
$effect(() => () => clearTimeout(revealTimer));
|
||||
|
||||
const title = $derived(`${v.sides[0].name} vs ${v.sides[1].name}`);
|
||||
|
||||
// Predictions close when the first map is picked, and settle when a winner is known.
|
||||
const predictionsOpen = $derived(
|
||||
!decided &&
|
||||
!v.live &&
|
||||
v.maps.length === 0 &&
|
||||
['created', 'waiting', 'rolling', 'choosing', 'banning', 'picking'].includes(v.phase)
|
||||
);
|
||||
const predictionResult = $derived.by((): PredictionResult | null => {
|
||||
const winner = v.sides.find((side) => side.id === v.winner);
|
||||
const loser = v.sides.find((side) => side.id !== v.winner);
|
||||
return winner && loser && decided
|
||||
? { winnerTeamId: winner.teamId, score: [winner.points, loser.points] }
|
||||
: null;
|
||||
});
|
||||
let panel = $state<'activity' | 'pool' | 'results' | 'predictions'>('activity');
|
||||
const activeStep = $derived(
|
||||
decided ? 3 : v.phase === 'playing' || turnPhase ? 2 : v.phase === 'rolling' ? 1 : 0
|
||||
);
|
||||
const steps = ['Lobby', 'Roll for order', 'Pick & play', 'Results'];
|
||||
const guidance = $derived.by(() => {
|
||||
if (v.paused) return { label: 'Match paused', detail: 'A referee will resume play.' };
|
||||
if (decided)
|
||||
return { label: 'That’s a wrap', detail: 'Open results for every map and player score.' };
|
||||
if (v.phase === 'created')
|
||||
return {
|
||||
label: 'Lobby is being prepared',
|
||||
detail: 'The invite will arrive in osu! chat when it opens.'
|
||||
};
|
||||
if (v.phase === 'waiting')
|
||||
return live.mySide
|
||||
? {
|
||||
label: 'Join the osu! lobby',
|
||||
detail: 'Accept your invite in osu! chat. Your team’s arrivals update here live.'
|
||||
}
|
||||
: { label: 'Waiting for players', detail: 'Both teams are joining the osu! lobby.' };
|
||||
if (v.phase === 'rolling')
|
||||
return live.myTurn
|
||||
? { label: 'Your turn to roll', detail: 'Roll here or type !roll in the osu! lobby.' }
|
||||
: { label: 'Rolling for order', detail: 'The higher roll decides who goes first.' };
|
||||
if (turnPhase)
|
||||
return live.myTurn
|
||||
? { label: 'Your move', detail: 'Choose below before the timer runs out.' }
|
||||
: {
|
||||
label: 'Waiting for a choice',
|
||||
detail: `${live.side(v.prompt.side)?.name ?? 'A team'} is making the next move.`
|
||||
};
|
||||
if (v.phase === 'playing')
|
||||
return v.live?.startedAt
|
||||
? { label: 'Map in progress', detail: 'Scores and progress update as players finish.' }
|
||||
: { label: 'Ready up in osu!', detail: 'The selected map is loading. Get ready to play.' };
|
||||
return { label: 'Match in progress', detail: 'Updates appear here as they happen.' };
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{v.sides[0].points}–{v.sides[1].points} · {title} · Vash Esports</title>
|
||||
<meta name="description" content="{title}, best of {v.rules.bestOf} on Vash Esports." />
|
||||
<meta property="og:title" content="{title} · Vash Esports" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="{v.sides[0].points}–{v.sides[1].points} · best of {v.rules.bestOf}"
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
content={v.slots.find((slot) => slot.coverUrl)?.coverUrl ?? '/logo.png'}
|
||||
/>
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto flex max-w-[1600px] flex-col gap-3 lg:h-[calc(100dvh-6.5rem)] lg:min-h-[680px]">
|
||||
<header class="flex min-h-10 flex-wrap items-center gap-2">
|
||||
<a
|
||||
href="/matches"
|
||||
class="grid size-9 place-items-center rounded-lg border border-white/10 text-zinc-400 transition hover:bg-white/5 hover:text-white"
|
||||
aria-label="All matches"><ArrowLeft class="size-4" /></a
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="truncate text-base font-bold tracking-tight sm:text-lg">{v.name ?? title}</h1>
|
||||
<div class="flex flex-wrap items-center gap-x-2 text-xs text-zinc-500">
|
||||
{#if v.tournament}
|
||||
<a
|
||||
href="/tournaments/{v.tournament.id}"
|
||||
class="font-medium hover:underline"
|
||||
style="color:{v.tournament.accentColor ?? '#e4e4e7'}"
|
||||
>{v.tournament.name}{v.tournament.round ? ` · ${v.tournament.round}` : ''}</a
|
||||
>
|
||||
{:else}
|
||||
<span class="capitalize">{v.kind} match</span>
|
||||
{/if}
|
||||
<span>·</span><span>BO{v.rules.bestOf} · {v.rules.teamSize}v{v.rules.teamSize}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="hidden items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium sm:flex {decided ||
|
||||
live.connected
|
||||
? 'border-emerald-400/20 bg-emerald-400/5 text-emerald-300'
|
||||
: 'border-amber-400/30 bg-amber-400/10 text-amber-300'}"
|
||||
title={decided
|
||||
? 'Final score'
|
||||
: live.connected
|
||||
? 'Live updates connected'
|
||||
: live.fallbackConnected
|
||||
? 'Updating every five seconds'
|
||||
: 'Reconnecting to live updates'}
|
||||
>
|
||||
{#if decided}<Trophy class="size-3.5" />Final{:else if live.connected}<Wifi
|
||||
class="size-3.5"
|
||||
/>Live · <Eye class="size-3.5" />{live.viewers}{:else if live.fallbackConnected}<Wifi
|
||||
class="size-3.5"
|
||||
/>Updating{:else}<WifiOff class="size-3.5" />Reconnecting{/if}
|
||||
</span>
|
||||
{#each videos.live as stream (stream.channelId)}
|
||||
<a
|
||||
href="/watch/{stream.channelId}"
|
||||
class="flex items-center gap-1.5 rounded-lg bg-red-500 px-3 py-1.5 text-sm font-semibold text-white hover:bg-red-400"
|
||||
><CirclePlay class="size-4" />Watch on {stream.name}</a
|
||||
>
|
||||
{/each}
|
||||
{#each videos.recorded.slice(0, 1) as recording (recording.sessionId)}
|
||||
<a
|
||||
href="/watch/{recording.channelId}?session={recording.sessionId}&t={recording.t}"
|
||||
class="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-sm hover:bg-white/5"
|
||||
><CirclePlay class="size-4" />Watch the broadcast</a
|
||||
>
|
||||
{/each}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onclick={toggleSound}
|
||||
title={sound ? 'Turn alerts off' : 'Turn alerts on'}
|
||||
>
|
||||
{#if sound}<Bell class="size-4" />{:else}<BellOff class="size-4" />{/if}
|
||||
</Button>
|
||||
<details class="relative z-20">
|
||||
<summary
|
||||
class="flex h-8 cursor-pointer list-none items-center gap-1 rounded-lg border border-white/10 px-2.5 text-xs text-zinc-300 hover:bg-white/5"
|
||||
>Details <ChevronDown class="size-3" /></summary
|
||||
>
|
||||
<div
|
||||
class="absolute right-0 mt-2 w-64 rounded-xl border border-white/10 bg-zinc-900 p-3 text-xs shadow-2xl"
|
||||
>
|
||||
<p class="mb-2 font-semibold text-white">Match details</p>
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-zinc-400">
|
||||
<dt>Format</dt>
|
||||
<dd class="text-right text-zinc-200">
|
||||
BO{v.rules.bestOf} · {v.rules.teamSize}v{v.rules.teamSize}
|
||||
</dd>
|
||||
<dt>Win by</dt>
|
||||
<dd class="text-right text-zinc-200 capitalize">
|
||||
{v.rules.winCondition.replace('_', ' ')}
|
||||
</dd>
|
||||
<dt>Viewers</dt>
|
||||
<dd class="text-right text-zinc-200">{live.viewers}</dd>
|
||||
</dl>
|
||||
{#if v.lobby.id && v.lobby.provider === 'irc'}
|
||||
<a
|
||||
href="https://osu.ppy.sh/mp/{v.lobby.id}"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="mt-3 flex items-center justify-between border-t border-white/10 pt-3 text-zinc-200 hover:text-white"
|
||||
>Open osu! lobby <ExternalLink class="size-3" /></a
|
||||
>
|
||||
{:else if v.lobby.provider === 'simulator' && v.lobby.id}
|
||||
<p class="mt-3 flex items-center gap-1.5 border-t border-white/10 pt-3 text-fuchsia-300">
|
||||
<FlaskConical class="size-3" />Simulated lobby
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</details>
|
||||
{#if devTools && v.sim}<SimPanel {live} />{/if}
|
||||
{#if isReferee}<RefereePanel {live} />{/if}
|
||||
</header>
|
||||
|
||||
<Scoreboard {live} />
|
||||
|
||||
<div class="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1fr)_23rem]">
|
||||
<section
|
||||
class="relative flex min-h-[25rem] flex-col overflow-hidden rounded-2xl border border-white/10 bg-gradient-to-b from-zinc-900/90 to-zinc-950 lg:min-h-0"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-2 border-b border-white/8 px-4 py-3 sm:px-5"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-xs font-semibold text-zinc-300">
|
||||
<span
|
||||
class="size-2 rounded-full {live.myTurn
|
||||
? 'animate-pulse bg-amber-300'
|
||||
: 'bg-emerald-400'}"
|
||||
></span>{guidance.label}
|
||||
</div>
|
||||
<nav aria-label="Match progress" class="flex items-center gap-1.5">
|
||||
{#each steps as step, index}
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-[10px] font-semibold {index === activeStep
|
||||
? 'bg-white/10 text-white'
|
||||
: index < activeStep
|
||||
? 'text-emerald-300'
|
||||
: 'text-zinc-600'}">{index < activeStep ? '✓ ' : ''}{step}</span
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 py-4 sm:px-6 sm:py-5">
|
||||
<div class="my-auto w-full">
|
||||
<p class="mb-4 text-center text-xs text-zinc-500">{guidance.detail}</p>
|
||||
{#if v.paused && !decided}
|
||||
<div
|
||||
transition:fade
|
||||
class="mb-4 flex items-center gap-2 rounded-xl border border-amber-400/30 bg-amber-400/10 p-3 text-sm text-amber-200"
|
||||
>
|
||||
<Pause class="size-4 shrink-0" />
|
||||
<span class="flex-1">{v.paused.reason}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if reveal}
|
||||
{#key reveal.index}
|
||||
<div class="mb-4" out:fade={{ duration: 300 }}><MapReveal {live} map={reveal} /></div>
|
||||
{/key}
|
||||
{/if}
|
||||
{#key v.phase === 'playing' ? `playing:${v.live?.slotId}` : v.phase}
|
||||
<div in:fade={{ duration: 250 }}>
|
||||
{#if decided}
|
||||
<StageFinal {live} {celebrate} />
|
||||
{:else if v.phase === 'created' || v.phase === 'waiting'}
|
||||
<StageLobby {live} {isReferee} />
|
||||
{:else if v.phase === 'rolling'}
|
||||
<StageRoll {live} />
|
||||
{:else if turnPhase}
|
||||
<StageTurn {live} onact={() => (dialogOpen = true)} />
|
||||
{:else if v.phase === 'playing' && v.live}
|
||||
<NowPlaying {live} />
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-white/8 bg-black/20 px-3 py-2.5 sm:px-4">
|
||||
<div class="mb-2 flex items-center justify-between text-[11px] font-semibold text-zinc-400">
|
||||
<span>MAP JOURNEY</span><span>{v.maps.length} played · first to {v.rules.winsNeeded}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex gap-1.5 overflow-x-auto pb-1">
|
||||
{#each Array.from({ length: v.rules.bestOf }) as _, index}
|
||||
{@const map = v.maps[index]}
|
||||
{@const isLive = !map && !!v.live && index === v.maps.length}
|
||||
{@const slot = map ? live.slot(map.slotId) : isLive ? live.slot(v.live?.slotId) : null}
|
||||
{@const winner = map ? live.side(map.winner) : null}
|
||||
<div
|
||||
class="flex min-w-24 flex-1 basis-24 items-center gap-2 rounded-lg border px-2 py-1.5 text-xs {isLive
|
||||
? 'border-emerald-400/40 bg-emerald-400/10'
|
||||
: map
|
||||
? 'border-white/12 bg-white/5'
|
||||
: 'border-white/5 bg-white/[0.02] text-zinc-600'}"
|
||||
>
|
||||
<span
|
||||
class="grid size-6 shrink-0 place-items-center rounded-md bg-black/30 font-mono text-[10px] font-bold"
|
||||
style={slot ? `color:${modColor(slot.category)}` : ''}
|
||||
>{slot?.label ?? index + 1}</span
|
||||
>
|
||||
<span class="min-w-0 truncate"
|
||||
>{isLive
|
||||
? 'Now playing'
|
||||
: winner
|
||||
? winner.name
|
||||
: map
|
||||
? 'Draw'
|
||||
: `Map ${index + 1}`}</span
|
||||
>
|
||||
{#if winner}<span
|
||||
class="ml-auto size-2 shrink-0 rounded-full"
|
||||
style="background:{SIDE_COLORS[winner.color].solid}"
|
||||
></span>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<aside
|
||||
class="flex min-h-[26rem] flex-col overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/70 lg:min-h-0"
|
||||
aria-label="Match information"
|
||||
>
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-1 border-b border-white/8 p-2"
|
||||
role="tablist"
|
||||
aria-label="Match information"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={panel === 'activity'}
|
||||
onclick={() => (panel = 'activity')}
|
||||
class="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-2 py-2 text-xs font-semibold transition {panel ===
|
||||
'activity'
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-zinc-500 hover:bg-white/5 hover:text-zinc-200'}"
|
||||
><List class="size-3.5" />Activity</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={panel === 'pool'}
|
||||
onclick={() => (panel = 'pool')}
|
||||
class="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-2 py-2 text-xs font-semibold transition {panel ===
|
||||
'pool'
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-zinc-500 hover:bg-white/5 hover:text-zinc-200'}"
|
||||
><Layers class="size-3.5" />Maps</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={panel === 'results'}
|
||||
onclick={() => (panel = 'results')}
|
||||
class="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-2 py-2 text-xs font-semibold transition {panel ===
|
||||
'results'
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-zinc-500 hover:bg-white/5 hover:text-zinc-200'}"
|
||||
><Trophy class="size-3.5" />Results</button
|
||||
>
|
||||
{#if prediction}<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={panel === 'predictions'}
|
||||
onclick={() => (panel = 'predictions')}
|
||||
class="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-2 py-2 text-xs font-semibold transition {panel ===
|
||||
'predictions'
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-zinc-500 hover:bg-white/5 hover:text-zinc-200'}"
|
||||
><Sparkles class="size-3.5" />Picks</button
|
||||
>{/if}
|
||||
</div>
|
||||
<div class="min-h-0 flex-1" role="tabpanel">
|
||||
{#if panel === 'activity'}
|
||||
<MatchFeed {live} embedded />
|
||||
{:else if panel === 'pool'}
|
||||
<div class="h-full overflow-y-auto p-2"><MapPool {live} compact /></div>
|
||||
{:else if panel === 'results'}
|
||||
<div class="h-full overflow-y-auto p-2">
|
||||
{#if v.maps.length}<MapHistory {live} {isReferee} embedded />{:else}<p
|
||||
class="px-3 py-12 text-center text-sm text-zinc-500"
|
||||
>
|
||||
Map results will appear here after the first play.
|
||||
</p>{/if}
|
||||
</div>
|
||||
{:else if prediction}
|
||||
<div class="h-full overflow-y-auto p-2">
|
||||
<PredictionCard
|
||||
state={prediction}
|
||||
crowd={live.crowd}
|
||||
open={predictionsOpen}
|
||||
result={predictionResult}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ActionDialog {live} bind:open={dialogOpen} />
|
||||
115
src/lib/components/match/MatchFeed.svelte
Normal file
115
src/lib/components/match/MatchFeed.svelte
Normal file
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import type { LogLine } from '$lib/match-view';
|
||||
import type { MatchLive } from './live.svelte';
|
||||
import { SIDE_COLORS } from './theme';
|
||||
import Ban from '@lucide/svelte/icons/ban';
|
||||
import Crosshair from '@lucide/svelte/icons/crosshair';
|
||||
import Dice from '@lucide/svelte/icons/dices';
|
||||
import Flag from '@lucide/svelte/icons/flag';
|
||||
import Info from '@lucide/svelte/icons/info';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Scale from '@lucide/svelte/icons/scale';
|
||||
import Shield from '@lucide/svelte/icons/shield';
|
||||
import Trophy from '@lucide/svelte/icons/trophy';
|
||||
import TriangleAlert from '@lucide/svelte/icons/triangle-alert';
|
||||
import UserCheck from '@lucide/svelte/icons/user-check';
|
||||
|
||||
const { live, embedded = false }: { live: MatchLive; embedded?: boolean } = $props();
|
||||
const view = $derived(live.view);
|
||||
const icons: Record<LogLine['kind'], typeof Info> = {
|
||||
info: Info,
|
||||
join: UserCheck,
|
||||
roll: Dice,
|
||||
choose: Scale,
|
||||
ban: Ban,
|
||||
pick: Crosshair,
|
||||
start: Play,
|
||||
result: Trophy,
|
||||
warn: TriangleAlert,
|
||||
ref: Shield,
|
||||
end: Flag
|
||||
};
|
||||
const events = $derived([...view.log].reverse());
|
||||
|
||||
let chatBox = $state<HTMLDivElement | null>(null);
|
||||
let stick = true;
|
||||
$effect(() => {
|
||||
void view.chat.length;
|
||||
if (!chatBox || !stick) return;
|
||||
void tick().then(() => chatBox && (chatBox.scrollTop = chatBox.scrollHeight));
|
||||
});
|
||||
function time(t: number) {
|
||||
return new Date(t).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<Tabs.Root
|
||||
value="events"
|
||||
class="flex h-full min-h-0 flex-col {embedded
|
||||
? ''
|
||||
: 'rounded-2xl border border-white/8 bg-zinc-950/60'}"
|
||||
>
|
||||
<Tabs.List class="m-2 grid grid-cols-2">
|
||||
<Tabs.Trigger value="events">Timeline</Tabs.Trigger>
|
||||
<Tabs.Trigger value="chat"
|
||||
>Lobby chat{view.chat.length ? ` (${view.chat.length})` : ''}</Tabs.Trigger
|
||||
>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="events" class="min-h-0 flex-1 overflow-y-auto px-3 pb-3">
|
||||
<ol class="space-y-1.5">
|
||||
{#each events as line, index (line.t + ':' + line.text + ':' + index)}
|
||||
{@const Icon = icons[line.kind] ?? Info}
|
||||
{@const side = live.side(line.side)}
|
||||
<li class="flex gap-2 text-[13px] leading-snug {index === 0 ? 'vash-rise' : ''}">
|
||||
<span
|
||||
class="mt-0.5 grid size-5 shrink-0 place-items-center rounded-md"
|
||||
style="background:{side
|
||||
? SIDE_COLORS[side.color].soft
|
||||
: 'rgb(255 255 255 / 0.06)'};color:{side
|
||||
? SIDE_COLORS[side.color].text
|
||||
: line.kind === 'warn'
|
||||
? '#fcd34d'
|
||||
: '#a1a1aa'}"
|
||||
>
|
||||
<Icon class="size-3" />
|
||||
</span>
|
||||
<span class="flex-1 {line.kind === 'warn' ? 'text-amber-200' : 'text-zinc-300'}"
|
||||
>{line.text}</span
|
||||
>
|
||||
<span class="shrink-0 font-mono text-[10px] text-zinc-600">{time(line.t)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="chat" class="min-h-0 flex-1">
|
||||
<div
|
||||
bind:this={chatBox}
|
||||
onscroll={() =>
|
||||
chatBox && (stick = chatBox.scrollHeight - chatBox.scrollTop - chatBox.clientHeight < 40)}
|
||||
class="h-full max-h-[28rem] overflow-y-auto px-3 pb-3 font-mono text-[12px] leading-relaxed lg:max-h-none"
|
||||
>
|
||||
{#if view.chat.length === 0}
|
||||
<p class="py-8 text-center font-sans text-sm text-zinc-500">No messages yet.</p>
|
||||
{/if}
|
||||
{#each view.chat as line, index (line.t + ':' + index)}
|
||||
<div class="break-words">
|
||||
<span class="text-zinc-600">{time(line.t)}</span>
|
||||
<span
|
||||
class="font-semibold {line.role === 'bancho'
|
||||
? 'text-pink-400'
|
||||
: line.role === 'host'
|
||||
? 'text-sky-400'
|
||||
: 'text-zinc-100'}">{line.role === 'host' ? 'Vash' : line.from}:</span
|
||||
>
|
||||
<span class="text-zinc-300">{line.text}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
158
src/lib/components/match/NowPlaying.svelte
Normal file
158
src/lib/components/match/NowPlaying.svelte
Normal file
@@ -0,0 +1,158 @@
|
||||
<script lang="ts">
|
||||
import { Tween } from 'svelte/motion';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import type { MatchLive } from './live.svelte';
|
||||
import MapCard from './MapCard.svelte';
|
||||
import { SIDE_COLORS, formatScore, modColor, playerHref } from './theme';
|
||||
import { receiveCard, sendCard } from './transitions';
|
||||
import Radio from '@lucide/svelte/icons/radio';
|
||||
import Hourglass from '@lucide/svelte/icons/hourglass';
|
||||
|
||||
const { live }: { live: MatchLive } = $props();
|
||||
const view = $derived(live.view);
|
||||
const current = $derived(view.live!);
|
||||
const slot = $derived(live.slot(current.slotId)!);
|
||||
const picker = $derived(live.side(current.pickedBy));
|
||||
const started = $derived(current.startedAt != null);
|
||||
const starting = $derived(!started && current.startRequestedAt != null);
|
||||
const speed = $derived(
|
||||
slot.mods.includes('DT') || slot.mods.includes('NC') ? 1.5 : slot.mods.includes('HT') ? 0.75 : 1
|
||||
);
|
||||
|
||||
const players = $derived(
|
||||
view.sides
|
||||
.flatMap((side) => side.players.map((player) => ({ player, side })))
|
||||
.filter(({ player }) => player.live)
|
||||
);
|
||||
const reported = $derived(
|
||||
Math.max(0, ...players.map(({ player }) => player.live?.progress ?? 0))
|
||||
);
|
||||
const progress = $derived(
|
||||
!started
|
||||
? 0
|
||||
: reported > 0
|
||||
? reported
|
||||
: Math.min(1, (live.now - current.startedAt!) / (((slot.lengthSec ?? 180) * 1000) / speed))
|
||||
);
|
||||
const totals = $derived(
|
||||
view.sides.map((side) =>
|
||||
side.players.reduce((sum, player) => sum + (player.live?.score ?? 0), 0)
|
||||
)
|
||||
);
|
||||
const red = new Tween(0, { duration: 700, easing: cubicOut });
|
||||
const blue = new Tween(0, { duration: 700, easing: cubicOut });
|
||||
$effect(() => {
|
||||
red.target = totals[0];
|
||||
blue.target = totals[1];
|
||||
});
|
||||
const share = $derived(
|
||||
red.current + blue.current > 0 ? red.current / (red.current + blue.current) : 0.5
|
||||
);
|
||||
const leading = $derived(
|
||||
totals[0] === totals[1] ? null : totals[0] > totals[1] ? view.sides[0] : view.sides[1]
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2 text-sm font-semibold">
|
||||
{#if started}
|
||||
<span class="flex items-center gap-1.5 text-emerald-300"
|
||||
><Radio class="size-4 animate-pulse" /> Live</span
|
||||
>
|
||||
{:else if starting}
|
||||
<span class="text-amber-300">Starting…</span>
|
||||
{:else}
|
||||
<span class="flex items-center gap-1.5 text-zinc-300"
|
||||
><Hourglass class="size-4" /> Ready up in osu!</span
|
||||
>
|
||||
{#if view.deadlineAt}<span class="text-xs text-zinc-500"
|
||||
>force start in {live.secondsLeft()}s</span
|
||||
>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-xs text-zinc-400">
|
||||
{#if current.tiebreaker}
|
||||
<span class="font-bold text-white">Tiebreaker</span>
|
||||
{:else if picker}
|
||||
Picked by <span class="font-semibold" style="color:{SIDE_COLORS[picker.color].text}"
|
||||
>{picker.name}</span
|
||||
>
|
||||
{/if}
|
||||
{#if current.attempt > 1}<span class="ml-2 rounded bg-amber-400/15 px-1.5 text-amber-300"
|
||||
>replay #{current.attempt}</span
|
||||
>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
in:receiveCard|global={{ key: slot.id }}
|
||||
out:sendCard|global={{ key: slot.id }}
|
||||
class="relative"
|
||||
>
|
||||
<MapCard {slot} sides={view.sides} size="lg" />
|
||||
<div class="absolute right-0 bottom-0 left-0 h-1 overflow-hidden rounded-b-xl bg-white/10">
|
||||
<div
|
||||
class="h-full transition-[width] duration-1000 ease-linear"
|
||||
style="width:{progress * 100}%;background:{modColor(slot.category)}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-1.5 text-[11px]">
|
||||
{#each slot.mods as mod (mod)}<span class="rounded bg-white/10 px-1.5 py-0.5 font-semibold"
|
||||
>{mod}</span
|
||||
>{/each}
|
||||
{#if slot.freemod}<span
|
||||
class="rounded bg-emerald-400/15 px-1.5 py-0.5 font-semibold text-emerald-300">Freemod</span
|
||||
>{/if}
|
||||
<span class="rounded bg-white/5 px-1.5 py-0.5 text-zinc-400"
|
||||
>{view.rules.winCondition.replace('_', ' ').toUpperCase()}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if players.length}
|
||||
<div class="space-y-2">
|
||||
<div class="flex h-9 overflow-hidden rounded-lg text-xs font-bold">
|
||||
<div
|
||||
class="flex items-center justify-start px-2.5 transition-all duration-700"
|
||||
style="width:{share * 100}%;background:{SIDE_COLORS.red.solid}"
|
||||
>
|
||||
{formatScore(red.current, view.rules.winCondition)}
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-1 items-center justify-end px-2.5"
|
||||
style="background:{SIDE_COLORS.blue.solid}"
|
||||
>
|
||||
{formatScore(blue.current, view.rules.winCondition)}
|
||||
</div>
|
||||
</div>
|
||||
{#if leading}
|
||||
<p class="text-center text-xs text-zinc-400">
|
||||
<span style="color:{SIDE_COLORS[leading.color].text}" class="font-semibold"
|
||||
>{leading.name}</span
|
||||
>
|
||||
leads by
|
||||
{formatScore(Math.abs(totals[0] - totals[1]), view.rules.winCondition)}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="grid gap-1.5 sm:grid-cols-2">
|
||||
{#each players as { player, side } (player.id)}
|
||||
<div class="flex items-center gap-2 rounded-lg bg-white/5 px-2 py-1.5 text-xs">
|
||||
<span class="h-6 w-1 rounded" style="background:{SIDE_COLORS[side.color].solid}"></span>
|
||||
<a href={playerHref(player)} class="flex-1 truncate hover:underline"
|
||||
>{player.osuName ?? player.name}</a
|
||||
>
|
||||
<span class="text-zinc-400 tabular-nums">{player.live!.accuracy.toFixed(2)}%</span>
|
||||
<span class="text-zinc-400 tabular-nums">{player.live!.combo}x</span>
|
||||
<span class="w-20 text-right font-semibold tabular-nums"
|
||||
>{player.live!.score.toLocaleString('en-US')}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if started}
|
||||
<p class="text-center text-xs text-zinc-500">Scores appear the moment the map ends.</p>
|
||||
{/if}
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user