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:
@@ -30,3 +30,4 @@ export * from './stats';
|
||||
export * from './pubg-map';
|
||||
export * from './ai';
|
||||
export * from './movies';
|
||||
export * from './osu-goals';
|
||||
|
||||
180
src/lib/server/db/schema/osu-goals.ts
Normal file
180
src/lib/server/db/schema/osu-goals.ts
Normal 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;
|
||||
Reference in New Issue
Block a user