/** * @file Game routes handling save/load mechanics, daily bonuses, and anti-cheat validation. * @copyright nhcarrigan * @license Naomi's Public License * @author Naomi Carrigan */ /* eslint-disable max-lines -- Game route has many validation steps */ /* eslint-disable max-lines-per-function -- Route handlers require many steps */ /* eslint-disable max-statements -- Route handlers require many statements */ /* eslint-disable complexity -- Route handlers have inherent complexity */ import { createHmac } from "node:crypto"; import { computeSetBonuses, computeUnlockedCompanionIds, getActiveCompanionBonus, type GameState, type LoginBonusResult, type SaveRequest, } from "@elysium/types"; import { Hono } from "hono"; import { defaultBosses } from "../data/bosses.js"; import { defaultEquipmentSets } from "../data/equipmentSets.js"; import { initialGameState } from "../data/initialState.js"; import { dailyRewards } from "../data/loginBonus.js"; import { defaultQuests } from "../data/quests.js"; import { currentSchemaVersion } from "../data/schemaVersion.js"; import { prisma } from "../db/client.js"; import { authMiddleware } from "../middleware/auth.js"; import { getOrResetDailyChallenges } from "../services/dailyChallenges.js"; import { fetchDiscordUserById } from "../services/discord.js"; import { logger } from "../services/logger.js"; import { calculateOfflineEarnings } from "../services/offlineProgress.js"; import { checkAndUnlockTitles, parseUnlockedTitles, } from "../services/titles.js"; import type { HonoEnvironment } from "../types/hono.js"; const resourceCap = 1e300; /** * Maximum elapsed seconds credited for passive income — mirrors the offline earnings cap. */ const elapsedCapSeconds = 8 * 3600; /** * Multiplier applied to passive income when computing the maximum legitimate gold/essence * increase per save. The 2× buffer covers mid-session purchases (adventurers, upgrades) * that increase income beyond what the previous DB snapshot can predict. */ const incomeBufferMultiplier = 2; /** * Generous clicks-per-second estimate used to bound click income between saves. */ const clickBufferCps = 10; /** * 60-second grace period when checking whether a quest timer has expired. */ const questGraceMs = 60_000; /** * 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"); }; /** * Calculates the maximum passive gold and essence income per second from the given state, * using the same formula as applyTick in tick.ts. Must be kept in sync with that function. * @param state - The current game state to compute income for. * @returns An object with goldPerSecond and essencePerSecond values. */ const computeMaxPassiveIncome = ( state: GameState, ): { goldPerSecond: number; essencePerSecond: number } => { // Gather equipped items and compute multipliers // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 11 -- @preserve */ const equippedItems = state.equipment.filter((item) => { return item.equipped; }); let equipmentGoldMultiplier = 1; for (const item of equippedItems) { const goldMult = item.bonus.goldMultiplier ?? 1; equipmentGoldMultiplier = equipmentGoldMultiplier * goldMult; } const equippedItemIds = equippedItems.map((item) => { return item.id; }); const setGoldMultiplier = computeSetBonuses( equippedItemIds, defaultEquipmentSets, ).goldMultiplier; // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 5 -- @preserve */ const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1; const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1; const craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1; const craftedEssenceMultiplier = state.exploration?.craftedEssenceMultiplier ?? 1; // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 8 -- @preserve */ const companionBonus = getActiveCompanionBonus( state.companions?.activeCompanionId, state.companions?.unlockedCompanionIds ?? [], ); const companionGoldMult = companionBonus?.type === "passiveGold" ? 1 + companionBonus.value : 1; // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 4 -- @preserve */ const companionEssenceMult = companionBonus?.type === "essenceIncome" ? 1 + companionBonus.value : 1; let goldPerSecond = 0; let essencePerSecond = 0; for (const adventurer of state.adventurers) { // Skip the comment line and use a block-comment-safe pattern // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 3 -- @preserve */ if (!adventurer.unlocked || adventurer.count === 0) { continue; } // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 10 -- @preserve */ let upgradeMultiplier = 1; for (const upgrade of state.upgrades) { const isGlobal = upgrade.purchased && upgrade.target === "global"; const isThisAdventurer = upgrade.purchased && upgrade.target === "adventurer" && upgrade.adventurerId === adventurer.id; if (isGlobal || isThisAdventurer) { upgradeMultiplier = upgradeMultiplier * upgrade.multiplier; } } const prestige = state.prestige.productionMultiplier; const goldContribution = adventurer.goldPerSecond * adventurer.count * upgradeMultiplier * prestige * runestonesIncome * equipmentGoldMultiplier * setGoldMultiplier * craftedGoldMultiplier; goldPerSecond = goldPerSecond + goldContribution; const essenceContribution = adventurer.essencePerSecond * adventurer.count * upgradeMultiplier * prestige * runestonesEssence * craftedEssenceMultiplier; essencePerSecond = essencePerSecond + essenceContribution; } return { essencePerSecond: essencePerSecond * companionEssenceMult, goldPerSecond: goldPerSecond * companionGoldMult, }; }; /** * Calculates the maximum gold a player could earn per second via clicking. * Mirrors calculateClickPower from tick.ts — must be kept in sync with that function. * Uses clickBufferCps as a generous upper bound on clicks per second. * @param state - The current game state to compute click income for. * @returns The maximum gold per second from clicking. */ const computeMaxClickGoldPerSecond = (state: GameState): number => { let clickMultiplier = 1; for (const upgrade of state.upgrades) { if (upgrade.purchased && upgrade.target === "click") { clickMultiplier = clickMultiplier * upgrade.multiplier; } } // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 16 -- @preserve */ const equippedItems = state.equipment.filter((item) => { return item.equipped; }); let equipmentClickMultiplier = 1; for (const item of equippedItems) { if (item.bonus.clickMultiplier !== undefined) { equipmentClickMultiplier = equipmentClickMultiplier * item.bonus.clickMultiplier; } } const setClickMultiplier = computeSetBonuses( equippedItems.map((item) => { return item.id; }), defaultEquipmentSets, ).clickMultiplier; const runestonesClick = state.prestige.runestonesClickMultiplier ?? 1; const companionBonus = getActiveCompanionBonus( state.companions?.activeCompanionId, // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ state.companions?.unlockedCompanionIds ?? [], ); // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 4 -- @preserve */ const companionClickMult = companionBonus?.type === "clickGold" ? 1 + companionBonus.value : 1; const clickPower = state.baseClickPower * clickMultiplier * state.prestige.productionMultiplier * runestonesClick * equipmentClickMultiplier * setClickMultiplier * companionClickMult; return clickPower * clickBufferCps; }; /** * Options for the computeQuestRewards function. */ interface QuestRewardOptions { incoming: GameState; previous: GameState; now: number; questTimeReduction: number; } /** * Sums the gold and essence rewards for quests that legitimately completed during * this save interval. A quest is eligible when: * - It was "active" in the previous (DB-trusted) state, and * - Its timer has genuinely expired by the current server time (plus questGraceMs), and * - It is now "completed" in the incoming state. * * Reward amounts and durations are taken from defaultQuests (authoritative game data) * to prevent client-side reward or duration tampering. The questTimeReduction parameter * (0–1 fraction) applies a companion time bonus to the effective duration check. * @param options - The incoming and previous state, current timestamp, and questTimeReduction. * @returns An object with gold and essence totals from completed quests. */ const computeQuestRewards = ( options: QuestRewardOptions, ): { gold: number; essence: number } => { const { incoming, now, previous, questTimeReduction } = options; let gold = 0; let essence = 0; for (const incomingQuest of incoming.quests) { if (incomingQuest.status !== "completed") { continue; } const previousQuest = previous.quests.find((quest) => { return quest.id === incomingQuest.id; }); if (!previousQuest || previousQuest.status === "completed") { continue; } const questNotActive = previousQuest.status !== "active"; const questNotStarted = previousQuest.startedAt === undefined; if (questNotActive || questNotStarted) { continue; } /* * Use authoritative duration from game data so a tampered durationSeconds in the * saved state cannot cause a timer to appear expired prematurely. */ const questData = defaultQuests.find((quest) => { return quest.id === incomingQuest.id; }); if (!questData) { continue; } // Apply companion quest-time reduction to the effective duration check. const effectiveDuration = questData.durationSeconds * (1 - questTimeReduction); const durationMs = effectiveDuration * 1000; // The questNotStarted guard above ensures startedAt is defined here // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 4 -- @preserve */ const questExpiresAt = (previousQuest.startedAt ?? 0) + durationMs; if (questExpiresAt > now + questGraceMs) { continue; } // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 8 -- @preserve */ for (const reward of questData.rewards) { if (reward.type === "gold" && reward.amount !== undefined) { gold = gold + reward.amount; } if (reward.type === "essence" && reward.amount !== undefined) { essence = essence + reward.amount; } } } return { essence, gold }; }; /** * Sums the gold and essence rewards for bosses newly defeated during this save interval. * * Boss fights are fully server-authoritative (boss.ts writes rewards directly to the DB), * so in the normal flow previousState already reflects the boss rewards and this function * returns zero. It exists solely as a safety buffer for the rare race condition where a * boss DB write and a save request arrive simultaneously, leaving previousState stale. * * Reward amounts are taken from defaultBosses (authoritative game data) to prevent * client-side reward tampering. * @param incoming - The incoming game state from the client. * @param previous - The previous trusted game state from the database. * @returns An object with gold and essence totals from newly defeated bosses. */ const computeBossRewards = ( incoming: GameState, previous: GameState, ): { gold: number; essence: number } => { let gold = 0; let essence = 0; for (const incomingBoss of incoming.bosses) { if (incomingBoss.status !== "defeated") { continue; } const previousBoss = previous.bosses.find((boss) => { return boss.id === incomingBoss.id; }); if (!previousBoss || previousBoss.status === "defeated") { continue; } /* * Only credit bosses that were actually challengeable in the previous state, * ruling out bosses that somehow skipped the server-authoritative fight flow. */ if ( previousBoss.status !== "available" && previousBoss.status !== "in_progress" ) { continue; } // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 9 -- @preserve */ const bossData = defaultBosses.find((boss) => { return boss.id === incomingBoss.id; }); if (!bossData) { continue; } gold = gold + bossData.goldReward; essence = essence + bossData.essenceReward; } return { essence, gold }; }; /** * Validates the incoming state against the previous saved state and returns a * sanitised copy. Protects against: * - Gold or essence exceeding what could legitimately be earned since the last save * - Resources exceeding the absolute cap * - Runestones increasing between saves (only granted server-side via prestige) * - Defeating a boss being reversed * - Completing a quest being reversed * - Unlocking an achievement being reversed or backdated to a future timestamp * - Prestige count going backwards. * @param incoming - The incoming game state from the client. * @param previous - The previous trusted game state from the database. * @returns The sanitised game state. */ const validateAndSanitize = ( incoming: GameState, previous: GameState, ): GameState => { const now = Date.now(); /* * Elapsed seconds since the last trusted tick, capped at 8 hours to match the * offline earnings cap and prevent a stale lastTickAt from inflating the allowance. * Falls back to 30 s for old saves that predate the lastTickAt field. */ // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 4 -- @preserve */ const rawElapsed = previous.lastTickAt > 0 ? (now - previous.lastTickAt) / 1000 : 30; const elapsedSeconds = Math.max(0, Math.min(rawElapsed, elapsedCapSeconds)); // Per-second income rates from the previous (DB-trusted) state. const { goldPerSecond, essencePerSecond } = computeMaxPassiveIncome(previous); const clickGoldPerSecond = computeMaxClickGoldPerSecond(previous); // Determine quest-time reduction from the companion active in the previous (trusted) state. // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 4 -- @preserve */ const previousCompanionBonus = getActiveCompanionBonus( previous.companions?.activeCompanionId, previous.companions?.unlockedCompanionIds ?? [], ); // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 4 -- @preserve */ const questTimeReduction = previousCompanionBonus?.type === "questTime" ? previousCompanionBonus.value : 0; // Precise one-time rewards for events that could have occurred this interval. const questRewards = computeQuestRewards({ incoming, now, previous, questTimeReduction, }); const bossRewards = computeBossRewards(incoming, previous); /* * Passive and click income receive a 2× buffer to cover mid-session adventurer/upgrade * purchases that raise income beyond what the previous snapshot can predict. * Quest and boss rewards are exact (sourced from authoritative game data) and need no buffer. */ const combinedGoldPerSecond = goldPerSecond + clickGoldPerSecond; const passiveAndClickGold = combinedGoldPerSecond * elapsedSeconds * incomeBufferMultiplier; const maxGoldIncrease = passiveAndClickGold + questRewards.gold + bossRewards.gold; const passiveEssence = essencePerSecond * elapsedSeconds * incomeBufferMultiplier; const maxEssenceIncrease = passiveEssence + questRewards.essence + bossRewards.essence; const resources = { crystals: Math.min(incoming.resources.crystals, resourceCap), essence: Math.min( incoming.resources.essence, previous.resources.essence + maxEssenceIncrease, resourceCap, ), gold: Math.min( incoming.resources.gold, previous.resources.gold + maxGoldIncrease, resourceCap, ), /* * Runestones are only granted server-side via prestige and can only decrease between * saves (spent on prestige upgrades via the buy-upgrade endpoint). Cap at the previous * value to block client-side inflation. */ runestones: Math.min( incoming.resources.runestones, previous.resources.runestones, ), }; const bosses = incoming.bosses.map((boss) => { const matchingBoss = previous.bosses.find((storedBoss) => { return storedBoss.id === boss.id; }); if (!matchingBoss) { return boss; } if (matchingBoss.status === "defeated" && boss.status !== "defeated") { return { ...boss, currentHp: 0, status: "defeated" as const }; } return boss; }); const quests = incoming.quests.map((quest) => { const matchingQuest = previous.quests.find((storedQuest) => { return storedQuest.id === quest.id; }); if (!matchingQuest) { return quest; } if (matchingQuest.status === "completed" && quest.status !== "completed") { return { ...matchingQuest }; } return quest; }); const achievements = incoming.achievements.map((achievement) => { const matchingAchievement = previous.achievements.find( (storedAchievement) => { return storedAchievement.id === achievement.id; }, ); if (!matchingAchievement) { return achievement; } const wasUnlocked = matchingAchievement.unlockedAt !== null; const isNowNull = achievement.unlockedAt === null; if (wasUnlocked && isNowNull) { return { ...achievement, unlockedAt: matchingAchievement.unlockedAt }; } const isFuture = achievement.unlockedAt !== null && achievement.unlockedAt > now; if (isFuture) { const safeUnlockedAt = matchingAchievement.unlockedAt ?? null; return { ...achievement, unlockedAt: safeUnlockedAt }; } return achievement; }); // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 4 -- @preserve */ const prestige = incoming.prestige.count < previous.prestige.count ? previous.prestige : incoming.prestige; /* * If the DB prestige count is higher than the client's, the client is sending a * stale pre-prestige save. Discard its upgrades (which have purchased: true) in * favour of the DB's post-prestige upgrades (purchased: false) so that upgrade * multipliers cannot persist across prestige via a race-condition auto-save. */ const upgrades = incoming.prestige.count < previous.prestige.count ? previous.upgrades : incoming.upgrades; /* * Echoes are only granted server-side via transcendence and can only decrease between * saves (spent on echo upgrades). Cap at the previous value to block inflation. */ const cappedEchoes = Math.min( incoming.transcendence?.echoes ?? 0, previous.transcendence?.echoes ?? 0, ); // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 10 -- @preserve */ let transcendenceSpread: object = {}; if (incoming.transcendence) { transcendenceSpread = { transcendence: { ...incoming.transcendence, echoes: cappedEchoes }, }; } else if (previous.transcendence) { transcendenceSpread = { transcendence: previous.transcendence }; } // Apotheosis count can only increase server-side — cap at the previous value. // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 12 -- @preserve */ let apotheosisSpread: object = {}; if (incoming.apotheosis) { apotheosisSpread = { apotheosis: { count: Math.min( incoming.apotheosis.count, previous.apotheosis?.count ?? 0, ), }, }; } else if (previous.apotheosis) { // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 2 -- @preserve */ apotheosisSpread = { apotheosis: previous.apotheosis }; } /* * Exploration: materials and crafted recipes can only be added server-side. * Cap material quantities and crafted recipe IDs at the previous DB values to block inflation. * Crafted multipliers are always derived from the previous state (only /craft can change them). */ // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 30 -- @preserve */ let explorationSpread: object = {}; const previousExploration = previous.exploration; if (!incoming.exploration && previousExploration) { explorationSpread = { exploration: previousExploration }; } else if (incoming.exploration && !previousExploration) { explorationSpread = { exploration: incoming.exploration }; } else if (incoming.exploration && previousExploration) { const previousMaterialMap = new Map( previousExploration.materials.map((mat) => { return [ mat.materialId, mat.quantity ] as const; }), ); const materials = incoming.exploration.materials.map((material) => { const previousQuantity = previousMaterialMap.get(material.materialId) ?? 0; const cappedQuantity = Math.min(material.quantity, previousQuantity); return { ...material, quantity: cappedQuantity }; }); /* * Merge crafted recipe IDs from both states so the list can only ever grow. * A stale auto-save arriving after a craft must not silently un-craft items. */ const craftedRecipeIds = [ ...new Set([ ...previousExploration.craftedRecipeIds, ...incoming.exploration.craftedRecipeIds, ]), ]; explorationSpread = { exploration: { ...incoming.exploration, craftedClickMultiplier: previousExploration.craftedClickMultiplier, craftedCombatMultiplier: previousExploration.craftedCombatMultiplier, craftedEssenceMultiplier: previousExploration.craftedEssenceMultiplier, craftedGoldMultiplier: previousExploration.craftedGoldMultiplier, craftedRecipeIds: craftedRecipeIds, materials: materials, }, }; } /* * Story progress: completed chapters can only grow, unlocked IDs can only grow. * Low cheat risk (no rewards), so we allow all incoming additions. */ // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 28 -- @preserve */ let storySpread: object = {}; if (incoming.story) { const previousUnlocked = previous.story?.unlockedChapterIds ?? []; const previousCompleted = previous.story?.completedChapters ?? []; const unlockedChapterIds = [ ...previousUnlocked, ...incoming.story.unlockedChapterIds.filter((id) => { return !previousUnlocked.includes(id); }), ]; const previousCompletedIds = new Set( previousCompleted.map((chapter) => { return chapter.chapterId; }), ); const completedChapters = [ ...previousCompleted, ...incoming.story.completedChapters.filter((chapter) => { return !previousCompletedIds.has(chapter.chapterId); }), ]; const storyValue = { completedChapters, unlockedChapterIds }; storySpread = { story: storyValue }; } else if (previous.story) { // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 2 -- @preserve */ storySpread = { story: previous.story }; } /* * Merge daily challenge progress: take the maximum progress for each * challenge so a stale auto-save arriving after a craft/boss/etc. update * cannot silently roll back server-side challenge completions. */ // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 35 -- @preserve */ let dailyChallengesSpread: object = {}; // eslint-disable-next-line stylistic/max-len -- Long condition; splitting would reduce readability if (incoming.dailyChallenges !== undefined && previous.dailyChallenges !== undefined) { const previousChallengeMap = new Map( previous.dailyChallenges.challenges.map((challenge) => { return [ challenge.id, challenge ]; }), ); // eslint-disable-next-line stylistic/max-len -- Long chain; splitting would reduce readability const mergedChallenges = incoming.dailyChallenges.challenges.map((challenge) => { const serverChallenge = previousChallengeMap.get(challenge.id); if (serverChallenge === undefined) { return challenge; } // eslint-disable-next-line stylistic/max-len -- Long expression; splitting would reduce readability const bestProgress = Math.max(challenge.progress, serverChallenge.progress); return { ...challenge, completed: bestProgress >= challenge.target, progress: bestProgress, }; }); dailyChallengesSpread = { dailyChallenges: { ...incoming.dailyChallenges, challenges: mergedChallenges, }, }; } else if (previous.dailyChallenges !== undefined) { dailyChallengesSpread = { dailyChallenges: previous.dailyChallenges }; } /* * Goddess state: preserve server-only currencies (divinity, stardust, prayers) at * previous values, and apply the same forward-only rules to bosses/quests/achievements * and exploration materials that the mortal realm uses. * Prayers income will be computed and allowed to grow once Chunk 7 adds goddess tick logic. */ // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 145 -- @preserve */ let goddessSpread: object = {}; const previousGoddess = previous.goddess; const incomingGoddess = incoming.goddess; if (!incomingGoddess && previousGoddess) { goddessSpread = { goddess: previousGoddess }; } else if (incomingGoddess) { const goddessBosses = incomingGoddess.bosses.map((boss) => { const matchingBoss = previousGoddess?.bosses.find((storedBoss) => { return storedBoss.id === boss.id; }); if (!matchingBoss) { return boss; } if (matchingBoss.status === "defeated" && boss.status !== "defeated") { return { ...boss, currentHp: 0, status: "defeated" as const }; } return boss; }); const goddessQuests = incomingGoddess.quests.map((quest) => { const matchingQuest = previousGoddess?.quests.find((storedQuest) => { return storedQuest.id === quest.id; }); if (!matchingQuest) { return quest; } // eslint-disable-next-line stylistic/max-len -- Long condition; splitting would reduce readability if (matchingQuest.status === "completed" && quest.status !== "completed") { return { ...matchingQuest }; } return quest; }); // eslint-disable-next-line stylistic/max-len -- Long variable name; splitting would reduce readability const goddessAchievements = incomingGoddess.achievements.map((achievement) => { const matchingAchievement = previousGoddess?.achievements.find( (storedAchievement) => { return storedAchievement.id === achievement.id; }, ); if (!matchingAchievement) { return achievement; } const wasUnlocked = matchingAchievement.unlockedAt !== null; const isNowNull = achievement.unlockedAt === null; if (wasUnlocked && isNowNull) { return { ...achievement, unlockedAt: matchingAchievement.unlockedAt }; } const isFuture = achievement.unlockedAt !== null && achievement.unlockedAt > now; if (isFuture) { const safeUnlockedAt = matchingAchievement.unlockedAt ?? null; return { ...achievement, unlockedAt: safeUnlockedAt }; } return achievement; }); const previousGoddessExploration = previousGoddess?.exploration; let goddessExploration = incomingGoddess.exploration; if (previousGoddessExploration) { const previousMaterialMap = new Map( previousGoddessExploration.materials.map((mat) => { return [ mat.materialId, mat.quantity ] as const; }), ); // eslint-disable-next-line stylistic/max-len -- Long variable name; splitting would reduce readability const materials = incomingGoddess.exploration.materials.map((material) => { const previousQuantity = previousMaterialMap.get(material.materialId) ?? 0; return { ...material, quantity: Math.min(material.quantity, previousQuantity), }; }); const goddessRecipeIds = [ ...new Set([ ...previousGoddessExploration.craftedRecipeIds, ...incomingGoddess.exploration.craftedRecipeIds, ]), ]; goddessExploration = { ...incomingGoddess.exploration, // eslint-disable-next-line stylistic/max-len -- Long field name; splitting would reduce readability craftedCombatMultiplier: previousGoddessExploration.craftedCombatMultiplier, // eslint-disable-next-line stylistic/max-len -- Long field name; splitting would reduce readability craftedDivinityMultiplier: previousGoddessExploration.craftedDivinityMultiplier, // eslint-disable-next-line stylistic/max-len -- Long field name; splitting would reduce readability craftedPrayersMultiplier: previousGoddessExploration.craftedPrayersMultiplier, craftedRecipeIds: goddessRecipeIds, materials: materials, }; } const consecration = previousGoddess ? { ...incomingGoddess.consecration, count: Math.min( incomingGoddess.consecration.count, previousGoddess.consecration.count, ), divinity: Math.min( incomingGoddess.consecration.divinity, previousGoddess.consecration.divinity, ), productionMultiplier: previousGoddess.consecration.productionMultiplier, } : incomingGoddess.consecration; const enlightenment = previousGoddess ? { ...incomingGoddess.enlightenment, count: Math.min( incomingGoddess.enlightenment.count, previousGoddess.enlightenment.count, ), stardust: Math.min( incomingGoddess.enlightenment.stardust, previousGoddess.enlightenment.stardust, ), stardustCombatMultiplier: previousGoddess.enlightenment.stardustCombatMultiplier, stardustConsecrationDivinityMultiplier: previousGoddess.enlightenment.stardustConsecrationDivinityMultiplier, stardustConsecrationThresholdMultiplier: previousGoddess.enlightenment.stardustConsecrationThresholdMultiplier, stardustMetaMultiplier: previousGoddess.enlightenment.stardustMetaMultiplier, stardustPrayersMultiplier: previousGoddess.enlightenment.stardustPrayersMultiplier, } : incomingGoddess.enlightenment; goddessSpread = { goddess: { ...incomingGoddess, achievements: goddessAchievements, bosses: goddessBosses, consecration: consecration, enlightenment: enlightenment, exploration: goddessExploration, lifetimeBossesDefeated: Math.min( incomingGoddess.lifetimeBossesDefeated, previousGoddess?.lifetimeBossesDefeated ?? 0, ), lifetimePrayersEarned: Math.min( incomingGoddess.lifetimePrayersEarned, previousGoddess?.lifetimePrayersEarned ?? 0, ), lifetimeQuestsCompleted: Math.min( incomingGoddess.lifetimeQuestsCompleted, previousGoddess?.lifetimeQuestsCompleted ?? 0, ), quests: goddessQuests, totalPrayersEarned: Math.min( incomingGoddess.totalPrayersEarned, previousGoddess?.totalPrayersEarned ?? 0, ), }, }; } /* * Vampire state: preserve server-only currencies (ichor, soul shards, blood) at * previous values, and apply the same forward-only rules to bosses/quests/achievements * and exploration materials that the mortal and goddess realms use. * Blood income will be computed and allowed to grow once Chunk 7 adds vampire tick logic. */ // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 160 -- @preserve */ let vampireSpread: object = {}; const previousVampire = previous.vampire; const incomingVampire = incoming.vampire; if (!incomingVampire && previousVampire) { vampireSpread = { vampire: previousVampire }; } else if (incomingVampire) { const vampireBosses = incomingVampire.bosses.map((boss) => { const matchingBoss = previousVampire?.bosses.find((storedBoss) => { return storedBoss.id === boss.id; }); if (!matchingBoss) { return boss; } if (matchingBoss.status === "defeated" && boss.status !== "defeated") { return { ...boss, currentHp: 0, status: "defeated" as const }; } return boss; }); const vampireQuests = incomingVampire.quests.map((quest) => { const matchingQuest = previousVampire?.quests.find((storedQuest) => { return storedQuest.id === quest.id; }); if (!matchingQuest) { return quest; } // eslint-disable-next-line stylistic/max-len -- Long condition; splitting would reduce readability if (matchingQuest.status === "completed" && quest.status !== "completed") { return { ...matchingQuest }; } return quest; }); // eslint-disable-next-line stylistic/max-len -- Long variable name; splitting would reduce readability const vampireAchievements = incomingVampire.achievements.map((achievement) => { const matchingAchievement = previousVampire?.achievements.find( (storedAchievement) => { return storedAchievement.id === achievement.id; }, ); if (!matchingAchievement) { return achievement; } const wasUnlocked = matchingAchievement.unlockedAt !== null; const isNowNull = achievement.unlockedAt === null; if (wasUnlocked && isNowNull) { return { ...achievement, unlockedAt: matchingAchievement.unlockedAt }; } const isFuture = achievement.unlockedAt !== null && achievement.unlockedAt > now; if (isFuture) { const safeUnlockedAt = matchingAchievement.unlockedAt ?? null; return { ...achievement, unlockedAt: safeUnlockedAt }; } return achievement; }); const previousVampireExploration = previousVampire?.exploration; let vampireExploration = incomingVampire.exploration; if (previousVampireExploration) { const previousMaterialMap = new Map( previousVampireExploration.materials.map((mat) => { return [ mat.materialId, mat.quantity ] as const; }), ); // eslint-disable-next-line stylistic/max-len -- Long variable name; splitting would reduce readability const materials = incomingVampire.exploration.materials.map((material) => { const previousQuantity = previousMaterialMap.get(material.materialId) ?? 0; return { ...material, quantity: Math.min(material.quantity, previousQuantity), }; }); const vampireRecipeIds = [ ...new Set([ ...previousVampireExploration.craftedRecipeIds, ...incomingVampire.exploration.craftedRecipeIds, ]), ]; vampireExploration = { ...incomingVampire.exploration, // eslint-disable-next-line stylistic/max-len -- Long field name; splitting would reduce readability craftedBloodMultiplier: previousVampireExploration.craftedBloodMultiplier, // eslint-disable-next-line stylistic/max-len -- Long field name; splitting would reduce readability craftedCombatMultiplier: previousVampireExploration.craftedCombatMultiplier, // eslint-disable-next-line stylistic/max-len -- Long field name; splitting would reduce readability craftedIchorMultiplier: previousVampireExploration.craftedIchorMultiplier, craftedRecipeIds: vampireRecipeIds, materials: materials, }; } const siring = previousVampire ? { ...incomingVampire.siring, count: Math.min( incomingVampire.siring.count, previousVampire.siring.count, ), ichor: Math.min( incomingVampire.siring.ichor, previousVampire.siring.ichor, ), productionMultiplier: previousVampire.siring.productionMultiplier, } : incomingVampire.siring; const awakening = previousVampire ? { ...incomingVampire.awakening, count: Math.min( incomingVampire.awakening.count, previousVampire.awakening.count, ), soulShards: Math.min( incomingVampire.awakening.soulShards, previousVampire.awakening.soulShards, ), // eslint-disable-next-line stylistic/max-len -- Long field name; splitting would reduce readability soulShardsBloodMultiplier: previousVampire.awakening.soulShardsBloodMultiplier, // eslint-disable-next-line stylistic/max-len -- Long field name; splitting would reduce readability soulShardsCombatMultiplier: previousVampire.awakening.soulShardsCombatMultiplier, // eslint-disable-next-line stylistic/max-len -- Long field name; splitting would reduce readability soulShardsMetaMultiplier: previousVampire.awakening.soulShardsMetaMultiplier, // eslint-disable-next-line stylistic/max-len -- Long field name; splitting would reduce readability soulShardsSiringIchorMultiplier: previousVampire.awakening.soulShardsSiringIchorMultiplier, // eslint-disable-next-line stylistic/max-len -- Long field name; splitting would reduce readability soulShardsSiringThresholdMultiplier: previousVampire.awakening.soulShardsSiringThresholdMultiplier, } : incomingVampire.awakening; vampireSpread = { vampire: { ...incomingVampire, achievements: vampireAchievements, awakening: awakening, bosses: vampireBosses, eternalSovereignty: { count: Math.min( incomingVampire.eternalSovereignty.count, previousVampire?.eternalSovereignty.count ?? 0, ), }, exploration: vampireExploration, lifetimeBloodEarned: Math.min( incomingVampire.lifetimeBloodEarned, previousVampire?.lifetimeBloodEarned ?? 0, ), lifetimeBossesDefeated: Math.min( incomingVampire.lifetimeBossesDefeated, previousVampire?.lifetimeBossesDefeated ?? 0, ), lifetimeQuestsCompleted: Math.min( incomingVampire.lifetimeQuestsCompleted, previousVampire?.lifetimeQuestsCompleted ?? 0, ), quests: vampireQuests, siring: siring, totalBloodEarned: Math.min( incomingVampire.totalBloodEarned, previousVampire?.totalBloodEarned ?? 0, ), }, }; } return { ...incoming, achievements, bosses, prestige, quests, resources, upgrades, ...transcendenceSpread, ...apotheosisSpread, ...explorationSpread, ...storySpread, ...dailyChallengesSpread, ...goddessSpread, ...vampireSpread, }; }; const gameRouter = new Hono(); gameRouter.use("*", authMiddleware); gameRouter.get("/load", async(context) => { try { const discordId = context.get("discordId"); const [ [ record, playerRecord ], freshDiscordUser ] = await Promise.all([ Promise.all([ prisma.gameState.findUnique({ where: { discordId } }), prisma.player.findUnique({ where: { discordId } }), ]), fetchDiscordUserById(discordId), ]); // Refresh avatar in DB when Discord returns an updated hash if ( freshDiscordUser !== null && playerRecord !== null && freshDiscordUser.avatar !== playerRecord.avatar ) { playerRecord.avatar = freshDiscordUser.avatar; void prisma.player.update({ data: { avatar: freshDiscordUser.avatar }, where: { discordId }, }).catch((error: unknown) => { void logger.error( "avatar_refresh", error instanceof Error ? error : new Error(String(error)), ); }); } if (!record) { // No save found — create a fresh state (handles nuked DB or first-time load race) 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(), // eslint-disable-next-line stylistic/max-len -- Long property names exceed limit after try-block indent lifetimeAchievementsUnlocked: playerRecord.lifetimeAchievementsUnlocked, // eslint-disable-next-line stylistic/max-len -- Long property names exceed limit after try-block indent 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.create({ data: { discordId: discordId, /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ state: freshState as object, updatedAt: createdAt, }, }); const secret = process.env.ANTI_CHEAT_SECRET; // Sign the state for anti-cheat verification // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 3 -- @preserve */ const signature = secret === undefined ? undefined : computeHmac(JSON.stringify(freshState), secret); return context.json({ currentSchemaVersion: currentSchemaVersion, inGuild: playerRecord.inGuild, loginBonus: null, loginStreak: playerRecord.loginStreak, offlineEssence: 0, offlineGold: 0, offlineSeconds: 0, schemaOutdated: false, signature: signature, state: freshState, }); } const rawState: unknown = record.state; /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ const state = rawState as GameState; /* * Always sync character name from the Player record — the profile update route * writes to Player.characterName directly, bypassing the game state blob. */ if (playerRecord !== null) { state.player.characterName = playerRecord.characterName; state.player.avatar = playerRecord.avatar; } const now = Date.now(); const { offlineGold, offlineEssence, offlineSeconds } = calculateOfflineEarnings(state, now); if (offlineGold > 0) { state.resources.gold = state.resources.gold + offlineGold; state.player.totalGoldEarned = state.player.totalGoldEarned + offlineGold; } if (offlineEssence > 0) { state.resources.essence = state.resources.essence + offlineEssence; } // Generate or reset daily challenges if a new day has begun state.dailyChallenges = getOrResetDailyChallenges(state); // Daily login bonus — award once per calendar day (UTC) const todayUTC = new Date().toISOString(). slice(0, 10); const yesterdayUTC = new Date(now - 86_400_000).toISOString(). slice(0, 10); let loginBonus: LoginBonusResult | null = null; // Default loginStreak to 1 for brand-new accounts // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ let loginStreak = playerRecord?.loginStreak ?? 1; if (playerRecord && playerRecord.lastLoginDate !== todayUTC) { const previousStreak = playerRecord.loginStreak; const updatedStreak = playerRecord.lastLoginDate === yesterdayUTC ? previousStreak + 1 : 1; const dayIndex = (updatedStreak - 1) % 7; const weekMultiplier = Math.floor((updatedStreak - 1) / 7) + 1; const reward = dailyRewards[dayIndex]; // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 2 -- @preserve */ const goldEarned = (reward?.goldBase ?? 500) * weekMultiplier; const crystalsEarned = (reward?.crystals ?? 0) * weekMultiplier; state.resources.gold = Math.min( state.resources.gold + goldEarned, resourceCap, ); state.player.totalGoldEarned = state.player.totalGoldEarned + goldEarned; state.resources.crystals = Math.min( state.resources.crystals + crystalsEarned, resourceCap, ); loginStreak = updatedStreak; loginBonus = { crystalsEarned: crystalsEarned, day: dayIndex + 1, goldEarned: goldEarned, streak: updatedStreak, weekMultiplier: weekMultiplier, }; await prisma.player. update({ data: { lastLoginDate: todayUTC, loginStreak: updatedStreak }, where: { discordId }, }). catch((error: unknown) => { // Ignore write-conflict errors (P2034) — rethrow anything else // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 5 -- @preserve */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */ const { code } = error as { code?: string }; if (code !== "P2034") { throw error; } }); } state.lastTickAt = now; if (offlineGold > 0 || offlineEssence > 0 || loginBonus !== null) { // Persist updated state immediately so offline/login rewards aren't double-counted. /* * Swallow write conflicts (P2034): offline earnings and login bonus are applied * server-side and must be persisted immediately so they aren't double-counted. */ await prisma.gameState. update({ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ data: { state: state as object, updatedAt: now }, where: { discordId }, }). catch((error: unknown) => { // Ignore write-conflict errors (P2034) — rethrow anything else // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 5 -- @preserve */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */ const { code } = error as { code?: string }; if (code !== "P2034") { throw error; } }); } // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ const schemaOutdated = (state.schemaVersion ?? 0) < currentSchemaVersion; const secret = process.env.ANTI_CHEAT_SECRET; const signature = secret === undefined ? undefined : computeHmac(JSON.stringify(state), secret); const inGuild = playerRecord?.inGuild ?? false; return context.json({ currentSchemaVersion, inGuild, loginBonus, loginStreak, offlineEssence, offlineGold, offlineSeconds, schemaOutdated, signature, state, }); } catch (error) { void logger.error( "game_load", error instanceof Error ? error : new Error(String(error)), ); return context.json({ error: "Internal server error" }, 500); } }); gameRouter.post("/save", async(context) => { try { const discordId = context.get("discordId"); const body = await context.req.json(); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Defensive check for malformed requests if (body.state === null || body.state === undefined) { return context.json({ error: "Missing state in request body" }, 400); } // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ if ((body.state.schemaVersion ?? 0) < currentSchemaVersion) { return context.json( { // eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened error: "Save rejected: outdated save. Reset your progress to continue.", }, 409, ); } const secret = process.env.ANTI_CHEAT_SECRET; const [ record, playerRecord ] = await Promise.all([ prisma.gameState.findUnique({ where: { discordId } }), prisma.player.findUnique({ where: { discordId } }), ]); let stateToSave = body.state; if (record) { const rawPreviousState: unknown = record.state; /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ const previousState = rawPreviousState as GameState; // Option D: verify HMAC signature if the secret is configured and client sent one if (secret !== undefined && body.signature !== undefined) { const expectedSig = computeHmac(JSON.stringify(previousState), secret); if (body.signature !== expectedSig) { return context.json( { error: "Save rejected: signature mismatch" }, 400, ); } } // Option A: sanitise the incoming state against the previous to block rollbacks and cap cheats stateToSave = validateAndSanitize(body.state, previousState); } const now = Date.now(); /* * Stamp the authoritative save timestamp into the state blob so that on the * next load the client reads the correct value from state.player.lastSavedAt. */ stateToSave = { ...stateToSave, player: { ...stateToSave.player, lastSavedAt: now }, }; /* * Preserve the Player record's character name so that profile updates are not * overwritten by the next auto-save (profile PUT writes to Player, not the blob). */ stateToSave = { ...stateToSave, player: { ...stateToSave.player, characterName: playerRecord?.characterName ?? stateToSave.player.characterName, }, }; /* * Recompute companion unlocks server-side using DB-authoritative player lifetime stats. * This prevents clients from claiming companions they haven't legitimately unlocked. */ // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 8 -- @preserve */ const companionUnlocks = computeUnlockedCompanionIds({ apotheosisCount: stateToSave.apotheosis?.count ?? 0, lifetimeBossesDefeated: playerRecord?.lifetimeBossesDefeated ?? 0, // eslint-disable-next-line stylistic/max-len -- Long property; splitting would reduce readability lifetimeGoldEarned: (playerRecord?.lifetimeGoldEarned ?? 0) + stateToSave.player.totalGoldEarned, lifetimeQuestsCompleted: playerRecord?.lifetimeQuestsCompleted ?? 0, prestigeCount: stateToSave.prestige.count, transcendenceCount: stateToSave.transcendence?.count ?? 0, }); const clientActiveCompanionId = stateToSave.companions?.activeCompanionId ?? null; const validatedActiveCompanionId = clientActiveCompanionId !== null && companionUnlocks.includes(clientActiveCompanionId) ? clientActiveCompanionId : null; stateToSave = { ...stateToSave, companions: { activeCompanionId: validatedActiveCompanionId, unlockedCompanionIds: companionUnlocks, }, }; const currentUnlocked = parseUnlockedTitles(playerRecord?.unlockedTitles); // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 6 -- @preserve */ const updatedTitles = checkAndUnlockTitles({ createdAt: playerRecord?.createdAt ?? Date.now(), currentUnlocked: currentUnlocked, guildName: playerRecord?.guildName ?? "", state: stateToSave, }); const updatedUnlocked = updatedTitles.length > 0 ? [ ...currentUnlocked, ...updatedTitles ] : undefined; await prisma.player.update({ data: { characterName: stateToSave.player.characterName, lastSavedAt: now, totalClicks: stateToSave.player.totalClicks, totalGoldEarned: stateToSave.player.totalGoldEarned, ...updatedUnlocked ? { unlockedTitles: updatedUnlocked } : {}, }, where: { discordId }, }); await prisma.gameState.upsert({ create: { discordId: discordId, /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */ state: stateToSave as unknown as never, updatedAt: now, }, /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */ update: { state: stateToSave as unknown as never, updatedAt: now }, where: { discordId }, }); const signature = secret === undefined ? undefined : computeHmac(JSON.stringify(stateToSave), secret); return context.json({ savedAt: now, signature: signature }); } catch (error) { void logger.error( "game_save", error instanceof Error ? error : new Error(String(error)), ); return context.json({ error: "Internal server error" }, 500); } }); gameRouter.post("/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( "game_reset", error instanceof Error ? error : new Error(String(error)), ); return context.json({ error: "Internal server error" }, 500); } }); export { gameRouter };