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

@@ -30,3 +30,4 @@ export * from './stats';
export * from './pubg-map';
export * from './ai';
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;