diff --git a/common/src/utils/util.ts b/common/src/utils/util.ts index 376c30834..8e6dd1ba5 100644 --- a/common/src/utils/util.ts +++ b/common/src/utils/util.ts @@ -253,3 +253,13 @@ export function FormatSieglindePMS(sgl: number): string { return `●${(sgl - 12).toFixed(2)}`; } + +export function CreateSongMap(songs: Array>) { + const songMap = new Map>(); + + for (const song of songs) { + songMap.set(song.id, song); + } + + return songMap; +} diff --git a/server/src/server/router/api/v1/games/_game/_playtype/content/bms-router.test.ts b/server/src/server/router/api/v1/games/_game/_playtype/content/bms-router.test.ts new file mode 100644 index 000000000..40d82b731 --- /dev/null +++ b/server/src/server/router/api/v1/games/_game/_playtype/content/bms-router.test.ts @@ -0,0 +1,54 @@ +import t from "tap"; +import mockApi from "test-utils/mock-api"; + +t.test("GET /api/v1/games/bms/7K/content", (t) => { + t.test("Should exist.", async (t) => { + const res = await mockApi.get("/api/v1/games/bms/7K/content"); + + t.equal(res.body.success, true); + + t.end(); + }); + + t.end(); +}); + +t.test("GET /api/v1/games/bms/7K/content/sieglindeEC", (t) => { + t.test("Should point to the right file.", async (t) => { + const res = await mockApi.get("/api/v1/games/bms/7K/content/sieglindeEC/header.json"); + + t.equal(res.body.data_url, "./body.json"); + + t.end(); + }); + + t.test("`body.json` should return an array.", async (t) => { + const res = await mockApi.get("/api/v1/games/bms/7K/content/sieglindeEC/body.json"); + + t.equal(Array.isArray(res.body), true); + + t.end(); + }); + + t.end(); +}); + +t.test("GET /api/v1/games/bms/7K/content/sieglindeHC", (t) => { + t.test("Should point to the right file.", async (t) => { + const res = await mockApi.get("/api/v1/games/bms/7K/content/sieglindeHC/header.json"); + + t.equal(res.body.data_url, "./body.json"); + + t.end(); + }); + + t.test("`body.json` should return an array.", async (t) => { + const res = await mockApi.get("/api/v1/games/bms/7K/content/sieglindeHC/body.json"); + + t.equal(Array.isArray(res.body), true); + + t.end(); + }); + + t.end(); +}); diff --git a/server/src/server/router/api/v1/games/_game/_playtype/content/bms-router.ts b/server/src/server/router/api/v1/games/_game/_playtype/content/bms-router.ts new file mode 100644 index 000000000..cedb417e9 --- /dev/null +++ b/server/src/server/router/api/v1/games/_game/_playtype/content/bms-router.ts @@ -0,0 +1,178 @@ +import { Router } from "express"; +import db from "external/mongo/db"; +import CreateLogCtx from "lib/logger/logger"; +import { CreateSongMap } from "tachi-common"; +import { integer, SongDocument } from "tachi-common/types"; +import { GetFolderCharts, GetFoldersFromTable } from "utils/folder"; +import { GetTachiData } from "utils/req-tachi-data"; +import type { Request, Response } from "express"; +import type { ChartDocument } from "tachi-common/types"; + +const logger = CreateLogCtx(__filename); + +const router: Router = Router({ mergeParams: true }); + +/** + * Returns "Endpoint exists" + * + * @name GET /api/v1/games/bms/7K/content + */ +router.get("/", (req, res) => { + return res.status(200).json({ + success: true, + description: "Endpoint exists", + }); +}); + +/** + * Utility function for mounting a BMS-style Table. This defines + * a header.json endpoint, and a body.json endpoint. + * + * It also defines a HTML-emitting endpoint that contains a pointer + * to said header.json file. + */ +function CreateAndMountTable( + router: Router, + tableName: string, + headerHandler: (req: Request, res: Response) => any, + bodyHandler: (req: Request, res: Response) => any +) { + router.get(`/${tableName}`, (req, res) => { + return res.status(200).send(` + + + + This is a stub page for beatoraja compatibility. No useful information here! + `); + }); + + router.get(`/${tableName}/header.json`, headerHandler); + + router.get(`/${tableName}/body.json`, bodyHandler); +} + +/** + * Returns a difficulty table of all charts rated according to their Sieglinde values for easy clears + * + * @name GET /api/v1/games/bms/7k/content/sieglindeEC + */ +CreateAndMountTable( + router, + "sieglindeEC", + /* header.json */ (_req, res) => { + return res.status(200).send({ + name: "Sieglinde EC", + symbol: "sgl-", + data_url: "./body.json", + }); + }, + /* body.json */ async (req, res) => { + const game = "bms"; + + const table = await db.tables.findOne({ + tableID: "bms-7K-sgl-EC", + game, + playtype: "7K", + }); + + if (!table) { + logger.error( + "Could not find table bms-7K-sgl-EC. Cannot convert sieglinde tables into BMS form!" + ); + return res.status(500).json([]); + } + + const folders = await GetFoldersFromTable(table); + const response = []; + + for (const folder of folders) { + const data = await GetFolderCharts(folder, {}, true); + const charts = data.charts as Array>; + const songMap = CreateSongMap(data.songs); + + for (const chart of charts) { + const song = songMap.get(chart.songID); + + if (!song) { + response.push({ + level: folder.title, + md5: chart.data.hashMD5, + }); + } else { + response.push({ + level: folder.title, + title: song.title, + artist: song.artist, + md5: chart.data.hashMD5, + }); + } + } + } + + return res.status(200).json(response); + } +); + +/** + * Returns a difficulty table of all charts rated according to their Sieglinde values for hard clears + * + * @name GET /api/v1/games/bms/7k/content/sieglindeHC + */ +CreateAndMountTable( + router, + "sieglindeHC", + /* header.json */ (_req, res) => { + return res.status(200).send({ + name: "Sieglinde HC", + symbol: "sgl-", + data_url: "./body.json", + }); + }, + /* body.json */ async (req, res) => { + const game = "bms"; + + const table = await db.tables.findOne({ + tableID: "bms-7K-sgl-HC", + game, + playtype: "7K", + }); + + if (!table) { + logger.error( + "Could not find table bms-7K-sgl-HC. Cannot convert sieglinde tables into BMS form!" + ); + return res.status(500).json([]); + } + + const folders = await GetFoldersFromTable(table); + const response = []; + + for (const folder of folders) { + const data = await GetFolderCharts(folder, {}, true); + const charts = data.charts as Array>; + const songMap = CreateSongMap(data.songs); + + for (const chart of charts) { + const song = songMap.get(chart.songID); + + if (!song) { + response.push({ + level: folder.title, + md5: chart.data.hashMD5, + }); + } else { + response.push({ + level: folder.title, + title: song.title, + artist: song.artist, + md5: chart.data.hashMD5, + }); + } + } + } + + return res.status(200).json(response); + } +); + +export default router; diff --git a/server/src/server/router/api/v1/games/router.ts b/server/src/server/router/api/v1/games/router.ts index 46cfdc403..dfb0ba97e 100644 --- a/server/src/server/router/api/v1/games/router.ts +++ b/server/src/server/router/api/v1/games/router.ts @@ -1,3 +1,4 @@ +import bmsContentRouter from "./_game/_playtype/content/bms-router"; import gameRouter from "./_game/router"; import { Router } from "express"; import { TachiConfig } from "lib/setup/config"; @@ -25,6 +26,14 @@ router.get("/", (req, res) => { }); }); +// "Content" endpoints are game-playtype specific. +// as such, we want to mount them *before* any generic endpoints +// so that they'll guaranteeably get hit. + +// This is a bit unorthodox, but arguably necessary, as all /content +// endpoints really are *entirely* GPT specific. +router.use("/bms/7K/content", bmsContentRouter); + router.use("/:game", gameRouter); export default router;