generated from nhcarrigan/template
666a5b2d6d
## What changed and why ### Runestone formula (`prestige.ts`) - Swapped `sqrt` for `cbrt` — much stronger diminishing returns for large gold values - Added base cap of **200** (→ ~1,125 max with all upgrades at 5.625× multiplier) - Prevents extended AFK sessions from producing runestone windfalls that allow immediate upgrade purchasing and rapid prestige chaining ### Prestige threshold formula (`prestige.ts`) - Old: `1,000,000 × 5^n` — exponential, grows impossibly fast, prestige 10+ takes years - New: `1,000,000 × (n+1)²` — polynomial, peaks at ~1 day/run around P8–10, then gets *easier* as the production multiplier overtakes it - Removed `thresholdScaleFactor` constant (no longer needed) ### Production multiplier (`prestige.ts`) - Old: `1.15^n` - New: `1.25^n` — compounds faster, ensures the polynomial threshold eventually gets easy in the late game ### Boss prestige requirements (`bosses.ts`) - Rescaled proportionally from 0–88 range to 0–20 range - The Absolute One now requires prestige **20** (was 88), making transcendence reachable in a few weeks of idle play ### Echo formula (`transcendence.ts`) - Constant changed from 853 → **224** - At the target prestige of 20: `floor(224 / sqrt(20)) = 50 echoes` per transcendence (no meta upgrades) - With all echo_meta upgrades (3.75× total): up to **187 echoes** per transcendence ### Transcendence upgrade costs (`transcendenceUpgrades.ts`) - Old total: **866 echoes** → New total: **400 echoes** (roughly halved across all categories) - Apotheosis still requires **all 15 upgrades** purchased ### Balance fixes (closes #141, #142, #143, #144, #145) - Equipment: `philosophers_stone` click multiplier 2.25→2.5, `crystal_shard` 1.55→1.65 (#144) - Recipes: added `primal_omega_lens` cross-zone click_power recipe at 1.38× (#142) - Adventurers: `celestial_guard` base cost adjusted to smooth tier 14→15→16 cost curve (#145) ### Quest reward rebalancing (closes #136, #137) - Shadow Marshes: buffed `shadow_mere`, `witch_coven`, `plague_ruins` rewards to match combat requirements (#136) - Astral Void: added gold to `void_rift`, increased rewards across all Astral Void quests (#137) ### Boss reward additions (closes #138, #139, #140) - Assigned 9 unassigned adventurer-specific upgrades to Crystalline Spire through Eternal Throne bosses that had empty `upgradeRewards` arrays (#140) ### Combat power documentation (closes #153) - Expanded JSDoc on `computePartyCombatPower` to clarify companion `bossDamage` multiplier behaviour ### Effective adventurer stats (closes #154) - Added `computeEffectiveAdventurerStats` to `tick.ts` and updated `AdventurerCard` to display effective post-multiplier stats ### Adventurer upgrade timing (closes #158) - Audited every adventurer-specific upgrade reward — upgrades now land within the same progression window where that adventurer tier is still a meaningful contributor ### Sync and save fixes (closes #147, #148, #151) - Fixed sync new content count to report only genuinely changed items (#147) - Fixed signature mismatch after first auto-boss completion (#148) - Added auto-buy cap (100) on non-max-tier adventurers (#151) ### Auto-adventurer persistence (closes #156) - Auto-buy preference now preserved across prestige resets ### Broken CDN image (closes #159) - Uploaded missing `auto_adventurer.jpg` to CDN ### Codex unlock hints (closes #146) - Locked codex entries now display a hint generated from `sourceType` and `sourceId` ### Exploration bug fixes (closes #160, #161) - Fixed auto-save race condition discarding exploration materials collected mid-tick (#160) - Fixed exploration areas failing to unlock when zone was unlocked via boss kill or quest completion (#161) ### Concurrent prestige fix (closes #162) - Added optimistic locking via `updatedAt` — concurrent prestige requests return 409 ### Prestige UX (closes #163) - Added `reloadSilent` to game context — no loading screen flash after prestige ### Balance adjustments (closes #164, #165, #166, #167) - Reduced `shadow_mere` CP requirement 5,000,000 → 2,000,000 (#164) - Buffed crystal drops from Shadow Marshes bosses and quests (#165) - Increased runestone yield from 10 → 15 per prestige level (#166) - Daily challenge set always includes a clicks challenge (#167) ### Progression QoL (closes #168, #169) - Added `computeProjectedRunestones()` and persistent `+N On Prestige` resource bar row (#168) - Added `enablePrestigeAnnouncements` setting per player (#169) --- ## Comprehensive balance audit (closes #187, #191, #192, #193, #194, #195, #196, #197, #198) ### Crystal economy fixes - Zeroed crystal rewards for all Zone 7+ boss drops (Celestial Reaches onwards) — crystals are an early/mid-game currency and should not flow freely into the endgame (#187) - Zeroed crystal rewards for all Zone 9+ quest rewards (Infernal Court onwards) — same rationale (#191) ### Achievement additions and fixes - Added quest milestone achievements at 75 quests (10,000 crystals) and 100 quests (15,000 crystals) - Added boss milestone achievement at 50 bosses (15,000 crystals) - Added prestige milestone achievements at P50, P100, P150, P200 — rewarding **runestones** rather than crystals to match the late-game economy - Added gold milestone achievements through 1e90 gold earned - Fixed `quest_eternal` condition from 122 → **112** (actual quest count) — was permanently impossible (#197) - Fixed `fully_equipped` condition from 65 → **78** (actual equipment count after new items) (#197) - Fixed `devourer_slayer` description to remove incorrect zone reference ### Upgrade balance - Fixed Essence Guild multiplier 1.5× → **2×** — was identical to the cheaper Merchant Alliance for 5× the cost (#194) - Raised Void Ascendancy crystal cost 10M → **50M** — was trivially cheap compared to the parallel Celestial Mandate upgrade (100B essence + 50T gold) (#195) - Fixed Sunken Temple quest rewards (gold 2M → 60M, essence 1,500 → 25,000, crystals 75 → 400) — was rewarding less than its easier prerequisite Witch Coven (#193) ### Equipment balance - Buffed Eternal Prism stats to click 5×, combat **3×**, gold **2.5×** — was only marginally better than the free Eternity Stone boss drop for 100M crystals (#196) ### Missing content - Created **13 missing equipment items** for Zones 15–18 (primordial_chaos through the_absolute) that were referenced by late-game boss `equipmentRewards` arrays but never existed in `equipment.ts` (#198): - `chaos_mantle`, `titan_core` (Primordial Chaos) - `expanse_blade`, `void_armour_mk2` (Infinite Expanse) - `cosmos_blade`, `reality_plate` (Reality Forge) - `maelstrom_edge`, `cosmic_plate` (Cosmic Maelstrom) - `primeval_blade`, `ancient_aegis` (Primeval Sanctum) - `absolute_blade`, `eternity_plate`, `omniversal_core` (The Absolute) - Stats scale from combat 14× / gold 9× (Zone 15) up to combat 28× / gold 20× for the final boss drops ### Type system - Extended `AchievementReward` type to support `runestones` field - Updated tick engine achievement processing to award both crystals and runestones --- ## Target progression timeline (optimal play, ~16h/day idle) - First cycle to P20: ~375h (~3.3 weeks) - Each subsequent cycle gets faster as echo upgrades boost income/combat/threshold - Expected **~5 transcendences** before apotheosis at 50–187 echoes/transcendence - **~6 months** to apotheosis for a dedicated player ## Test plan - [ ] Lint, build, and test pipeline passes (100% coverage maintained) - [ ] Prestige threshold at P0 is still 1,000,000 gold - [ ] Prestige runs feel ~1 day long around P8–10 and get easier after - [ ] The Absolute One is locked until prestige 20 - [ ] Transcendence at P20 awards 50 echoes (no meta upgrades) - [ ] All 15 transcendence upgrades cost 400 echoes total - [ ] Bosses in Zones 7+ drop 0 crystals; Zones 1–6 retain crystal drops - [ ] Quests in Zones 9+ reward 0 crystals; Zones 1–8 retain crystal rewards - [ ] Sunken Temple rewards more gold/essence/crystals than Witch Coven - [ ] Essence Guild gives 2× income (stronger than Merchant Alliance 1.5×) - [ ] Void Ascendancy costs 50M crystals - [ ] Eternal Prism stats are click 5×, combat 3×, gold 2.5× - [ ] Late-game bosses (primordial_titan through the_absolute_one) drop equipment on kill - [ ] `quest_eternal` achievement requires 112 quests - [ ] `fully_equipped` achievement requires 78 equipment pieces - [ ] P50/P100/P150/P200 prestige achievements reward runestones - [ ] Adventurer cards show effective post-multiplier stats - [ ] Exploration areas unlock correctly when their zone is unlocked - [ ] Concurrent prestige requests return 409 - [ ] No loading screen flash after prestige - [ ] Daily challenge set always includes a clicks challenge - [ ] Resource bar shows `+N On Prestige` runestone preview ✨ This PR was crafted with help from Hikari~ 🌸 Reviewed-on: #135 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
1252 lines
42 KiB
TypeScript
1252 lines
42 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;
|
|
}
|
|
const hasChanged
|
|
= 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;
|
|
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;
|
|
if (hasChanged) {
|
|
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;
|
|
}
|
|
const savedPrereqs = JSON.stringify(savedQuest.prerequisiteIds);
|
|
const defaultPrereqs = JSON.stringify(defaultQuest.prerequisiteIds);
|
|
const hasChanged
|
|
= savedQuest.name !== defaultQuest.name
|
|
|| savedQuest.description !== defaultQuest.description
|
|
|| savedQuest.durationSeconds !== defaultQuest.durationSeconds
|
|
|| savedPrereqs !== defaultPrereqs
|
|
|| savedQuest.zoneId !== defaultQuest.zoneId
|
|
|| savedQuest.combatPowerRequired !== defaultQuest.combatPowerRequired;
|
|
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;
|
|
}
|
|
if (hasChanged) {
|
|
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.
|
|
*/
|
|
/* eslint-disable-next-line complexity, max-statements -- Comparing many boss stat fields for change detection */
|
|
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;
|
|
}
|
|
const savedRewards = JSON.stringify(savedBoss.equipmentRewards);
|
|
const defaultRewards = JSON.stringify(defaultBoss.equipmentRewards);
|
|
const hasChanged
|
|
= 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
|
|
|| savedRewards !== defaultRewards
|
|
|| savedBoss.prestigeRequirement !== defaultBoss.prestigeRequirement
|
|
|| savedBoss.zoneId !== defaultBoss.zoneId
|
|
|| savedBoss.bountyRunestones !== defaultBoss.bountyRunestones;
|
|
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;
|
|
if (hasChanged) {
|
|
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;
|
|
}
|
|
const hasChanged
|
|
= savedZone.name !== defaultZone.name
|
|
|| savedZone.description !== defaultZone.description
|
|
|| savedZone.emoji !== defaultZone.emoji
|
|
|| savedZone.unlockBossId !== defaultZone.unlockBossId
|
|
|| savedZone.unlockQuestId !== defaultZone.unlockQuestId;
|
|
savedZone.name = defaultZone.name;
|
|
savedZone.description = defaultZone.description;
|
|
savedZone.emoji = defaultZone.emoji;
|
|
savedZone.unlockBossId = defaultZone.unlockBossId;
|
|
savedZone.unlockQuestId = defaultZone.unlockQuestId;
|
|
if (hasChanged) {
|
|
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.
|
|
*/
|
|
/* eslint-disable-next-line complexity -- Comparing many upgrade stat fields for change detection */
|
|
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;
|
|
}
|
|
const hasChanged
|
|
= savedUpgrade.name !== defaultUpgrade.name
|
|
|| savedUpgrade.description !== defaultUpgrade.description
|
|
|| savedUpgrade.target !== defaultUpgrade.target
|
|
|| savedUpgrade.adventurerId !== defaultUpgrade.adventurerId
|
|
|| savedUpgrade.multiplier !== defaultUpgrade.multiplier
|
|
|| savedUpgrade.costGold !== defaultUpgrade.costGold
|
|
|| savedUpgrade.costEssence !== defaultUpgrade.costEssence
|
|
|| savedUpgrade.costCrystals !== defaultUpgrade.costCrystals;
|
|
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;
|
|
if (hasChanged) {
|
|
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.
|
|
*/
|
|
/* eslint-disable-next-line complexity, max-statements -- Comparing many equipment stat fields for change detection */
|
|
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;
|
|
}
|
|
const savedBonus = JSON.stringify(savedItem.bonus);
|
|
const defaultBonus = JSON.stringify(defaultItem.bonus);
|
|
const savedCost = JSON.stringify(savedItem.cost);
|
|
const defaultCost = JSON.stringify(defaultItem.cost);
|
|
const hasChanged
|
|
= savedItem.name !== defaultItem.name
|
|
|| savedItem.description !== defaultItem.description
|
|
|| savedItem.type !== defaultItem.type
|
|
|| savedItem.rarity !== defaultItem.rarity
|
|
|| savedBonus !== defaultBonus
|
|
|| savedCost !== defaultCost
|
|
|| savedItem.setId !== defaultItem.setId;
|
|
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;
|
|
}
|
|
if (hasChanged) {
|
|
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;
|
|
}
|
|
const savedCondition = JSON.stringify(savedAchievement.condition);
|
|
const defaultCondition = JSON.stringify(defaultAchievement.condition);
|
|
const savedReward = JSON.stringify(savedAchievement.reward);
|
|
const defaultReward = JSON.stringify(defaultAchievement.reward);
|
|
const hasChanged
|
|
= savedAchievement.name !== defaultAchievement.name
|
|
|| savedAchievement.description !== defaultAchievement.description
|
|
|| savedAchievement.icon !== defaultAchievement.icon
|
|
|| savedCondition !== defaultCondition
|
|
|| savedReward !== defaultReward;
|
|
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 };
|
|
}
|
|
if (hasChanged) {
|
|
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 };
|