Files
website/src/lib/game-tracker/osu-goals.ts
2026-10-26 00:44:12 +00:00

190 lines
6.8 KiB
TypeScript

// 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.
*/
/** 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}]`);
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')}`;