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

This commit is contained in:
2026-10-26 00:35:42 +00:00
parent 45841d6f6c
commit 9f8d54d9e3
14 changed files with 41357 additions and 5 deletions

View File

@@ -0,0 +1,102 @@
CREATE TABLE "games"."osu_goals" (
"id" serial PRIMARY KEY,
"name" text NOT NULL,
"goal" text,
"mods" text[] DEFAULT '{}'::text[] NOT NULL,
"notes" text,
"sort_order" integer DEFAULT 0 NOT NULL,
"list_item_id" integer,
"match_status" text DEFAULT 'unmatched' NOT NULL,
"match_note" text,
"candidates" jsonb DEFAULT '[]' NOT NULL,
"beatmapset_id" integer,
"beatmap_id" integer,
"mode" text,
"artist" text,
"title" text,
"artist_unicode" text,
"title_unicode" text,
"mapper" text,
"mapper_id" integer,
"version" text,
"diff_mapper_id" integer,
"status" text,
"ranked_at" timestamp with time zone,
"submitted_at" timestamp with time zone,
"source" text,
"genre" text,
"language" text,
"tags" text[] DEFAULT '{}'::text[] NOT NULL,
"user_tags" text[] DEFAULT '{}'::text[] NOT NULL,
"bpm" double precision,
"bpm_min" double precision,
"bpm_max" double precision,
"length_seconds" integer,
"drain_seconds" integer,
"ar" double precision,
"od" double precision,
"cs" double precision,
"hp" double precision,
"max_combo" integer,
"circles" integer,
"sliders" integer,
"spinners" integer,
"stars" double precision,
"stars_dt" double precision,
"stars_hr" double precision,
"stars_hd" double precision,
"stars_hddt" double precision,
"stars_hdhr" double precision,
"attributes" jsonb,
"longest_stream" integer,
"stream_notes" integer,
"play_count" integer,
"pass_count" integer,
"favourite_count" integer,
"map_play_count" integer,
"cover_url" text,
"card_url" text,
"list_url" text,
"preview_url" text,
"focus" text[] DEFAULT '{}'::text[] NOT NULL,
"metadata_at" timestamp with time zone,
"my_play_count" integer,
"best_score_id" bigint,
"best_score" bigint,
"best_accuracy" double precision,
"best_mods" text[] DEFAULT '{}'::text[] NOT NULL,
"best_rank" text,
"best_pp" double precision,
"best_combo" integer,
"best_misses" integer,
"best_fc" boolean,
"best_at" timestamp with time zone,
"passed" boolean DEFAULT false NOT NULL,
"fc" boolean DEFAULT false NOT NULL,
"achieved_at" timestamp with time zone,
"scores" jsonb DEFAULT '[]' NOT NULL,
"scores_at" timestamp with time zone,
"last_error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "osu_goals_goal_check" CHECK ("goal" IS NULL OR "goal" IN ('pass','fc')),
CONSTRAINT "osu_goals_match_check" CHECK ("match_status" IN ('matched','ambiguous','unmatched') AND ("match_status" <> 'matched' OR "beatmap_id" IS NOT NULL)),
CONSTRAINT "osu_goals_name_check" CHECK (length(btrim("name")) > 0)
);
--> statement-breakpoint
CREATE INDEX "osu_goals_beatmap_idx" ON "games"."osu_goals" ("beatmap_id");--> statement-breakpoint
-- Hand-written: grants (same pattern as the other app tables) and the updated_at trigger.
-- Rows come from the retired list lists.osu_maps (imported once, then refreshed by the app).
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON games.osu_goals TO personal_app;
GRANT USAGE, SELECT ON SEQUENCE games.osu_goals_id_seq TO personal_app;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
GRANT SELECT ON games.osu_goals TO personal_ro;
END IF;
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
CREATE TRIGGER touch_updated_at BEFORE UPDATE ON games.osu_goals FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at();
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import type { Handle, HandleServerError, ServerInit } from '@sveltejs/kit/hooks'; import type { Handle, HandleServerError, ServerInit } from '@sveltejs/kit/hooks';
import { startMusicPoller } from '#lib/server/music/poller.js'; import { startMusicPoller } from '#lib/server/music/poller.js';
import { startGamesPoller } from '#lib/server/games/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 { registerPubgMapChannel } from '#lib/server/pubg-map/live.js';
import { building } from '$app/env'; import { building } from '$app/env';
import { auth } from '#lib/server/auth.js'; import { auth } from '#lib/server/auth.js';
@@ -10,12 +11,13 @@ import { error } from '@sveltejs/kit';
const analytics = /<!-- analytics -->[\s\S]*?<!-- \/analytics -->/; const analytics = /<!-- analytics -->[\s\S]*?<!-- \/analytics -->/;
// Background jobs that live in the app process: the Spotify scrobbler (idle until connected) // 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. // server.js on the same Bun server): the PUBG drop map rooms.
export const init: ServerInit = () => { export const init: ServerInit = () => {
if (!building) { if (!building) {
startMusicPoller(); startMusicPoller();
startGamesPoller(); startGamesPoller();
startOsuGoalsPoller();
registerPubgMapChannel(); registerPubgMapChannel();
} }
}; };

View 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}&nbsp;+{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}&nbsp;+{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>

View 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([]);
});
});

View 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')}`;

View File

@@ -30,3 +30,4 @@ export * from './stats';
export * from './pubg-map'; export * from './pubg-map';
export * from './ai'; export * from './ai';
export * from './movies'; export * from './movies';
export * from './osu-goals';

View 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;

View 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 };
}
};

View 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);
}

View 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 };
}

View File

@@ -26,7 +26,8 @@ const RETIRED_LISTS: Record<string, string> = {
'books-to-read': '/trackers/books', 'books-to-read': '/trackers/books',
'scientific-papers': '/trackers/books?kind=papers', 'scientific-papers': '/trackers/books?kind=papers',
'cars-trains-planes-to-spot': '/trackers/spotting', '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) { async function currentList(slug: string) {

View File

@@ -5,11 +5,13 @@ import { gameLibrary, hardwareSensitivities } from '#lib/server/db/schema/index.
import { isOwner, requireOwner } from '#lib/server/owner.js'; import { isOwner, requireOwner } from '#lib/server/owner.js';
import { setGameActive } from '#lib/server/games/active.js'; import { setGameActive } from '#lib/server/games/active.js';
import { isAutoTracked, loadHistory } from '#lib/server/games/history.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'; import type { Actions, PageServerLoad } from './$types';
// One game from games.library, plus its mouse sensitivity (hardware.sensitivities, matched by // 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 // 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 // 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. // entry (all generations) now points at the Pokémon journal that covers every game.
const MOVED: Record<string, string> = { const MOVED: Record<string, string> = {
@@ -54,6 +56,7 @@ export const load: PageServerLoad = async (event) => {
owner: isOwner(event), owner: isOwner(event),
sensitivity: sensitivity ?? null, sensitivity: sensitivity ?? null,
history: isAutoTracked(game.slug) ? await loadHistory(game.slug, isOwner(event)) : null, history: isAutoTracked(game.slug) ? await loadHistory(game.slug, isOwner(event)) : null,
osuGoals: game.slug === 'osu' ? await loadOsuGoals() : null,
seo: { seo: {
title: `${game.name} · Games`, title: `${game.name} · Games`,
description: `${game.name} on Stan Runge’s game tracker${game.status === 'main' ? ': my main game right now' : ''}.` 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))) if (!(await setGameActive(event.params.slug, active)))
return fail(404, { message: 'Unknown game' }); return fail(404, { message: 'Unknown game' });
return { active }; return { active };
} },
...osuGoalActions
}; };

View File

@@ -11,6 +11,7 @@
import MapIcon from 'lucide-svelte/icons/map'; import MapIcon from 'lucide-svelte/icons/map';
import TrackerIcon from '#lib/components/TrackerIcon.svelte'; import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import MatchHistory from '#lib/game-tracker/MatchHistory.svelte'; import MatchHistory from '#lib/game-tracker/MatchHistory.svelte';
import OsuGoals from '#lib/game-tracker/OsuGoals.svelte';
import { gameTrackers } from '#lib/projects.js'; import { gameTrackers } from '#lib/projects.js';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
@@ -115,6 +116,10 @@
<MatchHistory history={data.history} game={game.slug as 'cs2' | 'pubg' | 'r6'} /> <MatchHistory history={data.history} game={game.slug as 'cs2' | 'pubg' | 'r6'} />
{/if} {/if}
{#if data.osuGoals}
<OsuGoals goals={data.osuGoals} owner={data.owner} />
{/if}
{#if game.slug === 'pubg' && data.owner} {#if game.slug === 'pubg' && data.owner}
<a <a
href={resolve('/trackers/games/pubg/map')} href={resolve('/trackers/games/pubg/map')}
@@ -144,7 +149,7 @@
</span> </span>
<ArrowRight size={18} aria-hidden="true" class="ml-4 text-[var(--site-muted)]" /> <ArrowRight size={18} aria-hidden="true" class="ml-4 text-[var(--site-muted)]" />
</a> </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)]"> <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. Nothing tracked for {game.shortName} yet. Stats, ranks and notes show up here once I add them.
</p> </p>