Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 24 additions & 11 deletions apps/discord-bot/src/commands/quests/quests.profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ import {
GenericQuestInstance,
METADATA_KEY,
MetadataScanner,
MonthlyQuests,
OverallQuests,
type QuestProgress,
QuestModes,
QuestTime,
User,
Expand Down Expand Up @@ -55,9 +57,12 @@ function getQuestMetadata<T>(constructor: Constructor<T>) {
return Object.fromEntries(metadata);
}

const questMetadata = [DailyQuests, WeeklyQuests, OverallQuests].map((constructor) =>
getQuestMetadata(constructor as Constructor<GenericQuestInstance>)
);
const questMetadata = {
[QuestTime.Daily]: getQuestMetadata(DailyQuests as Constructor<GenericQuestInstance>),
[QuestTime.Weekly]: getQuestMetadata(WeeklyQuests as Constructor<GenericQuestInstance>),
[QuestTime.Monthly]: getQuestMetadata(MonthlyQuests as Constructor<GenericQuestInstance>),
[QuestTime.Overall]: getQuestMetadata(OverallQuests as Constructor<GenericQuestInstance>),
};

export interface QuestProfileProps extends Omit<BaseProfileProps, "time"> {
mode: GameMode<QuestModes>;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<box width="100%">
<text align="left">
{completions > 0 ? "§a" : "§c"}§l{name}
{(completions as number) > 0 ? "§a" : "§c"}§l{name}
</text>
<div width="remaining" />
{isOverall ?
<text>{t(completions)}</text> :
<img margin={2} image={completions === 0 ? cross : check} />}
<text>{t(completions as number)}</text> :
(completions as number) > 0 ?
<img margin={2} image={check} /> :
questProgress ?
<text>§e{t(questProgress.current)}/{t(questProgress.target)}</text> :
<img margin={2} image={cross} />
}
</box>
);
});
Expand Down
3 changes: 2 additions & 1 deletion apps/scripts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^",
Expand Down
248 changes: 248 additions & 0 deletions apps/scripts/src/regen-quest-targets.js
Original file line number Diff line number Diff line change
@@ -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";

Check failure on line 20 in apps/scripts/src/regen-quest-targets.js

View workflow job for this annotation

GitHub Actions / CI

Member 'readdirSync' of the import declaration should be sorted alphabetically
import { fileURLToPath } from "node:url";

Check failure on line 21 in apps/scripts/src/regen-quest-targets.js

View workflow job for this annotation

GitHub Actions / CI

Imports should be sorted alphabetically
import { dirname, join, resolve } from "node:path";

Check failure on line 22 in apps/scripts/src/regen-quest-targets.js

View workflow job for this annotation

GitHub Actions / CI

Imports should be sorted alphabetically
import { config } from "@statsify/util";

Check failure on line 23 in apps/scripts/src/regen-quest-targets.js

View workflow job for this annotation

GitHub Actions / CI

Imports should be sorted alphabetically

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]});`)();

Check failure on line 101 in apps/scripts/src/regen-quest-targets.js

View workflow job for this annotation

GitHub Actions / CI

Use `new Function()` instead of `Function()`
} 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);

Check failure on line 112 in apps/scripts/src/regen-quest-targets.js

View workflow job for this annotation

GitHub Actions / CI

Move arrow function 'formatNumber' to the outer scope

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<string, Record<string, number>> = {",
...lines,
"};",
"",
].join("\n");
}

// ---------------------------------------------------------------------------
// Step 6: Drift report
// ---------------------------------------------------------------------------

function levenshtein(a, b) {
const m = a.length, n = b.length;

Check failure on line 146 in apps/scripts/src/regen-quest-targets.js

View workflow job for this annotation

GitHub Actions / CI

Split 'const' declarations into multiple statements
const dp = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_, j) => i === 0 ? j : j === 0 ? i : 0));

Check failure on line 147 in apps/scripts/src/regen-quest-targets.js

View workflow job for this annotation

GitHub Actions / CI

Nested ternary expression should be parenthesized

Check failure on line 147 in apps/scripts/src/regen-quest-targets.js

View workflow job for this annotation

GitHub Actions / CI

This line has a length of 125. Maximum allowed is 120
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}`);
3 changes: 2 additions & 1 deletion packages/schemas/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion packages/schemas/src/player/gamemodes/quests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -95,6 +96,7 @@ export const OverallQuests = createQuestsInstance(QuestTime.Overall, questModes)

export interface GameQuests {
total: number;
_progress?: Record<string, QuestProgress>;
}

export type GenericQuestInstance = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
],
});
2 changes: 1 addition & 1 deletion packages/schemas/src/player/gamemodes/quests/modes/pit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
],
});
Loading
Loading