import { Client } from "discord.js"; import humaniseDuration from "humanize-duration"; import _ from "lodash"; import { DateTime } from "luxon"; import { ChartDocument, Game, GenericFormatGradeDelta, GetGameConfig, GetGamePTConfig, GoalDocument, Grades, IDStrings, integer, PBScoreDocument, Playtype, Playtypes, PublicUserDocument, ScoreCalculatedDataLookup, ScoreDocument, SongDocument, UGSRatingsLookup, } from "tachi-common"; import { GameClassSets } from "tachi-common/js/game-classes"; import { BotConfig } from "../config"; /** * Random From Array - Selects a random value from an array. */ export function RFA(arr: T[]): T { return arr[Math.floor(Math.random() * arr.length)]; } export function TruncateString(string: string, len = 30) { if (string.length < len) { return string; } return `${string.substring(0, len - 3)}...`; } /** * Checks whether a discord user has admin permissions or not. This lets them do * slightly more destructive things. */ export function IsAdmin(discordID: string) { return BotConfig.DISCORD.ADMIN_USERS.includes(discordID); } export function Sleep(ms: number) { return new Promise((resolve) => setTimeout(() => resolve(), ms)); } export function Pluralise(int: integer, str: string) { if (int === 1) { return str; } return `${str}s`; } export function FormatProfileRating( game: Game, playtype: Playtype, rating: UGSRatingsLookup[IDStrings], value: number | null | undefined ) { if (value === null || value === undefined) { return "No Data."; } const formatter = GetGamePTConfig(game, playtype).profileRatingAlgFormatters[rating]; if (!formatter) { return value.toFixed(2); } return formatter(value); } export function FormatScoreRating( game: Game, playtype: Playtype, rating: ScoreCalculatedDataLookup[IDStrings], value: number | null | undefined ) { if (value === null || value === undefined) { return "No Data."; } const formatter = GetGamePTConfig(game, playtype).scoreRatingAlgFormatters[rating]; if (!formatter) { return value.toFixed(2); } return formatter(value); } /** * Typesafe asserted version of Object.entries. */ export function Entries(rec: Partial>): [K, V][] { return Object.entries(rec) as [K, V][]; } export function ParseGPT(str: string) { const spl = str.split(":"); const game = spl[0] as Game; const playtype = spl[1] as Playtype; const gameConfig = GetGameConfig(game); if (!gameConfig || !gameConfig.validPlaytypes.includes(playtype)) { throw new Error(`Invalid GPT Combination '${str}'.`); } return { game, playtype }; } export function UppercaseFirst(str: string) { return str[0].toUpperCase() + str.substring(1); } export function MillisToSince(ms: number) { return DateTime.fromMillis(ms).toRelative(); } export function FormatTime(ms: number) { return DateTime.fromMillis(ms).toLocaleString(DateTime.DATETIME_MED); } export function FormatDate(ms: number) { return DateTime.fromMillis(ms).toLocaleString(DateTime.DATE_HUGE); } export function FormatDuration(ms: number) { return humaniseDuration(ms, { units: ["d", "h", "m"], maxDecimalPoints: 0, }); } export function FormatTimeSmall(ms: number) { return DateTime.fromMillis(ms).toLocaleString(DateTime.DATE_SHORT); } export function FormatClass( game: Game, playtype: Playtype, classSet: GameClassSets[IDStrings], classValue: integer ) { const gptConfig = GetGamePTConfig(game, playtype); return gptConfig.classHumanisedFormat[classSet][classValue].display; } /** * Given a game, return the discord channel it's associated with. */ export function GetGameChannel(client: Client, game: Game) { const gameChannelID = BotConfig.DISCORD.GAME_CHANNELS[game]; if (!gameChannelID) { throw new Error( `Attempted to get channel for ${game}, but no GAME_CHANNEL was registered.` ); } const channel = client.channels.cache.find((c) => c.id === gameChannelID); if (!channel) { throw new Error(`No channel with ID ${gameChannelID} is in the cache for this bot.`); } if (!channel.isText()) { throw new Error( `Channel ${gameChannelID} (${game}) is not a text channel. Can't send message.` ); } return channel; } /** * Given a chart and a game, return a link to the site for that chart. */ export function CreateChartLink(chart: ChartDocument, game: Game) { if (chart.isPrimary) { return `${BotConfig.TACHI_SERVER_LOCATION}/dashboard/games/${game}/${ chart.playtype }/songs/${chart.songID}/${encodeURIComponent(chart.difficulty)}`; } return `${BotConfig.TACHI_SERVER_LOCATION}/dashboard/games/${game}/${chart.playtype}/songs/${chart.songID}/${chart.chartID}`; } type ScOrPBDoc = ScoreDocument | PBScoreDocument; export function FormatScoreData(score: ScOrPBDoc) { const game = score.game; let lampStr: string = score.scoreData.lamp; let scoreStr = `${score.scoreData.score.toLocaleString()} (${ score.scoreData.grade }, ${score.scoreData.percent.toFixed(2)}%)`; if (game === "iidx" || game === "bms" || game === "pms") { const bp = ( score as ScOrPBDoc< "iidx:SP" | "iidx:DP" | "pms:Controller" | "pms:Keyboard" | "bms:14K" | "bms:7K" > ).scoreData.hitMeta.bp; lampStr = `${score.scoreData.lamp} (BP: ${bp ?? "No Data"})`; const { lower, upper, closer } = GenericFormatGradeDelta( game, score.playtype, score.scoreData.score, score.scoreData.percent, score.scoreData.grade ); scoreStr = `${closer === "lower" ? lower : upper} (${ score.scoreData.score }, ${score.scoreData.percent.toFixed(2)}%)`; } return { lampStr, scoreStr }; } /** * Util for getting a games' grade for a given percent. */ export function GetGradeFromPercent( game: Game, playtype: Playtype, percent: number ): Grades[I] { const gptConfig = GetGamePTConfig(game, playtype); const boundaries = gptConfig.gradeBoundaries; const grades = gptConfig.grades; if (!boundaries) { throw new Error( `Invalid call to GetGradeFromPercent! GPT ${game}:${playtype} does not use grade boundaries.` ); } // (hey, this for loop is backwards!) for (let i = boundaries.length; i >= 0; i--) { if (percent + Number.EPSILON >= boundaries[i]) { return grades[i] as Grades[I]; } } throw new Error(`Couldn't find grade for ${game}:${playtype} (${percent}%)`); } export function FormatIIDXEXScore( exscore: integer, notecount: integer, playtype: Playtypes["iidx"] ) { const percent = (exscore * 100) / (notecount * 2); const { closer, upper, lower } = GenericFormatGradeDelta( "iidx", playtype, exscore, percent, GetGradeFromPercent("iidx", playtype, percent) ); return `${closer === "lower" ? lower : upper} (${exscore}, ${percent.toFixed(2)}%)`; } export function GetChartPertinentInfo(game: Game, chart: ChartDocument) { if (game === "iidx") { const ch = chart as ChartDocument<"iidx:DP" | "iidx:SP">; if (!ch.data.kaidenAverage || !ch.data.worldRecord) { return null; } return `皆伝 Average: ${FormatIIDXEXScore( ch.data.kaidenAverage, ch.data.notecount, ch.playtype )} e-amusement WR: ${FormatIIDXEXScore(ch.data.worldRecord, ch.data.notecount, ch.playtype)}`; } return null; } export function FormatChartTierlistInfo(game: Game, chart: ChartDocument) { const gptConfig = GetGamePTConfig(game, chart.playtype); if (gptConfig.tierlists.length === 0) { return null; } const fmts = []; for (const tierlist of gptConfig.tierlists) { const data = chart.tierlistInfo[tierlist]; if (!data) { continue; } fmts.push( `${tierlist}: ${data.text} (${data.value}${data.individualDifference ? " ⚖️" : ""})` ); } if (fmts.length === 0) { return null; } return fmts.join("\n"); } export function ConvertInputIntoGenerousRegex(input: string) { const inputSafeRegex = _.escapeRegExp(input); // for any non-ascii input, replace them with a ".?", representing maybe. This // is so users can say things like "Re Master" or "Remaster" for "Re:Master". // It also generally gives lenience. // We match based on what the string starts with case-insensitively. // "A" will match "ANOTHER", but not "NORMAL". const regex = new RegExp(`^${inputSafeRegex.replace(/[^a-zA-Z]/gu, ".?")}`, "iu"); return regex; } export function CreateSongMap(songs: SongDocument[]) { const songMap = new Map>(); for (const song of songs) { songMap.set(song.id, song); } return songMap; } export function CreateUserMap(users: PublicUserDocument[]) { const userMap = new Map(); for (const user of users) { userMap.set(user.id, user); } return userMap; } export function CreateGoalMap(goals: GoalDocument[]) { const goalMap = new Map(); for (const goal of goals) { goalMap.set(goal.goalID, goal); } return goalMap; } export function CreateChartIDMap(arr: T[]): Map { const map = new Map(); for (const t of arr) { map.set(t.chartID, t); } return map; } export function CreateChartMap(charts: ChartDocument[]) { const chartMap = new Map>(); for (const chart of charts) { chartMap.set(chart.chartID, chart); } return chartMap; } export function CreateScoreIDMap(scores: ScoreDocument[]) { const scoreMap = new Map>(); for (const score of scores) { scoreMap.set(score.scoreID, score); } return scoreMap; } /** * Sorts On Value numerically. * @returns A sorting function */ export function NumericSOV(getValueFn: (data: T) => number, reverse = false) { if (reverse) { return (a: T, b: T) => getValueFn(b) - getValueFn(a); } return (a: T, b: T) => getValueFn(a) - getValueFn(b); } /** * Sorts On Value using locale compare. * @returns A sorting function */ export function StrSOV(getValueFn: (data: T) => string) { return (a: T, b: T) => getValueFn(a).localeCompare(getValueFn(b)); } export function UniqueOnKey(prop: keyof T) { const seenBefore = new Set(); return (item: T) => { if (seenBefore.has(item[prop])) { return false; } seenBefore.add(item[prop]); return true; }; } export const ONE_SECOND = 1000; export const ONE_MINUTE = ONE_SECOND * 60; export const ONE_HOUR = ONE_MINUTE * 60; export const ONE_DAY = ONE_HOUR * 24; export const ONE_WEEK = ONE_DAY * 7;