Compare commits

...

4 Commits

Author SHA1 Message Date
331507ee40 fix: migrate with the owner role and a private health file
The app's database role cannot create tables in public, so migrations
failed with "permission denied for schema public". They now also load
/etc/esports/{prod,staging}-migrations.env, whose DATABASE_URL overrides
the app's. The health pre-check wrote a fixed /tmp file that another user
already owned, which silently skipped the live-match check; it uses mktemp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:47:58 +00:00
6ff5fe53c5 fix: deploy with only sudo tee and docker
Some checks failed
Test and deploy staging / staging (push) Failing after 16s
The deploy user has passwordless sudo for tee and docker only, so the
script stopped at its first sudo install. release.env is rewritten with
tee, and the backup container creates the backup directory and sets the
file modes itself. HOST_PORT comes from the target, not release.env.

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:02:25 +00:00
285 changed files with 9847 additions and 2026 deletions

View File

@@ -1,8 +1,6 @@
FROM oven/bun:1.4.0 AS builder
WORKDIR /app
COPY package.json bun.lock ./
# Patched dependencies are applied during install.
COPY patches ./patches
RUN bun install --frozen-lockfile
COPY . .
ENV NODE_ENV=production

497
STREAMING_HANDOFF.md Normal file
View File

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

112
bun.lock
View File

@@ -17,8 +17,9 @@
"@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/adapter-bun": "^1.0.0",
"@sveltejs/kit": "^3.0.0",
"@sveltejs/load-config": "^0.2.2",
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3",
@@ -51,7 +52,7 @@
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "~5.8.3",
"typescript": "^6.0.0",
"typescript-eslint": "^8.70.1",
"vite": "^8.3.1",
"vite-plugin-devtools-json": "^1.1.0",
@@ -59,9 +60,6 @@
},
},
},
"patchedDependencies": {
"@sveltejs/kit@2.70.3": "patches/@sveltejs%2Fkit@2.70.3.patch",
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
@@ -281,8 +279,6 @@
"@lucide/svelte": ["@lucide/svelte@1.48.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-bLimVDjehxu3fpu9XEUpqEkBk2sJAHAT2sVcLHhlM70EdNsXuj9ZnYENWv5xgw3+05h4i5TirAxTiTgnZZf8gw=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
"@noble/ciphers": ["@noble/ciphers@2.4.0", "", {}, "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw=="],
"@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="],
@@ -343,66 +339,6 @@
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@29.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-ZaOxZceP7SOUW7Lqw5IRVweSQYWaeIPnXIGLiB690EBA3FGJTO40EEr2L5yZplJWsgTCogILRSpcAe7+U0Otdg=="],
"@rollup/plugin-json": ["@rollup/plugin-json@6.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA=="],
"@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@16.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg=="],
"@rollup/plugin-replace": ["@rollup/plugin-replace@6.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "magic-string": "^0.30.3" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA=="],
"@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.63.5", "", { "os": "android", "cpu": "arm" }, "sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.63.5", "", { "os": "android", "cpu": "arm64" }, "sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.63.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.63.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.63.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.63.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.63.5", "", { "os": "linux", "cpu": "arm" }, "sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.63.5", "", { "os": "linux", "cpu": "arm" }, "sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.63.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.63.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.63.5", "", { "os": "linux", "cpu": "none" }, "sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.63.5", "", { "os": "linux", "cpu": "none" }, "sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.63.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.63.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.63.5", "", { "os": "linux", "cpu": "none" }, "sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.63.5", "", { "os": "linux", "cpu": "none" }, "sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.63.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.63.5", "", { "os": "linux", "cpu": "x64" }, "sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.63.5", "", { "os": "linux", "cpu": "x64" }, "sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.63.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.63.5", "", { "os": "none", "cpu": "arm64" }, "sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.63.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.63.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.63.5", "", { "os": "win32", "cpu": "x64" }, "sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.63.5", "", { "os": "win32", "cpu": "x64" }, "sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw=="],
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
"@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="],
@@ -467,9 +403,9 @@
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.13", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ=="],
"@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.5.7", "", { "dependencies": { "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "@rollup/plugin-replace": "^6.0.3", "rollup": "^4.59.0" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-uOfc9eVlI3A37RRSaKcgrheBYPrfJwC9VMqDp8x/O6tlKdcLLvHThSWD0KNIbjQ/d+7bwLGx3vx6aowAcRfd2g=="],
"@sveltejs/adapter-bun": ["@sveltejs/adapter-bun@1.0.0", "", { "dependencies": { "bun-types": "^1.3.14" }, "peerDependencies": { "@sveltejs/kit": "^3.0.0-next.0" } }, "sha512-CUfFNxrWrKk/0VqSM1DrXCAwpY3tWocaFwBohuBeyxKx07859shLqPdT9oo00lDti9PvVwhQMDS6bKQylTyv0g=="],
"@sveltejs/kit": ["@sveltejs/kit@2.70.3", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg=="],
"@sveltejs/kit": ["@sveltejs/kit@3.1.0", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1", "@standard-schema/spec": "^1.1.0", "cookie": "^2.0.1", "devalue": "^5.9.4", "esm-env": "^1.2.2", "magic-string": "^1.2.3", "mrmime": "^2.0.1", "sirv": "^3.0.2" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0", "svelte": "^5.57.1", "typescript": "^6.0.0", "vite": "^8.0.12" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-JTWlq2HQlkqx6vIoxhC0R6sc4WVqbQoRyEmr5YlqKmiYbzY1U3awtSWTmjMLlS056a49AmkQR6rnNANXIE8B0Q=="],
"@sveltejs/load-config": ["@sveltejs/load-config@0.2.3", "", {}, "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ=="],
@@ -515,8 +451,6 @@
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
@@ -527,8 +461,6 @@
"@types/node": ["@types/node@25.9.8", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-VfMrScDmMhUJQmd5hArdQnFvK0OIeD36uN2Va1FcpYaLB/BgkgM8Ulc50XtYISPMz7APJ30+N0T5EM0jlcdfRw=="],
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.70.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.70.1", "@typescript-eslint/type-utils": "8.70.1", "@typescript-eslint/utils": "8.70.1", "@typescript-eslint/visitor-keys": "8.70.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.70.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA=="],
@@ -643,8 +575,6 @@
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
"compare-func": ["compare-func@2.0.0", "", { "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA=="],
"conventional-changelog-angular": ["conventional-changelog-angular@8.3.1", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg=="],
@@ -655,7 +585,7 @@
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
"cookie": ["cookie@2.0.1", "", {}, "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w=="],
"cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="],
@@ -707,8 +637,6 @@
"error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
"es-toolkit": ["es-toolkit@1.52.0", "", {}, "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA=="],
@@ -741,7 +669,7 @@
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
@@ -773,8 +701,6 @@
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
@@ -801,8 +727,6 @@
"hashery": ["hashery@1.5.1", "", { "dependencies": { "hookified": "^1.15.0" } }, "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ=="],
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
"hls.js": ["hls.js@1.7.3", "", {}, "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A=="],
@@ -829,16 +753,12 @@
"is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
"is-core-module": ["is-core-module@2.17.0", "", { "dependencies": { "hasown": "^2.0.4" } }, "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
"is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
@@ -869,8 +789,6 @@
"keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"known-css-properties": ["known-css-properties@0.37.0", "", {}, "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="],
"kysely": ["kysely@0.28.17", "", {}, "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q=="],
@@ -979,8 +897,6 @@
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
@@ -1043,8 +959,6 @@
"require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="],
"resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
@@ -1055,8 +969,6 @@
"rolldown": ["rolldown@1.2.11", "", { "dependencies": { "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.11", "@rolldown/binding-android-arm64": "1.2.11", "@rolldown/binding-darwin-arm64": "1.2.11", "@rolldown/binding-darwin-x64": "1.2.11", "@rolldown/binding-freebsd-x64": "1.2.11", "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", "@rolldown/binding-linux-arm64-gnu": "1.2.11", "@rolldown/binding-linux-arm64-musl": "1.2.11", "@rolldown/binding-linux-ppc64-gnu": "1.2.11", "@rolldown/binding-linux-s390x-gnu": "1.2.11", "@rolldown/binding-linux-x64-gnu": "1.2.11", "@rolldown/binding-linux-x64-musl": "1.2.11", "@rolldown/binding-openharmony-arm64": "1.2.11", "@rolldown/binding-win32-arm64-msvc": "1.2.11", "@rolldown/binding-win32-x64-msvc": "1.2.11" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw=="],
"rollup": ["rollup@4.63.5", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.5", "@rollup/rollup-android-arm64": "4.63.5", "@rollup/rollup-darwin-arm64": "4.63.5", "@rollup/rollup-darwin-x64": "4.63.5", "@rollup/rollup-freebsd-arm64": "4.63.5", "@rollup/rollup-freebsd-x64": "4.63.5", "@rollup/rollup-linux-arm-gnueabihf": "4.63.5", "@rollup/rollup-linux-arm-musleabihf": "4.63.5", "@rollup/rollup-linux-arm64-gnu": "4.63.5", "@rollup/rollup-linux-arm64-musl": "4.63.5", "@rollup/rollup-linux-loong64-gnu": "4.63.5", "@rollup/rollup-linux-loong64-musl": "4.63.5", "@rollup/rollup-linux-ppc64-gnu": "4.63.5", "@rollup/rollup-linux-ppc64-musl": "4.63.5", "@rollup/rollup-linux-riscv64-gnu": "4.63.5", "@rollup/rollup-linux-riscv64-musl": "4.63.5", "@rollup/rollup-linux-s390x-gnu": "4.63.5", "@rollup/rollup-linux-x64-gnu": "4.63.5", "@rollup/rollup-linux-x64-musl": "4.63.5", "@rollup/rollup-openbsd-x64": "4.63.5", "@rollup/rollup-openharmony-arm64": "4.63.5", "@rollup/rollup-win32-arm64-msvc": "4.63.5", "@rollup/rollup-win32-ia32-msvc": "4.63.5", "@rollup/rollup-win32-x64-gnu": "4.63.5", "@rollup/rollup-win32-x64-msvc": "4.63.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw=="],
"rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
"runed": ["runed@0.35.1", "", { "dependencies": { "dequal": "^2.0.3", "esm-env": "^1.0.0", "lz-string": "^1.5.0" }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" }, "optionalPeers": ["@sveltejs/kit"] }, "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q=="],
@@ -1111,8 +1023,6 @@
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"svelte": ["svelte@5.57.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@jridgewell/sourcemap-codec": "^1.6.0", "@sveltejs/acorn-typescript": "^1.0.13", "@types/estree": "^1.0.9", "acorn": "^8.18.0", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.9.2", "esm-env": "^1.2.1", "esrap": "^2.3.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA=="],
"svelte-check": ["svelte-check@4.7.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.3", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw=="],
@@ -1161,7 +1071,7 @@
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"typescript-eslint": ["typescript-eslint@8.70.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.70.1", "@typescript-eslint/parser": "8.70.1", "@typescript-eslint/typescript-estree": "8.70.1", "@typescript-eslint/utils": "8.70.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA=="],
@@ -1231,7 +1141,7 @@
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
"@sveltejs/kit/magic-string": ["magic-string@1.4.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g=="],
"@sveltejs/vite-plugin-svelte/magic-string": ["magic-string@1.4.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g=="],
@@ -1253,8 +1163,6 @@
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.10", "", {}, "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q=="],
"@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"better-call/@better-fetch/fetch": ["@better-fetch/fetch@1.3.2", "", {}, "sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow=="],
"bun-types/@types/node": ["@types/node@26.6.3", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg=="],

View File

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

View File

@@ -18,6 +18,7 @@
"test:e2e": "playwright test",
"test:streamer": "bun test streamer/",
"streamer": "bun streamer/agent.ts",
"broadcast:build": "bun build streamer/windows/cli.ts --compile --minify --target=bun-windows-x64 --outfile dist/vash-broadcast.exe",
"streamer:build": "bun build streamer/agent.ts --compile --minify --target=bun-windows-x64 --outfile dist/vash-streamer.exe && bun build streamer/agent.ts --compile --minify --target=bun-linux-x64 --outfile dist/vash-streamer",
"db:seed": "bun scripts/seed-dev.ts",
"db:push": "drizzle-kit push",
@@ -34,8 +35,9 @@
"@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/adapter-bun": "^1.0.0",
"@sveltejs/kit": "^3.0.0",
"@sveltejs/load-config": "^0.2.2",
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3",
@@ -68,7 +70,7 @@
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "~5.8.3",
"typescript": "^6.0.0",
"typescript-eslint": "^8.70.1",
"vite": "^8.3.1",
"vite-plugin-devtools-json": "^1.1.0",
@@ -88,7 +90,8 @@
"discord.js": "^14.27.0",
"zod": "^4.6.5"
},
"patchedDependencies": {
"@sveltejs/kit@2.70.3": "patches/@sveltejs%2Fkit@2.70.3.patch"
"imports": {
"#lib": "./src/lib/index.js",
"#lib/*": "./src/lib/*"
}
}

View File

@@ -1,15 +0,0 @@
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
}));

View File

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

View File

@@ -10,9 +10,11 @@ esac
case "$directory:$origin" in
/opt/esports:https://esports.vash.software)
environment=/etc/esports/prod.env
migration_environment=/etc/esports/prod-migrations.env
host_port=3100 ;;
/opt/esports-staging:https://esports-staging.vash.software)
environment=/etc/esports-staging/staging.env
migration_environment=/etc/esports/staging-migrations.env
host_port=3101 ;;
*) echo 'Unrecognized deployment target' >&2; exit 2 ;;
esac
@@ -25,13 +27,15 @@ 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
# The deploy user only has passwordless sudo for tee and docker.
if [ ! -d "$directory" ]; then
echo "$directory does not exist; create it once on the host" >&2
exit 1
fi
previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/release.env" 2>/dev/null | head -n 1 || true)
write_release() {
printf 'IMAGE_TAG=%s\nHOST_PORT=%s\n' "$1" "$host_port" | sudo tee "$directory/release.env" >/dev/null
}
sudo tee "$compose" >/dev/null <<EOF
services:
@@ -59,22 +63,22 @@ networks:
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"
write_release "$previous_tag"
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"))
port=$host_port
health=$(mktemp)
if [ "${FORCE_DEPLOY:-}" != 1 ] && curl --fail --silent --show-error "http://127.0.0.1:${port}/api/health" > "$health"; then
HEALTH_FILE=$health python3 - <<'PY'
import json, os
data = json.load(open(os.environ["HEALTH_FILE"]))
active = int(data.get("activeMatches") or data.get("irc", {}).get("activeLobbies") or 0)
if active:
raise SystemExit(f"{active} matches are still live. Drain them from the admin page, or set FORCE_DEPLOY=1.")
@@ -107,20 +111,22 @@ 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"
# The container runs as root, so it creates the directory and sets the modes.
sudo docker run --rm --network backend --env-file "$environment" \
--mount "type=bind,source=$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"
--mount "type=bind,source=$(dirname "$backup_dir"),target=/backups" \
--env "BACKUP_FILE=$(basename "$backup_dir")/$backup_name" "$backup_image" \
sh -eu -c 'mkdir -p -m 0700 "/backups/${BACKUP_FILE%/*}"
pg_dump --format=custom --no-owner --no-acl --file="/backups/$BACKUP_FILE" "${DB_URL:-$DATABASE_URL}"
chmod 0600 "/backups/$BACKUP_FILE"
pg_restore --list "/backups/$BACKUP_FILE" >/dev/null'
echo "Verified $environment_name database backup at $backup_dir/$backup_name"
sudo docker run --rm --network backend --env-file "$environment" "$image" \
bun scripts/migrate.ts
# The app's database role cannot create tables; the migrations file overrides
# DATABASE_URL with the owner role and keeps the rest (ROOT_ADMIN_USER_ID).
sudo docker run --rm --network backend --env-file "$environment" \
--env-file "$migration_environment" "$image" bun scripts/migrate.ts
sudo sed -i "s/^IMAGE_TAG=.*/IMAGE_TAG=$sha/" "$directory/release.env"
write_release "$sha"
switched=1
sudo docker compose --env-file "$directory/release.env" -f "$compose" up -d
curl --retry 12 --retry-delay 2 --retry-all-errors --fail --silent --show-error \

4
src/app.d.ts vendored
View File

@@ -1,7 +1,7 @@
// src/app.d.ts
import type { Session } from 'better-auth/minimal';
import type { user } from '$lib/server/db/auth.schema';
import type { ConnectionProfile } from '$lib/auth-identity';
import type { user } from '#lib/server/db/auth.schema.js';
import type { ConnectionProfile } from '#lib/auth-identity.js';
declare global {
namespace App {

41
src/env.ts Normal file
View File

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

View File

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

View File

@@ -1,9 +1,9 @@
// src/hooks.server.ts
import type { Handle, HandleServerError } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import { building, dev } from '$app/environment';
import { sequence, type Handle, type HandleServerError } from '@sveltejs/kit/hooks';
import { building, dev } from '$app/env';
import * as Sentry from '@sentry/sveltekit';
import { appEnv, titlePrefix } from '$lib/server/app-env';
import { appEnv, titlePrefix } from '#lib/server/app-env.js';
if (!building) {
Sentry.init({
@@ -16,16 +16,16 @@ if (!building) {
});
}
import { auth } from '$lib/server/auth';
import { auth } from '#lib/server/auth.js';
import { svelteKitHandler } from 'better-auth/svelte-kit';
import { db } from '$lib/server/db';
import { user as userTable } from '$lib/server/db/auth.schema';
import { db } from '#lib/server/db/index.js';
import { user as userTable } from '#lib/server/db/auth.schema.js';
import { eq } from 'drizzle-orm';
import { ensureRootAdminRole } from '$lib/server/permissions';
import { checkRateLimit } from '$lib/server/rate-limit';
import { log } from '$lib/server/logger';
import { metrics } from '$lib/server/metrics';
import { startServices } from '$lib/server/services';
import { ensureRootAdminRole } from '#lib/server/permissions/index.js';
import { checkRateLimit } from '#lib/server/rate-limit.js';
import { log } from '#lib/server/logger.js';
import { metrics } from '#lib/server/metrics.js';
import { startServices } from '#lib/server/services.js';
if (!building) {
startServices().catch((err) => log.http.fatal({ err }, 'background services failed to start'));
@@ -135,7 +135,10 @@ const handleAuth: Handle = async ({ event, resolve }) => {
export const handle: Handle = sequence(Sentry.sentryHandle(), handleRequestLogging, handleAuth);
export const handleError: HandleServerError = Sentry.handleErrorWithSentry(({ error, event }) => {
export const handleError: HandleServerError = Sentry.handleErrorWithSentry((input) => {
// Expected errors (error(...), 404s, validation) keep their own status and message.
if (input.kind !== 'unknown') return;
const { error, event } = input;
const err = error instanceof Error ? error : new Error(String(error));
log.http.error(
{

View File

@@ -2,9 +2,9 @@
* 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 { LiveMatch } from '#lib/live.js';
import type { MatchView } from '#lib/match-view.js';
import type { Crowd } from '#lib/predictions.js';
import type { Layout, SceneId } from './layout';
export type ChannelMode = 'auto' | 'match' | 'tournament';

View File

@@ -2,7 +2,7 @@
* 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 { MatchView, PlayerView, SideView, SlotView } from '#lib/match-view.js';
import type { ChannelPayload } from './channel';
import type { Layout, SceneId } from './layout';

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'vitest';
import type { LiveMatch } from '$lib/live';
import type { LiveMatch } from '#lib/live.js';
import { interest, pickMatch, RESULT_LINGER_MS } from './director';
function live(

View File

@@ -5,7 +5,7 @@
* 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 { DECIDED, type LiveMatch } from '#lib/live.js';
import type { ChannelMode } from './channel';
export const RESULT_LINGER_MS = 30_000;

View File

@@ -5,7 +5,7 @@
* 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';
import type { MatchView, Phase } from '#lib/match-view.js';
export const STAGE = { width: 1920, height: 1080 } as const;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
<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 { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import { Label } from '#lib/components/ui/label/index.js';
import { hoursToRanges, type TimeRange } from '#lib/availability.js';
import { Clock3, Plus, Trash2, LocateFixed } from '@lucide/svelte';
const {
timezone,

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { gradientFor, initials } from '$lib/format';
import { gradientFor, initials } from '#lib/format.js';
/** A picture, or the initials on a gradient that is always the same for the same name. */
const {

View File

@@ -2,7 +2,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/state';
import type { Snippet } from 'svelte';
import type { TableMeta } from '$lib/server/table';
import type { TableMeta } from '#lib/server/table.js';
interface Column {
key: string;
@@ -55,7 +55,7 @@
});
function updateParams(updates: Record<string, string>) {
const url = new URL(page.url);
const url = new URL(page.url.href);
for (const [key, value] of Object.entries(updates)) {
if (value) {
url.searchParams.set(key, value);
@@ -67,7 +67,7 @@
if (!('page' in updates)) {
url.searchParams.set('page', '1');
}
goto(url.toString(), { keepFocus: true, noScroll: true });
goto(url.toString(), { reset: false });
}
function handleSearch() {

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import Avatar from '$lib/components/Avatar.svelte';
import Avatar from '#lib/components/Avatar.svelte';
import { page } from '$app/state';
import { enhance } from '$app/forms';

View File

@@ -1,7 +1,7 @@
<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 Avatar from '#lib/components/Avatar.svelte';
import { LIVE_STATES, type MatchSummary } from '#lib/match-summary.js';
import { timeAgo } from '#lib/format.js';
import Ban from '@lucide/svelte/icons/ban';
import Crown from '@lucide/svelte/icons/crown';
import Disc3 from '@lucide/svelte/icons/disc-3';

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { SIDE_COLORS, initials } from '$lib/components/match/theme';
import { SIDE_COLORS, initials } from '#lib/components/match/theme.js';
import {
POINTS,
crowdShare,
@@ -9,7 +9,7 @@
type Crowd,
type PredictionResult,
type PredictionState
} from '$lib/predictions';
} from '#lib/predictions.js';
import Check from '@lucide/svelte/icons/check';
import Lock from '@lucide/svelte/icons/lock';
import Sparkles from '@lucide/svelte/icons/sparkles';

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import Avatar from '$lib/components/Avatar.svelte';
import Avatar from '#lib/components/Avatar.svelte';
import { enhance } from '$app/forms';
import { page } from '$app/state';

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import Avatar from '$lib/components/Avatar.svelte';
import Avatar from '#lib/components/Avatar.svelte';
import { goto } from '$app/navigation';
interface SearchResult {

View File

@@ -1,7 +1,7 @@
<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';
import { queue, joinQueue, leaveQueue } from '#lib/stores/queue.svelte.js';
import { Button } from '#lib/components/ui/button/index.js';
const user = $derived(page.data?.user);
const activeMatch = $derived(page.data?.activeMatch);

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import { SIDE_COLORS, modColor } from '$lib/components/match/theme';
import { SIDE_COLORS, modColor } from '#lib/components/match/theme.js';
import { numberFormat, type OverlayProps } from './context';
import Panel from './Panel.svelte';

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { Tween } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
import { SIDE_COLORS } from '$lib/components/match/theme';
import { SIDE_COLORS } from '#lib/components/match/theme.js';
import { numberFormat, type OverlayProps } from './context';
const { data, props, h }: OverlayProps = $props();

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { flip } from 'svelte/animate';
import { SIDE_COLORS, modColor } from '$lib/components/match/theme';
import { SIDE_COLORS, modColor } from '#lib/components/match/theme.js';
import type { OverlayProps } from './context';
const { data, props, w, h }: OverlayProps = $props();

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { modColor } from '$lib/components/match/theme';
import { modColor } from '#lib/components/match/theme.js';
import type { OverlayProps } from './context';
const { data, props, h, accent }: OverlayProps = $props();

View File

@@ -1,8 +1,8 @@
<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 { ChannelPayload } from '#lib/broadcast/channel.js';
import { stageOf, type ComponentType, type SceneId } from '#lib/broadcast/layout.js';
import type { OverlayProps } from './context';
import Scoreboard from './Scoreboard.svelte';
import LiveScore from './LiveScore.svelte';

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import { SIDE_COLORS, flagUrl, initials } from '$lib/components/match/theme';
import { SIDE_COLORS, flagUrl, initials } from '#lib/components/match/theme.js';
import type { OverlayProps } from './context';
const { data }: OverlayProps = $props();

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { SIDE_COLORS } from '$lib/components/match/theme';
import { crowdShare } from '$lib/predictions';
import { SIDE_COLORS } from '#lib/components/match/theme.js';
import { crowdShare } from '#lib/predictions.js';
import type { OverlayProps } from './context';
import Panel from './Panel.svelte';

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import Overlay from './Overlay.svelte';
import { demoChannel, demoPayload } from '$lib/broadcast/demo';
import type { Layout, SceneId } from '$lib/broadcast/layout';
import { demoChannel, demoPayload } from '#lib/broadcast/demo.js';
import type { Layout, SceneId } from '#lib/broadcast/layout.js';
/** A small live render of a layout with the demo match; the checkerboard is where OBS shows through. */
const { layout, scene = 'gameplay' }: { layout: Layout; scene?: SceneId } = $props();

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { PHASE_LABELS } from '$lib/match-view';
import { SIDE_COLORS, initials } from '$lib/components/match/theme';
import { PHASE_LABELS } from '#lib/match-view.js';
import { SIDE_COLORS, initials } from '#lib/components/match/theme.js';
import type { OverlayProps } from './context';
const { data, props, h, accent }: OverlayProps = $props();

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { SIDE_COLORS } from '$lib/components/match/theme';
import { DECIDED } from '$lib/live';
import { SIDE_COLORS } from '#lib/components/match/theme.js';
import { DECIDED } from '#lib/live.js';
import type { OverlayProps } from './context';
const { data, props, accent }: OverlayProps = $props();

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { SIDE_COLORS } from '$lib/components/match/theme';
import { SIDE_COLORS } from '#lib/components/match/theme.js';
import type { OverlayProps } from './context';
const { data, now, h, accent }: OverlayProps = $props();

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { scale } from 'svelte/transition';
import { elasticOut } from 'svelte/easing';
import { SIDE_COLORS, initials } from '$lib/components/match/theme';
import { SIDE_COLORS, initials } from '#lib/components/match/theme.js';
import type { OverlayProps } from './context';
const { data, props, h, accent }: OverlayProps = $props();

View File

@@ -1,5 +1,5 @@
import type { ChannelPayload } from '$lib/broadcast/channel';
import type { PlacedComponent } from '$lib/broadcast/layout';
import type { ChannelPayload } from '#lib/broadcast/channel.js';
import type { PlacedComponent } from '#lib/broadcast/layout.js';
/** What every overlay component gets. */
export interface OverlayProps {

View File

@@ -1,7 +1,7 @@
<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 { PHASE_LABELS } from '#lib/match-view.js';
import { DECIDED, type LiveMatch } from '#lib/live.js';
import { SIDE_COLORS, initials, modColor } from '#lib/components/match/theme.js';
import Eye from '@lucide/svelte/icons/eye';
import Crown from '@lucide/svelte/icons/crown';

View File

@@ -1,7 +1,7 @@
<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 * as Dialog from '#lib/components/ui/dialog/index.js';
import { Button } from '#lib/components/ui/button/index.js';
import type { SlotView } from '#lib/match-view.js';
import type { MatchLive } from './live.svelte';
import MapCard from './MapCard.svelte';
import CountdownRing from './CountdownRing.svelte';
@@ -61,7 +61,7 @@
<Dialog.Root bind:open>
<Dialog.Content
class="max-h-[92dvh] overflow-y-auto border-2 sm:max-w-3xl"
class="max-h-[92dvh] overflow-y-auto border-2 sm:max-w-4xl"
style="border-color:{colors.ring}"
>
<Dialog.Header>
@@ -70,7 +70,7 @@
<Dialog.Title class="text-2xl font-black tracking-tight" style="color:{colors.text}"
>{title}</Dialog.Title
>
<Dialog.Description>
<Dialog.Description class="text-base">
{#if prompt.kind === 'choose'}
{view.rules.bansPerTeam > 0
? 'Whoever picks first bans second.'

View File

@@ -60,9 +60,11 @@
</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
class="absolute font-mono font-semibold tabular-nums {size >= 48
? 'text-base'
: size < 40 && text.length > 2
? 'text-[10px]'
: 'text-xs'} {urgent ? 'text-red-300' : 'text-zinc-200'}">{text}</span
>
{/if}
</div>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import type { SideView, SlotView } from '$lib/match-view';
import type { SideView, SlotView } from '#lib/match-view.js';
import { SIDE_COLORS, formatLength, modColor } from './theme';
import Lock from '@lucide/svelte/icons/lock';
import Trophy from '@lucide/svelte/icons/trophy';
@@ -35,7 +35,7 @@
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]' };
const heights = { sm: 'h-[68px]', md: 'h-[112px]', lg: 'h-[112px]' };
</script>
<button
@@ -90,12 +90,12 @@
<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"
class="rounded-md px-1.5 py-0.5 font-mono text-sm 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"
class="flex items-center gap-1 rounded-md bg-black/50 px-1.5 py-0.5 text-sm font-semibold text-zinc-100 backdrop-blur"
>
{#if locked}<Lock class="size-3" />{/if}
{#if slot.starRating}<Star
@@ -105,19 +105,21 @@
</div>
<div class="min-w-0">
<div
class="truncate text-sm leading-tight font-semibold text-white {size === 'sm'
? 'text-xs'
: ''}"
class="truncate leading-tight font-semibold text-white {size === 'lg'
? 'text-xl'
: size === 'md'
? 'text-base'
: 'text-sm'}"
>
{slot.title ?? `Beatmap #${slot.beatmapId}`}
</div>
{#if size !== 'sm'}
<div class="truncate text-[11px] text-zinc-300/80">
<div class="truncate text-sm text-zinc-300">
{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">
<div class="mt-1 flex items-center gap-2.5 text-xs text-zinc-300">
{#if slot.bpm}<span class="flex items-center gap-0.5"
><Music class="size-2.5" />{Math.round(slot.bpm)}</span
>{/if}
@@ -152,7 +154,7 @@
{/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"
class="absolute right-2 bottom-2 flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs 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'}

View File

@@ -24,7 +24,7 @@
{#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>
<h2 class="mb-3 text-base 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)}
@@ -43,29 +43,29 @@
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"
<span class="w-4 shrink-0 text-center text-sm text-zinc-400 tabular-nums"
>{map.index + 1}</span
>
<span
class="shrink-0 font-mono text-xs font-black"
class="shrink-0 font-mono text-sm 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"
<span class="min-w-0 flex-1 truncate text-base font-medium"
>{slot?.title ?? `Beatmap #${slot?.beatmapId ?? ''}`}</span
>
<ChevronDown
class="size-4 shrink-0 text-zinc-500 transition {open.has(map.index)
class="size-4 shrink-0 text-zinc-400 transition {open.has(map.index)
? 'rotate-180'
: ''}"
/>
</div>
<div class="mt-2 flex items-center justify-between gap-2 pl-6 text-xs">
<div class="mt-2 flex items-center justify-between gap-2 pl-6 text-sm">
<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 class="shrink-0 font-mono text-sm tabular-nums"
><span style="color:{SIDE_COLORS.red.text}"
>{formatScore(red, view.rules.winCondition)}</span
><span class="px-1 text-zinc-600">–</span><span
@@ -82,12 +82,12 @@
{#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">
<p class="mb-1 text-xs font-semibold tracking-wide text-zinc-400 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">
<div class="flex items-center gap-2 border-t border-white/5 py-1.5 text-sm">
<span
class="size-2 shrink-0 rounded-full"
style="background:{side ? SIDE_COLORS[side.color].solid : '#71717a'}"
@@ -96,7 +96,7 @@
<a
href="/users/{score.userId}"
class="block truncate font-medium hover:underline">{name(score.playerId)}</a
><span class="text-[10px] text-zinc-500"
><span class="text-xs text-zinc-400"
>{score.accuracy ? `${score.accuracy.toFixed(2)}%` : '–'} · {score.combo
? `${score.combo}x`
: '–'} · {score.mods.filter((mod) => mod !== 'NF').join('') ||
@@ -110,15 +110,15 @@
</div>
{/each}
{:else}
<p class="text-xs text-zinc-500">
<p class="text-sm text-zinc-400">
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"
class="mt-2 flex flex-wrap items-center gap-1.5 border-t border-white/5 pt-2 text-sm"
>
<span class="w-full text-zinc-500">Correct winner:</span>
<span class="w-full text-zinc-400">Correct winner:</span>
{#each view.sides as side (side.id)}
<button
type="button"

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import Avatar from '$lib/components/Avatar.svelte';
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 { SlotView } from '#lib/match-view.js';
import type { MatchLive } from './live.svelte';
import MapCard from './MapCard.svelte';
import { CATEGORY_NAMES, categoryRank, modColor } from './theme';
@@ -62,13 +62,13 @@
<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"
<span class="block text-sm text-zinc-400 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">
<div class="flex gap-3 text-sm text-zinc-400">
<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>
@@ -89,7 +89,7 @@
<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"
<span class="hidden text-xs leading-tight text-zinc-400 sm:block"
>{CATEGORY_NAMES[category] ?? ''}</span
>
</div>
@@ -101,9 +101,9 @@
{#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
class="flex items-center justify-center gap-2 rounded-xl border-2 border-dashed text-center text-sm font-semibold {compact
? 'h-[68px]'
: 'h-[92px]'}"
: 'h-[112px]'}"
style="border-color:{modColor(slot.category)}66;color:{modColor(slot.category)}"
>
<Radio class="size-4 animate-pulse" />

View File

@@ -2,7 +2,7 @@
import { Tween } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
import { onMount } from 'svelte';
import type { MapView } from '$lib/match-view';
import type { MapView } from '#lib/match-view.js';
import type { MatchLive } from './live.svelte';
import { SIDE_COLORS, formatScore, modColor } from './theme';

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import type { MapView, MatchView } from '$lib/match-view';
import type { MapView, MatchView } from '#lib/match-view.js';
import { MatchLive } from './live.svelte';
import Scoreboard from './Scoreboard.svelte';
import MapPool from './MapPool.svelte';
@@ -15,10 +15,10 @@
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 PredictionCard from '#lib/components/PredictionCard.svelte';
import type { PredictionResult, PredictionState } from '#lib/predictions.js';
import { blinkTitle, chime, notify } from './attention';
import { Button } from '$lib/components/ui/button/index.js';
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';
@@ -174,7 +174,10 @@
};
if (v.phase === 'playing')
return v.live?.startedAt
? { label: 'Map in progress', detail: 'Scores and progress update as players finish.' }
? {
label: 'Map in progress',
detail: 'Follow live scores, accuracy and combo while the map plays.'
}
: { 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.' };
});
@@ -195,16 +198,19 @@
<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">
<div
data-match-dashboard
class="mx-auto flex max-w-[1680px] flex-col gap-3 lg:h-[calc(100dvh-6.5rem)] lg:min-h-[680px]"
>
<header class="flex min-h-10 shrink-0 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">
<h1 class="truncate text-lg font-bold tracking-tight sm:text-xl">{v.name ?? title}</h1>
<div class="flex flex-wrap items-center gap-x-2 text-sm text-zinc-400">
{#if v.tournament}
<a
href="/tournaments/{v.tournament.id}"
@@ -219,7 +225,7 @@
</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 ||
class="hidden items-center gap-1.5 rounded-full border px-2.5 py-1 text-sm 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'}"
@@ -261,11 +267,11 @@
</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"
class="flex h-8 cursor-pointer list-none items-center gap-1 rounded-lg border border-white/10 px-2.5 text-sm 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"
class="absolute right-0 mt-2 w-64 rounded-xl border border-white/10 bg-zinc-900 p-3 text-sm 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">
@@ -301,14 +307,14 @@
<Scoreboard {live} />
<div class="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1fr)_23rem]">
<div class="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1fr)_26rem]">
<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"
class="flex shrink-0 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">
<div class="flex items-center gap-2 text-base font-semibold text-zinc-200">
<span
class="size-2 rounded-full {live.myTurn
? 'animate-pulse bg-amber-300'
@@ -318,18 +324,20 @@
<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
class="rounded-full px-2 py-0.5 text-xs font-semibold {index === activeStep
? 'bg-white/10 text-white'
: index < activeStep
? 'text-emerald-300'
: 'text-zinc-600'}">{index < activeStep ? '✓ ' : ''}{step}</span
: 'text-zinc-500'}">{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 data-match-stage class="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 py-3 sm:px-5">
<div class="my-auto w-full">
<p class="mb-4 text-center text-xs text-zinc-500">{guidance.detail}</p>
{#if v.phase !== 'playing'}<p class="mb-3 text-center text-base text-zinc-300">
{guidance.detail}
</p>{/if}
{#if v.paused && !decided}
<div
transition:fade
@@ -361,8 +369,8 @@
{/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">
<div class="shrink-0 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-sm font-semibold text-zinc-400">
<span>MAP JOURNEY</span><span>{v.maps.length} played · first to {v.rules.winsNeeded}</span
>
</div>
@@ -373,14 +381,14 @@
{@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
class="flex min-w-24 flex-1 basis-24 items-center gap-2 rounded-lg border px-2 py-1.5 text-sm {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'}"
: 'border-white/5 bg-white/[0.02] text-zinc-500'}"
>
<span
class="grid size-6 shrink-0 place-items-center rounded-md bg-black/30 font-mono text-[10px] font-bold"
class="grid size-6 shrink-0 place-items-center rounded-md bg-black/30 font-mono text-xs font-bold"
style={slot ? `color:${modColor(slot.category)}` : ''}
>{slot?.label ?? index + 1}</span
>
@@ -416,10 +424,10 @@
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 ===
class="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-2 py-2 text-sm font-semibold transition {panel ===
'activity'
? 'bg-white/10 text-white'
: 'text-zinc-500 hover:bg-white/5 hover:text-zinc-200'}"
: 'text-zinc-400 hover:bg-white/5 hover:text-zinc-200'}"
><List class="size-3.5" />Activity</button
>
<button
@@ -427,10 +435,10 @@
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 ===
class="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-2 py-2 text-sm font-semibold transition {panel ===
'pool'
? 'bg-white/10 text-white'
: 'text-zinc-500 hover:bg-white/5 hover:text-zinc-200'}"
: 'text-zinc-400 hover:bg-white/5 hover:text-zinc-200'}"
><Layers class="size-3.5" />Maps</button
>
<button
@@ -438,10 +446,10 @@
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 ===
class="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-2 py-2 text-sm font-semibold transition {panel ===
'results'
? 'bg-white/10 text-white'
: 'text-zinc-500 hover:bg-white/5 hover:text-zinc-200'}"
: 'text-zinc-400 hover:bg-white/5 hover:text-zinc-200'}"
><Trophy class="size-3.5" />Results</button
>
{#if prediction}<button
@@ -449,10 +457,10 @@
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 ===
class="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-2 py-2 text-sm font-semibold transition {panel ===
'predictions'
? 'bg-white/10 text-white'
: 'text-zinc-500 hover:bg-white/5 hover:text-zinc-200'}"
: 'text-zinc-400 hover:bg-white/5 hover:text-zinc-200'}"
><Sparkles class="size-3.5" />Picks</button
>{/if}
</div>
@@ -464,7 +472,7 @@
{: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"
class="px-3 py-12 text-center text-sm text-zinc-400"
>
Map results will appear here after the first play.
</p>{/if}

View File

@@ -1,7 +1,7 @@
<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 * as Tabs from '#lib/components/ui/tabs/index.js';
import type { LogLine } from '#lib/match-view.js';
import type { MatchLive } from './live.svelte';
import { SIDE_COLORS } from './theme';
import Ban from '@lucide/svelte/icons/ban';
@@ -66,9 +66,9 @@
{#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' : ''}">
<li class="flex gap-2 text-base leading-snug {index === 0 ? 'vash-rise' : ''}">
<span
class="mt-0.5 grid size-5 shrink-0 place-items-center rounded-md"
class="mt-0.5 grid size-6 shrink-0 place-items-center rounded-md"
style="background:{side
? SIDE_COLORS[side.color].soft
: 'rgb(255 255 255 / 0.06)'};color:{side
@@ -77,12 +77,12 @@
? '#fcd34d'
: '#a1a1aa'}"
>
<Icon class="size-3" />
<Icon class="size-3.5" />
</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>
<span class="shrink-0 font-mono text-xs text-zinc-400">{time(line.t)}</span>
</li>
{/each}
</ol>
@@ -92,14 +92,14 @@
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"
class="h-full max-h-[28rem] overflow-y-auto px-3 pb-3 font-mono text-sm 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="text-zinc-400">{time(line.t)}</span>
<span
class="font-semibold {line.role === 'bancho'
? 'text-pink-400'

View File

@@ -4,6 +4,7 @@
import type { MatchLive } from './live.svelte';
import MapCard from './MapCard.svelte';
import { SIDE_COLORS, formatScore, modColor, playerHref } from './theme';
import { liveTotal, scoreIsFresh } from '#lib/match-scores.js';
import { receiveCard, sendCard } from './transitions';
import Radio from '@lucide/svelte/icons/radio';
import Hourglass from '@lucide/svelte/icons/hourglass';
@@ -18,15 +19,11 @@
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 players = $derived(view.sides.flatMap((side) => side.players));
const reporting = $derived(players.filter((player) => player.live));
const fresh = $derived(reporting.filter((player) => scoreIsFresh(player.live, live.now)));
const stale = $derived(reporting.length > 0 && fresh.length === 0);
const reported = $derived(Math.max(0, ...reporting.map((player) => player.live?.progress ?? 0)));
const progress = $derived(
!started
? 0
@@ -34,53 +31,60 @@
? 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 totals = $derived(view.sides.map((side) => liveTotal(side, view.rules.winCondition)));
const values = [
new Tween(0, { duration: 250, easing: cubicOut }),
new Tween(0, { duration: 250, easing: cubicOut })
];
$effect(() => {
values[0].target = totals[0] ?? 0;
values[1].target = totals[1] ?? 0;
});
const both = $derived(totals[0] != null && totals[1] != null);
const share = $derived(
both && values[0].current + values[1].current > 0
? values[0].current / (values[0].current + values[1].current)
: 0.5
);
const complete = $derived(
view.sides.every(
(side) =>
side.players.filter((player) => scoreIsFresh(player.live, live.now)).length >=
view.rules.teamSize
)
);
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]
complete && both && totals[0] !== totals[1] ? view.sides[totals[0]! > totals[1]! ? 0 : 1] : null
);
</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">
<div class="flex flex-col gap-2" data-match-playing>
<div class="flex flex-wrap items-center justify-between gap-2 text-base">
<div class="flex items-center gap-2 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>
<span class={stale || !reporting.length ? 'text-amber-300' : 'text-emerald-300'}>
<Radio class="mr-1.5 inline size-4" />{stale
? 'Score feed interrupted'
: reporting.length
? 'Live scores'
: 'Waiting for live scores'}
</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
<span class="text-zinc-200"><Hourglass class="mr-1.5 inline size-4" />Ready up in osu!</span
>
{#if view.deadlineAt}<span class="text-xs text-zinc-500"
{#if view.deadlineAt}<span class="text-sm text-zinc-400"
>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
<div class="text-sm text-zinc-300">
{#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 text-amber-300">replay #{current.attempt}</span
>{/if}
</div>
</div>
@@ -98,61 +102,87 @@
></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"
<div class="flex flex-wrap items-center gap-2 text-sm">
{#each slot.mods as mod (mod)}<span class="rounded bg-white/10 px-2 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
class="rounded bg-emerald-400/15 px-2 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
<span class="text-zinc-400"
>{view.rules.winCondition.replace('_', ' ').toUpperCase()} · provisional scores</span
>
{#if started}<span class="ml-auto text-zinc-400 tabular-nums"
>{fresh.length}/{view.rules.teamSize * 2} players reporting</span
>{/if}
</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}"
{#if started}
<div class="grid gap-3 sm:grid-cols-2">
{#each view.sides as side, index (side.id)}
{@const colors = SIDE_COLORS[side.color]}
<section
class="min-w-0 overflow-hidden rounded-xl border"
style="border-color:{colors.ring};background:{colors.soft}"
aria-label="{side.name} live scores"
>
{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
<header class="flex items-center justify-between gap-3 px-3 py-2">
<span class="min-w-0 truncate text-base font-semibold" style="color:{colors.text}"
>{side.name}</span
>
<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
<span class="shrink-0 text-2xl font-black tabular-nums xl:text-3xl" data-team-live-score
>{totals[index] == null
? '—'
: formatScore(values[index].current, view.rules.winCondition)}</span
>
</header>
<div class="bg-black/25">
{#each side.players as player (player.id)}
{@const freshPlayer = scoreIsFresh(player.live, live.now)}
<div class="flex min-h-14 items-center gap-3 border-t border-white/5 px-3 py-1">
<div class="min-w-0 flex-1">
<a
href={playerHref(player)}
class="block truncate text-base font-semibold hover:underline"
>{player.osuName ?? player.name}</a
>
<div
class="text-sm tabular-nums {freshPlayer ? 'text-zinc-300' : 'text-amber-200'}"
>
{#if player.live}{player.live.accuracy.toFixed(2)}% · {player.live
.combo}x{#if !freshPlayer}
· delayed{/if}
{:else}Waiting for score{/if}
</div>
</div>
<span class="shrink-0 text-lg font-bold tabular-nums" data-player-live-score
>{player.live ? player.live.score.toLocaleString('en-US') : '—'}</span
>
</div>
{/each}
</div>
{/each}
</div>
</section>
{/each}
</div>
{:else if started}
<p class="text-center text-xs text-zinc-500">Scores appear the moment the map ends.</p>
{#if both}
<div class="flex h-2 overflow-hidden rounded-full bg-white/5" aria-hidden="true">
<div
class="transition-[width] duration-250"
style="width:{share * 100}%;background:{SIDE_COLORS.red.solid}"
></div>
<div class="flex-1" style="background:{SIDE_COLORS.blue.solid}"></div>
</div>
{/if}
<p class="text-center text-sm text-zinc-300">
{#if stale}Showing the last received scores. Waiting for the score feed to reconnect.
{:else if !reporting.length}Waiting for live score updates. Final results still arrive when
the map ends.
{:else if leading}<span class="font-semibold" style="color:{SIDE_COLORS[leading.color].text}"
>{leading.name}</span
>
leads by {formatScore(Math.abs(totals[0]! - totals[1]!), view.rules.winCondition)}
{:else if complete}Scores are tied.
{:else}Waiting for the remaining players’ live scores.{/if}
</p>
{/if}
</div>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import * as Sheet from '$lib/components/ui/sheet/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as Sheet from '#lib/components/ui/sheet/index.js';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import type { MatchLive } from './live.svelte';
import { SIDE_COLORS } from './theme';
import Shield from '@lucide/svelte/icons/shield';

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { PHASE_LABELS, type SideView } from '$lib/match-view';
import { PHASE_LABELS, type SideView } from '#lib/match-view.js';
import type { MatchLive } from './live.svelte';
import { SIDE_COLORS, flagUrl, initials, playerHref, sideHref } from './theme';
import CountdownRing from './CountdownRing.svelte';
@@ -79,11 +79,11 @@
<div class="min-w-0 flex-1">
<a
href={sideHref(side)}
class="block truncate text-base font-bold tracking-tight hover:underline sm:text-xl"
class="block truncate text-lg font-bold tracking-tight hover:underline sm:text-2xl"
>{side.name}</a
>
<div
class="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-zinc-400 {align ===
class="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-zinc-400 {align ===
'right'
? 'justify-end'
: ''}"
@@ -159,26 +159,26 @@
</div>
{/snippet}
<section class="flex items-stretch gap-2 sm:gap-3">
<section class="flex shrink-0 items-stretch gap-2 sm:gap-3">
{@render team(red, 'left')}
<div class="flex w-28 shrink-0 flex-col items-center justify-center gap-1.5 sm:w-44">
<div class="flex w-28 shrink-0 flex-col items-center justify-center gap-1.5 sm:w-56">
<div class="flex items-baseline gap-2 font-black tabular-nums sm:gap-3">
{#key red.points}<span
class="vash-pop text-4xl sm:text-6xl"
class="vash-pop text-4xl sm:text-7xl"
style="color:{SIDE_COLORS.red.text}">{red.points}</span
>{/key}
<span class="text-xl text-zinc-600 sm:text-3xl">:</span>
{#key blue.points}<span
class="vash-pop text-4xl sm:text-6xl"
class="vash-pop text-4xl sm:text-7xl"
style="color:{SIDE_COLORS.blue.text}">{blue.points}</span
>{/key}
</div>
<div class="text-[11px] tracking-wide text-zinc-500 uppercase">
<div class="text-sm tracking-wide text-zinc-400 uppercase">
First to {needed} · BO{view.rules.bestOf}
</div>
<div class="flex items-center gap-2">
<span
class="flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-semibold {view.paused
class="flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-sm font-semibold {view.paused
? 'border-amber-400/40 bg-amber-400/10 text-amber-300'
: liveNow
? 'border-emerald-400/40 bg-emerald-400/10 text-emerald-300'
@@ -193,7 +193,7 @@
deadline={view.deadlineAt}
now={live.now}
total={totalMs}
size={36}
size={52}
/>{/if}
</div>
</div>

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import * as Sheet from '$lib/components/ui/sheet/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import * as Sheet from '#lib/components/ui/sheet/index.js';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import { Switch } from '#lib/components/ui/switch/index.js';
import type { MatchLive } from './live.svelte';
import { SIDE_COLORS } from './theme';
import FlaskConical from '@lucide/svelte/icons/flask-conical';

View File

@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import type { MatchLive } from './live.svelte';
import { SIDE_COLORS, modColor, sideHref } from './theme';
import Avatar from '$lib/components/Avatar.svelte';
import Avatar from '#lib/components/Avatar.svelte';
import Trophy from '@lucide/svelte/icons/trophy';
import CircleX from '@lucide/svelte/icons/circle-x';
@@ -29,13 +29,13 @@
<div class="flex flex-col items-center gap-4 py-2 text-center">
{#if cancelled}
<CircleX class="size-14 text-zinc-500" />
<CircleX class="size-14 text-zinc-400" />
<h3 class="text-2xl font-black">Match cancelled</h3>
<p class="max-w-md text-sm text-zinc-400">{view.endReason ?? 'This match was cancelled.'}</p>
<p class="max-w-md text-base text-zinc-400">{view.endReason ?? 'This match was cancelled.'}</p>
{:else if winner}
{@const colors = SIDE_COLORS[winner.color]}
{@const loser = view.sides.find((side) => side.id !== winner.id)!}
<div class="text-xs font-bold tracking-[0.3em] text-zinc-400 uppercase">Victory</div>
<div class="text-sm font-bold tracking-[0.3em] text-zinc-400 uppercase">Victory</div>
<div class="flex items-center gap-6 sm:gap-10">
{#each [winner, loser] as side (side.id)}
{@const sideColors = SIDE_COLORS[side.color]}
@@ -90,7 +90,7 @@
{@const slot = live.slot(map.slotId)}
{@const side = live.side(map.winner)}
<span
class="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs"
class="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-sm"
>
<span class="font-mono font-bold" style="color:{slot ? modColor(slot.category) : '#fff'}"
>{slot?.label}</span
@@ -104,6 +104,6 @@
</div>
{/if}
{#if view.phase === 'closing'}
<p class="text-xs text-zinc-500">The lobby closes shortly. GG!</p>
<p class="text-sm text-zinc-400">The lobby closes shortly. GG!</p>
{/if}
</div>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { MatchLive } from './live.svelte';
import { SIDE_COLORS, flagUrl, initials, playerHref, sideHref } from './theme';
import { Button } from '$lib/components/ui/button/index.js';
import { Button } from '#lib/components/ui/button/index.js';
import CheckCircle from '@lucide/svelte/icons/circle-check';
import Loader from '@lucide/svelte/icons/loader-circle';
import Send from '@lucide/svelte/icons/send';
@@ -15,8 +15,8 @@
<div class="flex flex-col gap-5">
<div class="text-center">
{#if created}
<h3 class="text-xl font-bold">Getting a lobby ready</h3>
<p class="mt-1 text-sm text-zinc-400">
<h3 class="text-2xl font-bold">Getting a lobby ready</h3>
<p class="mt-1 text-base text-zinc-400">
{view.scheduledAt && Date.parse(view.scheduledAt) > live.now
? `The lobby opens at ${new Date(view.scheduledAt).toLocaleString()}.`
: 'Waiting for a free osu! lobby slot. This usually takes seconds.'}
@@ -30,8 +30,8 @@
>
{/if}
{:else}
<h3 class="text-xl font-bold">Join the lobby</h3>
<p class="mt-1 text-sm text-zinc-400">
<h3 class="text-2xl font-bold">Join the lobby</h3>
<p class="mt-1 text-base text-zinc-400">
Accept the invite in osu! (check your chat). Each team needs {view.rules.teamSize} player{view
.rules.teamSize === 1
? ''
@@ -65,7 +65,7 @@
<a href={sideHref(side)} class="font-semibold hover:underline" style="color:{colors.text}"
>{side.name}</a
>
<span class="text-xs text-zinc-400 tabular-nums"
<span class="text-sm text-zinc-400 tabular-nums"
>{Math.min(present, view.rules.teamSize)}/{view.rules.teamSize}</span
>
</div>
@@ -80,13 +80,13 @@
<img
src={player.avatarUrl}
alt=""
class="size-7 rounded-full object-cover {player.inLobby
class="size-8 rounded-full object-cover {player.inLobby
? ''
: 'opacity-50 grayscale'}"
/>
{:else}
<span
class="grid size-7 place-items-center rounded-full bg-zinc-800 text-[10px] font-bold"
class="grid size-8 place-items-center rounded-full bg-zinc-800 text-[10px] font-bold"
>{initials(player.name)}</span
>
{/if}
@@ -97,14 +97,14 @@
/>{/if}
<a
href={playerHref(player)}
class="flex-1 truncate text-sm hover:underline {player.inLobby
class="flex-1 truncate text-base hover:underline {player.inLobby
? 'text-white'
: 'text-zinc-400'}">{player.osuName ?? player.name}</a
>
{#if player.inLobby}
<CheckCircle class="size-4 text-emerald-400" />
{:else if !created}
<Loader class="size-4 animate-spin text-zinc-500" />
<Loader class="size-4 animate-spin text-zinc-400" />
{/if}
</li>
{/each}

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import type { SideView } from '$lib/match-view';
import type { SideView } from '#lib/match-view.js';
import type { MatchLive } from './live.svelte';
import { SIDE_COLORS } from './theme';
import { Button } from '$lib/components/ui/button/index.js';
import { Button } from '#lib/components/ui/button/index.js';
import Dice from '@lucide/svelte/icons/dices';
const { live }: { live: MatchLive } = $props();
@@ -53,7 +53,7 @@
{@const colors = SIDE_COLORS[side.color]}
{@const won = leader?.id === side.id && !rolling[side.id]}
<div class="flex flex-col items-center gap-3">
<div class="text-sm font-semibold" style="color:{colors.text}">{side.name}</div>
<div class="text-base font-semibold" style="color:{colors.text}">{side.name}</div>
<div
class="relative grid size-28 place-items-center rounded-3xl border-2 transition-all duration-500 sm:size-36 {rolling[
side.id
@@ -75,12 +75,12 @@
<Dice class="size-12 text-zinc-600" />
{/if}
{#if side.serverRoll && !rolling[side.id]}
<span class="absolute -bottom-2 rounded bg-zinc-800 px-1.5 text-[10px] text-zinc-400"
<span class="absolute -bottom-2 rounded bg-zinc-800 px-1.5 text-xs text-zinc-400"
>auto-rolled</span
>
{/if}
</div>
{#if won}<span class="vash-pop text-xs font-bold tracking-widest text-amber-300 uppercase"
{#if won}<span class="vash-pop text-sm font-bold tracking-widest text-amber-300 uppercase"
>High roll</span
>{/if}
</div>
@@ -88,10 +88,10 @@
<div class="flex flex-col items-center gap-5">
<div class="text-center">
<h3 class="text-xl font-bold">
<h3 class="text-2xl font-bold">
{view.rollFor === 'tiebreaker' ? 'Tiebreaker roll' : 'Roll for order'}
</h3>
<p class="mt-1 text-sm text-zinc-400">
<p class="mt-1 text-base text-zinc-400">
{view.rollFor === 'tiebreaker'
? 'The higher roll picks the tiebreaker.'
: view.rules.bansPerTeam > 0

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { MatchLive } from './live.svelte';
import { SIDE_COLORS, modColor } from './theme';
import { Button } from '$lib/components/ui/button/index.js';
import { Button } from '#lib/components/ui/button/index.js';
import Ban from '@lucide/svelte/icons/ban';
import Crosshair from '@lucide/svelte/icons/crosshair';
import Scale from '@lucide/svelte/icons/scale';
@@ -50,7 +50,7 @@
class="size-6 text-white"
/>{:else}<Crosshair class="size-6 text-white" />{/if}
</div>
<div class="text-2xl font-black tracking-tight sm:text-3xl">
<div class="text-2xl font-black tracking-tight sm:text-4xl">
{#if mine}
<span style="color:{colors.text}">Your turn</span>
{:else}
@@ -58,7 +58,7 @@
<span class="text-zinc-300">{verb}</span>
{/if}
</div>
<p class="text-sm text-zinc-400">
<p class="text-base text-zinc-300">
{#if kind === 'choose'}
{actor.name} won the roll. {choiceHint}
{:else if kind === 'ban'}
@@ -74,7 +74,7 @@
{#if mine}
<Button
size="lg"
class="vash-glow mt-2 h-11 px-7 font-bold"
class="vash-glow mt-2 h-12 px-7 text-lg font-bold"
style="--glow:{colors.solid}"
onclick={onact}
>
@@ -96,7 +96,7 @@
{@const slot = live.slot(line.slotId)}
{#if side && slot}
<span
class="vash-rise flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold"
class="vash-rise flex items-center gap-1 rounded-full border px-2 py-0.5 text-sm font-semibold"
style="border-color:{SIDE_COLORS[side.color].ring};background:{SIDE_COLORS[side.color]
.soft}"
>

View File

@@ -1,7 +1,7 @@
import { toast } from 'svelte-sonner';
import type { MatchCommand, MatchView, SideView } from '$lib/match-view';
import type { Crowd } from '$lib/predictions';
import { connectTopics } from '$lib/topic-socket';
import type { MatchCommand, MatchView, SideView } from '#lib/match-view.js';
import type { Crowd } from '#lib/predictions.js';
import { connectTopics } from '#lib/topic-socket.js';
type SimCommand = Record<string, unknown> & { type: string };

View File

@@ -1,4 +1,4 @@
import type { SideColor } from '$lib/match-view';
import type { SideColor } from '#lib/match-view.js';
/** One color per mod pool. Tiebreakers are white, as on every tournament sheet. */
const MOD_COLORS: Record<string, string> = {

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { toast } from 'svelte-sonner';
import { renderBBCode } from '$lib/bbcode';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import { renderBBCode } from '#lib/bbcode.js';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import { Textarea } from '#lib/components/ui/textarea/index.js';
import Palette from '@lucide/svelte/icons/palette';
const {

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { flagUrl, initials } from '$lib/components/match/theme';
import { flagUrl, initials } from '#lib/components/match/theme.js';
import Crown from '@lucide/svelte/icons/crown';
interface Member {

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import Avatar from '$lib/components/Avatar.svelte';
import { gradientFor } from '$lib/format';
import Avatar from '#lib/components/Avatar.svelte';
import { gradientFor } from '#lib/format.js';
import CalendarDays from '@lucide/svelte/icons/calendar-days';
const {

View File

@@ -23,7 +23,7 @@
</script>
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLAnchorAttributes } from 'svelte/elements';
let {

View File

@@ -1,6 +1,6 @@
<script lang="ts" module>
import { type VariantProps, tv } from 'tailwind-variants';
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
export const buttonVariants = tv({

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import XIcon from '@lucide/svelte/icons/x';
import { Button } from '$lib/components/ui/button/index.js';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import { Button } from '#lib/components/ui/button/index.js';
import { cn, type WithoutChildrenOrChild } from '#lib/utils.js';
import * as Dialog from './index.js';
import DialogPortal from './dialog-portal.svelte';
import type { Snippet } from 'svelte';

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '#lib/utils.js';
let {
ref = $bindable(null),

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { Button } from '$lib/components/ui/button/index.js';
import { cn, type WithElementRef } from '$lib/utils.js';
import { Button } from '#lib/components/ui/button/index.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '#lib/utils.js';
let {
ref = $bindable(null),

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '#lib/utils.js';
let {
ref = $bindable(null),

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from 'svelte/elements';
type InputType = Exclude<HTMLInputTypeAttribute, 'file'>;

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Label as LabelPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '#lib/utils.js';
let {
ref = $bindable(null),

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '#lib/utils.js';
let {
ref = $bindable(null),

View File

@@ -5,8 +5,8 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from 'bits-ui';
import XIcon from '@lucide/svelte/icons/x';
import { Button } from '$lib/components/ui/button/index.js';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import { Button } from '#lib/components/ui/button/index.js';
import { cn, type WithoutChildrenOrChild } from '#lib/utils.js';
import SheetOverlay from './sheet-overlay.svelte';
import SheetPortal from './sheet-portal.svelte';
import type { Snippet } from 'svelte';

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '#lib/utils.js';
let {
ref = $bindable(null),

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '#lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '#lib/utils.js';
let {
ref = $bindable(null),

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '#lib/utils.js';
let {
ref = $bindable(null),

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef, type WithoutChildren } from '$lib/utils.js';
import { cn, type WithElementRef, type WithoutChildren } from '#lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Switch as SwitchPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import { cn, type WithoutChildrenOrChild } from '#lib/utils.js';
let {
ref = $bindable(null),

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '#lib/utils.js';
let {
ref = $bindable(null),

View File

@@ -19,7 +19,7 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '#lib/utils.js';
let {
ref = $bindable(null),

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '#lib/utils.js';
let {
ref = $bindable(null),

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '#lib/utils.js';
let {
ref = $bindable(null),

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef, type WithoutChildren } from '$lib/utils.js';
import { cn, type WithElementRef, type WithoutChildren } from '#lib/utils.js';
import type { HTMLTextareaAttributes } from 'svelte/elements';
let {

View File

@@ -2,7 +2,7 @@
* One live match as the Live page and stream overlays show it: small enough
* to send for every match on every change.
*/
import type { MatchView, Phase, SideColor } from '$lib/match-view';
import type { MatchView, Phase, SideColor } from '#lib/match-view.js';
export interface LiveSide {
id: string;

View File

@@ -0,0 +1,21 @@
import { expect, test } from 'vitest';
import { demoView } from './broadcast/demo';
import { liveTotal, scoreIsFresh, SCORE_STALE_MS } from './match-scores';
test('live values use the match win condition and do not present a missing feed as zero', () => {
const side = demoView('gameplay').sides[0];
side.players[0].live = { score: 500, accuracy: 98, combo: 30, progress: 0.5 };
side.players[1].live = { score: 300, accuracy: 96, combo: 20, progress: 0.5 };
expect(liveTotal(side, 'score_v2')).toBe(800);
expect(liveTotal(side, 'accuracy')).toBe(97);
expect(liveTotal(side, 'combo')).toBe(50);
side.players.forEach((player) => (player.live = null));
expect(liveTotal(side, 'score')).toBeNull();
});
test('score freshness expires independently of the match websocket connection', () => {
const score = { score: 0, accuracy: 100, combo: 0, progress: 0, updatedAt: 1000 };
expect(scoreIsFresh(score, 1000)).toBe(true);
expect(scoreIsFresh(score, 1000 + SCORE_STALE_MS + 1)).toBe(false);
expect(scoreIsFresh(null, 1000)).toBe(false);
});

19
src/lib/match-scores.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { LiveScore, SideView } from './match-view';
export const SCORE_STALE_MS = 5000;
export function scoreIsFresh(score: LiveScore | null, now: number) {
return !!score && (score.updatedAt == null || now - score.updatedAt <= SCORE_STALE_MS);
}
/** Provisional team values; final results still come from the match referee. */
export function liveTotal(side: SideView, winCondition: string): number | null {
const scores = side.players.flatMap((player) => (player.live ? [player.live] : []));
if (!scores.length) return null;
if (winCondition === 'accuracy')
return scores.reduce((sum, score) => sum + score.accuracy, 0) / scores.length;
return scores.reduce(
(sum, score) => sum + (winCondition === 'combo' ? score.combo : score.score),
0
);
}

View File

@@ -3,7 +3,7 @@
* every change over the websocket. Everything a viewer needs to draw the page
* is in here; the client never refetches the page to update.
*/
import type { LogLine, Pause, Phase, Prompt, WinCondition } from '$lib/server/osu/procedure';
import type { LogLine, Pause, Phase, Prompt, WinCondition } from '#lib/server/osu/procedure.js';
export type { LogLine, Phase, Prompt };
@@ -14,6 +14,8 @@ export interface LiveScore {
accuracy: number;
combo: number;
progress: number;
/** Server receipt time, so an interrupted score feed is visible to spectators. */
updatedAt?: number;
}
export interface PlayerView {

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