import { GetGPTString, GetGameConfig, GetGamePTConfig, GetSpecificGPTConfig, } from "../config/config"; import type { GradeBoundary } from "../constants/grade-boundaries"; import type { BMSCourseDocument, ChartDocument, GPTString, GPTStrings, Game, Playtypes, SongDocument, integer, } from "../types"; import type { PrudenceError, ValidSchemaValue } from "prudence"; import type { AnyZodObject } from "zod"; export function FormatInt(v: number): string { return Math.floor(v).toFixed(0); } export function FormatDifficulty(chart: ChartDocument, game: Game): string { if (game === "bms" || game === "pms") { const bmsChart = chart as ChartDocument<"bms:7K" | "bms:14K">; return ( bmsChart.data.tableFolders.map((e) => `${e.table}${e.level}`).join(", ") || "Unrated" ); } if (game === "itg") { const itgChart = chart as ChartDocument<"itg:Stamina">; const level = itgChart.data.rankedLevel ?? itgChart.data.chartLevel; const unranked = itgChart.data.rankedLevel === null ? "UNRANKED " : ""; return `${unranked}${itgChart.data.difficultyTag} ${level} (${itgChart.data.charter})`; } if (game === "gitadora") { const ch = chart as ChartDocument; const gptConfig = GetSpecificGPTConfig( GetGPTString(game, chart.playtype) as GPTStrings["gitadora"] ); // it's complaining that since the dora config doesn't have shorthand for // "BASS BASIC", this assignment may fail. // it's technically correct, but in the worst way, since this isn't // actually possible. // todo: come up with something better. // eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/prefer-ts-expect-error // @ts-ignore this fails in my IDE but is fine in the compiler, can't use ts-expect-err here. const shortDiff = gptConfig.difficulties.shorthand[ch.difficulty]; // gitadora should always use short diffs. they just look better. return `${shortDiff} ${chart.level}`; } const gameConfig = GetGameConfig(game); if (gameConfig.playtypes.length > 1) { return `${chart.playtype} ${chart.difficulty} ${chart.level}`; } return `${chart.difficulty} ${chart.level}`; } /** * Formats a chart's difficulty into a shorter variant. This handles a lot of * game-specific strange edge cases. */ export function FormatDifficultyShort(chart: ChartDocument, game: Game): string { const gameConfig = GetGameConfig(game); const gptConfig = GetGamePTConfig(game, chart.playtype); if (game === "itg") { const itgChart = chart as ChartDocument<"itg:Stamina">; return `S${itgChart.data.difficultyTag} ${chart.level}`; } if (gptConfig.difficulties.type === "DYNAMIC") { // TODO cap string length return `${chart.difficulty} ${chart.level}`; } const shortDiff = gptConfig.difficulties.shorthand[chart.difficulty] ?? chart.difficulty; if (gameConfig.playtypes.length === 1 || game === "gitadora") { return `${shortDiff} ${chart.level}`; } if (game === "usc") { return `${chart.playtype === "Controller" ? "CON" : "KB"} ${shortDiff} ${chart.level}`; } return `${chart.playtype}${shortDiff} ${chart.level}`; } export function FormatGame(game: Game, playtype: Playtypes[Game]): string { const gameConfig = GetGameConfig(game); if (gameConfig.playtypes.length === 1) { return gameConfig.name; } return `${gameConfig.name} (${playtype})`; } export function FormatChart( game: Game, song: SongDocument, chart: ChartDocument, short = false ): string { if (game === "bms") { const tables = (chart as ChartDocument).data.tableFolders; const bmsSong = song as SongDocument<"bms">; let realTitle = bmsSong.title; if (bmsSong.data.subtitle) { realTitle = `${realTitle} - ${bmsSong.data.subtitle}`; } if (bmsSong.data.genre) { realTitle = `${realTitle} [${bmsSong.data.genre}]`; } if (tables.length === 0) { return realTitle; } return `${realTitle} (${tables.map((e) => `${e.table}${e.level}`).join(", ")})`; } else if (game === "usc") { const uscChart = chart as ChartDocument; // If this chart isn't an official, render it differently if (!uscChart.data.isOfficial) { // Same as BMS. turn this into SongTitle (Keyboard MXM normal1, insane2) return `${song.title} (${chart.playtype} ${ chart.difficulty } ${uscChart.data.tableFolders.map((e) => `${e.table}${e.level}`).join(", ")})`; } else if (uscChart.data.tableFolders.length !== 0) { // if this chart is an official **AND** is on tables (unlikely), render // it as so: // SongTitle (Keyboard MXM 17, normal1, insane2) return `${song.title} (${chart.playtype} ${chart.difficulty} ${ chart.level }, ${uscChart.data.tableFolders.map((e) => `${e.table}${e.level}`).join(", ")})`; } // otherwise, it's just an official and should be rendered like any other game. } else if (game === "itg") { const itgChart = chart as ChartDocument<"itg:Stamina">; const itgSong = song as SongDocument<"itg">; const level = itgChart.data.rankedLevel ?? `${itgChart.data.chartLevel}?`; return `${itgSong.title}${itgSong.data.subtitle ? ` ${itgSong.data.subtitle}` : ""} ${ itgChart.data.difficultyTag } ${level}`; } const gameConfig = GetGameConfig(game); let playtypeStr = `${chart.playtype}`; if (gameConfig.playtypes.length === 1) { playtypeStr = ""; } const gptConfig = GetGamePTConfig(game, chart.playtype); let diff: string; if (gptConfig.difficulties.type === "DYNAMIC") { diff = chart.difficulty; } else if (short) { diff = gptConfig.difficulties.shorthand[chart.difficulty] ?? chart.difficulty; } else { diff = chart.difficulty; } // iidx formats things like SPA instead of SP A. // this is a hack, this should be part of the gptConfig, tbh. let space = ""; if ((game === "iidx" && short) || !playtypeStr) { space = ""; } else { space = " "; } // return the most recent version this chart appeared in if it // is not primary. if (!chart.isPrimary) { return `${song.title} (${playtypeStr}${space}${diff} ${chart.level} ${chart.versions[0]})`; } return `${song.title} (${playtypeStr}${space}${diff} ${chart.level})`; } /** * Run a zod schema inside prudence. */ export function PrudenceZodShim(zodSchema: AnyZodObject): ValidSchemaValue { return (self) => { const res = zodSchema.safeParse(self); if (res.success) { return true; } return res.error.message; }; } /** * Formats a number (14100) into "14K". */ export function FmtNumCompact(num: number) { return Intl.NumberFormat("en", { notation: "compact" }).format(num); } /** * Formats a number (14100) into "14,100" */ export function FmtNum(num: number) { return num.toLocaleString(); } export function FmtPercent(v: number, dp = 2) { return `${v.toFixed(dp)}%`; } /** * Turns a number of 12834 into "12834" instead of "12,834". */ export function FmtScoreNoCommas(v: number) { return v.toString(); } function WrapGrade(grade: string) { if (grade.endsWith("-") || grade.endsWith("+")) { return `(${grade})`; } return grade; } function RelativeGradeDelta( gradeBoundaries: Array>, scoreGrade: G, scoreValue: number, // Positive number means higher grade, etc. relativeIndex: number ) { const gradeBoundary = gradeBoundaries[gradeBoundaries.findIndex((e) => e.name === scoreGrade) + relativeIndex]; if (!gradeBoundary) { return null; } return AbsoluteGradeDelta(gradeBoundary, scoreValue); } function AbsoluteGradeDelta(gradeBoundary: GradeBoundary, scoreValue: number) { return { grade: gradeBoundary.name, delta: scoreValue - gradeBoundary.lowerBound, }; } export function GetGradeDeltas( gradeBoundaries: Array>, scoreGrade: G, scoreValue: number, formatNumFn = FmtNumCompact ) { const scoreGradeBoundary = gradeBoundaries.find((e) => e.name === scoreGrade); if (!scoreGradeBoundary) { throw new Error( `Passed a scoreGrade of ${scoreGrade} but no such boundary exists in ${gradeBoundaries .map((e) => e.name) .join(", ")}` ); } const upper = RelativeGradeDelta(gradeBoundaries, scoreGrade, scoreValue, 1); const lower = AbsoluteGradeDelta(scoreGradeBoundary, scoreValue); const formatLower = `${WrapGrade(lower.grade)}+${formatNumFn(lower.delta)}`; // there might be *no* grade above this one, in this case lower obviously wins. if (!upper) { return { lower: formatLower, closer: "lower", }; } // this will automatically have a - separating the two. const formatUpper = `${WrapGrade(upper.grade)}${formatNumFn(upper.delta)}`; // are we closer to the lower bound, or the upper one? let closer: "lower" | "upper" = upper.delta + lower.delta < 0 ? "lower" : "upper"; // lovely hardcoded exception for IIDXLikes - (MAX-)+ is always a stupid metric // so always mute it. if (formatLower.startsWith("(MAX-)+")) { closer = "upper"; } return { lower: formatLower, upper: formatUpper, closer, }; } export function GetCloserGradeDelta( gradeBoundaries: Array>, scoreGrade: G, scoreValue: number, formatNumFn = FmtNumCompact ): string { const { lower, upper, closer } = GetGradeDeltas( gradeBoundaries, scoreGrade, scoreValue, formatNumFn ); if (closer === "upper") { // this type assertion is unecessary in theory, but in practice older versions // of TS aren't happy with it. // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion return upper!; } return lower; } export function CreateSongMap(songs: Array>) { const songMap = new Map>(); for (const song of songs) { songMap.set(song.id, song); } return songMap; } export function CreateChartMap( charts: Array> ) { const chartMap = new Map>(); for (const chart of charts) { chartMap.set(chart.chartID, chart); } return chartMap; } /** * Formats a PrudenceError into something a little more readable. * @param err - The prudence error to format. * @param foreword - A description of what kind of error this was. Defaults to "Error". */ export function FormatPrError(err: PrudenceError, foreword = "Error"): string { const receivedText = typeof err.userVal === "object" && err.userVal !== null ? "" : ` | Received ${err.userVal} [${err.userVal === null ? "null" : typeof err.userVal}]`; return `${foreword}: ${err.keychain} | ${err.message}${receivedText}.`; } export function GetBMSCourseIndex(course: BMSCourseDocument) { const gptConf = GetGamePTConfig("bms", course.playtype); const cls = gptConf.classes[course.set]; if (!cls) { throw new Error( `Invalid BMSCourse set of ${course.set}. No classes are defined for this set.` ); } return cls.values.findIndex((e) => e.id === course.value); } /** * Util for getting a games' grade for a given score. */ export function GetGrade(grades: Array>, score: number): G { // sort grades going downwards in their boundaries. const descendingGrades = grades.slice(0).sort((a, b) => b.lowerBound - a.lowerBound); for (const { name, lowerBound } of descendingGrades) { if (score >= lowerBound) { return name; } } throw new Error(`Could not resolve grade for score ${score}.`); }