generated from nhcarrigan/template
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:
+221
-5
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user