diff --git a/typescript/client/src/components/tables/seeds/SeedsBMSCourseLookupTable.tsx b/typescript/client/src/components/tables/seeds/SeedsBMSCourseLookupTable.tsx index 14f0a7039..cb1deb593 100644 --- a/typescript/client/src/components/tables/seeds/SeedsBMSCourseLookupTable.tsx +++ b/typescript/client/src/components/tables/seeds/SeedsBMSCourseLookupTable.tsx @@ -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 = ({ {typeof e === "string" ? ( UNKNOWN CHART ) : ( - {FormatChart(e.chart)} + {FormatChart({ ...e.chart, song: e.song } as unknown as ChartDocument)} )} {!compress && (
- {typeof e === "string" ? e : e.chart.data.hashMD5} + + {typeof e === "string" + ? e + : (e.chart.data as ChartDocumentData["bms-7k"] | ChartDocumentData["bms-14k"]) + .hashMD5} +
)} diff --git a/typescript/client/src/components/tables/seeds/SeedsChartsTable.tsx b/typescript/client/src/components/tables/seeds/SeedsChartsTable.tsx index 95d792005..ce88afe63 100644 --- a/typescript/client/src/components/tables/seeds/SeedsChartsTable.tsx +++ b/typescript/client/src/components/tables/seeds/SeedsChartsTable.tsx @@ -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 }) => ( <> - + {/* if we have a song, render the title cell. Otherwise, this chart has no parent song, and is invalid. */} {data.__related.song ? ( - + ) : ( - INVALID SONG ({data.song.id}) + INVALID SONG ({data.songID})
diff --git a/typescript/client/src/components/tables/seeds/SeedsQuestTable.tsx b/typescript/client/src/components/tables/seeds/SeedsQuestTable.tsx index 2563d18da..8b8057f58 100644 --- a/typescript/client/src/components/tables/seeds/SeedsQuestTable.tsx +++ b/typescript/client/src/components/tables/seeds/SeedsQuestTable.tsx @@ -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 = { 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 = ({ {data.name} - {FormatGame(data.game)} + + {LEGACY_FormatGameGroupPT( + data.game as GameGroup, + data.playtype as LEGACY_Playtypes[typeof data.game], + )} +
{data.questData.map((section) => ( @@ -51,7 +65,7 @@ export const SeedsQuestCells: CellsRenderFN = ({ if (goal) { return (
- + {ref.note && {ref.note}}
); diff --git a/typescript/client/src/components/tables/seeds/SeedsQuestlineTable.tsx b/typescript/client/src/components/tables/seeds/SeedsQuestlineTable.tsx index 3f9f4223b..b76da2ad5 100644 --- a/typescript/client/src/components/tables/seeds/SeedsQuestlineTable.tsx +++ b/typescript/client/src/components/tables/seeds/SeedsQuestlineTable.tsx @@ -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 = { 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 = ({ data
{data.desc} - {FormatGame(data.game)} + + {LEGACY_FormatGameGroupPT( + data.game as GameGroup, + data.playtype as LEGACY_Playtypes[typeof data.game], + )} +
{data.quests.map((e) => { @@ -47,7 +60,12 @@ export const SeedsQuestlineCells: CellsRenderFN = ({ data
{quest ? ( - {quest.name} ({FormatGame(quest.game)}) + {quest.name} ( + {LEGACY_FormatGameGroupPT( + quest.game as GameGroup, + quest.playtype as LEGACY_Playtypes[typeof quest.game], + )} + ) ) : ( UNKNOWN QUEST {e} diff --git a/typescript/client/src/components/tables/seeds/SeedsTableTable.tsx b/typescript/client/src/components/tables/seeds/SeedsTableTable.tsx index 3d607522b..15ad686fd 100644 --- a/typescript/client/src/components/tables/seeds/SeedsTableTable.tsx +++ b/typescript/client/src/components/tables/seeds/SeedsTableTable.tsx @@ -9,7 +9,7 @@ import { FormatGame } from "tachi-common"; import { type Header } from "../components/TachiTable"; export const SeedsTableHeaders: Header[] = [ - ["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[] = [ export const SeedsTableSearchFns: SearchFunctions = { 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 = { export const SeedsTableCells: CellsRenderFN = ({ data }) => ( <> - {data.tableID} + {data.id} {data.title} diff --git a/typescript/client/src/types/seeds.ts b/typescript/client/src/types/seeds.ts index 1a8dc7eb5..7eae09070 100644 --- a/typescript/client/src/types/seeds.ts +++ b/typescript/client/src/types/seeds.ts @@ -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 = { __related: { song: SongDocument | undefined; }; -} & ChartDocument; +} & SEEDS_ChartDocument; type SongSeedsWithRelated = { - [G in GameGroup as `songs-${G}.json`]: Array>; + [G in GameGroup as `songs-${G}.json`]: Array>; }; type ChartSeedsWithRelated = { @@ -78,9 +79,9 @@ export type DatabaseSeedsWithRelated = { "bms-course-lookup.json": Array; // intentional: folders don't need to be joined with anything. - "folders.json": Array; + "folders.json": Array; - "goals.json": Array; + "goals.json": Array; "questlines.json": Array; "quests.json": Array; "tables.json": Array; diff --git a/typescript/client/src/util/data.ts b/typescript/client/src/util/data.ts index 14b85d0cf..b0a0fa37b 100644 --- a/typescript/client/src/util/data.ts +++ b/typescript/client/src/util/data.ts @@ -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(songs: SongDocument[]) { +export function CreateSongMap( + songs: Array | SEEDS_SongDocument>, +) { const songMap = new Map>(); for (const song of songs) { - songMap.set(song.id, song); + songMap.set(song.id, song as SongDocument); } return songMap; @@ -48,8 +50,8 @@ export function CreateUserMap(users: UserDocument[]) { return userMap; } -export function CreateGoalMap(goals: GoalDocument[]) { - const goalMap = new Map(); +export function CreateGoalMap(goals: Array) { + const goalMap = new Map(); 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); diff --git a/typescript/client/src/util/misc.ts b/typescript/client/src/util/misc.ts index 965810b0c..83f629145 100644 --- a/typescript/client/src/util/misc.ts +++ b/typescript/client/src/util/misc.ts @@ -544,8 +544,8 @@ export function clamp(a: number, low: number, up: number) { return a; } -export function CreateQuestMap(quests: Array) { - const map = new Map(); +export function CreateQuestMap(quests: Array) { + const map = new Map(); for (const q of quests) { map.set(q.questID, q); diff --git a/typescript/client/src/util/seeds.ts b/typescript/client/src/util/seeds.ts index 5a9d3c1ca..c5ae6a4ff 100644 --- a/typescript/client/src/util/seeds.ts +++ b/typescript/client/src/util/seeds.ts @@ -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): BMSCourseWithRelated } const songMap = CreateSongMap(bmsSongs); - const chartMap = new Map>(); + const chartMap = new Map>(); 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): 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): TableWithRelated[] { return []; } - const folderById = new Map(); - const folderByGameSlug = new Map(); + const folderById = new Map(); + const folderByGameSlug = new Map(); for (const folder of data["folders.json"] ?? []) { - const raw = folder as { - id?: string; - slug?: string; - versionFilter?: Array; - 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): QuestWithRelated[] { function RelateCharts(data: Partial, 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 + | undefined; + const charts = data[`charts-${game}.json` as keyof AllDatabaseSeeds] as + | Array + | undefined; if (!charts) { return []; @@ -358,7 +357,7 @@ function RelateCharts(data: Partial, 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(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; } } diff --git a/typescript/common/src/config/game-support/maimai-dx.ts b/typescript/common/src/config/game-support/maimai-dx.ts index d1238fe0d..b7ef42cf7 100644 --- a/typescript/common/src/config/game-support/maimai-dx.ts +++ b/typescript/common/src/config/game-support/maimai-dx.ts @@ -174,7 +174,7 @@ export const GAME_MAIMAI_DX_CONF = { defaultProfileRatingAlg: "naiveRate", difficulties: { - type: "FIXED", + type: "CHUGEKIMAI_STYLE", order: [ "Basic", "Advanced", diff --git a/typescript/common/src/config/game-support/maimai.ts b/typescript/common/src/config/game-support/maimai.ts index b26d37444..d761d24f2 100644 --- a/typescript/common/src/config/game-support/maimai.ts +++ b/typescript/common/src/config/game-support/maimai.ts @@ -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", diff --git a/typescript/common/src/config/game-support/ongeki.ts b/typescript/common/src/config/game-support/ongeki.ts index b4ae84dba..462bcfb43 100644 --- a/typescript/common/src/config/game-support/ongeki.ts +++ b/typescript/common/src/config/game-support/ongeki.ts @@ -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", diff --git a/typescript/common/src/seeds/types.ts b/typescript/common/src/seeds/types.ts index 71aab44f3..b97319c73 100644 --- a/typescript/common/src/seeds/types.ts +++ b/typescript/common/src/seeds/types.ts @@ -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>; + [TGame in V3Game as `charts-${TGame}.json`]: Array>; }; type SongDBSeeds = { - [G in GameGroup as `songs-${G}.json`]: Array>; + [G in GameGroup as `songs-${G}.json`]: Array>; }; interface OtherDBSeeds { - "bms-course-lookup.json": Array; - "folders.json": Array; - "goals.json": Array; - "questlines.json": Array; - "quests.json": Array; - "tables.json": Array; + "bms-course-lookup.json": Array; + "folders.json": Array; + "goals.json": Array; + "questlines.json": Array; + "quests.json": Array; + "tables.json": Array; } export type AllDatabaseSeeds = ChartDBSeeds & OtherDBSeeds & SongDBSeeds; diff --git a/typescript/common/src/types.ts b/typescript/common/src/types.ts index af57a17ad..fcf0396ff 100644 --- a/typescript/common/src/types.ts +++ b/typescript/common/src/types.ts @@ -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"; diff --git a/typescript/common/src/types/game-config-utils.ts b/typescript/common/src/types/game-config-utils.ts index d22a1a2aa..0790615f4 100644 --- a/typescript/common/src/types/game-config-utils.ts +++ b/typescript/common/src/types/game-config-utils.ts @@ -123,10 +123,36 @@ export interface FixedDifficulties { order: ReadonlyArray; /** - * 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>; + + 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 { + type: "CHUGEKIMAI_STYLE"; + + order: ReadonlyArray; + + /** + * 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>; @@ -134,5 +160,6 @@ export interface FixedDifficulties { } export type DifficultyConfig = + | ChuGekiMaiDifficulties | DynamicDifficulties | FixedDifficulties; diff --git a/typescript/common/src/types/game-config.ts b/typescript/common/src/types/game-config.ts index 979914dc6..ef642d78b 100644 --- a/typescript/common/src/types/game-config.ts +++ b/typescript/common/src/types/game-config.ts @@ -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 > diff --git a/typescript/common/src/types/seeds-documents-zod.ts b/typescript/common/src/types/seeds-documents-zod.ts new file mode 100644 index 000000000..041daafef --- /dev/null +++ b/typescript/common/src/types/seeds-documents-zod.ts @@ -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]); + +export const SEEDS_V3_GAME_GROUP_SCHEMA = z.enum( + allSupportedGameGroups as [GameGroup, ...Array], +); + +function difficultyZodSchema(game: G): z.ZodType { + const d = GetSpecificGameConfig(game).difficulties as DifficultyConfig; + if (d.type === "DYNAMIC") { + return z.string() as unknown as z.ZodType; + } + const order = d.order as ReadonlyArray; + return z.enum(order as [string, ...Array]) as unknown as z.ZodType; +} + +function versionsZodSchema(game: G): z.ZodType> { + 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>; +} + +function seedsChartDocumentSchemaForGame(game: G) { + const chartData = GetSpecificGameConfig(game).chartData as unknown as z.ZodType; + + 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; + }>; +} + +/** 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; +}; + +export type SEEDS_ChartDocument = z.infer< + (typeof SEEDS_CHART_DOCUMENT_SCHEMAS)[TGame] +>; + +function seedsSongDocumentSchemaForGroup(group: G) { + const songData = GAME_GROUP_CONFIGS[group].songData as unknown as z.ZodType; + + 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; + artist: string; + data: SongDocumentData[G]; + id: string; + legacySongID: number; + searchTerms: Array; + 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; +}; + +export type SEEDS_SongDocument = 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; + +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; + +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; + +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; + +/** 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; + +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; diff --git a/typescript/common/src/types/seeds-documents.ts b/typescript/common/src/types/seeds-documents.ts new file mode 100644 index 000000000..8c7f05fe8 --- /dev/null +++ b/typescript/common/src/types/seeds-documents.ts @@ -0,0 +1 @@ +export * from "./seeds-documents-zod"; diff --git a/typescript/common/src/utils/util.ts b/typescript/common/src/utils/util.ts index d875a6f59..e6453c85b 100644 --- a/typescript/common/src/utils/util.ts +++ b/typescript/common/src/utils/util.ts @@ -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( return lower; } -export function CreateSongMap(songs: Array>) { +export function CreateSongMap( + songs: Array | SongDocument>, +) { const songMap = new Map>(); for (const song of songs) { - songMap.set(song.id, song); + songMap.set(song.id, song as SongDocument); } 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]; diff --git a/typescript/seeds-scripts/test/collections.test.ts b/typescript/seeds-scripts/test/collections.test.ts index 811dfb3ae..743a123d8 100644 --- a/typescript/seeds-scripts/test/collections.test.ts +++ b/typescript/seeds-scripts/test/collections.test.ts @@ -71,8 +71,6 @@ for (const collection of collections) { } else { success++; } - - success++; } const report = `GOOD: ${success}, BAD: ${fails}(${Math.min( diff --git a/typescript/seeds-scripts/test/references.test.ts b/typescript/seeds-scripts/test/references.test.ts index 2d8dc4d92..7f2eaa7b1 100644 --- a/typescript/seeds-scripts/test/references.test.ts +++ b/typescript/seeds-scripts/test/references.test.ts @@ -54,7 +54,7 @@ const refChecks: Array = [ 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, }, diff --git a/typescript/seeds-scripts/test/schemas.ts b/typescript/seeds-scripts/test/schemas.ts index 7dd4627a0..93aec594a 100644 --- a/typescript/seeds-scripts/test/schemas.ts +++ b/typescript/seeds-scripts/test/schemas.ts @@ -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]); -export const V3_GAME_GROUP_SCHEMA = z.enum( - allSupportedGameGroups as [GameGroup, ...Array], -); +/** @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 = { - "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, }; diff --git a/typescript/seeds-scripts/test/table-default.test.ts b/typescript/seeds-scripts/test/table-default.test.ts index ae22a6567..32e431612 100644 --- a/typescript/seeds-scripts/test/table-default.test.ts +++ b/typescript/seeds-scripts/test/table-default.test.ts @@ -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> = ReadCollection("tables.json"); +const tables: Array> = ReadCollection("tables.json"); let errs = 0; diff --git a/typescript/seeds-scripts/tsconfig.json b/typescript/seeds-scripts/tsconfig.json index 70dfcbafb..2915b18c4 100644 --- a/typescript/seeds-scripts/tsconfig.json +++ b/typescript/seeds-scripts/tsconfig.json @@ -11,5 +11,5 @@ "#*": ["./src/*"] } }, - "exclude": ["node_modules"] + "exclude": ["node_modules", "rerunners/**"] } diff --git a/typescript/server/src/lib/score-import/framework/common/string-asserts.ts b/typescript/server/src/lib/score-import/framework/common/string-asserts.ts index 364a27609..f6f4ff5c0 100644 --- a/typescript/server/src/lib/score-import/framework/common/string-asserts.ts +++ b/typescript/server/src/lib/score-import/framework/common/string-asserts.ts @@ -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( ", ", diff --git a/typescript/server/src/lib/score-import/import-types/common/batch-manual/converter.ts b/typescript/server/src/lib/score-import/import-types/common/batch-manual/converter.ts index 490039ebc..9076964a2 100644 --- a/typescript/server/src/lib/score-import/import-types/common/batch-manual/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/common/batch-manual/converter.ts @@ -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) {