game_stats -> game_profiles

This commit is contained in:
zk
2026-03-28 13:15:03 +00:00
parent 180f072ac9
commit 6b3a4fa2f5
11 changed files with 157 additions and 94 deletions
+2 -18
View File
@@ -14,6 +14,8 @@ db-reset POSTGRES_DB=DEFAULT_DB:
tachidb database create
tachidb migrate run
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 \
@@ -21,24 +23,6 @@ db-migrate-status POSTGRES_DB=DEFAULT_DB:
FROM \"_migration\" \
ORDER BY version"
# Create (or recreate) the bot test template database with all migrations applied.
# Workers clone from this template rather than re-running migrations each time.
bot-db-test-template-reset:
#!/bin/bash
export POSTGRES_URL="{{POSTGRES_URL}}/tachi_bot_test_template"
tachidb database drop || true
tachidb database create
tachidb migrate run
# Create (or recreate) the server test template database with all migrations applied.
# Workers clone from this template rather than re-running migrations each time.
server-db-test-template-reset:
#!/bin/bash
export POSTGRES_URL="{{POSTGRES_URL}}/tachi_server_test_template"
tachidb database drop || true
tachidb database create
tachidb migrate run
# Load the seeds into the database.
db-load-seeds POSTGRES_DB=DEFAULT_DB:
cd typescript/server && POSTGRES_URL="{{POSTGRES_URL}}/{{POSTGRES_DB}}" \
+22
View File
@@ -70,3 +70,25 @@ test-typescript FILTER="*":
# Run all parity suites, or a single one if a name is given.
test-parity suite="":
TACHI_SERVER=https://127.0.0.1:8080 bun vitest run {{ if suite != "" { "tests/" + suite + ".test.ts" } else { "" } }}
# ==> Orchestration of parallel db tests
# Create (or recreate) the bot test template database with all migrations applied.
# Workers clone from this template rather than re-running migrations each time.
bot-db-test-template-reset:
#!/bin/bash
export POSTGRES_URL="{{POSTGRES_URL}}/tachi_bot_test_template"
tachidb database drop || true
tachidb database create
tachidb migrate run
# Create (or recreate) the server test template database with all migrations applied.
# Workers clone from this template rather than re-running migrations each time.
server-db-test-template-reset:
#!/bin/bash
export POSTGRES_URL="{{POSTGRES_URL}}/tachi_server_test_template"
tachidb database drop || true
tachidb database create
tachidb migrate run
# <== End of parallel db test justscripts
+1 -1
View File
@@ -549,7 +549,7 @@ CREATE TABLE "game_settings_showcase" (
data JSONB NOT NULL
);
CREATE TABLE "game_stats" (
CREATE TABLE "game_profile" (
user_id BIGINT REFERENCES account(id) NOT NULL,
game GAME NOT NULL,
+83 -52
View File
@@ -6,7 +6,7 @@ import { HumaniseError } from "#util/humanise-error";
import { HistorySafeGoBack } from "#util/misc";
import { useFormik } from "formik";
import React, { useContext, useRef, useState } from "react";
import { Button, Form } from "react-bootstrap";
import { Alert, Button, Form } from "react-bootstrap";
import ReCAPTCHA from "react-google-recaptcha";
import toast from "react-hot-toast";
import { Link, useHistory } from "react-router-dom";
@@ -21,68 +21,99 @@ export default function LoginPage() {
const recaptchaRef = useRef<any>(null);
const performLogin = async (values: {
username: string;
"!password": string;
captcha: string;
}) => {
setErr("");
const rj = await APIFetchV1(
"/auth/login",
{
method: "POST",
body: JSON.stringify({
username: values.username.trim(),
"!password": values["!password"],
captcha: values.captcha,
}),
headers: {
"Content-Type": "application/json",
},
},
false,
false,
);
if (recaptchaRef.current) {
recaptchaRef.current.reset();
}
if (!rj.success) {
setErr(HumaniseError(rj.description));
return;
}
const userRJ = await APIFetchV1<UserDocument>("/users/me");
if (userRJ.statusCode === 403) {
setErr("You are banned.");
return;
}
if (!userRJ.success) {
console.error("Error retrieving own user?");
setErr("An internal server error has occurred.");
return;
}
toast.success("Logged in!");
setTimeout(() => {
setUser(userRJ.body);
localStorage.setItem("isLoggedIn", "true");
HistorySafeGoBack(history);
}, 500);
};
const formik = useFormik({
initialValues: {
username: "",
"!password": "",
captcha: "",
},
onSubmit: async (values) => {
setErr("");
const rj = await APIFetchV1(
"/auth/login",
{
method: "POST",
body: JSON.stringify({
username: values.username.trim(),
"!password": values["!password"],
captcha: values.captcha,
}),
headers: {
"Content-Type": "application/json",
},
},
false,
false,
);
if (recaptchaRef.current) {
recaptchaRef.current.reset();
}
if (!rj.success) {
setErr(HumaniseError(rj.description));
return;
}
const userRJ = await APIFetchV1<UserDocument>("/users/me");
if (userRJ.statusCode === 403) {
setErr("You are banned.");
return;
}
if (!userRJ.success) {
console.error("Error retrieving own user?");
setErr("An internal server error has occurred.");
return;
}
toast.success("Logged in!");
setTimeout(() => {
setUser(userRJ.body);
localStorage.setItem("isLoggedIn", "true");
HistorySafeGoBack(history);
}, 500);
},
onSubmit: performLogin,
});
return (
<LoginPageLayout description={<Description />} heading="Log In">
<Form className="d-flex flex-column gap-4 w-100" onSubmit={formik.handleSubmit}>
{import.meta.env.VITE_IS_LOCAL_DEV && (
<Alert variant="info" className="mb-0">
<div className="d-flex flex-column gap-2">
<span>
You are in local development mode. You can login as the admin
account.
</span>
<Button
className="align-self-start"
onClick={() =>
void performLogin({
username: "admin",
"!password": "password",
captcha: formik.values.captcha,
})
}
size="sm"
type="button"
variant="outline-primary"
>
Log in as admin
</Button>
</div>
</Alert>
)}
<Form.Group>
<Form.Label>Username</Form.Label>
<Form.Control
+1 -1
View File
@@ -18,13 +18,13 @@ export { type default as ImportLockTable, type ImportLock, type NewImportLock, t
export { type score_blacklist_row_id, type default as ScoreBlacklistTable, type ScoreBlacklist, type NewScoreBlacklist, type ScoreBlacklistUpdate } from './public/ScoreBlacklist';
export { type default as PrivDiscordUserMapTable, type PrivDiscordUserMap, type NewPrivDiscordUserMap, type PrivDiscordUserMapUpdate } from './public/PrivDiscordUserMap';
export { type default as TableFolderTable, type TableFolder, type NewTableFolder, type TableFolderUpdate } from './public/TableFolder';
export { type game_stats_game, type default as GameStatsTable, type GameStats, type NewGameStats, type GameStatsUpdate } from './public/GameStats';
export { type priv_oauth2_auth_token_token, type default as PrivOauth2AuthTokenTable, type PrivOauth2AuthToken, type NewPrivOauth2AuthToken, type PrivOauth2AuthTokenUpdate } from './public/PrivOauth2AuthToken';
export { type import_session_row_id, type default as ImportSessionTable, type ImportSession, type NewImportSession, type ImportSessionUpdate } from './public/ImportSession';
export { type priv_svc_fer_card_card_id, type default as PrivSvcFerCardTable, type PrivSvcFerCard, type NewPrivSvcFerCard, type PrivSvcFerCardUpdate } from './public/PrivSvcFerCard';
export { type default as PbComposedFromTable, type PbComposedFrom, type NewPbComposedFrom, type PbComposedFromUpdate } from './public/PbComposedFrom';
export { type bms_course_lookup_md5sums, type default as BmsCourseLookupTable, type BmsCourseLookup, type NewBmsCourseLookup, type BmsCourseLookupUpdate } from './public/BmsCourseLookup';
export { type song_search_term_search_term, type default as SongSearchTermTable, type SongSearchTerm, type NewSongSearchTerm, type SongSearchTermUpdate } from './public/SongSearchTerm';
export { type game_profile_game, type default as GameProfileTable, type GameProfile, type NewGameProfile, type GameProfileUpdate } from './public/GameProfile';
export { type import_game_game, type default as ImportGameTable, type ImportGame, type NewImportGame, type ImportGameUpdate } from './public/ImportGame';
export { type import_goal_row_id, type default as ImportGoalTable, type ImportGoal, type NewImportGoal, type ImportGoalUpdate } from './public/ImportGoal';
export { type default as OrphanChartUserTable, type OrphanChartUser, type NewOrphanChartUser, type OrphanChartUserUpdate } from './public/OrphanChartUser';
@@ -0,0 +1,26 @@
// @generated
// This file is automatically generated by Kanel. Do not modify manually.
import type { default as Game } from './Game';
import type { account_id } from './Account';
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
/** Identifier type for public.game_profile */
export type game_profile_game = Game;
/** Represents the table public.game_profile */
export default interface GameProfileTable {
user_id: ColumnType<account_id, account_id, account_id>;
game: ColumnType<game_profile_game, game_profile_game, game_profile_game>;
ratings: ColumnType<unknown, unknown, unknown>;
classes: ColumnType<unknown, unknown, unknown>;
}
export type GameProfile = Selectable<GameProfileTable>;
export type NewGameProfile = Insertable<GameProfileTable>;
export type GameProfileUpdate = Updateable<GameProfileTable>;
@@ -18,13 +18,13 @@ import type { default as ImportLockTable } from './ImportLock';
import type { default as ScoreBlacklistTable } from './ScoreBlacklist';
import type { default as PrivDiscordUserMapTable } from './PrivDiscordUserMap';
import type { default as TableFolderTable } from './TableFolder';
import type { default as GameStatsTable } from './GameStats';
import type { default as PrivOauth2AuthTokenTable } from './PrivOauth2AuthToken';
import type { default as ImportSessionTable } from './ImportSession';
import type { default as PrivSvcFerCardTable } from './PrivSvcFerCard';
import type { default as PbComposedFromTable } from './PbComposedFrom';
import type { default as BmsCourseLookupTable } from './BmsCourseLookup';
import type { default as SongSearchTermTable } from './SongSearchTerm';
import type { default as GameProfileTable } from './GameProfile';
import type { default as ImportGameTable } from './ImportGame';
import type { default as ImportGoalTable } from './ImportGoal';
import type { default as OrphanChartUserTable } from './OrphanChartUser';
@@ -104,8 +104,6 @@ export default interface PublicSchema {
table_folder: TableFolderTable;
game_stats: GameStatsTable;
priv_oauth2_auth_token: PrivOauth2AuthTokenTable;
import_session: ImportSessionTable;
@@ -118,6 +116,8 @@ export default interface PublicSchema {
song_search_term: SongSearchTermTable;
game_profile: GameProfileTable;
import_game: ImportGameTable;
import_goal: ImportGoalTable;
@@ -8,15 +8,15 @@ import {
} from "tachi-common";
import { type Database } from "tachi-db";
export const SELECT_GAME_STATS = [
"game_stats.user_id",
"game_stats.game",
"game_stats.ratings",
"game_stats.classes",
export const SELECT_GAME_PROFILE = [
"game_profile.user_id",
"game_profile.game",
"game_profile.ratings",
"game_profile.classes",
] as const;
export function ToGameStatsDocument(
row: Selection<Database, "game_stats", (typeof SELECT_GAME_STATS)[number]>,
row: Selection<Database, "game_profile", (typeof SELECT_GAME_PROFILE)[number]>,
): UserGameStats {
const { game, playtype } = V3ToGamePT(row.game);
@@ -6,7 +6,7 @@ import { ACTION_ChangeUsername } from "#actions/change-username.js";
import { ACTION_UpdateUser } from "#actions/update-user.js";
import { GetRecentActivity } from "#lib/activity/activity";
import { ONE_MONTH } from "#lib/constants/time";
import { SELECT_GAME_STATS, ToGameStatsDocument } from "#lib/db-formats/game-stats.js";
import { SELECT_GAME_PROFILE, ToGameStatsDocument } from "#lib/db-formats/game-profiles.js";
import { log } from "#lib/log/log";
import { GetRivalIDs } from "#lib/rivals/rivals";
import prValidate from "#server/middleware/prudence-validate";
@@ -153,8 +153,8 @@ router.get("/game-stats", async (req, res) => {
{
__rankingData?: Record<AnyProfileRatingAlg, { outOf: number; ranking: number }>;
} & UserGameStats
> = await DB.selectFrom("game_stats")
.select(SELECT_GAME_STATS)
> = await DB.selectFrom("game_profile")
.select(SELECT_GAME_PROFILE)
.where("user_id", "=", user.id)
.execute()
.then((res) => res.map(ToGameStatsDocument));
@@ -567,12 +567,12 @@ router.get(
const user = GetUser(req);
const gameStatRows = await DB.selectFrom("game_stats")
.select(SELECT_GAME_STATS)
const gameProfileRows = await DB.selectFrom("game_profile")
.select(SELECT_GAME_PROFILE)
.where("user_id", "=", user.id)
.execute();
const gpts = gameStatRows.map(ToGameStatsDocument);
const gpts = gameProfileRows.map(ToGameStatsDocument);
const data: Partial<Record<GPTString, unknown>> = {};
+6 -6
View File
@@ -11,7 +11,7 @@ async function seedGameStats(
ktLampRating: number | null,
game: "iidx-sp" = "iidx-sp",
) {
await DB.insertInto("game_stats")
await DB.insertInto("game_profile")
.values({
user_id: userId,
game,
@@ -96,7 +96,7 @@ describe("GetUsersRankingAndOutOf", () => {
const user1 = await seedUser({ username: "bpi_high" });
const user2 = await seedUser({ username: "bpi_low" });
await DB.insertInto("game_stats")
await DB.insertInto("game_profile")
.values({
user_id: user1.id,
game: "iidx-sp",
@@ -104,7 +104,7 @@ describe("GetUsersRankingAndOutOf", () => {
classes: JSON.stringify({}),
})
.execute();
await DB.insertInto("game_stats")
await DB.insertInto("game_profile")
.values({
user_id: user2.id,
game: "iidx-sp",
@@ -139,7 +139,7 @@ describe("GetUsersRankingAndOutOf", () => {
const sdvxUser = await seedUser({ username: "sdvx_player" });
await seedGameStats(iidxUser.id, 10, "iidx-sp");
await DB.insertInto("game_stats")
await DB.insertInto("game_profile")
.values({
user_id: sdvxUser.id,
game: "sdvx",
@@ -162,7 +162,7 @@ describe("GetAllRankings", () => {
const user1 = await seedUser({ username: "top_player" });
const user2 = await seedUser({ username: "bot_player" });
await DB.insertInto("game_stats")
await DB.insertInto("game_profile")
.values({
user_id: user1.id,
game: "iidx-sp",
@@ -170,7 +170,7 @@ describe("GetAllRankings", () => {
classes: JSON.stringify({}),
})
.execute();
await DB.insertInto("game_stats")
await DB.insertInto("game_profile")
.values({
user_id: user2.id,
game: "iidx-sp",
+1 -1
View File
@@ -231,7 +231,7 @@ export async function GetUsersRankingAndOutOf(
const v3Game = GamePTToV3(stats.game, stats.playtype);
const userRating = stats.ratings[ratingAlg] ?? null;
const result = await DB.selectFrom("game_stats")
const result = await DB.selectFrom("game_profile")
.select([
(eb) => eb.fn.countAll().as("out_of"),
sql<number>`COUNT(*) FILTER (WHERE (ratings->>${ratingAlg})::numeric > ${userRating})`.as(