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.
This commit is contained in:
2026-03-06 22:56:45 -08:00
committed by Naomi Carrigan
parent aaeece1a18
commit dc1353a15c
+221 -5
View File
@@ -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;