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:
zk
2026-06-13 11:04:29 +01:00
committed by GitHub
parent 249dbf5e19
commit 99fe1186ff
15 changed files with 1193 additions and 215 deletions
+1 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -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",
+1 -1
View File
@@ -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
+4
View File
@@ -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);
});
});
+78 -48
View File
@@ -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);
}