Files
elysium/apps/api/src/routes/game.ts
T
hikari a36c8e72a5
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint, Build & Test (push) Successful in 1m8s
feat: error handling, logging, analytics, OG tags, and sticky sidebar (#44)
## Summary

- Add comprehensive try/catch error handling across all API routes, middleware, and the Hono global error handler, piping every unhandled error to the `@nhcarrigan/logger` service to prevent silent crashes and unhandled Promise rejections
- Add a `logError` utility on the frontend that forwards errors through the overridden `console.error` to the backend telemetry endpoint; apply it to every silent `catch {}` block in the game context, sound, notification, and clipboard utilities, and wrap the React tree in an `ErrorBoundary`
- Add Plausible analytics, Open Graph + Twitter Card meta tags, Tree-Nation widget, and Google Ads to `index.html`
- Make the game sidebar sticky with a `--resource-bar-height` CSS custom property offset so it stays viewport-height without overlapping the resource bar; reset sticky behaviour in the mobile responsive override

## Test plan

- [ ] Lint passes: `pnpm lint`
- [ ] Build passes: `pnpm build`
- [ ] Verify errors thrown in API routes appear in the logger service rather than crashing the process
- [ ] Verify frontend errors appear in the `/api/fe/error` backend log
- [ ] Verify Open Graph tags render correctly when sharing the URL
- [ ] Verify Plausible analytics fires on page load
- [ ] Verify Tree-Nation badge renders in the sidebar
- [ ] Verify sidebar stays fixed while the main content scrolls on desktop
- [ ] Verify mobile layout is unaffected

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #44
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-09 19:54:42 -07:00

1128 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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 { 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
* (01 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;
/*
* 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 };
});
const craftedRecipeIds = incoming.exploration.craftedRecipeIds.filter(
(recipeId) => {
return previousExploration.craftedRecipeIds.includes(recipeId);
},
);
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 };
}
return {
...incoming,
achievements,
bosses,
prestige,
quests,
resources,
...transcendenceSpread,
...apotheosisSpread,
...explorationSpread,
...storySpread,
};
};
const gameRouter = new Hono<HonoEnvironment>();
gameRouter.use("*", authMiddleware);
gameRouter.get("/load", async(context) => {
try {
const discordId = context.get("discordId");
const [ record, playerRecord ] = await Promise.all([
prisma.gameState.findUnique({ where: { discordId } }),
prisma.player.findUnique({ where: { discordId } }),
]);
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,
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;
}
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);
return context.json({
currentSchemaVersion,
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<SaveRequest>();
// 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,
lifetimeGoldEarned: playerRecord?.lifetimeGoldEarned ?? 0,
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 };