From dc1353a15c35045dd0c24c70aae0419f518e97ac Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 6 Mar 2026 22:56:45 -0800 Subject: [PATCH] fix: strengthen cloud save anti-cheat with resource delta validation Replaces the flat RESOURCE_CAP on gold and essence with a per-save maximum derived from the player's actual income rate. The server now computes max legitimate earnings using the previous (DB-trusted) state's adventurers, upgrades, prestige multipliers, and equipment, then applies a 2x buffer to cover mid-session purchases. Quest rewards are only credited when the quest was active with an expired timer in the previous state (using authoritative game data to prevent reward/duration tampering). Boss rewards are included as a race-condition safety buffer for simultaneous boss-write and save requests. Runestones are capped at the previous value since they are only granted server-side via prestige. --- apps/api/src/routes/game.ts | 226 +++++++++++++++++++++++++++++++++++- 1 file changed, 221 insertions(+), 5 deletions(-) diff --git a/apps/api/src/routes/game.ts b/apps/api/src/routes/game.ts index 066f477..af83fb9 100644 --- a/apps/api/src/routes/game.ts +++ b/apps/api/src/routes/game.ts @@ -4,31 +4,248 @@ import { Hono } from "hono"; import { prisma } from "../db/client.js"; import { DEFAULT_ACHIEVEMENTS } from "../data/achievements.js"; import { DEFAULT_ADVENTURERS } from "../data/adventurers.js"; +import { DEFAULT_BOSSES } from "../data/bosses.js"; import { DEFAULT_EQUIPMENT } from "../data/equipment.js"; +import { DEFAULT_QUESTS } from "../data/quests.js"; import { authMiddleware } from "../middleware/auth.js"; import { getOrResetDailyChallenges } from "../services/dailyChallenges.js"; import { calculateOfflineEarnings } from "../services/offlineProgress.js"; const RESOURCE_CAP = 1e300; +/** Maximum elapsed seconds credited for passive income — mirrors the offline earnings cap. */ +const ELAPSED_CAP_SECONDS = 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 INCOME_BUFFER_MULTIPLIER = 2; + +/** Generous clicks-per-second estimate used to bound click income between saves. */ +const CLICK_BUFFER_CPS = 10; + +/** 60-second grace period when checking whether a quest timer has expired. */ +const QUEST_GRACE_MS = 60_000; + const computeHmac = (data: string, secret: string): string => 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. + */ +const computeMaxPassiveIncome = ( + state: GameState, +): { goldPerSecond: number; essencePerSecond: number } => { + const equippedItems = (state.equipment ?? []).filter((e) => e.equipped); + const equipmentGoldMultiplier = equippedItems.reduce( + (mult, e) => mult * (e.bonus.goldMultiplier ?? 1), + 1, + ); + const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1; + const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1; + + let goldPerSecond = 0; + let essencePerSecond = 0; + + for (const adventurer of state.adventurers) { + if (!adventurer.unlocked || adventurer.count === 0) continue; + + const upgradeMultiplier = state.upgrades + .filter( + (u) => + u.purchased && + (u.target === "global" || + (u.target === "adventurer" && u.adventurerId === adventurer.id)), + ) + .reduce((mult, u) => mult * u.multiplier, 1); + + const prestige = state.prestige.productionMultiplier; + + goldPerSecond += + adventurer.goldPerSecond * + adventurer.count * + upgradeMultiplier * + prestige * + runestonesIncome * + equipmentGoldMultiplier; + + essencePerSecond += + adventurer.essencePerSecond * + adventurer.count * + upgradeMultiplier * + prestige * + runestonesEssence; + } + + return { goldPerSecond, essencePerSecond }; +}; + +/** + * 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 CLICK_BUFFER_CPS as a generous upper bound on clicks per second. + */ +const computeMaxClickGoldPerSecond = (state: GameState): number => { + const clickMultiplier = state.upgrades + .filter((u) => u.purchased && u.target === "click") + .reduce((mult, u) => mult * u.multiplier, 1); + + const equipmentClickMultiplier = (state.equipment ?? []) + .filter((e) => e.equipped && e.bonus.clickMultiplier != null) + .reduce((mult, e) => mult * (e.bonus.clickMultiplier ?? 1), 1); + + const runestonesClick = state.prestige.runestonesClickMultiplier ?? 1; + + const clickPower = + state.baseClickPower * + clickMultiplier * + state.prestige.productionMultiplier * + runestonesClick * + equipmentClickMultiplier; + + return clickPower * CLICK_BUFFER_CPS; +}; + +/** + * 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 QUEST_GRACE_MS), and + * - It is now "completed" in the incoming state. + * + * Reward amounts and durations are taken from DEFAULT_QUESTS (authoritative game data) + * to prevent client-side reward or duration tampering. + */ +const computeQuestRewards = ( + incoming: GameState, + previous: GameState, + now: number, +): { gold: number; essence: number } => { + let gold = 0; + let essence = 0; + + for (const incomingQuest of incoming.quests) { + if (incomingQuest.status !== "completed") continue; + + const prevQuest = previous.quests.find((q) => q.id === incomingQuest.id); + if (!prevQuest || prevQuest.status === "completed") continue; + + if (prevQuest.status !== "active" || prevQuest.startedAt == null) 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 = DEFAULT_QUESTS.find((q) => q.id === incomingQuest.id); + if (!questData) continue; + + if (prevQuest.startedAt + questData.durationSeconds * 1000 > now + QUEST_GRACE_MS) continue; + + for (const reward of questData.rewards) { + if (reward.type === "gold" && reward.amount != null) gold += reward.amount; + if (reward.type === "essence" && reward.amount != null) essence += reward.amount; + } + } + + return { gold, essence }; +}; + +/** + * 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 DEFAULT_BOSSES (authoritative game data) to prevent + * client-side reward tampering. + */ +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 prevBoss = previous.bosses.find((b) => b.id === incomingBoss.id); + if (!prevBoss || prevBoss.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 (prevBoss.status !== "available" && prevBoss.status !== "in_progress") continue; + + const bossData = DEFAULT_BOSSES.find((b) => b.id === incomingBoss.id); + if (!bossData) continue; + + gold += bossData.goldReward; + essence += bossData.essenceReward; + } + + return { gold, essence }; +}; + /** * Validates the incoming state against the previous saved state and returns a * sanitised copy. Protects against: - * - Resources exceeding the cap + * - 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 */ 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. + const rawElapsed = previous.lastTickAt > 0 ? (now - previous.lastTickAt) / 1000 : 30; + const elapsedSeconds = Math.max(0, Math.min(rawElapsed, ELAPSED_CAP_SECONDS)); + + // Per-second income rates from the previous (DB-trusted) state. + const { goldPerSecond, essencePerSecond } = computeMaxPassiveIncome(previous); + const clickGoldPerSecond = computeMaxClickGoldPerSecond(previous); + + // Precise one-time rewards for events that could have occurred this interval. + const questRewards = computeQuestRewards(incoming, previous, now); + 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 maxGoldIncrease = + (goldPerSecond + clickGoldPerSecond) * elapsedSeconds * INCOME_BUFFER_MULTIPLIER + + questRewards.gold + + bossRewards.gold; + + const maxEssenceIncrease = + essencePerSecond * elapsedSeconds * INCOME_BUFFER_MULTIPLIER + + questRewards.essence + + bossRewards.essence; + const resources = { - gold: Math.min(incoming.resources.gold, RESOURCE_CAP), - essence: Math.min(incoming.resources.essence, RESOURCE_CAP), + gold: Math.min( + incoming.resources.gold, + previous.resources.gold + maxGoldIncrease, + RESOURCE_CAP, + ), + essence: Math.min( + incoming.resources.essence, + previous.resources.essence + maxEssenceIncrease, + RESOURCE_CAP, + ), crystals: Math.min(incoming.resources.crystals, RESOURCE_CAP), - runestones: Math.min(incoming.resources.runestones, RESOURCE_CAP), + // 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((b) => { @@ -49,7 +266,6 @@ const validateAndSanitize = (incoming: GameState, previous: GameState): GameStat return q; }); - const now = Date.now(); const achievements = incoming.achievements.map((a) => { const prev = previous.achievements.find((p) => p.id === a.id); if (!prev) return a;