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/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..28db1cb4d --- /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) { + 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/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/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..0052354a8 100644 --- a/packages/schemas/src/player/gamemodes/quests/util.ts +++ b/packages/schemas/src/player/gamemodes/quests/util.ts @@ -15,9 +15,18 @@ import { import { DateTime } from "luxon"; 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 { @@ -37,6 +46,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< @@ -63,8 +74,10 @@ export interface CreateQuestsOptions< monthly?: QuestOption[]; } +type QuestInstance = Record>; + const processQuests = ( - instance: Record, + instance: QuestInstance, quests: APIData, time: QuestTime, options: QuestOption[], @@ -75,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 }; + } + } + } }); }; @@ -139,12 +163,23 @@ 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; + [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); } @@ -153,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); } @@ -166,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); }