generated from nhcarrigan/template
e02827dbb6
- 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)
1552 lines
57 KiB
TypeScript
1552 lines
57 KiB
TypeScript
/**
|
||
* @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
|
||
* (0–1 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 };
|