osu! goals: real play counts from my most-played list (weekly), BPM range from the .osu timing points
All checks were successful
Deploy production / deploy (push) Successful in 1m14s
All checks were successful
Deploy production / deploy (push) Successful in 1m14s
This commit is contained in:
@@ -156,7 +156,11 @@
|
|||||||
{#if g.starsHr != null}<span class="dim" title="star rating with HR"
|
{#if g.starsHr != null}<span class="dim" title="star rating with HR"
|
||||||
>HR {nf(g.starsHr, 2)}★</span
|
>HR {nf(g.starsHr, 2)}★</span
|
||||||
>{/if}
|
>{/if}
|
||||||
<span title="BPM"><Music size={12} aria-hidden="true" />{nf(g.bpm)}</span>
|
<span
|
||||||
|
title={g.bpmMin != null && g.bpmMax != null && g.bpmMax - g.bpmMin >= 1
|
||||||
|
? `BPM ${nf(g.bpmMin)}–${nf(g.bpmMax)}`
|
||||||
|
: 'BPM'}><Music size={12} aria-hidden="true" />{nf(g.bpm)}</span
|
||||||
|
>
|
||||||
<span title="length"
|
<span title="length"
|
||||||
><Clock size={12} aria-hidden="true" />{formatLength(g.lengthSeconds)}</span
|
><Clock size={12} aria-hidden="true" />{formatLength(g.lengthSeconds)}</span
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
bestScore,
|
bestScore,
|
||||||
|
bpmRange,
|
||||||
focusTags,
|
focusTags,
|
||||||
goalAchieved,
|
goalAchieved,
|
||||||
goalProgress,
|
goalProgress,
|
||||||
@@ -21,6 +22,21 @@ const osu = (objects: string[]) =>
|
|||||||
...objects
|
...objects
|
||||||
].join('\n');
|
].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', () => {
|
describe('streamStats', () => {
|
||||||
it('counts runs of circles a 1/4 beat apart', () => {
|
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.
|
// 200 BPM: beat 300 ms, 1/4 = 75 ms. A run of 6, a gap, then a run of 3.
|
||||||
|
|||||||
@@ -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.
|
* 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.
|
* 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 } {
|
export function streamStats(osu: string): { longest: number; notes: number } {
|
||||||
const section = (name: string) => {
|
const section = (name: string) => {
|
||||||
const start = osu.indexOf(`[${name}]`);
|
const start = osu.indexOf(`[${name}]`);
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export async function loadOsuGoals() {
|
|||||||
version: r.version,
|
version: r.version,
|
||||||
status: r.status,
|
status: r.status,
|
||||||
bpm: r.bpm,
|
bpm: r.bpm,
|
||||||
|
bpmMin: r.bpmMin,
|
||||||
|
bpmMax: r.bpmMax,
|
||||||
lengthSeconds: r.lengthSeconds,
|
lengthSeconds: r.lengthSeconds,
|
||||||
ar: r.ar,
|
ar: r.ar,
|
||||||
od: r.od,
|
od: r.od,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
goalAchieved,
|
goalAchieved,
|
||||||
goalProgress,
|
goalProgress,
|
||||||
parseScore,
|
parseScore,
|
||||||
|
bpmRange,
|
||||||
streamStats,
|
streamStats,
|
||||||
type OsuScore
|
type OsuScore
|
||||||
} from '#lib/game-tracker/osu-goals.js';
|
} 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;
|
return typeof v === 'number' ? Math.round(v * 100) / 100 : null;
|
||||||
};
|
};
|
||||||
let stream = { longest: null as number | null, notes: null as number | null };
|
let stream = { longest: null as number | null, notes: null as number | null };
|
||||||
|
let range: { min: number; max: number } | null = null;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`https://osu.ppy.sh/osu/${beatmapId}`, {
|
const res = await fetch(`https://osu.ppy.sh/osu/${beatmapId}`, {
|
||||||
signal: AbortSignal.timeout(12000)
|
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 {
|
} catch {
|
||||||
// Streams just stay unknown.
|
// Streams just stay unknown.
|
||||||
}
|
}
|
||||||
const covers = (set.covers ?? {}) as Json;
|
const covers = (set.covers ?? {}) as Json;
|
||||||
const owners = Array.isArray(diff.owners) ? (diff.owners 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 {
|
return {
|
||||||
beatmapsetId: setId,
|
beatmapsetId: setId,
|
||||||
beatmapId,
|
beatmapId,
|
||||||
@@ -127,8 +132,8 @@ export async function fetchMetadata(beatmapId: number) {
|
|||||||
tags: (str(set.tags) ?? '').split(/\s+/).filter(Boolean),
|
tags: (str(set.tags) ?? '').split(/\s+/).filter(Boolean),
|
||||||
userTags: await communityTags(diff.top_tag_ids),
|
userTags: await communityTags(diff.top_tag_ids),
|
||||||
bpm: num(diff.bpm) ?? num(set.bpm),
|
bpm: num(diff.bpm) ?? num(set.bpm),
|
||||||
bpmMin: bpms.length ? Math.min(...bpms) : null,
|
bpmMin: range?.min ?? null,
|
||||||
bpmMax: bpms.length ? Math.max(...bpms) : null,
|
bpmMax: range?.max ?? null,
|
||||||
lengthSeconds: int(diff.total_length),
|
lengthSeconds: int(diff.total_length),
|
||||||
drainSeconds: int(diff.hit_length),
|
drainSeconds: int(diff.hit_length),
|
||||||
ar: num(diff.ar),
|
ar: num(diff.ar),
|
||||||
@@ -173,8 +178,24 @@ export async function fetchMyScores(beatmapId: number): Promise<OsuScore[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How often I played each of `ids` (users/{id}/beatmapsets/most_played, highest first). */
|
||||||
|
export async function fetchMyPlayCounts(ids: Set<number>): Promise<Map<number, number>> {
|
||||||
|
const found = new Map<number, number>();
|
||||||
|
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. */
|
/** 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<number, number>) {
|
||||||
if (row.matchStatus !== 'matched' || !row.beatmapId) return row;
|
if (row.matchStatus !== 'matched' || !row.beatmapId) return row;
|
||||||
try {
|
try {
|
||||||
const stale =
|
const stale =
|
||||||
@@ -199,7 +220,7 @@ export async function refreshGoal(row: OsuGoalRow, force = false) {
|
|||||||
streamNotes: meta ? meta.streamNotes : row.streamNotes
|
streamNotes: meta ? meta.streamNotes : row.streamNotes
|
||||||
}),
|
}),
|
||||||
scores,
|
scores,
|
||||||
myPlayCount: scores.length,
|
myPlayCount: plays ? (plays.get(row.beatmapId) ?? 0) : row.myPlayCount,
|
||||||
bestScoreId: best?.id ?? null,
|
bestScoreId: best?.id ?? null,
|
||||||
bestScore: best?.score ?? null,
|
bestScore: best?.score ?? null,
|
||||||
bestAccuracy: best?.accuracy ?? null,
|
bestAccuracy: best?.accuracy ?? null,
|
||||||
|
|||||||
Reference in New Issue
Block a user