generated from nhcarrigan/template
feat: error handling, logging, analytics, OG tags, and sticky sidebar (#44)
## Summary
- Add comprehensive try/catch error handling across all API routes, middleware, and the Hono global error handler, piping every unhandled error to the `@nhcarrigan/logger` service to prevent silent crashes and unhandled Promise rejections
- Add a `logError` utility on the frontend that forwards errors through the overridden `console.error` to the backend telemetry endpoint; apply it to every silent `catch {}` block in the game context, sound, notification, and clipboard utilities, and wrap the React tree in an `ErrorBoundary`
- Add Plausible analytics, Open Graph + Twitter Card meta tags, Tree-Nation widget, and Google Ads to `index.html`
- Make the game sidebar sticky with a `--resource-bar-height` CSS custom property offset so it stays viewport-height without overlapping the resource bar; reset sticky behaviour in the mobile responsive override
## Test plan
- [ ] Lint passes: `pnpm lint`
- [ ] Build passes: `pnpm build`
- [ ] Verify errors thrown in API routes appear in the logger service rather than crashing the process
- [ ] Verify frontend errors appear in the `/api/fe/error` backend log
- [ ] Verify Open Graph tags render correctly when sharing the URL
- [ ] Verify Plausible analytics fires on page load
- [ ] Verify Tree-Nation badge renders in the sidebar
- [ ] Verify sidebar stays fixed while the main content scrolls on desktop
- [ ] Verify mobile layout is unaffected
✨ This issue was created with help from Hikari~ 🌸
Reviewed-on: #44
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #44.
This commit is contained in:
+392
-356
@@ -27,6 +27,7 @@ import { currentSchemaVersion } from "../data/schemaVersion.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import { calculateOfflineEarnings } from "../services/offlineProgress.js";
|
||||
import {
|
||||
checkAndUnlockTitles,
|
||||
@@ -681,18 +682,387 @@ const gameRouter = new Hono<HonoEnvironment>();
|
||||
gameRouter.use("*", authMiddleware);
|
||||
|
||||
gameRouter.get("/load", async(context) => {
|
||||
const discordId = context.get("discordId");
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const [ record, playerRecord ] = await Promise.all([
|
||||
prisma.gameState.findUnique({ where: { discordId } }),
|
||||
prisma.player.findUnique({ where: { discordId } }),
|
||||
]);
|
||||
const [ record, playerRecord ] = await Promise.all([
|
||||
prisma.gameState.findUnique({ where: { discordId } }),
|
||||
prisma.player.findUnique({ where: { discordId } }),
|
||||
]);
|
||||
|
||||
if (!record) {
|
||||
// No save found — create a fresh state (handles nuked DB or first-time load race)
|
||||
if (!record) {
|
||||
// No save found — create a fresh state (handles nuked DB or first-time load race)
|
||||
if (!playerRecord) {
|
||||
return context.json({ error: "No player found" }, 404);
|
||||
}
|
||||
const freshState = initialGameState(
|
||||
{
|
||||
avatar: playerRecord.avatar,
|
||||
characterName: playerRecord.characterName,
|
||||
createdAt: playerRecord.createdAt,
|
||||
discordId: playerRecord.discordId,
|
||||
discriminator: playerRecord.discriminator,
|
||||
lastSavedAt: Date.now(),
|
||||
// eslint-disable-next-line stylistic/max-len -- Long property names exceed limit after try-block indent
|
||||
lifetimeAchievementsUnlocked: playerRecord.lifetimeAchievementsUnlocked,
|
||||
// eslint-disable-next-line stylistic/max-len -- Long property names exceed limit after try-block indent
|
||||
lifetimeAdventurersRecruited: playerRecord.lifetimeAdventurersRecruited,
|
||||
lifetimeBossesDefeated: playerRecord.lifetimeBossesDefeated,
|
||||
lifetimeClicks: playerRecord.lifetimeClicks,
|
||||
lifetimeGoldEarned: playerRecord.lifetimeGoldEarned,
|
||||
lifetimeQuestsCompleted: playerRecord.lifetimeQuestsCompleted,
|
||||
totalClicks: 0,
|
||||
totalGoldEarned: 0,
|
||||
username: playerRecord.username,
|
||||
},
|
||||
playerRecord.characterName,
|
||||
);
|
||||
const createdAt = Date.now();
|
||||
await prisma.gameState.create({
|
||||
data: {
|
||||
discordId: discordId,
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
state: freshState as object,
|
||||
updatedAt: createdAt,
|
||||
},
|
||||
});
|
||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||
|
||||
// Sign the state for anti-cheat verification
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
const signature = secret === undefined
|
||||
? undefined
|
||||
: computeHmac(JSON.stringify(freshState), secret);
|
||||
return context.json({
|
||||
currentSchemaVersion: currentSchemaVersion,
|
||||
loginBonus: null,
|
||||
loginStreak: playerRecord.loginStreak,
|
||||
offlineEssence: 0,
|
||||
offlineGold: 0,
|
||||
offlineSeconds: 0,
|
||||
schemaOutdated: false,
|
||||
signature: signature,
|
||||
state: freshState,
|
||||
});
|
||||
}
|
||||
|
||||
const rawState: unknown = record.state;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||
const state = rawState as GameState;
|
||||
|
||||
/*
|
||||
* Always sync character name from the Player record — the profile update route
|
||||
* writes to Player.characterName directly, bypassing the game state blob.
|
||||
*/
|
||||
if (playerRecord !== null) {
|
||||
state.player.characterName = playerRecord.characterName;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const { offlineGold, offlineEssence, offlineSeconds }
|
||||
= calculateOfflineEarnings(state, now);
|
||||
|
||||
if (offlineGold > 0) {
|
||||
state.resources.gold = state.resources.gold + offlineGold;
|
||||
state.player.totalGoldEarned = state.player.totalGoldEarned + offlineGold;
|
||||
}
|
||||
|
||||
if (offlineEssence > 0) {
|
||||
state.resources.essence = state.resources.essence + offlineEssence;
|
||||
}
|
||||
|
||||
// Generate or reset daily challenges if a new day has begun
|
||||
state.dailyChallenges = getOrResetDailyChallenges(state);
|
||||
|
||||
// Daily login bonus — award once per calendar day (UTC)
|
||||
const todayUTC = new Date().toISOString().
|
||||
slice(0, 10);
|
||||
const yesterdayUTC = new Date(now - 86_400_000).toISOString().
|
||||
slice(0, 10);
|
||||
let loginBonus: LoginBonusResult | null = null;
|
||||
|
||||
// Default loginStreak to 1 for brand-new accounts
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
let loginStreak = playerRecord?.loginStreak ?? 1;
|
||||
|
||||
if (playerRecord && playerRecord.lastLoginDate !== todayUTC) {
|
||||
const previousStreak = playerRecord.loginStreak;
|
||||
const updatedStreak
|
||||
= playerRecord.lastLoginDate === yesterdayUTC
|
||||
? previousStreak + 1
|
||||
: 1;
|
||||
const dayIndex = (updatedStreak - 1) % 7;
|
||||
const weekMultiplier = Math.floor((updatedStreak - 1) / 7) + 1;
|
||||
const reward = dailyRewards[dayIndex];
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 2 -- @preserve */
|
||||
const goldEarned = (reward?.goldBase ?? 500) * weekMultiplier;
|
||||
const crystalsEarned = (reward?.crystals ?? 0) * weekMultiplier;
|
||||
|
||||
state.resources.gold = Math.min(
|
||||
state.resources.gold + goldEarned,
|
||||
resourceCap,
|
||||
);
|
||||
state.player.totalGoldEarned = state.player.totalGoldEarned + goldEarned;
|
||||
state.resources.crystals = Math.min(
|
||||
state.resources.crystals + crystalsEarned,
|
||||
resourceCap,
|
||||
);
|
||||
|
||||
loginStreak = updatedStreak;
|
||||
loginBonus = {
|
||||
crystalsEarned: crystalsEarned,
|
||||
day: dayIndex + 1,
|
||||
goldEarned: goldEarned,
|
||||
streak: updatedStreak,
|
||||
weekMultiplier: weekMultiplier,
|
||||
};
|
||||
|
||||
await prisma.player.
|
||||
update({
|
||||
data: { lastLoginDate: todayUTC, loginStreak: updatedStreak },
|
||||
where: { discordId },
|
||||
}).
|
||||
catch((error: unknown) => {
|
||||
// Ignore write-conflict errors (P2034) — rethrow anything else
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 5 -- @preserve */
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */
|
||||
const { code } = error as { code?: string };
|
||||
if (code !== "P2034") {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
state.lastTickAt = now;
|
||||
|
||||
if (offlineGold > 0 || offlineEssence > 0 || loginBonus !== null) {
|
||||
// Persist updated state immediately so offline/login rewards aren't double-counted.
|
||||
/*
|
||||
* Swallow write conflicts (P2034): offline earnings and login bonus are applied
|
||||
* server-side and must be persisted immediately so they aren't double-counted.
|
||||
*/
|
||||
await prisma.gameState.
|
||||
update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: state as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
}).
|
||||
catch((error: unknown) => {
|
||||
// Ignore write-conflict errors (P2034) — rethrow anything else
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 5 -- @preserve */
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */
|
||||
const { code } = error as { code?: string };
|
||||
if (code !== "P2034") {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const schemaOutdated = (state.schemaVersion ?? 0) < currentSchemaVersion;
|
||||
|
||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||
const signature = secret === undefined
|
||||
? undefined
|
||||
: computeHmac(JSON.stringify(state), secret);
|
||||
return context.json({
|
||||
currentSchemaVersion,
|
||||
loginBonus,
|
||||
loginStreak,
|
||||
offlineEssence,
|
||||
offlineGold,
|
||||
offlineSeconds,
|
||||
schemaOutdated,
|
||||
signature,
|
||||
state,
|
||||
});
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"game_load",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
gameRouter.post("/save", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<SaveRequest>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Defensive check for malformed requests
|
||||
if (body.state === null || body.state === undefined) {
|
||||
return context.json({ error: "Missing state in request body" }, 400);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
if ((body.state.schemaVersion ?? 0) < currentSchemaVersion) {
|
||||
return context.json(
|
||||
{
|
||||
// eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened
|
||||
error: "Save rejected: outdated save. Reset your progress to continue.",
|
||||
},
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||
const [ record, playerRecord ] = await Promise.all([
|
||||
prisma.gameState.findUnique({ where: { discordId } }),
|
||||
prisma.player.findUnique({ where: { discordId } }),
|
||||
]);
|
||||
|
||||
let stateToSave = body.state;
|
||||
|
||||
if (record) {
|
||||
const rawPreviousState: unknown = record.state;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||
const previousState = rawPreviousState as GameState;
|
||||
|
||||
// Option D: verify HMAC signature if the secret is configured and client sent one
|
||||
if (secret !== undefined && body.signature !== undefined) {
|
||||
const expectedSig = computeHmac(JSON.stringify(previousState), secret);
|
||||
if (body.signature !== expectedSig) {
|
||||
return context.json(
|
||||
{ error: "Save rejected: signature mismatch" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Option A: sanitise the incoming state against the previous to block rollbacks and cap cheats
|
||||
stateToSave = validateAndSanitize(body.state, previousState);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
/*
|
||||
* Stamp the authoritative save timestamp into the state blob so that on the
|
||||
* next load the client reads the correct value from state.player.lastSavedAt.
|
||||
*/
|
||||
stateToSave = {
|
||||
...stateToSave,
|
||||
player: { ...stateToSave.player, lastSavedAt: now },
|
||||
};
|
||||
|
||||
/*
|
||||
* Preserve the Player record's character name so that profile updates are not
|
||||
* overwritten by the next auto-save (profile PUT writes to Player, not the blob).
|
||||
*/
|
||||
stateToSave = {
|
||||
...stateToSave,
|
||||
player: {
|
||||
...stateToSave.player,
|
||||
characterName:
|
||||
playerRecord?.characterName ?? stateToSave.player.characterName,
|
||||
},
|
||||
};
|
||||
|
||||
/*
|
||||
* Recompute companion unlocks server-side using DB-authoritative player lifetime stats.
|
||||
* This prevents clients from claiming companions they haven't legitimately unlocked.
|
||||
*/
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 8 -- @preserve */
|
||||
const companionUnlocks = computeUnlockedCompanionIds({
|
||||
apotheosisCount: stateToSave.apotheosis?.count ?? 0,
|
||||
lifetimeBossesDefeated: playerRecord?.lifetimeBossesDefeated ?? 0,
|
||||
lifetimeGoldEarned: playerRecord?.lifetimeGoldEarned ?? 0,
|
||||
lifetimeQuestsCompleted: playerRecord?.lifetimeQuestsCompleted ?? 0,
|
||||
prestigeCount: stateToSave.prestige.count,
|
||||
transcendenceCount: stateToSave.transcendence?.count ?? 0,
|
||||
});
|
||||
const clientActiveCompanionId
|
||||
= stateToSave.companions?.activeCompanionId ?? null;
|
||||
const validatedActiveCompanionId
|
||||
= clientActiveCompanionId !== null
|
||||
&& companionUnlocks.includes(clientActiveCompanionId)
|
||||
? clientActiveCompanionId
|
||||
: null;
|
||||
stateToSave = {
|
||||
...stateToSave,
|
||||
companions: {
|
||||
activeCompanionId: validatedActiveCompanionId,
|
||||
unlockedCompanionIds: companionUnlocks,
|
||||
},
|
||||
};
|
||||
|
||||
const currentUnlocked = parseUnlockedTitles(playerRecord?.unlockedTitles);
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 6 -- @preserve */
|
||||
const updatedTitles = checkAndUnlockTitles({
|
||||
createdAt: playerRecord?.createdAt ?? Date.now(),
|
||||
currentUnlocked: currentUnlocked,
|
||||
guildName: playerRecord?.guildName ?? "",
|
||||
state: stateToSave,
|
||||
});
|
||||
const updatedUnlocked
|
||||
= updatedTitles.length > 0
|
||||
? [ ...currentUnlocked, ...updatedTitles ]
|
||||
: undefined;
|
||||
|
||||
await prisma.player.update({
|
||||
data: {
|
||||
characterName: stateToSave.player.characterName,
|
||||
lastSavedAt: now,
|
||||
totalClicks: stateToSave.player.totalClicks,
|
||||
totalGoldEarned: stateToSave.player.totalGoldEarned,
|
||||
...updatedUnlocked
|
||||
? { unlockedTitles: updatedUnlocked }
|
||||
: {},
|
||||
},
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
await prisma.gameState.upsert({
|
||||
create: {
|
||||
discordId: discordId,
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */
|
||||
state: stateToSave as unknown as never,
|
||||
updatedAt: now,
|
||||
},
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */
|
||||
update: { state: stateToSave as unknown as never, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
const signature = secret === undefined
|
||||
? undefined
|
||||
: computeHmac(JSON.stringify(stateToSave), secret);
|
||||
return context.json({ savedAt: now, signature: signature });
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"game_save",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
gameRouter.post("/reset", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const playerRecord = await prisma.player.findUnique({
|
||||
where: { discordId },
|
||||
});
|
||||
if (!playerRecord) {
|
||||
return context.json({ error: "No player found" }, 404);
|
||||
}
|
||||
|
||||
const freshState = initialGameState(
|
||||
{
|
||||
avatar: playerRecord.avatar,
|
||||
@@ -713,23 +1083,25 @@ gameRouter.get("/load", async(context) => {
|
||||
},
|
||||
playerRecord.characterName,
|
||||
);
|
||||
|
||||
const createdAt = Date.now();
|
||||
await prisma.gameState.create({
|
||||
data: {
|
||||
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;
|
||||
|
||||
// Sign the state for anti-cheat verification
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||
const signature = secret === undefined
|
||||
? undefined
|
||||
: computeHmac(JSON.stringify(freshState), secret);
|
||||
|
||||
return context.json({
|
||||
currentSchemaVersion: currentSchemaVersion,
|
||||
loginBonus: null,
|
||||
@@ -741,351 +1113,15 @@ gameRouter.get("/load", async(context) => {
|
||||
signature: signature,
|
||||
state: freshState,
|
||||
});
|
||||
}
|
||||
|
||||
const rawState: unknown = record.state;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||
const state = rawState as GameState;
|
||||
|
||||
/*
|
||||
* Always sync character name from the Player record — the profile update route
|
||||
* writes to Player.characterName directly, bypassing the game state blob.
|
||||
*/
|
||||
if (playerRecord !== null) {
|
||||
state.player.characterName = playerRecord.characterName;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const { offlineGold, offlineEssence, offlineSeconds }
|
||||
= calculateOfflineEarnings(state, now);
|
||||
|
||||
if (offlineGold > 0) {
|
||||
state.resources.gold = state.resources.gold + offlineGold;
|
||||
state.player.totalGoldEarned = state.player.totalGoldEarned + offlineGold;
|
||||
}
|
||||
|
||||
if (offlineEssence > 0) {
|
||||
state.resources.essence = state.resources.essence + offlineEssence;
|
||||
}
|
||||
|
||||
// Generate or reset daily challenges if a new day has begun
|
||||
state.dailyChallenges = getOrResetDailyChallenges(state);
|
||||
|
||||
// Daily login bonus — award once per calendar day (UTC)
|
||||
const todayUTC = new Date().toISOString().
|
||||
slice(0, 10);
|
||||
const yesterdayUTC = new Date(now - 86_400_000).toISOString().
|
||||
slice(0, 10);
|
||||
let loginBonus: LoginBonusResult | null = null;
|
||||
|
||||
// Default loginStreak to 1 for brand-new accounts
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
let loginStreak = playerRecord?.loginStreak ?? 1;
|
||||
|
||||
if (playerRecord && playerRecord.lastLoginDate !== todayUTC) {
|
||||
const previousStreak = playerRecord.loginStreak;
|
||||
const updatedStreak
|
||||
= playerRecord.lastLoginDate === yesterdayUTC
|
||||
? previousStreak + 1
|
||||
: 1;
|
||||
const dayIndex = (updatedStreak - 1) % 7;
|
||||
const weekMultiplier = Math.floor((updatedStreak - 1) / 7) + 1;
|
||||
const reward = dailyRewards[dayIndex];
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 2 -- @preserve */
|
||||
const goldEarned = (reward?.goldBase ?? 500) * weekMultiplier;
|
||||
const crystalsEarned = (reward?.crystals ?? 0) * weekMultiplier;
|
||||
|
||||
state.resources.gold = Math.min(
|
||||
state.resources.gold + goldEarned,
|
||||
resourceCap,
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"game_reset",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
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;
|
||||
}
|
||||
});
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
|
||||
state.lastTickAt = now;
|
||||
|
||||
if (offlineGold > 0 || offlineEssence > 0 || loginBonus !== null) {
|
||||
// Persist updated state immediately so offline/login rewards aren't double-counted.
|
||||
/*
|
||||
* Swallow write conflicts (P2034): offline earnings and login bonus are applied
|
||||
* server-side and must be persisted immediately so they aren't double-counted.
|
||||
*/
|
||||
await prisma.gameState.
|
||||
update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: state as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
}).
|
||||
catch((error: unknown) => {
|
||||
// Ignore write-conflict errors (P2034) — rethrow anything else
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 5 -- @preserve */
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */
|
||||
const { code } = error as { code?: string };
|
||||
if (code !== "P2034") {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const schemaOutdated = (state.schemaVersion ?? 0) < currentSchemaVersion;
|
||||
|
||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||
const signature = secret === undefined
|
||||
? undefined
|
||||
: computeHmac(JSON.stringify(state), secret);
|
||||
return context.json({
|
||||
currentSchemaVersion,
|
||||
loginBonus,
|
||||
loginStreak,
|
||||
offlineEssence,
|
||||
offlineGold,
|
||||
offlineSeconds,
|
||||
schemaOutdated,
|
||||
signature,
|
||||
state,
|
||||
});
|
||||
});
|
||||
|
||||
gameRouter.post("/save", async(context) => {
|
||||
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(
|
||||
{
|
||||
error: "Save rejected: outdated save. Reset your progress to continue.",
|
||||
},
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||
const [ record, playerRecord ] = await Promise.all([
|
||||
prisma.gameState.findUnique({ where: { discordId } }),
|
||||
prisma.player.findUnique({ where: { discordId } }),
|
||||
]);
|
||||
|
||||
let stateToSave = body.state;
|
||||
|
||||
if (record) {
|
||||
const rawPreviousState: unknown = record.state;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||
const previousState = rawPreviousState as GameState;
|
||||
|
||||
// Option D: verify HMAC signature if the secret is configured and client sent one
|
||||
if (secret !== undefined && body.signature !== undefined) {
|
||||
const expectedSig = computeHmac(JSON.stringify(previousState), secret);
|
||||
if (body.signature !== expectedSig) {
|
||||
return context.json(
|
||||
{ error: "Save rejected: signature mismatch" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Option A: sanitise the incoming state against the previous to block rollbacks and cap cheats
|
||||
stateToSave = validateAndSanitize(body.state, previousState);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
/*
|
||||
* Stamp the authoritative save timestamp into the state blob so that on the
|
||||
* next load the client reads the correct value from state.player.lastSavedAt.
|
||||
*/
|
||||
stateToSave = {
|
||||
...stateToSave,
|
||||
player: { ...stateToSave.player, lastSavedAt: now },
|
||||
};
|
||||
|
||||
/*
|
||||
* Preserve the Player record's character name so that profile updates are not
|
||||
* overwritten by the next auto-save (profile PUT writes to Player, not the blob).
|
||||
*/
|
||||
stateToSave = {
|
||||
...stateToSave,
|
||||
player: {
|
||||
...stateToSave.player,
|
||||
characterName:
|
||||
playerRecord?.characterName ?? stateToSave.player.characterName,
|
||||
},
|
||||
};
|
||||
|
||||
/*
|
||||
* Recompute companion unlocks server-side using DB-authoritative player lifetime stats.
|
||||
* This prevents clients from claiming companions they haven't legitimately unlocked.
|
||||
*/
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 8 -- @preserve */
|
||||
const companionUnlocks = computeUnlockedCompanionIds({
|
||||
apotheosisCount: stateToSave.apotheosis?.count ?? 0,
|
||||
lifetimeBossesDefeated: playerRecord?.lifetimeBossesDefeated ?? 0,
|
||||
lifetimeGoldEarned: playerRecord?.lifetimeGoldEarned ?? 0,
|
||||
lifetimeQuestsCompleted: playerRecord?.lifetimeQuestsCompleted ?? 0,
|
||||
prestigeCount: stateToSave.prestige.count,
|
||||
transcendenceCount: stateToSave.transcendence?.count ?? 0,
|
||||
});
|
||||
const clientActiveCompanionId
|
||||
= stateToSave.companions?.activeCompanionId ?? null;
|
||||
const validatedActiveCompanionId
|
||||
= clientActiveCompanionId !== null
|
||||
&& companionUnlocks.includes(clientActiveCompanionId)
|
||||
? clientActiveCompanionId
|
||||
: null;
|
||||
stateToSave = {
|
||||
...stateToSave,
|
||||
companions: {
|
||||
activeCompanionId: validatedActiveCompanionId,
|
||||
unlockedCompanionIds: companionUnlocks,
|
||||
},
|
||||
};
|
||||
|
||||
const currentUnlocked = parseUnlockedTitles(playerRecord?.unlockedTitles);
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 6 -- @preserve */
|
||||
const updatedTitles = checkAndUnlockTitles({
|
||||
createdAt: playerRecord?.createdAt ?? Date.now(),
|
||||
currentUnlocked: currentUnlocked,
|
||||
guildName: playerRecord?.guildName ?? "",
|
||||
state: stateToSave,
|
||||
});
|
||||
const updatedUnlocked
|
||||
= updatedTitles.length > 0
|
||||
? [ ...currentUnlocked, ...updatedTitles ]
|
||||
: undefined;
|
||||
|
||||
await prisma.player.update({
|
||||
data: {
|
||||
characterName: stateToSave.player.characterName,
|
||||
lastSavedAt: now,
|
||||
totalClicks: stateToSave.player.totalClicks,
|
||||
totalGoldEarned: stateToSave.player.totalGoldEarned,
|
||||
...updatedUnlocked
|
||||
? { unlockedTitles: updatedUnlocked }
|
||||
: {},
|
||||
},
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
await prisma.gameState.upsert({
|
||||
create: {
|
||||
discordId: discordId,
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */
|
||||
state: stateToSave as unknown as never,
|
||||
updatedAt: now,
|
||||
},
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */
|
||||
update: { state: stateToSave as unknown as never, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
const signature = secret === undefined
|
||||
? undefined
|
||||
: computeHmac(JSON.stringify(stateToSave), secret);
|
||||
return context.json({ savedAt: now, signature: signature });
|
||||
});
|
||||
|
||||
gameRouter.post("/reset", async(context) => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
export { gameRouter };
|
||||
|
||||
Reference in New Issue
Block a user