osu! goals: games.osu_goals (maps to pass/FC from the old list) with osu! API v2 metadata (stars NM/DT/HR/HD, BPM, length, AR/OD/CS/HP, combo, objects, tags, community tags, streams from .osu), my best scores and pass/FC via hourly in-app refresh; Goals section on /trackers/games/osu; retired list URL 301s there
All checks were successful
Deploy production / deploy (push) Successful in 1m15s
All checks were successful
Deploy production / deploy (push) Successful in 1m15s
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type { Handle, HandleServerError, ServerInit } from '@sveltejs/kit/hooks';
|
||||
import { startMusicPoller } from '#lib/server/music/poller.js';
|
||||
import { startGamesPoller } from '#lib/server/games/poller.js';
|
||||
import { startOsuGoalsPoller } from '#lib/server/osu-goals/poller.js';
|
||||
import { registerPubgMapChannel } from '#lib/server/pubg-map/live.js';
|
||||
import { building } from '$app/env';
|
||||
import { auth } from '#lib/server/auth.js';
|
||||
@@ -10,12 +11,13 @@ import { error } from '@sveltejs/kit';
|
||||
const analytics = /<!-- analytics -->[\s\S]*?<!-- \/analytics -->/;
|
||||
|
||||
// Background jobs that live in the app process: the Spotify scrobbler (idle until connected)
|
||||
// and the CS2 / PUBG / R6 match history poller. Also the WebSocket channels (served by
|
||||
// the CS2 / PUBG / R6 match history poller and the osu! goals refresh. Also the WebSocket channels (served by
|
||||
// server.js on the same Bun server): the PUBG drop map rooms.
|
||||
export const init: ServerInit = () => {
|
||||
if (!building) {
|
||||
startMusicPoller();
|
||||
startGamesPoller();
|
||||
startOsuGoalsPoller();
|
||||
registerPubgMapChannel();
|
||||
}
|
||||
};
|
||||
|
||||
494
src/lib/game-tracker/OsuGoals.svelte
Normal file
494
src/lib/game-tracker/OsuGoals.svelte
Normal file
@@ -0,0 +1,494 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import Target from 'lucide-svelte/icons/target';
|
||||
import Trophy from 'lucide-svelte/icons/trophy';
|
||||
import CircleCheck from 'lucide-svelte/icons/circle-check';
|
||||
import Star from 'lucide-svelte/icons/star';
|
||||
import Clock from 'lucide-svelte/icons/clock';
|
||||
import Music from 'lucide-svelte/icons/music';
|
||||
import EyeOff from 'lucide-svelte/icons/eye-off';
|
||||
import FastForward from 'lucide-svelte/icons/fast-forward';
|
||||
import Waves from 'lucide-svelte/icons/waves';
|
||||
import Gauge from 'lucide-svelte/icons/gauge';
|
||||
import RefreshCw from 'lucide-svelte/icons/refresh-cw';
|
||||
import CircleHelp from 'lucide-svelte/icons/circle-help';
|
||||
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||||
import { FOCUS_LABELS, formatLength } from '#lib/game-tracker/osu-goals.js';
|
||||
import type { OsuGoalView } from '#lib/server/osu-goals/load.js';
|
||||
|
||||
// Maps I want to pass or FC (games.osu_goals, moved from the old list), with osu! API
|
||||
// metadata and my best score. Refreshed hourly by the in-app poller.
|
||||
let { goals, owner }: { goals: OsuGoalView[]; owner: boolean } = $props();
|
||||
|
||||
const matched = $derived(goals.filter((g) => g.matchStatus === 'matched'));
|
||||
const open = $derived(goals.filter((g) => g.matchStatus !== 'matched'));
|
||||
const done = $derived(goals.filter((g) => g.achievedAt).length);
|
||||
const passedCount = $derived(matched.filter((g) => g.passed).length);
|
||||
const fcCount = $derived(matched.filter((g) => g.fc).length);
|
||||
const lastCheck = $derived(
|
||||
goals
|
||||
.map((g) => g.scoresAt)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.at(-1)
|
||||
);
|
||||
|
||||
// osu!'s own star rating colour spectrum.
|
||||
const STOPS: [number, string][] = [
|
||||
[0.1, '#4290fb'],
|
||||
[1.25, '#4fc0ff'],
|
||||
[2, '#4fffd5'],
|
||||
[2.5, '#7cff4f'],
|
||||
[3.3, '#f6f05c'],
|
||||
[4.2, '#ff8068'],
|
||||
[4.9, '#ff4e6f'],
|
||||
[5.8, '#c645b8'],
|
||||
[6.7, '#6563de'],
|
||||
[7.7, '#18158e'],
|
||||
[9, '#000000']
|
||||
];
|
||||
function starColour(s: number) {
|
||||
if (s <= STOPS[0][0]) return STOPS[0][1];
|
||||
for (let i = 1; i < STOPS.length; i++) {
|
||||
const [x1, c1] = STOPS[i];
|
||||
const [x0, c0] = STOPS[i - 1];
|
||||
if (s <= x1) {
|
||||
const t = (s - x0) / (x1 - x0);
|
||||
const ch = (c: string, k: number) => parseInt(c.slice(1 + k * 2, 3 + k * 2), 16);
|
||||
const mix = [0, 1, 2].map((k) => Math.round(ch(c0, k) + (ch(c1, k) - ch(c0, k)) * t));
|
||||
return `rgb(${mix.join(',')})`;
|
||||
}
|
||||
}
|
||||
return '#000';
|
||||
}
|
||||
const rankClass = (r: string | null) =>
|
||||
!r
|
||||
? ''
|
||||
: r.startsWith('X') || r === 'SS' || r === 'SSH'
|
||||
? 'rank-x'
|
||||
: `rank-${r[0].toLowerCase()}`;
|
||||
const rankLabel = (r: string | null) =>
|
||||
r === 'X' ? 'SS' : r === 'XH' ? 'SS' : r === 'SH' ? 'S' : (r ?? '');
|
||||
const silver = (r: string | null) => r === 'XH' || r === 'SH';
|
||||
const goalText = (g: OsuGoalView) =>
|
||||
g.goal === 'fc' ? 'FC' : g.goal === 'pass' ? 'pass' : 'pass / FC';
|
||||
const nf = (n: number | null, d = 0) =>
|
||||
n == null
|
||||
? '–'
|
||||
: n.toLocaleString('en-GB', { maximumFractionDigits: d, minimumFractionDigits: d });
|
||||
const day = (iso: string | null) =>
|
||||
iso
|
||||
? new Date(iso).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})
|
||||
: '';
|
||||
const focusIcon: Record<string, typeof EyeOff> = {
|
||||
hd: EyeOff,
|
||||
dt: FastForward,
|
||||
streams: Waves,
|
||||
'fast-bpm': Gauge
|
||||
};
|
||||
</script>
|
||||
|
||||
<section id="goals" class="mb-10 scroll-mt-24">
|
||||
<div class="mb-4 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||
<span class="icon-tile"><Target size={18} aria-hidden="true" /></span>goals: to pass / FC
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-[var(--site-muted)]">
|
||||
{goals.length} maps · {passedCount} passed · {fcCount} FC’d{#if done}
|
||||
· {done} goal{done === 1 ? '' : 's'} done{/if}{#if lastCheck}
|
||||
· scores checked {day(lastCheck)}{/if}
|
||||
</p>
|
||||
</div>
|
||||
{#if owner}
|
||||
<form method="POST" action="?/osuGoalsRefresh" use:enhance>
|
||||
<button class="refresh" type="submit"
|
||||
><RefreshCw size={14} aria-hidden="true" /> refresh scores</button
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ul class="goals">
|
||||
{#each matched as g (g.id)}
|
||||
<li class={['goal', g.achievedAt && 'achieved']}>
|
||||
<a
|
||||
class="thumb"
|
||||
href="https://osu.ppy.sh/beatmaps/{g.beatmapId}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="{g.artist} – {g.title} on osu!"
|
||||
>
|
||||
{#if g.listUrl}<img src={g.listUrl} alt="" loading="lazy" width="64" height="64" />{/if}
|
||||
</a>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<a
|
||||
class="truncate font-medium hover:underline"
|
||||
href="https://osu.ppy.sh/beatmaps/{g.beatmapId}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">{g.artist} – {g.title}</a
|
||||
>
|
||||
<span class={['goal-badge', g.goal ?? 'unset', g.achievedAt && 'done']}
|
||||
>{#if g.achievedAt}<CircleCheck size={12} aria-hidden="true" />{:else}<Trophy
|
||||
size={12}
|
||||
aria-hidden="true"
|
||||
/>{/if}{goalText(g)}{#if g.mods.length} +{g.mods.join('')}{/if}</span
|
||||
>
|
||||
</div>
|
||||
<div class="truncate text-sm text-[var(--site-muted)]">
|
||||
[{g.version}] · {g.mapper}{#if g.notes}
|
||||
· {g.notes}{/if}
|
||||
</div>
|
||||
<div class="meta mt-1.5">
|
||||
{#if g.stars != null}
|
||||
<span class="stars" style:--star={starColour(g.stars)}
|
||||
><Star size={11} aria-hidden="true" />{nf(g.stars, 2)}</span
|
||||
>
|
||||
{/if}
|
||||
{#if g.starsDt != null}<span class="dim" title="star rating with DT"
|
||||
>DT {nf(g.starsDt, 2)}★</span
|
||||
>{/if}
|
||||
{#if g.starsHr != null}<span class="dim" title="star rating with HR"
|
||||
>HR {nf(g.starsHr, 2)}★</span
|
||||
>{/if}
|
||||
<span title="BPM"><Music size={12} aria-hidden="true" />{nf(g.bpm)}</span>
|
||||
<span title="length"
|
||||
><Clock size={12} aria-hidden="true" />{formatLength(g.lengthSeconds)}</span
|
||||
>
|
||||
<span class="dim">AR {nf(g.ar, 1)} · OD {nf(g.od, 1)} · CS {nf(g.cs, 1)}</span>
|
||||
{#each g.focus as f (f)}
|
||||
{@const Icon = focusIcon[f]}
|
||||
<span class="focus focus-{f}"
|
||||
><Icon size={11} aria-hidden="true" />{FOCUS_LABELS[f] ?? f}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="best">
|
||||
{#if g.bestRank}
|
||||
<a
|
||||
class="flex items-center gap-2"
|
||||
href="https://osu.ppy.sh/scores/{g.bestScoreId}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="my best score{g.bestAt ? `, ${day(g.bestAt)}` : ''}"
|
||||
>
|
||||
<span class={['rank', rankClass(g.bestRank), silver(g.bestRank) && 'silver']}
|
||||
>{rankLabel(g.bestRank)}</span
|
||||
>
|
||||
<span class="text-right leading-tight">
|
||||
<span class="block font-medium tabular-nums"
|
||||
>{nf(g.bestAccuracy, 2)}%{#if g.bestMods.length}<span class="mods"
|
||||
>+{g.bestMods.join('')}</span
|
||||
>{/if}</span
|
||||
>
|
||||
<span class="block text-xs text-[var(--site-muted)] tabular-nums"
|
||||
>{nf(g.bestPp)}pp · {nf(g.bestCombo)}/{nf(g.maxCombo)}x{#if g.bestFc}
|
||||
· FC{:else if g.bestMisses}
|
||||
· {g.bestMisses}❌{/if}</span
|
||||
>
|
||||
</span>
|
||||
</a>
|
||||
{:else}
|
||||
<span class="text-sm text-[var(--site-muted)]">no pass yet</span>
|
||||
{/if}
|
||||
{#if owner}
|
||||
<form method="POST" action="?/osuGoalSet" use:enhance class="mt-1">
|
||||
<input type="hidden" name="id" value={g.id} />
|
||||
<select
|
||||
name="goal"
|
||||
class="goal-select"
|
||||
aria-label="goal for {g.title}"
|
||||
value={g.goal ?? ''}
|
||||
onchange={(e) => e.currentTarget.form?.requestSubmit()}
|
||||
>
|
||||
<option value="">pass / FC (not set)</option>
|
||||
<option value="pass">pass</option>
|
||||
<option value="fc">FC</option>
|
||||
</select>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
{#each open as g (g.id)}
|
||||
<li class="goal unmatched">
|
||||
<span class="thumb grid place-items-center text-[var(--site-muted)]"
|
||||
><CircleHelp size={22} aria-hidden="true" /></span
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium">{g.name}</span>
|
||||
<span class="goal-badge unset"
|
||||
><Trophy size={12} aria-hidden="true" />{goalText(
|
||||
g
|
||||
)}{#if g.mods.length} +{g.mods.join('')}{/if}</span
|
||||
>
|
||||
<span class="text-xs text-[var(--site-muted)]"
|
||||
>{g.matchStatus === 'ambiguous' ? 'which difficulty?' : 'no map found'}</span
|
||||
>
|
||||
</div>
|
||||
{#if g.matchNote}<p class="text-sm text-[var(--site-muted)]">{g.matchNote}</p>{/if}
|
||||
{#if g.candidates.length}
|
||||
<ul class="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{#each g.candidates as c (c.beatmapId)}
|
||||
<li class="candidate">
|
||||
<a
|
||||
href="https://osu.ppy.sh/beatmaps/{c.beatmapId}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{c.label}{#if c.stars != null}
|
||||
· {nf(c.stars, 2)}★{/if}{#if c.myPlays}
|
||||
· {c.myPlays} plays{/if}<ExternalLink size={11} aria-hidden="true" /></a
|
||||
>
|
||||
{#if owner}
|
||||
<form method="POST" action="?/osuGoalPick" use:enhance>
|
||||
<input type="hidden" name="id" value={g.id} />
|
||||
<input type="hidden" name="beatmapId" value={c.beatmapId} />
|
||||
<button type="submit" class="pick">this one</button>
|
||||
</form>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="mt-2 text-xs text-[var(--site-muted)]">
|
||||
Map data and scores from the <a
|
||||
class="underline"
|
||||
href="https://osu.ppy.sh/docs/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">osu! API</a
|
||||
>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.icon-tile {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
border-radius: 0.5rem;
|
||||
color: #fff;
|
||||
background-image: linear-gradient(135deg, #ff66aa, #ff3d7f);
|
||||
}
|
||||
.refresh,
|
||||
.pick {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid color-mix(in oklab, var(--foreground) 14%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
transition:
|
||||
color 160ms ease,
|
||||
background-color 160ms ease;
|
||||
}
|
||||
.pick {
|
||||
padding: 0.05rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.refresh:hover,
|
||||
.pick:hover {
|
||||
color: var(--foreground);
|
||||
background: color-mix(in oklab, var(--foreground) 7%, transparent);
|
||||
}
|
||||
.goals {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.goal {
|
||||
display: grid;
|
||||
grid-template-columns: 4rem minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
padding: 0.6rem 0.9rem 0.6rem 0.6rem;
|
||||
border: 1px solid color-mix(in oklab, var(--foreground) 9%, transparent);
|
||||
border-radius: 0.9rem;
|
||||
background: color-mix(in oklab, var(--foreground) 2%, transparent);
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease;
|
||||
}
|
||||
.goal:hover {
|
||||
background: color-mix(in oklab, var(--foreground) 5%, transparent);
|
||||
border-color: color-mix(in oklab, var(--foreground) 18%, transparent);
|
||||
}
|
||||
.goal.achieved {
|
||||
border-color: color-mix(in oklab, #22c55e 45%, transparent);
|
||||
}
|
||||
.goal.unmatched {
|
||||
border-style: dashed;
|
||||
}
|
||||
.thumb {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
border-radius: 0.6rem;
|
||||
overflow: hidden;
|
||||
background: color-mix(in oklab, var(--foreground) 6%, transparent);
|
||||
}
|
||||
.thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.3rem 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.meta > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.meta .dim {
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.stars {
|
||||
padding: 0.05rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: var(--star);
|
||||
text-shadow: 0 1px 1px rgb(0 0 0 / 0.35);
|
||||
}
|
||||
.focus {
|
||||
padding: 0.05rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 500;
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
.focus-hd {
|
||||
color: #f59e0b;
|
||||
}
|
||||
.focus-dt {
|
||||
color: #a855f7;
|
||||
}
|
||||
.focus-streams {
|
||||
color: #06b6d4;
|
||||
}
|
||||
.focus-fast-bpm {
|
||||
color: #f43f5e;
|
||||
}
|
||||
.goal-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.05rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border: 1px solid color-mix(in oklab, var(--foreground) 18%, transparent);
|
||||
}
|
||||
.goal-badge.fc {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #ff66aa, #ff3d7f);
|
||||
border-color: transparent;
|
||||
}
|
||||
.goal-badge.pass {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #3b82f6, #6366f1);
|
||||
border-color: transparent;
|
||||
}
|
||||
.goal-badge.unset {
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.goal-badge.done {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #22c55e, #10b981);
|
||||
border-color: transparent;
|
||||
}
|
||||
.best {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
min-width: 9rem;
|
||||
}
|
||||
.rank {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 800;
|
||||
font-size: 1rem;
|
||||
color: #fff;
|
||||
}
|
||||
.rank-x,
|
||||
.rank-s {
|
||||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||||
}
|
||||
.rank.silver {
|
||||
background: linear-gradient(135deg, #e5e7eb, #9ca3af);
|
||||
color: #111;
|
||||
}
|
||||
.rank-a {
|
||||
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||
}
|
||||
.rank-b {
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
}
|
||||
.rank-c {
|
||||
background: linear-gradient(135deg, #a855f7, #9333ea);
|
||||
}
|
||||
.rank-d {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
}
|
||||
.mods {
|
||||
margin-left: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.goal-select {
|
||||
font-size: 0.72rem;
|
||||
background: transparent;
|
||||
color: var(--site-muted);
|
||||
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
}
|
||||
.candidate {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.15rem 0.3rem 0.15rem 0.6rem;
|
||||
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent);
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.candidate a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.goal {
|
||||
grid-template-columns: 3rem minmax(0, 1fr);
|
||||
}
|
||||
.thumb {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
.best {
|
||||
grid-column: 2;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
118
src/lib/game-tracker/osu-goals.test.ts
Normal file
118
src/lib/game-tracker/osu-goals.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
bestScore,
|
||||
focusTags,
|
||||
goalAchieved,
|
||||
goalProgress,
|
||||
hasMods,
|
||||
parseScore,
|
||||
streamStats,
|
||||
type OsuScore
|
||||
} from './osu-goals';
|
||||
|
||||
const osu = (objects: string[]) =>
|
||||
[
|
||||
'osu file format v14',
|
||||
'',
|
||||
'[TimingPoints]',
|
||||
'0,300,4,2,0,60,1,0',
|
||||
'',
|
||||
'[HitObjects]',
|
||||
...objects
|
||||
].join('\n');
|
||||
|
||||
describe('streamStats', () => {
|
||||
it('counts runs of circles a 1/4 beat apart', () => {
|
||||
// 200 BPM: beat 300 ms, 1/4 = 75 ms. A run of 6, a gap, then a run of 3.
|
||||
const run = (start: number, n: number) =>
|
||||
Array.from({ length: n }, (_, i) => `256,192,${start + i * 75},1,0`);
|
||||
expect(streamStats(osu([...run(0, 6), ...run(2000, 3)]))).toEqual({ longest: 6, notes: 6 });
|
||||
});
|
||||
it('breaks runs on sliders and 1/2 gaps', () => {
|
||||
const objs = [
|
||||
'0,0,0,1,0',
|
||||
'0,0,75,1,0',
|
||||
'0,0,150,2,0,L|1:1,1,10',
|
||||
'0,0,225,1,0',
|
||||
'0,0,375,1,0'
|
||||
];
|
||||
expect(streamStats(osu(objs))).toEqual({ longest: 2, notes: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
const score = (p: Partial<OsuScore>): OsuScore => ({
|
||||
id: 1,
|
||||
score: 1,
|
||||
accuracy: 95,
|
||||
mods: [],
|
||||
rank: 'A',
|
||||
pp: 100,
|
||||
combo: 10,
|
||||
misses: 1,
|
||||
fc: false,
|
||||
passed: true,
|
||||
at: '2024-01-01T00:00:00Z',
|
||||
...p
|
||||
});
|
||||
|
||||
describe('scores and goals', () => {
|
||||
it('parses API v2 scores and drops CL', () => {
|
||||
const s = parseScore({
|
||||
id: 5,
|
||||
accuracy: 0.9934,
|
||||
mods: [{ acronym: 'HR' }, { acronym: 'CL' }],
|
||||
rank: 'A',
|
||||
pp: 342.7,
|
||||
max_combo: 542,
|
||||
statistics: { miss: 2 },
|
||||
is_perfect_combo: false,
|
||||
legacy_perfect: false,
|
||||
passed: true,
|
||||
ended_at: '2020-11-05T00:00:00Z',
|
||||
total_score: 900000,
|
||||
legacy_total_score: 12345678
|
||||
});
|
||||
expect(s).toMatchObject({
|
||||
id: 5,
|
||||
accuracy: 99.34,
|
||||
mods: ['HR'],
|
||||
score: 12345678,
|
||||
misses: 2,
|
||||
fc: false
|
||||
});
|
||||
});
|
||||
it('needs the goal mods; NC counts as DT', () => {
|
||||
expect(hasMods(['HD', 'NC'], ['DT'])).toBe(true);
|
||||
expect(hasMods([], ['HR'])).toBe(false);
|
||||
const scores = [score({ id: 1, fc: true }), score({ id: 2, mods: ['HR'], pp: 50 })];
|
||||
expect(goalProgress(scores, ['HR'])).toEqual({ passed: true, fc: false });
|
||||
expect(goalAchieved('fc', goalProgress(scores, ['HR']))).toBe(false);
|
||||
expect(goalAchieved('fc', goalProgress(scores, []))).toBe(true);
|
||||
expect(goalAchieved(null, goalProgress(scores, []))).toBe(null);
|
||||
expect(bestScore(scores, ['HR'])?.id).toBe(2);
|
||||
expect(bestScore(scores, [])?.id).toBe(1);
|
||||
});
|
||||
it('derives focus tags from data only', () => {
|
||||
const tags = focusTags({
|
||||
mods: ['HR'],
|
||||
scores: [score({ mods: ['HD'] })],
|
||||
userTags: ['skillset/streams'],
|
||||
bpm: 204,
|
||||
circles: 100,
|
||||
longestStream: 3,
|
||||
streamNotes: 0
|
||||
});
|
||||
expect(tags).toEqual(['hd', 'streams', 'fast-bpm']);
|
||||
expect(
|
||||
focusTags({
|
||||
mods: [],
|
||||
scores: [],
|
||||
userTags: [],
|
||||
bpm: 180,
|
||||
circles: 300,
|
||||
longestStream: 8,
|
||||
streamNotes: 20
|
||||
})
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
174
src/lib/game-tracker/osu-goals.ts
Normal file
174
src/lib/game-tracker/osu-goals.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
// Pure helpers for the osu! goals (games.osu_goals): .osu stream analysis, score parsing,
|
||||
// goal checks and the focus tags (HD, DT, streams, fast BPM) shown on /trackers/games/osu.
|
||||
|
||||
export type OsuScore = {
|
||||
id: number;
|
||||
score: number;
|
||||
accuracy: number;
|
||||
mods: string[];
|
||||
rank: string;
|
||||
pp: number | null;
|
||||
combo: number;
|
||||
misses: number;
|
||||
fc: boolean;
|
||||
passed: boolean;
|
||||
at: string;
|
||||
};
|
||||
|
||||
/** Fast BPM: the main BPM at or above this (no mods). */
|
||||
export const FAST_BPM = 200;
|
||||
|
||||
/**
|
||||
* Streams in a .osu file: consecutive circles at most a 1/4 beat apart (5% slack) form a run.
|
||||
* Returns the longest run and the number of circles in runs of 5 or more.
|
||||
*/
|
||||
export function streamStats(osu: string): { longest: number; notes: number } {
|
||||
const section = (name: string) => {
|
||||
const start = osu.indexOf(`[${name}]`);
|
||||
if (start < 0) return [] as string[];
|
||||
const rest = osu.slice(start + name.length + 2);
|
||||
const end = rest.search(/^\[/m);
|
||||
return (end < 0 ? rest : rest.slice(0, end))
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.startsWith('//'));
|
||||
};
|
||||
const timing = section('TimingPoints')
|
||||
.map((l) => l.split(','))
|
||||
.filter((p) => p.length >= 2 && Number(p[1]) > 0 && (p.length < 7 || p[6] !== '0'))
|
||||
.map((p) => ({ time: Number(p[0]), beat: Number(p[1]) }))
|
||||
.filter((p) => Number.isFinite(p.time) && Number.isFinite(p.beat))
|
||||
.sort((a, b) => a.time - b.time);
|
||||
const objects = section('HitObjects')
|
||||
.map((l) => l.split(','))
|
||||
.filter((p) => p.length >= 4)
|
||||
.map((p) => ({ time: Number(p[2]), circle: (Number(p[3]) & 1) === 1 }))
|
||||
.filter((o) => Number.isFinite(o.time))
|
||||
.sort((a, b) => a.time - b.time);
|
||||
if (!timing.length || !objects.length) return { longest: 0, notes: 0 };
|
||||
let ti = 0;
|
||||
const beatAt = (t: number) => {
|
||||
while (ti + 1 < timing.length && timing[ti + 1].time <= t) ti++;
|
||||
return timing[ti].beat;
|
||||
};
|
||||
let longest = 0,
|
||||
notes = 0,
|
||||
run = 0;
|
||||
const close = () => {
|
||||
if (run > longest) longest = run;
|
||||
if (run >= 5) notes += run;
|
||||
run = 0;
|
||||
};
|
||||
for (let i = 0; i < objects.length; i++) {
|
||||
const o = objects[i];
|
||||
if (!o.circle) {
|
||||
close();
|
||||
continue;
|
||||
}
|
||||
if (run === 0) run = 1;
|
||||
const next = objects[i + 1];
|
||||
const gap = next ? next.time - o.time : Infinity;
|
||||
if (next?.circle && gap > 0 && gap <= (beatAt(o.time) / 4) * 1.05) run++;
|
||||
else close();
|
||||
}
|
||||
close();
|
||||
return { longest, notes };
|
||||
}
|
||||
|
||||
const str = (v: unknown) => (typeof v === 'string' ? v : '');
|
||||
const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
||||
|
||||
/** One score from the osu! API v2 (x-api-version 20240529 shape). CL (stable) is dropped. */
|
||||
export function parseScore(value: unknown): OsuScore | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const s = value as Record<string, unknown>;
|
||||
const id = num(s.id);
|
||||
if (id == null) return null;
|
||||
const mods = Array.isArray(s.mods)
|
||||
? s.mods
|
||||
.map((m) => (typeof m === 'string' ? m : str((m as Record<string, unknown>)?.acronym)))
|
||||
.filter((m) => m && m !== 'CL')
|
||||
: [];
|
||||
const stats = (s.statistics ?? {}) as Record<string, unknown>;
|
||||
const legacy = num(s.legacy_total_score);
|
||||
return {
|
||||
id,
|
||||
score: legacy && legacy > 0 ? legacy : (num(s.total_score) ?? num(s.score) ?? 0),
|
||||
accuracy: Math.round((num(s.accuracy) ?? 0) * 10000) / 100,
|
||||
mods,
|
||||
rank: str(s.rank) || 'F',
|
||||
pp: num(s.pp),
|
||||
combo: num(s.max_combo) ?? 0,
|
||||
misses: num(stats.miss) ?? num(stats.count_miss) ?? 0,
|
||||
fc: s.is_perfect_combo === true || s.legacy_perfect === true || s.perfect === true,
|
||||
passed: s.passed !== false && str(s.rank) !== 'F',
|
||||
at: str(s.ended_at) || str(s.created_at)
|
||||
};
|
||||
}
|
||||
|
||||
/** Does a play with `mods` count for a goal that needs `required`? NC counts as DT, NC/DT as each other. */
|
||||
export function hasMods(mods: string[], required: string[]) {
|
||||
const set = new Set(mods.map((m) => m.toUpperCase()));
|
||||
if (set.has('NC')) set.add('DT');
|
||||
if (set.has('PF')) set.add('SD');
|
||||
return required.every((m) => set.has(m.toUpperCase()));
|
||||
}
|
||||
|
||||
/** The score to show: best pp (then score) among passes with the goal's mods, else any pass. */
|
||||
export function bestScore(scores: OsuScore[], required: string[]): OsuScore | null {
|
||||
const by = (a: OsuScore, b: OsuScore) => (b.pp ?? 0) - (a.pp ?? 0) || b.score - a.score;
|
||||
const passes = scores.filter((s) => s.passed);
|
||||
return passes.filter((s) => hasMods(s.mods, required)).sort(by)[0] ?? passes.sort(by)[0] ?? null;
|
||||
}
|
||||
|
||||
export function goalProgress(scores: OsuScore[], required: string[]) {
|
||||
const counting = scores.filter((s) => s.passed && hasMods(s.mods, required));
|
||||
return { passed: counting.length > 0, fc: counting.some((s) => s.fc) };
|
||||
}
|
||||
|
||||
/** null when the goal (pass or FC) is not set. */
|
||||
export function goalAchieved(
|
||||
goal: 'pass' | 'fc' | null,
|
||||
progress: { passed: boolean; fc: boolean }
|
||||
) {
|
||||
if (!goal) return null;
|
||||
return goal === 'pass' ? progress.passed : progress.fc;
|
||||
}
|
||||
|
||||
export const FOCUS_LABELS: Record<string, string> = {
|
||||
hd: 'HD',
|
||||
dt: 'DT',
|
||||
streams: 'streams',
|
||||
'fast-bpm': 'fast BPM'
|
||||
};
|
||||
|
||||
/**
|
||||
* Focus tags (my focus is HD, streams, fast BPM and DT), only from real data:
|
||||
* hd/dt when the goal needs the mod or I have a play with it; streams from the community
|
||||
* tags or the .osu file (a run of 16+ or a fifth of the circles in runs of 5+);
|
||||
* fast-bpm at FAST_BPM and up.
|
||||
*/
|
||||
export function focusTags(input: {
|
||||
mods: string[];
|
||||
scores: OsuScore[];
|
||||
userTags: string[];
|
||||
bpm: number | null;
|
||||
circles: number | null;
|
||||
longestStream: number | null;
|
||||
streamNotes: number | null;
|
||||
}): string[] {
|
||||
const tags: string[] = [];
|
||||
const played = (m: string) => input.scores.some((s) => hasMods(s.mods, [m]));
|
||||
if (hasMods(input.mods, ['HD']) || played('HD')) tags.push('hd');
|
||||
if (hasMods(input.mods, ['DT']) || played('DT')) tags.push('dt');
|
||||
const tagged = input.userTags.some((t) => t === 'skillset/streams' || t.startsWith('streams/'));
|
||||
const longest = input.longestStream ?? 0;
|
||||
const notes = input.streamNotes ?? 0;
|
||||
if (tagged || longest >= 16 || (input.circles && notes >= 40 && notes >= input.circles * 0.2))
|
||||
tags.push('streams');
|
||||
if ((input.bpm ?? 0) >= FAST_BPM) tags.push('fast-bpm');
|
||||
return tags;
|
||||
}
|
||||
|
||||
export const formatLength = (s: number | null) =>
|
||||
s == null ? '' : `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, '0')}`;
|
||||
@@ -30,3 +30,4 @@ export * from './stats';
|
||||
export * from './pubg-map';
|
||||
export * from './ai';
|
||||
export * from './movies';
|
||||
export * from './osu-goals';
|
||||
|
||||
180
src/lib/server/db/schema/osu-goals.ts
Normal file
180
src/lib/server/db/schema/osu-goals.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
check,
|
||||
doublePrecision,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
text
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { gamesSchema } from './schemas';
|
||||
import { id, timestamps, timestamptz } from './helpers';
|
||||
|
||||
// osu! maps I want to pass or FC (moved here from the old /lists/osu-maps-to-pass-fc list).
|
||||
// One row per goal. The map metadata is copied from the official osu! API v2 (beatmap,
|
||||
// beatmapset, difficulty attributes, user tags) so nothing depends on a link staying up, and
|
||||
// my best score comes from the user-scores endpoint. src/lib/server/osu-goals/ fills and
|
||||
// refreshes it (in-app poller, hourly). Rows that could not be matched to one map without
|
||||
// guessing keep `match_status` 'ambiguous'/'unmatched' and list their `candidates`.
|
||||
export const OSU_GOAL_KINDS = ['pass', 'fc'] as const;
|
||||
export const OSU_MATCH_STATUSES = ['matched', 'ambiguous', 'unmatched'] as const;
|
||||
|
||||
export type OsuGoalCandidate = {
|
||||
beatmapId: number;
|
||||
beatmapsetId: number;
|
||||
label: string;
|
||||
stars?: number;
|
||||
myPlays?: number;
|
||||
note?: string;
|
||||
};
|
||||
export type OsuGoalScore = {
|
||||
id: number;
|
||||
score: number;
|
||||
accuracy: number;
|
||||
mods: string[];
|
||||
rank: string;
|
||||
pp: number | null;
|
||||
combo: number;
|
||||
misses: number;
|
||||
fc: boolean;
|
||||
passed: boolean;
|
||||
at: string;
|
||||
};
|
||||
|
||||
export const osuGoals = gamesSchema.table(
|
||||
'osu_goals',
|
||||
{
|
||||
id: id(),
|
||||
/** The item text as it was on the list. */
|
||||
name: text('name').notNull(),
|
||||
/** 'pass' or 'fc'; null when the list did not say which (the list was "to pass/FC"). */
|
||||
goal: text('goal', { enum: OSU_GOAL_KINDS }),
|
||||
/** Mods the goal is for, e.g. ['HR']; a play counts only when it has all of them. */
|
||||
mods: text('mods')
|
||||
.array()
|
||||
.notNull()
|
||||
.default(sql`'{}'::text[]`),
|
||||
notes: text('notes'),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
/** Old lists.osu_maps id, for traceability after the list was retired. */
|
||||
listItemId: integer('list_item_id'),
|
||||
matchStatus: text('match_status', { enum: OSU_MATCH_STATUSES }).notNull().default('unmatched'),
|
||||
/** Why this map (or why no map): the evidence used, never a guess. */
|
||||
matchNote: text('match_note'),
|
||||
candidates: jsonb('candidates')
|
||||
.$type<OsuGoalCandidate[]>()
|
||||
.notNull()
|
||||
.default(sql`'[]'::jsonb`),
|
||||
|
||||
// Map metadata (osu! API v2).
|
||||
beatmapsetId: integer('beatmapset_id'),
|
||||
beatmapId: integer('beatmap_id'),
|
||||
mode: text('mode'),
|
||||
artist: text('artist'),
|
||||
title: text('title'),
|
||||
artistUnicode: text('artist_unicode'),
|
||||
titleUnicode: text('title_unicode'),
|
||||
mapper: text('mapper'),
|
||||
mapperId: integer('mapper_id'),
|
||||
/** Difficulty name, e.g. "Fiery's Extreme". */
|
||||
version: text('version'),
|
||||
/** Owner of the difficulty when it is a guest difficulty. */
|
||||
diffMapperId: integer('diff_mapper_id'),
|
||||
status: text('status'),
|
||||
rankedAt: timestamptz('ranked_at'),
|
||||
submittedAt: timestamptz('submitted_at'),
|
||||
source: text('source'),
|
||||
genre: text('genre'),
|
||||
language: text('language'),
|
||||
/** Mapper tags (beatmapset.tags, space separated in the API). */
|
||||
tags: text('tags')
|
||||
.array()
|
||||
.notNull()
|
||||
.default(sql`'{}'::text[]`),
|
||||
/** Community tags voted on this difficulty (e.g. "skillset/streams"). */
|
||||
userTags: text('user_tags')
|
||||
.array()
|
||||
.notNull()
|
||||
.default(sql`'{}'::text[]`),
|
||||
bpm: doublePrecision('bpm'),
|
||||
bpmMin: doublePrecision('bpm_min'),
|
||||
bpmMax: doublePrecision('bpm_max'),
|
||||
/** Seconds, first object to last (total_length) and without breaks (hit_length). */
|
||||
lengthSeconds: integer('length_seconds'),
|
||||
drainSeconds: integer('drain_seconds'),
|
||||
ar: doublePrecision('ar'),
|
||||
od: doublePrecision('od'),
|
||||
cs: doublePrecision('cs'),
|
||||
hp: doublePrecision('hp'),
|
||||
maxCombo: integer('max_combo'),
|
||||
circles: integer('circles'),
|
||||
sliders: integer('sliders'),
|
||||
spinners: integer('spinners'),
|
||||
stars: doublePrecision('stars'),
|
||||
starsDt: doublePrecision('stars_dt'),
|
||||
starsHr: doublePrecision('stars_hr'),
|
||||
starsHd: doublePrecision('stars_hd'),
|
||||
starsHdDt: doublePrecision('stars_hddt'),
|
||||
starsHdHr: doublePrecision('stars_hdhr'),
|
||||
/** Full difficulty attributes per mod combination ('NM', 'DT', 'HR', ...). */
|
||||
attributes: jsonb('attributes').$type<Record<string, Record<string, number>>>(),
|
||||
/** From the .osu file: longest 1/4 run of circles and notes in runs of 5+. */
|
||||
longestStream: integer('longest_stream'),
|
||||
streamNotes: integer('stream_notes'),
|
||||
playCount: integer('play_count'),
|
||||
passCount: integer('pass_count'),
|
||||
favouriteCount: integer('favourite_count'),
|
||||
mapPlayCount: integer('map_play_count'),
|
||||
coverUrl: text('cover_url'),
|
||||
cardUrl: text('card_url'),
|
||||
listUrl: text('list_url'),
|
||||
previewUrl: text('preview_url'),
|
||||
/** Focus tags derived from the data: hd, dt, streams, fast-bpm. */
|
||||
focus: text('focus')
|
||||
.array()
|
||||
.notNull()
|
||||
.default(sql`'{}'::text[]`),
|
||||
metadataAt: timestamptz('metadata_at'),
|
||||
|
||||
// My scores on this difficulty (osu! API v2 /beatmaps/{id}/scores/users/{user}/all).
|
||||
myPlayCount: integer('my_play_count'),
|
||||
bestScoreId: bigint('best_score_id', { mode: 'number' }),
|
||||
bestScore: bigint('best_score', { mode: 'number' }),
|
||||
bestAccuracy: doublePrecision('best_accuracy'),
|
||||
bestMods: text('best_mods')
|
||||
.array()
|
||||
.notNull()
|
||||
.default(sql`'{}'::text[]`),
|
||||
bestRank: text('best_rank'),
|
||||
bestPp: doublePrecision('best_pp'),
|
||||
bestCombo: integer('best_combo'),
|
||||
bestMisses: integer('best_misses'),
|
||||
bestFc: boolean('best_fc'),
|
||||
bestAt: timestamptz('best_at'),
|
||||
/** A pass / an FC with the goal's mods. */
|
||||
passed: boolean('passed').notNull().default(false),
|
||||
fc: boolean('fc').notNull().default(false),
|
||||
/** When the goal was first seen achieved (null while open or when no goal is set). */
|
||||
achievedAt: timestamptz('achieved_at'),
|
||||
scores: jsonb('scores')
|
||||
.$type<OsuGoalScore[]>()
|
||||
.notNull()
|
||||
.default(sql`'[]'::jsonb`),
|
||||
scoresAt: timestamptz('scores_at'),
|
||||
lastError: text('last_error'),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
index('osu_goals_beatmap_idx').on(t.beatmapId),
|
||||
check('osu_goals_goal_check', sql`${t.goal} IS NULL OR ${t.goal} IN ('pass','fc')`),
|
||||
check(
|
||||
'osu_goals_match_check',
|
||||
sql`${t.matchStatus} IN ('matched','ambiguous','unmatched') AND (${t.matchStatus} <> 'matched' OR ${t.beatmapId} IS NOT NULL)`
|
||||
),
|
||||
check('osu_goals_name_check', sql`length(btrim(${t.name})) > 0`)
|
||||
]
|
||||
);
|
||||
|
||||
export type OsuGoalRow = typeof osuGoals.$inferSelect;
|
||||
113
src/lib/server/osu-goals/load.ts
Normal file
113
src/lib/server/osu-goals/load.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import { osuGoals } from '#lib/server/db/schema/index.js';
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { fetchMetadata, osuGoalsConfigured, refreshAllGoals, refreshGoal } from './refresh';
|
||||
|
||||
type OwnerEvent = { locals: App.Locals };
|
||||
|
||||
/** What /trackers/games/osu shows per goal (no raw attributes or score lists). */
|
||||
export async function loadOsuGoals() {
|
||||
const rows = await db.select().from(osuGoals).orderBy(asc(osuGoals.sortOrder), asc(osuGoals.id));
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
goal: r.goal,
|
||||
mods: r.mods,
|
||||
notes: r.notes,
|
||||
matchStatus: r.matchStatus,
|
||||
matchNote: r.matchNote,
|
||||
candidates: r.candidates,
|
||||
beatmapsetId: r.beatmapsetId,
|
||||
beatmapId: r.beatmapId,
|
||||
artist: r.artist,
|
||||
title: r.title,
|
||||
mapper: r.mapper,
|
||||
version: r.version,
|
||||
status: r.status,
|
||||
bpm: r.bpm,
|
||||
lengthSeconds: r.lengthSeconds,
|
||||
ar: r.ar,
|
||||
od: r.od,
|
||||
cs: r.cs,
|
||||
hp: r.hp,
|
||||
maxCombo: r.maxCombo,
|
||||
stars: r.stars,
|
||||
starsDt: r.starsDt,
|
||||
starsHr: r.starsHr,
|
||||
longestStream: r.longestStream,
|
||||
cardUrl: r.cardUrl,
|
||||
listUrl: r.listUrl,
|
||||
focus: r.focus,
|
||||
bestScoreId: r.bestScoreId,
|
||||
bestScore: r.bestScore,
|
||||
bestAccuracy: r.bestAccuracy,
|
||||
bestMods: r.bestMods,
|
||||
bestRank: r.bestRank,
|
||||
bestPp: r.bestPp,
|
||||
bestCombo: r.bestCombo,
|
||||
bestMisses: r.bestMisses,
|
||||
bestFc: r.bestFc,
|
||||
bestAt: r.bestAt?.toISOString() ?? null,
|
||||
myPlayCount: r.myPlayCount,
|
||||
passed: r.passed,
|
||||
fc: r.fc,
|
||||
achievedAt: r.achievedAt?.toISOString() ?? null,
|
||||
scoresAt: r.scoresAt?.toISOString() ?? null
|
||||
}));
|
||||
}
|
||||
export type OsuGoalView = Awaited<ReturnType<typeof loadOsuGoals>>[number];
|
||||
|
||||
const goalId = (form: FormData) => {
|
||||
const id = Number(form.get('id'));
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
};
|
||||
|
||||
/** Owner actions for the goals on /trackers/games/osu. */
|
||||
export const osuGoalActions = {
|
||||
osuGoalSet: async (event: OwnerEvent & { request: Request }) => {
|
||||
requireOwner(event);
|
||||
const form = await event.request.formData();
|
||||
const id = goalId(form);
|
||||
const goal = String(form.get('goal') ?? '');
|
||||
if (!id || !['', 'pass', 'fc'].includes(goal)) return fail(400, { message: 'Invalid goal.' });
|
||||
const [row] = await db
|
||||
.update(osuGoals)
|
||||
.set({ goal: (goal || null) as 'pass' | 'fc' | null })
|
||||
.where(eq(osuGoals.id, id))
|
||||
.returning();
|
||||
if (!row) return fail(404, { message: 'Goal not found.' });
|
||||
if (row.matchStatus === 'matched' && osuGoalsConfigured())
|
||||
await refreshGoal(row).catch(() => {});
|
||||
return { saved: true };
|
||||
},
|
||||
/** Pick the map for an ambiguous item (one of its candidates). */
|
||||
osuGoalPick: async (event: OwnerEvent & { request: Request }) => {
|
||||
requireOwner(event);
|
||||
const form = await event.request.formData();
|
||||
const id = goalId(form);
|
||||
const beatmapId = Number(form.get('beatmapId'));
|
||||
if (!id || !Number.isSafeInteger(beatmapId) || beatmapId <= 0)
|
||||
return fail(400, { message: 'Invalid map.' });
|
||||
const [row] = await db.select().from(osuGoals).where(eq(osuGoals.id, id));
|
||||
if (!row) return fail(404, { message: 'Goal not found.' });
|
||||
if (!row.candidates.some((c) => c.beatmapId === beatmapId))
|
||||
return fail(400, { message: 'Pick one of the candidate maps.' });
|
||||
if (!osuGoalsConfigured()) return fail(503, { message: 'osu! API is not configured.' });
|
||||
const meta = await fetchMetadata(beatmapId).catch(() => null);
|
||||
if (!meta) return fail(502, { message: 'osu! did not return that map.' });
|
||||
const [updated] = await db
|
||||
.update(osuGoals)
|
||||
.set({ ...meta, matchStatus: 'matched', matchNote: 'Picked by Stan from the candidates.' })
|
||||
.where(eq(osuGoals.id, id))
|
||||
.returning();
|
||||
await refreshGoal(updated).catch(() => {});
|
||||
return { saved: true };
|
||||
},
|
||||
osuGoalsRefresh: async (event: OwnerEvent) => {
|
||||
requireOwner(event);
|
||||
const result = await refreshAllGoals();
|
||||
return { refreshed: result };
|
||||
}
|
||||
};
|
||||
28
src/lib/server/osu-goals/poller.ts
Normal file
28
src/lib/server/osu-goals/poller.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { GAMES_POLLER } from '$app/env/private';
|
||||
import { refreshAllGoals } from './refresh';
|
||||
|
||||
// Hourly refresh of the osu! goals (games.osu_goals): my best scores, pass/FC and achieved
|
||||
// goals; map metadata once a week. Runs in the app like the CS2 / PUBG / R6 poller and
|
||||
// stops with it (GAMES_POLLER=off). Refreshing twice (mid-deploy) is harmless.
|
||||
const EVERY_MS = 60 * 60_000;
|
||||
const FIRST_MS = 2 * 60_000;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function tick() {
|
||||
try {
|
||||
const r = await refreshAllGoals();
|
||||
if (r.failed || r.achieved)
|
||||
console.log(
|
||||
`[osu-goals] refreshed ${r.refreshed}, failed ${r.failed}, achieved +${r.achieved}`
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(`[osu-goals] refresh failed: ${(e as Error).message}`);
|
||||
} finally {
|
||||
timer = setTimeout(tick, EVERY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
export function startOsuGoalsPoller() {
|
||||
if (timer || GAMES_POLLER === 'off') return;
|
||||
timer = setTimeout(tick, FIRST_MS);
|
||||
}
|
||||
247
src/lib/server/osu-goals/refresh.ts
Normal file
247
src/lib/server/osu-goals/refresh.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { OSU_CLIENT_ID, OSU_CLIENT_SECRET } from '$app/env/private';
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import { osuGoals, type OsuGoalRow } from '#lib/server/db/schema/index.js';
|
||||
import { createTokenCache, requestJson } from '#lib/server/finder-http.js';
|
||||
import {
|
||||
bestScore,
|
||||
focusTags,
|
||||
goalAchieved,
|
||||
goalProgress,
|
||||
parseScore,
|
||||
streamStats,
|
||||
type OsuScore
|
||||
} from '#lib/game-tracker/osu-goals.js';
|
||||
|
||||
// Fills and refreshes games.osu_goals from the official osu! API v2 (client credentials,
|
||||
// the same OSU_CLIENT_ID/SECRET as the map finder). Metadata: beatmapset + difficulty,
|
||||
// difficulty attributes per mod combo, community tags and the .osu file (streams).
|
||||
// Scores: every score of mine on the difficulty, so passes and FCs mark goals achieved.
|
||||
|
||||
/** osu.ppy.sh/users/11212255 (also linked in the site footer). */
|
||||
export const OSU_USER_ID = 11212255;
|
||||
const API = 'https://osu.ppy.sh/api/v2';
|
||||
const token = createTokenCache();
|
||||
const METADATA_MAX_AGE = 7 * 24 * 3600_000;
|
||||
|
||||
export const osuGoalsConfigured = () => !!(OSU_CLIENT_ID && OSU_CLIENT_SECRET);
|
||||
|
||||
async function api(path: string, init: RequestInit = {}): Promise<unknown> {
|
||||
const access = await token(() =>
|
||||
requestJson(fetch, 'osu!', 'https://osu.ppy.sh/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({
|
||||
client_id: Number(OSU_CLIENT_ID),
|
||||
client_secret: OSU_CLIENT_SECRET,
|
||||
grant_type: 'client_credentials',
|
||||
scope: 'public'
|
||||
})
|
||||
})
|
||||
);
|
||||
return requestJson(fetch, 'osu!', `${API}/${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${access}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-version': '20240529'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
||||
const int = (v: unknown) => (num(v) == null ? null : Math.round(v as number));
|
||||
const str = (v: unknown) => (typeof v === 'string' && v ? v : null);
|
||||
const date = (v: unknown) => (str(v) ? new Date(v as string) : null);
|
||||
|
||||
let tagNames: Map<number, string> | undefined;
|
||||
async function communityTags(ids: unknown): Promise<string[]> {
|
||||
if (!Array.isArray(ids) || !ids.length) return [];
|
||||
if (!tagNames) {
|
||||
const res = (await api('tags')) as { tags?: { id: number; name: string }[] };
|
||||
tagNames = new Map((res.tags ?? []).map((t) => [t.id, t.name]));
|
||||
}
|
||||
return ids.map((t) => tagNames!.get(Number((t as Json)?.tag_id))).filter((t): t is string => !!t);
|
||||
}
|
||||
|
||||
const MOD_COMBOS: [string, string[]][] = [
|
||||
['NM', []],
|
||||
['DT', ['DT']],
|
||||
['HR', ['HR']],
|
||||
['HD', ['HD']],
|
||||
['HDDT', ['HD', 'DT']],
|
||||
['HDHR', ['HD', 'HR']]
|
||||
];
|
||||
|
||||
/** Everything about one difficulty, as columns of games.osu_goals. */
|
||||
export async function fetchMetadata(beatmapId: number) {
|
||||
const beatmap = (await api(`beatmaps/${beatmapId}`)) as Json;
|
||||
const setId = int(beatmap.beatmapset_id);
|
||||
if (!setId) throw new Error(`beatmap ${beatmapId} has no beatmapset`);
|
||||
const set = (await api(`beatmapsets/${setId}`)) as Json;
|
||||
const diff = ((set.beatmaps as Json[]) ?? []).find((b) => b.id === beatmapId) ?? beatmap;
|
||||
const attributes: Record<string, Record<string, number>> = {};
|
||||
for (const [key, mods] of MOD_COMBOS) {
|
||||
const res = (await api(`beatmaps/${beatmapId}/attributes`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mods, ruleset: str(diff.mode) ?? 'osu' })
|
||||
})) as { attributes?: Record<string, number> };
|
||||
if (res.attributes) attributes[key] = res.attributes;
|
||||
}
|
||||
const star = (k: string) => {
|
||||
const v = attributes[k]?.star_rating;
|
||||
return typeof v === 'number' ? Math.round(v * 100) / 100 : null;
|
||||
};
|
||||
let stream = { longest: null as number | null, notes: null as number | null };
|
||||
try {
|
||||
const res = await fetch(`https://osu.ppy.sh/osu/${beatmapId}`, {
|
||||
signal: AbortSignal.timeout(12000)
|
||||
});
|
||||
if (res.ok) stream = streamStats(await res.text());
|
||||
} catch {
|
||||
// Streams just stay unknown.
|
||||
}
|
||||
const covers = (set.covers ?? {}) as Json;
|
||||
const owners = Array.isArray(diff.owners) ? (diff.owners as Json[]) : [];
|
||||
const bpms = Array.isArray(diff.bpm_list) ? (diff.bpm_list as number[]) : [];
|
||||
return {
|
||||
beatmapsetId: setId,
|
||||
beatmapId,
|
||||
mode: str(diff.mode),
|
||||
artist: str(set.artist),
|
||||
title: str(set.title),
|
||||
artistUnicode: str(set.artist_unicode),
|
||||
titleUnicode: str(set.title_unicode),
|
||||
mapper: str(set.creator),
|
||||
mapperId: int(set.user_id),
|
||||
version: str(diff.version),
|
||||
diffMapperId: int(owners[0]?.id) ?? int(diff.user_id),
|
||||
status: str(diff.status) ?? str(set.status),
|
||||
rankedAt: date(set.ranked_date),
|
||||
submittedAt: date(set.submitted_date),
|
||||
source: str(set.source),
|
||||
genre: str((set.genre as Json)?.name),
|
||||
language: str((set.language as Json)?.name),
|
||||
tags: (str(set.tags) ?? '').split(/\s+/).filter(Boolean),
|
||||
userTags: await communityTags(diff.top_tag_ids),
|
||||
bpm: num(diff.bpm) ?? num(set.bpm),
|
||||
bpmMin: bpms.length ? Math.min(...bpms) : null,
|
||||
bpmMax: bpms.length ? Math.max(...bpms) : null,
|
||||
lengthSeconds: int(diff.total_length),
|
||||
drainSeconds: int(diff.hit_length),
|
||||
ar: num(diff.ar),
|
||||
od: num(diff.accuracy),
|
||||
cs: num(diff.cs),
|
||||
hp: num(diff.drain),
|
||||
maxCombo: int(diff.max_combo) ?? int(attributes.NM?.max_combo),
|
||||
circles: int(diff.count_circles),
|
||||
sliders: int(diff.count_sliders),
|
||||
spinners: int(diff.count_spinners),
|
||||
stars: star('NM') ?? num(diff.difficulty_rating),
|
||||
starsDt: star('DT'),
|
||||
starsHr: star('HR'),
|
||||
starsHd: star('HD'),
|
||||
starsHdDt: star('HDDT'),
|
||||
starsHdHr: star('HDHR'),
|
||||
attributes,
|
||||
longestStream: stream.longest,
|
||||
streamNotes: stream.notes,
|
||||
playCount: int(set.play_count),
|
||||
passCount: int(diff.passcount),
|
||||
favouriteCount: int(set.favourite_count),
|
||||
mapPlayCount: int(diff.playcount),
|
||||
coverUrl: str(covers['cover@2x']) ?? str(covers.cover),
|
||||
cardUrl: str(covers['card@2x']) ?? str(covers.card),
|
||||
listUrl: str(covers['list@2x']) ?? str(covers.list),
|
||||
previewUrl: str(set.preview_url)?.replace(/^\/\//, 'https://') ?? null,
|
||||
metadataAt: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchMyScores(beatmapId: number): Promise<OsuScore[]> {
|
||||
try {
|
||||
const res = (await api(
|
||||
`beatmaps/${beatmapId}/scores/users/${OSU_USER_ID}/all?ruleset=osu`
|
||||
)) as { scores?: unknown[] };
|
||||
return (res.scores ?? []).map(parseScore).filter((s): s is OsuScore => s !== null);
|
||||
} catch (e) {
|
||||
// 404: no leaderboard (graveyard/WIP) or no scores.
|
||||
if ((e as { status?: number }).status === 404) return [];
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Refresh one goal: metadata when missing or older than a week (or `force`), always scores. */
|
||||
export async function refreshGoal(row: OsuGoalRow, force = false) {
|
||||
if (row.matchStatus !== 'matched' || !row.beatmapId) return row;
|
||||
try {
|
||||
const stale =
|
||||
force || !row.metadataAt || Date.now() - row.metadataAt.getTime() > METADATA_MAX_AGE;
|
||||
const meta = stale ? await fetchMetadata(row.beatmapId) : null;
|
||||
const scores = await fetchMyScores(row.beatmapId);
|
||||
const best = bestScore(scores, row.mods);
|
||||
const progress = goalProgress(scores, row.mods);
|
||||
const achieved = goalAchieved(row.goal, progress);
|
||||
const userTags = meta?.userTags ?? row.userTags;
|
||||
const [updated] = await db
|
||||
.update(osuGoals)
|
||||
.set({
|
||||
...(meta ?? {}),
|
||||
focus: focusTags({
|
||||
mods: row.mods,
|
||||
scores,
|
||||
userTags,
|
||||
bpm: meta?.bpm ?? row.bpm,
|
||||
circles: meta?.circles ?? row.circles,
|
||||
longestStream: meta ? meta.longestStream : row.longestStream,
|
||||
streamNotes: meta ? meta.streamNotes : row.streamNotes
|
||||
}),
|
||||
scores,
|
||||
myPlayCount: scores.length,
|
||||
bestScoreId: best?.id ?? null,
|
||||
bestScore: best?.score ?? null,
|
||||
bestAccuracy: best?.accuracy ?? null,
|
||||
bestMods: best?.mods ?? [],
|
||||
bestRank: best?.rank ?? null,
|
||||
bestPp: best?.pp ?? null,
|
||||
bestCombo: best?.combo ?? null,
|
||||
bestMisses: best?.misses ?? null,
|
||||
bestFc: best?.fc ?? null,
|
||||
bestAt: best?.at ? new Date(best.at) : null,
|
||||
passed: progress.passed,
|
||||
fc: progress.fc,
|
||||
achievedAt: achieved ? (row.achievedAt ?? new Date()) : null,
|
||||
scoresAt: new Date(),
|
||||
lastError: null
|
||||
})
|
||||
.where(eq(osuGoals.id, row.id))
|
||||
.returning();
|
||||
return updated;
|
||||
} catch (e) {
|
||||
const message = (e as Error).message?.slice(0, 500) ?? 'refresh failed';
|
||||
await db.update(osuGoals).set({ lastError: message }).where(eq(osuGoals.id, row.id));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshAllGoals(force = false) {
|
||||
if (!osuGoalsConfigured()) return { refreshed: 0, failed: 0, achieved: 0 };
|
||||
const rows = await db.select().from(osuGoals).orderBy(asc(osuGoals.sortOrder));
|
||||
let refreshed = 0,
|
||||
failed = 0,
|
||||
achieved = 0;
|
||||
for (const row of rows) {
|
||||
if (row.matchStatus !== 'matched') continue;
|
||||
try {
|
||||
const updated = await refreshGoal(row, force);
|
||||
refreshed++;
|
||||
if (updated.achievedAt && !row.achievedAt) achieved++;
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.error(`[osu-goals] ${row.name}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
return { refreshed, failed, achieved };
|
||||
}
|
||||
@@ -26,7 +26,8 @@ const RETIRED_LISTS: Record<string, string> = {
|
||||
'books-to-read': '/trackers/books',
|
||||
'scientific-papers': '/trackers/books?kind=papers',
|
||||
'cars-trains-planes-to-spot': '/trackers/spotting',
|
||||
'movies-shows-to-watch': '/trackers/movies?status=watchlist'
|
||||
'movies-shows-to-watch': '/trackers/movies?status=watchlist',
|
||||
'osu-maps-to-pass-fc': '/trackers/games/osu#goals'
|
||||
};
|
||||
|
||||
async function currentList(slug: string) {
|
||||
|
||||
@@ -5,11 +5,13 @@ import { gameLibrary, hardwareSensitivities } from '#lib/server/db/schema/index.
|
||||
import { isOwner, requireOwner } from '#lib/server/owner.js';
|
||||
import { setGameActive } from '#lib/server/games/active.js';
|
||||
import { isAutoTracked, loadHistory } from '#lib/server/games/history.js';
|
||||
import { loadOsuGoals, osuGoalActions } from '#lib/server/osu-goals/load.js';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
// One game from games.library, plus its mouse sensitivity (hardware.sensitivities, matched by
|
||||
// name, short name or alias) when there is one. CS2, PUBG and R6 also get their automatic
|
||||
// match history (games.matches, filled by the in-app poller).
|
||||
// match history (games.matches, filled by the in-app poller), and the osu! goals
|
||||
// (games.osu_goals: maps to pass or FC, with osu! API metadata and my best scores).
|
||||
// Old single entries, split per title/generation: bo2 became black-ops-2, and the Pokémon
|
||||
// entry (all generations) now points at the Pokémon journal that covers every game.
|
||||
const MOVED: Record<string, string> = {
|
||||
@@ -54,6 +56,7 @@ export const load: PageServerLoad = async (event) => {
|
||||
owner: isOwner(event),
|
||||
sensitivity: sensitivity ?? null,
|
||||
history: isAutoTracked(game.slug) ? await loadHistory(game.slug, isOwner(event)) : null,
|
||||
osuGoals: game.slug === 'osu' ? await loadOsuGoals() : null,
|
||||
seo: {
|
||||
title: `${game.name} · Games`,
|
||||
description: `${game.name} on Stan Runge’s game tracker${game.status === 'main' ? ': my main game right now' : ''}.`
|
||||
@@ -70,5 +73,6 @@ export const actions: Actions = {
|
||||
if (!(await setGameActive(event.params.slug, active)))
|
||||
return fail(404, { message: 'Unknown game' });
|
||||
return { active };
|
||||
}
|
||||
},
|
||||
...osuGoalActions
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import MapIcon from 'lucide-svelte/icons/map';
|
||||
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
|
||||
import MatchHistory from '#lib/game-tracker/MatchHistory.svelte';
|
||||
import OsuGoals from '#lib/game-tracker/OsuGoals.svelte';
|
||||
import { gameTrackers } from '#lib/projects.js';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
@@ -115,6 +116,10 @@
|
||||
<MatchHistory history={data.history} game={game.slug as 'cs2' | 'pubg' | 'r6'} />
|
||||
{/if}
|
||||
|
||||
{#if data.osuGoals}
|
||||
<OsuGoals goals={data.osuGoals} owner={data.owner} />
|
||||
{/if}
|
||||
|
||||
{#if game.slug === 'pubg' && data.owner}
|
||||
<a
|
||||
href={resolve('/trackers/games/pubg/map')}
|
||||
@@ -144,7 +149,7 @@
|
||||
</span>
|
||||
<ArrowRight size={18} aria-hidden="true" class="ml-4 text-[var(--site-muted)]" />
|
||||
</a>
|
||||
{:else if !hasStats && !game.notes && !data.history}
|
||||
{:else if !hasStats && !game.notes && !data.history && !data.osuGoals}
|
||||
<p class="empty rounded-xl p-6 text-[var(--site-muted)]">
|
||||
Nothing tracked for {game.shortName} yet. Stats, ranks and notes show up here once I add them.
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user