Files
elysium/apps/api/src/routes/game.ts
T
hikari e02827dbb6 feat: vampire tick engine, auto systems, and full test suite
- vampire blood production tick with thrall bloodPerSecond + multipliers
- auto-quest and auto-thrall purchase in tick engine
- computeVampireBloodPerSecond helper exposed for ResourceBar display
- ResourceBar now shows blood/s and currency balances for vampire mode
- vampire quests and thralls panels gain auto-toggle buttons
- About page updated with vampire mode how-to-play entries
- vampireEquipmentSets data file added to web
- 100% test coverage across all API routes and services:
  - siring, awakening, vampireBoss, vampireCraft, vampireExplore, vampireUpgrade
  - debug route now covers grant-apotheosis endpoint
  - vampireMaterials excluded from coverage (ID-referenced only, same as goddessMaterials)
2026-04-16 14:01:50 -07:00

1552 lines
57 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 { 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
* (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;
/*
* 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<HonoEnvironment>();
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<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,
// 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 };