From 5e10ecf525ac734e1c5c1e19ff8e23e46e88e07d Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Mon, 26 Oct 2026 00:44:12 +0000 Subject: [PATCH] osu! goals: real play counts from my most-played list (weekly), BPM range from the .osu timing points --- src/lib/game-tracker/OsuGoals.svelte | 6 ++++- src/lib/game-tracker/osu-goals.test.ts | 16 +++++++++++++ src/lib/game-tracker/osu-goals.ts | 15 ++++++++++++ src/lib/server/osu-goals/load.ts | 2 ++ src/lib/server/osu-goals/refresh.ts | 33 +++++++++++++++++++++----- 5 files changed, 65 insertions(+), 7 deletions(-) diff --git a/src/lib/game-tracker/OsuGoals.svelte b/src/lib/game-tracker/OsuGoals.svelte index d5f9144..529bd5b 100644 --- a/src/lib/game-tracker/OsuGoals.svelte +++ b/src/lib/game-tracker/OsuGoals.svelte @@ -156,7 +156,11 @@ {#if g.starsHr != null}HR {nf(g.starsHr, 2)}★{/if} - + = 1 + ? `BPM ${nf(g.bpmMin)}–${nf(g.bpmMax)}` + : 'BPM'}> diff --git a/src/lib/game-tracker/osu-goals.test.ts b/src/lib/game-tracker/osu-goals.test.ts index 96f29ae..10b544d 100644 --- a/src/lib/game-tracker/osu-goals.test.ts +++ b/src/lib/game-tracker/osu-goals.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { bestScore, + bpmRange, focusTags, goalAchieved, goalProgress, @@ -21,6 +22,21 @@ const osu = (objects: string[]) => ...objects ].join('\n'); +describe('bpmRange', () => { + it('reads uninherited timing points only', () => { + const text = [ + '[TimingPoints]', + '0,300,4,2,0,60,1,0', + '1000,-50,4,2,0,60,0,0', + '2000,500,4,2,0,60,1,0', + '', + '[HitObjects]' + ].join('\n'); + expect(bpmRange(text)).toEqual({ min: 120, max: 200 }); + expect(bpmRange('osu file format v14')).toBe(null); + }); +}); + 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. diff --git a/src/lib/game-tracker/osu-goals.ts b/src/lib/game-tracker/osu-goals.ts index 5c6a267..9304261 100644 --- a/src/lib/game-tracker/osu-goals.ts +++ b/src/lib/game-tracker/osu-goals.ts @@ -22,6 +22,21 @@ 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. */ +/** BPM range from the uninherited timing points of an .osu file (null when there are none). */ +export function bpmRange(osu: string): { min: number; max: number } | null { + const start = osu.indexOf('[TimingPoints]'); + if (start < 0) return null; + const rest = osu.slice(start + 14); + const end = rest.search(/^\[/m); + const bpms = (end < 0 ? rest : rest.slice(0, end)) + .split(/\r?\n/) + .map((l) => l.trim().split(',')) + .filter((p) => p.length >= 2 && Number(p[1]) > 0 && (p.length < 7 || p[6] !== '0')) + .map((p) => Math.round((60000 / Number(p[1])) * 10) / 10) + .filter((b) => Number.isFinite(b) && b > 0); + return bpms.length ? { min: Math.min(...bpms), max: Math.max(...bpms) } : null; +} + export function streamStats(osu: string): { longest: number; notes: number } { const section = (name: string) => { const start = osu.indexOf(`[${name}]`); diff --git a/src/lib/server/osu-goals/load.ts b/src/lib/server/osu-goals/load.ts index 479a443..6ade0fe 100644 --- a/src/lib/server/osu-goals/load.ts +++ b/src/lib/server/osu-goals/load.ts @@ -27,6 +27,8 @@ export async function loadOsuGoals() { version: r.version, status: r.status, bpm: r.bpm, + bpmMin: r.bpmMin, + bpmMax: r.bpmMax, lengthSeconds: r.lengthSeconds, ar: r.ar, od: r.od, diff --git a/src/lib/server/osu-goals/refresh.ts b/src/lib/server/osu-goals/refresh.ts index 42901d2..68c4506 100644 --- a/src/lib/server/osu-goals/refresh.ts +++ b/src/lib/server/osu-goals/refresh.ts @@ -9,6 +9,7 @@ import { goalAchieved, goalProgress, parseScore, + bpmRange, streamStats, type OsuScore } from '#lib/game-tracker/osu-goals.js'; @@ -95,17 +96,21 @@ export async function fetchMetadata(beatmapId: number) { return typeof v === 'number' ? Math.round(v * 100) / 100 : null; }; let stream = { longest: null as number | null, notes: null as number | null }; + let range: { min: number; max: number } | null = null; try { const res = await fetch(`https://osu.ppy.sh/osu/${beatmapId}`, { signal: AbortSignal.timeout(12000) }); - if (res.ok) stream = streamStats(await res.text()); + if (res.ok) { + const text = await res.text(); + stream = streamStats(text); + range = bpmRange(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, @@ -127,8 +132,8 @@ export async function fetchMetadata(beatmapId: number) { 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, + bpmMin: range?.min ?? null, + bpmMax: range?.max ?? null, lengthSeconds: int(diff.total_length), drainSeconds: int(diff.hit_length), ar: num(diff.ar), @@ -173,8 +178,24 @@ export async function fetchMyScores(beatmapId: number): Promise { } } +/** How often I played each of `ids` (users/{id}/beatmapsets/most_played, highest first). */ +export async function fetchMyPlayCounts(ids: Set): Promise> { + const found = new Map(); + for (let offset = 0; offset < 20000 && found.size < ids.size; offset += 100) { + const page = (await api( + `users/${OSU_USER_ID}/beatmapsets/most_played?limit=100&offset=${offset}` + )) as Json[]; + if (!Array.isArray(page) || !page.length) break; + for (const p of page) { + const id = int(p.beatmap_id); + if (id && ids.has(id)) found.set(id, int(p.count) ?? 0); + } + } + return found; +} + /** Refresh one goal: metadata when missing or older than a week (or `force`), always scores. */ -export async function refreshGoal(row: OsuGoalRow, force = false) { +export async function refreshGoal(row: OsuGoalRow, force = false, plays?: Map) { if (row.matchStatus !== 'matched' || !row.beatmapId) return row; try { const stale = @@ -199,7 +220,7 @@ export async function refreshGoal(row: OsuGoalRow, force = false) { streamNotes: meta ? meta.streamNotes : row.streamNotes }), scores, - myPlayCount: scores.length, + myPlayCount: plays ? (plays.get(row.beatmapId) ?? 0) : row.myPlayCount, bestScoreId: best?.id ?? null, bestScore: best?.score ?? null, bestAccuracy: best?.accuracy ?? null,