feat: i'm really worried about this bit, but empiricism will win (#41)

* feat: i'm really worried about this bit, but empiricism will win

* feat: light driving

* feat: migrate over user game status

* feat: custom scrollbar, and migrate over settings

* feat: eamusement csv movement

* fix: rm debug

* feat: shark reaction

* checkpoint

* feat: get tests passing again

* checkpoint

* feat: bms table sync

* feat: ugs snapshot and backsync bms pms

* feat: checkpoint

* feat: iidx playlists

* manic depression

* continues

* fly

* happy house

* we're happy here

* I think. 6 years is enough for me. This is killing me, and I am dying.
This commit is contained in:
zk
2026-03-31 23:37:51 +01:00
committed by GitHub
parent c156382623
commit dd2447c848
257 changed files with 12033 additions and 6093 deletions
+56
View File
@@ -0,0 +1,56 @@
---
name: no-return-await
description: Prefer `return` over `return await` in async functions when the await is redundant. Use when writing or reviewing async TypeScript/JavaScript, simplifying control flow, or fixing redundant awaits on returned promises.
---
# Prefer `return` over `return await`
## Default rule
In an `async function`, **`return await x` and `return x` are equivalent** for callers when `x` is a Promise (or thenable): both return a Promise with the same fulfillment and rejection.
Prefer **`return x`** — the extra `await` adds a microtask and obscures that you are simply forwarding the result.
```typescript
// Prefer
async function load() {
return fetchData();
}
// Avoid (unless you need await for control flow — see below)
async function load() {
return await fetchData();
}
```
## When you must `await`
Use **`await`** (including `return await`) only when you need the async function to **suspend on that operation** for control flow:
- **`try` / `catch` / `finally`**: To handle or finalize on **rejection** of the inner promise, you must `await` it inside `try`. A bare `return innerPromise()` does **not** route that rejection through `catch` / `finally` the same way.
```typescript
async function safeLoad() {
try {
// `return await` is correct here so `catch` sees rejections from fetchData()
return await fetchData();
} catch (e) {
return defaultData();
}
}
```
- **`finally`** that must run after the inner work settles (same idea — often needs `await` in the `try`).
If there is **no** `try`/`catch`/`finally` depending on that promise settling inside the function, **do not** use `return await`.
## Review checklist
- [ ] If the last statement is `return await expr` and nothing in the function uses `try`/`catch`/`finally` around that path for that promise → use `return expr`.
- [ ] If `catch` or `finally` must apply to failures of `expr` → keep `await` (often `return await expr` in the `try`).
## Why
- Clearer intent: forwarding a Promise vs. explicitly sequencing.
- Slightly leaner: no unnecessary `await` + re-wrapping.
- Aligns with common ESLint `no-return-await` guidance (with the try/catch exception above).
+25
View File
@@ -0,0 +1,25 @@
---
name: preserve-comments
description: Preserves existing code comments (line, block, JSDoc, region markers) when editing files; does not delete or collapse comments for brevity unless the user asked to remove or update them. Use when editing any source file, refactoring, or applying fixes where comments already exist.
---
# Preserve comments when editing
## Default rule
When changing code, **keep existing comments** in place unless one of the exceptions below applies.
- **Line comments** (`//`), **block comments** (`/* */`), **JSDoc/TSDoc** (`/** */`), and **region/pragma-style markers** the file already uses — preserve them; do not strip them to “clean up” or shorten the diff.
- If a comment sits next to changed logic, **update the comment only when the behavior it describes changed**. Otherwise leave it untouched.
- **Do not** replace a nuanced comment with nothing, or with a shorter generic note, unless the user asked for that.
## When removal or heavy edits are OK
- The user **explicitly** asked to remove comments, delete dead documentation, or “dedupe” comments.
- The comment is **factually wrong** after the edit and would mislead readers — then fix or remove that comment and prefer a short accurate replacement over silence.
- The **entire block of code** the comment referred to is deleted — remove or relocate the comment so it does not orphan misleading text (or move it if it still applies elsewhere).
## What this is not
- This does not require adding new comments for every change.
- This does not forbid editing comments when they must track the code.
+2 -1
View File
@@ -23,8 +23,9 @@ build
# Coverage
coverage
test-results
.nyc_output
# Vitest bench --outputJson (see Justfile-bench bench-json)
# Vitest bench --outputJson (see Justfile-bench)
typescript/server/bench-results.json
*.tsbuildinfo
+9 -4
View File
@@ -5,15 +5,20 @@ DEFAULT_DB := "tachi_dev"
db POSTGRES_DB=DEFAULT_DB:
psql "{{POSTGRES_URL}}/{{POSTGRES_DB}}"
# Drop and recreate the database so migrations can be re-applied from scratch.
db-reset POSTGRES_DB=DEFAULT_DB:
db-cheap-reset POSTGRES_DB=DEFAULT_DB:
#!/bin/bash
export POSTGRES_URL="{{POSTGRES_URL}}/{{POSTGRES_DB}}"
tachidb database drop
tachidb database create
tachidb migrate run
# Drop and recreate the database so migrations can be re-applied from scratch.
db-reset POSTGRES_DB=DEFAULT_DB:
#!/bin/bash
just db-cheap-reset "{{POSTGRES_DB}}"
just db-load-seeds "{{POSTGRES_DB}}"
# Show the status of all applied (and failed) migrations.
db-migrate-status POSTGRES_DB=DEFAULT_DB:
psql "{{POSTGRES_URL}}/{{POSTGRES_DB}}" -c \
@@ -27,5 +32,5 @@ db-load-seeds POSTGRES_DB=DEFAULT_DB:
bun run src/scripts/load-seeds-pg.ts
# Reset templatedb from migrations, then dump schema and indexes for local debugging.
db-schema: (db-reset "templatedb")
db-schema: (db-cheap-reset "templatedb")
pg_dump "{{POSTGRES_URL}}/templatedb" --schema-only --no-owner --no-acl -f db/schema.sql
+1 -1
View File
@@ -5,7 +5,7 @@
gen: gen-kanel gen-proto
# Generate kanel types from the postgres definitions in Tachi.
gen-kanel: (db-reset "templatedb")
gen-kanel: (db-cheap-reset "templatedb")
bun run kanel -d "{{ POSTGRES_URL }}/templatedb" -c db/kanel_config.js
just fmt
+1 -1
View File
@@ -9,7 +9,7 @@ migrate-v3:
# Migrate all MongoDB data into Postgres (run once, requires both DBs to be reachable).
mongo-to-pg POSTGRES_DB_NAME MONGO_DB_NAME:
just db-reset "{{ POSTGRES_DB_NAME }}"
just db-cheap-reset "{{ POSTGRES_DB_NAME }}"
cd typescript/server && VERSION=$(jq -r .version package.json) COMMIT_HASH=$(git rev-parse --short HEAD) POSTGRES_URL="{{POSTGRES_URL}}/{{POSTGRES_DB_NAME}}" MONGO_URL="mongodb://tachi-mongo/{{MONGO_DB_NAME}}" \
bun run src/scripts/migrate-to-postgres.ts
+1 -1
View File
@@ -21,7 +21,7 @@ fix:
set -euo pipefail
just gen
bun run --sequential --elide-lines=0 --filter '*' lint-fix
bun run --elide-lines=0 --filter '*' lint-fix
.scripts/ts_autoinherit.js
just fmt
+1
View File
@@ -426,6 +426,7 @@
"devDependencies": {
"@typescript/native-preview": "catalog:",
"eslint-config-tachi": "workspace:*",
"vitest": "catalog:",
},
},
},
+43 -13
View File
@@ -625,7 +625,13 @@ CREATE TABLE "session" (
time_ended TIMESTAMPTZ NOT NULL,
calculated_data JSONB NOT NULL,
highlight BOOLEAN NOT NULL
highlight BOOLEAN NOT NULL,
-- Session UGPT search: FTS + trgm (see lib/search/session-search.ts).
textsearch tsvector NOT NULL GENERATED ALWAYS AS (
setweight(to_tsvector('simple', coalesce(name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(description, '')), 'B')
) STORED
);
CREATE TABLE "import" (
@@ -646,7 +652,7 @@ CREATE TABLE "import" (
-- but importantly they're always in the same game group
-- this is a random tachi2 holdover.
CREATE TABLE "import_game" (
id TEXT REFERENCES import(id) NOT NULL,
id TEXT REFERENCES import(id) ON DELETE CASCADE NOT NULL,
game GAME NOT NULL,
PRIMARY KEY (id, game)
@@ -655,7 +661,7 @@ CREATE TABLE "import_game" (
CREATE TABLE "import_error" (
row_id UUID PRIMARY KEY NOT NULL DEFAULT uuidv7(),
import_id TEXT REFERENCES import(id) NOT NULL,
import_id TEXT REFERENCES import(id) ON DELETE CASCADE NOT NULL,
type TEXT NOT NULL,
message TEXT NOT NULL
);
@@ -663,7 +669,7 @@ CREATE TABLE "import_error" (
CREATE TABLE "import_session" (
row_id UUID PRIMARY KEY NOT NULL DEFAULT uuidv7(),
import_id TEXT REFERENCES import(id) NOT NULL,
import_id TEXT REFERENCES import(id) ON DELETE CASCADE NOT NULL,
session_id TEXT REFERENCES session(id) NOT NULL,
type TEXT NOT NULL CHECK (type IN ('appended', 'created')),
@@ -673,7 +679,7 @@ CREATE TABLE "import_session" (
CREATE TABLE "import_class" (
row_id UUID PRIMARY KEY DEFAULT uuidv7(),
import_id TEXT REFERENCES import(id) NOT NULL,
import_id TEXT REFERENCES import(id) ON DELETE CASCADE NOT NULL,
game GAME NOT NULL,
set TEXT NOT NULL,
prev TEXT,
@@ -682,7 +688,7 @@ CREATE TABLE "import_class" (
CREATE TABLE "import_timing" (
id TEXT PRIMARY KEY REFERENCES import(id) NOT NULL,
id TEXT PRIMARY KEY REFERENCES import(id) ON DELETE CASCADE NOT NULL,
timestamp TIMESTAMPTZ NOT NULL,
import_secs_avg FLOAT8 NOT NULL,
@@ -738,7 +744,10 @@ CREATE TABLE "score" (
time_added TIMESTAMPTZ NOT NULL,
highlight BOOLEAN NOT NULL,
comment TEXT
comment TEXT,
-- Staging: false until post-import steps finish; then true (see score_import_uncommitted_idx).
committed BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE "pb" (
@@ -758,6 +767,7 @@ CREATE TABLE "pb" (
data JSONB NOT NULL,
derived_data JSONB NOT NULL,
calculated_data JSONB NOT NULL,
judgements JSONB NOT NULL,
-- how to rank scores. Five (sorry, hardcoded) additional
-- tiebreakers are available.
@@ -783,11 +793,13 @@ CREATE TABLE "pb_composed_from" (
PRIMARY KEY (pb_id, score_id)
);
-- Global chart rank/outOf live in one place: the leaderboard window over `pb`,
-- not duplicated into `calculated_data`.
CREATE VIEW "chart_leaderboard" AS
SELECT
pb.*,
RANK() OVER (
PARTITION BY chart_id
PARTITION BY chart_id, lens
ORDER BY
ranking_value DESC NULLS LAST,
ranking_value_tb1 DESC NULLS LAST,
@@ -795,15 +807,20 @@ SELECT
ranking_value_tb3 DESC NULLS LAST,
ranking_value_tb4 DESC NULLS LAST,
ranking_value_tb5 DESC NULLS LAST,
-- oldest time_achieved wins ties
time_achieved ASC NULLS LAST
) AS rank
) AS rank,
COUNT(*) OVER (PARTITION BY chart_id, lens) AS out_of
FROM pb;
CREATE TABLE "orphan_score" (
row_id UUID PRIMARY KEY NOT NULL DEFAULT uuidv7(),
user_id BIGINT REFERENCES account(id) NOT NULL,
import_id TEXT REFERENCES import(id) NOT NULL,
import_id TEXT REFERENCES import(id) ON DELETE SET NULL,
orphan_id TEXT NOT NULL,
import_type IMPORT_TYPE NOT NULL,
game_group GAME_GROUP NOT NULL,
data JSONB NOT NULL,
context JSONB NOT NULL,
@@ -944,7 +961,7 @@ CREATE TABLE "questline_quest" (
CREATE TABLE "import_quest" (
row_id UUID PRIMARY KEY DEFAULT uuidv7(),
import_id TEXT REFERENCES import(id) NOT NULL,
import_id TEXT REFERENCES import(id) ON DELETE CASCADE NOT NULL,
quest_id TEXT REFERENCES quest(id) NOT NULL,
prev_achieved BOOLEAN NOT NULL,
@@ -957,7 +974,7 @@ CREATE TABLE "import_quest" (
CREATE TABLE "import_goal" (
row_id UUID PRIMARY KEY DEFAULT uuidv7(),
import_id TEXT REFERENCES import(id) NOT NULL,
import_id TEXT REFERENCES import(id) ON DELETE CASCADE NOT NULL,
goal_id TEXT REFERENCES goal(id) NOT NULL,
prev_achieved BOOLEAN NOT NULL,
@@ -992,6 +1009,13 @@ CREATE INDEX score_user_highlights_idx ON score (user_id, time_added DESC)
-- Needed separately because score_user_chart_idx has user_id as the left column.
CREATE INDEX score_chart_idx ON score (chart_id);
-- Failed-import cleanup: delete uncommitted rows by import_id.
CREATE INDEX score_import_uncommitted_idx ON score (import_id)
WHERE
committed = FALSE;
CREATE UNIQUE INDEX orphan_score_orphan_id_key ON orphan_score (orphan_id);
-- pb (5M rows) — hottest table
-- The single most important index in the schema.
-- Serves: chart_leaderboard view, leaderboard pagination, and the COUNT rank
@@ -1021,6 +1045,12 @@ CREATE INDEX session_user_game_idx ON session (user_id, game, time_started DESC)
-- Global game activity feed ("recent sessions for IIDX globally").
CREATE INDEX session_game_recent_idx ON session (game, time_started DESC);
CREATE INDEX session_textsearch_gin ON session USING GIN (textsearch);
CREATE INDEX session_name_trgm ON session USING GIN (name gin_trgm_ops);
CREATE INDEX session_description_trgm ON session USING GIN (description gin_trgm_ops);
-- import
-- User's import history page. time_started DESC for newest-first ordering.
CREATE INDEX import_user_idx ON import (user_id, time_started DESC);
+1 -1
View File
@@ -23243,7 +23243,7 @@
"title": "Timepiece phase Ⅱ"
},
{
"altTitles": ["¡¡Fanta∽cramble!!"],
"altTitles": [],
"artist": "Potwi",
"data": {
"displayVersion": "exceed"
+4
View File
@@ -56,6 +56,8 @@ export function MakeAnonAction<A extends AnonActionName>(
db,
appName: APP_NAME,
kind,
inputSchema: AnonActionSignatures[kind].input,
outputSchema: AnonActionSignatures[kind].output,
// @ts-expect-error we're being creative with the types here
fn,
}) as AnonActionFn<A>;
@@ -70,6 +72,8 @@ export function MakeAction<A extends ActionName>(kind: A, fn: ActionFn<A>): Acti
db,
appName: APP_NAME,
kind,
inputSchema: ActionSignatures[kind].input,
outputSchema: ActionSignatures[kind].output,
// @ts-expect-error we're being creative with the types here
fn,
}) as ActionFn<A>;
+1 -1
View File
@@ -111,7 +111,7 @@ describe("ACTION_Sync", () => {
// node-pg returns BIGINT columns as strings
expect(action).toMatchObject({
app: "BOT",
app: "TACHI_BOT",
kind: "SYNC",
result: "GOOD",
user_id: taker.acct.id,
@@ -63,7 +63,7 @@ describe("ACTION_Letmein", () => {
// node-pg returns BIGINT columns as strings
expect(action).toMatchObject({
app: "BOT",
app: "TACHI_BOT",
kind: "LETMEIN",
result: "GOOD",
user_id: null,
@@ -31,9 +31,8 @@ describe("ACTION_Register", () => {
.where("user_id", "=", userId)
.executeTakeFirst();
// node-pg returns BIGINT columns as strings
expect(row).toMatchObject({
user_id: String(userId),
user_id: userId,
discord_id: discordId,
api_token: apiToken,
});
@@ -80,7 +79,7 @@ describe("ACTION_Register", () => {
.executeTakeFirst();
expect(action).toMatchObject({
app: "BOT",
app: "TACHI_BOT",
kind: "REGISTER",
result: "GOOD",
user_id: null,
+9 -1
View File
@@ -2,7 +2,15 @@ import type { Database } from "tachi-db";
import { Env } from "#config";
import { Kysely, PostgresDialect } from "kysely";
import { Pool } from "pg";
import pg, { Pool } from "pg";
// pg returns BIGINT/BIGSERIAL (OID 20) as strings by default to avoid
// precision loss for very large values. Our IDs are well within Number.MAX_SAFE_INTEGER,
// so parse them as numbers to keep types consistent across the codebase.
pg.types.setTypeParser(pg.types.builtins.TIMESTAMPTZ, (val) => val);
pg.types.setTypeParser(pg.types.builtins.INT4, (val) => Number(val));
pg.types.setTypeParser(pg.types.builtins.INT2, (val) => Number(val));
pg.types.setTypeParser(pg.types.builtins.INT8, (val) => Number(val));
const pool = new Pool({ connectionString: Env.POSTGRES_URL });
+4
View File
@@ -1,3 +1,5 @@
import { CustomScrollbar } from "#components/layout/CustomScrollbar";
import { LocalDevMissingSeedsBanner } from "#components/layout/LocalDevMissingSeedsBanner";
import { LoadingScreen } from "#components/layout/screens/LoadingScreen";
import { AllLUGPTStatsContextProvider } from "#context/AllLUGPTStatsContext";
import { BannedContextProvider } from "#context/BannedContext";
@@ -21,6 +23,7 @@ export default function App({ basename }: { basename: string }) {
return (
<React.StrictMode>
<WindowContextProvider>
<CustomScrollbar />
<QueryClientProvider client={queryClient}>
<BannedContextProvider>
<UserContextProvider>
@@ -30,6 +33,7 @@ export default function App({ basename }: { basename: string }) {
<AllLUGPTStatsContextProvider>
<BrowserRouter basename={basename}>
<Toaster />
<LocalDevMissingSeedsBanner />
<SubheaderContextProvider>
<Routes />
</SubheaderContextProvider>
@@ -100,9 +100,33 @@ export default function UGPTSettingsPage({ reqUser, game, playtype }: UGPT) {
<div className="col-12">
<Divider className="mt-4 mb-4" />
{page === "preferences" ? (
<PreferencesForm {...UGPT} loggedInData={loggedInData} />
loggedInData.settings ? (
<PreferencesForm
{...UGPT}
loggedInData={{ ...loggedInData, settings: loggedInData.settings }}
/>
) : (
<div className="text-center">
<Muted>
No game settings exist yet for this profile. Submit a score or
import first, then return here.
</Muted>
</div>
)
) : page === "showcase" ? (
<ShowcaseForm {...UGPT} loggedInData={loggedInData} />
loggedInData.settings ? (
<ShowcaseForm
{...UGPT}
loggedInData={{ ...loggedInData, settings: loggedInData.settings }}
/>
) : (
<div className="text-center">
<Muted>
No game settings exist yet for this profile. Submit a score or
import first, then return here.
</Muted>
</div>
)
) : (
<ManageAccount {...UGPT} />
)}
@@ -117,7 +141,7 @@ function PreferencesForm({
game,
playtype,
loggedInData,
}: { loggedInData: UGPTData } & UGPT) {
}: { loggedInData: { settings: MONGO_UGPTSettingsDocument } & UGPTData } & UGPT) {
const { setLoggedInData } = useContext(UGPTContext);
const settings = loggedInData.settings;
@@ -127,17 +151,17 @@ function PreferencesForm({
const formik = useFormik({
initialValues: {
preferredScoreAlg:
settings!.preferences.preferredScoreAlg || gptConfig.defaultScoreRatingAlg,
settings.preferences.preferredScoreAlg || gptConfig.defaultScoreRatingAlg,
preferredProfileAlg:
settings!.preferences.preferredProfileAlg || gptConfig.defaultProfileRatingAlg,
settings.preferences.preferredProfileAlg || gptConfig.defaultProfileRatingAlg,
preferredSessionAlg:
settings!.preferences.preferredSessionAlg || gptConfig.defaultSessionRatingAlg,
settings.preferences.preferredSessionAlg || gptConfig.defaultSessionRatingAlg,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
gameSpecific: settings!.preferences.gameSpecific as any,
defaultTable: settings!.preferences.defaultTable,
gameSpecific: settings.preferences.gameSpecific as any,
defaultTable: settings.preferences.defaultTable,
preferredDefaultEnum:
settings!.preferences.preferredDefaultEnum ?? gptConfig.preferredDefaultEnum,
preferredRanking: settings!.preferences.preferredRanking ?? "global",
settings.preferences.preferredDefaultEnum ?? gptConfig.preferredDefaultEnum,
preferredRanking: settings.preferences.preferredRanking ?? "global",
},
onSubmit: async (values) => {
const rj = await APIFetchV1<MONGO_UserDocument>(
@@ -177,7 +201,7 @@ function PreferencesForm({
}
const displayableTables = tables.filter(
(e) => !e.inactive || settings?.preferences.defaultTable === e.tableID,
(e) => !e.inactive || settings.preferences.defaultTable === e.tableID,
);
const formGroupClassNames = "d-flex flex-column";
@@ -407,12 +431,12 @@ function ShowcaseForm({
game,
playtype,
loggedInData,
}: { loggedInData: UGPTData } & UGPT) {
}: { loggedInData: { settings: MONGO_UGPTSettingsDocument } & UGPTData } & UGPT) {
const { setLoggedInData } = useContext(UGPTContext);
const settings = loggedInData.settings;
const [stats, setStats] = useState(settings!.preferences.stats);
const [stats, setStats] = useState(settings.preferences.stats);
const [show, setShow] = useState(false);
const SaveChanges = async () => {
@@ -28,7 +28,7 @@ export default function IIDXStyleSongChartInfoFormat({
function LevelText(chart: MONGO_ChartDocument, game: GameGroup) {
if ("tableFolders" in chart.data) {
const hasLevel = chart.data.tableFolders.length > 0;
const hasLevel = Object.keys(chart.data.tableFolders).length > 0;
return hasLevel ? FormatTables(chart.data.tableFolders) : "No Level";
}
return FormatDifficulty(chart, game);
@@ -100,7 +100,7 @@ function Component({ game, playtype }: UGPT) {
BMS_TABLES.map((e) => [
e.asciiPrefix,
(d: DatasetElement) =>
!!d.data.tableFolders.find((k) => k.table === e.prefix),
!!Object.keys(d.data.tableFolders).find((k) => k === e.prefix),
]),
)}
/>
@@ -0,0 +1,37 @@
.track {
position: fixed;
top: 0;
right: 0;
// Above .fixed-top (1030), below modal/offcanvas layers (~1050+)
z-index: 1035;
box-sizing: border-box;
width: var(--custom-scrollbar-track-width, 12px);
height: 100vh;
height: 100dvh;
padding: 2px;
pointer-events: auto;
touch-action: pan-y;
}
.trackInner {
position: relative;
width: 100%;
height: 100%;
border-radius: 999px;
background-color: rgba(var(--bs-tertiary-bg-rgb), 0.35);
}
.thumb {
position: absolute;
right: 0;
left: 0;
cursor: grab;
touch-action: none;
border-radius: 999px;
background-color: rgba(var(--bs-secondary-rgb), 0.45);
transition: background-color 0.12s ease;
&:hover {
background-color: rgba(var(--bs-secondary-rgb), 0.65);
}
}
@@ -0,0 +1,134 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import classes from "./CustomScrollbar.module.scss";
const THUMB_MIN_PX = 32;
function readMetrics() {
const root = document.documentElement;
return {
scrollTop: root.scrollTop,
scrollHeight: root.scrollHeight,
clientHeight: root.clientHeight,
};
}
/**
* Hides the native document scrollbar via global CSS; renders a fixed overlay
* track so layout width stays stable while scrolling.
*/
export function CustomScrollbar() {
const [metrics, setMetrics] = useState(readMetrics);
const thumbRef = useRef<HTMLDivElement>(null);
const update = useCallback(() => {
setMetrics(readMetrics());
}, []);
useEffect(() => {
update();
window.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
const ro = new ResizeObserver(update);
ro.observe(document.documentElement);
ro.observe(document.body);
return () => {
window.removeEventListener("scroll", update);
window.removeEventListener("resize", update);
ro.disconnect();
};
}, [update]);
const { scrollTop, scrollHeight, clientHeight } = metrics;
const scrollable = Math.max(0, scrollHeight - clientHeight);
const show = scrollable > 1;
const thumbH =
scrollable <= 0 ? 0 : Math.max(THUMB_MIN_PX, (clientHeight / scrollHeight) * clientHeight);
const thumbTravel = Math.max(0, clientHeight - thumbH);
const thumbTop = scrollable <= 0 ? 0 : (scrollTop / scrollable) * thumbTravel;
const onThumbPointerDown = (e: React.PointerEvent) => {
if (e.button !== 0) {
return;
}
e.preventDefault();
const startY = e.clientY;
const startScrollTop = document.documentElement.scrollTop;
const onMove = (ev: PointerEvent) => {
const cur = readMetrics();
const sc = Math.max(0, cur.scrollHeight - cur.clientHeight);
if (sc <= 0) {
return;
}
const tH = Math.max(
THUMB_MIN_PX,
(cur.clientHeight / cur.scrollHeight) * cur.clientHeight,
);
const tTrack = Math.max(0, cur.clientHeight - tH);
if (tTrack <= 0) {
return;
}
const deltaY = ev.clientY - startY;
const next = startScrollTop + (deltaY / tTrack) * sc;
document.documentElement.scrollTop = Math.max(0, Math.min(sc, next));
};
const onUp = (ev: PointerEvent) => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onUp);
document.body.style.removeProperty("user-select");
if (thumbRef.current?.hasPointerCapture(ev.pointerId)) {
thumbRef.current.releasePointerCapture(ev.pointerId);
}
};
thumbRef.current?.setPointerCapture(e.pointerId);
document.body.style.userSelect = "none";
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
};
const onTrackPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0 || thumbRef.current?.contains(e.target as Node)) {
return;
}
const thumbEl = thumbRef.current;
if (!thumbEl) {
return;
}
const thumbRect = thumbEl.getBoundingClientRect();
const mid = thumbRect.top + thumbRect.height / 2;
const page = clientHeight * 0.85;
if (e.clientY < mid) {
document.documentElement.scrollBy({ top: -page, behavior: "smooth" });
} else {
document.documentElement.scrollBy({ top: page, behavior: "smooth" });
}
};
if (!show) {
return null;
}
return (
<div
aria-hidden
className={classes.track}
onPointerDown={onTrackPointerDown}
role="presentation"
>
<div className={classes.trackInner}>
<div
className={classes.thumb}
onPointerDown={onThumbPointerDown}
ref={thumbRef}
style={{ height: thumbH, top: thumbTop }}
/>
</div>
</div>
);
}
@@ -0,0 +1,39 @@
import { APIFetchV1 } from "#util/api";
import React, { useEffect, useState } from "react";
import Alert from "react-bootstrap/Alert";
/**
* Shown only when the Vite local-dev client talks to a dev server whose Postgres `song`
* table is empty (seeds were never loaded).
*/
export function LocalDevMissingSeedsBanner() {
const [show, setShow] = useState(false);
useEffect(() => {
if (!import.meta.env.VITE_IS_LOCAL_DEV) {
return;
}
void APIFetchV1<{ missingSongSeeds: boolean }>("/localdev/song-seed-status").then((r) => {
if (r.success && r.body.missingSongSeeds) {
setShow(true);
}
});
}, []);
if (!show) {
return null;
}
return (
<Alert
className="position-fixed bottom-0 start-50 translate-middle-x mb-3 mx-3 shadow"
role="alert"
style={{ zIndex: 1080, maxWidth: "min(42rem, calc(100vw - 2rem))" }}
variant="warning"
>
There are no seeds in the database. <br />
Have you forgotten to run <code className="user-select-all">just db-load-seeds</code>?
</Alert>
);
}
@@ -25,7 +25,7 @@ export default function BMSOrPMSDifficultyCell({
settings: MONGO_UGPTSettingsDocument<"bms:7K" | "bms:14K">;
};
const hasLevel = chart.data.tableFolders.length > 0;
const hasLevel = Object.keys(chart.data.tableFolders).length > 0;
const aiLevel =
game === "bms" && (chart as MONGO_ChartDocument<"bms:7K" | "bms:14K">).data.aiLevel;
@@ -33,18 +33,35 @@ export default function BMSOrPMSDifficultyCell({
let levelText = "No Rating";
let backgroundColour = hasLevel ? COLOUR_SET.red : COLOUR_SET.gray;
let filteredTables: Array<{ level: string; table: string }> = [];
let filteredTables: Record<string, string> = {};
if (hasLevel) {
let tables = chart.data.tableFolders;
if (settings?.preferences.gameSpecific.displayTables) {
filteredTables = tables.filter(
(e) => !settings.preferences.gameSpecific.displayTables!.includes(e.table),
);
tables = tables.filter((e) =>
settings.preferences.gameSpecific.displayTables!.includes(e.table),
);
filteredTables = Object.entries(tables)
.filter(
([table, _]) =>
!settings.preferences.gameSpecific.displayTables!.includes(table),
)
.reduce(
(acc, [table, level]) => {
acc[table] = level;
return acc;
},
{} as Record<string, string>,
);
tables = Object.entries(tables)
.filter(([table, _]) =>
settings.preferences.gameSpecific.displayTables!.includes(table),
)
.reduce(
(acc, [table, level]) => {
acc[table] = level;
return acc;
},
{} as Record<string, string>,
);
}
levelText = FormatTables(tables);
@@ -71,7 +88,7 @@ export default function BMSOrPMSDifficultyCell({
</div>
</QuickTooltip>
</>
) : filteredTables.length > 0 ? (
) : Object.keys(filteredTables).length > 0 ? (
<QuickTooltip
tooltipContent={
<div>
@@ -100,8 +117,8 @@ function FindTableColour(
) {
const lookup = new Map(BMS_TABLES.map((e) => [e.prefix, e.colour]));
for (const table of tableFolders) {
const colour = lookup.get(table.table);
for (const table of Object.keys(tableFolders)) {
const colour = lookup.get(table);
if (colour) {
return colour;
@@ -1,3 +1,4 @@
import DebugContent from "#components/util/DebugContent.js";
import useScoreRatingAlg from "#components/util/useScoreRatingAlg";
import { type ScoreDataset } from "#types/tables";
import { NumericSOV, StrSOV } from "#util/sorts";
@@ -15,7 +15,7 @@ import {
* It's worth keeping this around for both the currently-viewed user and the currently-logged-in user.
*/
export interface UGPTData {
settings: MONGO_UGPTSettingsDocument;
settings: MONGO_UGPTSettingsDocument | null;
stats: MONGO_UserGameStats;
game: GameGroup;
playtype: Playtype;
@@ -48,7 +48,7 @@ export default async function fetchUGPTData(
);
}
const settingsRes = await APIFetchV1<MONGO_UGPTSettingsDocument>(
const settingsRes = await APIFetchV1<MONGO_UGPTSettingsDocument | null>(
`/users/${userID}/games/${game}/${playtype}/settings`,
);
+9 -4
View File
@@ -1,4 +1,4 @@
import { ToAPIURL } from "#util/api";
import { ToAbsoluteAPIURLForHelpLink, ToAPIURL } from "#util/api";
import { type TachiServerCoreConfig } from "tachi-common";
// @ts-expect-error No types available...
import syncFetch from "sync-fetch";
@@ -17,6 +17,9 @@ try {
throw new Error(`Failed to fetch config -- ${configRes.description}.`);
}
} catch (err) {
const devServerUrl = import.meta.env.VITE_SERVER_URL ?? "";
const showHttpsCertHint = devServerUrl.startsWith("https://");
const statusHelpUrl = ToAbsoluteAPIURLForHelpLink("/status");
document.open();
document.write(`
<style>
@@ -44,9 +47,11 @@ try {
<h1><b>Couldn't connect to the server.</b></h1>
<h3>You are in local development mode.</h3>
<ul style="font-size: 2rem;">
<li>Have you accepted the HTTPS certificates for <a href="${ToAPIURL(
"/status",
)}">the server?</a>. If not, the site won't load.</li>
<li>${
showHttpsCertHint
? `Have you accepted the HTTPS certificates for <a href="${statusHelpUrl}">the server?</a> If not, the site won't load.`
: `Is the backend running? Try opening <a href="${statusHelpUrl}">the status endpoint</a> in your browser (default dev URL is <code>http://127.0.0.1:8080</code>).`
}</li>
</ul>
`
: `<h1>Failed to connect!</h1>
+19
View File
@@ -1,3 +1,22 @@
// Hide native document scrollbar (width); overlay bar in CustomScrollbar.tsx
html {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
body {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.nav-link {
@extend .rounded;
}
+16
View File
@@ -22,6 +22,22 @@ export function ToAPIURL(url: string) {
return `${BASE_URL}/api/v1${url}`;
}
/**
* When `VITE_SERVER_URL` is unset, `ToAPIURL` is same-origin relative. On the Vite dev
* server, a plain `/api/v1/...` link would hit the SPA and reload `index.html` in a loop.
* For "open in browser" help links, force an absolute backend URL in local dev.
*/
export function ToAbsoluteAPIURLForHelpLink(url: string) {
const u = ToAPIURL(url);
if (u.startsWith("http://") || u.startsWith("https://")) {
return u;
}
if (import.meta.env.VITE_IS_LOCAL_DEV) {
return `http://127.0.0.1:8080${u}`;
}
return u;
}
export function ToCDNURL(url: string) {
if (url[0] !== "/") {
url = `/${url}`;
+5 -3
View File
@@ -50,8 +50,10 @@ export function PartialArrayRecordAssign<K extends number | string | symbol, T>(
}
}
export function FormatTables(tables: { level: string; table: string }[]) {
return tables.map((e) => `${e.table}${e.level}`).join(", ");
export function FormatTables(tables: Record<string, string>) {
return Object.entries(tables)
.map(([table, level]) => `${table}${level}`)
.join(", ");
}
export function FormatGPTProfileRating(
@@ -191,7 +193,7 @@ export function SelectRightChart(
return chart;
}
if (chartID === chart.chartID || chartID === chart.legacyChartId) {
if (chartID === chart.chartID) {
return chart;
}
}
@@ -19,9 +19,9 @@ import {
} from "tachi-common";
function GetBMSTableVal(chart: MONGO_ChartDocument<"bms:7K" | "bms:14K">, key: string) {
for (const table of chart.data.tableFolders) {
if (table.table === key) {
return Number(table.level);
for (const [table, level] of Object.entries(chart.data.tableFolders)) {
if (table === key) {
return Number(level);
}
}
@@ -371,7 +371,7 @@ export const BMS_7K_CONF = {
notecount: zodNonNegativeInt,
hashMD5: z.string(),
hashSHA256: z.string(),
tableFolders: z.array(z.strictObject({ table: z.string(), level: z.string() })),
tableFolders: z.record(z.string(), z.string()),
sglEC: z.number().nullable().optional(),
sglHC: z.number().nullable().optional(),
aiLevel: z.string().nullable().optional(),
@@ -13,6 +13,11 @@ export const IIDX_CONF = {
songData: z.strictObject({
genre: z.string(),
displayVersion: z.nullable(z.string()),
// Sometimes, the titles in the e-amusement CSV are different from the titles in the database.
eamusementCsvTitle: z.optional(z.string()),
eamusementCsvArtist: z.optional(z.string()),
eamusementCsvGenre: z.optional(z.string()),
}),
} as const satisfies INTERNAL_GAME_CONFIG;
@@ -141,7 +141,7 @@ export const PMS_CONTROLLER_CONF = {
notecount: zodNonNegativeInt,
hashMD5: z.string(),
hashSHA256: z.string(),
tableFolders: z.array(z.strictObject({ table: z.string(), level: z.string() })),
tableFolders: z.record(z.string(), z.string()),
sglEC: z.number().nullable().optional(),
sglHC: z.number().nullable().optional(),
}),
@@ -107,12 +107,7 @@ export const USC_CONTROLLER_CONF = {
hashSHA1: z.union([z.array(z.string()), z.string()]),
isOfficial: z.boolean(),
effector: z.string(),
tableFolders: z.array(
z.strictObject({
table: z.string(),
level: z.string(),
}),
),
tableFolders: z.record(z.string(), z.string()),
}),
preferences: z.strictObject({ vf6Target: z.number().optional().nullable() }),
+1
View File
@@ -11,5 +11,6 @@ export * as Schemas from "./lib/schemas";
export * from "./lib/zod-schemas";
export * from "./seeds/types";
export * from "./types";
export * from "./utils/tachi-id";
export * from "./utils/util";
export * from "./webhooks";
+2 -5
View File
@@ -13,7 +13,6 @@ import type {
integer,
Judgements,
MONGO_ChartDocumentData,
MONGO_SongDocumentData,
MongoDerivedMetrics as MongoDerivedMetrics,
MongoOptionalMetrics as MongoOptionalMetrics,
MongoProvidedMetrics as MongoProvidedMetrics,
@@ -29,6 +28,7 @@ import type {
ScoreMeta,
ScoreRatingAlgorithms,
SessionRatingAlgorithms,
SongDocumentData,
UserAuthLevels,
V3Game,
V3GameToGPTString,
@@ -361,10 +361,7 @@ export interface ChartTierlistInfo {
}
export interface MONGO_ChartDocument<GPT extends GPTString = GPTString> {
/** Postgres `chart.id` — canonical in API URLs and JSON. */
chartID: string;
/** Legacy Mongo-era chart id (`chart.legacy_id` in Postgres). Required when loaded from PG; omit on raw Mongo chart docs. */
legacyChartId?: string;
songID: integer;
level: string;
levelNum: number;
@@ -381,7 +378,7 @@ export interface MONGO_SongDocument<G extends GameGroup = GameGroup> {
artist: string;
searchTerms: Array<string>;
altTitles: Array<string>;
data: MONGO_SongDocumentData[G];
data: SongDocumentData[G];
}
export interface MONGO_TableDocument {
+1 -1
View File
@@ -46,7 +46,7 @@ export type Playtypes = {
*/
export type Playtype = Playtypes[GameGroup];
export type MONGO_SongDocumentData = {
export type SongDocumentData = {
[G in GameGroup]: z.infer<(typeof GAME_GROUP_CONFIGS)[G]["songData"]>;
};
@@ -0,0 +1,39 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
CreateChartID,
CreateFolderID,
CreateQuestID,
CreateSongID,
CreateTableID,
tachiID,
} from "./tachi-id";
describe("tachi-id", () => {
beforeEach(() => {
let i = 0;
const bytes = [0xab, 0xcd, 0xef, 0x10];
vi.stubGlobal("crypto", {
getRandomValues(arr: Uint8Array) {
for (let j = 0; j < arr.length; j++) {
arr[j] = bytes[i++ % bytes.length]!;
}
return arr;
},
} as Crypto);
});
it("tachiID concatenates prefix, hex time delta, and 8 hex chars", () => {
expect(tachiID("X", 4096, 0)).toBe("X1000abcdef10");
});
it("Create* helpers use expected prefixes", () => {
expect(CreateChartID(1)).toMatch(/^C1[0-9a-f]{8}$/u);
expect(CreateQuestID(1)).toMatch(/^Q1[0-9a-f]{8}$/u);
expect(CreateSongID(1)).toMatch(/^S1[0-9a-f]{8}$/u);
expect(CreateFolderID(1)).toMatch(/^F1[0-9a-f]{8}$/u);
expect(CreateTableID(1)).toMatch(/^T1[0-9a-f]{8}$/u);
});
});
+44
View File
@@ -0,0 +1,44 @@
function randomHex(byteLength: number): string {
const buf = new Uint8Array(byteLength);
globalThis.crypto.getRandomValues(buf);
let hex = "";
for (let i = 0; i < buf.length; i++) {
hex += buf[i]!.toString(16).padStart(2, "0");
}
return hex;
}
/**
* Snowflake-style string IDs: `prefix` + hex(timestamp − epoch) + 8 hex chars (4 random bytes).
* Used for chart, song, quest, folder, and table documents in seeds and APIs.
*/
export function tachiID(prefix: string, ts: number = Date.now(), epoch = 0): string {
const dt = ts - epoch;
const dtb = dt.toString(16);
const rand = randomHex(4);
return prefix + dtb + rand;
}
export function CreateChartID(ts: number = Date.now()): string {
return tachiID("C", ts);
}
export function CreateQuestID(ts: number = Date.now()): string {
return tachiID("Q", ts);
}
export function CreateSongID(ts: number = Date.now()): string {
return tachiID("S", ts);
}
export function CreateFolderID(ts: number = Date.now()): string {
return tachiID("F", ts);
}
export function CreateTableID(ts: number = Date.now()): string {
return tachiID("T", ts);
}
+24 -32
View File
@@ -1,54 +1,46 @@
import t from "tap";
import { describe, expect, it } from "vitest";
import { IIDXLIKE_GBOUNDARIES, WACCA_GBOUNDARIES } from "../constants/grade-boundaries";
import { GetGradeDeltas } from "./util";
t.test("#GetGradeDeltas", (t) => {
t.strictSame(
GetGradeDeltas(WACCA_GBOUNDARIES, "S", 921_013),
{
describe("#GetGradeDeltas", () => {
it("Should correctly calculate grade boundaries.", () => {
expect(GetGradeDeltas(WACCA_GBOUNDARIES, "S", 921_013)).toStrictEqual({
lower: "S+21K",
upper: "(S+)-9K",
closer: "upper",
},
"Should correctly calculate grade boundaries.",
);
});
});
t.strictSame(
GetGradeDeltas(WACCA_GBOUNDARIES, "S", 901_013),
{
it("Should correctly calculate grade boundaries when score is near lower bound.", () => {
expect(GetGradeDeltas(WACCA_GBOUNDARIES, "S", 901_013)).toStrictEqual({
lower: "S+1K",
upper: "(S+)-29K",
closer: "lower",
},
"Should correctly calculate grade boundaries.",
);
});
});
t.strictSame(
GetGradeDeltas(WACCA_GBOUNDARIES, "S", 901_013, (n) => n.toString()),
{
it("Should apply the num format function.", () => {
expect(GetGradeDeltas(WACCA_GBOUNDARIES, "S", 901_013, (n) => n.toString())).toStrictEqual({
lower: "S+1013",
upper: "(S+)-28987",
closer: "lower",
},
"Should apply the num format function.",
);
});
});
t.strictSame(
GetGradeDeltas(IIDXLIKE_GBOUNDARIES, "MAX-", 99.47, (deltaPercent) => {
const max = 2090;
it("i hate iidx", () => {
expect(
GetGradeDeltas(IIDXLIKE_GBOUNDARIES, "MAX-", 99.47, (deltaPercent) => {
const max = 2090;
const v = (deltaPercent / 100) * max;
const v = (deltaPercent / 100) * max;
return Math.round(v).toFixed(0);
}),
{
return Math.round(v).toFixed(0);
}),
).toStrictEqual({
lower: "(MAX-)+105",
upper: "MAX-11",
closer: "upper",
},
"i hate iidx",
);
t.end();
});
});
});
+50 -14
View File
@@ -12,8 +12,14 @@ import type {
MONGO_SongDocument,
Playtypes,
V3Game,
V3GameToGPTString,
} from "../types";
import type { GetEnumValue } from "../types/metrics";
import type {
AllConfMetrics,
ConfEnumScoreMetric,
ExtractEnumMetricNames,
GetEnumValue,
} from "../types/metrics";
import {
GetGameGroupConfig,
@@ -21,6 +27,7 @@ import {
GetGPTString,
GetSpecificGPTConfig,
SplitGPT,
V3GetGameConfig,
V3ToGPTString,
} from "../config/config";
@@ -33,7 +40,9 @@ export function FormatDifficulty(chart: MONGO_ChartDocument, game: GameGroup): s
const bmsChart = chart as MONGO_ChartDocument<"bms:7K" | "bms:14K">;
return (
bmsChart.data.tableFolders.map((e) => `${e.table}${e.level}`).join(", ") || "Unrated"
Object.entries(bmsChart.data.tableFolders)
.map(([table, level]) => `${table}${level}`)
.join(", ") || "Unrated"
);
}
@@ -191,28 +200,34 @@ export function FormatChart(
realTitle = `${realTitle} [${bmsSong.data.genre}]`;
}
if (tables.length === 0) {
if (Object.keys(tables).length === 0) {
return realTitle;
}
return `${realTitle} (${tables.map((e) => `${e.table}${e.level}`).join(", ")})`;
return `${realTitle} (${Object.entries(tables)
.map(([table, level]) => `${table}${level}`)
.join(", ")})`;
} else if (game === "usc") {
const uscChart = chart as MONGO_ChartDocument<GPTStrings["usc"]>;
// If this chart isn't an official, render it differently
if (!uscChart.data.isOfficial) {
// Same as BMS. turn this into SongTitle (Keyboard MXM normal1, insane2)
return `${song.title} (${chart.playtype} ${
chart.difficulty
} ${uscChart.data.tableFolders.map((e) => `${e.table}${e.level}`).join(", ")})`;
} else if (uscChart.data.tableFolders.length !== 0) {
return `${song.title} (${chart.playtype} ${chart.difficulty} ${Object.entries(
uscChart.data.tableFolders,
)
.map(([table, level]) => `${table}${level}`)
.join(", ")})`;
} else if (Object.keys(uscChart.data.tableFolders).length !== 0) {
// if this chart is an official **AND** is on tables (unlikely), render
// it as so:
// SongTitle (Keyboard MXM 17, normal1, insane2)
return `${song.title} (${chart.playtype} ${chart.difficulty} ${
chart.level
}, ${uscChart.data.tableFolders.map((e) => `${e.table}${e.level}`).join(", ")})`;
}, ${Object.entries(uscChart.data.tableFolders)
.map(([table, level]) => `${table}${level}`)
.join(", ")})`;
}
// otherwise, it's just an official and should be rendered like any other game.
@@ -450,17 +465,14 @@ export function CreateChartMap<GPT extends GPTString = GPTString>(
for (const chart of charts) {
chartMap.set(chart.chartID, chart);
if (chart.legacyChartId) {
chartMap.set(chart.legacyChartId, chart);
}
}
return chartMap;
}
/** Mongo `chartID` field (`personal-bests`, `scores`, etc.): legacy string, or same as `chartID` on raw Mongo chart docs. */
/** TODO(zk) remove this function */
export function MongoChartLegacyId(chart: MONGO_ChartDocument): string {
return chart.legacyChartId ?? chart.chartID;
return chart.chartID;
}
/**
@@ -535,3 +547,27 @@ export function IIDXLikeGetGrade(
return "F";
}
export function EnumIndexToValue<
G extends V3Game,
EV extends ExtractEnumMetricNames<AllConfMetrics[V3GameToGPTString[G]]>,
>(game: G, enumMetric: EV, index: integer): GetEnumValue<V3GameToGPTString[G], EV> {
const config = V3GetGameConfig(game);
let metric = config.providedMetrics[enumMetric];
if (!metric) {
metric = config.derivedMetrics[enumMetric];
}
if (!metric) {
metric = config.optionalMetrics[enumMetric];
}
if (!metric) {
throw new Error(`Invalid enum metric ${enumMetric} for game ${game}.`);
}
return (metric as ConfEnumScoreMetric<string>).values[index] as GetEnumValue<
V3GameToGPTString[G],
EV
>;
}
+1 -1
View File
@@ -4,6 +4,6 @@
"module": "Preserve",
"moduleResolution": "bundler"
},
"include": ["src"],
"include": ["src", "vitest.config.ts"],
"exclude": ["node_modules"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
exclude: [
// Compiled output — not test sources.
"build/**",
// Legacy tap-based tests not yet migrated to vitest.
"src/index.test.ts",
"src/config/config.test.ts",
"src/constants/bms-tables.test.ts",
],
},
});
+1 -1
View File
@@ -134,7 +134,7 @@ async function withAdvisoryLock<T>(client: Client, fn: () => Promise<T>): Promis
await client.query(`SELECT pg_advisory_lock(${ADVISORY_LOCK})`);
try {
return await fn();
return fn();
} finally {
await client.query(`SELECT pg_advisory_unlock(${ADVISORY_LOCK})`).catch(() => undefined);
}
@@ -22,6 +22,8 @@ export default interface ChartLeaderboardTable {
calculated_data: ColumnType<unknown, never, never>;
judgements: ColumnType<unknown, never, never>;
ranking_value: ColumnType<number, never, never>;
ranking_value_tb1: ColumnType<number | null, never, never>;
@@ -39,6 +41,8 @@ export default interface ChartLeaderboardTable {
time_achieved: ColumnType<string | null, never, never>;
rank: ColumnType<number, never, never>;
out_of: ColumnType<number, never, never>;
}
export type ChartLeaderboard = Selectable<ChartLeaderboardTable>;
@@ -2,7 +2,9 @@
// This file is automatically generated by Kanel. Do not modify manually.
import type { account_id } from './Account';
import type { default as GameGroup } from './GameGroup';
import type { import_id } from './Import';
import type { default as ImportType } from './ImportType';
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
/** Identifier type for public.orphan_score */
@@ -10,19 +12,25 @@ export type orphan_score_row_id = string;
/** Represents the table public.orphan_score */
export default interface OrphanScoreTable {
row_id: ColumnType<orphan_score_row_id, orphan_score_row_id | undefined, orphan_score_row_id>;
row_id: ColumnType<orphan_score_row_id, orphan_score_row_id | undefined, orphan_score_row_id>;
user_id: ColumnType<account_id, account_id, account_id>;
orphan_id: ColumnType<string, string, string>;
import_id: ColumnType<import_id, import_id, import_id>;
user_id: ColumnType<account_id, account_id, account_id>;
data: ColumnType<unknown, unknown, unknown>;
import_id: ColumnType<import_id | null, import_id | null | undefined, import_id | null>;
context: ColumnType<unknown, unknown, unknown>;
import_type: ColumnType<ImportType, ImportType, ImportType>;
time_inserted: ColumnType<string, string, string>;
game_group: ColumnType<GameGroup, GameGroup, GameGroup>;
error_message: ColumnType<string, string, string>;
data: ColumnType<unknown, unknown, unknown>;
context: ColumnType<unknown, unknown, unknown>;
time_inserted: ColumnType<string, string, string>;
error_message: ColumnType<string, string, string>;
}
export type OrphanScore = Selectable<OrphanScoreTable>;
+2
View File
@@ -24,6 +24,8 @@ export default interface PbTable {
calculated_data: ColumnType<unknown, unknown, unknown>;
judgements: ColumnType<unknown, unknown, unknown>;
ranking_value: ColumnType<number, number, number>;
ranking_value_tb1: ColumnType<number | null, number | null, number | null>;
@@ -40,6 +40,8 @@ export default interface ScoreTable {
highlight: ColumnType<boolean, boolean, boolean>;
comment: ColumnType<string | null, string | null, string | null>;
committed: ColumnType<boolean, boolean | undefined, boolean>;
}
export type Score = Selectable<ScoreTable>;
@@ -20,6 +20,8 @@ export default interface SessionTable {
description: ColumnType<string | null, string | null, string | null>;
textsearch: ColumnType<string, never, never>;
time_inserted: ColumnType<string, string, string>;
time_started: ColumnType<string, string, string>;
-1
View File
@@ -2,7 +2,6 @@
"name": "tachi-seeds-scripts",
"version": "0.1.0-see-project-root",
"private": true,
"type": "module",
"description": "Scripts for dealing with tachi-seeds.",
"scripts": {
"build": "tsgo -b",
+1 -1
View File
@@ -16,7 +16,7 @@ for test in "${tests[@]}"
do
# Output stderr and stdout to the terminal, but only save stderr to a file
# Isn't bash wonderful?
ts-node "$test" 2> >(tee failed-tests.log >&2)
bun "$test" 2> >(tee failed-tests.log >&2)
done
# if more than 0 commands failed
@@ -4,6 +4,10 @@ import { type MONGO_GoalDocument } from "tachi-common";
import { CreateGoalID, ReadCollection } from "../util";
import { FormatFunctions } from "./test-utils";
// disabled for now
console.log("Disabled for now");
process.exit(0);
let success = 0;
let fails = 0;
@@ -12,7 +16,7 @@ const goals = ReadCollection("goals.json", true) as MONGO_GoalDocument[];
for (const goal of goals) {
const pretty = FormatFunctions["goals.json"]!(goal, null);
const expectedGoalID = CreateGoalID(goal.charts, goal.criteria, goal.game, goal.playtype);
const expectedGoalID = CreateGoalID(goal.charts, goal.criteria, goal.game);
if (expectedGoalID !== goal.goalID) {
console.error(
+59 -27
View File
@@ -1,21 +1,23 @@
import chalk from "chalk";
import fjsh from "fast-json-stable-hash";
import get from "lodash.get";
import { allSupportedGameGroups, type GameGroup, type integer } from "tachi-common";
import { type SCHEMAS } from "tachi-common/lib/schemas";
import {
allSupportedGameGroups,
type GameGroup,
type integer,
v3AllGames,
type V3Game,
V3ToGameGroup,
} from "tachi-common";
import { ReadCollection } from "../util";
import { GetChartCollectionGame, GetSongCollectionGame, ReadCollection } from "../util";
import { FormatFunctions } from "./test-utils";
// Either it's a bare string or an array of strings for co-uniqueness.
type DuplicateKeyDecl = string | string[];
// @ts-expect-error filled out dynamically.
const SongChartKeys: Record<`${"chart" | "song"}s-${GameGroup}`, DuplicateKeyDecl[]> = {};
for (const game of allSupportedGameGroups) {
SongChartKeys[`songs-${game}`] = ["id", "legacySongID"];
SongChartKeys[`charts-${game}`] = [
function chartDupKeyDecls(): DuplicateKeyDecl[] {
return [
"id",
"legacyChartID",
// @todo THIS IS WRONG. CHARTS ARE ALLOWED TO HAVE MULTIPLE
@@ -24,7 +26,17 @@ for (const game of allSupportedGameGroups) {
];
}
const UniqueKeys: Partial<Record<keyof typeof SCHEMAS, DuplicateKeyDecl[]>> = {
const SongChartKeys: Record<string, DuplicateKeyDecl[]> = {};
for (const gameGroup of allSupportedGameGroups) {
SongChartKeys[`songs-${gameGroup}`] = ["id", "legacySongID"];
}
for (const v3Game of v3AllGames) {
SongChartKeys[`charts-${v3Game}`] = chartDupKeyDecls();
}
const UniqueKeys: Record<string, DuplicateKeyDecl[]> = {
"bms-course-lookup": [["set", "playtype", "value"]],
folders: ["id", "legacyFolderID"],
tables: ["id", "legacyTableID"],
@@ -34,17 +46,23 @@ const UniqueKeys: Partial<Record<keyof typeof SCHEMAS, DuplicateKeyDecl[]>> = {
...SongChartKeys,
};
UniqueKeys["charts-usc"]!.push(["data.hashSHA1", "playtype"]);
for (const v3 of ["usc-keyboard", "usc-controller"] as const) {
UniqueKeys[`charts-${v3}`]!.push("data.hashSHA1");
}
UniqueKeys["charts-popn"]!.push("data.hashSHA256");
UniqueKeys["charts-bms"]!.push("data.hashMD5");
UniqueKeys["charts-bms"]!.push("data.hashSHA256");
for (const v3 of ["bms-7k", "bms-14k"] as const) {
UniqueKeys[`charts-${v3}`]!.push("data.hashMD5");
UniqueKeys[`charts-${v3}`]!.push("data.hashSHA256");
}
UniqueKeys["charts-pms"]!.push(["data.hashMD5", "playtype"]);
UniqueKeys["charts-pms"]!.push(["data.hashSHA256", "playtype"]);
for (const v3 of ["pms-keyboard", "pms-controller"] as const) {
UniqueKeys[`charts-${v3}`]!.push("data.hashMD5");
UniqueKeys[`charts-${v3}`]!.push("data.hashSHA256");
}
UniqueKeys["charts-itg"]!.push("data.hashGSv3");
UniqueKeys["charts-itg-stamina"]!.push("data.hashGSv3");
let exitCode = 0;
const suites: Array<{ good: boolean; name: string; report: unknown }> = [];
@@ -84,33 +102,41 @@ function cartesianProduct(elements: Array<Array<unknown>>) {
return result;
}
function gameGroupForFormat(collection: string): GameGroup {
if (collection.startsWith("songs-")) {
return GetSongCollectionGame(`${collection}.json`) as GameGroup;
}
if (collection.startsWith("charts-")) {
const v3Game = GetChartCollectionGame(`${collection}.json`) as V3Game;
return V3ToGameGroup(v3Game);
}
throw new Error(`Expected songs-* or charts-* collection, got ${collection}`);
}
for (const [collection, uniqueIDs] of Object.entries(UniqueKeys)) {
console.log(`[VALIDATING DUPES] ${collection}`);
const collectionName = `${collection}.json`;
const formatFn = FormatFunctions[collection] ?? ((v) => JSON.stringify(v));
const formatFn = FormatFunctions[collectionName] ?? ((v) => JSON.stringify(v));
let success = 0;
let fails = 0;
const data = ReadCollection(collectionName);
const data = ReadCollection(collectionName, true);
let game = "";
let gameGroupForFmt: GameGroup | null = null;
if (collection.startsWith("songs-") || collection.startsWith("charts-")) {
const maybeGame = collection.split("-")[1];
if (maybeGame === undefined) {
throw new Error(`You passed ${collection} as a collection. Why?`);
}
game = maybeGame;
gameGroupForFmt = gameGroupForFormat(collection);
}
for (const uniqueID of uniqueIDs) {
const set = new Set<string>();
for (const d of data) {
const pretty = formatFn(d, game as GameGroup);
const pretty = formatFn(d, gameGroupForFmt);
let value: Array<Array<number | string>>;
@@ -142,6 +168,12 @@ for (const [collection, uniqueIDs] of Object.entries(UniqueKeys)) {
}
}
const valueCells = value.flatMap((e) => (Array.isArray(e) ? e : [e]));
if (valueCells.some((cell) => cell === undefined)) {
// Optional seed fields (e.g. PMS hashes): cannot enforce uniqueness.
continue;
}
// cartesian product values before we interact with them.
const values = cartesianProduct(value.map((e) => (Array.isArray(e) ? e : [e]))).map(
(e) => ({
@@ -7,6 +7,7 @@ import {
v3AllGames,
V3FormatGame,
type V3Game,
V3ToGameGroup,
V3ToGPTString,
} from "tachi-common";
@@ -91,7 +92,7 @@ for (const { game, matchType } of uniquenessChecks) {
const data =
handler.type === "CHARTS"
? ReadCollection(`charts-${game}.json`)
: ReadCollection(`songs-${game}.json`);
: ReadCollection(`songs-${V3ToGameGroup(game)}.json`);
const uniqueIDs = new Set();
for (const el of data) {
+10 -34
View File
@@ -1,8 +1,15 @@
const crypto = require("crypto");
const fjsh = require("fast-json-stable-hash");
const fs = require("fs");
const path = require("path");
const {
CreateChartID,
CreateFolderID,
CreateQuestID,
CreateSongID,
CreateTableID,
} = require("tachi-common");
const DeterministicCollectionSort = require("./sort-seeds");
function IterateCollections(cb) {
@@ -53,37 +60,6 @@ function MutateCollection(name, cb) {
WriteCollection(name, data);
}
/**
* A variant of the snowflake ID algorithm for tachi
*/
function tachiID(prefix, ts = Date.now(), epoch = 0) {
let dt = ts - epoch;
let dtb = dt.toString(16);
let rand = crypto.randomBytes(4).toString("hex");
return prefix + dtb + rand;
}
function CreateChartID(ts = Date.now()) {
return tachiID("C", ts);
}
function CreateQuestID(ts = Date.now()) {
return tachiID("Q", ts);
}
function CreateSongID(ts = Date.now()) {
return tachiID("S", ts);
}
function CreateFolderID(ts = Date.now()) {
return tachiID("F", ts);
}
function CreateTableID(ts = Date.now()) {
return tachiID("T", ts);
}
// this api sucks, maybe dont use it
//
// TODO(zk): remove this and give folders actual readable names
@@ -95,8 +71,8 @@ function CreateLegacyFolderIDFromFolder(folder) {
return CreateLegacyFolderID(folder.data, folder.game, folder.playtype);
}
function CreateGoalID(charts, criteria, game, playtype) {
return `G${fjsh.hash({ charts, criteria, game, playtype }, "sha256")}`;
function CreateGoalID(charts, criteria, game) {
return `G${fjsh.hash({ charts, criteria, game }, "sha256")}`;
}
// quick inplace deepmerge hack
+44 -1
View File
@@ -6,4 +6,47 @@ LOG_LEVEL="debug"
MSGPACKR_NATIVE_ACCELERATION_DISABLED=true
POSTGRES_URL="postgresql://tachi:tachi@tachi-postgres/tachi_dev"
MIGRATIONS_DIR="/tachi/db/migrations"
MIGRATIONS_DIR="/tachi/db/migrations"
REDIS_URL=tachi-redis
MONGO_URL=tachi-mongo
PORT=8080
MIGRATIONS_DIR=/tachi/db/migrations
LOG_LEVEL=debug
TACHI_CLIENT_DEV_SERVER=http://127.0.0.1:3000
TACHI_ENABLE_SERVER_HTTPS=true
TACHI_MONGO_DATABASE_NAME=tachi-dev
TACHI_CAPTCHA_SECRET_KEY=unused
TACHI_SESSION_SECRET=unused
TACHI_FLO_API_URL=https://flo.example.com
TACHI_EAG_API_URL=https://eag.example.com
TACHI_MIN_API_URL=https://min.example.com
TACHI_MYT_API_HOST=myt.example.com
TACHI_FLO_OAUTH2_CLIENT_ID=DUMMY_CLIENT_ID
TACHI_FLO_OAUTH2_CLIENT_SECRET=DUMMY_CLIENT_SECRET
TACHI_FLO_OAUTH2_REDIRECT_URI=https://example.com
TACHI_EAG_OAUTH2_CLIENT_ID=DUMMY_CLIENT_ID
TACHI_EAG_OAUTH2_CLIENT_SECRET=DUMMY_CLIENT_SECRET
TACHI_EAG_OAUTH2_REDIRECT_URI=https://example.com
TACHI_ARC_AUTH_TOKEN=unused
TACHI_MYT_AUTH_TOKEN=unused
TACHI_OUR_URL=https://127.0.0.1:8080
; TACHI_INVITE_CODE_BATCH_SIZE=2
; TACHI_INVITE_CODE_INVITE_CAP=100
; TACHI_INVITE_CODE_BETA_USER_BONUS=5
TACHI_CDN_WEB_LOCATION=http://tachi-s3:9000/tachi-public
TACHI_CDN_SAVE_LOCATION_ENDPOINT=http://tachi-s3:9000
TACHI_CDN_SAVE_LOCATION_ACCESS_KEY_ID=minio
TACHI_CDN_SAVE_LOCATION_SECRET_ACCESS_KEY=password
TACHI_CDN_SAVE_LOCATION_BUCKET=tachi-public
TACHI_CDN_SAVE_LOCATION_REGION=us-east-1
TACHI_CDN_SAVE_LOCATION_PRIVATE_ENDPOINT=http://tachi-s3:9000
TACHI_CDN_SAVE_LOCATION_PRIVATE_ACCESS_KEY_ID=minio
TACHI_CDN_SAVE_LOCATION_PRIVATE_SECRET_ACCESS_KEY=password
TACHI_CDN_SAVE_LOCATION_PRIVATE_BUCKET=tachi-private
TACHI_CDN_SAVE_LOCATION_PRIVATE_REGION=us-east-1
TACHI_NAME=Tachi Local Dev
TACHI_TYPE=omni
TACHI_GAMES=iidx,museca,sdvx,bms,chunithm,usc,wacca,popn,jubeat,pms,maimai,maimaidx,arcaea,ongeki
TACHI_IMPORT_TYPES=file/eamusement-iidx-csv,file/batch-manual,file/solid-state-squad,file/pli-iidx-csv,ir/direct-manual,ir/barbatos,ir/fervidex,ir/fervidex-static,ir/beatoraja,ir/usc,ir/kshook-sv6c,api/eag-iidx,api/eag-sdvx,api/flo-iidx,api/flo-sdvx,api/min-sdvx
@@ -0,0 +1,120 @@
import type { GameGroup, MONGO_ChartDocument, MONGO_SongDocument } from "tachi-common";
/* eslint-disable no-await-in-loop */
import { MakeAction } from "#lib/actions/actions.js";
import { SELECT_CHART, ToChartDocument } from "#lib/db-formats/chart.js";
import { SELECT_SONG_DOCUMENT, ToSongDocument } from "#lib/db-formats/song.js";
import { log } from "#lib/log/log.js";
import { PullDatabaseSeeds } from "#lib/seeds/repo";
import DB from "#services/pg/db.js";
import { IsUserAdmin } from "#utils/user.js";
import { ExpectedErr } from "bliss";
const FETCH_CHUNK = 3_000;
async function loadAllSongsForGame(game: GameGroup): Promise<Array<MONGO_SongDocument>> {
const out: Array<MONGO_SongDocument> = [];
let lastLegacy: number | undefined;
for (;;) {
let q = DB.selectFrom("song")
.select(SELECT_SONG_DOCUMENT)
.where("game_group", "=", game)
.orderBy("legacy_id", "asc")
.limit(FETCH_CHUNK);
if (lastLegacy !== undefined) {
q = q.where("legacy_id", ">", lastLegacy);
}
const rows = await q.execute();
if (rows.length === 0) {
break;
}
for (const row of rows) {
out.push(ToSongDocument(row));
}
lastLegacy = rows[rows.length - 1]!.song_legacy_id;
}
return out;
}
async function loadAllChartsForGame(game: GameGroup): Promise<Array<MONGO_ChartDocument>> {
const out: Array<MONGO_ChartDocument> = [];
let lastChartId: string | undefined;
for (;;) {
let q = DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("song.game_group", "=", game)
.orderBy("chart.id", "asc")
.limit(FETCH_CHUNK);
if (lastChartId !== undefined) {
q = q.where("chart.id", ">", lastChartId);
}
const rows = await q.execute();
if (rows.length === 0) {
break;
}
for (const row of rows) {
out.push(ToChartDocument(row, row.song_legacy_id));
}
lastChartId = rows[rows.length - 1]!.chart_id;
}
return out;
}
/**
* Writes live BMS/PMS songs and charts from Postgres into the seeds repo and commits.
*/
export async function runBacksyncBmsPmsSeedsCore() {
const repo = await PullDatabaseSeeds();
try {
for (const game of ["bms", "pms"] as const) {
log.info(`Fetching ${game} songs from DB.`);
{
const songs = await loadAllSongsForGame(game);
log.info(`Found ${songs.length} ${game} songs.`);
await repo.WriteCollection(`songs-${game}`, songs);
}
log.info(`Fetching ${game} charts from DB.`);
{
const charts = await loadAllChartsForGame(game);
log.info(`Found ${charts.length} ${game} charts.`);
await repo.WriteCollection(`charts-${game}`, charts);
}
}
await repo.CommitChangesBack(`Backsync BMS+PMS Songs/Charts ${new Date().toISOString()}`);
} finally {
await repo.Destroy();
}
}
export const ACTION_BacksyncBmsPmsSeeds = MakeAction(
"BACKSYNC_BMS_PMS_SEEDS",
async (taker, _input) => {
if (!(await IsUserAdmin(taker.acct.id))) {
throw new ExpectedErr(403, "You are not authorized to perform this action.");
}
await runBacksyncBmsPmsSeedsCore();
return {};
},
);
@@ -0,0 +1,140 @@
import { DefaultAdminUser } from "#lib/jobs/default-admin-user";
import DB from "#services/pg/db";
import { seedUser } from "#test-utils/pg-fixtures";
import { CreateChartID } from "tachi-common";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ACTION_BMSTableSync } from "./bms-table-sync";
const { testMd5, mockLoadBMSTable, fakeTable } = vi.hoisted(() => {
const md5 = "c".repeat(32);
const load = vi.fn();
const table = {
name: "TestTable",
prefix: "★",
asciiPrefix: "t",
playtype: "7K" as const,
url: "https://test.invalid/bms-table-unit",
description: "unit test",
};
return { testMd5: md5, mockLoadBMSTable: load, fakeTable: table };
});
vi.mock("bms-table-loader", () => ({
LoadBMSTable: mockLoadBMSTable,
}));
vi.mock("tachi-common", async (importOriginal) => {
const actual = await importOriginal<typeof import("tachi-common")>();
return {
...actual,
BMS_TABLES: [fakeTable],
};
});
describe("ACTION_BMSTableSync", () => {
const songPgId = "song-bms-action-test";
const chartId = CreateChartID();
const legacySongId = 884_001;
let adminId: number;
beforeEach(async () => {
({ id: adminId } = await seedUser({ authLevel: "admin" }));
mockLoadBMSTable.mockResolvedValue({
body: [
{
checksum: { type: "md5" as const, value: testMd5 },
content: { title: "Unit", level: 9 },
},
],
});
});
afterEach(async () => {
await DB.deleteFrom("chart").where("id", "=", chartId).execute();
await DB.deleteFrom("song").where("id", "=", songPgId).execute();
await DB.deleteFrom("action").where("user_id", "=", adminId).execute();
await DB.deleteFrom("account").where("id", "=", adminId).execute();
mockLoadBMSTable.mockReset();
});
it("applies mocked table levels to an existing BMS chart and song (DefaultAdminUser taker)", async () => {
await DB.insertInto("song")
.values({
id: songPgId,
legacy_id: legacySongId,
game_group: "bms",
title: "Unit Song",
artist: "Unit",
search_terms: [],
alt_titles: [],
fts_document: "",
data: {
subtitle: null,
subartist: null,
genre: null,
tableString: null,
},
})
.execute();
await DB.insertInto("chart")
.values({
id: chartId,
legacy_id: chartId,
game: "bms-7k",
song_id: songPgId,
level: "?",
level_num: 0,
is_primary: true,
difficulty: "CHART",
versions: [],
data: {
hashMD5: testMd5,
hashSHA256: "d".repeat(64),
notecount: 1,
tableFolders: {},
aiLevel: null,
sglEC: null,
sglHC: null,
},
})
.execute();
const taker = await DefaultAdminUser.actionTaker();
expect(taker.acct.id).toBe(adminId);
await ACTION_BMSTableSync(taker, {});
expect(mockLoadBMSTable).toHaveBeenCalledWith(fakeTable.url);
const chartRow = await DB.selectFrom("chart")
.select("data")
.where("id", "=", chartId)
.executeTakeFirstOrThrow();
const folders = (chartRow.data as { tableFolders: Record<string, string> }).tableFolders;
expect(folders["★"]).toBe("9");
const songRow = await DB.selectFrom("song")
.select("data")
.where("id", "=", songPgId)
.executeTakeFirstOrThrow();
const ts = (songRow.data as { tableString: string | null }).tableString;
expect(ts).toContain("★");
expect(ts).toContain("9");
});
it("rejects non-admin takers", async () => {
const { id: userId } = await seedUser({ authLevel: "user", username: "normie" });
await expect(
ACTION_BMSTableSync({ ip: "127.0.0.1", acct: { id: userId, username: "normie" } }, {}),
).rejects.toThrow(/not authorized/u);
await DB.deleteFrom("action").where("user_id", "=", userId).execute();
await DB.deleteFrom("account").where("id", "=", userId).execute();
});
});
@@ -0,0 +1,244 @@
/* eslint-disable no-await-in-loop */
import type { Game } from "tachi-db";
import { MakeAction } from "#lib/actions/actions";
import { log } from "#lib/log/log";
import { DeorphanBmsIfInOrphanChartPg } from "#lib/orphan-queue/deorphan-bms-pg";
import DB from "#services/pg/db";
import { FormatBMSTables } from "#utils/misc";
import { FindBMSChartOnHashInGame } from "#utils/queries/charts";
import { IsUserAdmin } from "#utils/user";
import { ExpectedErr } from "bliss";
import { type BMSTableEntry, LoadBMSTable } from "bms-table-loader";
import { sql } from "kysely";
import _ from "lodash";
import {
BMS_TABLES,
type BMSTableInfo,
GamePTToV3,
type MONGO_ChartDocument,
type MONGO_ChartDocumentData,
type Playtypes,
} from "tachi-common";
const UPDATE_CHUNK = 500;
type BmsChartData = MONGO_ChartDocumentData["bms:7K"];
function v3GameForBmsPlaytype(playtype: Playtypes["bms"]): Game {
return GamePTToV3("bms", playtype) as Game;
}
function stripTableFoldersKeySql(prefix: string) {
return sql`jsonb_set(
data::jsonb,
'{tableFolders}',
coalesce(data::jsonb->'tableFolders', '{}'::jsonb) - ${prefix}
)`;
}
/**
* Tables might have updates that remove charts from their table.
*
* We need to handle this -- infact, it's quite common for something
* to go from the sl12 folder to st0 -- which is a cross-table
* change.
*/
async function HandleTableRemovals(
tableEntries: Array<BMSTableEntry>,
playtype: Playtypes["bms"],
prefix: string,
) {
if (tableEntries.length === 0) {
log.info(
`No entries in table ${prefix}, skipping removals to prevent instantly wiping the table.`,
);
return;
}
const v3Game = v3GameForBmsPlaytype(playtype);
const rows = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(["chart.id as chart_id", "chart.data as chart_data"])
.where("chart.game", "=", v3Game)
.where(sql<boolean>`(chart.data::jsonb->'tableFolders') ? ${prefix}`)
.execute();
log.info(`Found ${rows.length} existing chart(s) in the database for table ${prefix}.`);
const newTableMD5s = new Set<string>();
const newTableSHA256s = new Set<string>();
for (const entry of tableEntries) {
switch (entry.checksum.type) {
case "md5": {
newTableMD5s.add(entry.checksum.value);
break;
}
case "sha256":
newTableSHA256s.add(entry.checksum.value);
}
}
const toRemove: Array<string> = [];
for (const row of rows) {
const data = row.chart_data as BmsChartData;
if (newTableMD5s.has(data.hashMD5)) {
continue;
}
if (newTableSHA256s.has(data.hashSHA256)) {
continue;
}
toRemove.push(row.chart_id);
}
if (toRemove.length === 0) {
return;
}
log.info(`Removing ${toRemove.length} chart(s) from ${prefix}.`);
for (let i = 0; i < toRemove.length; i += UPDATE_CHUNK) {
const chunk = toRemove.slice(i, i + UPDATE_CHUNK);
await DB.updateTable("chart")
.set({ data: stripTableFoldersKeySql(prefix) })
.where("id", "in", chunk)
.execute();
}
}
async function ImportTableLevels(
tableEntries: Array<BMSTableEntry>,
prefix: string,
playtype: Playtypes["bms"],
) {
let failures = 0;
let success = 0;
const total = tableEntries.length;
const v3Game = v3GameForBmsPlaytype(playtype);
log.info(`Handling removals for ${playtype}:${prefix}...`);
await HandleTableRemovals(tableEntries, playtype, prefix);
const md5s = tableEntries.filter((e) => e.checksum.type === "md5").map((e) => e.checksum.value);
const sha256s = tableEntries
.filter((e) => e.checksum.type === "sha256")
.map((e) => e.checksum.value);
if (md5s.length > 0) {
await DB.updateTable("chart")
.set({ data: stripTableFoldersKeySql(prefix) })
.where("game", "=", v3Game)
.where(sql`(data::jsonb->>'hashMD5')::text`, "in", md5s)
.execute();
}
if (sha256s.length > 0) {
await DB.updateTable("chart")
.set({ data: stripTableFoldersKeySql(prefix) })
.where("game", "=", v3Game)
.where(sql`(data::jsonb->>'hashSHA256')::text`, "in", sha256s)
.execute();
}
for (const td of tableEntries) {
let chart: MONGO_ChartDocument<"bms:7K" | "bms:14K"> | null =
await FindBMSChartOnHashInGame(td.checksum.value, v3Game);
if (!chart) {
chart = await DeorphanBmsIfInOrphanChartPg(
playtype,
td.checksum.type === "md5" ? "md5" : "sha256",
td.checksum.value,
);
}
if (!chart) {
log.warn(
`No chart exists in table for (${td.checksum.type}=${td.checksum.value} Possible title: ${td.content.title} ${prefix}${td.content.level}`,
);
failures++;
continue;
}
const tableFolders = _.cloneDeep(chart.data.tableFolders ?? {});
tableFolders[prefix] = td.content.level.toString();
const sortedTableFolders = Object.keys(tableFolders)
.sort()
.reduce(
(acc, key) => {
acc[key] = tableFolders[key];
return acc;
},
{} as typeof tableFolders,
);
Object.assign(tableFolders, sortedTableFolders);
const mergedChartData = { ...chart.data, tableFolders };
await DB.updateTable("chart")
.set({ data: mergedChartData as object })
.where("id", "=", chart.chartID)
.execute();
const tableString = FormatBMSTables(tableFolders);
await DB.updateTable("song")
.set({
data:
tableString === null
? sql`jsonb_set(data::jsonb, '{tableString}', 'null'::jsonb)`
: sql`jsonb_set(data::jsonb, '{tableString}', to_jsonb(${tableString}::text))`,
})
.where("legacy_id", "=", chart.songID)
.where("song.game_group", "=", "bms")
.execute();
success++;
}
log.info(`Finished updating table ${prefix}.`);
log.info(`${success} Success | ${failures} Failures | ${total} Total.`);
}
export async function UpdateTable(tableInfo: BMSTableInfo) {
const table = await LoadBMSTable(tableInfo.url);
log.info(`Bumping levels...`);
await ImportTableLevels(table.body, tableInfo.prefix, tableInfo.playtype);
log.info(`Levels bumped.`);
}
/** Full sync over {@link BMS_TABLES}. */
export async function syncBmsTablesCore() {
for (const table of BMS_TABLES) {
try {
await UpdateTable(table);
} catch (err) {
log.error({ err }, `Failed to update table ${table.name} (${table.url}).`);
}
}
log.info(`Re-initialising folder-chart-lookup, since changes may have been made.`);
// await InitaliseFolderChartLookup();
log.info(`Done.`);
}
export async function SyncBMSTables() {
await syncBmsTablesCore();
}
export const ACTION_BMSTableSync = MakeAction("BMS_TABLE_SYNC", async (taker, _input) => {
if (!(await IsUserAdmin(taker.acct.id))) {
throw new ExpectedErr(403, "You are not authorized to perform this action.");
}
await syncBmsTablesCore();
return {};
});
@@ -136,6 +136,7 @@ describe("ACTION_CustomiseScore", () => {
data: JSON.stringify({}),
derived_data: JSON.stringify({}),
calculated_data: JSON.stringify({}),
judgements: JSON.stringify({}),
ranking_value: 0,
ranking_value_tb1: null,
ranking_value_tb2: null,
@@ -0,0 +1,120 @@
import DB from "#services/pg/db";
import { seedUser } from "#test-utils/pg-fixtures";
import { describe, expect, it } from "vitest";
import { ACTION_DeleteImport } from "./delete-import";
describe("ACTION_DeleteImport", () => {
let importCounter = 0;
function nextImportId() {
return `import-di-${++importCounter}`;
}
async function seedFinishedImport(userId: number) {
const id = nextImportId();
const finishedAt = new Date().toISOString();
await DB.insertInto("import")
.values({
id,
user_id: userId,
time_started: finishedAt,
time_finished: finishedAt,
game_group: "iidx",
import_type: "file/batch-manual" as never,
user_intent: true,
service: "test",
})
.execute();
return id;
}
it("throws 404 when the import does not exist", async () => {
const { id: userId, username } = await seedUser({ username: "di_404" });
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await expect(ACTION_DeleteImport(taker, { id: "missing-import" })).rejects.toMatchObject({
code: 404,
});
});
it("throws 403 when the import belongs to another user", async () => {
const { id: ownerId } = await seedUser({ username: "di_owner" });
const { id: otherId, username: otherName } = await seedUser({ username: "di_other" });
const importId = await seedFinishedImport(ownerId);
const taker = { ip: "127.0.0.1", acct: { id: otherId, username: otherName } };
await expect(ACTION_DeleteImport(taker, { id: importId })).rejects.toMatchObject({
code: 403,
});
});
it("allows an admin to delete another users import", async () => {
const { id: ownerId } = await seedUser({ username: "di_victim" });
const { id: adminId, username: adminName } = await seedUser({
username: "di_admin",
authLevel: "admin",
});
const importId = await seedFinishedImport(ownerId);
const taker = { ip: "127.0.0.1", acct: { id: adminId, username: adminName } };
await ACTION_DeleteImport(taker, { id: importId });
const row = await DB.selectFrom("import").select("id").where("id", "=", importId).executeTakeFirst();
expect(row).toBeUndefined();
});
it("removes the import row RevertImport-style when it has no scores", async () => {
const { id: userId, username } = await seedUser({ username: "di_ok" });
const importId = await seedFinishedImport(userId);
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await ACTION_DeleteImport(taker, { id: importId });
const row = await DB.selectFrom("import").select("id").where("id", "=", importId).executeTakeFirst();
expect(row).toBeUndefined();
});
it("throws 409 when the user has an active import lock", async () => {
const { id: userId, username } = await seedUser({ username: "di_locked" });
const importId = await seedFinishedImport(userId);
const now = new Date().toISOString();
await DB.insertInto("import_lock")
.values({
user_id: userId,
locked: true,
locked_at: now,
})
.execute();
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await expect(ACTION_DeleteImport(taker, { id: importId })).rejects.toMatchObject({
code: 409,
});
});
it("writes a GOOD action row on success", async () => {
const { id: userId, username } = await seedUser({ username: "di_audit" });
const importId = await seedFinishedImport(userId);
const taker = { ip: "10.0.0.4", acct: { id: userId, username } };
await ACTION_DeleteImport(taker, { id: importId });
const action = await DB.selectFrom("action")
.selectAll()
.where("kind", "=", "DELETE_IMPORT")
.orderBy("ts_end", "desc")
.executeTakeFirstOrThrow();
expect(action).toMatchObject({
kind: "DELETE_IMPORT",
result: "GOOD",
ip: "10.0.0.4",
user_id: userId,
});
});
});
@@ -0,0 +1,28 @@
import { MakeAction } from "#lib/actions/actions.js";
import { LoadImportDocumentById } from "#lib/db-formats/import-document.js";
import { RevertImport } from "#lib/imports/imports.js";
import { IsUserAdmin } from "#utils/user.js";
import { ExpectedErr } from "bliss";
export const ACTION_DeleteImport = MakeAction("DELETE_IMPORT", async (taker, { id }) => {
const importDoc = await LoadImportDocumentById(id);
if (!importDoc) {
throw new ExpectedErr(404, "This import does not exist.");
}
if (
importDoc.userID !== taker.acct.id &&
!(await IsUserAdmin(taker.acct.id))
) {
throw new ExpectedErr(403, "You are not authorised to perform this action.");
}
const err = await RevertImport(importDoc);
if (err !== null) {
throw new ExpectedErr(409, "You already have an import or a revert ongoing.");
}
return {};
});
@@ -0,0 +1,146 @@
import { mongoScoreDataToPg } from "#lib/v3/migration-tools";
import DB from "#services/pg/db";
import { seedUser } from "#test-utils/pg-fixtures";
import { describe, expect, it } from "vitest";
import { type MONGO_ScoreData } from "tachi-common";
import { ACTION_DeleteScore } from "./delete-score";
describe("ACTION_DeleteScore", () => {
let counter = 0;
async function seedIidxScore(userId: number) {
const n = ++counter;
const songId = `song-ds-${n}`;
const chartId = `chart-ds-${n}`;
const scoreId = `score-ds-${n}`;
const now = new Date().toISOString();
await DB.insertInto("song")
.values({
id: songId,
legacy_id: 700_000 + n,
game_group: "iidx",
title: "S",
artist: "A",
search_terms: [],
alt_titles: [],
data: JSON.stringify({}),
fts_document: "",
})
.execute();
await DB.insertInto("chart")
.values({
id: chartId,
legacy_id: chartId,
game: "iidx-sp",
song_id: songId,
level: "10",
level_num: 10,
is_primary: true,
difficulty: "NORMAL",
versions: [],
data: JSON.stringify({}),
})
.execute();
const { data, derived, judgements } = mongoScoreDataToPg("iidx:SP", {
grade: "F",
lamp: "FAILED",
percent: 0,
score: 100,
optional: {},
judgements: {},
} as MONGO_ScoreData<"iidx:SP">);
await DB.insertInto("score")
.values({
id: scoreId,
user_id: userId,
chart_id: chartId,
game: "iidx-sp",
session_id: null,
import_id: null,
data: JSON.stringify(data),
derived_data: JSON.stringify(derived),
judgements: JSON.stringify(judgements),
calculated_data: JSON.stringify({}),
meta: JSON.stringify({}),
time_achieved: null,
time_added: now,
highlight: false,
comment: null,
})
.execute();
return scoreId;
}
it("throws 404 when the score does not exist", async () => {
const { id: userId, username } = await seedUser({ username: "ds_owner_404" });
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await expect(ACTION_DeleteScore(taker, { id: "no-such-score" })).rejects.toMatchObject({
code: 404,
});
});
it("throws 403 when the score belongs to another user", async () => {
const { id: ownerId } = await seedUser({ username: "ds_owner" });
const { id: otherId, username: otherName } = await seedUser({ username: "ds_other" });
const scoreId = await seedIidxScore(ownerId);
const taker = { ip: "127.0.0.1", acct: { id: otherId, username: otherName } };
await expect(ACTION_DeleteScore(taker, { id: scoreId })).rejects.toMatchObject({
code: 403,
});
});
it("allows an admin to delete another users score", async () => {
const { id: ownerId } = await seedUser({ username: "ds_victim" });
const { id: adminId, username: adminName } = await seedUser({
username: "ds_admin",
authLevel: "admin",
});
const scoreId = await seedIidxScore(ownerId);
const taker = { ip: "127.0.0.1", acct: { id: adminId, username: adminName } };
await ACTION_DeleteScore(taker, { id: scoreId });
const row = await DB.selectFrom("score").select("id").where("id", "=", scoreId).executeTakeFirst();
expect(row).toBeUndefined();
});
it("removes the score for the owner", async () => {
const { id: userId, username } = await seedUser({ username: "ds_ok" });
const scoreId = await seedIidxScore(userId);
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await ACTION_DeleteScore(taker, { id: scoreId });
const row = await DB.selectFrom("score").select("id").where("id", "=", scoreId).executeTakeFirst();
expect(row).toBeUndefined();
});
it("writes a GOOD action row on success", async () => {
const { id: userId, username } = await seedUser({ username: "ds_audit" });
const scoreId = await seedIidxScore(userId);
const taker = { ip: "10.0.0.2", acct: { id: userId, username } };
await ACTION_DeleteScore(taker, { id: scoreId });
const action = await DB.selectFrom("action")
.selectAll()
.where("kind", "=", "DELETE_SCORE")
.orderBy("ts_end", "desc")
.executeTakeFirstOrThrow();
expect(action).toMatchObject({
kind: "DELETE_SCORE",
result: "GOOD",
ip: "10.0.0.2",
user_id: userId,
});
});
});
@@ -0,0 +1,24 @@
import { MakeAction } from "#lib/actions/actions.js";
import { LoadScoreDocumentById } from "#lib/db-formats/score.js";
import { DeleteScore } from "#lib/score-mutation/delete-scores.js";
import { IsUserAdmin } from "#utils/user.js";
import { ExpectedErr } from "bliss";
export const ACTION_DeleteScore = MakeAction("DELETE_SCORE", async (taker, { id, blacklist }) => {
const score = await LoadScoreDocumentById(id);
if (!score) {
throw new ExpectedErr(404, "This score does not exist.");
}
if (
score.userID !== taker.acct.id &&
!(await IsUserAdmin(taker.acct.id))
) {
throw new ExpectedErr(403, "You are not authorised to perform this action.");
}
await DeleteScore(score, blacklist ?? false);
return {};
});
@@ -0,0 +1,168 @@
import { mongoScoreDataToPg } from "#lib/v3/migration-tools";
import DB from "#services/pg/db";
import { seedUser } from "#test-utils/pg-fixtures";
import { describe, expect, it } from "vitest";
import { type MONGO_ScoreData } from "tachi-common";
import { ACTION_DeleteSession } from "./delete-session";
describe("ACTION_DeleteSession", () => {
let counter = 0;
async function seedSessionWithOneScore(userId: number) {
const n = ++counter;
const sessionId = `Q${n.toString(16).padStart(40, "0").slice(0, 40)}`;
const songId = `song-dsess-${n}`;
const chartId = `chart-dsess-${n}`;
const scoreId = `score-dsess-${n}`;
const now = new Date().toISOString();
await DB.insertInto("song")
.values({
id: songId,
legacy_id: 800_000 + n,
game_group: "iidx",
title: "S",
artist: "A",
search_terms: [],
alt_titles: [],
data: JSON.stringify({}),
fts_document: "",
})
.execute();
await DB.insertInto("chart")
.values({
id: chartId,
legacy_id: chartId,
game: "iidx-sp",
song_id: songId,
level: "10",
level_num: 10,
is_primary: true,
difficulty: "NORMAL",
versions: [],
data: JSON.stringify({}),
})
.execute();
const { data, derived, judgements } = mongoScoreDataToPg("iidx:SP", {
grade: "F",
lamp: "FAILED",
percent: 0,
score: 100,
optional: {},
judgements: {},
} as MONGO_ScoreData<"iidx:SP">);
await DB.insertInto("session")
.values({
id: sessionId,
user_id: userId,
game: "iidx-sp",
name: "Seed",
description: null,
time_inserted: now,
time_started: now,
time_ended: now,
calculated_data: JSON.stringify({}),
highlight: false,
})
.execute();
await DB.insertInto("score")
.values({
id: scoreId,
user_id: userId,
chart_id: chartId,
game: "iidx-sp",
session_id: sessionId,
import_id: null,
data: JSON.stringify(data),
derived_data: JSON.stringify(derived),
judgements: JSON.stringify(judgements),
calculated_data: JSON.stringify({}),
meta: JSON.stringify({}),
time_achieved: now,
time_added: now,
highlight: false,
comment: null,
})
.execute();
return { sessionId, scoreId };
}
it("throws 404 when the session does not exist", async () => {
const { id: userId, username } = await seedUser({ username: "dse_404" });
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await expect(ACTION_DeleteSession(taker, { id: "no-such-session" })).rejects.toMatchObject({
code: 404,
});
});
it("throws 403 when the session belongs to another user", async () => {
const { id: ownerId } = await seedUser({ username: "dse_owner" });
const { id: otherId, username: otherName } = await seedUser({ username: "dse_intruder" });
const { sessionId } = await seedSessionWithOneScore(ownerId);
const taker = { ip: "127.0.0.1", acct: { id: otherId, username: otherName } };
await expect(ACTION_DeleteSession(taker, { id: sessionId })).rejects.toMatchObject({
code: 403,
});
});
it("allows an admin to delete another users session", async () => {
const { id: ownerId } = await seedUser({ username: "dse_victim" });
const { id: adminId, username: adminName } = await seedUser({
username: "dse_admin",
authLevel: "admin",
});
const { sessionId, scoreId } = await seedSessionWithOneScore(ownerId);
const taker = { ip: "127.0.0.1", acct: { id: adminId, username: adminName } };
await ACTION_DeleteSession(taker, { id: sessionId });
const sess = await DB.selectFrom("session").select("id").where("id", "=", sessionId).executeTakeFirst();
const score = await DB.selectFrom("score").select("id").where("id", "=", scoreId).executeTakeFirst();
expect(sess).toBeUndefined();
expect(score).toBeUndefined();
});
it("removes the session and its scores for the owner", async () => {
const { id: userId, username } = await seedUser({ username: "dse_ok" });
const { sessionId, scoreId } = await seedSessionWithOneScore(userId);
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await ACTION_DeleteSession(taker, { id: sessionId });
const sess = await DB.selectFrom("session").select("id").where("id", "=", sessionId).executeTakeFirst();
const score = await DB.selectFrom("score").select("id").where("id", "=", scoreId).executeTakeFirst();
expect(sess).toBeUndefined();
expect(score).toBeUndefined();
});
it("writes a GOOD action row on success", async () => {
const { id: userId, username } = await seedUser({ username: "dse_audit" });
const { sessionId } = await seedSessionWithOneScore(userId);
const taker = { ip: "10.0.0.3", acct: { id: userId, username } };
await ACTION_DeleteSession(taker, { id: sessionId });
const action = await DB.selectFrom("action")
.selectAll()
.where("kind", "=", "DELETE_SESSION")
.orderBy("ts_end", "desc")
.executeTakeFirstOrThrow();
expect(action).toMatchObject({
kind: "DELETE_SESSION",
result: "GOOD",
ip: "10.0.0.3",
user_id: userId,
});
});
});
@@ -0,0 +1,26 @@
import { MakeAction } from "#lib/actions/actions.js";
import { LoadSessionDocumentById } from "#lib/db-formats/session.js";
import { DeleteMultipleScores } from "#lib/score-mutation/delete-scores.js";
import { GetScoresFromSession } from "#utils/session.js";
import { IsUserAdmin } from "#utils/user.js";
import { ExpectedErr } from "bliss";
export const ACTION_DeleteSession = MakeAction("DELETE_SESSION", async (taker, { id }) => {
const session = await LoadSessionDocumentById(id);
if (!session) {
throw new ExpectedErr(404, "This session does not exist.");
}
if (
session.userID !== taker.acct.id &&
!(await IsUserAdmin(taker.acct.id))
) {
throw new ExpectedErr(403, "You are not authorised to perform this action.");
}
const scores = await GetScoresFromSession(session);
await DeleteMultipleScores(scores);
return {};
});
@@ -0,0 +1,84 @@
import { GetUGPTSettingsDocument } from "#lib/db-formats/ugpt-settings.js";
import DB from "#services/pg/db";
import { seedUser } from "#test-utils/pg-fixtures";
import { beforeEach, describe, expect, it } from "vitest";
import { ACTION_PatchUGPTSettings } from "./patch-ugpt-settings";
describe("ACTION_PatchUGPTSettings", () => {
let userId: number;
let username: string;
beforeEach(async () => {
({ id: userId, username } = await seedUser({ username: `ugpt_set_${Date.now()}` }));
await DB.insertInto("game_settings")
.values({
user_id: userId,
game: "iidx-sp",
pf_preferred_score_alg: null,
pf_preferred_session_alg: null,
pf_preferred_profile_alg: null,
pf_preferred_default_enum: null,
pf_default_table: null,
pf_preferred_ranking: null,
data: JSON.stringify({ display2DXTra: false, bpiTarget: 0 }),
})
.execute();
});
it("updates preferred score algorithm", async () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await ACTION_PatchUGPTSettings(taker, {
userID: userId,
game: "iidx",
playtype: "SP",
preferences: { preferredScoreAlg: "ktLampRating" },
});
const settings = await GetUGPTSettingsDocument(userId, "iidx", "SP");
expect(settings?.preferences.preferredScoreAlg).toBe("ktLampRating");
const row = await DB.selectFrom("game_settings")
.selectAll()
.where("user_id", "=", userId)
.where("game", "=", "iidx-sp")
.executeTakeFirstOrThrow();
expect(row.pf_preferred_score_alg).toBe("ktLampRating");
});
it("writes a GOOD action row on success", async () => {
const taker = { ip: "10.0.0.2", acct: { id: userId, username } };
await ACTION_PatchUGPTSettings(taker, {
userID: userId,
game: "iidx",
playtype: "SP",
preferences: { preferredProfileAlg: "ktRating" },
});
const actionRow = await DB.selectFrom("action")
.selectAll()
.where("kind", "=", "PATCH_UGPT_SETTINGS")
.executeTakeFirstOrThrow();
expect(actionRow).toMatchObject({ result: "GOOD", ip: "10.0.0.2", user_id: userId });
});
it("throws 403 when targeting another user as non-admin", async () => {
const other = await seedUser({ username: `other_${Date.now()}` });
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await expect(
ACTION_PatchUGPTSettings(taker, {
userID: other.id,
game: "iidx",
playtype: "SP",
preferences: { preferredScoreAlg: "ktLampRating" },
}),
).rejects.toMatchObject({ code: 403 });
});
});
@@ -0,0 +1,109 @@
import { MakeAction } from "#lib/actions/actions.js";
import DB from "#services/pg/db.js";
import { IsUserAdmin } from "#utils/user.js";
import { ExpectedErr } from "bliss";
import {
type GameGroup,
GamePTToV3,
type MONGO_UGPTSettingsDocument,
type Playtype,
} from "tachi-common";
import { type GameSettingsUpdate } from "tachi-db";
export const ACTION_PatchUGPTSettings = MakeAction("PATCH_UGPT_SETTINGS", async (taker, input) => {
const { userID, game: gameStr, playtype: playtypeStr, preferences } = input;
const game = gameStr as GameGroup;
const playtype = playtypeStr as Playtype;
const body = preferences as Partial<MONGO_UGPTSettingsDocument["preferences"]>;
if (taker.acct.id !== userID && !(await IsUserAdmin(taker.acct.id))) {
throw new ExpectedErr(403, "You are not authorised to modify this user's settings.");
}
const v3Game = GamePTToV3(game, playtype);
if (typeof body.defaultTable === "string") {
const table = await DB.selectFrom("table")
.select("id")
.where("game", "=", v3Game)
.where("legacy_id", "=", body.defaultTable)
.executeTakeFirst();
if (!table) {
throw new ExpectedErr(
400,
`The table (${body.defaultTable}) does not exist (and therefore cannot be set as a default).`,
);
}
}
const hasGameSpecificKeys =
body.gameSpecific !== undefined &&
body.gameSpecific !== null &&
typeof body.gameSpecific === "object" &&
Object.keys(body.gameSpecific as object).length > 0;
const hasUpdates =
body.preferredScoreAlg !== undefined ||
body.preferredSessionAlg !== undefined ||
body.preferredProfileAlg !== undefined ||
body.preferredDefaultEnum !== undefined ||
body.defaultTable !== undefined ||
body.preferredRanking !== undefined ||
hasGameSpecificKeys;
if (!hasUpdates) {
return {};
}
const row = await DB.selectFrom("game_settings")
.selectAll()
.where("user_id", "=", userID)
.where("game", "=", v3Game)
.executeTakeFirst();
if (!row) {
throw new ExpectedErr(404, "You do not have an account for this game.");
}
const set: GameSettingsUpdate = {};
if (body.preferredScoreAlg !== undefined) {
set.pf_preferred_score_alg = body.preferredScoreAlg;
}
if (body.preferredSessionAlg !== undefined) {
set.pf_preferred_session_alg = body.preferredSessionAlg;
}
if (body.preferredProfileAlg !== undefined) {
set.pf_preferred_profile_alg = body.preferredProfileAlg;
}
if (body.preferredDefaultEnum !== undefined) {
set.pf_preferred_default_enum = body.preferredDefaultEnum;
}
if (body.defaultTable !== undefined) {
set.pf_default_table = body.defaultTable;
}
if (body.preferredRanking !== undefined) {
set.pf_preferred_ranking = body.preferredRanking;
}
if (hasGameSpecificKeys) {
const nextData = {
...(row.data as Record<string, unknown>),
...(body.gameSpecific as Record<string, unknown>),
};
set.data = JSON.stringify(nextData);
}
if (Object.keys(set).length === 0) {
return {};
}
await DB.updateTable("game_settings")
.set(set)
.where("user_id", "=", userID)
.where("game", "=", v3Game)
.execute();
return {};
});
@@ -46,6 +46,7 @@ interface SeedApiTokenOpts {
identifier?: string;
fromClient?: string | null;
submitScore?: boolean;
customiseSession?: boolean;
}
export async function seedApiToken(opts: SeedApiTokenOpts) {
@@ -58,7 +59,7 @@ export async function seedApiToken(opts: SeedApiTokenOpts) {
pm_submit_score: opts.submitScore ?? null,
pm_customise_profile: null,
pm_customise_score: null,
pm_customise_session: null,
pm_customise_session: opts.customiseSession ?? null,
pm_delete_score: null,
pm_manage_rivals: null,
pm_manage_targets: null,
@@ -0,0 +1,148 @@
import type { Game } from "tachi-db";
/* eslint-disable no-await-in-loop */
import { MakeAction } from "#lib/actions/actions.js";
import { SELECT_GAME_PROFILE, ToGameStatsDocument } from "#lib/db-formats/game-profiles.js";
import { log } from "#lib/log/log.js";
import DB from "#services/pg/db.js";
import { GetMillisecondsSince } from "#utils/misc.js";
import { UnixMillisecondsToISO8601 } from "#utils/time.js";
import { GetAllRankings, GetUGPTPlaycount, IsUserAdmin } from "#utils/user.js";
import { ExpectedErr } from "bliss";
const PROFILE_BATCH = 500;
const INSERT_BATCH = 500;
function snapshotTimestampUtcMidnight(): string {
const ms = new Date().setUTCHours(0, 0, 0, 0);
return UnixMillisecondsToISO8601(ms);
}
/**
* Snapshots every {@link game_profile} row into {@link game_stats_snapshot} for UTC midnight
* of the current run. Intended for the daily job runner; also exposed as {@link ACTION_UGSSnapshot}.
*/
export async function runUgsSnapshotCore() {
const timeStart = process.hrtime.bigint();
const snapshotTimestampIso = snapshotTimestampUtcMidnight();
const dupe = await DB.selectFrom("game_stats_snapshot")
.select("user_id")
.where("timestamp", "=", snapshotTimestampIso)
.executeTakeFirst();
if (dupe) {
const msg = `There already exist snapshots at this time. Has this job been run twice on one day? Ignoring request.`;
log.warn(msg);
throw new ExpectedErr(409, msg);
}
log.info(`Snapshotting game_profile → game_stats_snapshot.`);
let insertBatch: Array<{
classes: string;
game: Game;
playcount: number;
rankings: string;
ratings: string;
timestamp: string;
user_id: number;
}> = [];
const flush = async () => {
if (insertBatch.length === 0) {
return;
}
await DB.insertInto("game_stats_snapshot").values(insertBatch).execute();
insertBatch = [];
};
try {
let cursor: { game: Game; user_id: number } | undefined;
while (true) {
const cursorAtStart = cursor;
let q = DB.selectFrom("game_profile")
.select(SELECT_GAME_PROFILE)
.orderBy("user_id", "asc")
.orderBy("game", "asc")
.limit(PROFILE_BATCH);
if (cursorAtStart) {
q = q.where((eb) =>
eb.or([
eb("user_id", ">", cursorAtStart.user_id),
eb.and([
eb("user_id", "=", cursorAtStart.user_id),
eb("game", ">", cursorAtStart.game),
]),
]),
);
}
const rows = await q.execute();
if (rows.length === 0) {
break;
}
for (const row of rows) {
const stats = ToGameStatsDocument(row);
log.debug(`Snapshotting ${stats.userID} ${stats.playtype} ${stats.game}.`);
const [playcount, rankings] = await Promise.all([
GetUGPTPlaycount(stats.userID, stats.game, stats.playtype),
GetAllRankings(stats),
]);
insertBatch.push({
classes: JSON.stringify(stats.classes),
game: row.game,
playcount,
rankings: JSON.stringify(rankings),
ratings: JSON.stringify(stats.ratings),
timestamp: snapshotTimestampIso,
user_id: stats.userID,
});
if (insertBatch.length >= INSERT_BATCH) {
log.debug(`Flushed batch.`);
await flush();
}
}
const last = rows[rows.length - 1]!;
cursor = { game: last.game, user_id: last.user_id };
}
await flush();
log.info(
`Successfully snapshotted all data as of ${snapshotTimestampIso}. Took ${GetMillisecondsSince(timeStart)} ms.`,
);
} catch (err) {
log.error({ err }, `FATAL IN UGS-SNAPSHOT - Possibly failed midway through snapshotting.`);
log.info(`Removing all snapshots at this timestamp (${snapshotTimestampIso}).`);
await DB.deleteFrom("game_stats_snapshot")
.where("timestamp", "=", snapshotTimestampIso)
.execute();
log.info(`Removed.`);
throw err;
}
}
export const ACTION_UGSSnapshot = MakeAction("UGS_SNAPSHOT", async (taker, _input) => {
if (!(await IsUserAdmin(taker.acct.id))) {
throw new ExpectedErr(403, "You are not authorized to perform this action.");
}
await runUgsSnapshotCore();
return {};
});
@@ -0,0 +1,148 @@
/* eslint-disable no-await-in-loop */
import type { Difficulties, integer, MONGO_ChartDocument, Playtypes } from "tachi-common";
import { MakeAction } from "#lib/actions/actions";
import { log } from "#lib/log/log";
import DB from "#services/pg/db";
import { RecalcAllScores } from "#utils/calculations/recalc-scores";
import fetch from "#utils/fetch";
import { FindChartWithPTDFVersion } from "#utils/queries/charts";
import { FindSongOnTitle } from "#utils/queries/songs";
import { IsUserAdmin } from "#utils/user";
import { ExpectedErr } from "bliss";
const difficultyResolve: Record<string, [Playtypes["iidx"], Difficulties["iidx:DP" | "iidx:SP"]]> =
{
3: ["SP", "HYPER"],
4: ["SP", "ANOTHER"],
8: ["DP", "HYPER"],
9: ["DP", "ANOTHER"],
10: ["SP", "LEGGENDARIA"],
11: ["DP", "LEGGENDARIA"],
};
interface PoyashiProxyBPIInfo {
title: string;
difficulty: string;
wr: integer;
avg: integer;
notes: string;
bpm: string;
textage: string;
difficultyLevel: string;
dpLevel: string;
coef?: number | string | null;
removed?: boolean;
}
interface PoyashiProxyData {
version: integer;
requireVersion: string;
body: Array<PoyashiProxyBPIInfo>;
}
/**
* Fetches Poyashi BPI's latest information and writes BPI coefficients / WR / kaiden avg
* to Postgres `chart.data` for IIDX charts (version 29).
*/
export async function updateBpiDataCore() {
log.info("Fetching data from proxy...");
const data = (await fetch("https://proxy.poyashi.me/?type=bpi").then((r) =>
r.json(),
)) as PoyashiProxyData;
log.info("Fetched data.");
const updatedChartIDs: Array<string> = [];
for (const d of data.body) {
const res: ["DP" | "SP", Difficulties["iidx:DP" | "iidx:SP"]] | undefined =
difficultyResolve[d.difficulty];
if (!res) {
throw new Error(`Unknown difficulty ${d.difficulty}`);
}
const [playtype, diff] = res;
const tachiSong = await FindSongOnTitle("iidx", d.title);
if (!tachiSong) {
log.warn(`Cannot find song ${d.title}?`);
continue;
}
const tachiChart = await FindChartWithPTDFVersion(
"iidx",
tachiSong.id,
playtype,
diff,
"29",
);
if (!tachiChart) {
log.warn(
`Cannot find chart ${tachiSong.title} (${tachiSong.id}) ${playtype}, ${diff}?`,
);
continue;
}
const kavg = Number(d.avg);
if (kavg < 0) {
log.warn(`${tachiSong.title} (${playtype} ${diff}). Invalid kavg ${d.avg}, Skipping.`);
continue;
}
if (d.removed === true) {
log.info(`Skipping removed chart ${tachiSong.title}.`);
continue;
}
const newCoef = Number(d.coef) === -1 || d.coef === undefined ? null : Number(d.coef);
const newKavg = Number(d.avg);
const newWR = Number(d.wr);
const iidxChart = tachiChart as
| MONGO_ChartDocument<"iidx:DP">
| MONGO_ChartDocument<"iidx:SP">;
if (
iidxChart.data.bpiCoefficient !== newCoef ||
iidxChart.data.kaidenAverage !== newKavg ||
iidxChart.data.worldRecord !== newWR
) {
updatedChartIDs.push(tachiChart.chartID);
const mergedData = {
...iidxChart.data,
bpiCoefficient: newCoef,
kaidenAverage: newKavg,
worldRecord: newWR,
};
await DB.updateTable("chart")
.set({ data: mergedData as object })
.where("id", "=", tachiChart.chartID)
.execute();
}
}
if (updatedChartIDs.length !== 0) {
log.info(`Finished applying BPI changes in Postgres. Recalcing scores.`);
await RecalcAllScores({
game: "iidx",
chartID: { $in: updatedChartIDs },
});
log.info(`Finished recalcing scores.`);
}
return { chartsUpdated: updatedChartIDs.length };
}
export const ACTION_UpdateBpiData = MakeAction("UPDATE_BPI_DATA", async (taker, _input) => {
if (!(await IsUserAdmin(taker.acct.id))) {
throw new ExpectedErr(403, "You are not authorized to perform this action.");
}
return updateBpiDataCore();
});
@@ -0,0 +1,138 @@
import type { MONGO_ChartDocument } from "tachi-common";
/* eslint-disable no-await-in-loop */
import { MakeAction } from "#lib/actions/actions";
import { log } from "#lib/log/log";
import DB from "#services/pg/db";
import { RecalcAllScores } from "#utils/calculations/recalc-scores";
import fetch from "#utils/fetch";
import { FindChartWithPTDFVersion } from "#utils/queries/charts";
import { FindSongOnTitle } from "#utils/queries/songs";
import { IsUserAdmin } from "#utils/user";
import { ExpectedErr } from "bliss";
import { decode } from "html-entities";
import { parse } from "node-html-parser";
export async function updateDpTiersCore() {
const rawHTML = await fetch("https://zasa.sakura.ne.jp/dp/run.php").then((r) => r.text());
const data = parse(rawHTML);
const rows = data.querySelectorAll("tr");
const parsedData: Array<{
ANOTHER: number | null;
HYPER: number | null;
LEGGENDARIA: number | null;
songTitle: string;
}> = [];
for (const row of rows) {
if (row.childNodes.length !== 4) {
continue;
}
parsedData.push({
HYPER: ParseTierStr(row.childNodes[0]!.innerText),
ANOTHER: ParseTierStr(row.childNodes[1]!.innerText),
LEGGENDARIA: ParseTierStr(row.childNodes[2]!.innerText),
songTitle: decode(row.childNodes[3]!.innerText),
});
}
log.info(`Got DP tier data. Applying it.`);
const updatedSongIDs = new Set<number>();
for (const d of parsedData) {
const song = await FindSongOnTitle("iidx", d.songTitle);
if (!song) {
log.warn(`Couldn't find song with title ${d.songTitle}.`);
continue;
}
for (const difficulty of ["HYPER", "ANOTHER", "LEGGENDARIA"] as const) {
if (d[difficulty] === null) {
continue;
}
const chart = await FindChartWithPTDFVersion("iidx", song.id, "DP", difficulty, "29");
if (!chart) {
log.warn(
`Couldn't find DP chart for ${d.songTitle} (${song.id}) ${difficulty} on version 29.`,
);
continue;
}
const nextTier = {
text: d[difficulty]!.toString(),
value: d[difficulty]!,
individualDifference: false,
};
const dpChart = chart as MONGO_ChartDocument<"iidx:DP">;
const prev = dpChart.data.dpTier;
if (
prev &&
prev.text === nextTier.text &&
prev.value === nextTier.value &&
prev.individualDifference === nextTier.individualDifference
) {
continue;
}
const mergedData = {
...dpChart.data,
dpTier: nextTier,
};
await DB.updateTable("chart")
.set({ data: mergedData as object })
.where("id", "=", chart.chartID)
.execute();
updatedSongIDs.add(song.id);
}
}
if (updatedSongIDs.size !== 0) {
log.info(`${updatedSongIDs.size} songs were changed. Recalcing the relevant scores now.`);
await RecalcAllScores({
game: "iidx",
playtype: "DP",
songID: { $in: [...updatedSongIDs.values()] },
});
log.info(`Recalced those scores.`);
}
log.info("Done.");
return { songsTouched: updatedSongIDs.size };
}
function ParseTierStr(tierStr: string) {
if (tierStr === "-") {
return null;
}
const result = /\((.*)\)$/u.exec(tierStr);
if (result?.[1]) {
return Number(result[1]);
}
throw new Error(`Can't parse tierStr ${tierStr}.`);
}
export const ACTION_UpdateDpTiers = MakeAction("UPDATE_DP_TIERS", async (taker, _input) => {
if (!(await IsUserAdmin(taker.acct.id))) {
throw new ExpectedErr(403, "You are not authorized to perform this action.");
}
return updateDpTiersCore();
});
@@ -0,0 +1,96 @@
import { LoadSessionDocumentById } from "#lib/db-formats/session";
import DB from "#services/pg/db";
import { seedUser } from "#test-utils/pg-fixtures";
import { describe, expect, it } from "vitest";
import { ACTION_UpdateSession } from "./update-session";
describe("ACTION_UpdateSession", () => {
it("throws 400 when nothing to update", async () => {
const { id: userId, username } = await seedUser();
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await expect(ACTION_UpdateSession(taker, { sessionID: "missing" })).rejects.toMatchObject({
code: 400,
});
});
it("throws 404 when session does not exist", async () => {
const { id: userId, username } = await seedUser();
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await expect(
ACTION_UpdateSession(taker, { sessionID: "no-such-session", name: "ValidName" }),
).rejects.toMatchObject({ code: 404 });
});
it("throws 403 when the user does not own the session", async () => {
const { id: ownerId } = await seedUser({ username: "owner_sess" });
const { id: otherId, username: otherName } = await seedUser({ username: "other_sess" });
const sessionId = `Q${"b".repeat(40)}`;
const now = new Date().toISOString();
await DB.insertInto("session")
.values({
id: sessionId,
user_id: ownerId,
game: "iidx-sp",
name: "N",
description: null,
time_inserted: now,
time_started: now,
time_ended: now,
calculated_data: JSON.stringify({}),
highlight: false,
})
.execute();
const taker = { ip: "127.0.0.1", acct: { id: otherId, username: otherName } };
await expect(
ACTION_UpdateSession(taker, { sessionID: sessionId, name: "Hijacked" }),
).rejects.toMatchObject({ code: 403 });
});
it("updates name in the database", async () => {
const { id: userId, username } = await seedUser();
const sessionId = `Q${"c".repeat(40)}`;
const now = new Date().toISOString();
await DB.insertInto("session")
.values({
id: sessionId,
user_id: userId,
game: "iidx-sp",
name: "Old",
description: null,
time_inserted: now,
time_started: now,
time_ended: now,
calculated_data: JSON.stringify({}),
highlight: false,
})
.execute();
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await ACTION_UpdateSession(taker, {
sessionID: sessionId,
name: "NewName",
});
const doc = await LoadSessionDocumentById(sessionId);
expect(doc?.name).toBe("NewName");
const row = await DB.selectFrom("action")
.selectAll()
.where("kind", "=", "UPDATE_SESSION")
.orderBy("ts_end", "desc")
.executeTakeFirst();
expect(row).toMatchObject({ result: "GOOD", ip: "127.0.0.1", user_id: userId });
});
});
@@ -0,0 +1,43 @@
import { MakeAction } from "#lib/actions/actions.js";
import DB from "#services/pg/db.js";
import { ExpectedErr } from "bliss";
export const ACTION_UpdateSession = MakeAction("UPDATE_SESSION", async (taker, input) => {
const hasUpdate =
input.name !== undefined || input.desc !== undefined || input.highlight !== undefined;
if (!hasUpdate) {
throw new ExpectedErr(400, "This request modifies nothing about this session.");
}
const row = await DB.selectFrom("session")
.select(["id", "user_id"])
.where("id", "=", input.sessionID)
.executeTakeFirst();
if (!row) {
throw new ExpectedErr(404, "This session does not exist.");
}
if (row.user_id !== taker.acct.id) {
throw new ExpectedErr(403, "You are not authorised to modify this session.");
}
const set: { description?: string | null; highlight?: boolean; name?: string } = {};
if (input.name !== undefined) {
set.name = input.name;
}
if (input.desc !== undefined) {
set.description = input.desc;
}
if (input.highlight !== undefined) {
set.highlight = input.highlight;
}
await DB.updateTable("session").set(set).where("id", "=", input.sessionID).execute();
return {};
});
@@ -0,0 +1,250 @@
import type { Difficulties, integer, MONGO_ChartDocument } from "tachi-common";
/* eslint-disable no-await-in-loop */
import { MakeAction } from "#lib/actions/actions";
import { log } from "#lib/log/log";
import DB from "#services/pg/db";
import { RecalcAllScores } from "#utils/calculations/recalc-scores";
import fetch from "#utils/fetch";
import { FindChartWithPTDF } from "#utils/queries/charts";
import { FindSongOnTitle } from "#utils/queries/songs";
import { IsUserAdmin } from "#utils/user";
import { ExpectedErr } from "bliss";
import { p } from "prudence";
interface SP12Data {
id: integer;
title: string;
n_clear: integer;
hard: integer;
exh: integer;
n_clear_string: string;
hard_string: string;
exh_string: string;
version: integer;
}
export async function updateSp12DataCore() {
const unvalidatedRJ: unknown = await fetch("https://sp12.iidx.app/api/v1/sheets").then((r) =>
r.json(),
);
const err = p(unvalidatedRJ, {
sheets: [
{
id: p.isPositiveNonZeroInteger,
title: "string",
n_clear: p.isPositiveInteger,
hard: p.isPositiveInteger,
exh: p.isPositiveInteger,
n_clear_string: "string",
hard_string: "string",
exh_string: "string",
version: p.isPositiveInteger,
},
],
});
if (err) {
log.error({ unvalidatedRJ }, `Got invalid/unexpected content from sp12.`);
throw new Error(`Got invalid/unexpected content from sp12.`);
}
const rj = unvalidatedRJ as {
sheets: Array<SP12Data>;
};
const updatedChartIDs: Array<string> = [];
for (const sh of rj.sheets) {
let chart: MONGO_ChartDocument<"iidx:SP">;
try {
chart = await HumanisedTitleLookup(sh.title);
} catch (e) {
log.error((e as Error).message);
continue;
}
let chartData = { ...chart.data };
for (const key of ["n_clear", "hard", "exh"] as const) {
let val: number;
switch (key) {
case "n_clear": {
const v = Math.floor(sh[key] / 2);
if (v === 9) {
val = 11.8;
} else if (v === 8) {
val = 12.0;
} else if (v === 7) {
val = 12.2;
} else if (v === 6) {
val = 12.4;
} else if (v === 5) {
val = 12.6;
} else if (v < 0) {
continue;
} else {
val = 12.6 + (5 - v) * 0.1;
}
break;
}
case "hard": {
const v2 = Math.floor(sh[key] / 2);
if (v2 === 9) {
val = 11.9;
} else if (v2 === 8) {
val = 12.1;
} else if (v2 === 7) {
val = 12.3;
} else if (v2 === 6) {
val = 12.5;
} else if (v2 === 5) {
val = 12.7;
} else if (v2 < 0) {
continue;
} else {
val = 12.7 + (5 - v2) * 0.1;
}
break;
}
case "exh": {
const v3 = sh[key];
if (v3 >= 12 || v3 <= 0) {
continue;
}
val = 12.4 + (12 - v3) * 0.1;
break;
}
default:
throw new Error("??");
}
const stringVal = sh[`${key}_string` as const];
if (stringVal === "難易度未定") {
continue;
}
val = parseFloat(val.toFixed(2));
let ktKey: "exhcTier" | "hcTier" | "ncTier";
if (key === "exh") {
ktKey = "exhcTier";
} else if (key === "hard") {
ktKey = "hcTier";
} else {
ktKey = "ncTier";
}
const text =
ktKey === "exhcTier"
? val.toFixed(2)
: `12${stringVal.replace(/(個人差|地力)/u, "")}`;
const idvDiff = stringVal.includes("個人差");
const existingTlInfo = chartData[ktKey];
if (
existingTlInfo &&
existingTlInfo.text === text &&
existingTlInfo.value === val &&
existingTlInfo.individualDifference === idvDiff
) {
continue;
}
updatedChartIDs.push(chart.chartID);
chartData = {
...chartData,
[ktKey]: {
text,
value: val,
individualDifference: idvDiff,
},
};
await DB.updateTable("chart")
.set({ data: chartData as object })
.where("id", "=", chart.chartID)
.execute();
log.info(`Saved ${sh.title} value ${key} = ${val} (${text}).`);
}
}
if (updatedChartIDs.length !== 0) {
log.info(`Finished applying SP12 changes. Recalcing.`);
await RecalcAllScores({
game: "iidx",
chartID: { $in: updatedChartIDs },
});
log.info(`Finished recalcing scores.`);
}
return { chartsUpdated: updatedChartIDs.length };
}
async function HumanisedTitleLookup(originalTitle: string) {
let difficulty: Difficulties["iidx:SP"] = "ANOTHER";
let title: string | undefined = originalTitle;
if (/(†|†LEGGENDARIA)$/u.exec(title)) {
difficulty = "LEGGENDARIA";
title = title.split(/(†|†LEGGENDARIA)$/u)[0];
} else if (/\[H\]$/u.exec(title)) {
difficulty = "HYPER";
title = title.split("[")[0];
} else if (/\[A\]$/u.exec(title)) {
difficulty = "ANOTHER";
title = title.split("[")[0];
}
if (title === undefined) {
throw new Error(
`Unexpected title of undefined converted from song title: ${originalTitle}. Was there a faulty split? Was the chart literally called †LEGGENDARIA?`,
);
}
const song = await FindSongOnTitle("iidx", title);
if (!song) {
throw new Error(
`Could not resolve song ${title} (${difficulty}) (Original ${originalTitle}).`,
);
}
const chart = await FindChartWithPTDF("iidx", song.id, "SP", difficulty);
if (!chart) {
throw new Error(
`Could not resolve chart ${title} ${difficulty} (Original ${originalTitle}).`,
);
}
return chart as MONGO_ChartDocument<"iidx:SP">;
}
export const ACTION_UpdateSp12Data = MakeAction("UPDATE_SP12_DATA", async (taker, _input) => {
if (!(await IsUserAdmin(taker.acct.id))) {
throw new ExpectedErr(403, "You are not authorized to perform this action.");
}
return updateSp12DataCore();
});
@@ -0,0 +1,52 @@
import { MakeAction } from "#lib/actions/actions.js";
import { GetUGPTSettingsDocument } from "#lib/db-formats/ugpt-settings.js";
import DB from "#services/pg/db.js";
import { IsUserAdmin } from "#utils/user.js";
import { ExpectedErr } from "bliss";
import { type GameGroup, GamePTToV3, type Playtype, type ShowcaseStatDetails } from "tachi-common";
export const ACTION_UpdateUgptShowcase = MakeAction(
"UPDATE_UGPT_SHOWCASE",
async (taker, input) => {
const { userID, game: gameStr, playtype: playtypeStr, stats } = input;
const game = gameStr as GameGroup;
const playtype = playtypeStr as Playtype;
if (taker.acct.id !== userID && !(await IsUserAdmin(taker.acct.id))) {
throw new ExpectedErr(403, "You are not authorised to modify this user's showcase.");
}
const v3Game = GamePTToV3(game, playtype);
const settingsRow = await DB.selectFrom("game_settings")
.select("user_id")
.where("user_id", "=", userID)
.where("game", "=", v3Game)
.executeTakeFirst();
if (!settingsRow) {
throw new ExpectedErr(404, "You do not have a profile for this game.");
}
const payload = stats as Array<ShowcaseStatDetails>;
await DB.insertInto("game_settings_showcase")
.values({
user_id: userID,
game: v3Game,
data: JSON.stringify(payload),
})
.onConflict((oc) =>
oc.columns(["user_id", "game"]).doUpdateSet({ data: JSON.stringify(payload) }),
)
.execute();
const newSettings = await GetUGPTSettingsDocument(userID, game, playtype);
if (!newSettings) {
throw new ExpectedErr(500, "Failed to load settings after updating showcase.");
}
return {};
},
);
@@ -221,7 +221,7 @@ export const SDVXLIKE_GOAL_PG_FMT: GPTGoalProgressFormatters<SDVXLikes> = {
};
export const SDVXLIKE_PB_MERGERS: Array<PBMergeFunction<SDVXLikes>> = [
CreatePBMergeFor("largest", "enumIndexes.lamp", "Best Lamp", (base, score) => {
CreatePBMergeFor("largest", { type: "REGULAR", metric: "lamp" }, "Best Lamp", (base, score) => {
base.scoreData.lamp = score.scoreData.lamp;
}),
];
@@ -86,9 +86,14 @@ export const ARCAEA_IMPL: GPTServerImplementation<GPTStrings["arcaea"]> = {
score: GoalOutOfFmtScore,
},
pbMergeFunctions: [
CreatePBMergeFor("largest", "enumIndexes.lamp", "Best Lamp", (base, score) => {
base.scoreData.lamp = score.scoreData.lamp;
}),
CreatePBMergeFor(
"largest",
{ type: "REGULAR", metric: "lamp" },
"Best Lamp",
(base, score) => {
base.scoreData.lamp = score.scoreData.lamp;
},
),
],
defaultMergeRefName: "Best Score",
scoreValidators: [
@@ -17,7 +17,7 @@ import {
} from "./_common";
const BMS_PMS_MERGERS: Array<PBMergeFunction<GPTStrings["bms" | "pms"]>> = [
CreatePBMergeFor("largest", "enumIndexes.lamp", "Best Lamp", (base, lamp) => {
CreatePBMergeFor("largest", { type: "REGULAR", metric: "lamp" }, "Best Lamp", (base, lamp) => {
base.scoreData.lamp = lamp.scoreData.lamp;
// technically these don't exist on PMS scores but since undefined is a
@@ -28,7 +28,7 @@ const BMS_PMS_MERGERS: Array<PBMergeFunction<GPTStrings["bms" | "pms"]>> = [
base.scoreData.optional.gaugeHistoryGroove = lamp.scoreData.optional.gaugeHistoryGroove;
base.scoreData.optional.gaugeHistoryHard = lamp.scoreData.optional.gaugeHistoryHard;
}),
CreatePBMergeFor("smallest", "optional.bp", "Lowest BP", (base, bp) => {
CreatePBMergeFor("smallest", { type: "REGULAR", metric: "bp" }, "Lowest BP", (base, bp) => {
base.scoreData.optional.bp = bp.scoreData.optional.bp;
}),
];
@@ -91,12 +91,22 @@ export const CHUNITHM_IMPL: GPTServerImplementation<"chunithm:Single"> = {
score: GoalOutOfFmtScore,
},
pbMergeFunctions: [
CreatePBMergeFor("largest", "enumIndexes.noteLamp", "Best Note Lamp", (base, score) => {
base.scoreData.noteLamp = score.scoreData.noteLamp;
}),
CreatePBMergeFor("largest", "enumIndexes.clearLamp", "Best Clear Lamp", (base, score) => {
base.scoreData.clearLamp = score.scoreData.clearLamp;
}),
CreatePBMergeFor(
"largest",
{ type: "REGULAR", metric: "noteLamp" },
"Best Note Lamp",
(base, score) => {
base.scoreData.noteLamp = score.scoreData.noteLamp;
},
),
CreatePBMergeFor(
"largest",
{ type: "REGULAR", metric: "clearLamp" },
"Best Clear Lamp",
(base, score) => {
base.scoreData.clearLamp = score.scoreData.clearLamp;
},
),
],
defaultMergeRefName: "Best Score",
scoreValidators: [
@@ -67,6 +67,7 @@ async function seedDdrPbRow(
data: JSON.stringify({}),
derived_data: JSON.stringify({}),
calculated_data: JSON.stringify(calculatedData),
judgements: JSON.stringify({}),
ranking_value: args.flareSkill,
ranking_value_tb1: null,
ranking_value_tb2: null,
@@ -320,16 +320,31 @@ export const DDR_IMPL: GPTServerImplementation<"ddr:DP" | "ddr:SP"> = {
goalProgressFormatters: DDR_GOAL_PG_FMT,
goalOutOfFormatters: DDR_GOAL_OO_FMT,
pbMergeFunctions: [
CreatePBMergeFor("largest", "enumIndexes.lamp", "Best Lamp", (base, score) => {
base.scoreData.lamp = score.scoreData.lamp;
}),
CreatePBMergeFor("largest", "score", "Best Score", (base, score) => {
base.scoreData.score = score.scoreData.score;
base.scoreData.grade = score.scoreData.grade;
}),
CreatePBMergeFor("largest", "optional.exScore", "Best EX Score", (base, score) => {
base.scoreData.optional.exScore = score.scoreData.optional.exScore;
}),
CreatePBMergeFor(
"largest",
{ type: "REGULAR", metric: "lamp" },
"Best Lamp",
(base, score) => {
base.scoreData.lamp = score.scoreData.lamp;
},
),
CreatePBMergeFor(
"largest",
{ type: "REGULAR", metric: "score" },
"Best Score",
(base, score) => {
base.scoreData.score = score.scoreData.score;
base.scoreData.grade = score.scoreData.grade;
},
),
CreatePBMergeFor(
"largest",
{ type: "REGULAR", metric: "exScore" },
"Best EX Score",
(base, score) => {
base.scoreData.optional.exScore = score.scoreData.optional.exScore;
},
),
],
sessionCalcs: (arr) => ({
flareSkill: SessionAvgBest10For("flareSkill")(arr),
@@ -93,9 +93,14 @@ const GITADORA_IMPL: GPTServerImplementation<"gitadora:Dora" | "gitadora:Gita">
percent: GoalOutOfFmtPercent,
},
pbMergeFunctions: [
CreatePBMergeFor("largest", "enumIndexes.lamp", "Best Lamp", (base, score) => {
base.scoreData.lamp = score.scoreData.lamp;
}),
CreatePBMergeFor(
"largest",
{ type: "REGULAR", metric: "lamp" },
"Best Lamp",
(base, score) => {
base.scoreData.lamp = score.scoreData.lamp;
},
),
],
defaultMergeRefName: "Best Percent",
scoreValidators: [],
@@ -44,7 +44,7 @@ const IIDX_PROFILE_CALCS: GPTProfileCalcs<"iidx:DP" | "iidx:SP"> = async (
};
const IIDX_MERGERS: Array<PBMergeFunction<GPTStrings["iidx"]>> = [
CreatePBMergeFor("largest", "enumIndexes.lamp", "Best Lamp", (base, lamp) => {
CreatePBMergeFor("largest", { type: "REGULAR", metric: "lamp" }, "Best Lamp", (base, lamp) => {
base.scoreData.lamp = lamp.scoreData.lamp;
// Update lamp related iidx-specific info from the lampPB.
@@ -58,7 +58,7 @@ const IIDX_MERGERS: Array<PBMergeFunction<GPTStrings["iidx"]>> = [
base.scoreData.optional.comboBreak = lamp.scoreData.optional.comboBreak;
}),
CreatePBMergeFor("smallest", "optional.bp", "Lowest BP", (base, bp) => {
CreatePBMergeFor("smallest", { type: "REGULAR", metric: "bp" }, "Lowest BP", (base, bp) => {
base.scoreData.optional.bp = bp.scoreData.optional.bp;
}),
];
@@ -113,9 +113,14 @@ export const ITG_STAMINA_IMPL: GPTServerImplementation<"itg:Stamina"> = {
pbMergeFunctions: [
// we'll pluck the best lamp, but this game has a pretty interesting concept
// for merging PBs. This is probably fine.
CreatePBMergeFor("largest", "enumIndexes.lamp", "Best Lamp", (base, score) => {
base.scoreData.lamp = score.scoreData.lamp;
}),
CreatePBMergeFor(
"largest",
{ type: "REGULAR", metric: "lamp" },
"Best Lamp",
(base, score) => {
base.scoreData.lamp = score.scoreData.lamp;
},
),
],
// this name sucks, what should we do instead? TODO.
@@ -75,6 +75,7 @@ async function seedJubeatChartPbOnSong(
data: JSON.stringify({}),
derived_data: JSON.stringify({}),
calculated_data: JSON.stringify(calculatedData),
judgements: JSON.stringify({}),
ranking_value: args.jubility,
ranking_value_tb1: null,
ranking_value_tb2: null,
@@ -282,6 +283,7 @@ describe("GetPBsForJubility (Postgres)", () => {
data: JSON.stringify({}),
derived_data: JSON.stringify({}),
calculated_data: JSON.stringify({ jubility: 33 }),
judgements: JSON.stringify({}),
ranking_value: 33,
ranking_value_tb1: null,
ranking_value_tb2: null,
@@ -93,13 +93,15 @@ export async function GetBestJubilityOnSongs(
pb.ranking_value_tb5,
pb.highlight,
pb.time_achieved,
chart.legacy_id as chart_legacy_id,
song.legacy_id as song_legacy_id,
chart.game as chart_game,
chart.is_primary as is_primary
chart.is_primary as is_primary,
chart_leaderboard.rank AS leaderboard_rank,
chart_leaderboard.out_of AS leaderboard_out_of
FROM pb
INNER JOIN chart ON chart.id = pb.chart_id
INNER JOIN song ON song.id = chart.song_id
INNER JOIN chart_leaderboard ON chart_leaderboard.row_id = pb.row_id
INNER JOIN filtered f ON f.row_id = pb.row_id AND f.rn = 1
ORDER BY (pb.calculated_data::jsonb->>'jubility')::double precision DESC NULLS LAST
LIMIT ${limit}
@@ -248,13 +250,23 @@ export const JUBEAT_IMPL: GPTServerImplementation<"jubeat:Single"> = {
// musicRate is the default prop
// but we want the user's best score to count aswell.
pbMergeFunctions: [
CreatePBMergeFor("largest", "enumIndexes.lamp", "Best Lamp", (base, score) => {
base.scoreData.lamp = score.scoreData.lamp;
}),
CreatePBMergeFor("largest", "score", "Best Score", (base, score) => {
base.scoreData.score = score.scoreData.score;
base.scoreData.grade = score.scoreData.grade;
}),
CreatePBMergeFor(
"largest",
{ type: "REGULAR", metric: "lamp" },
"Best Lamp",
(base, score) => {
base.scoreData.lamp = score.scoreData.lamp;
},
),
CreatePBMergeFor(
"largest",
{ type: "REGULAR", metric: "score" },
"Best Score",
(base, score) => {
base.scoreData.score = score.scoreData.score;
base.scoreData.grade = score.scoreData.grade;
},
),
],
defaultMergeRefName: "Best Music Rate",
scoreValidators: [
@@ -115,9 +115,14 @@ export const MAIMAI_IMPL: GPTServerImplementation<"maimai:Single"> = {
percent: GoalOutOfFmtPercent,
},
pbMergeFunctions: [
CreatePBMergeFor("largest", "enumIndexes.lamp", "Best Lamp", (base, score) => {
base.scoreData.lamp = score.scoreData.lamp;
}),
CreatePBMergeFor(
"largest",
{ type: "REGULAR", metric: "lamp" },
"Best Lamp",
(base, score) => {
base.scoreData.lamp = score.scoreData.lamp;
},
),
],
defaultMergeRefName: "Best Percent",
scoreValidators: [
@@ -81,9 +81,14 @@ export const MAIMAIDX_IMPL: GPTServerImplementation<"maimaidx:Single"> = {
percent: (v) => GoalOutOfFmtPercent(v, 4),
},
pbMergeFunctions: [
CreatePBMergeFor("largest", "enumIndexes.lamp", "Best Lamp", (base, score) => {
base.scoreData.lamp = score.scoreData.lamp;
}),
CreatePBMergeFor(
"largest",
{ type: "REGULAR", metric: "lamp" },
"Best Lamp",
(base, score) => {
base.scoreData.lamp = score.scoreData.lamp;
},
),
],
defaultMergeRefName: "Best Percent",
scoreValidators: [
@@ -49,9 +49,14 @@ export const MUSECA_IMPL: GPTServerImplementation<"museca:Single"> = {
score: GoalOutOfFmtScore,
},
pbMergeFunctions: [
CreatePBMergeFor("largest", "enumIndexes.lamp", "Best Lamp", (base, score) => {
base.scoreData.lamp = score.scoreData.lamp;
}),
CreatePBMergeFor(
"largest",
{ type: "REGULAR", metric: "lamp" },
"Best Lamp",
(base, score) => {
base.scoreData.lamp = score.scoreData.lamp;
},
),
],
defaultMergeRefName: "Best Score",
scoreValidators: [
@@ -164,16 +164,31 @@ export const ONGEKI_IMPL: GPTServerImplementation<"ongeki:Single"> = {
platinumStars: () => FmtStarsCompact(6),
},
pbMergeFunctions: [
CreatePBMergeFor("largest", "platinumScore", "Best Platinum Score", (base, score) => {
base.scoreData.platinumScore = score.scoreData.platinumScore;
base.scoreData.platinumStars = score.scoreData.platinumStars;
}),
CreatePBMergeFor("largest", "enumIndexes.noteLamp", "Best Note Lamp", (base, score) => {
base.scoreData.noteLamp = score.scoreData.noteLamp;
}),
CreatePBMergeFor("largest", "enumIndexes.bellLamp", "Best Bell Lamp", (base, score) => {
base.scoreData.bellLamp = score.scoreData.bellLamp;
}),
CreatePBMergeFor(
"largest",
{ type: "REGULAR", metric: "platinumScore" },
"Best Platinum Score",
(base, score) => {
base.scoreData.platinumScore = score.scoreData.platinumScore;
base.scoreData.platinumStars = score.scoreData.platinumStars;
},
),
CreatePBMergeFor(
"largest",
{ type: "REGULAR", metric: "noteLamp" },
"Best Note Lamp",
(base, score) => {
base.scoreData.noteLamp = score.scoreData.noteLamp;
},
),
CreatePBMergeFor(
"largest",
{ type: "REGULAR", metric: "bellLamp" },
"Best Bell Lamp",
(base, score) => {
base.scoreData.bellLamp = score.scoreData.bellLamp;
},
),
],
defaultMergeRefName: "Best Score",
scoreValidators: [
@@ -107,11 +107,16 @@ export const POPN_9B_IMPL: GPTServerImplementation<"popn:9B"> = {
score: GoalOutOfFmtScore,
},
pbMergeFunctions: [
CreatePBMergeFor("largest", "enumIndexes.clearMedal", "Best Clear", (base, score) => {
base.scoreData.clearMedal = score.scoreData.clearMedal;
// these are directly related. pluck both.
base.scoreData.lamp = score.scoreData.lamp;
}),
CreatePBMergeFor(
"largest",
{ type: "REGULAR", metric: "clearMedal" },
"Best Clear",
(base, score) => {
base.scoreData.clearMedal = score.scoreData.clearMedal;
// these are directly related. pluck both.
base.scoreData.lamp = score.scoreData.lamp;
},
),
],
defaultMergeRefName: "Best Score",
scoreValidators: [
@@ -47,12 +47,22 @@ export const SDVX_IMPL: GPTServerImplementation<"sdvx:Single"> = {
goalProgressFormatters: SDVXLIKE_GOAL_PG_FMT,
goalOutOfFormatters: SDVXLIKE_GOAL_OO_FMT,
pbMergeFunctions: [
CreatePBMergeFor("largest", "enumIndexes.lamp", "Best Lamp", (base, score) => {
base.scoreData.lamp = score.scoreData.lamp;
}),
CreatePBMergeFor("largest", "optional.exScore", "Best EX Score", (base, score) => {
base.scoreData.optional.exScore = score.scoreData.optional.exScore;
}),
CreatePBMergeFor(
"largest",
{ type: "REGULAR", metric: "lamp" },
"Best Lamp",
(base, score) => {
base.scoreData.lamp = score.scoreData.lamp;
},
),
CreatePBMergeFor(
"largest",
{ type: "REGULAR", metric: "exScore" },
"Best EX Score",
(base, score) => {
base.scoreData.optional.exScore = score.scoreData.optional.exScore;
},
),
],
defaultMergeRefName: SDVXLIKE_DEFAULT_MERGE_NAME,
scoreValidators: SDVXLIKE_SCORE_VALIDATORS,
@@ -76,9 +76,14 @@ export const WACCA_IMPL: GPTServerImplementation<"wacca:Single"> = {
score: GoalOutOfFmtScore,
},
pbMergeFunctions: [
CreatePBMergeFor("largest", "enumIndexes.lamp", "Best Lamp", (base, score) => {
base.scoreData.lamp = score.scoreData.lamp;
}),
CreatePBMergeFor(
"largest",
{ type: "REGULAR", metric: "lamp" },
"Best Lamp",
(base, score) => {
base.scoreData.lamp = score.scoreData.lamp;
},
),
],
defaultMergeRefName: "Best Score",
@@ -13,6 +13,7 @@ import {
SELECT_SCORE_DOCUMENT,
ToScoreDocument,
} from "#lib/db-formats/score";
import { scoreVisibleSql } from "#lib/score-import/framework/pg/score-visibility";
import DB from "#services/pg/db";
import { UnixMillisecondsToISO8601 } from "#utils/time.js";
import { sql } from "kysely";
@@ -48,6 +49,9 @@ function metricIsNumericSql<GPT extends GPTString>(metric: MetricKeys<GPT>) {
* Utility for making a PB merge function. In short, get the best score this user has
* on this chart for the stated metric, then run the applicator if a score was found.
*
* `metric` uses Postgres JSON keys on `score.data` / `score.derived_data` (see `mongoScoreDataToPg`).
* For enum ordinals, use the metric name (e.g. `{ type: "REGULAR", metric: "lamp" }`), not `enumIndexes.*`.
*
* @param direction - Whether to pick the largest value or smallest value for this metric.
*/
export function CreatePBMergeFor<GPT extends GPTString>(
@@ -64,7 +68,8 @@ export function CreatePBMergeFor<GPT extends GPTString>(
.select(SELECT_SCORE_DOCUMENT)
.where("score.user_id", "=", userID)
.where("chart.legacy_id", "=", chartID)
.where(metricIsNumericSql(metric));
.where(metricIsNumericSql(metric))
.where(scoreVisibleSql());
if (asOfTimestamp !== null) {
q = q.where(
@@ -50,6 +50,7 @@ async function seedChunithmChartsWithPbs(userId: number, ratings: Array<number>)
data: JSON.stringify({}),
derived_data: JSON.stringify({}),
calculated_data: JSON.stringify({ rating }),
judgements: JSON.stringify({}),
ranking_value: rating,
ranking_value_tb1: null,
ranking_value_tb2: null,
@@ -149,6 +150,7 @@ describe("profile-calc (Postgres)", () => {
data: JSON.stringify({}),
derived_data: JSON.stringify({}),
calculated_data: JSON.stringify({ rating: "not-a-number" }),
judgements: JSON.stringify({}),
ranking_value: 0,
ranking_value_tb1: null,
ranking_value_tb2: null,
@@ -145,13 +145,15 @@ export async function GetBestRatingOnSongs(
pb.ranking_value_tb5,
pb.highlight,
pb.time_achieved,
chart.legacy_id as chart_legacy_id,
song.legacy_id as song_legacy_id,
chart.game as chart_game,
chart.is_primary as is_primary
chart.is_primary as is_primary,
chart_leaderboard.rank AS leaderboard_rank,
chart_leaderboard.out_of AS leaderboard_out_of
FROM pb
INNER JOIN chart ON chart.id = pb.chart_id
INNER JOIN song ON song.id = chart.song_id
INNER JOIN chart_leaderboard ON chart_leaderboard.row_id = pb.row_id
INNER JOIN filtered f ON f.row_id = pb.row_id AND f.rn = 1
ORDER BY (pb.calculated_data::jsonb->>${sql.lit(ratingProp)})::double precision DESC NULLS LAST
LIMIT ${limit}
@@ -306,6 +306,82 @@ export const ActionSignatures = {
}),
output: z.object({}),
},
PATCH_UGPT_SETTINGS: {
input: z.object({
userID: z.number().int(),
game: z.string(),
playtype: z.string(),
preferences: z.any(),
}),
output: z.object({}),
},
UPDATE_UGPT_SHOWCASE: {
input: z.object({
userID: z.number().int(),
game: z.string(),
playtype: z.string(),
stats: z.array(z.any()),
}),
output: z.object({}),
},
UPDATE_SESSION: {
input: z.object({
sessionID: z.string(),
name: z.string().min(3).max(80).optional(),
desc: z.union([z.string().min(3).max(120), z.null()]).optional(),
highlight: z.boolean().optional(),
}),
output: z.object({}),
},
DELETE_SCORE: {
input: z.object({
id: z.string(),
blacklist: z.boolean().optional(),
}),
output: z.object({}),
},
DELETE_SESSION: {
input: z.object({
id: z.string(),
}),
output: z.object({}),
},
DELETE_IMPORT: {
input: z.object({
id: z.string(),
}),
output: z.object({}),
},
BMS_TABLE_SYNC: {
input: z.object({}),
output: z.object({}),
},
UGS_SNAPSHOT: {
input: z.object({}),
output: z.object({}),
},
BACKSYNC_BMS_PMS_SEEDS: {
input: z.object({}),
output: z.object({}),
},
UPDATE_BPI_DATA: {
input: z.object({}),
output: z.object({
chartsUpdated: z.number().int(),
}),
},
UPDATE_DP_TIERS: {
input: z.object({}),
output: z.object({
songsTouched: z.number().int(),
}),
},
UPDATE_SP12_DATA: {
input: z.object({}),
output: z.object({
chartsUpdated: z.number().int(),
}),
},
} satisfies Record<string, ActionSignature>;
export const AnonActionSignatures = {
@@ -0,0 +1,275 @@
import { GetRecentActivity } from "#lib/activity/activity";
import DB from "#services/pg/db";
import mockApi, { CloseServerConnection } from "#test-utils/mock-api";
import { seedUser } from "#test-utils/pg-fixtures";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import { afterAll, describe, expect, it } from "vitest";
afterAll(() => CloseServerConnection());
describe("GetRecentActivity (Postgres)", () => {
let counter = 0;
async function insertIidxSession(opts: {
id: string;
timeEndedMs: number;
timeStartedMs: number;
userId: number;
}) {
const isoStart = UnixMillisecondsToISO8601(opts.timeStartedMs);
const isoEnd = UnixMillisecondsToISO8601(opts.timeEndedMs);
await DB.insertInto("session")
.values({
id: opts.id,
user_id: opts.userId,
game: "iidx-sp",
name: "s",
description: null,
time_inserted: isoEnd,
time_started: isoStart,
time_ended: isoEnd,
calculated_data: JSON.stringify({}),
highlight: false,
})
.execute();
}
async function seedIidxScore(opts: {
chartId: string;
highlight: boolean;
scoreId: string;
songId: string;
timeAchievedMs: number | null;
timeAddedMs: number;
userId: number;
}) {
const n = ++counter;
await DB.insertInto("song")
.values({
id: opts.songId,
legacy_id: 7_000_000 + n,
game_group: "iidx",
title: "T",
artist: "A",
search_terms: [],
alt_titles: [],
data: JSON.stringify({}),
fts_document: "",
})
.execute();
await DB.insertInto("chart")
.values({
id: opts.chartId,
legacy_id: opts.chartId,
game: "iidx-sp",
song_id: opts.songId,
level: "10",
level_num: 10,
is_primary: true,
difficulty: "NORMAL",
versions: [],
data: JSON.stringify({}),
})
.execute();
await DB.insertInto("score")
.values({
id: opts.scoreId,
user_id: opts.userId,
chart_id: opts.chartId,
game: "iidx-sp",
session_id: null,
import_id: null,
data: JSON.stringify({}),
derived_data: JSON.stringify({}),
judgements: JSON.stringify({}),
calculated_data: JSON.stringify({}),
meta: JSON.stringify({}),
time_achieved:
opts.timeAchievedMs === null
? null
: UnixMillisecondsToISO8601(opts.timeAchievedMs),
time_added: UnixMillisecondsToISO8601(opts.timeAddedMs),
highlight: opts.highlight,
comment: null,
})
.execute();
}
it("returns at most N recent sessions ordered by time_started desc", async () => {
const { id: userId } = await seedUser({
username: `act_sess_${Date.now()}`,
});
const base = 10_000_000;
await insertIidxSession({
userId,
id: `act-s1-${base}`,
timeStartedMs: base + 1000,
timeEndedMs: base + 2000,
});
await insertIidxSession({
userId,
id: `act-s2-${base}`,
timeStartedMs: base + 3000,
timeEndedMs: base + 4000,
});
await insertIidxSession({
userId,
id: `act-s3-${base}`,
timeStartedMs: base + 5000,
timeEndedMs: base + 6000,
});
const result = await GetRecentActivity(
"iidx",
{ game: "iidx", playtype: "SP", userID: userId },
2,
null,
);
expect(result.recentSessions).toHaveLength(2);
expect(result.recentSessions[0]?.sessionID).toBe(`act-s3-${base}`);
expect(result.recentSessions[1]?.sessionID).toBe(`act-s2-${base}`);
});
it("returns only highlighted scores with time_achieved inside the session window", async () => {
const { id: userId } = await seedUser({
username: `act_hl_${Date.now()}`,
});
const base = 20_000_000;
const chartIn = `act-ch-in-${base}`;
const chartOut = `act-ch-out-${base}`;
await insertIidxSession({
userId,
id: `act-win-old-${base}`,
timeStartedMs: base,
timeEndedMs: base + 1000,
});
await insertIidxSession({
userId,
id: `act-win-new-${base}`,
timeStartedMs: base + 10_000,
timeEndedMs: base + 11_000,
});
await seedIidxScore({
userId,
scoreId: `sc-in-${base}`,
songId: `act-song-in-${base}`,
chartId: chartIn,
highlight: true,
timeAchievedMs: base + 5000,
timeAddedMs: base + 5000,
});
await seedIidxScore({
userId,
scoreId: `sc-out-${base}`,
songId: `act-song-out-${base}`,
chartId: chartOut,
highlight: true,
timeAchievedMs: base - 5000,
timeAddedMs: base - 5000,
});
const result = await GetRecentActivity(
"iidx",
{ game: "iidx", playtype: "SP", userID: userId },
2,
null,
);
const ids = result.recentlyHighlightedScores.map((s) => s.scoreID);
expect(ids).toContain(`sc-in-${base}`);
expect(ids).not.toContain(`sc-out-${base}`);
});
it("maps class_prev_value empty string to classOldValue null", async () => {
const { id: userId } = await seedUser({
username: `act_cls_${Date.now()}`,
});
const base = 30_000_000;
await insertIidxSession({
userId,
id: `act-cls-s-${base}`,
timeStartedMs: base,
timeEndedMs: base + 1000,
});
await DB.insertInto("class_achievement")
.values({
game: "iidx-sp",
user_id: userId,
class_set: "dan",
class_prev_value: "",
class_value: "DAN_1",
timestamp: UnixMillisecondsToISO8601(base + 500),
})
.execute();
const result = await GetRecentActivity(
"iidx",
{ game: "iidx", playtype: "SP", userID: userId },
10,
null,
);
expect(result.achievedClasses).toHaveLength(1);
expect(result.achievedClasses[0]?.classOldValue).toBe(null);
expect(result.achievedClasses[0]?.classValue).toBe("DAN_1");
});
it("returns no sessions, scores, or class rows when userID $in is empty", async () => {
const result = await GetRecentActivity(
"iidx",
{ game: "iidx", playtype: "SP", userID: { $in: [] } },
10,
null,
);
expect(result.recentSessions).toHaveLength(0);
expect(result.recentlyHighlightedScores).toHaveLength(0);
expect(result.achievedClasses).toHaveLength(0);
expect(result.goalSubs).toHaveLength(0);
expect(result.questSubs).toHaveLength(0);
});
});
describe("GET /api/v1/games/:game/:playtype/activity (smoke)", () => {
it("returns success with seeded iidx SP activity", async () => {
const { id: userId } = await seedUser({
username: `act_api_${Date.now()}`,
});
const base = 40_000_000;
await DB.insertInto("session")
.values({
id: `act-api-s-${base}`,
user_id: userId,
game: "iidx-sp",
name: "api",
description: null,
time_inserted: UnixMillisecondsToISO8601(base + 1000),
time_started: UnixMillisecondsToISO8601(base),
time_ended: UnixMillisecondsToISO8601(base + 1000),
calculated_data: JSON.stringify({}),
highlight: false,
})
.execute();
const res = await mockApi.get("/api/v1/games/iidx/SP/activity");
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.body.recentSessions).toEqual(
expect.arrayContaining([
expect.objectContaining({ sessionID: `act-api-s-${base}` }),
]),
);
});
});
+153 -67
View File
@@ -1,34 +1,99 @@
import type { Request, Response } from "express-serve-static-core";
import type { FilterQuery } from "mongodb";
import type {
GameGroup,
GPTString,
MONGO_ChartDocument,
MONGO_ClassAchievementDocument,
MONGO_GoalDocument,
MONGO_GoalSubscriptionDocument,
MONGO_QuestDocument,
MONGO_QuestSubscriptionDocument,
MONGO_ScoreDocument,
MONGO_SessionDocument,
MONGO_SongDocument,
MONGO_UserDocument,
Playtype,
} from "tachi-common";
import type { Game } from "tachi-db";
import MONGODB_KILL from "#services/mongo/db";
import {
SELECT_CLASS_ACHIEVEMENT_DOCUMENT,
ToClassAchievementDocument,
} from "#lib/db-formats/class-achievement";
import { type ScoreDocumentJoinRow, ToScoreDocument } from "#lib/db-formats/score";
import { SELECT_SESSION_DOCUMENT, ToSessionDocument } from "#lib/db-formats/session";
import DB from "#services/pg/db";
import {
GetRecentlyAchievedGoals,
GetRecentlyAchievedQuests,
GetRelevantSongsAndCharts,
} from "#utils/db";
import { DedupeArr } from "#utils/misc";
import { scoreDocumentJoin } from "#utils/queries/scores";
import { GetScoreIdsGroupedBySessionId } from "#utils/queries/sessions";
import { GetGPT } from "#utils/req-tachi-data";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import { GetUsersWithIDs } from "#utils/user";
import { sql } from "kysely";
import {
type GameGroup,
GamePTToV3,
type GPTString,
type integer,
type MONGO_ChartDocument,
type MONGO_ClassAchievementDocument,
type MONGO_GoalDocument,
type MONGO_GoalSubscriptionDocument,
type MONGO_QuestDocument,
type MONGO_QuestSubscriptionDocument,
type MONGO_ScoreDocument,
type MONGO_SessionDocument,
type MONGO_SongDocument,
type MONGO_UserDocument,
type Playtype,
} from "tachi-common";
export type ActivityConstraint = FilterQuery<
MONGO_ClassAchievementDocument & MONGO_ScoreDocument & MONGO_SessionDocument
>;
export type ActivityConstraint = {
game: GameGroup;
playtype: Playtype;
userID?: integer | { $in: Array<integer> };
};
/** Kysely dynamic column refs — same pattern as `whereUserIdOnGoalSub` in `#utils/db`. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- OperandExpressionFactory varies by query (session / join / class_achievement).
type ActivityWhereEb = any;
function whereActivityUserId(
userID: ActivityConstraint["userID"],
column: "score.user_id" | "user_id",
) {
if (userID === undefined) {
return undefined;
}
if (typeof userID === "number") {
return (eb: ActivityWhereEb) => eb(column, "=", userID);
}
if (
userID &&
typeof userID === "object" &&
"$in" in userID &&
Array.isArray(userID.$in)
) {
const ids = userID.$in;
if (ids.length === 0) {
return () => sql<boolean>`false`;
}
return (eb: ActivityWhereEb) => eb(column, "in", ids);
}
throw new Error("Unsupported userID filter for activity.");
}
function whereMsRangeOnColumn(
column: "class_achievement.timestamp" | "score.time_achieved",
earliestMs: number,
startFrom: number | null,
) {
return (eb: ActivityWhereEb) => {
if (startFrom !== null) {
return eb.and([
eb(column, ">=", UnixMillisecondsToISO8601(earliestMs)),
eb(column, "<", UnixMillisecondsToISO8601(startFrom)),
]);
}
return eb(column, ">=", UnixMillisecondsToISO8601(earliestMs));
};
}
/**
* Retrieves recent activity for this group of users for this GPT.
@@ -70,26 +135,41 @@ export async function GetRecentActivity(
songs: Array<MONGO_SongDocument>;
users: Array<MONGO_UserDocument>;
}> {
const baseQuery = query;
const v3Game = GamePTToV3(query.game, query.playtype) as Game;
const sessionUserWhere = whereActivityUserId(query.userID, "user_id");
const scoreUserWhere = whereActivityUserId(query.userID, "score.user_id");
const classUserWhere = whereActivityUserId(query.userID, "user_id");
const initialSessionQuery = {
...baseQuery,
let sessionQ = DB.selectFrom("session")
.select(SELECT_SESSION_DOCUMENT)
.where("session.game", "=", v3Game);
// start from anytime if startFrom is omitted, otherwise, cap at the start.
timeStarted: { $lt: startFrom === null ? Infinity : startFrom },
};
if (sessionUserWhere) {
sessionQ = sessionQ.where(sessionUserWhere);
}
const recentSessions = await MONGODB_KILL.sessions.find(initialSessionQuery, {
sort: {
timeStarted: -1,
},
limit: sessions,
});
if (startFrom !== null) {
sessionQ = sessionQ.where(
"session.time_started",
"<",
UnixMillisecondsToISO8601(startFrom),
);
}
const sessionRows = await sessionQ
.orderBy("session.time_started", "desc")
.limit(sessions)
.execute();
const scoreMap =
sessionRows.length === 0
? new Map<string, Array<string>>()
: await GetScoreIdsGroupedBySessionId(sessionRows.map((r) => r.id));
const recentSessions = sessionRows.map((row) =>
ToSessionDocument(row, scoreMap.get(row.id) ?? []),
);
// find the earliest point in the sessions we just fetched.
// if we found no sessions, set this to now, which means we'll fetch no highlighted
// scores.
// (it's not possible to have no sessions *and* have scores with timestamps)
const earliestSession = recentSessions.at(-1)?.timeStarted ?? Date.now();
const timeConstraint =
@@ -99,42 +179,48 @@ export async function GetRecentActivity(
$gte: earliestSession,
};
const timeWhereAchieved = whereMsRangeOnColumn(
"class_achievement.timestamp",
earliestSession,
startFrom,
);
const timeWhereScore = whereMsRangeOnColumn("score.time_achieved", earliestSession, startFrom);
let classQ = DB.selectFrom("class_achievement")
.select(SELECT_CLASS_ACHIEVEMENT_DOCUMENT)
.where("class_achievement.game", "=", v3Game)
.where(timeWhereAchieved);
if (classUserWhere) {
classQ = classQ.where(classUserWhere);
}
let highlightQ = scoreDocumentJoin()
.where("score.game", "=", v3Game)
.where("score.highlight", "=", true)
.where(timeWhereScore);
if (scoreUserWhere) {
highlightQ = highlightQ.where(scoreUserWhere);
}
const [
// yeah, i hate doing stuff like this in parallel. It's really easy to get
// wrong, but this code is hit so frequently that I'd be insane not to take
// the performance win of parallelism in queries.
achievedClasses,
recentlyHighlightedScores,
classRows,
highlightRows,
{ goals, goalSubs },
{ quests, questSubs },
] = await Promise.all([
MONGODB_KILL["class-achievements"].find(
{
...baseQuery,
timeAchieved: timeConstraint,
},
{
sort: {
timeAchieved: -1,
},
},
),
MONGODB_KILL.scores.find(
{
...baseQuery,
highlight: true,
timeAchieved: timeConstraint,
},
{
sort: {
timeAchieved: -1,
},
},
),
GetRecentlyAchievedGoals({ ...baseQuery, timeAchieved: timeConstraint }, 0),
GetRecentlyAchievedQuests({ ...baseQuery, timeAchieved: timeConstraint }, 0),
classQ.orderBy("class_achievement.timestamp", "desc").execute(),
highlightQ.orderBy(sql`score.time_achieved desc nulls last`).execute(),
GetRecentlyAchievedGoals({ ...query, timeAchieved: timeConstraint }, 0),
GetRecentlyAchievedQuests({ ...query, timeAchieved: timeConstraint }, 0),
]);
const achievedClasses = classRows.map(ToClassAchievementDocument);
const recentlyHighlightedScores = highlightRows.map((row) =>
ToScoreDocument(row as ScoreDocumentJoinRow),
);
const { songs, charts } = await GetRelevantSongsAndCharts(recentlyHighlightedScores, game);
const userIDs = DedupeArr([
@@ -167,7 +253,7 @@ export async function GetRecentActivity(
* @param gpts - An array of Game+Playtype combos to fetch from.
*/
export async function GetRecentActivityForMultipleGames(
gpts: Array<{ game: GameGroup; playtype: Playtype; query: ActivityConstraint }>,
gpts: Array<{ game: GameGroup; playtype: Playtype }>,
sessions = 30,
startFrom: number | null = null,
) {

Some files were not shown because too many files have changed in this diff Show More