checkpoint

This commit is contained in:
zk
2026-04-21 20:03:02 +00:00
parent 2bc4c3a628
commit c157d0026a
26 changed files with 485 additions and 212 deletions
@@ -2,7 +2,12 @@ import { type BMSCourseWithRelated, type CellsRenderFN } from "#types/seeds";
import { StrSOV } from "#util/sorts";
import { type SearchFunctions } from "#util/ztable/search";
import React from "react";
import { FormatChart, GetBMSCourseIndex } from "tachi-common";
import {
type ChartDocument,
type ChartDocumentData,
FormatChart,
GetBMSCourseIndex,
} from "tachi-common";
import { type Header } from "../components/TachiTable";
@@ -40,7 +45,7 @@ export const SeedsBMSCourseLookupSearchFns: SearchFunctions<BMSCourseWithRelated
return `UNKNOWN CHART (${e})`;
}
return FormatChart(e.chart);
return FormatChart({ ...e.chart, song: e.song } as unknown as ChartDocument);
})
.join("\n"),
};
@@ -59,12 +64,17 @@ export const SeedsBMSCourseLookupCells: CellsRenderFN<BMSCourseWithRelated> = ({
{typeof e === "string" ? (
<span className="text-danger">UNKNOWN CHART</span>
) : (
<span>{FormatChart(e.chart)} </span>
<span>{FormatChart({ ...e.chart, song: e.song } as unknown as ChartDocument)} </span>
)}
{!compress && (
<div className="ms-auto" key={i}>
<code>{typeof e === "string" ? e : e.chart.data.hashMD5}</code>
<code>
{typeof e === "string"
? e
: (e.chart.data as ChartDocumentData["bms-7k"] | ChartDocumentData["bms-14k"])
.hashMD5}
</code>
</div>
)}
</div>
@@ -6,7 +6,7 @@ import { FlattenValue, StringifyKeyChain } from "#util/misc";
import { StrSOV } from "#util/sorts";
import { type SearchFunctions } from "#util/ztable/search";
import React from "react";
import { COLOUR_SET, type V3Game } from "tachi-common";
import { type ChartDocument, COLOUR_SET, type V3Game } from "tachi-common";
import DifficultyCell from "../cells/DifficultyCell";
import ObjCell from "../cells/ObjCell";
@@ -21,7 +21,7 @@ export function MakeSeedsChartsControls(game: V3Game): {
} {
return {
headers: [
ChartHeader(game, (k) => k),
ChartHeader(game, (k) => k as unknown as ChartDocument),
["Title", "Title", StrSOV((x) => x.__related.song?.title ?? "")],
["Data", "Data"],
],
@@ -31,7 +31,7 @@ export function MakeSeedsChartsControls(game: V3Game): {
songID: (x) => x.__related.song?.id ?? null,
searchTerms: (x) => x.__related.song?.searchTerms.join(", ") ?? null,
altTitles: (x) => x.__related.song?.altTitles.join(", ") ?? null,
difficulty: (x) => x.difficulty,
difficulty: (x) => String(x.difficulty),
level: (x) => x.levelNum,
data: (x) =>
FlattenValue(x.data)
@@ -40,14 +40,14 @@ export function MakeSeedsChartsControls(game: V3Game): {
},
Cells: ({ data }) => (
<>
<DifficultyCell chart={data} game={game} />
<DifficultyCell chart={data as unknown as ChartDocument} game={game} />
{/* if we have a song, render the title cell. Otherwise, this chart has no parent song, and is invalid. */}
{data.__related.song ? (
<TitleCell chart={data} game={game} song={data.__related.song} />
<TitleCell chart={data as unknown as ChartDocument} game={game} song={data.__related.song} />
) : (
<td style={{ backgroundColor: ChangeOpacity(COLOUR_SET.red, 0.3) }}>
INVALID SONG ({data.song.id})
INVALID SONG ({data.songID})
<QuickTooltip tooltipContent="This chart points to a songID that does not exist at the time of this commit. This is a fatal error, and this commit doesn't pass tests.">
<div>
<Icon type="info" />
@@ -4,7 +4,12 @@ import { type CellsRenderFN, type QuestWithRelated } from "#types/seeds";
import { StrSOV } from "#util/sorts";
import { type SearchFunctions } from "#util/ztable/search";
import React from "react";
import { FormatGame } from "tachi-common";
import {
type GameGroup,
type GoalDocument,
LEGACY_FormatGameGroupPT,
type LEGACY_Playtypes,
} from "tachi-common";
import { type Header } from "../components/TachiTable";
@@ -19,7 +24,11 @@ export const SeedsQuestSearchFns: SearchFunctions<QuestWithRelated> = {
name: (x) => x.name,
questID: (x) => x.questID,
game: (x) => x.game,
gpt: (x) => FormatGame(x.game),
gpt: (x) =>
LEGACY_FormatGameGroupPT(
x.game as GameGroup,
x.playtype as LEGACY_Playtypes[typeof x.game],
),
goals: (x) =>
Object.values(x.__related.goals)
.map((e) => e!.name)
@@ -38,7 +47,12 @@ export const SeedsQuestCells: CellsRenderFN<QuestWithRelated> = ({
<td>
<strong>{data.name}</strong>
</td>
<td>{FormatGame(data.game)}</td>
<td>
{LEGACY_FormatGameGroupPT(
data.game as GameGroup,
data.playtype as LEGACY_Playtypes[typeof data.game],
)}
</td>
<td>
<div style={{ maxHeight: "200px", overflowY: "auto" }}>
{data.questData.map((section) => (
@@ -51,7 +65,7 @@ export const SeedsQuestCells: CellsRenderFN<QuestWithRelated> = ({
if (goal) {
return (
<div className="text-start" key={ref.goalID}>
<InnerQuestSectionGoal goal={goal} />
<InnerQuestSectionGoal goal={goal as GoalDocument} />
{ref.note && <Muted>{ref.note}</Muted>}
</div>
);
@@ -3,7 +3,11 @@ import { type CellsRenderFN, type QuestlineWithRelated } from "#types/seeds";
import { StrSOV } from "#util/sorts";
import { type SearchFunctions } from "#util/ztable/search";
import React from "react";
import { FormatGame } from "tachi-common";
import {
type GameGroup,
LEGACY_FormatGameGroupPT,
type LEGACY_Playtypes,
} from "tachi-common";
import { type Header } from "../components/TachiTable";
@@ -19,7 +23,11 @@ export const SeedsQuestlineSearchFns: SearchFunctions<QuestlineWithRelated> = {
questlineID: (x) => x.questlineID,
desc: (x) => x.desc,
game: (x) => x.game,
gpt: (x) => FormatGame(x.game),
gpt: (x) =>
LEGACY_FormatGameGroupPT(
x.game as GameGroup,
x.playtype as LEGACY_Playtypes[typeof x.game],
),
quests: (x) =>
Object.values(x.__related.quests)
.filter((e) => e !== undefined)
@@ -37,7 +45,12 @@ export const SeedsQuestlineCells: CellsRenderFN<QuestlineWithRelated> = ({ data
<br />
<Muted>{data.desc}</Muted>
</td>
<td>{FormatGame(data.game)}</td>
<td>
{LEGACY_FormatGameGroupPT(
data.game as GameGroup,
data.playtype as LEGACY_Playtypes[typeof data.game],
)}
</td>
<td className="text-start">
<div style={{ maxHeight: "200px", overflowY: "auto" }}>
{data.quests.map((e) => {
@@ -47,7 +60,12 @@ export const SeedsQuestlineCells: CellsRenderFN<QuestlineWithRelated> = ({ data
<div key={e}>
{quest ? (
<span>
{quest.name} ({FormatGame(quest.game)})
{quest.name} (
{LEGACY_FormatGameGroupPT(
quest.game as GameGroup,
quest.playtype as LEGACY_Playtypes[typeof quest.game],
)}
)
</span>
) : (
<span className="text-danger">UNKNOWN QUEST {e}</span>
@@ -9,7 +9,7 @@ import { FormatGame } from "tachi-common";
import { type Header } from "../components/TachiTable";
export const SeedsTableHeaders: Header<TableWithRelated>[] = [
["ID", "ID", StrSOV((x) => x.tableID)],
["ID", "ID", StrSOV((x) => x.id)],
["Name", "Name", StrSOV((x) => x.title)],
["GPT", "GPT", StrSOV((x) => x.game)],
["Folders", "Folders", StrSOV((x) => x.title)],
@@ -17,7 +17,7 @@ export const SeedsTableHeaders: Header<TableWithRelated>[] = [
export const SeedsTableSearchFns: SearchFunctions<TableWithRelated> = {
title: (x) => x.title,
tableID: (x) => x.tableID,
tableID: (x) => x.id,
inactive: (x) => x.inactive,
default: (x) => x.default,
description: (x) => x.description,
@@ -33,7 +33,7 @@ export const SeedsTableSearchFns: SearchFunctions<TableWithRelated> = {
export const SeedsTableCells: CellsRenderFN<TableWithRelated> = ({ data }) => (
<>
<td>
<code>{data.tableID}</code>
<code>{data.id}</code>
</td>
<td>
<strong>{data.title}</strong>
+20 -19
View File
@@ -1,14 +1,15 @@
import { type JSONAttributeDiff } from "#util/misc";
import {
type BMSCourseDocument,
type ChartDocument,
type FolderDocument,
type GameGroup,
type GoalDocument,
type QuestDocument,
type QuestlineDocument,
type SEEDS_BMSCourseDocument,
type SEEDS_ChartDocument,
type SEEDS_FolderDocument,
type SEEDS_GoalDocument,
type SEEDS_QuestDocument,
type SEEDS_QuestlineDocument,
type SEEDS_SongDocument,
type SEEDS_TableDocument,
type SongDocument,
type TableDocument,
type V3Game,
} from "tachi-common";
@@ -23,46 +24,46 @@ export type BMSCourseWithRelated = {
entries: Array<
| string
| {
chart: ChartDocument<"bms-7k" | "bms-14k">;
chart: SEEDS_ChartDocument<"bms-7k" | "bms-14k">;
song: SongDocument<"bms">;
}
>;
};
} & BMSCourseDocument;
} & SEEDS_BMSCourseDocument;
export type TableWithRelated = {
__related: {
/** Keys match `tables.json` folder slug strings (per game). */
folders: {
[folderSlug: string]: FolderDocument | undefined;
[folderSlug: string]: SEEDS_FolderDocument | undefined;
};
};
} & TableDocument;
} & SEEDS_TableDocument;
export type QuestlineWithRelated = {
__related: {
quests: {
[questID: string]: QuestDocument | undefined;
[questID: string]: SEEDS_QuestDocument | undefined;
};
};
} & QuestlineDocument;
} & SEEDS_QuestlineDocument;
export type QuestWithRelated = {
__related: {
goals: {
[goalID: string]: GoalDocument | undefined;
[goalID: string]: SEEDS_GoalDocument | undefined;
};
};
} & QuestDocument;
} & SEEDS_QuestDocument;
export type ChartWithRelated<T extends V3Game = V3Game> = {
__related: {
song: SongDocument | undefined;
};
} & ChartDocument<T>;
} & SEEDS_ChartDocument<T>;
type SongSeedsWithRelated = {
[G in GameGroup as `songs-${G}.json`]: Array<SongDocument<G>>;
[G in GameGroup as `songs-${G}.json`]: Array<SEEDS_SongDocument<G>>;
};
type ChartSeedsWithRelated = {
@@ -78,9 +79,9 @@ export type DatabaseSeedsWithRelated = {
"bms-course-lookup.json": Array<BMSCourseWithRelated>;
// intentional: folders don't need to be joined with anything.
"folders.json": Array<FolderDocument>;
"folders.json": Array<SEEDS_FolderDocument>;
"goals.json": Array<GoalDocument>;
"goals.json": Array<SEEDS_GoalDocument>;
"questlines.json": Array<QuestlineWithRelated>;
"quests.json": Array<QuestWithRelated>;
"tables.json": Array<TableWithRelated>;
+10 -6
View File
@@ -4,9 +4,9 @@ import {
type GameGroup,
type GoalDocument,
type integer,
type QuestDocument,
type ScoreDocument,
type SessionScoreInfo,
type SEEDS_SongDocument,
type SongDocument,
type UserDocument,
type V3Game,
@@ -28,11 +28,13 @@ export function GetPBs(scoreInfo: SessionScoreInfo[]) {
});
}
export function CreateSongMap<G extends GameGroup = GameGroup>(songs: SongDocument<G>[]) {
export function CreateSongMap<G extends GameGroup = GameGroup>(
songs: Array<SongDocument<G> | SEEDS_SongDocument<G>>,
) {
const songMap = new Map<string, SongDocument<G>>();
for (const song of songs) {
songMap.set(song.id, song);
songMap.set(song.id, song as SongDocument<G>);
}
return songMap;
@@ -48,8 +50,8 @@ export function CreateUserMap(users: UserDocument[]) {
return userMap;
}
export function CreateGoalMap(goals: GoalDocument[]) {
const goalMap = new Map<string, GoalDocument>();
export function CreateGoalMap<G extends { goalID: string }>(goals: Array<G>) {
const goalMap = new Map<string, G>();
for (const goal of goals) {
goalMap.set(goal.goalID, goal);
@@ -93,7 +95,9 @@ export function CreateChartLink(chart: ChartDocument) {
}
// stolen from server
export function GetGoalIDsFromQuest(quest: QuestDocument) {
export function GetGoalIDsFromQuest(quest: {
questData: Array<{ goals: Array<{ goalID: string }> }>;
}) {
// this sucks - maybe a nicer way to do this, because nested
// maps are just ugly
return quest.questData.map((e) => e.goals.map((e) => e.goalID)).flat(1);
+2 -2
View File
@@ -544,8 +544,8 @@ export function clamp(a: number, low: number, up: number) {
return a;
}
export function CreateQuestMap(quests: Array<QuestDocument>) {
const map = new Map<string, QuestDocument>();
export function CreateQuestMap<Q extends { questID: string }>(quests: Array<Q>) {
const map = new Map<string, Q>();
for (const q of quests) {
map.set(q.questID, q);
+32 -33
View File
@@ -12,19 +12,20 @@ import {
} from "#types/seeds";
import {
type AllDatabaseSeeds,
type BMSCourseDocument,
type ChartDocument,
type ChartDocumentData,
computeFolderSlug,
CreateSongMap,
DatabaseSeedNames,
type FolderDocument,
type GameGroup,
type GoalDocument,
type QuestDocument,
type QuestlineDocument,
type SEEDS_BMSCourseDocument,
type SEEDS_ChartDocument,
type SEEDS_FolderDocument,
type SEEDS_GoalDocument,
type SEEDS_QuestDocument,
type SEEDS_QuestlineDocument,
type SEEDS_TableDocument,
type SeedFolderRow,
type SongDocument,
type TableDocument,
} from "tachi-common";
import { APIFetchV1 } from "./api";
@@ -211,10 +212,11 @@ function RelateBMSCourses(data: Partial<AllDatabaseSeeds>): BMSCourseWithRelated
}
const songMap = CreateSongMap(bmsSongs);
const chartMap = new Map<string, ChartDocument<"bms-7k" | "bms-14k">>();
const chartMap = new Map<string, SEEDS_ChartDocument<"bms-7k" | "bms-14k">>();
for (const chart of bmsCharts) {
chartMap.set(chart.data.hashMD5, chart);
const d = chart.data as ChartDocumentData["bms-7k"] | ChartDocumentData["bms-14k"];
chartMap.set(d.hashMD5, chart);
}
return base.map((course) => {
@@ -236,7 +238,7 @@ function RelateBMSCourses(data: Partial<AllDatabaseSeeds>): BMSCourseWithRelated
return {
chart: chartMap.get(e)!,
song: songMap.get(chartMap.get(e)!.song.id)!,
song: songMap.get(chartMap.get(e)!.songID)! as SongDocument<"bms">,
};
}),
},
@@ -253,17 +255,12 @@ function RelateTables(data: Partial<AllDatabaseSeeds>): TableWithRelated[] {
return [];
}
const folderById = new Map<string, FolderDocument>();
const folderByGameSlug = new Map<string, FolderDocument>();
const folderById = new Map<string, SEEDS_FolderDocument>();
const folderByGameSlug = new Map<string, SEEDS_FolderDocument>();
for (const folder of data["folders.json"] ?? []) {
const raw = folder as {
id?: string;
slug?: string;
versionFilter?: Array<string>;
where?: string;
} & FolderDocument;
const id = raw.folderID ?? raw.id;
const raw = folder;
const id = raw.id;
if (id !== undefined) {
folderById.set(id, folder);
@@ -340,10 +337,12 @@ function RelateQuests(data: Partial<AllDatabaseSeeds>): QuestWithRelated[] {
function RelateCharts(data: Partial<AllDatabaseSeeds>, file: string): ChartWithRelated[] {
const [_, game] = /charts-(.*?)\.json/u.exec(file)!;
// @ts-expect-error too lazy to fix this properly
const songs: SongDocument[] = data[`songs-${game}.json`];
// @ts-expect-error too lazy to fix this properly
const charts: ChartDocument[] = data[`charts-${game}.json`];
const songs = data[`songs-${game}.json` as keyof AllDatabaseSeeds] as
| Array<SongDocument>
| undefined;
const charts = data[`charts-${game}.json` as keyof AllDatabaseSeeds] as
| Array<SEEDS_ChartDocument>
| undefined;
if (!charts) {
return [];
@@ -358,7 +357,7 @@ function RelateCharts(data: Partial<AllDatabaseSeeds>, file: string): ChartWithR
return charts.map((e) => ({
...e,
__related: {
song: songMap.get(e.song.id),
song: songMap.get(e.songID),
},
}));
}
@@ -542,34 +541,34 @@ function GetUniqID<K extends keyof AllDatabaseSeeds>(collection: K, value: AllDa
if (collection.startsWith("songs-")) {
return (value as SongDocument).id.toString();
} else if (collection.startsWith("charts-")) {
return (value as ChartDocument).chartID;
return (value as SEEDS_ChartDocument).id;
}
const c = collection as NotSongsChartsSeeds;
switch (c) {
case "bms-course-lookup.json": {
const v = value as BMSCourseDocument;
const v = value as SEEDS_BMSCourseDocument;
return `${v.set}-${v.game}-${v.value}`;
}
case "folders.json": {
const v = value as { id?: string } & FolderDocument;
return v.folderID ?? v.id ?? "";
const v = value as SEEDS_FolderDocument;
return v.id;
}
case "tables.json": {
const v = value as TableDocument;
return v.tableID;
const v = value as SEEDS_TableDocument;
return v.id;
}
case "goals.json": {
const v = value as GoalDocument;
const v = value as SEEDS_GoalDocument;
return v.goalID;
}
case "quests.json": {
const v = value as QuestDocument;
const v = value as SEEDS_QuestDocument;
return v.questID;
}
case "questlines.json": {
const v = value as QuestlineDocument;
const v = value as SEEDS_QuestlineDocument;
return v.questlineID;
}
}
@@ -174,7 +174,7 @@ export const GAME_MAIMAI_DX_CONF = {
defaultProfileRatingAlg: "naiveRate",
difficulties: {
type: "FIXED",
type: "CHUGEKIMAI_STYLE",
order: [
"Basic",
"Advanced",
@@ -124,12 +124,8 @@ export const GAME_MAIMAI_CONF = {
defaultSessionRatingAlg: "rate",
defaultProfileRatingAlg: "naiveRate",
// Similar to CHUNITHM, this game has a dynamic set of difficulties,
// with a song able to have as many UTAGE difficulties as it likes.
// This will also not be implemented for the same reasons as CHUNITHM.
// Sorry!
difficulties: {
type: "FIXED",
type: "CHUGEKIMAI_STYLE",
order: ["Easy", "Basic", "Advanced", "Expert", "Master", "Re:Master"],
format: {
Easy: "ESY",
@@ -182,7 +182,7 @@ export const GAME_ONGEKI_CONF = {
defaultProfileRatingAlg: "naiveRating",
difficulties: {
type: "FIXED",
type: "CHUGEKIMAI_STYLE",
order: ["BASIC", "ADVANCED", "EXPERT", "MASTER", "LUNATIC"],
format: {
BASIC: "BAS",
+18 -19
View File
@@ -1,15 +1,14 @@
import type { GameGroup, V3Game } from "../types";
import type {
BMSCourseDocument,
ChartDocument,
FolderDocument,
GameGroup,
GoalDocument,
QuestDocument,
QuestlineDocument,
SongDocument,
TableDocument,
V3Game,
} from "../types";
SEEDS_BMSCourseDocument,
SEEDS_ChartDocument,
SEEDS_FolderDocument,
SEEDS_GoalDocument,
SEEDS_QuestDocument,
SEEDS_QuestlineDocument,
SEEDS_SongDocument,
SEEDS_TableDocument,
} from "../types/seeds-documents";
import { allSupportedGameGroups } from "../config/config";
@@ -17,20 +16,20 @@ import { allSupportedGameGroups } from "../config/config";
// note that TS won't let you do this multiple times within an object
// so, we have to join them ourselves. Ah well, not that bad.
type ChartDBSeeds = {
[TGame in V3Game as `charts-${TGame}.json`]: Array<ChartDocument<TGame>>;
[TGame in V3Game as `charts-${TGame}.json`]: Array<SEEDS_ChartDocument<TGame>>;
};
type SongDBSeeds = {
[G in GameGroup as `songs-${G}.json`]: Array<SongDocument<G>>;
[G in GameGroup as `songs-${G}.json`]: Array<SEEDS_SongDocument<G>>;
};
interface OtherDBSeeds {
"bms-course-lookup.json": Array<BMSCourseDocument>;
"folders.json": Array<FolderDocument>;
"goals.json": Array<GoalDocument>;
"questlines.json": Array<QuestlineDocument>;
"quests.json": Array<QuestDocument>;
"tables.json": Array<TableDocument>;
"bms-course-lookup.json": Array<SEEDS_BMSCourseDocument>;
"folders.json": Array<SEEDS_FolderDocument>;
"goals.json": Array<SEEDS_GoalDocument>;
"questlines.json": Array<SEEDS_QuestlineDocument>;
"quests.json": Array<SEEDS_QuestDocument>;
"tables.json": Array<SEEDS_TableDocument>;
}
export type AllDatabaseSeeds = ChartDBSeeds & OtherDBSeeds & SongDBSeeds;
+1
View File
@@ -31,3 +31,4 @@ export * from "./types/documents";
export * from "./types/game-config";
export * from "./types/import-types";
export * from "./types/notifications";
export * from "./types/seeds-documents";
@@ -123,10 +123,36 @@ export interface FixedDifficulties<Difficulty extends string> {
order: ReadonlyArray<Difficulty>;
/**
* For mobile view (and certain pages where things are generally smaller)
* how should we shorten these difficulty names?
* How should we format these difficulty names?
*/
format: Partial<Record<Difficulty, string>>;
default: Difficulty;
}
/**
* This is for chugekimai; where they have a "fixed" set of difficulties, and then a dynamic amount
* of difficulties for LUNATIC/WORLD'S END/UTAGE charts. This way, we can have the fixed set and
* still support the dynamic set of additional ones.
*
* This is a bit of a bodge. We don't have a nice way of handling this - but we also don't have a
* nice way of disambiguating score imports as usual - we can't let people import "i got xxx on
* the worlds end for yyy", because such a uniqueness relation *does not exist*. the difficulty name
* is not the unique bit - it is meaningless flavour like the dynamic diffnames.
*
* Originally, I called this "SEMI_FIXED", which is the sort of ZK quirkiness you've came to know at
* this point, but now there's no point, this is for chugekimai, and this makes it clear.
*/
export interface ChuGekiMaiDifficulties<Difficulty extends string> {
type: "CHUGEKIMAI_STYLE";
order: ReadonlyArray<Difficulty>;
/**
* How should we format these difficulty names?
*
* For example, in IIDX "ANOTHER" is generally shortened to "A".
* Dynamic ones (i.e. ones not in the Order set) are not formatted - they are printed
* exactly as-is.
*/
format: Partial<Record<Difficulty, string>>;
@@ -134,5 +160,6 @@ export interface FixedDifficulties<Difficulty extends string> {
}
export type DifficultyConfig<D extends string = string> =
| ChuGekiMaiDifficulties<D>
| DynamicDifficulties
| FixedDifficulties<D>;
@@ -241,6 +241,7 @@ export type LEGACY_GameToPlaytype = {
export type Difficulties = {
// If this game has fixed difficulties, infer what they are
// otherwise, difficulties are an arbitrary string
// n.b. still true even with the new chugekimai stuff
[TGame in V3Game]: (typeof GAME_CONFIGS)[TGame]["difficulties"] extends FixedDifficulties<
infer D
>
@@ -0,0 +1,253 @@
import z from "zod";
import type {
ChartDocumentData,
Difficulties,
GameGroup,
SongDocumentData,
V3Game,
Versions,
} from "./game-config";
import type { DifficultyConfig } from "./game-config-utils";
import {
ALL_GAMES,
allSupportedGameGroups,
GAME_GROUP_CONFIGS,
GetSpecificGameConfig,
} from "../config/config";
/** Tachi seed row id: one-letter prefix + 19 lowercase hex digits. */
export function SEEDS_TachiIdSchema(prefix: string) {
return z.string().regex(new RegExp(`^${prefix}[0-9a-f]{19}$`, "u"));
}
export const SEEDS_V3_GAME_SCHEMA = z.enum(ALL_GAMES as [V3Game, ...Array<V3Game>]);
export const SEEDS_V3_GAME_GROUP_SCHEMA = z.enum(
allSupportedGameGroups as [GameGroup, ...Array<GameGroup>],
);
function difficultyZodSchema<G extends V3Game>(game: G): z.ZodType<Difficulties[G]> {
const d = GetSpecificGameConfig(game).difficulties as DifficultyConfig;
if (d.type === "DYNAMIC") {
return z.string() as unknown as z.ZodType<Difficulties[G]>;
}
const order = d.order as ReadonlyArray<string>;
return z.enum(order as [string, ...Array<string>]) as unknown as z.ZodType<Difficulties[G]>;
}
function versionsZodSchema<G extends V3Game>(game: G): z.ZodType<Array<Versions[G]>> {
const versionKeys = Object.keys(GetSpecificGameConfig(game).versions);
return z.array(z.string()).superRefine((val, ctx) => {
if (versionKeys.length === 0) {
if (val.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "This game defines no chart versions; expected [].",
});
}
return;
}
for (const v of val) {
if (!versionKeys.includes(v)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid version ${v}`,
});
}
}
if (new Set(val).size !== val.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Versions array shouldn't contain the same version twice",
});
}
}) as unknown as z.ZodType<Array<Versions[G]>>;
}
function seedsChartDocumentSchemaForGame<G extends V3Game>(game: G) {
const chartData = GetSpecificGameConfig(game).chartData as unknown as z.ZodType<ChartDocumentData[G]>;
return z.strictObject({
data: chartData,
difficulty: difficultyZodSchema(game),
id: SEEDS_TachiIdSchema("C"),
isPrimary: z.boolean(),
legacyChartID: z.string(),
level: z.string(),
levelNum: z.number(),
songID: SEEDS_TachiIdSchema("S"),
versions: versionsZodSchema(game),
}) as unknown as z.ZodType<{
data: ChartDocumentData[G];
difficulty: Difficulties[G];
id: string;
isPrimary: boolean;
legacyChartID: string;
level: string;
levelNum: number;
songID: string;
versions: Array<Versions[G]>;
}>;
}
/** One schema per `charts-${game}.json` collection. */
export const SEEDS_CHART_DOCUMENT_SCHEMAS = Object.fromEntries(
ALL_GAMES.map((game) => [game, seedsChartDocumentSchemaForGame(game)]),
) as {
readonly [G in V3Game]: ReturnType<typeof seedsChartDocumentSchemaForGame>;
};
export type SEEDS_ChartDocument<TGame extends V3Game = V3Game> = z.infer<
(typeof SEEDS_CHART_DOCUMENT_SCHEMAS)[TGame]
>;
function seedsSongDocumentSchemaForGroup<G extends GameGroup>(group: G) {
const songData = GAME_GROUP_CONFIGS[group].songData as unknown as z.ZodType<SongDocumentData[G]>;
return z.strictObject({
altTitles: z.array(z.string()),
artist: z.string(),
data: songData,
id: SEEDS_TachiIdSchema("S"),
legacySongID: z.number(),
searchTerms: z.array(z.string()),
title: z.string(),
}) as unknown as z.ZodType<{
altTitles: Array<string>;
artist: string;
data: SongDocumentData[G];
id: string;
legacySongID: number;
searchTerms: Array<string>;
title: string;
}>;
}
/** One schema per `songs-${gameGroup}.json` collection. */
export const SEEDS_SONG_DOCUMENT_SCHEMAS = Object.fromEntries(
allSupportedGameGroups.map((group) => [group, seedsSongDocumentSchemaForGroup(group)]),
) as {
readonly [G in GameGroup]: ReturnType<typeof seedsSongDocumentSchemaForGroup>;
};
export type SEEDS_SongDocument<G extends GameGroup = GameGroup> = z.infer<
(typeof SEEDS_SONG_DOCUMENT_SCHEMAS)[G]
>;
export const SEEDS_BMS_COURSE_DOCUMENT_SCHEMA = z.strictObject({
game: z.enum(["bms-14k", "bms-7k", "pms-controller", "pms-keyboard"]),
md5sums: z.string(),
set: z.string(),
title: z.string(),
value: z.string(),
});
export type SEEDS_BMSCourseDocument = z.infer<typeof SEEDS_BMS_COURSE_DOCUMENT_SCHEMA>;
export const SEEDS_TABLE_DOCUMENT_SCHEMA = z.strictObject({
default: z.boolean(),
description: z.string(),
folders: z.array(z.string()),
game: SEEDS_V3_GAME_SCHEMA,
id: SEEDS_TachiIdSchema("T"),
inactive: z.boolean(),
legacyTableID: z.string(),
slug: z.string().optional(),
title: z.string(),
});
export type SEEDS_TableDocument = z.infer<typeof SEEDS_TABLE_DOCUMENT_SCHEMA>;
const seedsQuestGoalRefSchema = z.strictObject({
goalID: z.string(),
note: z.string().optional(),
});
const seedsQuestSectionSchema = z.strictObject({
desc: z.string().optional(),
goals: z.array(seedsQuestGoalRefSchema),
title: z.string(),
});
export const SEEDS_QUEST_DOCUMENT_SCHEMA = z.strictObject({
desc: z.string(),
game: SEEDS_V3_GAME_GROUP_SCHEMA,
name: z.string(),
playtype: z.string(),
questData: z.array(seedsQuestSectionSchema),
questID: z.string(),
});
export type SEEDS_QuestDocument = z.infer<typeof SEEDS_QUEST_DOCUMENT_SCHEMA>;
export const SEEDS_QUESTLINE_DOCUMENT_SCHEMA = z.strictObject({
desc: z.string(),
game: SEEDS_V3_GAME_GROUP_SCHEMA,
name: z.string(),
playtype: z.string(),
questlineID: z.string(),
quests: z.array(z.string()),
});
export type SEEDS_QuestlineDocument = z.infer<typeof SEEDS_QUESTLINE_DOCUMENT_SCHEMA>;
/** Row shape in `folders.json` seed collections. */
export const SEEDS_FOLDER_DOCUMENT_SCHEMA = z.strictObject({
game: SEEDS_V3_GAME_SCHEMA,
id: SEEDS_TachiIdSchema("F"),
inactive: z.boolean(),
legacyFolderID: z.string(),
searchTerms: z.array(z.string()),
slug: z.string(),
title: z.string(),
versionFilter: z.array(z.string()).optional(),
where: z.string(),
});
export type SEEDS_FolderDocument = z.infer<typeof SEEDS_FOLDER_DOCUMENT_SCHEMA>;
const SEEDS_GOAL_CRITERIA_SCHEMA = z.union([
z.strictObject({
countNum: z.number(),
key: z.string(),
mode: z.union([z.literal("absolute"), z.literal("proportion")]),
value: z.number(),
}),
z.strictObject({
key: z.string(),
mode: z.literal("single"),
value: z.number(),
}),
]);
const SEEDS_GOAL_CHARTS_SCHEMA = z.discriminatedUnion("type", [
z.strictObject({
data: z.string(),
folderSlug: z.string().optional(),
type: z.literal("folder"),
}),
z.strictObject({
data: z.array(z.string()),
type: z.literal("multi"),
}),
z.strictObject({
data: z.string(),
type: z.literal("single"),
}),
]);
export const SEEDS_GOAL_DOCUMENT_SCHEMA = z.strictObject({
charts: SEEDS_GOAL_CHARTS_SCHEMA,
criteria: SEEDS_GOAL_CRITERIA_SCHEMA,
game: SEEDS_V3_GAME_GROUP_SCHEMA,
goalID: z.string(),
name: z.string(),
playtype: z.string(),
});
export type SEEDS_GoalDocument = z.infer<typeof SEEDS_GOAL_DOCUMENT_SCHEMA>;
@@ -0,0 +1 @@
export * from "./seeds-documents-zod";
+7 -3
View File
@@ -10,6 +10,8 @@ import type {
GamesForGroup,
integer,
LEGACY_Playtypes,
SEEDS_BMSCourseDocument,
SEEDS_SongDocument,
SongDocument,
V3Game,
} from "../types";
@@ -357,11 +359,13 @@ export function GetCloserGradeDelta<G extends string>(
return lower;
}
export function CreateSongMap<G extends GameGroup = GameGroup>(songs: Array<SongDocument<G>>) {
export function CreateSongMap<G extends GameGroup = GameGroup>(
songs: Array<SEEDS_SongDocument<G> | SongDocument<G>>,
) {
const songMap = new Map<string, SongDocument<G>>();
for (const song of songs) {
songMap.set(song.id, song);
songMap.set(song.id, song as SongDocument<G>);
}
return songMap;
@@ -391,7 +395,7 @@ export function FormatPrError(err: PrudenceError, foreword = "Error"): string {
return `${foreword}: ${err.keychain} | ${err.message}${receivedText}.`;
}
export function GetBMSCourseIndex(course: BMSCourseDocument) {
export function GetBMSCourseIndex(course: BMSCourseDocument | SEEDS_BMSCourseDocument) {
const gameConfig = GetGameConfig(course.game);
const cls = gameConfig.classes[course.set as keyof typeof gameConfig.classes];
@@ -71,8 +71,6 @@ for (const collection of collections) {
} else {
success++;
}
success++;
}
const report = `GOOD: ${success}, BAD: ${fails}(${Math.min(
@@ -54,7 +54,7 @@ const refChecks: Array<ReferenceCheck> = [
base: "tables",
baseKey: (table) => table.folders.map((slug: string) => `${table.game}:${slug}`),
parent: "folders",
parentKey: (folder) => `${folder.game}:${folderSeedSlug(folder)}`,
parentKey: (folder) => [`${folder.game}:${folderSeedSlug(folder)}`],
gameGroup: null,
game: null,
},
+23 -88
View File
@@ -1,12 +1,18 @@
import {
ALL_GAMES,
allSupportedGameGroups,
GAME_CONFIGS,
GAME_GROUP_CONFIGS,
SEEDS_BMS_COURSE_DOCUMENT_SCHEMA,
SEEDS_CHART_DOCUMENT_SCHEMAS,
SEEDS_FOLDER_DOCUMENT_SCHEMA,
SEEDS_GOAL_DOCUMENT_SCHEMA,
SEEDS_QUEST_DOCUMENT_SCHEMA,
SEEDS_QUESTLINE_DOCUMENT_SCHEMA,
SEEDS_SONG_DOCUMENT_SCHEMAS,
SEEDS_TABLE_DOCUMENT_SCHEMA,
type GameGroup,
type V3Game,
} from "tachi-common";
import { z, type ZodType } from "zod";
import { type ZodType } from "zod";
export type AllCollections =
| "bms-course-lookup.json"
@@ -18,94 +24,23 @@ export type AllCollections =
| `charts-${V3Game}.json`
| `songs-${GameGroup}.json`;
export const V3_GAME_SCHEMA = z.enum(ALL_GAMES as [V3Game, ...Array<V3Game>]);
export const V3_GAME_GROUP_SCHEMA = z.enum(
allSupportedGameGroups as [GameGroup, ...Array<GameGroup>],
);
/** @deprecated Use {@link SEEDS_CHART_DOCUMENT_SCHEMAS} from tachi-common. */
export const V3_CHART_SCHEMAS = Object.fromEntries(
ALL_GAMES.map((game) => [`charts-${game}.json` as const, SEEDS_CHART_DOCUMENT_SCHEMAS[game]]),
) as Record<`charts-${V3Game}.json`, ZodType>;
export const V3_TACHI_ID = (prefix: string) =>
z.string().regex(new RegExp(`^${prefix}[0-9a-f]{19}$`, "u"));
export const V3_FOLDER_SCHEMA = z.strictObject({
title: z.string(),
game: V3_GAME_SCHEMA,
id: V3_TACHI_ID("F"),
legacyFolderID: z.string(),
inactive: z.boolean(),
searchTerms: z.array(z.string()),
versionFilter: z.array(z.string()).optional(),
where: z.string(),
slug: z.string().optional(),
});
export const V3_TABLE_SCHEMA = z.strictObject({
game: V3_GAME_SCHEMA,
id: V3_TACHI_ID("T"),
legacyTableID: z.string(),
inactive: z.boolean(),
title: z.string(),
description: z.string(),
slug: z.string().optional(),
default: z.boolean(),
folders: z.array(z.string()),
});
export const V3_BMS_COURSE_LOOKUP_SCHEMA = z.strictObject({
md5sums: z.string(),
title: z.string(),
set: z.string(),
game: z.enum(["bms-7k", "bms-14k"]),
value: z.string(),
});
// TODO(zk): impl
export const V3_QUEST_SCHEMA = z.any();
export const V3_QUESTLINE_SCHEMA = z.any();
export const V3_GOAL_SCHEMA = z.any();
export const V3_SONG_SCHEMAS: Record<`songs-${GameGroup}.json`, ZodType> = Object.fromEntries(
allSupportedGameGroups.map((gameGroup) => [
`songs-${gameGroup}.json` as const,
z.strictObject({
id: V3_TACHI_ID("S"),
legacySongID: z.number(),
title: z.string(),
artist: z.string(),
altTitles: z.array(z.string()),
searchTerms: z.array(z.string()),
data: GAME_GROUP_CONFIGS[gameGroup].songData,
}),
]),
) as unknown as Record<`songs-${GameGroup}.json`, ZodType>;
// ^ object.fromEntries is hardcoded to [k: string]
// even when k is a subset of string
export const V3_CHART_SCHEMAS: Record<`charts-${V3Game}.json`, ZodType> = Object.fromEntries(
ALL_GAMES.map((game) => [
`charts-${game}.json` as const,
z.strictObject({
id: V3_TACHI_ID("C"),
// TODO(zk): Game specific?
difficulty: z.string(),
isPrimary: z.boolean(),
legacyChartID: z.string(),
level: z.string(),
levelNum: z.number(),
songID: V3_TACHI_ID("S"),
versions: z.array(z.string()),
data: GAME_CONFIGS[game].chartData,
}),
]),
) as unknown as Record<`charts-${V3Game}.json`, ZodType>;
/** @deprecated Use {@link SEEDS_SONG_DOCUMENT_SCHEMAS} from tachi-common. */
export const V3_SONG_SCHEMAS = Object.fromEntries(
allSupportedGameGroups.map((g) => [`songs-${g}.json` as const, SEEDS_SONG_DOCUMENT_SCHEMAS[g]]),
) as Record<`songs-${GameGroup}.json`, ZodType>;
export const V3_SCHEMAS: Record<AllCollections, ZodType> = {
"bms-course-lookup.json": V3_BMS_COURSE_LOOKUP_SCHEMA,
"folders.json": V3_FOLDER_SCHEMA,
"tables.json": V3_TABLE_SCHEMA,
"quests.json": V3_QUEST_SCHEMA,
"questlines.json": V3_QUESTLINE_SCHEMA,
"goals.json": V3_GOAL_SCHEMA,
"bms-course-lookup.json": SEEDS_BMS_COURSE_DOCUMENT_SCHEMA,
"folders.json": SEEDS_FOLDER_DOCUMENT_SCHEMA,
"goals.json": SEEDS_GOAL_DOCUMENT_SCHEMA,
"questlines.json": SEEDS_QUESTLINE_DOCUMENT_SCHEMA,
"quests.json": SEEDS_QUEST_DOCUMENT_SCHEMA,
"tables.json": SEEDS_TABLE_DOCUMENT_SCHEMA,
...V3_SONG_SCHEMAS,
...V3_CHART_SCHEMAS,
};
@@ -1,14 +1,13 @@
import type { z } from "zod";
import chalk from "chalk";
import { ALL_GAMES, FormatGame } from "tachi-common";
import { ALL_GAMES, FormatGame, type SEEDS_TABLE_DOCUMENT_SCHEMA } from "tachi-common";
import { ReadCollection } from "../util";
import { type V3_TABLE_SCHEMA } from "./schemas";
const defaultTableMap = {};
const tables: Array<z.infer<typeof V3_TABLE_SCHEMA>> = ReadCollection("tables.json");
const tables: Array<z.infer<typeof SEEDS_TABLE_DOCUMENT_SCHEMA>> = ReadCollection("tables.json");
let errs = 0;
+1 -1
View File
@@ -11,5 +11,5 @@
"#*": ["./src/*"]
}
},
"exclude": ["node_modules"]
"exclude": ["node_modules", "rerunners/**"]
}
@@ -11,10 +11,6 @@ export function AssertStrAsDifficulty(strVal: string, game: V3Game): Difficultie
}
if (!diffConf.order.includes(strVal)) {
if (game === "chunithm" && strVal === "WORLD'S END") {
throw new InvalidScoreFailure("WORLD'S END is not supported. Sorry.");
}
throw new InvalidScoreFailure(
`Invalid Difficulty for ${FormatGame(game)} - Expected any of ${diffConf.order.join(
", ",
@@ -482,6 +482,23 @@ export async function ResolveChartFromSong(
const difficulty = AssertStrAsDifficulty(resolver.difficulty, game);
const gameConfig = GetGameConfig(resolver.game);
switch (gameConfig.difficulties.type) {
case "DYNAMIC":
throw new InvalidScoreFailure(
"You cannot use a songTitle+difficulty lookup for a game with 'DYNAMIC' difficulties",
);
case "CHUGEKIMAI_STYLE":
if (!gameConfig.difficulties.order.includes(difficulty)) {
throw new InvalidScoreFailure(
`This difficulty "${difficulty}" is not supported for songTitle+difficulty lookups. If you are trying to import for WORLD'S END/LUNATIC/UTAGE scores, you must use inGameID, or a different lookup method.`,
);
}
break;
case "FIXED":
break;
}
let chart;
if (resolver.version) {