generated from nhcarrigan/template
b48beef474
## Summary - Sync New Content now **injects** missing entries AND **patches canonical fields** on all existing entries to match current defaults - Adventurers: stats (baseCost, combatPower, goldPerSecond, essencePerSecond, name, class, level) - Quests: duration, prerequisites, combat requirement, rewards - Bosses: HP, damage, rewards, prestige requirement, upgrade rewards - Zones: unlock conditions (boss/quest required) - Upgrades: multiplier, costs - Equipment: bonus, cost, set membership - Achievements: condition, reward - Crafting: multipliers recomputed from `craftedRecipeIds` so recipe balance changes apply retroactively Closes #126 ## Test plan - [ ] On an existing save, click Sync New Content and verify the notification reports patched counts for all content types - [ ] Verify that rebalanced adventurer/boss/upgrade stats are reflected in the UI after syncing - [ ] Verify that player-owned state (counts, unlock status, boss HP, quest status) is preserved after syncing - [ ] Verify crafting multipliers are correct after syncing if any recipes were previously crafted ✨ This issue was created with help from Hikari~ 🌸 Reviewed-on: #130 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
1167 lines
38 KiB
TypeScript
1167 lines
38 KiB
TypeScript
/**
|
|
* @file Debug routes for administrative player state corrections.
|
|
* @copyright nhcarrigan
|
|
* @license Naomi's Public License
|
|
* @author Naomi Carrigan
|
|
*/
|
|
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
|
/* eslint-disable max-lines -- Multiple route handlers and helper functions in one file */
|
|
import { createHmac } from "node:crypto";
|
|
import {
|
|
STORY_CHAPTERS,
|
|
isStoryChapterUnlocked,
|
|
type GameState,
|
|
} from "@elysium/types";
|
|
import { Hono } from "hono";
|
|
import { defaultAchievements } from "../data/achievements.js";
|
|
import { defaultAdventurers } from "../data/adventurers.js";
|
|
import { defaultBosses } from "../data/bosses.js";
|
|
import { defaultEquipment } from "../data/equipment.js";
|
|
import { defaultExplorations } from "../data/explorations.js";
|
|
import { initialGameState } from "../data/initialState.js";
|
|
import { defaultQuests } from "../data/quests.js";
|
|
import { defaultRecipes } from "../data/recipes.js";
|
|
import { currentSchemaVersion } from "../data/schemaVersion.js";
|
|
import { defaultUpgrades } from "../data/upgrades.js";
|
|
import { defaultZones } from "../data/zones.js";
|
|
import { prisma } from "../db/client.js";
|
|
import { authMiddleware } from "../middleware/auth.js";
|
|
import { logger } from "../services/logger.js";
|
|
import type { HonoEnvironment } from "../types/hono.js";
|
|
|
|
/**
|
|
* Computes the HMAC-SHA256 of data using the given secret.
|
|
* @param data - The data string to sign.
|
|
* @param secret - The HMAC secret key.
|
|
* @returns The hex-encoded HMAC digest.
|
|
*/
|
|
const computeHmac = (data: string, secret: string): string => {
|
|
return createHmac("sha256", secret).update(data).
|
|
digest("hex");
|
|
};
|
|
|
|
/**
|
|
* Unlocks any zones whose required boss and quest conditions are satisfied.
|
|
* @param state - The player's current game state (mutated directly).
|
|
* @returns The number of zones that were unlocked.
|
|
*/
|
|
const applyZoneUnlocks = (state: GameState): number => {
|
|
let count = 0;
|
|
for (const zoneDefinition of defaultZones) {
|
|
const zoneInState = state.zones.find((z) => {
|
|
return z.id === zoneDefinition.id;
|
|
});
|
|
if (!zoneInState || zoneInState.status !== "locked") {
|
|
continue;
|
|
}
|
|
|
|
const requiredBossDefeated
|
|
= zoneDefinition.unlockBossId === null
|
|
|| state.bosses.some((b) => {
|
|
return b.id === zoneDefinition.unlockBossId && b.status === "defeated";
|
|
});
|
|
|
|
const requiredQuestCompleted
|
|
= zoneDefinition.unlockQuestId === null
|
|
|| state.quests.some((q) => {
|
|
return (
|
|
q.id === zoneDefinition.unlockQuestId && q.status === "completed"
|
|
);
|
|
});
|
|
|
|
if (requiredBossDefeated && requiredQuestCompleted) {
|
|
zoneInState.status = "unlocked";
|
|
count = count + 1;
|
|
}
|
|
}
|
|
return count;
|
|
};
|
|
|
|
interface QuestUnlockCheck {
|
|
questId: string;
|
|
zoneId: string;
|
|
prerequisiteIds: Array<string>;
|
|
state: GameState;
|
|
completedQuestIds: Set<string>;
|
|
}
|
|
|
|
/**
|
|
* Determines whether a quest should be made available given the current state.
|
|
* @param options - The options for the quest unlock check.
|
|
* @param options.questId - The ID of the quest to check.
|
|
* @param options.zoneId - The zone the quest belongs to.
|
|
* @param options.prerequisiteIds - The quest IDs that must be completed first.
|
|
* @param options.state - The current game state.
|
|
* @param options.completedQuestIds - Set of already-completed quest IDs.
|
|
* @returns True when the quest should be unlocked.
|
|
*/
|
|
const shouldUnlockQuest = ({
|
|
questId,
|
|
zoneId,
|
|
prerequisiteIds,
|
|
state,
|
|
completedQuestIds,
|
|
}: QuestUnlockCheck): boolean => {
|
|
const questInState = state.quests.find((q) => {
|
|
return q.id === questId;
|
|
});
|
|
if (!questInState || questInState.status !== "locked") {
|
|
return false;
|
|
}
|
|
const zoneInState = state.zones.find((z) => {
|
|
return z.id === zoneId;
|
|
});
|
|
if (!zoneInState || zoneInState.status === "locked") {
|
|
return false;
|
|
}
|
|
return prerequisiteIds.every((id) => {
|
|
return completedQuestIds.has(id);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Makes available any quests whose zone is unlocked and prerequisites are met.
|
|
* @param state - The player's current game state (mutated directly).
|
|
* @returns The number of quests that were made available.
|
|
*/
|
|
const applyQuestUnlocks = (state: GameState): number => {
|
|
let count = 0;
|
|
const completedQuestIds = new Set(
|
|
state.quests.
|
|
filter((q) => {
|
|
return q.status === "completed";
|
|
}).
|
|
map((q) => {
|
|
return q.id;
|
|
}),
|
|
);
|
|
|
|
for (const questDefinition of defaultQuests) {
|
|
if (
|
|
!shouldUnlockQuest({
|
|
completedQuestIds: completedQuestIds,
|
|
prerequisiteIds: questDefinition.prerequisiteIds,
|
|
questId: questDefinition.id,
|
|
state: state,
|
|
zoneId: questDefinition.zoneId,
|
|
})
|
|
) {
|
|
continue;
|
|
}
|
|
const questInState = state.quests.find((q) => {
|
|
return q.id === questDefinition.id;
|
|
});
|
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
/* v8 ignore next 4 -- @preserve */
|
|
if (questInState) {
|
|
questInState.status = "available";
|
|
count = count + 1;
|
|
}
|
|
}
|
|
return count;
|
|
};
|
|
|
|
interface BossUnlockCheck {
|
|
bossId: string;
|
|
previousBossId: string | undefined;
|
|
isFirstInZone: boolean;
|
|
prestigeRequirement: number;
|
|
state: GameState;
|
|
prestigeCount: number;
|
|
}
|
|
|
|
/**
|
|
* Determines whether a boss should be made available given the current state.
|
|
* @param options - The options for the boss unlock check.
|
|
* @param options.bossId - The ID of the boss to check.
|
|
* @param options.previousBossId - The ID of the previous boss in the zone.
|
|
* @param options.isFirstInZone - Whether this boss is the first in its zone.
|
|
* @param options.prestigeRequirement - The prestige level required for this boss.
|
|
* @param options.state - The current game state.
|
|
* @param options.prestigeCount - The player's current prestige count.
|
|
* @returns True when the boss should be made available.
|
|
*/
|
|
const shouldUnlockBoss = ({
|
|
bossId,
|
|
previousBossId,
|
|
isFirstInZone,
|
|
prestigeRequirement,
|
|
state,
|
|
prestigeCount,
|
|
}: BossUnlockCheck): boolean => {
|
|
const bossInState = state.bosses.find((b) => {
|
|
return b.id === bossId;
|
|
});
|
|
if (!bossInState || bossInState.status !== "locked") {
|
|
return false;
|
|
}
|
|
if (prestigeRequirement > prestigeCount) {
|
|
return false;
|
|
}
|
|
if (isFirstInZone) {
|
|
return true;
|
|
}
|
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
/* v8 ignore next 3 -- @preserve */
|
|
if (previousBossId === undefined) {
|
|
return false;
|
|
}
|
|
const previousBossInState = state.bosses.find((b) => {
|
|
return b.id === previousBossId;
|
|
});
|
|
return previousBossInState?.status === "defeated";
|
|
};
|
|
|
|
/**
|
|
* Makes available any bosses that should be accessible based on zone status
|
|
* and sequential defeat order within each zone.
|
|
* @param state - The player's current game state (mutated directly).
|
|
* @returns The number of bosses that were made available.
|
|
*/
|
|
const applyBossUnlocks = (state: GameState): number => {
|
|
let count = 0;
|
|
const prestigeCount = state.prestige.count;
|
|
|
|
for (const zoneDefinition of defaultZones) {
|
|
const zoneInState = state.zones.find((z) => {
|
|
return z.id === zoneDefinition.id;
|
|
});
|
|
if (!zoneInState || zoneInState.status === "locked") {
|
|
continue;
|
|
}
|
|
|
|
const bossesInZone = defaultBosses.filter((b) => {
|
|
return b.zoneId === zoneDefinition.id;
|
|
});
|
|
|
|
for (let index = 0; index < bossesInZone.length; index = index + 1) {
|
|
const bossDefinition = bossesInZone[index];
|
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
/* v8 ignore next 3 -- @preserve */
|
|
if (!bossDefinition) {
|
|
continue;
|
|
}
|
|
const previousBossDefinition = bossesInZone[index - 1];
|
|
const unlock = shouldUnlockBoss({
|
|
bossId: bossDefinition.id,
|
|
isFirstInZone: index === 0,
|
|
prestigeCount: prestigeCount,
|
|
prestigeRequirement: bossDefinition.prestigeRequirement,
|
|
previousBossId: previousBossDefinition?.id,
|
|
state: state,
|
|
});
|
|
if (unlock) {
|
|
const bossInState = state.bosses.find((b) => {
|
|
return b.id === bossDefinition.id;
|
|
});
|
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
/* v8 ignore next 4 -- @preserve */
|
|
if (bossInState) {
|
|
bossInState.status = "available";
|
|
count = count + 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return count;
|
|
};
|
|
|
|
/**
|
|
* Unlocks any adventurer tiers that were granted as rewards for completed quests
|
|
* but are still locked in the player's state.
|
|
* @param state - The player's current game state (mutated directly).
|
|
* @returns The number of adventurer tiers that were unlocked.
|
|
*/
|
|
const applyAdventurerUnlocks = (state: GameState): number => {
|
|
let count = 0;
|
|
const completedQuestIds = new Set(
|
|
state.quests.
|
|
filter((q) => {
|
|
return q.status === "completed";
|
|
}).
|
|
map((q) => {
|
|
return q.id;
|
|
}),
|
|
);
|
|
const earnedAdventurerIds = new Set<string>();
|
|
|
|
for (const questDefinition of defaultQuests) {
|
|
if (!completedQuestIds.has(questDefinition.id)) {
|
|
continue;
|
|
}
|
|
for (const reward of questDefinition.rewards) {
|
|
if (reward.type === "adventurer" && reward.targetId !== undefined) {
|
|
earnedAdventurerIds.add(reward.targetId);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const adventurer of state.adventurers) {
|
|
if (!adventurer.unlocked && earnedAdventurerIds.has(adventurer.id)) {
|
|
adventurer.unlocked = true;
|
|
count = count + 1;
|
|
}
|
|
}
|
|
|
|
return count;
|
|
};
|
|
|
|
/**
|
|
* Collects all upgrade IDs the player has legitimately earned via boss defeats
|
|
* and completed quest rewards, sourcing reward data from game definitions.
|
|
* @param state - The player's current game state.
|
|
* @returns A set of earned upgrade IDs.
|
|
*/
|
|
const collectEarnedUpgradeIds = (state: GameState): Set<string> => {
|
|
const earnedIds = new Set<string>();
|
|
const defeatedBossIds = new Set(
|
|
state.bosses.
|
|
filter((b) => {
|
|
return b.status === "defeated";
|
|
}).
|
|
map((b) => {
|
|
return b.id;
|
|
}),
|
|
);
|
|
const completedQuestIds = new Set(
|
|
state.quests.
|
|
filter((q) => {
|
|
return q.status === "completed";
|
|
}).
|
|
map((q) => {
|
|
return q.id;
|
|
}),
|
|
);
|
|
|
|
for (const bossDefinition of defaultBosses) {
|
|
if (!defeatedBossIds.has(bossDefinition.id)) {
|
|
continue;
|
|
}
|
|
for (const upgradeId of bossDefinition.upgradeRewards) {
|
|
earnedIds.add(upgradeId);
|
|
}
|
|
}
|
|
|
|
for (const questDefinition of defaultQuests) {
|
|
if (!completedQuestIds.has(questDefinition.id)) {
|
|
continue;
|
|
}
|
|
for (const reward of questDefinition.rewards) {
|
|
if (reward.type === "upgrade" && reward.targetId !== undefined) {
|
|
earnedIds.add(reward.targetId);
|
|
}
|
|
}
|
|
}
|
|
|
|
return earnedIds;
|
|
};
|
|
|
|
/**
|
|
* Unlocks any upgrades that were granted as rewards for defeated bosses or
|
|
* completed quests but are still locked in the player's state.
|
|
* @param state - The player's current game state (mutated directly).
|
|
* @returns The number of upgrades that were unlocked.
|
|
*/
|
|
const applyUpgradeUnlocks = (state: GameState): number => {
|
|
let count = 0;
|
|
const earnedUpgradeIds = collectEarnedUpgradeIds(state);
|
|
|
|
for (const upgrade of state.upgrades) {
|
|
if (!upgrade.unlocked && earnedUpgradeIds.has(upgrade.id)) {
|
|
upgrade.unlocked = true;
|
|
count = count + 1;
|
|
}
|
|
}
|
|
|
|
return count;
|
|
};
|
|
|
|
/**
|
|
* Marks as owned any equipment that was granted as a reward for defeated bosses
|
|
* but is still unowned in the player's state.
|
|
* @param state - The player's current game state (mutated directly).
|
|
* @returns The number of equipment items that were marked as owned.
|
|
*/
|
|
const applyEquipmentUnlocks = (state: GameState): number => {
|
|
let count = 0;
|
|
const defeatedBossIds = new Set(
|
|
state.bosses.
|
|
filter((b) => {
|
|
return b.status === "defeated";
|
|
}).
|
|
map((b) => {
|
|
return b.id;
|
|
}),
|
|
);
|
|
const earnedEquipmentIds = new Set<string>();
|
|
|
|
for (const bossDefinition of defaultBosses) {
|
|
if (!defeatedBossIds.has(bossDefinition.id)) {
|
|
continue;
|
|
}
|
|
for (const equipmentId of bossDefinition.equipmentRewards) {
|
|
earnedEquipmentIds.add(equipmentId);
|
|
}
|
|
}
|
|
|
|
for (const item of state.equipment) {
|
|
if (!item.owned && earnedEquipmentIds.has(item.id)) {
|
|
item.owned = true;
|
|
count = count + 1;
|
|
}
|
|
}
|
|
|
|
return count;
|
|
};
|
|
|
|
/**
|
|
* Unlocks any story chapters whose conditions are met by the current game state
|
|
* but are still absent from the player's unlockedChapterIds list.
|
|
* @param state - The player's current game state (mutated directly).
|
|
* @returns The number of story chapters that were unlocked.
|
|
*/
|
|
const applyStoryUnlocks = (state: GameState): number => {
|
|
if (state.story === undefined) {
|
|
return 0;
|
|
}
|
|
let count = 0;
|
|
const alreadyUnlocked = new Set(state.story.unlockedChapterIds);
|
|
|
|
for (const chapter of STORY_CHAPTERS) {
|
|
if (alreadyUnlocked.has(chapter.id)) {
|
|
continue;
|
|
}
|
|
if (isStoryChapterUnlocked(chapter, state)) {
|
|
state.story.unlockedChapterIds.push(chapter.id);
|
|
count = count + 1;
|
|
}
|
|
}
|
|
|
|
return count;
|
|
};
|
|
|
|
/**
|
|
* Makes available any exploration areas whose parent zone is now unlocked.
|
|
* @param state - The player's current game state (mutated directly).
|
|
* @returns The number of exploration areas that were made available.
|
|
*/
|
|
const applyExplorationUnlocks = (state: GameState): number => {
|
|
if (state.exploration === undefined) {
|
|
return 0;
|
|
}
|
|
let count = 0;
|
|
const unlockedZoneIds = new Set(
|
|
state.zones.
|
|
filter((z) => {
|
|
return z.status === "unlocked";
|
|
}).
|
|
map((z) => {
|
|
return z.id;
|
|
}),
|
|
);
|
|
|
|
for (const areaDefinition of defaultExplorations) {
|
|
if (!unlockedZoneIds.has(areaDefinition.zoneId)) {
|
|
continue;
|
|
}
|
|
const areaInState = state.exploration.areas.find((a) => {
|
|
return a.id === areaDefinition.id;
|
|
});
|
|
if (areaInState && areaInState.status === "locked") {
|
|
areaInState.status = "available";
|
|
count = count + 1;
|
|
}
|
|
}
|
|
return count;
|
|
};
|
|
|
|
/**
|
|
* Applies all missing unlock corrections to a game state in-place.
|
|
* Delegates to per-category helpers and aggregates the results.
|
|
* @param state - The player's current game state (mutated directly).
|
|
* @returns Counts of each entity type that was corrected.
|
|
*/
|
|
const applyForceUnlocks = (
|
|
state: GameState,
|
|
): {
|
|
adventurersUnlocked: number;
|
|
bossesUnlocked: number;
|
|
equipmentUnlocked: number;
|
|
explorationUnlocked: number;
|
|
questsUnlocked: number;
|
|
storyUnlocked: number;
|
|
upgradesUnlocked: number;
|
|
zonesUnlocked: number;
|
|
} => {
|
|
const zonesUnlocked = applyZoneUnlocks(state);
|
|
const questsUnlocked = applyQuestUnlocks(state);
|
|
const bossesUnlocked = applyBossUnlocks(state);
|
|
const explorationUnlocked = applyExplorationUnlocks(state);
|
|
const adventurersUnlocked = applyAdventurerUnlocks(state);
|
|
const upgradesUnlocked = applyUpgradeUnlocks(state);
|
|
const equipmentUnlocked = applyEquipmentUnlocks(state);
|
|
const storyUnlocked = applyStoryUnlocks(state);
|
|
return {
|
|
adventurersUnlocked,
|
|
bossesUnlocked,
|
|
equipmentUnlocked,
|
|
explorationUnlocked,
|
|
questsUnlocked,
|
|
storyUnlocked,
|
|
upgradesUnlocked,
|
|
zonesUnlocked,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Injects any entries from a defaults array that are missing from an existing
|
|
* saved array (matched by `id`), cloning each new entry before pushing.
|
|
* @param existing - The player's saved array (mutated in place).
|
|
* @param defaults - The current default data array to compare against.
|
|
* @returns The number of entries that were added.
|
|
*/
|
|
const injectMissingEntries = <T extends { id: string }>(
|
|
existing: Array<T>,
|
|
defaults: Array<T>,
|
|
): number => {
|
|
const existingIds = new Set(existing.map((item) => {
|
|
return item.id;
|
|
}));
|
|
let added = 0;
|
|
for (const item of defaults) {
|
|
if (!existingIds.has(item.id)) {
|
|
existing.push(structuredClone(item));
|
|
added = added + 1;
|
|
}
|
|
}
|
|
const defaultOrder = new Map(defaults.map((item, index) => {
|
|
return [ item.id, index ] as const;
|
|
}));
|
|
existing.sort((itemA, itemB) => {
|
|
return (defaultOrder.get(itemA.id) ?? Number.MAX_SAFE_INTEGER)
|
|
- (defaultOrder.get(itemB.id) ?? Number.MAX_SAFE_INTEGER);
|
|
});
|
|
return added;
|
|
};
|
|
|
|
/**
|
|
* Injects any exploration areas from the defaults that are missing from the
|
|
* player's exploration state, seeding each new area as locked.
|
|
* @param state - The player's current game state (mutated in place).
|
|
* @returns The number of exploration areas that were added.
|
|
*/
|
|
const injectMissingExplorationAreas = (state: GameState): number => {
|
|
if (state.exploration === undefined) {
|
|
return 0;
|
|
}
|
|
const existingIds = new Set(state.exploration.areas.map((area) => {
|
|
return area.id;
|
|
}));
|
|
let added = 0;
|
|
for (const area of defaultExplorations) {
|
|
if (!existingIds.has(area.id)) {
|
|
state.exploration.areas.push({ id: area.id, status: "locked" });
|
|
added = added + 1;
|
|
}
|
|
}
|
|
return added;
|
|
};
|
|
|
|
/**
|
|
* Patches rewards on existing quests whose reward lists have grown since the
|
|
* save was created (e.g. A new upgrade added as a reward to an old quest).
|
|
* @param state - The player's current game state (mutated in place).
|
|
* @returns The total number of individual rewards that were added.
|
|
*/
|
|
const patchQuestRewards = (state: GameState): number => {
|
|
const defaultQuestMap = new Map(defaultQuests.map((quest) => {
|
|
return [ quest.id, quest ] as const;
|
|
}));
|
|
let added = 0;
|
|
for (const savedQuest of state.quests) {
|
|
const defaultQuest = defaultQuestMap.get(savedQuest.id);
|
|
if (defaultQuest === undefined) {
|
|
continue;
|
|
}
|
|
const existingKeys = new Set(savedQuest.rewards.map((reward) => {
|
|
return `${reward.type}:${String(reward.targetId ?? reward.amount ?? "")}`;
|
|
}));
|
|
for (const reward of defaultQuest.rewards) {
|
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
/* v8 ignore next -- @preserve */
|
|
const key = `${reward.type}:${String(reward.targetId ?? reward.amount ?? "")}`;
|
|
if (!existingKeys.has(key)) {
|
|
savedQuest.rewards.push(structuredClone(reward));
|
|
added = added + 1;
|
|
}
|
|
}
|
|
}
|
|
return added;
|
|
};
|
|
|
|
/**
|
|
* Patches upgradeRewards on existing bosses whose reward lists have grown
|
|
* since the save was created.
|
|
* @param state - The player's current game state (mutated in place).
|
|
* @returns The total number of upgrade reward IDs that were added.
|
|
*/
|
|
const patchBossUpgradeRewards = (state: GameState): number => {
|
|
const defaultBossMap = new Map(defaultBosses.map((boss) => {
|
|
return [ boss.id, boss ] as const;
|
|
}));
|
|
let added = 0;
|
|
for (const savedBoss of state.bosses) {
|
|
const defaultBoss = defaultBossMap.get(savedBoss.id);
|
|
if (defaultBoss === undefined) {
|
|
continue;
|
|
}
|
|
const existingIds = new Set(savedBoss.upgradeRewards);
|
|
for (const upgradeId of defaultBoss.upgradeRewards) {
|
|
if (!existingIds.has(upgradeId)) {
|
|
savedBoss.upgradeRewards.push(upgradeId);
|
|
added = added + 1;
|
|
}
|
|
}
|
|
}
|
|
return added;
|
|
};
|
|
|
|
/**
|
|
* Updates the stat fields of existing adventurers to match the current defaults,
|
|
* preserving only player-state fields (count and unlocked status).
|
|
* @param state - The player's current game state (mutated in place).
|
|
* @returns The number of adventurer entries whose stats were updated.
|
|
*/
|
|
const patchAdventurerStats = (state: GameState): number => {
|
|
const defaultAdventurerMap = new Map(defaultAdventurers.map((adventurer) => {
|
|
return [ adventurer.id, adventurer ] as const;
|
|
}));
|
|
let patched = 0;
|
|
for (const savedAdventurer of state.adventurers) {
|
|
const defaultAdventurer = defaultAdventurerMap.get(savedAdventurer.id);
|
|
if (defaultAdventurer === undefined) {
|
|
continue;
|
|
}
|
|
savedAdventurer.baseCost = defaultAdventurer.baseCost;
|
|
savedAdventurer.class = defaultAdventurer.class;
|
|
savedAdventurer.combatPower = defaultAdventurer.combatPower;
|
|
savedAdventurer.essencePerSecond = defaultAdventurer.essencePerSecond;
|
|
savedAdventurer.goldPerSecond = defaultAdventurer.goldPerSecond;
|
|
savedAdventurer.level = defaultAdventurer.level;
|
|
savedAdventurer.name = defaultAdventurer.name;
|
|
patched = patched + 1;
|
|
}
|
|
return patched;
|
|
};
|
|
|
|
/**
|
|
* Updates the stat fields of existing quests to match the current defaults,
|
|
* preserving only player-state fields (status, startedAt, lastFailedAt, rewards).
|
|
* @param state - The player's current game state (mutated in place).
|
|
* @returns The number of quest entries whose stats were updated.
|
|
*/
|
|
const patchQuestStats = (state: GameState): number => {
|
|
const defaultQuestMap = new Map(defaultQuests.map((quest) => {
|
|
return [ quest.id, quest ] as const;
|
|
}));
|
|
let patched = 0;
|
|
for (const savedQuest of state.quests) {
|
|
const defaultQuest = defaultQuestMap.get(savedQuest.id);
|
|
if (defaultQuest === undefined) {
|
|
continue;
|
|
}
|
|
savedQuest.name = defaultQuest.name;
|
|
savedQuest.description = defaultQuest.description;
|
|
savedQuest.durationSeconds = defaultQuest.durationSeconds;
|
|
savedQuest.prerequisiteIds = defaultQuest.prerequisiteIds;
|
|
savedQuest.zoneId = defaultQuest.zoneId;
|
|
if (defaultQuest.combatPowerRequired !== undefined) {
|
|
savedQuest.combatPowerRequired = defaultQuest.combatPowerRequired;
|
|
}
|
|
patched = patched + 1;
|
|
}
|
|
return patched;
|
|
};
|
|
|
|
/**
|
|
* Updates the stat fields of existing bosses to match the current defaults,
|
|
* preserving only player-state fields (status, currentHp, bountyRunestonesClaimed, upgradeRewards).
|
|
* @param state - The player's current game state (mutated in place).
|
|
* @returns The number of boss entries whose stats were updated.
|
|
*/
|
|
const patchBossStats = (state: GameState): number => {
|
|
const defaultBossMap = new Map(defaultBosses.map((boss) => {
|
|
return [ boss.id, boss ] as const;
|
|
}));
|
|
let patched = 0;
|
|
for (const savedBoss of state.bosses) {
|
|
const defaultBoss = defaultBossMap.get(savedBoss.id);
|
|
if (defaultBoss === undefined) {
|
|
continue;
|
|
}
|
|
savedBoss.name = defaultBoss.name;
|
|
savedBoss.description = defaultBoss.description;
|
|
savedBoss.maxHp = defaultBoss.maxHp;
|
|
savedBoss.damagePerSecond = defaultBoss.damagePerSecond;
|
|
savedBoss.goldReward = defaultBoss.goldReward;
|
|
savedBoss.essenceReward = defaultBoss.essenceReward;
|
|
savedBoss.crystalReward = defaultBoss.crystalReward;
|
|
savedBoss.equipmentRewards = [ ...defaultBoss.equipmentRewards ];
|
|
savedBoss.prestigeRequirement = defaultBoss.prestigeRequirement;
|
|
savedBoss.zoneId = defaultBoss.zoneId;
|
|
savedBoss.bountyRunestones = defaultBoss.bountyRunestones;
|
|
patched = patched + 1;
|
|
}
|
|
return patched;
|
|
};
|
|
|
|
/**
|
|
* Updates the stat fields of existing zones to match the current defaults,
|
|
* preserving only player-state fields (status).
|
|
* @param state - The player's current game state (mutated in place).
|
|
* @returns The number of zone entries whose stats were updated.
|
|
*/
|
|
const patchZoneStats = (state: GameState): number => {
|
|
const defaultZoneMap = new Map(defaultZones.map((zone) => {
|
|
return [ zone.id, zone ] as const;
|
|
}));
|
|
let patched = 0;
|
|
for (const savedZone of state.zones) {
|
|
const defaultZone = defaultZoneMap.get(savedZone.id);
|
|
if (defaultZone === undefined) {
|
|
continue;
|
|
}
|
|
savedZone.name = defaultZone.name;
|
|
savedZone.description = defaultZone.description;
|
|
savedZone.emoji = defaultZone.emoji;
|
|
savedZone.unlockBossId = defaultZone.unlockBossId;
|
|
savedZone.unlockQuestId = defaultZone.unlockQuestId;
|
|
patched = patched + 1;
|
|
}
|
|
return patched;
|
|
};
|
|
|
|
/**
|
|
* Updates the stat fields of existing upgrades to match the current defaults,
|
|
* preserving only player-state fields (purchased, unlocked).
|
|
* @param state - The player's current game state (mutated in place).
|
|
* @returns The number of upgrade entries whose stats were updated.
|
|
*/
|
|
const patchUpgradeStats = (state: GameState): number => {
|
|
const defaultUpgradeMap = new Map(defaultUpgrades.map((upgrade) => {
|
|
return [ upgrade.id, upgrade ] as const;
|
|
}));
|
|
let patched = 0;
|
|
for (const savedUpgrade of state.upgrades) {
|
|
const defaultUpgrade = defaultUpgradeMap.get(savedUpgrade.id);
|
|
if (defaultUpgrade === undefined) {
|
|
continue;
|
|
}
|
|
savedUpgrade.name = defaultUpgrade.name;
|
|
savedUpgrade.description = defaultUpgrade.description;
|
|
savedUpgrade.target = defaultUpgrade.target;
|
|
if (defaultUpgrade.adventurerId !== undefined) {
|
|
savedUpgrade.adventurerId = defaultUpgrade.adventurerId;
|
|
}
|
|
savedUpgrade.multiplier = defaultUpgrade.multiplier;
|
|
savedUpgrade.costGold = defaultUpgrade.costGold;
|
|
savedUpgrade.costEssence = defaultUpgrade.costEssence;
|
|
savedUpgrade.costCrystals = defaultUpgrade.costCrystals;
|
|
patched = patched + 1;
|
|
}
|
|
return patched;
|
|
};
|
|
|
|
/**
|
|
* Updates the stat fields of existing equipment items to match the current defaults,
|
|
* preserving only player-state fields (owned, equipped).
|
|
* @param state - The player's current game state (mutated in place).
|
|
* @returns The number of equipment entries whose stats were updated.
|
|
*/
|
|
const patchEquipmentStats = (state: GameState): number => {
|
|
const defaultEquipmentMap = new Map(defaultEquipment.map((item) => {
|
|
return [ item.id, item ] as const;
|
|
}));
|
|
let patched = 0;
|
|
for (const savedItem of state.equipment) {
|
|
const defaultItem = defaultEquipmentMap.get(savedItem.id);
|
|
if (defaultItem === undefined) {
|
|
continue;
|
|
}
|
|
savedItem.name = defaultItem.name;
|
|
savedItem.description = defaultItem.description;
|
|
savedItem.type = defaultItem.type;
|
|
savedItem.rarity = defaultItem.rarity;
|
|
savedItem.bonus = structuredClone(defaultItem.bonus);
|
|
if (defaultItem.cost !== undefined) {
|
|
savedItem.cost = { ...defaultItem.cost };
|
|
}
|
|
if (defaultItem.setId !== undefined) {
|
|
savedItem.setId = defaultItem.setId;
|
|
}
|
|
patched = patched + 1;
|
|
}
|
|
return patched;
|
|
};
|
|
|
|
/**
|
|
* Updates the stat fields of existing achievements to match the current defaults,
|
|
* preserving only player-state fields (unlockedAt).
|
|
* @param state - The player's current game state (mutated in place).
|
|
* @returns The number of achievement entries whose stats were updated.
|
|
*/
|
|
const patchAchievementStats = (state: GameState): number => {
|
|
const defaultAchievementMap = new Map(defaultAchievements.map((a) => {
|
|
return [ a.id, a ] as const;
|
|
}));
|
|
let patched = 0;
|
|
for (const savedAchievement of state.achievements) {
|
|
const defaultAchievement = defaultAchievementMap.get(savedAchievement.id);
|
|
if (defaultAchievement === undefined) {
|
|
continue;
|
|
}
|
|
savedAchievement.name = defaultAchievement.name;
|
|
savedAchievement.description = defaultAchievement.description;
|
|
savedAchievement.icon = defaultAchievement.icon;
|
|
savedAchievement.condition = structuredClone(defaultAchievement.condition);
|
|
if (defaultAchievement.reward !== undefined) {
|
|
savedAchievement.reward = { ...defaultAchievement.reward };
|
|
}
|
|
patched = patched + 1;
|
|
}
|
|
return patched;
|
|
};
|
|
|
|
/* eslint-disable stylistic/max-len -- Filter conditions cannot be shortened without losing readability */
|
|
/**
|
|
* Recomputes all four crafting multipliers from the player's craftedRecipeIds,
|
|
* replacing any stale cached values with the correct product of all crafted bonuses.
|
|
* @param state - The player's current game state (mutated in place).
|
|
* @returns The number of crafted recipe IDs that were processed, or 0 if exploration is undefined.
|
|
*/
|
|
const recomputeCraftingMultipliers = (state: GameState): number => {
|
|
if (state.exploration === undefined) {
|
|
return 0;
|
|
}
|
|
const { craftedRecipeIds } = state.exploration;
|
|
state.exploration.craftedGoldMultiplier = defaultRecipes.filter((recipe) => {
|
|
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "gold_income";
|
|
}).reduce((multiplier, recipe) => {
|
|
return multiplier * recipe.bonus.value;
|
|
}, 1);
|
|
state.exploration.craftedEssenceMultiplier = defaultRecipes.filter((recipe) => {
|
|
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "essence_income";
|
|
}).reduce((multiplier, recipe) => {
|
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
/* v8 ignore next -- @preserve */
|
|
return multiplier * recipe.bonus.value;
|
|
}, 1);
|
|
state.exploration.craftedClickMultiplier = defaultRecipes.filter((recipe) => {
|
|
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "click_power";
|
|
}).reduce((multiplier, recipe) => {
|
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
/* v8 ignore next -- @preserve */
|
|
return multiplier * recipe.bonus.value;
|
|
}, 1);
|
|
state.exploration.craftedCombatMultiplier = defaultRecipes.filter((recipe) => {
|
|
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "combat_power";
|
|
}).reduce((multiplier, recipe) => {
|
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
/* v8 ignore next -- @preserve */
|
|
return multiplier * recipe.bonus.value;
|
|
}, 1);
|
|
return craftedRecipeIds.length;
|
|
};
|
|
/* eslint-enable stylistic/max-len -- Re-enable after long lines */
|
|
|
|
/* eslint-disable stylistic/max-len -- Long function call lines cannot be shortened without losing alignment */
|
|
/**
|
|
* Syncs a player's save with the current game data, injecting any content
|
|
* entries that are missing because they were added after the save was created,
|
|
* and patching stat fields on existing entries to match the current defaults.
|
|
* @param state - The player's current game state (mutated in place).
|
|
* @returns Counts of how many entries were added or patched per content type.
|
|
*/
|
|
const syncNewContent = (
|
|
state: GameState,
|
|
): {
|
|
achievementsAdded: number;
|
|
achievementsPatched: number;
|
|
adventurersAdded: number;
|
|
adventurerStatsPatched: number;
|
|
bossesAdded: number;
|
|
bossesPatched: number;
|
|
bossRewardsPatched: number;
|
|
craftingRecipesReapplied: number;
|
|
equipmentAdded: number;
|
|
equipmentPatched: number;
|
|
explorationAreasAdded: number;
|
|
questRewardsPatched: number;
|
|
questsAdded: number;
|
|
questsPatched: number;
|
|
upgradesAdded: number;
|
|
upgradesPatched: number;
|
|
zonesAdded: number;
|
|
zonesPatched: number;
|
|
} => {
|
|
const adventurerStatsPatched = patchAdventurerStats(state);
|
|
const questsPatched = patchQuestStats(state);
|
|
const bossesPatched = patchBossStats(state);
|
|
const zonesPatched = patchZoneStats(state);
|
|
const upgradesPatched = patchUpgradeStats(state);
|
|
const equipmentPatched = patchEquipmentStats(state);
|
|
const achievementsPatched = patchAchievementStats(state);
|
|
const craftingRecipesReapplied = recomputeCraftingMultipliers(state);
|
|
const achievementsAdded = injectMissingEntries(state.achievements, defaultAchievements);
|
|
const adventurersAdded = injectMissingEntries(state.adventurers, defaultAdventurers);
|
|
const bossRewardsPatched = patchBossUpgradeRewards(state);
|
|
const bossesAdded = injectMissingEntries(state.bosses, defaultBosses);
|
|
const equipmentAdded = injectMissingEntries(state.equipment, defaultEquipment);
|
|
const explorationAreasAdded = injectMissingExplorationAreas(state);
|
|
const questRewardsPatched = patchQuestRewards(state);
|
|
const questsAdded = injectMissingEntries(state.quests, defaultQuests);
|
|
const upgradesAdded = injectMissingEntries(state.upgrades, defaultUpgrades);
|
|
const zonesAdded = injectMissingEntries(state.zones, defaultZones);
|
|
return {
|
|
achievementsAdded,
|
|
achievementsPatched,
|
|
adventurerStatsPatched,
|
|
adventurersAdded,
|
|
bossRewardsPatched,
|
|
bossesAdded,
|
|
bossesPatched,
|
|
craftingRecipesReapplied,
|
|
equipmentAdded,
|
|
equipmentPatched,
|
|
explorationAreasAdded,
|
|
questRewardsPatched,
|
|
questsAdded,
|
|
questsPatched,
|
|
upgradesAdded,
|
|
upgradesPatched,
|
|
zonesAdded,
|
|
zonesPatched,
|
|
};
|
|
};
|
|
/* eslint-enable stylistic/max-len -- Re-enable after long lines */
|
|
|
|
const debugRouter = new Hono<HonoEnvironment>();
|
|
debugRouter.use(authMiddleware);
|
|
|
|
debugRouter.post("/force-unlocks", async(context) => {
|
|
try {
|
|
const discordId = context.get("discordId");
|
|
|
|
const gameStateRecord = await prisma.gameState.findUnique({
|
|
where: { discordId },
|
|
});
|
|
if (!gameStateRecord) {
|
|
return context.json({ error: "No game state found" }, 404);
|
|
}
|
|
|
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma stores state as JSON object */
|
|
const state = gameStateRecord.state as unknown as GameState;
|
|
|
|
const {
|
|
adventurersUnlocked,
|
|
bossesUnlocked,
|
|
equipmentUnlocked,
|
|
explorationUnlocked,
|
|
questsUnlocked,
|
|
storyUnlocked,
|
|
upgradesUnlocked,
|
|
zonesUnlocked,
|
|
} = applyForceUnlocks(state);
|
|
|
|
const updatedAt = Date.now();
|
|
await prisma.gameState.update({
|
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
|
data: { state: state as object, updatedAt: updatedAt },
|
|
where: { discordId },
|
|
});
|
|
|
|
const secret = process.env.ANTI_CHEAT_SECRET;
|
|
const signature
|
|
= secret === undefined
|
|
? undefined
|
|
: computeHmac(JSON.stringify(state), secret);
|
|
|
|
return context.json({
|
|
adventurersUnlocked,
|
|
bossesUnlocked,
|
|
equipmentUnlocked,
|
|
explorationUnlocked,
|
|
questsUnlocked,
|
|
signature,
|
|
state,
|
|
storyUnlocked,
|
|
upgradesUnlocked,
|
|
zonesUnlocked,
|
|
});
|
|
} catch (error) {
|
|
void logger.error(
|
|
"debug_force_unlocks",
|
|
error instanceof Error
|
|
? error
|
|
: new Error(String(error)),
|
|
);
|
|
return context.json({ error: "Internal server error" }, 500);
|
|
}
|
|
});
|
|
|
|
debugRouter.post("/sync-new-content", async(context) => {
|
|
try {
|
|
const discordId = context.get("discordId");
|
|
|
|
const gameStateRecord = await prisma.gameState.findUnique({
|
|
where: { discordId },
|
|
});
|
|
if (!gameStateRecord) {
|
|
return context.json({ error: "No game state found" }, 404);
|
|
}
|
|
|
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma stores state as JSON object */
|
|
const state = gameStateRecord.state as unknown as GameState;
|
|
|
|
const {
|
|
achievementsAdded,
|
|
achievementsPatched,
|
|
adventurersAdded,
|
|
adventurerStatsPatched,
|
|
bossesAdded,
|
|
bossesPatched,
|
|
bossRewardsPatched,
|
|
craftingRecipesReapplied,
|
|
equipmentAdded,
|
|
equipmentPatched,
|
|
explorationAreasAdded,
|
|
questRewardsPatched,
|
|
questsAdded,
|
|
questsPatched,
|
|
upgradesAdded,
|
|
upgradesPatched,
|
|
zonesAdded,
|
|
zonesPatched,
|
|
} = syncNewContent(state);
|
|
|
|
const updatedAt = Date.now();
|
|
await prisma.gameState.update({
|
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
|
data: { state: state as object, updatedAt: updatedAt },
|
|
where: { discordId },
|
|
});
|
|
|
|
const secret = process.env.ANTI_CHEAT_SECRET;
|
|
const signature
|
|
= secret === undefined
|
|
? undefined
|
|
: computeHmac(JSON.stringify(state), secret);
|
|
|
|
return context.json({
|
|
achievementsAdded,
|
|
achievementsPatched,
|
|
adventurerStatsPatched,
|
|
adventurersAdded,
|
|
bossRewardsPatched,
|
|
bossesAdded,
|
|
bossesPatched,
|
|
craftingRecipesReapplied,
|
|
equipmentAdded,
|
|
equipmentPatched,
|
|
explorationAreasAdded,
|
|
questRewardsPatched,
|
|
questsAdded,
|
|
questsPatched,
|
|
signature,
|
|
state,
|
|
upgradesAdded,
|
|
upgradesPatched,
|
|
zonesAdded,
|
|
zonesPatched,
|
|
});
|
|
} catch (error) {
|
|
void logger.error(
|
|
"debug_sync_new_content",
|
|
error instanceof Error
|
|
? error
|
|
: new Error(String(error)),
|
|
);
|
|
return context.json({ error: "Internal server error" }, 500);
|
|
}
|
|
});
|
|
|
|
debugRouter.post("/hard-reset", async(context) => {
|
|
try {
|
|
const discordId = context.get("discordId");
|
|
|
|
const playerRecord = await prisma.player.findUnique({
|
|
where: { discordId },
|
|
});
|
|
if (!playerRecord) {
|
|
return context.json({ error: "No player found" }, 404);
|
|
}
|
|
|
|
const freshState = initialGameState(
|
|
{
|
|
avatar: playerRecord.avatar,
|
|
characterName: playerRecord.characterName,
|
|
createdAt: playerRecord.createdAt,
|
|
discordId: playerRecord.discordId,
|
|
discriminator: playerRecord.discriminator,
|
|
lastSavedAt: Date.now(),
|
|
lifetimeAchievementsUnlocked: playerRecord.lifetimeAchievementsUnlocked,
|
|
lifetimeAdventurersRecruited: playerRecord.lifetimeAdventurersRecruited,
|
|
lifetimeBossesDefeated: playerRecord.lifetimeBossesDefeated,
|
|
lifetimeClicks: playerRecord.lifetimeClicks,
|
|
lifetimeGoldEarned: playerRecord.lifetimeGoldEarned,
|
|
lifetimeQuestsCompleted: playerRecord.lifetimeQuestsCompleted,
|
|
totalClicks: 0,
|
|
totalGoldEarned: 0,
|
|
username: playerRecord.username,
|
|
},
|
|
playerRecord.characterName,
|
|
);
|
|
|
|
const createdAt = Date.now();
|
|
await prisma.gameState.upsert({
|
|
create: {
|
|
discordId: discordId,
|
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
|
state: freshState as object,
|
|
updatedAt: createdAt,
|
|
},
|
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
|
update: { state: freshState as object, updatedAt: createdAt },
|
|
where: { discordId },
|
|
});
|
|
|
|
const secret = process.env.ANTI_CHEAT_SECRET;
|
|
const signature
|
|
= secret === undefined
|
|
? undefined
|
|
: computeHmac(JSON.stringify(freshState), secret);
|
|
|
|
return context.json({
|
|
currentSchemaVersion: currentSchemaVersion,
|
|
loginBonus: null,
|
|
loginStreak: playerRecord.loginStreak,
|
|
offlineEssence: 0,
|
|
offlineGold: 0,
|
|
offlineSeconds: 0,
|
|
schemaOutdated: false,
|
|
signature: signature,
|
|
state: freshState,
|
|
});
|
|
} catch (error) {
|
|
void logger.error(
|
|
"debug_hard_reset",
|
|
error instanceof Error
|
|
? error
|
|
: new Error(String(error)),
|
|
);
|
|
return context.json({ error: "Internal server error" }, 500);
|
|
}
|
|
});
|
|
|
|
export { debugRouter };
|