mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-15 02:29:28 +03:00
fix: sdvx goals maxxive + ensure this is easier to fix in future (#1661)
* fix: goalfix * fix: goalfix 2 * fix: fmt * fix: script for fixing quests, too, ugh
This commit is contained in:
+1
-1
@@ -81,5 +81,5 @@ db-load-dataset:
|
||||
echo "Restoring $file into $db_name..."
|
||||
gunzip -c "$file" | psql -d "$db_url"
|
||||
|
||||
echo "Done. Change typescript/server/.env to use POSTGRES=\"$db_url\""
|
||||
echo "Done. Change typescript/server/.env to use POSTGRES_URL=\"$db_url\""
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- When a goal's canonical id changes (content hash), child rows should follow.
|
||||
-- Used by reconcile-goal-ids and other goal-id maintenance scripts.
|
||||
|
||||
ALTER TABLE "goal_sub" DROP CONSTRAINT goal_sub_goal_id_fkey;
|
||||
|
||||
ALTER TABLE "goal_sub"
|
||||
ADD CONSTRAINT goal_sub_goal_id_fkey
|
||||
FOREIGN KEY (goal_id) REFERENCES goal (id) ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "import_goal" DROP CONSTRAINT import_goal_goal_id_fkey;
|
||||
|
||||
ALTER TABLE "import_goal"
|
||||
ADD CONSTRAINT import_goal_goal_id_fkey
|
||||
FOREIGN KEY (goal_id) REFERENCES goal (id) ON UPDATE CASCADE;
|
||||
+154
-154
File diff suppressed because it is too large
Load Diff
@@ -878,7 +878,7 @@
|
||||
"desc": "",
|
||||
"goals": [
|
||||
{
|
||||
"goalID": "G6f51d83a87ea6468d36357d2fbebad8248a1db01503d409f7bced22a17d21199",
|
||||
"goalID": "G7e7b6ba4f55a4523c7b81435cd3230c651d9e7d4b9cd56dba261ff5188334dbe",
|
||||
"note": "The first UC goal. Don't hesitate to try new 13s - don't get stuck on the ones you can't do now."
|
||||
},
|
||||
{
|
||||
@@ -934,7 +934,7 @@
|
||||
"desc": "",
|
||||
"goals": [
|
||||
{
|
||||
"goalID": "G2753016668b125fa4c054f8c5044e59ebee545a1cb4aa50f9a95af5e2afd600d"
|
||||
"goalID": "G5635cab6c361a3ae6445fb5838142ddda6f30317cded62dfdc658a70664b9f7a"
|
||||
},
|
||||
{
|
||||
"goalID": "Ga70c91a1d0c0240cfb3c0b3628b4b00e4250208335e09d2efd04cd137325d789"
|
||||
@@ -989,7 +989,7 @@
|
||||
"desc": "",
|
||||
"goals": [
|
||||
{
|
||||
"goalID": "G943e04101cb28a01426725ec45387a3a9de9bdbb999acaa0478f2e482c4da76b"
|
||||
"goalID": "G2ea6029d229d5ef580d24830c6fd2fcc9707f1e964d2b581a8afa18ff79efd14"
|
||||
},
|
||||
{
|
||||
"goalID": "Ge8bef7d934fe71987f467f45f1c7d335062cc66470775c67f85fde23e8eea88a"
|
||||
@@ -1045,7 +1045,7 @@
|
||||
"desc": "",
|
||||
"goals": [
|
||||
{
|
||||
"goalID": "G780126a57153930857d9bb1e3a8aa071f60fe58bf3dbf11a49033b0f249fb483"
|
||||
"goalID": "G8cd12b810909fcfd23c92f24fec287b6c6d6a9a863d8e7ab55879f28ba128f88"
|
||||
},
|
||||
{
|
||||
"goalID": "G475ca03a09c5035de96d162dbae307e016cb16f14d7215817b5fbf613c88ae35"
|
||||
@@ -1105,7 +1105,7 @@
|
||||
"desc": "",
|
||||
"goals": [
|
||||
{
|
||||
"goalID": "G3bf0295c50cf8431f0d2c6aa13595d7dc4dcdf0738ed4288453a9047c3f1fe86"
|
||||
"goalID": "G0702e660dd7161ff60eddae0225767a80c2ac64e84807e499136629bd838b363"
|
||||
},
|
||||
{
|
||||
"goalID": "G475ca03a09c5035de96d162dbae307e016cb16f14d7215817b5fbf613c88ae35",
|
||||
@@ -1165,7 +1165,7 @@
|
||||
"desc": "",
|
||||
"goals": [
|
||||
{
|
||||
"goalID": "G90bcbf690a855cc9be365f22c9fcfe47e1e73b158a2cc2ea228e14452825f354"
|
||||
"goalID": "G0c1d3396aa5bfe920b3dd7b08be96be9a8681933d7ce6d7649c3e288735ba4ba"
|
||||
},
|
||||
{
|
||||
"goalID": "G6358c8004675bb53ae6bde30a8cd93735aa8fd123438cd93256d70fbd6f29cc6",
|
||||
@@ -1222,7 +1222,7 @@
|
||||
"desc": "",
|
||||
"goals": [
|
||||
{
|
||||
"goalID": "G0f88e481ccc93429f62c7124103bb0d29c80eaf46265cb42ebf873fc1cded1ef"
|
||||
"goalID": "Gd3911a7aeeba1bfef571d1113c1a01d046b6f95b42c6acd2e68d9f9e5a4483c2"
|
||||
},
|
||||
{
|
||||
"goalID": "Gba598c9c26a325bb63a54c9fb6be633ef19f4c23e13f569204d3d586a4b6670e",
|
||||
@@ -1277,7 +1277,7 @@
|
||||
"desc": "",
|
||||
"goals": [
|
||||
{
|
||||
"goalID": "G5234d7e017a9d520b2714f84bd66ff9ac28e04573e4afff04249bcef6ed7c0ed"
|
||||
"goalID": "G21ca042b1cbaf0c525a154e6595257338db110772dfc0c654abdd3f05a198673"
|
||||
},
|
||||
{
|
||||
"goalID": "G10b47f5c3084d79bc92d45e616dcfb7dc3a7040aa3155fd49397352be6f5b6fa",
|
||||
|
||||
@@ -14,7 +14,7 @@ For more information on what metrics are and how they work, see [TODO]!
|
||||
| Metric Name | Type | Description |
|
||||
| :: | :: | :: |
|
||||
| `score` | Integer | The score value. This is between 0 and 10 million. |
|
||||
| `lamp` | "FAILED", "CLEAR", "EXCESSIVE CLEAR", "ULTIMATE CHAIN", "PERFECT ULTIMATE CHAIN" | The type of clear this was. |
|
||||
| `lamp` | "FAILED", "CLEAR", "EXCESSIVE CLEAR", "MAXXIVE CLEAR", "ULTIMATE CHAIN", "PERFECT ULTIMATE CHAIN" | The type of clear this was. |
|
||||
|
||||
### Derived Metrics
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
Common seeds functionality will end up here at some point
|
||||
like MutateCollection/ReadCollection whatever.
|
||||
|
||||
Not done yet though
|
||||
@@ -0,0 +1,234 @@
|
||||
import {
|
||||
ALL_GAMES,
|
||||
allSupportedGameGroups,
|
||||
type ChartDocument,
|
||||
FormatChart,
|
||||
GetGameConfig,
|
||||
GetScoreMetricConf,
|
||||
type GoalDocument,
|
||||
type V3Game,
|
||||
} from "tachi-common";
|
||||
|
||||
import { ReadCollection } from "../../util";
|
||||
|
||||
// temporary bullshit copypasted from tachi-server
|
||||
// todo: move to tachi common
|
||||
|
||||
function humanisedJoinArray(arr: Array<string>, lastJoiner = "or") {
|
||||
if (arr.length === 1) {
|
||||
return arr[0]!;
|
||||
}
|
||||
|
||||
return `${arr.slice(0, arr.length - 1).join(", ")} ${lastJoiner} ${arr[arr.length - 1]!}`;
|
||||
}
|
||||
|
||||
function onlyFloatToDP(num: number, points = 2) {
|
||||
if (Number.isInteger(num)) {
|
||||
return num.toFixed(0);
|
||||
}
|
||||
|
||||
return num.toFixed(points);
|
||||
}
|
||||
|
||||
function goalFmtPercent(val: number, dp = 2) {
|
||||
return `Get ${val.toFixed(dp)}% on`;
|
||||
}
|
||||
|
||||
function goalFmtScore(val: number) {
|
||||
return `Get a score of ${val.toLocaleString("en-GB")} on`;
|
||||
}
|
||||
|
||||
const GOAL_CRITERIA_FORMATTERS: Partial<
|
||||
Record<V3Game, Partial<Record<string, (value: number) => string>>>
|
||||
> = {
|
||||
"iidx-sp": {
|
||||
percent: goalFmtPercent,
|
||||
score: (v) => `Get a score of ${v} on`,
|
||||
},
|
||||
sdvx: {
|
||||
score: goalFmtScore,
|
||||
},
|
||||
};
|
||||
|
||||
export interface GoalTitleContext {
|
||||
chartsById: Map<string, ChartDocument>;
|
||||
foldersById: Map<string, { title: string }>;
|
||||
}
|
||||
|
||||
export function buildGoalTitleContext(): GoalTitleContext {
|
||||
const songsById = new Map<string, Record<string, unknown>>();
|
||||
|
||||
for (const gameGroup of allSupportedGameGroups) {
|
||||
for (const song of ReadCollection(`songs-${gameGroup}.json`)) {
|
||||
songsById.set(song.id, song);
|
||||
}
|
||||
}
|
||||
|
||||
const chartsById = new Map<string, ChartDocument>();
|
||||
|
||||
for (const game of ALL_GAMES) {
|
||||
for (const chart of ReadCollection(`charts-${game}.json`)) {
|
||||
const song = songsById.get(chart.songID);
|
||||
|
||||
if (!song) {
|
||||
continue;
|
||||
}
|
||||
|
||||
chartsById.set(chart.id, {
|
||||
...chart,
|
||||
chartID: chart.id,
|
||||
game,
|
||||
song,
|
||||
} as ChartDocument);
|
||||
}
|
||||
}
|
||||
|
||||
const foldersById = new Map<string, { title: string }>();
|
||||
|
||||
for (const folder of ReadCollection("folders.json")) {
|
||||
foldersById.set(folder.id, folder);
|
||||
}
|
||||
|
||||
return { chartsById, foldersById };
|
||||
}
|
||||
|
||||
function formatCriteria(criteria: GoalDocument["criteria"], game: V3Game) {
|
||||
const gameConfig = GetGameConfig(game);
|
||||
const conf = GetScoreMetricConf(gameConfig, criteria.key);
|
||||
|
||||
if (!conf) {
|
||||
throw new Error(`Invalid goal criteria with key ${criteria.key}. No config exists?`);
|
||||
}
|
||||
|
||||
if (conf.type === "ENUM") {
|
||||
const fmt = GOAL_CRITERIA_FORMATTERS[game]?.[criteria.key];
|
||||
const v = conf.values[criteria.value];
|
||||
|
||||
if (v === undefined) {
|
||||
throw new Error(`Invalid criteria value '${criteria.value}'.`);
|
||||
}
|
||||
|
||||
return fmt ? fmt(criteria.value) : v;
|
||||
}
|
||||
|
||||
if (conf.type === "DECIMAL" || conf.type === "INTEGER") {
|
||||
const fmt = GOAL_CRITERIA_FORMATTERS[game]?.[criteria.key];
|
||||
|
||||
if (!fmt) {
|
||||
throw new Error(`No formatter defined for ${criteria.key}, yet one must exist?`);
|
||||
}
|
||||
|
||||
return fmt(criteria.value);
|
||||
}
|
||||
|
||||
throw new Error(`Cannot set a goal for ${criteria.key} as it is of type ${conf.type}.`);
|
||||
}
|
||||
|
||||
function humaniseChartID(chartID: string, ctx: GoalTitleContext) {
|
||||
const chart = ctx.chartsById.get(chartID);
|
||||
|
||||
if (!chart) {
|
||||
throw new Error(`Chart ${chartID} not found in seeds.`);
|
||||
}
|
||||
|
||||
return FormatChart(chart);
|
||||
}
|
||||
|
||||
function formatCharts(
|
||||
charts: GoalDocument["charts"],
|
||||
criteria: GoalDocument["criteria"],
|
||||
ctx: GoalTitleContext,
|
||||
) {
|
||||
switch (charts.type) {
|
||||
case "single":
|
||||
return humaniseChartID(charts.data, ctx);
|
||||
case "multi": {
|
||||
const formattedTitles = charts.data.map((chartID) => humaniseChartID(chartID, ctx));
|
||||
|
||||
if (criteria.mode === "absolute" && criteria.countNum === charts.data.length) {
|
||||
return humanisedJoinArray(formattedTitles, "and");
|
||||
}
|
||||
|
||||
return humanisedJoinArray(formattedTitles);
|
||||
}
|
||||
case "folder": {
|
||||
const folder = ctx.foldersById.get(charts.data);
|
||||
|
||||
if (!folder) {
|
||||
throw new Error(`Folder ${charts.data} not found in seeds.`);
|
||||
}
|
||||
|
||||
return `the ${folder.title} folder`;
|
||||
}
|
||||
default:
|
||||
throw new Error(
|
||||
`Invalid goal charts.type -- got ${(charts as GoalDocument["charts"]).type}, which we don't support?`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors server `CreateGoalTitle`, but resolves chart and folder names from seed JSON.
|
||||
*/
|
||||
export function createGoalTitleFromSeeds(
|
||||
charts: GoalDocument["charts"],
|
||||
criteria: GoalDocument["criteria"],
|
||||
game: V3Game,
|
||||
ctx: GoalTitleContext,
|
||||
) {
|
||||
const formattedCriteria = formatCriteria(criteria, game);
|
||||
const datasetName = formatCharts(charts, criteria, ctx);
|
||||
|
||||
switch (criteria.mode) {
|
||||
case "single":
|
||||
switch (charts.type) {
|
||||
case "single":
|
||||
return `${formattedCriteria} ${datasetName}`;
|
||||
case "multi": {
|
||||
if (charts.data.length === 2) {
|
||||
return `${formattedCriteria} either ${datasetName}`;
|
||||
}
|
||||
|
||||
return `${formattedCriteria} any one of ${datasetName}`;
|
||||
}
|
||||
case "folder":
|
||||
return `${formattedCriteria} any chart in ${datasetName}`;
|
||||
}
|
||||
break;
|
||||
|
||||
case "absolute":
|
||||
switch (charts.type) {
|
||||
case "multi": {
|
||||
if (criteria.countNum === charts.data.length) {
|
||||
return `${formattedCriteria} ${datasetName}`;
|
||||
}
|
||||
|
||||
return `${formattedCriteria} any ${criteria.countNum} of ${datasetName}`;
|
||||
}
|
||||
case "folder":
|
||||
return `${formattedCriteria} ${criteria.countNum} charts in ${datasetName}`;
|
||||
case "single":
|
||||
throw new Error(
|
||||
`Invalid goal -- absolute mode cannot be paired with a charts.type of 'single'.`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case "proportion": {
|
||||
const propFormat = onlyFloatToDP(criteria.countNum * 100);
|
||||
|
||||
switch (charts.type) {
|
||||
case "multi":
|
||||
return `${formattedCriteria} ${propFormat}% of ${datasetName}`;
|
||||
case "folder":
|
||||
return `${formattedCriteria} ${propFormat}% of the charts in ${datasetName}`;
|
||||
case "single":
|
||||
throw new Error(
|
||||
`Invalid goal -- proportion mode cannot be paired with a charts.type of 'single'.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unable to format goal title for mode ${criteria.mode}.`);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Re-derive goalID and name for every row in goals.json from charts, criteria, and game.
|
||||
*
|
||||
* Run: bun typescript/seeds-scripts/rerunners/fix-goals.ts
|
||||
*/
|
||||
|
||||
import { type V3Game } from "tachi-common";
|
||||
|
||||
import {
|
||||
buildGoalTitleContext,
|
||||
createGoalTitleFromSeeds,
|
||||
} from "../lib/goals/goal-title-from-seeds.ts";
|
||||
import { log as logger } from "../log.ts";
|
||||
import { CreateGoalID, MutateCollection, ReadCollection, WriteCollection } from "../util.js";
|
||||
|
||||
const translateMap = new Map<string, string>();
|
||||
const origGoals = ReadCollection("goals.json", true);
|
||||
const ctx = buildGoalTitleContext();
|
||||
|
||||
MutateCollection("goals.json", (goals) => {
|
||||
logger.info("Re-deriving goalID and name for all goals.");
|
||||
|
||||
let idUpdates = 0;
|
||||
let nameUpdates = 0;
|
||||
|
||||
for (const goal of goals) {
|
||||
const oldGoalID = goal.goalID;
|
||||
const oldName = goal.name;
|
||||
|
||||
const newGoalID = CreateGoalID(goal.charts, goal.criteria, goal.game);
|
||||
const newName = createGoalTitleFromSeeds(
|
||||
goal.charts,
|
||||
goal.criteria,
|
||||
goal.game as V3Game,
|
||||
ctx,
|
||||
);
|
||||
|
||||
if (newGoalID !== oldGoalID) {
|
||||
translateMap.set(oldGoalID, newGoalID);
|
||||
goal.goalID = newGoalID;
|
||||
idUpdates++;
|
||||
logger.info(` goalID ${oldGoalID} -> ${newGoalID}`);
|
||||
}
|
||||
|
||||
if (newName !== oldName) {
|
||||
goal.name = newName;
|
||||
nameUpdates++;
|
||||
logger.info(` name "${oldName}" -> "${newName}"`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Updated ${idUpdates} goalID(s) and ${nameUpdates} name(s).`);
|
||||
|
||||
return goals;
|
||||
});
|
||||
|
||||
if (translateMap.size > 0) {
|
||||
try {
|
||||
MutateCollection("quests.json", (quests) => {
|
||||
logger.info("Updating quest goalID references.");
|
||||
|
||||
let patched = 0;
|
||||
|
||||
for (const quest of quests) {
|
||||
for (const qd of quest.questData) {
|
||||
for (const goal of qd.goals) {
|
||||
if (translateMap.has(goal.goalID)) {
|
||||
goal.goalID = translateMap.get(goal.goalID)!;
|
||||
patched++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Patched ${patched} quest goal reference(s).`);
|
||||
|
||||
return quests;
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("Failed to update quests.json, reverting goals.json.", { err });
|
||||
WriteCollection("goals.json", origGoals);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
// disabled for now
|
||||
console.log("Disabled for now");
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { GoalDocument, V3Game } from "tachi-common";
|
||||
|
||||
import chalk from "chalk";
|
||||
|
||||
import {
|
||||
buildGoalTitleContext,
|
||||
createGoalTitleFromSeeds,
|
||||
} from "../lib/goals/goal-title-from-seeds";
|
||||
import { CreateGoalID, ReadCollection } from "../util";
|
||||
|
||||
type SeedGoal = { goalID: string; name: string } & GoalDocument;
|
||||
|
||||
let exitCode = 0;
|
||||
let idMismatches = 0;
|
||||
let nameMismatches = 0;
|
||||
let checked = 0;
|
||||
|
||||
const ctx = buildGoalTitleContext();
|
||||
const goals = ReadCollection("goals.json") as Array<SeedGoal>;
|
||||
|
||||
for (const goal of goals) {
|
||||
checked++;
|
||||
|
||||
const expectedGoalID = CreateGoalID(goal.charts, goal.criteria, goal.game);
|
||||
|
||||
if (goal.goalID !== expectedGoalID) {
|
||||
idMismatches++;
|
||||
console.error(
|
||||
chalk.red(`[ERR] goals.json | ${goal.goalID} | goalID mismatch for "${goal.name}".`),
|
||||
);
|
||||
console.error(chalk.red(` expected: ${expectedGoalID}`));
|
||||
console.error(chalk.red(` actual: ${goal.goalID}`));
|
||||
}
|
||||
|
||||
let expectedName: string;
|
||||
|
||||
try {
|
||||
expectedName = createGoalTitleFromSeeds(
|
||||
goal.charts,
|
||||
goal.criteria,
|
||||
goal.game as V3Game,
|
||||
ctx,
|
||||
);
|
||||
} catch (err) {
|
||||
nameMismatches++;
|
||||
console.error(
|
||||
chalk.red(
|
||||
`[ERR] goals.json | ${goal.goalID} | failed to derive name for "${goal.name}".`,
|
||||
),
|
||||
);
|
||||
console.error(chalk.red(` ${err instanceof Error ? err.message : String(err)}`));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (goal.name !== expectedName) {
|
||||
nameMismatches++;
|
||||
console.error(chalk.red(`[ERR] goals.json | ${goal.goalID} | name mismatch.`));
|
||||
console.error(chalk.red(` expected: ${expectedName}`));
|
||||
console.error(chalk.red(` actual: ${goal.name}`));
|
||||
}
|
||||
}
|
||||
|
||||
const failures = idMismatches + nameMismatches;
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
`[FAILED] goals.json. Checked ${checked} goals. ` +
|
||||
`${idMismatches} goalID mismatch(es), ${nameMismatches} name mismatch(es).`,
|
||||
),
|
||||
);
|
||||
exitCode = 1;
|
||||
} else {
|
||||
console.log(
|
||||
chalk.green(`[GOOD] goals.json. All ${checked} goals have correct goalID and name.`),
|
||||
);
|
||||
}
|
||||
|
||||
process.exit(exitCode);
|
||||
@@ -0,0 +1,39 @@
|
||||
import { GetGameConfig, GetScoreMetricConf, type PBScoreDocument, SDVX_LAMPS } from "tachi-common";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { pbMeetsGoalThreshold } from "./goal-pb-queries";
|
||||
|
||||
const sdvxLampConf = GetScoreMetricConf(GetGameConfig("sdvx"), "lamp")!;
|
||||
|
||||
function sdvxPb(lamp: number): PBScoreDocument {
|
||||
return {
|
||||
scoreData: {
|
||||
enumIndexes: { lamp },
|
||||
},
|
||||
} as unknown as PBScoreDocument;
|
||||
}
|
||||
|
||||
describe("pbMeetsGoalThreshold (SDVX lamp / MAXXIVE index shift)", () => {
|
||||
it("maps MAXXIVE to index 3 and UC to index 4", () => {
|
||||
expect(SDVX_LAMPS.MAXXIVE_CLEAR).toBe(3);
|
||||
expect(SDVX_LAMPS.ULTIMATE_CHAIN).toBe(4);
|
||||
});
|
||||
|
||||
it("threshold 3 accepts MAXXIVE but not EXCESSIVE CLEAR", () => {
|
||||
expect(
|
||||
pbMeetsGoalThreshold(sdvxPb(SDVX_LAMPS.MAXXIVE_CLEAR), "lamp", 3, sdvxLampConf),
|
||||
).toBe(true);
|
||||
expect(
|
||||
pbMeetsGoalThreshold(sdvxPb(SDVX_LAMPS.EXCESSIVE_CLEAR), "lamp", 3, sdvxLampConf),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("threshold 4 accepts UC but not MAXXIVE (fixed UC quest goals)", () => {
|
||||
expect(
|
||||
pbMeetsGoalThreshold(sdvxPb(SDVX_LAMPS.ULTIMATE_CHAIN), "lamp", 4, sdvxLampConf),
|
||||
).toBe(true);
|
||||
expect(
|
||||
pbMeetsGoalThreshold(sdvxPb(SDVX_LAMPS.MAXXIVE_CLEAR), "lamp", 4, sdvxLampConf),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -452,6 +452,63 @@ export async function GetQuestsThatContainGoal(goalID: string): Promise<Array<Qu
|
||||
return rows.map(ToQuestDocument);
|
||||
}
|
||||
|
||||
/** Rewrites quest_data goal references after a goal primary key changes. */
|
||||
export async function remapGoalIdInQuests(oldGoalId: string, newGoalId: string) {
|
||||
const quests = await GetQuestsThatContainGoal(oldGoalId);
|
||||
|
||||
for (const quest of quests) {
|
||||
const newQuestData: QuestDocument["questData"] = [];
|
||||
|
||||
for (const qd of quest.questData) {
|
||||
const goals = [];
|
||||
|
||||
for (const goal of qd.goals) {
|
||||
if (goal.goalID === oldGoalId) {
|
||||
goals.push({ ...goal, goalID: newGoalId });
|
||||
} else {
|
||||
goals.push(goal);
|
||||
}
|
||||
}
|
||||
|
||||
newQuestData.push({
|
||||
...qd,
|
||||
goals,
|
||||
});
|
||||
}
|
||||
|
||||
await DB.updateTable("quest")
|
||||
.set({ quest_data: newQuestData })
|
||||
.where("quest.id", "=", quest.questID)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves subscriptions and import history from a duplicate goal row onto the
|
||||
* canonical goal id, dropping rows that would violate (goal_id, user_id).
|
||||
*/
|
||||
export async function mergeGoalSubscriptions(oldGoalId: string, newGoalId: string) {
|
||||
await sql`
|
||||
UPDATE goal_sub
|
||||
SET goal_id = ${newGoalId}
|
||||
WHERE goal_id = ${oldGoalId}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM goal_sub AS existing
|
||||
WHERE existing.goal_id = ${newGoalId}
|
||||
AND existing.user_id = goal_sub.user_id
|
||||
)
|
||||
`.execute(DB);
|
||||
|
||||
await DB.deleteFrom("goal_sub").where("goal_id", "=", oldGoalId).execute();
|
||||
|
||||
await sql`
|
||||
UPDATE import_goal
|
||||
SET goal_id = ${newGoalId}
|
||||
WHERE goal_id = ${oldGoalId}
|
||||
`.execute(DB);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribing from a goal may not be legal, because the goal might be part of
|
||||
* a quest the user is subscribed to. This function returns all quests
|
||||
@@ -748,55 +805,28 @@ export async function EditGoal(oldGoal: GoalDocument, newGoal: GoalDocument) {
|
||||
const newGoalID = CreateGoalID(newGoal.charts, newGoal.criteria, newGoal.game);
|
||||
|
||||
newGoal.goalID = newGoalID;
|
||||
|
||||
await DB.updateTable("goal_sub")
|
||||
.set({ goal_id: newGoalID })
|
||||
.where("goal_id", "=", oldGoal.goalID)
|
||||
.execute();
|
||||
|
||||
const quests = await GetQuestsThatContainGoal(oldGoal.goalID);
|
||||
|
||||
for (const quest of quests) {
|
||||
const newQuestData: QuestDocument["questData"] = [];
|
||||
|
||||
for (const qd of quest.questData) {
|
||||
const goals = [];
|
||||
|
||||
for (const goal of qd.goals) {
|
||||
if (goal.goalID === oldGoal.goalID) {
|
||||
goals.push({ ...goal, goalID: newGoal.goalID });
|
||||
} else {
|
||||
goals.push(goal);
|
||||
}
|
||||
}
|
||||
|
||||
newQuestData.push({
|
||||
...qd,
|
||||
goals,
|
||||
});
|
||||
}
|
||||
|
||||
await DB.updateTable("quest")
|
||||
.set({ quest_data: newQuestData })
|
||||
.where("quest.id", "=", quest.questID)
|
||||
.execute();
|
||||
}
|
||||
|
||||
await DB.deleteFrom("goal").where("goal.id", "=", oldGoal.goalID).execute();
|
||||
|
||||
newGoal.name = await CreateGoalName(newGoal.charts, newGoal.criteria, newGoal.game);
|
||||
|
||||
try {
|
||||
await DB.insertInto("goal")
|
||||
.values({
|
||||
id: newGoal.goalID,
|
||||
game: newGoal.game,
|
||||
name: newGoal.name,
|
||||
charts: newGoal.charts,
|
||||
criteria: newGoal.criteria,
|
||||
})
|
||||
.execute();
|
||||
} catch {
|
||||
log.info(`Goal ${newGoal.name} already existed.`);
|
||||
const existingTarget = await DB.selectFrom("goal")
|
||||
.select("goal.id")
|
||||
.where("goal.id", "=", newGoalID)
|
||||
.executeTakeFirst();
|
||||
|
||||
await remapGoalIdInQuests(oldGoal.goalID, newGoalID);
|
||||
|
||||
if (existingTarget && existingTarget.id !== oldGoal.goalID) {
|
||||
await mergeGoalSubscriptions(oldGoal.goalID, newGoalID);
|
||||
await DB.deleteFrom("goal").where("goal.id", "=", oldGoal.goalID).execute();
|
||||
return;
|
||||
}
|
||||
|
||||
await DB.updateTable("goal")
|
||||
.set({
|
||||
id: newGoal.goalID,
|
||||
name: newGoal.name,
|
||||
charts: newGoal.charts,
|
||||
criteria: newGoal.criteria,
|
||||
})
|
||||
.where("goal.id", "=", oldGoal.goalID)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { CreateGoalTitle } from "#lib/targets/goal-utils";
|
||||
import { CreateGoalID } from "#lib/targets/goals";
|
||||
import { reconcileGoalIds } from "#scripts/reconcile-goal-ids";
|
||||
import DB from "#services/pg/db";
|
||||
import { seedMinimalIidxSpChart, seedUser } from "#test-utils/pg-fixtures";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("reconcileGoalIds", () => {
|
||||
it("renames drifted goal ids and cascades goal_sub references", async () => {
|
||||
const chartId = await seedMinimalIidxSpChart();
|
||||
const { id: userId } = await seedUser();
|
||||
|
||||
const charts = { type: "single" as const, data: chartId };
|
||||
const criteria = { mode: "single" as const, value: 5, key: "lamp" as const };
|
||||
const game = "iidx-sp" as const;
|
||||
const canonicalId = CreateGoalID(charts, criteria, game);
|
||||
const driftedId = "G_wrong_goal_id_drifted_for_reconcile_test";
|
||||
|
||||
expect(canonicalId).not.toBe(driftedId);
|
||||
|
||||
await DB.insertInto("goal")
|
||||
.values({
|
||||
id: driftedId,
|
||||
game,
|
||||
name: "HARD CLEAR test chart",
|
||||
charts,
|
||||
criteria,
|
||||
})
|
||||
.execute();
|
||||
|
||||
await DB.insertInto("goal_sub")
|
||||
.values({
|
||||
goal_id: driftedId,
|
||||
user_id: userId,
|
||||
last_interaction: null,
|
||||
progress: 0,
|
||||
progress_human: "0",
|
||||
out_of: 5,
|
||||
out_of_human: "HARD CLEAR",
|
||||
achieved: false,
|
||||
time_achieved: null,
|
||||
was_instantly_achieved: false,
|
||||
was_assigned_standalone: true,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const { renamed } = await reconcileGoalIds({ dryRun: false });
|
||||
|
||||
expect(renamed).toBe(1);
|
||||
|
||||
const goalRow = await DB.selectFrom("goal")
|
||||
.select(["goal.id", "goal.name"])
|
||||
.where("goal.id", "=", canonicalId)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(goalRow).toBeDefined();
|
||||
expect(goalRow?.name).toBe(await CreateGoalTitle(charts, criteria, game));
|
||||
|
||||
const staleGoal = await DB.selectFrom("goal")
|
||||
.select("goal.id")
|
||||
.where("goal.id", "=", driftedId)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(staleGoal).toBeUndefined();
|
||||
|
||||
const subRow = await DB.selectFrom("goal_sub")
|
||||
.select("goal_sub.goal_id")
|
||||
.where("goal_sub.user_id", "=", userId)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(subRow?.goal_id).toBe(canonicalId);
|
||||
});
|
||||
|
||||
it("updates stale goal names when the id is already canonical", async () => {
|
||||
const chartId = await seedMinimalIidxSpChart();
|
||||
|
||||
const charts = { type: "single" as const, data: chartId };
|
||||
const criteria = { mode: "single" as const, value: 5, key: "lamp" as const };
|
||||
const game = "iidx-sp" as const;
|
||||
const goalID = CreateGoalID(charts, criteria, game);
|
||||
const expectedName = await CreateGoalTitle(charts, criteria, game);
|
||||
|
||||
await DB.insertInto("goal")
|
||||
.values({
|
||||
id: goalID,
|
||||
game,
|
||||
name: "stale human-readable title",
|
||||
charts,
|
||||
criteria,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const { namesUpdated, renamed } = await reconcileGoalIds({ dryRun: false });
|
||||
|
||||
expect(renamed).toBe(0);
|
||||
expect(namesUpdated).toBe(1);
|
||||
|
||||
const goalRow = await DB.selectFrom("goal")
|
||||
.select("goal.name")
|
||||
.where("goal.id", "=", goalID)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(goalRow?.name).toBe(expectedName);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Reconcile goal rows with their canonical id and display name.
|
||||
*
|
||||
* Goal ids are `CreateGoalID(charts, criteria, game)`. Names come from
|
||||
* `CreateGoalTitle(charts, criteria, game)`. When either drifts (e.g. after a
|
||||
* criteria fix without rehashing), this script repairs rows. `goal_sub` and
|
||||
* `import_goal` follow id renames via ON UPDATE CASCADE (see migration
|
||||
* goal_id_on_update_cascade).
|
||||
*
|
||||
* Quest goal references in `quest.quest_data` are updated separately (JSON, not FK).
|
||||
*
|
||||
* Goals are scanned in primary-key order, one batch at a time, so the full table
|
||||
* is never loaded into memory.
|
||||
*
|
||||
* Run:
|
||||
* bun run src/scripts/reconcile-goal-ids.ts
|
||||
* bun run src/scripts/reconcile-goal-ids.ts --dry-run
|
||||
* bun run src/scripts/reconcile-goal-ids.ts --batch-size 1000
|
||||
*/
|
||||
|
||||
import { SELECT_GOAL } from "#lib/db-formats/goal";
|
||||
import { ToGoalDocument } from "#lib/db-formats/target-documents";
|
||||
import { log } from "#lib/log/log";
|
||||
import { CreateGoalTitle } from "#lib/targets/goal-utils";
|
||||
import { CreateGoalID, mergeGoalSubscriptions, remapGoalIdInQuests } from "#lib/targets/goals";
|
||||
import DB from "#services/pg/db";
|
||||
import { parseArgs } from "node:util";
|
||||
import { type GoalDocument } from "tachi-common";
|
||||
|
||||
const DEFAULT_BATCH_SIZE = 500;
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: process.argv.slice(2),
|
||||
options: {
|
||||
"batch-size": { type: "string" },
|
||||
"dry-run": { type: "boolean" },
|
||||
help: { type: "boolean", short: "h" },
|
||||
},
|
||||
});
|
||||
|
||||
if (values.help) {
|
||||
console.log(`
|
||||
reconcile-goal-ids — align goal.id and goal.name with charts + criteria
|
||||
|
||||
--batch-size <n> Goals per DB fetch (default ${DEFAULT_BATCH_SIZE})
|
||||
--dry-run Log changes without writing
|
||||
-h, --help Show this help
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const cliDryRun = values["dry-run"] ?? false;
|
||||
const batchSize = Math.max(
|
||||
1,
|
||||
Number.parseInt(values["batch-size"] ?? "", 10) || DEFAULT_BATCH_SIZE,
|
||||
);
|
||||
|
||||
function canonicalGoalId(goal: GoalDocument): string {
|
||||
return CreateGoalID(goal.charts, goal.criteria, goal.game);
|
||||
}
|
||||
|
||||
async function canonicalGoalName(goal: GoalDocument): Promise<string | null> {
|
||||
try {
|
||||
return await CreateGoalTitle(goal.charts, goal.criteria, goal.game);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{ err, goalID: goal.goalID, game: goal.game },
|
||||
`Could not derive goal name for ${goal.goalID}; skipping name update.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGoalBatch(afterId: string, limit: number): Promise<Array<GoalDocument>> {
|
||||
const rows = await DB.selectFrom("goal")
|
||||
.select(SELECT_GOAL)
|
||||
.where("goal.id", ">", afterId)
|
||||
.orderBy("goal.id", "asc")
|
||||
.limit(limit)
|
||||
.execute();
|
||||
|
||||
return rows.map(ToGoalDocument);
|
||||
}
|
||||
|
||||
async function loadExistingGoalIds(goalIds: Array<string>): Promise<Set<string>> {
|
||||
if (goalIds.length === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const rows = await DB.selectFrom("goal")
|
||||
.select("goal.id")
|
||||
.where("goal.id", "in", goalIds)
|
||||
.execute();
|
||||
|
||||
return new Set(rows.map((r) => r.id));
|
||||
}
|
||||
|
||||
async function reconcileGoalId(
|
||||
goal: GoalDocument,
|
||||
expectedId: string,
|
||||
expectedName: string | null,
|
||||
targetExists: boolean,
|
||||
isDryRun: boolean,
|
||||
): Promise<"merged" | "renamed"> {
|
||||
if (isDryRun) {
|
||||
log.info(
|
||||
targetExists
|
||||
? `[dry-run] Would merge ${goal.goalID} -> ${expectedId} (${goal.name})`
|
||||
: `[dry-run] Would rename ${goal.goalID} -> ${expectedId} (${goal.name})`,
|
||||
);
|
||||
return targetExists ? "merged" : "renamed";
|
||||
}
|
||||
|
||||
await remapGoalIdInQuests(goal.goalID, expectedId);
|
||||
|
||||
if (targetExists) {
|
||||
await mergeGoalSubscriptions(goal.goalID, expectedId);
|
||||
await DB.deleteFrom("goal").where("goal.id", "=", goal.goalID).execute();
|
||||
log.info(`Merged ${goal.goalID} -> ${expectedId} (${goal.name})`);
|
||||
return "merged";
|
||||
}
|
||||
|
||||
const update: { id: string; name?: string } = { id: expectedId };
|
||||
|
||||
if (expectedName !== null) {
|
||||
update.name = expectedName;
|
||||
}
|
||||
|
||||
await DB.updateTable("goal").set(update).where("goal.id", "=", goal.goalID).execute();
|
||||
|
||||
log.info(`Renamed ${goal.goalID} -> ${expectedId} (${goal.name})`);
|
||||
return "renamed";
|
||||
}
|
||||
|
||||
async function reconcileGoalName(
|
||||
goal: GoalDocument,
|
||||
expectedName: string,
|
||||
isDryRun: boolean,
|
||||
): Promise<boolean> {
|
||||
if (goal.name === expectedName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isDryRun) {
|
||||
log.info(`[dry-run] Would rename goal ${goal.goalID}: "${goal.name}" -> "${expectedName}"`);
|
||||
return true;
|
||||
}
|
||||
|
||||
await DB.updateTable("goal")
|
||||
.set({ name: expectedName })
|
||||
.where("goal.id", "=", goal.goalID)
|
||||
.execute();
|
||||
|
||||
log.info(`Updated goal name ${goal.goalID}: "${goal.name}" -> "${expectedName}"`);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function reconcileGoalIds(options?: { batchSize?: number; dryRun?: boolean }) {
|
||||
const isDryRun = options?.dryRun ?? cliDryRun;
|
||||
const limit = options?.batchSize ?? batchSize;
|
||||
|
||||
let renamed = 0;
|
||||
let merged = 0;
|
||||
let namesUpdated = 0;
|
||||
let skipped = 0;
|
||||
let scanned = 0;
|
||||
let afterId = "";
|
||||
|
||||
for (;;) {
|
||||
const batch = await loadGoalBatch(afterId, limit);
|
||||
|
||||
if (batch.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
scanned += batch.length;
|
||||
|
||||
const drifted = batch.filter((goal) => canonicalGoalId(goal) !== goal.goalID);
|
||||
const expectedIds = [...new Set(drifted.map((goal) => canonicalGoalId(goal)))];
|
||||
const existingIds = await loadExistingGoalIds(expectedIds);
|
||||
|
||||
for (const goal of batch) {
|
||||
const expectedId = canonicalGoalId(goal);
|
||||
const expectedName = await canonicalGoalName(goal);
|
||||
|
||||
if (expectedId !== goal.goalID) {
|
||||
const targetExists = existingIds.has(expectedId);
|
||||
const result = await reconcileGoalId(
|
||||
goal,
|
||||
expectedId,
|
||||
expectedName,
|
||||
targetExists,
|
||||
isDryRun,
|
||||
);
|
||||
|
||||
if (result === "renamed") {
|
||||
renamed++;
|
||||
} else {
|
||||
merged++;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expectedName !== null && goal.name !== expectedName) {
|
||||
if (await reconcileGoalName(goal, expectedName, isDryRun)) {
|
||||
namesUpdated++;
|
||||
}
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
afterId = batch[batch.length - 1]!.goalID;
|
||||
|
||||
log.info(`reconcile-goal-ids: scanned ${scanned} goals (batch through ${afterId})`);
|
||||
|
||||
if (batch.length < limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
log.info(
|
||||
`reconcile-goal-ids complete: ${scanned} scanned, ${renamed} id-renamed, ${merged} merged, ${namesUpdated} names updated, ${skipped} unchanged.`,
|
||||
);
|
||||
|
||||
return { renamed, merged, namesUpdated, skipped, scanned };
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
await reconcileGoalIds();
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Re-sync quest subscriptions after quest definitions change.
|
||||
*
|
||||
* For each quest, calls `UpdateQuestSubscriptions`: unsubscribes every
|
||||
* subscriber and re-subscribes them against the current quest (refreshing goal
|
||||
* subs and progress), then sends a QUEST_CHANGED notification.
|
||||
*
|
||||
* Run after seed loads or goal-id reconciles when you want subscribers fully
|
||||
* migrated rather than relying on lazy repair on score import.
|
||||
*
|
||||
* Run:
|
||||
* bun run src/scripts/update-quest-subscriptions.ts
|
||||
* bun run src/scripts/update-quest-subscriptions.ts --dry-run
|
||||
* bun run src/scripts/update-quest-subscriptions.ts --only-subscribed
|
||||
* bun run src/scripts/update-quest-subscriptions.ts --game sdvx
|
||||
*/
|
||||
|
||||
import { log } from "#lib/log/log";
|
||||
import { UpdateQuestSubscriptions } from "#lib/targets/quests";
|
||||
import DB from "#services/pg/db";
|
||||
import { sql } from "kysely";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
const DEFAULT_BATCH_SIZE = 100;
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: process.argv.slice(2),
|
||||
options: {
|
||||
"batch-size": { type: "string" },
|
||||
"dry-run": { type: "boolean" },
|
||||
game: { type: "string" },
|
||||
"only-subscribed": { type: "boolean" },
|
||||
help: { type: "boolean", short: "h" },
|
||||
},
|
||||
});
|
||||
|
||||
if (values.help) {
|
||||
console.log(`
|
||||
update-quest-subscriptions — run UpdateQuestSubscriptions for all quests
|
||||
|
||||
--batch-size <n> Quest ids per DB fetch (default ${DEFAULT_BATCH_SIZE})
|
||||
--only-subscribed Only process quests with at least one quest_sub row
|
||||
--game <game> Limit to a single game (v3 id, e.g. sdvx or iidx-sp)
|
||||
--dry-run Log quests that would be updated without writing
|
||||
-h, --help Show this help
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const cliDryRun = values["dry-run"] ?? false;
|
||||
const onlySubscribed = values["only-subscribed"] ?? false;
|
||||
const gameFilter = values.game;
|
||||
const batchSize = Math.max(
|
||||
1,
|
||||
Number.parseInt(values["batch-size"] ?? "", 10) || DEFAULT_BATCH_SIZE,
|
||||
);
|
||||
|
||||
export async function updateAllQuestSubscriptions(options?: {
|
||||
batchSize?: number;
|
||||
dryRun?: boolean;
|
||||
game?: string;
|
||||
onlySubscribed?: boolean;
|
||||
}) {
|
||||
const isDryRun = options?.dryRun ?? cliDryRun;
|
||||
const limit = options?.batchSize ?? batchSize;
|
||||
const game = options?.game ?? gameFilter;
|
||||
const subscribedOnly = options?.onlySubscribed ?? onlySubscribed;
|
||||
|
||||
let processed = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
let subscribersNotified = 0;
|
||||
let afterId = "";
|
||||
|
||||
log.info(
|
||||
`update-quest-subscriptions: starting (dry-run=${isDryRun}, only-subscribed=${subscribedOnly}${game ? `, game=${game}` : ""})`,
|
||||
);
|
||||
|
||||
for (;;) {
|
||||
let query = DB.selectFrom("quest")
|
||||
.select(["quest.id", "quest.name"])
|
||||
.select((eb) =>
|
||||
eb
|
||||
.selectFrom("quest_sub")
|
||||
.select((eb2) => eb2.fn.countAll<string>().as("count"))
|
||||
.whereRef("quest_sub.quest_id", "=", "quest.id")
|
||||
.as("subscriber_count"),
|
||||
)
|
||||
.where("quest.id", ">", afterId)
|
||||
.orderBy("quest.id", "asc")
|
||||
.limit(limit);
|
||||
|
||||
if (subscribedOnly) {
|
||||
query = query.where((eb) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom("quest_sub")
|
||||
.select(sql`1`.as("one"))
|
||||
.whereRef("quest_sub.quest_id", "=", "quest.id"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (game) {
|
||||
query = query.where("quest.game", "=", game as never);
|
||||
}
|
||||
|
||||
const batch = await query.execute();
|
||||
|
||||
if (batch.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const row of batch) {
|
||||
processed++;
|
||||
const subscriberCount = Number(row.subscriber_count ?? 0);
|
||||
|
||||
if (subscriberCount === 0) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isDryRun) {
|
||||
log.info(
|
||||
`[dry-run] Would update quest ${row.id} (${row.name}) — ${subscriberCount} subscriber(s)`,
|
||||
);
|
||||
updated++;
|
||||
subscribersNotified += subscriberCount;
|
||||
continue;
|
||||
}
|
||||
|
||||
await UpdateQuestSubscriptions(row.id);
|
||||
updated++;
|
||||
subscribersNotified += subscriberCount;
|
||||
log.info(`Updated quest ${row.id} (${row.name}) — ${subscriberCount} subscriber(s)`);
|
||||
}
|
||||
|
||||
afterId = batch[batch.length - 1]!.id;
|
||||
|
||||
log.info(
|
||||
`update-quest-subscriptions: processed ${processed} quests (batch through ${afterId})`,
|
||||
);
|
||||
|
||||
if (batch.length < limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
log.info(
|
||||
`update-quest-subscriptions complete: ${processed} quest(s) scanned, ${updated} updated, ${skipped} skipped (no subscribers), ${subscribersNotified} subscriber notification(s).`,
|
||||
);
|
||||
|
||||
return { processed, updated, skipped, subscribersNotified };
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
await updateAllQuestSubscriptions();
|
||||
process.exit(0);
|
||||
}
|
||||
Reference in New Issue
Block a user