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:
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')}`;
|
||||
Reference in New Issue
Block a user