From 56bfa86b778864ff3314891c75ccbd7d6dbbba0f Mon Sep 17 00:00:00 2001 From: Cody Date: Tue, 12 May 2026 12:45:34 -0600 Subject: [PATCH 1/4] Add target calculation commands - Add `/target` and `/calculate` subcommands for game stat goals - Support stat and ratio targets with autocomplete and setback estimates - Add localized command and argument strings --- .../src/commands/target/target.command.ts | 522 ++++++++++++++++++ locales/en-US/default.json | 32 ++ 2 files changed, 554 insertions(+) create mode 100644 apps/discord-bot/src/commands/target/target.command.ts diff --git a/apps/discord-bot/src/commands/target/target.command.ts b/apps/discord-bot/src/commands/target/target.command.ts new file mode 100644 index 000000000..638ef2436 --- /dev/null +++ b/apps/discord-bot/src/commands/target/target.command.ts @@ -0,0 +1,522 @@ +/** + * Copyright (c) Statsify + * + * This source code is licensed under the GNU GPL v3 license found in the + * LICENSE file in the root directory of this source tree. + * https://github.com/Statsify/statsify/blob/main/LICENSE + */ + +import { + type APIApplicationCommandOptionChoice, + ApplicationCommandOptionType, +} from "discord-api-types/v10"; +import { + AbstractArgument, + ApiService, + Command, + CommandContext, + EmbedBuilder, + ErrorMessage, + type LocalizationString, + PlayerArgument, + SubCommand, +} from "@statsify/discord"; +import { + type Constructor, + prettify, + removeFormatting, +} from "@statsify/util"; +import { Container } from "typedi"; +import { + LEADERBOARD_RATIOS, + MetadataScanner, + type Player, + PlayerStats, + type Ratio, +} from "@statsify/schemas"; +import { STATUS_COLORS } from "@statsify/logger"; + +type GameKey = keyof PlayerStats; + +interface TargetStat { + denominator?: string; + denominatorName?: string; + key: string; + name: string; + numerator?: string; + numeratorName?: string; + ratio?: Ratio; + type: "ratio" | "stat"; +} + +const apiService = Container.get(ApiService); +const DEFAULT_SETBACK = 15; + +const GAMES: [GameKey, name: string, group?: string][] = [ + ["arcade", "Arcade"], + ["arenabrawl", "Arena Brawl", "classic"], + ["bedwars", "BedWars"], + ["blitzsg", "BlitzSG"], + ["buildbattle", "Build Battle"], + ["challenges", "Challenges"], + ["copsandcrims", "Cops and Crims"], + ["duels", "Duels"], + ["general", "General"], + ["megawalls", "MegaWalls"], + ["murdermystery", "Murder Mystery"], + ["paintball", "Paintball", "classic"], + ["parkour", "Parkour"], + ["pit", "Pit"], + ["quake", "Quake", "classic"], + ["quests", "Quests"], + ["skywars", "SkyWars"], + ["smashheroes", "Smash Heroes"], + ["speeduhc", "Speed UHC"], + ["tntgames", "TNT Games"], + ["turbokartracers", "Turbo Kart Racers", "classic"], + ["uhc", "UHC"], + ["vampirez", "VampireZ", "classic"], + ["walls", "Walls", "classic"], + ["warlords", "Warlords"], + ["woolgames", "WoolGames"], +]; + +const GAME_NAMES = new Map(GAMES.map(([key, name]) => [key, name])); + +const getGameClass = (game: GameKey) => + Reflect.getMetadata("design:type", PlayerStats.prototype, game) as Constructor; + +const statCache = new Map(); + +const targetArgs = (game: GameKey) => [ + new TargetStatArgument(game), + new TargetArgument(), + new PlayerArgument(), + new SetbackArgument(), +]; + +class TargetStatArgument extends AbstractArgument { + public autocomplete = true; + public description: LocalizationString; + public name = "stat"; + public required = true; + public type = ApplicationCommandOptionType.String; + private readonly game: GameKey; + + public constructor(game: GameKey) { + super(); + this.description = (t) => t("arguments.target-stat"); + this.game = game; + } + + public autocompleteHandler( + context: CommandContext + ): APIApplicationCommandOptionChoice[] { + const currentValue = context.option(this.name, "").toLowerCase(); + const stats = getTargetStats(this.game); + + const filtered = currentValue ? + stats.filter((stat) => + [stat.key, stat.name] + .some((value) => value.toLowerCase().includes(currentValue)) + ) : + stats; + + return filtered + .slice(0, 25) + .map((stat) => ({ name: stat.name.slice(0, 100), value: stat.key })); + } +} + +class TargetArgument extends AbstractArgument { + public description: LocalizationString; + public min_value = 0; + public name = "target"; + public required = true; + public type = ApplicationCommandOptionType.Number; + + public constructor() { + super(); + this.description = (t) => t("arguments.target"); + } +} + +class SetbackArgument extends AbstractArgument { + public description: LocalizationString; + public min_value = 0; + public name = "setback"; + public required = false; + public type = ApplicationCommandOptionType.Integer; + + public constructor() { + super(); + this.description = (t) => t("arguments.target-setback"); + } +} + +@Command({ description: (t) => t("commands.target") }) +export class TargetCommand { + @SubCommand({ description: (t) => t("commands.target-arcade"), args: targetArgs("arcade") }) + public arcade(context: CommandContext) { + return runTarget(context, "arcade"); + } + + @SubCommand({ description: (t) => t("commands.target-arenabrawl"), args: targetArgs("arenabrawl"), group: "classic" }) + public arenabrawl(context: CommandContext) { + return runTarget(context, "arenabrawl"); + } + + @SubCommand({ description: (t) => t("commands.target-bedwars"), args: targetArgs("bedwars") }) + public bedwars(context: CommandContext) { + return runTarget(context, "bedwars"); + } + + @SubCommand({ description: (t) => t("commands.target-blitzsg"), args: targetArgs("blitzsg") }) + public blitzsg(context: CommandContext) { + return runTarget(context, "blitzsg"); + } + + @SubCommand({ description: (t) => t("commands.target-buildbattle"), args: targetArgs("buildbattle") }) + public buildbattle(context: CommandContext) { + return runTarget(context, "buildbattle"); + } + + @SubCommand({ description: (t) => t("commands.target-challenges"), args: targetArgs("challenges") }) + public challenges(context: CommandContext) { + return runTarget(context, "challenges"); + } + + @SubCommand({ description: (t) => t("commands.target-copsandcrims"), args: targetArgs("copsandcrims") }) + public copsandcrims(context: CommandContext) { + return runTarget(context, "copsandcrims"); + } + + @SubCommand({ description: (t) => t("commands.target-duels"), args: targetArgs("duels") }) + public duels(context: CommandContext) { + return runTarget(context, "duels"); + } + + @SubCommand({ description: (t) => t("commands.target-general"), args: targetArgs("general") }) + public general(context: CommandContext) { + return runTarget(context, "general"); + } + + @SubCommand({ description: (t) => t("commands.target-megawalls"), args: targetArgs("megawalls") }) + public megawalls(context: CommandContext) { + return runTarget(context, "megawalls"); + } + + @SubCommand({ description: (t) => t("commands.target-murdermystery"), args: targetArgs("murdermystery") }) + public murdermystery(context: CommandContext) { + return runTarget(context, "murdermystery"); + } + + @SubCommand({ description: (t) => t("commands.target-paintball"), args: targetArgs("paintball"), group: "classic" }) + public paintball(context: CommandContext) { + return runTarget(context, "paintball"); + } + + @SubCommand({ description: (t) => t("commands.target-parkour"), args: targetArgs("parkour") }) + public parkour(context: CommandContext) { + return runTarget(context, "parkour"); + } + + @SubCommand({ description: (t) => t("commands.target-pit"), args: targetArgs("pit") }) + public pit(context: CommandContext) { + return runTarget(context, "pit"); + } + + @SubCommand({ description: (t) => t("commands.target-quake"), args: targetArgs("quake"), group: "classic" }) + public quake(context: CommandContext) { + return runTarget(context, "quake"); + } + + @SubCommand({ description: (t) => t("commands.target-quests"), args: targetArgs("quests") }) + public quests(context: CommandContext) { + return runTarget(context, "quests"); + } + + @SubCommand({ description: (t) => t("commands.target-skywars"), args: targetArgs("skywars") }) + public skywars(context: CommandContext) { + return runTarget(context, "skywars"); + } + + @SubCommand({ description: (t) => t("commands.target-smashheroes"), args: targetArgs("smashheroes") }) + public smashheroes(context: CommandContext) { + return runTarget(context, "smashheroes"); + } + + @SubCommand({ description: (t) => t("commands.target-speeduhc"), args: targetArgs("speeduhc") }) + public speeduhc(context: CommandContext) { + return runTarget(context, "speeduhc"); + } + + @SubCommand({ description: (t) => t("commands.target-tntgames"), args: targetArgs("tntgames") }) + public tntgames(context: CommandContext) { + return runTarget(context, "tntgames"); + } + + @SubCommand({ description: (t) => t("commands.target-turbokartracers"), args: targetArgs("turbokartracers"), group: "classic" }) + public turbokartracers(context: CommandContext) { + return runTarget(context, "turbokartracers"); + } + + @SubCommand({ description: (t) => t("commands.target-uhc"), args: targetArgs("uhc") }) + public uhc(context: CommandContext) { + return runTarget(context, "uhc"); + } + + @SubCommand({ description: (t) => t("commands.target-vampirez"), args: targetArgs("vampirez"), group: "classic" }) + public vampirez(context: CommandContext) { + return runTarget(context, "vampirez"); + } + + @SubCommand({ description: (t) => t("commands.target-walls"), args: targetArgs("walls"), group: "classic" }) + public walls(context: CommandContext) { + return runTarget(context, "walls"); + } + + @SubCommand({ description: (t) => t("commands.target-warlords"), args: targetArgs("warlords") }) + public warlords(context: CommandContext) { + return runTarget(context, "warlords"); + } + + @SubCommand({ description: (t) => t("commands.target-woolgames"), args: targetArgs("woolgames") }) + public woolgames(context: CommandContext) { + return runTarget(context, "woolgames"); + } +} + +@Command({ name: "calculate", description: (t) => t("commands.calculate") }) +export class CalculateCommand extends TargetCommand {} + +async function runTarget(context: CommandContext, game: GameKey) { + const user = context.getUser(); + const player = await apiService.getPlayer(context.option("player"), user); + const target = context.option("target"); + const setback = context.option("setback", DEFAULT_SETBACK); + const stat = resolveTargetStat(game, context.option("stat")); + const gameStats = player.stats[game] as unknown as Record; + const level = getLevel(gameStats); + + if (stat.type === "ratio") { + return buildRatioResponse(player, game, gameStats, stat, target, setback, level); + } + + return buildStatResponse(player, game, gameStats, stat, target, level); +} + +function buildRatioResponse( + player: Player, + game: GameKey, + gameStats: Record, + stat: TargetStat, + target: number, + setback: number, + level?: string +) { + const numerator = getNumber(gameStats, stat.numerator!); + const denominator = getNumber(gameStats, stat.denominator!); + const current = denominator === 0 ? numerator : numerator / denominator; + const needed = Math.max(0, Math.ceil(target * denominator - numerator)); + const neededWithSetback = Math.max( + 0, + Math.ceil(target * (denominator + setback) - numerator) + ); + const numeratorName = stat.numeratorName!; + const denominatorName = singularize(stat.denominatorName!); + + const lines = [ + `Current: **${formatDecimal(current)} ${stat.name}**`, + `Needed: **${formatInteger(needed)} ${numeratorName}** without another ${denominatorName}`, + ]; + + if (setback > 0) { + lines.push( + `Or: **${formatInteger(neededWithSetback)} ${numeratorName}** if you take **${formatInteger(setback)} ${stat.denominatorName}**` + ); + } + + return { + embeds: [ + baseEmbed(player, game, level) + .title(`To reach ${formatDecimal(target)} ${stat.name}:`) + .description(lines.join("\n")), + ], + }; +} + +function buildStatResponse( + player: Player, + game: GameKey, + gameStats: Record, + stat: TargetStat, + target: number, + level?: string +) { + const current = getNumber(gameStats, stat.key); + const needed = Math.max(0, Math.ceil(target - current)); + const statName = statNameLower(stat.name); + + return { + embeds: [ + baseEmbed(player, game, level) + .title(`To reach ${formatTarget(target)} ${stat.name}:`) + .description( + [ + `Current: **${formatTarget(current)} ${stat.name}**`, + `Needed: **${formatInteger(needed)} ${statName}**${needed === 0 ? " (target reached)" : ""}`, + ].join("\n") + ), + ], + }; +} + +function baseEmbed(player: Player, game: GameKey, level?: string) { + const titleParts = [player.displayName]; + if (level) titleParts.push(level); + + return new EmbedBuilder() + .author(titleParts.join(" ")) + .footer(GAME_NAMES.get(game)!) + .color(STATUS_COLORS.info); +} + +function getTargetStats(game: GameKey) { + if (statCache.has(game)) return statCache.get(game)!; + + const metadata = MetadataScanner.scan(getGameClass(game)); + const numberFields = metadata + .filter(([, { type }]) => type.type === Number) + .map(([key, { leaderboard }]) => ({ + key, + name: cleanName(leaderboard.fieldName || leaderboard.name || prettify(key)), + })); + + const byKey = new Map(numberFields.map((field) => [field.key, field])); + const ratioKeys = new Set(LEADERBOARD_RATIOS.map((ratio) => ratio[2])); + const ratios: TargetStat[] = []; + + for (const [numerator, denominator, ratioKey, prettyName] of LEADERBOARD_RATIOS) { + for (const field of numberFields) { + if (lastPathPart(field.key) !== ratioKey) continue; + + const parent = parentPath(field.key); + const numeratorKey = pathWithParent(parent, numerator); + const denominatorKey = pathWithParent(parent, denominator); + const numeratorField = byKey.get(numeratorKey); + const denominatorField = byKey.get(denominatorKey); + + if (!numeratorField || !denominatorField) continue; + + ratios.push({ + denominator: denominatorKey, + denominatorName: statNameLower(denominatorField.name), + key: field.key, + name: parent === "overall" || !parent ? prettyName : `${cleanName(parent)} ${prettyName}`, + numerator: numeratorKey, + numeratorName: statNameLower(numeratorField.name), + ratio: [numerator, denominator, ratioKey, prettyName], + type: "ratio", + }); + } + } + + const stats: TargetStat[] = [ + ...ratios, + ...numberFields + .filter((field) => !ratioKeys.has(lastPathPart(field.key))) + .map((field) => ({ ...field, type: "stat" as const })), + ]; + + statCache.set(game, stats); + return stats; +} + +function resolveTargetStat(game: GameKey, input: string) { + const stats = getTargetStats(game); + const normalized = input.toLowerCase(); + const exact = stats.find((stat) => stat.key === input); + if (exact) return exact; + + const overall = stats.find( + (stat) => + stat.key.toLowerCase() === `overall.${normalized}` || + stat.name.toLowerCase() === normalized + ); + if (overall) return overall; + + const fallback = stats.find( + (stat) => + lastPathPart(stat.key).toLowerCase() === normalized || + stat.name.toLowerCase().includes(normalized) + ); + + if (!fallback) { + throw new ErrorMessage( + "Target stat not found", + `I couldn't find \`${input}\` for ${GAME_NAMES.get(game)}. Use the stat autocomplete to pick a supported target.` + ); + } + + return fallback; +} + +function getLevel(gameStats: Record) { + const formatted = gameStats.levelFormatted || gameStats.naturalLevelFormatted; + if (typeof formatted === "string") return removeFormatting(formatted); + + const level = gameStats.level; + if (typeof level === "number") return `Level ${formatDecimal(level)}`; + + return undefined; +} + +function getNumber(data: Record, path: string) { + const value = path + .split(".") + .reduce((acc, key) => (acc as Record | undefined)?.[key], data); + + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function cleanName(value: string) { + return removeFormatting(value) + .replace(/\s+/g, " ") + .trim(); +} + +function statNameLower(value: string) { + return cleanName(value).toLowerCase(); +} + +function singularize(value: string) { + return value.endsWith("s") ? value.slice(0, -1) : value; +} + +function formatDecimal(value: number) { + return value.toLocaleString("en-US", { + maximumFractionDigits: 2, + minimumFractionDigits: 2, + }); +} + +function formatInteger(value: number) { + return value.toLocaleString("en-US", { maximumFractionDigits: 0 }); +} + +function formatTarget(value: number) { + return Number.isInteger(value) ? formatInteger(value) : formatDecimal(value); +} + +function lastPathPart(path: string) { + return path.split(".").at(-1)!; +} + +function parentPath(path: string) { + return path.split(".").slice(0, -1).join("."); +} + +function pathWithParent(parent: string, key: string) { + return parent ? `${parent}.${key}` : key; +} diff --git a/locales/en-US/default.json b/locales/en-US/default.json index b66f3e884..5e94dcc89 100644 --- a/locales/en-US/default.json +++ b/locales/en-US/default.json @@ -14,6 +14,9 @@ "server": "A Minecraft server name or a server IP", "tags-content": "The content of the tag", "tags-name": "The name of the tag", + "target": "The goal you want to reach", + "target-setback": "How many denominator stats to include in the alternate calculation", + "target-stat": "The stat or ratio you want to target", "text": "A message", "user": "Choose a Discord user" }, @@ -31,6 +34,7 @@ "buildbattle": "$t(commands.hypixel-command, { \"name\": \"Build Battle\" })", "cape": "View someone's Minecraft and Optifine capes", "challenges": "$t(commands.hypixel-command, { \"name\": \"Challenge\" })", + "calculate": "$t(commands.target)", "quests-command": "View your {{name}} questing stats", "quests": "$t(commands.hypixel-command, { \"name\": \"Questing\" })", "quests-overall": "$t(commands.quests-command, { \"name\": \"Overall\" })", @@ -212,6 +216,34 @@ "tags-delete": "Delete a support tag", "tags-rename": "Rename a support tag", "text": "Generate Minecraft text", + "target": "Calculate what you need to reach a stat target", + "target-arcade": "$t(commands.target-command, { \"name\": \"Arcade\" })", + "target-arenabrawl": "$t(commands.target-command, { \"name\": \"Arena Brawl\" })", + "target-bedwars": "$t(commands.target-command, { \"name\": \"BedWars\" })", + "target-blitzsg": "$t(commands.target-command, { \"name\": \"BlitzSG\" })", + "target-buildbattle": "$t(commands.target-command, { \"name\": \"Build Battle\" })", + "target-command": "Calculate a {{name}} stat target", + "target-challenges": "$t(commands.target-command, { \"name\": \"Challenges\" })", + "target-copsandcrims": "$t(commands.target-command, { \"name\": \"Cops and Crims\" })", + "target-duels": "$t(commands.target-command, { \"name\": \"Duels\" })", + "target-general": "$t(commands.target-command, { \"name\": \"General\" })", + "target-megawalls": "$t(commands.target-command, { \"name\": \"MegaWalls\" })", + "target-murdermystery": "$t(commands.target-command, { \"name\": \"Murder Mystery\" })", + "target-paintball": "$t(commands.target-command, { \"name\": \"Paintball\" })", + "target-parkour": "$t(commands.target-command, { \"name\": \"Parkour\" })", + "target-pit": "$t(commands.target-command, { \"name\": \"Pit\" })", + "target-quake": "$t(commands.target-command, { \"name\": \"Quake\" })", + "target-quests": "$t(commands.target-command, { \"name\": \"Quests\" })", + "target-skywars": "$t(commands.target-command, { \"name\": \"SkyWars\" })", + "target-smashheroes": "$t(commands.target-command, { \"name\": \"Smash Heroes\" })", + "target-speeduhc": "$t(commands.target-command, { \"name\": \"Speed UHC\" })", + "target-tntgames": "$t(commands.target-command, { \"name\": \"TNT Games\" })", + "target-turbokartracers": "$t(commands.target-command, { \"name\": \"Turbo Kart Racers\" })", + "target-uhc": "$t(commands.target-command, { \"name\": \"UHC\" })", + "target-vampirez": "$t(commands.target-command, { \"name\": \"VampireZ\" })", + "target-walls": "$t(commands.target-command, { \"name\": \"Walls\" })", + "target-warlords": "$t(commands.target-command, { \"name\": \"Warlords\" })", + "target-woolgames": "$t(commands.target-command, { \"name\": \"WoolGames\" })", "theme": "Change your theme for every profile", "theme-boxes": "Change the appearance of the profile boxes", "theme-font": "Change the font of the profiles", From 9b509d9cb534e209e0cdf853504aa7981a3cc5f3 Mon Sep 17 00:00:00 2001 From: Robert Date: Tue, 12 May 2026 13:00:50 -0600 Subject: [PATCH 2/4] Revert "Add target calculation commands" (#849) This reverts commit 56bfa86b778864ff3314891c75ccbd7d6dbbba0f. --- .../src/commands/target/target.command.ts | 522 ------------------ locales/en-US/default.json | 32 -- 2 files changed, 554 deletions(-) delete mode 100644 apps/discord-bot/src/commands/target/target.command.ts diff --git a/apps/discord-bot/src/commands/target/target.command.ts b/apps/discord-bot/src/commands/target/target.command.ts deleted file mode 100644 index 638ef2436..000000000 --- a/apps/discord-bot/src/commands/target/target.command.ts +++ /dev/null @@ -1,522 +0,0 @@ -/** - * Copyright (c) Statsify - * - * This source code is licensed under the GNU GPL v3 license found in the - * LICENSE file in the root directory of this source tree. - * https://github.com/Statsify/statsify/blob/main/LICENSE - */ - -import { - type APIApplicationCommandOptionChoice, - ApplicationCommandOptionType, -} from "discord-api-types/v10"; -import { - AbstractArgument, - ApiService, - Command, - CommandContext, - EmbedBuilder, - ErrorMessage, - type LocalizationString, - PlayerArgument, - SubCommand, -} from "@statsify/discord"; -import { - type Constructor, - prettify, - removeFormatting, -} from "@statsify/util"; -import { Container } from "typedi"; -import { - LEADERBOARD_RATIOS, - MetadataScanner, - type Player, - PlayerStats, - type Ratio, -} from "@statsify/schemas"; -import { STATUS_COLORS } from "@statsify/logger"; - -type GameKey = keyof PlayerStats; - -interface TargetStat { - denominator?: string; - denominatorName?: string; - key: string; - name: string; - numerator?: string; - numeratorName?: string; - ratio?: Ratio; - type: "ratio" | "stat"; -} - -const apiService = Container.get(ApiService); -const DEFAULT_SETBACK = 15; - -const GAMES: [GameKey, name: string, group?: string][] = [ - ["arcade", "Arcade"], - ["arenabrawl", "Arena Brawl", "classic"], - ["bedwars", "BedWars"], - ["blitzsg", "BlitzSG"], - ["buildbattle", "Build Battle"], - ["challenges", "Challenges"], - ["copsandcrims", "Cops and Crims"], - ["duels", "Duels"], - ["general", "General"], - ["megawalls", "MegaWalls"], - ["murdermystery", "Murder Mystery"], - ["paintball", "Paintball", "classic"], - ["parkour", "Parkour"], - ["pit", "Pit"], - ["quake", "Quake", "classic"], - ["quests", "Quests"], - ["skywars", "SkyWars"], - ["smashheroes", "Smash Heroes"], - ["speeduhc", "Speed UHC"], - ["tntgames", "TNT Games"], - ["turbokartracers", "Turbo Kart Racers", "classic"], - ["uhc", "UHC"], - ["vampirez", "VampireZ", "classic"], - ["walls", "Walls", "classic"], - ["warlords", "Warlords"], - ["woolgames", "WoolGames"], -]; - -const GAME_NAMES = new Map(GAMES.map(([key, name]) => [key, name])); - -const getGameClass = (game: GameKey) => - Reflect.getMetadata("design:type", PlayerStats.prototype, game) as Constructor; - -const statCache = new Map(); - -const targetArgs = (game: GameKey) => [ - new TargetStatArgument(game), - new TargetArgument(), - new PlayerArgument(), - new SetbackArgument(), -]; - -class TargetStatArgument extends AbstractArgument { - public autocomplete = true; - public description: LocalizationString; - public name = "stat"; - public required = true; - public type = ApplicationCommandOptionType.String; - private readonly game: GameKey; - - public constructor(game: GameKey) { - super(); - this.description = (t) => t("arguments.target-stat"); - this.game = game; - } - - public autocompleteHandler( - context: CommandContext - ): APIApplicationCommandOptionChoice[] { - const currentValue = context.option(this.name, "").toLowerCase(); - const stats = getTargetStats(this.game); - - const filtered = currentValue ? - stats.filter((stat) => - [stat.key, stat.name] - .some((value) => value.toLowerCase().includes(currentValue)) - ) : - stats; - - return filtered - .slice(0, 25) - .map((stat) => ({ name: stat.name.slice(0, 100), value: stat.key })); - } -} - -class TargetArgument extends AbstractArgument { - public description: LocalizationString; - public min_value = 0; - public name = "target"; - public required = true; - public type = ApplicationCommandOptionType.Number; - - public constructor() { - super(); - this.description = (t) => t("arguments.target"); - } -} - -class SetbackArgument extends AbstractArgument { - public description: LocalizationString; - public min_value = 0; - public name = "setback"; - public required = false; - public type = ApplicationCommandOptionType.Integer; - - public constructor() { - super(); - this.description = (t) => t("arguments.target-setback"); - } -} - -@Command({ description: (t) => t("commands.target") }) -export class TargetCommand { - @SubCommand({ description: (t) => t("commands.target-arcade"), args: targetArgs("arcade") }) - public arcade(context: CommandContext) { - return runTarget(context, "arcade"); - } - - @SubCommand({ description: (t) => t("commands.target-arenabrawl"), args: targetArgs("arenabrawl"), group: "classic" }) - public arenabrawl(context: CommandContext) { - return runTarget(context, "arenabrawl"); - } - - @SubCommand({ description: (t) => t("commands.target-bedwars"), args: targetArgs("bedwars") }) - public bedwars(context: CommandContext) { - return runTarget(context, "bedwars"); - } - - @SubCommand({ description: (t) => t("commands.target-blitzsg"), args: targetArgs("blitzsg") }) - public blitzsg(context: CommandContext) { - return runTarget(context, "blitzsg"); - } - - @SubCommand({ description: (t) => t("commands.target-buildbattle"), args: targetArgs("buildbattle") }) - public buildbattle(context: CommandContext) { - return runTarget(context, "buildbattle"); - } - - @SubCommand({ description: (t) => t("commands.target-challenges"), args: targetArgs("challenges") }) - public challenges(context: CommandContext) { - return runTarget(context, "challenges"); - } - - @SubCommand({ description: (t) => t("commands.target-copsandcrims"), args: targetArgs("copsandcrims") }) - public copsandcrims(context: CommandContext) { - return runTarget(context, "copsandcrims"); - } - - @SubCommand({ description: (t) => t("commands.target-duels"), args: targetArgs("duels") }) - public duels(context: CommandContext) { - return runTarget(context, "duels"); - } - - @SubCommand({ description: (t) => t("commands.target-general"), args: targetArgs("general") }) - public general(context: CommandContext) { - return runTarget(context, "general"); - } - - @SubCommand({ description: (t) => t("commands.target-megawalls"), args: targetArgs("megawalls") }) - public megawalls(context: CommandContext) { - return runTarget(context, "megawalls"); - } - - @SubCommand({ description: (t) => t("commands.target-murdermystery"), args: targetArgs("murdermystery") }) - public murdermystery(context: CommandContext) { - return runTarget(context, "murdermystery"); - } - - @SubCommand({ description: (t) => t("commands.target-paintball"), args: targetArgs("paintball"), group: "classic" }) - public paintball(context: CommandContext) { - return runTarget(context, "paintball"); - } - - @SubCommand({ description: (t) => t("commands.target-parkour"), args: targetArgs("parkour") }) - public parkour(context: CommandContext) { - return runTarget(context, "parkour"); - } - - @SubCommand({ description: (t) => t("commands.target-pit"), args: targetArgs("pit") }) - public pit(context: CommandContext) { - return runTarget(context, "pit"); - } - - @SubCommand({ description: (t) => t("commands.target-quake"), args: targetArgs("quake"), group: "classic" }) - public quake(context: CommandContext) { - return runTarget(context, "quake"); - } - - @SubCommand({ description: (t) => t("commands.target-quests"), args: targetArgs("quests") }) - public quests(context: CommandContext) { - return runTarget(context, "quests"); - } - - @SubCommand({ description: (t) => t("commands.target-skywars"), args: targetArgs("skywars") }) - public skywars(context: CommandContext) { - return runTarget(context, "skywars"); - } - - @SubCommand({ description: (t) => t("commands.target-smashheroes"), args: targetArgs("smashheroes") }) - public smashheroes(context: CommandContext) { - return runTarget(context, "smashheroes"); - } - - @SubCommand({ description: (t) => t("commands.target-speeduhc"), args: targetArgs("speeduhc") }) - public speeduhc(context: CommandContext) { - return runTarget(context, "speeduhc"); - } - - @SubCommand({ description: (t) => t("commands.target-tntgames"), args: targetArgs("tntgames") }) - public tntgames(context: CommandContext) { - return runTarget(context, "tntgames"); - } - - @SubCommand({ description: (t) => t("commands.target-turbokartracers"), args: targetArgs("turbokartracers"), group: "classic" }) - public turbokartracers(context: CommandContext) { - return runTarget(context, "turbokartracers"); - } - - @SubCommand({ description: (t) => t("commands.target-uhc"), args: targetArgs("uhc") }) - public uhc(context: CommandContext) { - return runTarget(context, "uhc"); - } - - @SubCommand({ description: (t) => t("commands.target-vampirez"), args: targetArgs("vampirez"), group: "classic" }) - public vampirez(context: CommandContext) { - return runTarget(context, "vampirez"); - } - - @SubCommand({ description: (t) => t("commands.target-walls"), args: targetArgs("walls"), group: "classic" }) - public walls(context: CommandContext) { - return runTarget(context, "walls"); - } - - @SubCommand({ description: (t) => t("commands.target-warlords"), args: targetArgs("warlords") }) - public warlords(context: CommandContext) { - return runTarget(context, "warlords"); - } - - @SubCommand({ description: (t) => t("commands.target-woolgames"), args: targetArgs("woolgames") }) - public woolgames(context: CommandContext) { - return runTarget(context, "woolgames"); - } -} - -@Command({ name: "calculate", description: (t) => t("commands.calculate") }) -export class CalculateCommand extends TargetCommand {} - -async function runTarget(context: CommandContext, game: GameKey) { - const user = context.getUser(); - const player = await apiService.getPlayer(context.option("player"), user); - const target = context.option("target"); - const setback = context.option("setback", DEFAULT_SETBACK); - const stat = resolveTargetStat(game, context.option("stat")); - const gameStats = player.stats[game] as unknown as Record; - const level = getLevel(gameStats); - - if (stat.type === "ratio") { - return buildRatioResponse(player, game, gameStats, stat, target, setback, level); - } - - return buildStatResponse(player, game, gameStats, stat, target, level); -} - -function buildRatioResponse( - player: Player, - game: GameKey, - gameStats: Record, - stat: TargetStat, - target: number, - setback: number, - level?: string -) { - const numerator = getNumber(gameStats, stat.numerator!); - const denominator = getNumber(gameStats, stat.denominator!); - const current = denominator === 0 ? numerator : numerator / denominator; - const needed = Math.max(0, Math.ceil(target * denominator - numerator)); - const neededWithSetback = Math.max( - 0, - Math.ceil(target * (denominator + setback) - numerator) - ); - const numeratorName = stat.numeratorName!; - const denominatorName = singularize(stat.denominatorName!); - - const lines = [ - `Current: **${formatDecimal(current)} ${stat.name}**`, - `Needed: **${formatInteger(needed)} ${numeratorName}** without another ${denominatorName}`, - ]; - - if (setback > 0) { - lines.push( - `Or: **${formatInteger(neededWithSetback)} ${numeratorName}** if you take **${formatInteger(setback)} ${stat.denominatorName}**` - ); - } - - return { - embeds: [ - baseEmbed(player, game, level) - .title(`To reach ${formatDecimal(target)} ${stat.name}:`) - .description(lines.join("\n")), - ], - }; -} - -function buildStatResponse( - player: Player, - game: GameKey, - gameStats: Record, - stat: TargetStat, - target: number, - level?: string -) { - const current = getNumber(gameStats, stat.key); - const needed = Math.max(0, Math.ceil(target - current)); - const statName = statNameLower(stat.name); - - return { - embeds: [ - baseEmbed(player, game, level) - .title(`To reach ${formatTarget(target)} ${stat.name}:`) - .description( - [ - `Current: **${formatTarget(current)} ${stat.name}**`, - `Needed: **${formatInteger(needed)} ${statName}**${needed === 0 ? " (target reached)" : ""}`, - ].join("\n") - ), - ], - }; -} - -function baseEmbed(player: Player, game: GameKey, level?: string) { - const titleParts = [player.displayName]; - if (level) titleParts.push(level); - - return new EmbedBuilder() - .author(titleParts.join(" ")) - .footer(GAME_NAMES.get(game)!) - .color(STATUS_COLORS.info); -} - -function getTargetStats(game: GameKey) { - if (statCache.has(game)) return statCache.get(game)!; - - const metadata = MetadataScanner.scan(getGameClass(game)); - const numberFields = metadata - .filter(([, { type }]) => type.type === Number) - .map(([key, { leaderboard }]) => ({ - key, - name: cleanName(leaderboard.fieldName || leaderboard.name || prettify(key)), - })); - - const byKey = new Map(numberFields.map((field) => [field.key, field])); - const ratioKeys = new Set(LEADERBOARD_RATIOS.map((ratio) => ratio[2])); - const ratios: TargetStat[] = []; - - for (const [numerator, denominator, ratioKey, prettyName] of LEADERBOARD_RATIOS) { - for (const field of numberFields) { - if (lastPathPart(field.key) !== ratioKey) continue; - - const parent = parentPath(field.key); - const numeratorKey = pathWithParent(parent, numerator); - const denominatorKey = pathWithParent(parent, denominator); - const numeratorField = byKey.get(numeratorKey); - const denominatorField = byKey.get(denominatorKey); - - if (!numeratorField || !denominatorField) continue; - - ratios.push({ - denominator: denominatorKey, - denominatorName: statNameLower(denominatorField.name), - key: field.key, - name: parent === "overall" || !parent ? prettyName : `${cleanName(parent)} ${prettyName}`, - numerator: numeratorKey, - numeratorName: statNameLower(numeratorField.name), - ratio: [numerator, denominator, ratioKey, prettyName], - type: "ratio", - }); - } - } - - const stats: TargetStat[] = [ - ...ratios, - ...numberFields - .filter((field) => !ratioKeys.has(lastPathPart(field.key))) - .map((field) => ({ ...field, type: "stat" as const })), - ]; - - statCache.set(game, stats); - return stats; -} - -function resolveTargetStat(game: GameKey, input: string) { - const stats = getTargetStats(game); - const normalized = input.toLowerCase(); - const exact = stats.find((stat) => stat.key === input); - if (exact) return exact; - - const overall = stats.find( - (stat) => - stat.key.toLowerCase() === `overall.${normalized}` || - stat.name.toLowerCase() === normalized - ); - if (overall) return overall; - - const fallback = stats.find( - (stat) => - lastPathPart(stat.key).toLowerCase() === normalized || - stat.name.toLowerCase().includes(normalized) - ); - - if (!fallback) { - throw new ErrorMessage( - "Target stat not found", - `I couldn't find \`${input}\` for ${GAME_NAMES.get(game)}. Use the stat autocomplete to pick a supported target.` - ); - } - - return fallback; -} - -function getLevel(gameStats: Record) { - const formatted = gameStats.levelFormatted || gameStats.naturalLevelFormatted; - if (typeof formatted === "string") return removeFormatting(formatted); - - const level = gameStats.level; - if (typeof level === "number") return `Level ${formatDecimal(level)}`; - - return undefined; -} - -function getNumber(data: Record, path: string) { - const value = path - .split(".") - .reduce((acc, key) => (acc as Record | undefined)?.[key], data); - - return typeof value === "number" && Number.isFinite(value) ? value : 0; -} - -function cleanName(value: string) { - return removeFormatting(value) - .replace(/\s+/g, " ") - .trim(); -} - -function statNameLower(value: string) { - return cleanName(value).toLowerCase(); -} - -function singularize(value: string) { - return value.endsWith("s") ? value.slice(0, -1) : value; -} - -function formatDecimal(value: number) { - return value.toLocaleString("en-US", { - maximumFractionDigits: 2, - minimumFractionDigits: 2, - }); -} - -function formatInteger(value: number) { - return value.toLocaleString("en-US", { maximumFractionDigits: 0 }); -} - -function formatTarget(value: number) { - return Number.isInteger(value) ? formatInteger(value) : formatDecimal(value); -} - -function lastPathPart(path: string) { - return path.split(".").at(-1)!; -} - -function parentPath(path: string) { - return path.split(".").slice(0, -1).join("."); -} - -function pathWithParent(parent: string, key: string) { - return parent ? `${parent}.${key}` : key; -} diff --git a/locales/en-US/default.json b/locales/en-US/default.json index 5e94dcc89..b66f3e884 100644 --- a/locales/en-US/default.json +++ b/locales/en-US/default.json @@ -14,9 +14,6 @@ "server": "A Minecraft server name or a server IP", "tags-content": "The content of the tag", "tags-name": "The name of the tag", - "target": "The goal you want to reach", - "target-setback": "How many denominator stats to include in the alternate calculation", - "target-stat": "The stat or ratio you want to target", "text": "A message", "user": "Choose a Discord user" }, @@ -34,7 +31,6 @@ "buildbattle": "$t(commands.hypixel-command, { \"name\": \"Build Battle\" })", "cape": "View someone's Minecraft and Optifine capes", "challenges": "$t(commands.hypixel-command, { \"name\": \"Challenge\" })", - "calculate": "$t(commands.target)", "quests-command": "View your {{name}} questing stats", "quests": "$t(commands.hypixel-command, { \"name\": \"Questing\" })", "quests-overall": "$t(commands.quests-command, { \"name\": \"Overall\" })", @@ -216,34 +212,6 @@ "tags-delete": "Delete a support tag", "tags-rename": "Rename a support tag", "text": "Generate Minecraft text", - "target": "Calculate what you need to reach a stat target", - "target-arcade": "$t(commands.target-command, { \"name\": \"Arcade\" })", - "target-arenabrawl": "$t(commands.target-command, { \"name\": \"Arena Brawl\" })", - "target-bedwars": "$t(commands.target-command, { \"name\": \"BedWars\" })", - "target-blitzsg": "$t(commands.target-command, { \"name\": \"BlitzSG\" })", - "target-buildbattle": "$t(commands.target-command, { \"name\": \"Build Battle\" })", - "target-command": "Calculate a {{name}} stat target", - "target-challenges": "$t(commands.target-command, { \"name\": \"Challenges\" })", - "target-copsandcrims": "$t(commands.target-command, { \"name\": \"Cops and Crims\" })", - "target-duels": "$t(commands.target-command, { \"name\": \"Duels\" })", - "target-general": "$t(commands.target-command, { \"name\": \"General\" })", - "target-megawalls": "$t(commands.target-command, { \"name\": \"MegaWalls\" })", - "target-murdermystery": "$t(commands.target-command, { \"name\": \"Murder Mystery\" })", - "target-paintball": "$t(commands.target-command, { \"name\": \"Paintball\" })", - "target-parkour": "$t(commands.target-command, { \"name\": \"Parkour\" })", - "target-pit": "$t(commands.target-command, { \"name\": \"Pit\" })", - "target-quake": "$t(commands.target-command, { \"name\": \"Quake\" })", - "target-quests": "$t(commands.target-command, { \"name\": \"Quests\" })", - "target-skywars": "$t(commands.target-command, { \"name\": \"SkyWars\" })", - "target-smashheroes": "$t(commands.target-command, { \"name\": \"Smash Heroes\" })", - "target-speeduhc": "$t(commands.target-command, { \"name\": \"Speed UHC\" })", - "target-tntgames": "$t(commands.target-command, { \"name\": \"TNT Games\" })", - "target-turbokartracers": "$t(commands.target-command, { \"name\": \"Turbo Kart Racers\" })", - "target-uhc": "$t(commands.target-command, { \"name\": \"UHC\" })", - "target-vampirez": "$t(commands.target-command, { \"name\": \"VampireZ\" })", - "target-walls": "$t(commands.target-command, { \"name\": \"Walls\" })", - "target-warlords": "$t(commands.target-command, { \"name\": \"Warlords\" })", - "target-woolgames": "$t(commands.target-command, { \"name\": \"WoolGames\" })", "theme": "Change your theme for every profile", "theme-boxes": "Change the appearance of the profile boxes", "theme-font": "Change the font of the profiles", From ea7d5590715c5545375235ab77af4858b3d0ddf6 Mon Sep 17 00:00:00 2001 From: Cody Date: Sat, 30 May 2026 16:35:01 -0600 Subject: [PATCH 3/4] feat(quests): source objective targets from Hypixel resource with drift check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a committed generated map of quest objective targets keyed by Hypixel quest id, a drift-check/regen script, and wire the targets into QuestOption so they are available at the schema layer without any runtime fetch. - packages/schemas: add objective-targets.generated.ts (127 quests from the live Hypixel quests resource, sorted for deterministic diffs) - packages/schemas: extend QuestOption with optional `objectives` field; createGameModeQuests now populates it from the generated map at module load time — quests without a match are unchanged - apps/scripts: add regen-quest-targets.js — fetches live resource, regenerates the committed map, and prints a 4-category drift report (local missing from Hypixel, Hypixel missing from local, target changes, and fuzzy/containment near-mismatches) - apps/scripts, packages/schemas: add `quests:regen` script alias Co-Authored-By: Claude Sonnet 4.6 --- apps/scripts/package.json | 3 +- apps/scripts/src/regen-quest-targets.js | 248 ++++++++++++++++++ packages/schemas/package.json | 3 +- .../quests/objective-targets.generated.ts | 141 ++++++++++ .../src/player/gamemodes/quests/util.ts | 11 + 5 files changed, 404 insertions(+), 2 deletions(-) create mode 100644 apps/scripts/src/regen-quest-targets.js create mode 100644 packages/schemas/src/player/gamemodes/quests/objective-targets.generated.ts diff --git a/apps/scripts/package.json b/apps/scripts/package.json index d883f2577..fbcd808b2 100644 --- a/apps/scripts/package.json +++ b/apps/scripts/package.json @@ -11,7 +11,8 @@ "purge": "node src/purge.js", "rank-emojis": "node src/rank-emojis.js", "timestamp": "node --no-warnings src/timestamp.js", - "validate-commands": "node --no-warnings src/validate-commands.js" + "validate-commands": "node --no-warnings src/validate-commands.js", + "quests:regen": "node src/regen-quest-targets.js" }, "dependencies": { "@statsify/api-client": "workspace:^", diff --git a/apps/scripts/src/regen-quest-targets.js b/apps/scripts/src/regen-quest-targets.js new file mode 100644 index 000000000..ec180f549 --- /dev/null +++ b/apps/scripts/src/regen-quest-targets.js @@ -0,0 +1,248 @@ +/** + * Copyright (c) Statsify + * + * This source code is licensed under the GNU GPL v3 license found in the + * LICENSE file in the root directory of this source tree. + * https://github.com/Statsify/statsify/blob/main/LICENSE + */ + +/** + * Regenerates packages/schemas/src/player/gamemodes/quests/objective-targets.generated.ts + * from the live Hypixel quests resource, and prints a drift report comparing: + * 1. Local quest ids missing from the Hypixel resource + * 2. Hypixel quest ids missing from our local schema + * 3. Objective target changes (committed map value ≠ live resource value) + * 4. Suspicious id near-mismatches (local vs Hypixel) + * + * Usage: pnpm scripts quests:regen + */ + +import { readFileSync, writeFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; +import { config } from "@statsify/util"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const MONOREPO_ROOT = resolve(__dirname, "../../../"); +const MODES_DIR = join(MONOREPO_ROOT, "packages/schemas/src/player/gamemodes/quests/modes"); +const GENERATED_FILE = join(MONOREPO_ROOT, "packages/schemas/src/player/gamemodes/quests/objective-targets.generated.ts"); + +// --------------------------------------------------------------------------- +// Step 1: Parse local quest ids from mode files +// --------------------------------------------------------------------------- + +function parseLocalQuestIds() { + const localIds = new Set(); + + const files = readdirSync(MODES_DIR).filter((f) => f.endsWith(".ts") && f !== "index.ts"); + + for (const file of files) { + const content = readFileSync(join(MODES_DIR, file), "utf8"); + + const prefixMatch = /fieldPrefix:\s*"([^"]+)"/.exec(content); + const fieldPrefix = prefixMatch?.[1]; + + for (const match of content.matchAll(/field:\s*"([^"]+)"/g)) { + const field = match[1]; + localIds.add(fieldPrefix ? `${fieldPrefix}_${field}` : field); + } + } + + return localIds; +} + +// --------------------------------------------------------------------------- +// Step 2: Fetch live Hypixel quests resource +// --------------------------------------------------------------------------- + +async function fetchHypixelQuests(apiKey) { + const res = await fetch("https://api.hypixel.net/v2/resources/quests", { + headers: { "API-Key": apiKey }, + }); + + if (!res.ok) throw new Error(`Hypixel API returned ${res.status}: ${await res.text()}`); + + const json = await res.json(); + if (!json.success) throw new Error(`Hypixel API error: ${JSON.stringify(json)}`); + + return json.quests; +} + +// --------------------------------------------------------------------------- +// Step 3: Build new objective-targets map from live resource +// --------------------------------------------------------------------------- + +function buildTargetsMap(hypixelQuests) { + const entries = Object.values(hypixelQuests) + .flat() + .map((quest) => { + const objectives = Object.fromEntries( + quest.objectives + .filter((o) => typeof o.integer === "number") + .map((o) => [o.id, o.integer]) + ); + return [quest.id, objectives]; + }); + + entries.sort((a, b) => a[0].localeCompare(b[0])); + return Object.fromEntries(entries); +} + +// --------------------------------------------------------------------------- +// Step 4: Parse existing generated file for comparison +// --------------------------------------------------------------------------- + +function parseExistingTargets() { + try { + const content = readFileSync(GENERATED_FILE, "utf8"); + const match = /QUEST_OBJECTIVE_TARGETS[^=]+=\s*(\{[\s\S]*?\});/.exec(content); + if (!match) return {}; + // Safe eval of the plain object literal (no imports, no functions) + return Function(`"use strict"; return (${match[1]});`)(); + } catch { + return {}; + } +} + +// --------------------------------------------------------------------------- +// Step 5: Generate file content +// --------------------------------------------------------------------------- + +function buildFileContent(targetsMap) { + const formatNumber = (n) => n >= 10_000 ? n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, "_") : String(n); + + const lines = Object.entries(targetsMap).map(([id, objectives]) => { + const objEntries = Object.entries(objectives); + if (objEntries.length === 0) return ` ${id}: {},`; + const objStr = objEntries.map(([k, v]) => `${k}: ${formatNumber(v)}`).join(", "); + return ` ${id}: { ${objStr} },`; + }); + + return [ + "/**", + " * Copyright (c) Statsify", + " *", + " * This source code is licensed under the GNU GPL v3 license found in the", + " * LICENSE file in the root directory of this source tree.", + " * https://github.com/Statsify/statsify/blob/main/LICENSE", + " */", + "", + "// @generated — do NOT edit manually.", + "// Run `pnpm scripts quests:regen` to regenerate from the live Hypixel resource.", + "// Shape: { [hypixelQuestId]: { [objectiveId]: target } }", + "", + "export const QUEST_OBJECTIVE_TARGETS: Record> = {", + ...lines, + "};", + "", + ].join("\n"); +} + +// --------------------------------------------------------------------------- +// Step 6: Drift report +// --------------------------------------------------------------------------- + +function levenshtein(a, b) { + const m = a.length, n = b.length; + const dp = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_, j) => i === 0 ? j : j === 0 ? i : 0)); + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); + } + } + return dp[m][n]; +} + +function printDriftReport(localIds, newTargets, existingTargets) { + const hypixelIds = new Set(Object.keys(newTargets)); + const localArr = [...localIds].sort(); + const hypixelArr = [...hypixelIds].sort(); + + let issues = 0; + + // Category 1: local ids missing from Hypixel + const missingFromHypixel = localArr.filter((id) => !hypixelIds.has(id)); + if (missingFromHypixel.length > 0) { + console.log(`\n[1] Local quest ids NOT found in the Hypixel resource (${missingFromHypixel.length}):`); + for (const id of missingFromHypixel) console.log(` - ${id}`); + issues += missingFromHypixel.length; + } + + // Category 2: Hypixel ids missing from local schema + const missingFromLocal = hypixelArr.filter((id) => !localIds.has(id)); + if (missingFromLocal.length > 0) { + console.log(`\n[2] Hypixel quest ids NOT tracked in our local schema (${missingFromLocal.length}):`); + for (const id of missingFromLocal) console.log(` + ${id}`); + issues += missingFromLocal.length; + } + + // Category 3: objective target changes + const changes = []; + for (const id of Object.keys(newTargets)) { + if (!(id in existingTargets)) continue; + const oldObj = existingTargets[id]; + const newObj = newTargets[id]; + const allKeys = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]); + for (const k of allKeys) { + if (oldObj[k] !== newObj[k]) { + changes.push({ id, key: k, old: oldObj[k], next: newObj[k] }); + } + } + } + if (changes.length > 0) { + console.log(`\n[3] Objective target changes (${changes.length}):`); + for (const c of changes) { + console.log(` ~ ${c.id} / ${c.key}: ${c.old} → ${c.next}`); + } + issues += changes.length; + } + + // Category 4: near-mismatches (edit distance ≤ 5, or one id is contained within the other) + const nearMatches = []; + for (const localId of missingFromHypixel) { + const candidates = hypixelArr + .map((hId) => ({ hId, dist: levenshtein(localId, hId) })) + .filter(({ hId, dist }) => dist <= 5 || localId.includes(hId) || hId.includes(localId)) + .sort((a, b) => a.dist - b.dist) + .slice(0, 3); + if (candidates.length > 0) nearMatches.push({ localId, candidates }); + } + if (nearMatches.length > 0) { + console.log(`\n[4] Suspicious near-mismatches (local ≈ Hypixel):`); + for (const { localId, candidates } of nearMatches) { + const suggestions = candidates.map(({ hId, dist }) => `${hId} (dist=${dist})`).join(", "); + console.log(` ? ${localId} → ${suggestions}`); + } + } + + if (issues === 0 && changes.length === 0) { + console.log("\n✓ No drift detected — generated map is current."); + } else { + console.log(`\n⚠ Drift detected: ${issues} issue(s) found. Review and update the schema or re-run after Hypixel fixes.`); + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const apiKey = await config("hypixelApi.key"); + +console.log("Parsing local quest ids from mode files..."); +const localIds = parseLocalQuestIds(); +console.log(` Found ${localIds.size} local quest ids.`); + +console.log("Fetching Hypixel quests resource..."); +const hypixelQuests = await fetchHypixelQuests(apiKey); +const totalHypixelQuests = Object.values(hypixelQuests).flat().length; +console.log(` Found ${totalHypixelQuests} quests across ${Object.keys(hypixelQuests).length} games.`); + +const existingTargets = parseExistingTargets(); +const newTargets = buildTargetsMap(hypixelQuests); + +console.log("\n--- Drift Report ---"); +printDriftReport(localIds, newTargets, existingTargets); + +const newContent = buildFileContent(newTargets); +writeFileSync(GENERATED_FILE, newContent, "utf8"); +console.log(`\nWrote ${Object.keys(newTargets).length} quest entries to ${GENERATED_FILE}`); diff --git a/packages/schemas/package.json b/packages/schemas/package.json index 5aac15176..4d51b4cee 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -7,7 +7,8 @@ "scripts": { "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --copy-files", "test:types": "tsc --noEmit", - "lint": "eslint" + "lint": "eslint", + "quests:regen": "pnpm --filter scripts quests:regen" }, "dependencies": { "@nestjs/swagger": "^11.2.0", diff --git a/packages/schemas/src/player/gamemodes/quests/objective-targets.generated.ts b/packages/schemas/src/player/gamemodes/quests/objective-targets.generated.ts new file mode 100644 index 000000000..8bbf10430 --- /dev/null +++ b/packages/schemas/src/player/gamemodes/quests/objective-targets.generated.ts @@ -0,0 +1,141 @@ +/** + * Copyright (c) Statsify + * + * This source code is licensed under the GNU GPL v3 license found in the + * LICENSE file in the root directory of this source tree. + * https://github.com/Statsify/statsify/blob/main/LICENSE + */ + +// @generated — do NOT edit manually. +// Run `pnpm scripts quests:regen` to regenerate from the live Hypixel resource. +// Shape: { [hypixelQuestId]: { [objectiveId]: target } } + +export const QUEST_OBJECTIVE_TARGETS: Record> = { + arcade_gamer: { play: 3 }, + arcade_specialist: { play: 20 }, + arcade_winner: { win: 1 }, + arena_daily_kills: { arena_daily_kills: 5 }, + arena_daily_play: { arena_daily_play: 5 }, + arena_daily_wins: { arena_daily_wins: 2 }, + arena_weekly_play: { arena_weekly_play: 35 }, + bedwars_daily_bed_breaker: { bedwars_weekly_final_killer: 3 }, + bedwars_daily_final_killer: { bedwars_daily_final_killer: 15 }, + bedwars_daily_one_more: { bedwars_daily_played: 3 }, + bedwars_daily_win: { bedwars_daily_win: 1 }, + bedwars_weekly_bed_elims: { bedwars_bed_elims: 25 }, + bedwars_weekly_challenges_win: { bedwars_weekly_challenge_wins: 5 }, + bedwars_weekly_dream_win: { bedwars_dream_wins: 10 }, + bedwars_weekly_final_killer: { bedwars_weekly_final_killer: 150 }, + blitz_game_of_the_day: {}, + blitz_kills: { killblitz10: 5 }, + blitz_loot_chest_daily: { lootchestblitz: 25 }, + blitz_loot_chest_weekly: { lootchestblitz: 100, dealdamageblitz: 250 }, + blitz_weekly_master: { blitz_games_played: 15, winblitz: 5, killblitz10: 30 }, + blitz_win: {}, + build_battle_player: { play: 3 }, + build_battle_weekly: { play: 30 }, + build_battle_winner: { win: 1 }, + crazy_walls_daily_kill: { crazy_walls_daily_kill: 10 }, + crazy_walls_daily_play: { crazy_walls_daily_play: 2 }, + crazy_walls_daily_win: { crazy_walls_daily_win: 1 }, + crazy_walls_weekly: { crazy_walls_weekly_play: 30 }, + cvc_kill: { cvc_kill_daily_deathmatch: 300 }, + cvc_kill_daily_normal: { cvc_kill_daily_normal: 15 }, + cvc_kill_weekly: { cvc_play_weekly: 100, cvc_play_weekly_2: 1500, cvc_play_weekly_3: 20 }, + cvc_play_daily_gungame: { cvc_play_daily_gungame: 3 }, + cvc_win_daily_deathmatch: { cvc_play_daily_deathmatch: 1 }, + cvc_win_daily_normal: { cvc_play_daily_normal: 1 }, + duels_killer: { kill: 5 }, + duels_player: { play: 5 }, + duels_weekly_kills: { kill: 100 }, + duels_weekly_wins: { win: 50 }, + duels_winner: { win: 1 }, + gingerbread_bling_bling: { gingerbread_gold_pickedup: 50 }, + gingerbread_maps: { gingerbread_races_completed: 5 }, + gingerbread_mastery: { gingerbread_races_completed: 35 }, + gingerbread_racer: { gingerbread_laps_completed: 5 }, + mega_walls_aggressor: { mega_walls_wither_damage_daily: 500 }, + mega_walls_defender: { mega_walls_kill_daily: 10 }, + mega_walls_faithful: { mega_walls_faithful_play: 3, mega_walls_faifthful_win: 1 }, + mega_walls_kill: { mega_walls_kill_daily: 15 }, + mega_walls_play: { mega_walls_play: 1 }, + mega_walls_teamwork: { mega_walls_wither_damage_weekly: 1500, mega_walls_kill_weekly: 50 }, + mega_walls_weekly: { mega_walls_play_weekly: 15, mega_walls_kill_weekly: 25 }, + mega_walls_win: { mega_walls_win: 1 }, + mm_daily_infector: { mm_infection_kills: 10 }, + mm_daily_power_play: { mm_power_play: 1 }, + mm_daily_target_kill: { mm_target_kills: 2 }, + mm_daily_win: { mm_daily_win: 1 }, + mm_weekly_murderer_kills: { mm_weekly_kills_as_murderer: 40 }, + mm_weekly_wins: { mm_weekly_win: 15 }, + paintball_expert: { kill: 750, play: 30 }, + paintball_killer: { kill: 100 }, + paintballer: { win: 1 }, + pit_daily_contract: { pit_daily_contract: 1 }, + pit_daily_kills: { kill: 25 }, + pit_weekly_gold: { pit_weekly_gold: 10_000 }, + quake_daily_kill: { quake_daily_kill: 50 }, + quake_daily_play: { quake_daily_play: 3 }, + quake_daily_win: { quake_daily_win: 1 }, + quake_weekly_play: { quake_weekly_play: 20, quake_weekly_streak: 10 }, + skyclash_kills: { kill: 15 }, + skyclash_play_games: { play: 5 }, + skyclash_play_points: { skyclash_play_points: 24 }, + skyclash_void: { skyclash_void_kills: 5, skyclash_enderchests: 3 }, + skyclash_weekly_kills: { kill: 150 }, + skywars_arcade_win: { skywars_arcade_win: 1 }, + skywars_daily_mega_kills: { skywars_mega_daily_kills: 5 }, + skywars_daily_mini_win: { skywars_mini_daily_win: 1 }, + skywars_monthly_earn_opals: { skywars_earn_opals: 1 }, + skywars_solo_win: { skywars_solo_win: 1 }, + skywars_team_win: { skywars_team_win: 1 }, + skywars_weekly_kills: { skywars_weekly_kills: 150 }, + skywars_weekly_wins: { skywars_weekly_wins: 15 }, + solo_brawler: {}, + supersmash_solo_kills: { supersmash_solo_kills: 15 }, + supersmash_solo_win: { supersmash_solo_win: 1 }, + supersmash_team_kills: { supersmash_team_kills: 15 }, + supersmash_team_win: { supersmash_team_win: 1 }, + supersmash_weekly_kills: { supersmash_weekly_kills: 150 }, + team_brawler: {}, + tnt_bowspleef_daily: { tnt_bowspleef_daily: 40 }, + tnt_bowspleef_weekly: { tnt_bowspleef_weekly: 200 }, + tnt_daily_win: { tnt_daily_win: 1 }, + tnt_pvprun_daily: { tnt_pvprun_daily: 3 }, + tnt_pvprun_weekly: { tnt_pvprun_weekly: 25 }, + tnt_tntrun_daily: { tnt_tntrun_daily: 500 }, + tnt_tntrun_weekly: { tnt_tntrun_weekly: 2000 }, + tnt_tnttag_daily: { tnt_tnttag_daily: 7 }, + tnt_tnttag_weekly: { tnt_tnttag_weekly: 50 }, + tnt_weekly_play: { tnt_weekly_play: 20 }, + tnt_wizards_daily: { tnt_wizards_daily_kills: 10 }, + tnt_wizards_weekly: { tnt_wizards_weekly_kills: 150 }, + uhc_dm: { uhc_kills: 2 }, + uhc_madness: { kill: 100 }, + uhc_solo: { uhc_kills: 1 }, + uhc_team: { uhc_kills: 1 }, + uhc_weekly: { uhc_kills: 20 }, + vampirez_daily_human_kill: { vampirez_daily_kill_human: 10 }, + vampirez_daily_kill: { vampirez_daily_kill_vampire: 10, vampirez_daily_kill_zombie: 20 }, + vampirez_daily_play: { vampirez_daily_play: 1 }, + vampirez_daily_win: { vampirez_daily_win: 1 }, + vampirez_weekly_human_kill: { vampirez_weekly_kill_survivor: 100 }, + vampirez_weekly_kill: { vampirez_weekly_kill_zombie: 130, vampirez_weekly_kill_vampire: 25 }, + vampirez_weekly_win: { vampirez_weekly_win_survivor: 12 }, + walls_daily_kill: { walls_daily_kill: 5 }, + walls_daily_play: { walls_daily_play: 1 }, + walls_daily_win: { walls_daily_win: 1 }, + walls_weekly: { walls_weekly_play: 7, walls_weekly_kills: 25 }, + warlords_all_star: { warlords_weekly_damage: 1_500_000, warlords_weekly_heal: 1_500_000 }, + warlords_ctf: {}, + warlords_dedication: { warlords_weekly_dedi: 20 }, + warlords_domination: {}, + warlords_objectives: { warlords_daily_objectives: 100 }, + warlords_tdm: {}, + warlords_victorious: {}, + wool_wars_daily_kills: { kill: 20 }, + wool_wars_daily_play: { play: 1 }, + wool_wars_daily_wins: { win: 3 }, + wool_wars_weekly_shears: { wool_weekly_shears: 200 }, + wool_weekly_play: { win: 15, kill: 100 }, +}; diff --git a/packages/schemas/src/player/gamemodes/quests/util.ts b/packages/schemas/src/player/gamemodes/quests/util.ts index ddfdddd88..da6713dd1 100644 --- a/packages/schemas/src/player/gamemodes/quests/util.ts +++ b/packages/schemas/src/player/gamemodes/quests/util.ts @@ -15,6 +15,7 @@ import { import { DateTime } from "luxon"; import { Field } from "#metadata"; import { FormattedGame } from "#game"; +import { QUEST_OBJECTIVE_TARGETS } from "./objective-targets.generated.js"; interface Quest { completions?: { time: number }[]; @@ -37,6 +38,8 @@ export interface QuestOption { fieldName?: string; name?: string; }; + /** Objective id → integer target, sourced from the Hypixel quests resource. Populated by createGameModeQuests; do not set manually. */ + objectives?: Record; } export interface CreateQuestsOptions< @@ -139,6 +142,14 @@ export function createGameModeQuests< WeeklyFields, MonthlyFields > { + for (const quest of [...daily, ...weekly, ...monthly]) { + if (quest.objectives === undefined) { + const id = fieldPrefix ? `${fieldPrefix}_${quest.field}` : quest.field; + const targets = QUEST_OBJECTIVE_TARGETS[id]; + if (targets !== undefined) quest.objectives = targets; + } + } + class Daily { [key: string]: number; From 6171ecd690172d97a5ce0e024fe6d028107fbd55 Mon Sep 17 00:00:00 2001 From: Cody Date: Mon, 1 Jun 2026 03:50:53 -0600 Subject: [PATCH 4/4] feat(quests): enhance quest progress tracking and metadata * Introduced MonthlyQuests and updated questMetadata structure * Improved quest entries filtering and sorting logic * Added support for tracking quest progress with current and target values * Updated various quest modes to reflect new field names and structures --- .../src/commands/quests/quests.profile.tsx | 35 +++++++++++----- apps/scripts/src/regen-quest-targets.js | 4 +- .../src/player/gamemodes/quests/index.ts | 4 +- .../player/gamemodes/quests/modes/bedwars.ts | 2 +- .../src/player/gamemodes/quests/modes/pit.ts | 2 +- .../player/gamemodes/quests/modes/vampirez.ts | 2 +- .../src/player/gamemodes/quests/util.ts | 40 ++++++++++++++++--- 7 files changed, 67 insertions(+), 22 deletions(-) diff --git a/apps/discord-bot/src/commands/quests/quests.profile.tsx b/apps/discord-bot/src/commands/quests/quests.profile.tsx index a94762f72..286fed038 100644 --- a/apps/discord-bot/src/commands/quests/quests.profile.tsx +++ b/apps/discord-bot/src/commands/quests/quests.profile.tsx @@ -17,7 +17,9 @@ import { GenericQuestInstance, METADATA_KEY, MetadataScanner, + MonthlyQuests, OverallQuests, + type QuestProgress, QuestModes, QuestTime, User, @@ -55,9 +57,12 @@ function getQuestMetadata(constructor: Constructor) { return Object.fromEntries(metadata); } -const questMetadata = [DailyQuests, WeeklyQuests, OverallQuests].map((constructor) => - getQuestMetadata(constructor as Constructor) -); +const questMetadata = { + [QuestTime.Daily]: getQuestMetadata(DailyQuests as Constructor), + [QuestTime.Weekly]: getQuestMetadata(WeeklyQuests as Constructor), + [QuestTime.Monthly]: getQuestMetadata(MonthlyQuests as Constructor), + [QuestTime.Overall]: getQuestMetadata(OverallQuests as Constructor), +}; export interface QuestProfileProps extends Omit { mode: GameMode; @@ -90,9 +95,11 @@ const NormalTable = ({ quests, t, gameIcons, colorPalette, time }: NormalTablePr const BOX_COLOR = (colorPalette?.boxes?.color as string) ?? Box.DEFAULT_COLOR; const entries: GameEntry[] = questEntries - // Require more than just a total field - .filter(([_, q]) => Object.keys(q).length > 1) - .map(([k, v]) => [k, v, Object.keys(v).length - 1] as const) + .map(([k, v]) => { + const questCount = Object.keys(v).filter((key) => key !== "total" && key !== "_progress").length; + return [k, v, questCount] as const; + }) + .filter(([_, __, questCount]) => questCount > 0) .sort((a, b) => ratio(b[1]?.total ?? 0, b[2]) - ratio(a[1]?.total ?? 0, a[2])) .map(([k, v, total]) => { const completed = v.total; @@ -145,20 +152,26 @@ const GameTable = ({ quests, t, game, time, logos: [cross, check] }: GameTablePr const isOverall = time === QuestTime.Overall; const entries = Object.entries(quests) - .filter(([k, v]) => k !== "total" && v !== null) - .sort((a, b) => b[1] - a[1]) + .filter(([k, v]) => k !== "total" && k !== "_progress" && v !== null) + .sort((a, b) => (b[1] as number) - (a[1] as number)) .map(([quest, completions]) => { const name = questMetadata[time][game][quest].leaderboard.name; + const questProgress = quests._progress?.[quest] as QuestProgress | undefined; return ( - {completions > 0 ? "§a" : "§c"}§l{name} + {(completions as number) > 0 ? "§a" : "§c"}§l{name}
{isOverall ? - {t(completions)} : - } + {t(completions as number)} : + (completions as number) > 0 ? + : + questProgress ? + §e{t(questProgress.current)}/{t(questProgress.target)} : + + } ); }); diff --git a/apps/scripts/src/regen-quest-targets.js b/apps/scripts/src/regen-quest-targets.js index ec180f549..28db1cb4d 100644 --- a/apps/scripts/src/regen-quest-targets.js +++ b/apps/scripts/src/regen-quest-targets.js @@ -77,7 +77,7 @@ function buildTargetsMap(hypixelQuests) { .flat() .map((quest) => { const objectives = Object.fromEntries( - quest.objectives + (quest.objectives ?? []) .filter((o) => typeof o.integer === "number") .map((o) => [o.id, o.integer]) ); @@ -215,7 +215,7 @@ function printDriftReport(localIds, newTargets, existingTargets) { } } - if (issues === 0 && changes.length === 0) { + if (issues === 0) { console.log("\n✓ No drift detected — generated map is current."); } else { console.log(`\n⚠ Drift detected: ${issues} issue(s) found. Review and update the schema or re-run after Hypixel fixes.`); diff --git a/packages/schemas/src/player/gamemodes/quests/index.ts b/packages/schemas/src/player/gamemodes/quests/index.ts index 3dcb0b5b4..b8e7e1c9d 100644 --- a/packages/schemas/src/player/gamemodes/quests/index.ts +++ b/packages/schemas/src/player/gamemodes/quests/index.ts @@ -33,7 +33,8 @@ import { } from "./modes/index.js"; import { ExtractGameModes, FormattedGame, GameModes } from "#game"; import { Field } from "#metadata"; -import { QuestTime, createQuestsInstance } from "./util.js"; +import { type QuestProgress, QuestTime, createQuestsInstance } from "./util.js"; +export type { QuestProgress } from "./util.js"; export const QUEST_MODES = new GameModes([ { api: "overall" }, @@ -95,6 +96,7 @@ export const OverallQuests = createQuestsInstance(QuestTime.Overall, questModes) export interface GameQuests { total: number; + _progress?: Record; } export type GenericQuestInstance = { diff --git a/packages/schemas/src/player/gamemodes/quests/modes/bedwars.ts b/packages/schemas/src/player/gamemodes/quests/modes/bedwars.ts index 5c41104ba..6b4da8c16 100644 --- a/packages/schemas/src/player/gamemodes/quests/modes/bedwars.ts +++ b/packages/schemas/src/player/gamemodes/quests/modes/bedwars.ts @@ -21,7 +21,7 @@ export const BedWarsQuests = createGameModeQuests({ weekly: [ { field: "weekly_bed_elims", propertyKey: "bedRemovalCo", name: "Bed Removal Co." }, { field: "weekly_dream_win", propertyKey: "sleepTight", name: "Sleep Tight." }, - { field: "weekly_challenges", propertyKey: "challenger" }, + { field: "weekly_challenges_win", propertyKey: "challenger" }, { field: "weekly_final_killer", propertyKey: "finishingTheJob" }, ], }); diff --git a/packages/schemas/src/player/gamemodes/quests/modes/pit.ts b/packages/schemas/src/player/gamemodes/quests/modes/pit.ts index 2663165e0..3e720097f 100644 --- a/packages/schemas/src/player/gamemodes/quests/modes/pit.ts +++ b/packages/schemas/src/player/gamemodes/quests/modes/pit.ts @@ -11,7 +11,7 @@ import { createGameModeQuests } from "../util.js"; export const PitQuests = createGameModeQuests({ game: FormattedGame.PIT, - fieldPrefix: "prototype_pit", + fieldPrefix: "pit", daily: [ { field: "daily_kills", propertyKey: "hunter" }, { field: "daily_contract", propertyKey: "contracted" }, diff --git a/packages/schemas/src/player/gamemodes/quests/modes/vampirez.ts b/packages/schemas/src/player/gamemodes/quests/modes/vampirez.ts index 45380eb5e..b935e4843 100644 --- a/packages/schemas/src/player/gamemodes/quests/modes/vampirez.ts +++ b/packages/schemas/src/player/gamemodes/quests/modes/vampirez.ts @@ -20,7 +20,7 @@ export const VampireZQuests = createGameModeQuests({ ], weekly: [ { field: "weekly_win", propertyKey: "vampireWinner" }, - { field: "vampirez_weekly_kill", propertyKey: "vampireSlayer" }, + { field: "weekly_kill", propertyKey: "vampireSlayer" }, { field: "weekly_human_kill", propertyKey: "humanSlayer" }, ], }); diff --git a/packages/schemas/src/player/gamemodes/quests/util.ts b/packages/schemas/src/player/gamemodes/quests/util.ts index da6713dd1..0052354a8 100644 --- a/packages/schemas/src/player/gamemodes/quests/util.ts +++ b/packages/schemas/src/player/gamemodes/quests/util.ts @@ -17,8 +17,16 @@ import { Field } from "#metadata"; import { FormattedGame } from "#game"; import { QUEST_OBJECTIVE_TARGETS } from "./objective-targets.generated.js"; +export interface QuestProgress { + current: number; + target: number; +} + interface Quest { completions?: { time: number }[]; + active?: { + objectives?: Record; + }; } export enum QuestTime { @@ -66,8 +74,10 @@ export interface CreateQuestsOptions< monthly?: QuestOption[]; } +type QuestInstance = Record>; + const processQuests = ( - instance: Record, + instance: QuestInstance, quests: APIData, time: QuestTime, options: QuestOption[], @@ -78,7 +88,18 @@ const processQuests = ( const field = fieldPrefix ? `${fieldPrefix}_${quest.field}` : quest.field; instance[k] = getQuestCountDuring(time, quests[field]); - instance.total += instance[k] ?? 0; + instance.total = (instance.total as number) + ((instance[k] as number) ?? 0); + + if ("_progress" in instance && quest.objectives) { + const active = (quests[field] as Quest | undefined)?.active?.objectives; + if (active) { + const current = Object.keys(quest.objectives).reduce((sum, id) => sum + (active[id] ?? 0), 0); + if (current > 0) { + const target = Object.values(quest.objectives).reduce((sum, v) => sum + v, 0); + (instance._progress as Record)[k] = { current, target }; + } + } + } }); }; @@ -151,11 +172,14 @@ export function createGameModeQuests< } class Daily { - [key: string]: number; + [key: string]: number | Record; @Field(questTotalFieldData(game)) public total: number = 0; + @Field({ leaderboard: { enabled: false }, historical: { enabled: false } }) + public _progress: Record = {}; + public constructor(quests: APIData) { processQuests(this, quests, QuestTime.Daily, daily, fieldPrefix); } @@ -164,11 +188,14 @@ export function createGameModeQuests< assignQuestMetadata(Daily, QuestTime.Daily, daily); class Weekly { - [key: string]: number; + [key: string]: number | Record; @Field(questTotalFieldData(game)) public total: number = 0; + @Field({ leaderboard: { enabled: false }, historical: { enabled: false } }) + public _progress: Record = {}; + public constructor(quests: APIData) { processQuests(this, quests, QuestTime.Weekly, weekly, fieldPrefix); } @@ -177,11 +204,14 @@ export function createGameModeQuests< assignQuestMetadata(Weekly, QuestTime.Weekly, weekly); class Monthly { - [key: string]: number; + [key: string]: number | Record; @Field(questTotalFieldData(game)) public total: number = 0; + @Field({ leaderboard: { enabled: false }, historical: { enabled: false } }) + public _progress: Record = {}; + public constructor(quests: APIData) { processQuests(this, quests, QuestTime.Monthly, monthly, fieldPrefix); }