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:
@@ -15,6 +15,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@elysium/types": "workspace:*",
|
"@elysium/types": "workspace:*",
|
||||||
"@hono/node-server": "1.13.7",
|
"@hono/node-server": "1.13.7",
|
||||||
|
"@nhcarrigan/logger": "1.1.1",
|
||||||
"@prisma/client": "6.5.0",
|
"@prisma/client": "6.5.0",
|
||||||
"hono": "4.7.4",
|
"hono": "4.7.4",
|
||||||
"prisma": "6.5.0"
|
"prisma": "6.5.0"
|
||||||
|
|||||||
+2
-1
@@ -9,4 +9,5 @@ CORS_ORIGIN="op://Environment Variables - Naomi/Elysium/origin"
|
|||||||
DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook"
|
DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook"
|
||||||
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token"
|
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token"
|
||||||
DISCORD_GUILD_ID="op://Environment Variables - Naomi/Elysium/discord guild id"
|
DISCORD_GUILD_ID="op://Environment Variables - Naomi/Elysium/discord guild id"
|
||||||
DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id"
|
DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id"
|
||||||
|
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
|
||||||
+27
-5
@@ -7,22 +7,24 @@
|
|||||||
import { serve } from "@hono/node-server";
|
import { serve } from "@hono/node-server";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { logger } from "hono/logger";
|
import { logger as honoLogger } from "hono/logger";
|
||||||
import { aboutRouter } from "./routes/about.js";
|
import { aboutRouter } from "./routes/about.js";
|
||||||
import { apotheosisRouter } from "./routes/apotheosis.js";
|
import { apotheosisRouter } from "./routes/apotheosis.js";
|
||||||
import { authRouter } from "./routes/auth.js";
|
import { authRouter } from "./routes/auth.js";
|
||||||
import { bossRouter } from "./routes/boss.js";
|
import { bossRouter } from "./routes/boss.js";
|
||||||
import { craftRouter } from "./routes/craft.js";
|
import { craftRouter } from "./routes/craft.js";
|
||||||
import { exploreRouter } from "./routes/explore.js";
|
import { exploreRouter } from "./routes/explore.js";
|
||||||
|
import { frontendRouter } from "./routes/frontend.js";
|
||||||
import { gameRouter } from "./routes/game.js";
|
import { gameRouter } from "./routes/game.js";
|
||||||
import { leaderboardRouter } from "./routes/leaderboards.js";
|
import { leaderboardRouter } from "./routes/leaderboards.js";
|
||||||
import { prestigeRouter } from "./routes/prestige.js";
|
import { prestigeRouter } from "./routes/prestige.js";
|
||||||
import { profileRouter } from "./routes/profile.js";
|
import { profileRouter } from "./routes/profile.js";
|
||||||
import { transcendenceRouter } from "./routes/transcendence.js";
|
import { transcendenceRouter } from "./routes/transcendence.js";
|
||||||
|
import { logger } from "./services/logger.js";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
app.use("*", logger());
|
app.use("*", honoLogger());
|
||||||
app.use(
|
app.use(
|
||||||
"*",
|
"*",
|
||||||
cors({
|
cors({
|
||||||
@@ -33,6 +35,7 @@ app.use(
|
|||||||
);
|
);
|
||||||
|
|
||||||
app.route("/about", aboutRouter);
|
app.route("/about", aboutRouter);
|
||||||
|
app.route("/fe", frontendRouter);
|
||||||
app.route("/auth", authRouter);
|
app.route("/auth", authRouter);
|
||||||
app.route("/game", gameRouter);
|
app.route("/game", gameRouter);
|
||||||
app.route("/boss", bossRouter);
|
app.route("/boss", bossRouter);
|
||||||
@@ -48,8 +51,27 @@ app.get("/health", (context) => {
|
|||||||
return context.json({ status: "ok" });
|
return context.json({ status: "ok" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.onError((error, context) => {
|
||||||
|
void logger.error(
|
||||||
|
"hono_unhandled_error",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
});
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 3001);
|
const port = Number(process.env.PORT ?? 3001);
|
||||||
|
|
||||||
serve({ fetch: app.fetch, port: port }, () => {
|
try {
|
||||||
process.stdout.write(`Elysium API running on port ${String(port)}\n`);
|
serve({ fetch: app.fetch, port: port }, () => {
|
||||||
});
|
process.stdout.write(`Elysium API running on port ${String(port)}\n`);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"server_startup",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { verifyToken } from "../services/jwt.js";
|
import { verifyToken } from "../services/jwt.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
import type { MiddlewareHandler } from "hono";
|
import type { MiddlewareHandler } from "hono";
|
||||||
|
|
||||||
@@ -33,7 +34,13 @@ export const authMiddleware: MiddlewareHandler<HonoEnvironment> = async(
|
|||||||
try {
|
try {
|
||||||
const payload = verifyToken(token);
|
const payload = verifyToken(token);
|
||||||
context.set("discordId", payload.discordId);
|
context.set("discordId", payload.discordId);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"auth_middleware",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
return context.json({ error: "Invalid or expired token" }, 401);
|
return context.json({ error: "Invalid or expired token" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
/* eslint-disable stylistic/max-len -- URL cannot be shortened */
|
/* eslint-disable stylistic/max-len -- URL cannot be shortened */
|
||||||
/* eslint-disable require-atomic-updates -- Simple cache; race condition is acceptable */
|
/* eslint-disable require-atomic-updates -- Simple cache; race condition is acceptable */
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { AboutResponse, GiteaRelease } from "@elysium/types";
|
import type { AboutResponse, GiteaRelease } from "@elysium/types";
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
@@ -46,12 +47,24 @@ const fetchReleases = async(): Promise<Array<GiteaRelease>> => {
|
|||||||
const aboutRouter = new Hono();
|
const aboutRouter = new Hono();
|
||||||
|
|
||||||
aboutRouter.get("/", async(context) => {
|
aboutRouter.get("/", async(context) => {
|
||||||
const releases = await fetchReleases();
|
try {
|
||||||
const body: AboutResponse = {
|
const releases = await fetchReleases();
|
||||||
apiVersion,
|
const body: AboutResponse = {
|
||||||
releases,
|
apiVersion,
|
||||||
};
|
releases,
|
||||||
return context.json(body);
|
};
|
||||||
|
return context.json(body);
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 9 -- @preserve */
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"about",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { aboutRouter };
|
export { aboutRouter };
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
/* eslint-disable max-lines-per-function -- Route handler requires many steps */
|
/* eslint-disable max-lines-per-function -- Route handler requires many steps */
|
||||||
|
/* eslint-disable max-statements -- Route handler requires many statements */
|
||||||
|
|
||||||
/* eslint-disable stylistic/max-len -- Description string cannot be shortened */
|
/* eslint-disable stylistic/max-len -- Description string cannot be shortened */
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
@@ -13,6 +15,7 @@ import {
|
|||||||
buildPostApotheosisState,
|
buildPostApotheosisState,
|
||||||
isEligibleForApotheosis,
|
isEligibleForApotheosis,
|
||||||
} from "../services/apotheosis.js";
|
} from "../services/apotheosis.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import {
|
import {
|
||||||
grantApotheosisRole,
|
grantApotheosisRole,
|
||||||
postMilestoneWebhook,
|
postMilestoneWebhook,
|
||||||
@@ -25,94 +28,106 @@ const apotheosisRouter = new Hono<HonoEnvironment>();
|
|||||||
apotheosisRouter.use("*", authMiddleware);
|
apotheosisRouter.use("*", authMiddleware);
|
||||||
|
|
||||||
apotheosisRouter.post("/", async(context) => {
|
apotheosisRouter.post("/", async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return context.json({ error: "No save found" }, 404);
|
return context.json({ error: "No save found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawState: unknown = record.state;
|
const rawState: unknown = record.state;
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||||
const state = rawState as GameState;
|
const state = rawState as GameState;
|
||||||
|
|
||||||
if (!isEligibleForApotheosis(state)) {
|
if (!isEligibleForApotheosis(state)) {
|
||||||
return context.json(
|
return context.json(
|
||||||
{
|
{
|
||||||
error:
|
error:
|
||||||
"Not eligible for Apotheosis — purchase all Transcendence upgrades first",
|
"Not eligible for Apotheosis — purchase all Transcendence upgrades first",
|
||||||
},
|
},
|
||||||
400,
|
400,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture current-run stats before the nuclear reset
|
// Capture current-run stats before the nuclear reset
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 9 -- @preserve */
|
|
||||||
const runBossesDefeated = state.bosses.filter((b) => {
|
|
||||||
return b.status === "defeated";
|
|
||||||
}).length;
|
|
||||||
const runQuestsCompleted = state.quests.filter((q) => {
|
|
||||||
return q.status === "completed";
|
|
||||||
}).length;
|
|
||||||
const runAdventurersRecruited = state.adventurers.reduce((sum, a) => {
|
|
||||||
return sum + a.count;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 3 -- @preserve */
|
|
||||||
const runAchievementsUnlocked = state.achievements.filter((a) => {
|
|
||||||
return a.unlockedAt !== null;
|
|
||||||
}).length;
|
|
||||||
|
|
||||||
const { updatedState, updatedApotheosisData } = buildPostApotheosisState(
|
|
||||||
state,
|
|
||||||
state.player.characterName,
|
|
||||||
);
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
await prisma.gameState.update({
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
|
||||||
data: { state: updatedState as object, updatedAt: now },
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.player.update({
|
|
||||||
data: {
|
|
||||||
characterName: state.player.characterName,
|
|
||||||
|
|
||||||
lastSavedAt: now,
|
|
||||||
|
|
||||||
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
|
|
||||||
|
|
||||||
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
|
|
||||||
|
|
||||||
lifetimeBossesDefeated: { increment: runBossesDefeated },
|
|
||||||
|
|
||||||
lifetimeClicks: { increment: state.player.totalClicks },
|
|
||||||
|
|
||||||
// Accumulate into lifetime totals
|
|
||||||
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
|
|
||||||
|
|
||||||
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
|
|
||||||
|
|
||||||
totalClicks: 0,
|
|
||||||
// Reset current-run counters
|
|
||||||
totalGoldEarned: 0,
|
|
||||||
},
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
void grantApotheosisRole(discordId);
|
|
||||||
void postMilestoneWebhook(discordId, "apotheosis", {
|
|
||||||
apotheosis: updatedApotheosisData.count,
|
|
||||||
prestige: updatedState.prestige.count,
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next 9 -- @preserve */
|
||||||
transcendence: updatedState.transcendence?.count ?? 0,
|
const runBossesDefeated = state.bosses.filter((b) => {
|
||||||
});
|
return b.status === "defeated";
|
||||||
|
}).length;
|
||||||
|
const runQuestsCompleted = state.quests.filter((q) => {
|
||||||
|
return q.status === "completed";
|
||||||
|
}).length;
|
||||||
|
const runAdventurersRecruited = state.adventurers.reduce((sum, a) => {
|
||||||
|
return sum + a.count;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
return context.json({ apotheosisCount: updatedApotheosisData.count });
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 3 -- @preserve */
|
||||||
|
const runAchievementsUnlocked = state.achievements.filter((a) => {
|
||||||
|
return a.unlockedAt !== null;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
const { updatedState, updatedApotheosisData } = buildPostApotheosisState(
|
||||||
|
state,
|
||||||
|
state.player.characterName,
|
||||||
|
);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: updatedState as object, updatedAt: now },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.player.update({
|
||||||
|
data: {
|
||||||
|
characterName: state.player.characterName,
|
||||||
|
|
||||||
|
lastSavedAt: now,
|
||||||
|
|
||||||
|
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
|
||||||
|
|
||||||
|
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
|
||||||
|
|
||||||
|
lifetimeBossesDefeated: { increment: runBossesDefeated },
|
||||||
|
|
||||||
|
lifetimeClicks: { increment: state.player.totalClicks },
|
||||||
|
|
||||||
|
// Accumulate into lifetime totals
|
||||||
|
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
|
||||||
|
|
||||||
|
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
|
||||||
|
|
||||||
|
totalClicks: 0,
|
||||||
|
// Reset current-run counters
|
||||||
|
totalGoldEarned: 0,
|
||||||
|
},
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const apotheosisCount = updatedApotheosisData.count;
|
||||||
|
void logger.metric("apotheosis", 1, { apotheosisCount, discordId });
|
||||||
|
void grantApotheosisRole(discordId);
|
||||||
|
void postMilestoneWebhook(discordId, "apotheosis", {
|
||||||
|
apotheosis: updatedApotheosisData.count,
|
||||||
|
prestige: updatedState.prestige.count,
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
transcendence: updatedState.transcendence?.count ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return context.json({ apotheosisCount: updatedApotheosisData.count });
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"apotheosis",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { apotheosisRouter };
|
export { apotheosisRouter };
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
fetchDiscordUser,
|
fetchDiscordUser,
|
||||||
} from "../services/discord.js";
|
} from "../services/discord.js";
|
||||||
import { signToken } from "../services/jwt.js";
|
import { signToken } from "../services/jwt.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { Player } from "@elysium/types";
|
import type { Player } from "@elysium/types";
|
||||||
|
|
||||||
const authRouter = new Hono();
|
const authRouter = new Hono();
|
||||||
@@ -92,6 +93,8 @@ authRouter.get("/callback", async(context) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const jwtToken = signToken(player.discordId);
|
const jwtToken = signToken(player.discordId);
|
||||||
|
void logger.log("info", `New player registered: ${player.discordId}`);
|
||||||
|
void logger.metric("user_registered", 1, { discordId: player.discordId });
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next -- @preserve */
|
||||||
@@ -111,6 +114,8 @@ authRouter.get("/callback", async(context) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const jwtToken = signToken(updated.discordId);
|
const jwtToken = signToken(updated.discordId);
|
||||||
|
void logger.log("info", `Player logged in: ${updated.discordId}`);
|
||||||
|
void logger.metric("user_login", 1, { discordId: updated.discordId });
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next -- @preserve */
|
||||||
@@ -118,7 +123,13 @@ authRouter.get("/callback", async(context) => {
|
|||||||
return context.redirect(
|
return context.redirect(
|
||||||
`${clientUrl}/auth/callback?token=${jwtToken}&isNew=false`,
|
`${clientUrl}/auth/callback?token=${jwtToken}&isNew=false`,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"auth_callback",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next -- @preserve */
|
||||||
const clientUrl = process.env.CORS_ORIGIN ?? "http://localhost:5173";
|
const clientUrl = process.env.CORS_ORIGIN ?? "http://localhost:5173";
|
||||||
|
|||||||
+246
-232
@@ -20,6 +20,7 @@ import { defaultEquipmentSets } from "../data/equipmentSets.js";
|
|||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
|
|
||||||
const bossRouter = new Hono<HonoEnvironment>();
|
const bossRouter = new Hono<HonoEnvironment>();
|
||||||
@@ -121,254 +122,267 @@ const calculatePartyStats = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
bossRouter.post("/challenge", async(context) => {
|
bossRouter.post("/challenge", async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
const body = await context.req.json<{ bossId: string }>();
|
const discordId = context.get("discordId");
|
||||||
|
const body = await context.req.json<{ bossId: string }>();
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
if (!body.bossId) {
|
if (!body.bossId) {
|
||||||
return context.json({ error: "Invalid request body" }, 400);
|
return context.json({ error: "Invalid request body" }, 400);
|
||||||
}
|
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
|
||||||
|
|
||||||
if (!record) {
|
|
||||||
return context.json({ error: "No save found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
const boss = state.bosses.find((b) => {
|
|
||||||
return b.id === body.bossId;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!boss) {
|
|
||||||
return context.json({ error: "Boss not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (boss.status !== "available" && boss.status !== "in_progress") {
|
|
||||||
return context.json({ error: "Boss is not currently available" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (boss.prestigeRequirement > state.prestige.count) {
|
|
||||||
return context.json({ error: "Prestige requirement not met" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { partyDPS, partyMaxHp } = calculatePartyStats(state);
|
|
||||||
|
|
||||||
if (
|
|
||||||
partyDPS === 0
|
|
||||||
|| partyMaxHp === 0
|
|
||||||
|| !Number.isFinite(partyDPS)
|
|
||||||
|| !Number.isFinite(partyMaxHp)
|
|
||||||
) {
|
|
||||||
return context.json(
|
|
||||||
{ error: "Your party has no adventurers ready to fight" },
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const bossHpBefore = boss.currentHp;
|
|
||||||
const bossDPS = boss.damagePerSecond;
|
|
||||||
|
|
||||||
const timeToKillBoss = bossHpBefore / partyDPS;
|
|
||||||
const timeToKillParty = partyMaxHp / bossDPS;
|
|
||||||
|
|
||||||
const won = timeToKillBoss <= timeToKillParty;
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
|
|
||||||
let partyHpRemaining: number;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
|
|
||||||
let bossHpAtBattleEnd: number;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
|
|
||||||
let bossUpdatedHp: number;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
|
|
||||||
let rewards: BossChallengeResponse["rewards"];
|
|
||||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
|
|
||||||
let casualties: BossChallengeResponse["casualties"];
|
|
||||||
|
|
||||||
if (won) {
|
|
||||||
bossHpAtBattleEnd = 0;
|
|
||||||
bossUpdatedHp = 0;
|
|
||||||
const bossDamageDealt = bossDPS * timeToKillBoss;
|
|
||||||
partyHpRemaining = Math.max(0, partyMaxHp - bossDamageDealt);
|
|
||||||
|
|
||||||
boss.status = "defeated";
|
|
||||||
boss.currentHp = 0;
|
|
||||||
|
|
||||||
state.resources.gold = state.resources.gold + boss.goldReward;
|
|
||||||
state.resources.essence = state.resources.essence + boss.essenceReward;
|
|
||||||
state.resources.crystals = state.resources.crystals + boss.crystalReward;
|
|
||||||
state.player.totalGoldEarned = state.player.totalGoldEarned + boss.goldReward;
|
|
||||||
|
|
||||||
for (const upgradeId of boss.upgradeRewards) {
|
|
||||||
const upgrade = state.upgrades.find((u) => {
|
|
||||||
return u.id === upgradeId;
|
|
||||||
});
|
|
||||||
if (upgrade) {
|
|
||||||
upgrade.unlocked = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grant equipment rewards — auto-equip if the slot is currently empty
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 14 -- @preserve */
|
|
||||||
for (const equipmentId of boss.equipmentRewards) {
|
|
||||||
const equipment = state.equipment.find((item) => {
|
|
||||||
return item.id === equipmentId;
|
|
||||||
});
|
|
||||||
if (equipment) {
|
|
||||||
equipment.owned = true;
|
|
||||||
|
|
||||||
const slotAlreadyEquipped = state.equipment.some((item) => {
|
if (!record) {
|
||||||
return item.type === equipment.type && item.equipped;
|
return context.json({ error: "No save found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
const boss = state.bosses.find((b) => {
|
||||||
|
return b.id === body.bossId;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!boss) {
|
||||||
|
return context.json({ error: "Boss not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boss.status !== "available" && boss.status !== "in_progress") {
|
||||||
|
return context.json({ error: "Boss is not currently available" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boss.prestigeRequirement > state.prestige.count) {
|
||||||
|
return context.json({ error: "Prestige requirement not met" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { partyDPS, partyMaxHp } = calculatePartyStats(state);
|
||||||
|
|
||||||
|
if (
|
||||||
|
partyDPS === 0
|
||||||
|
|| partyMaxHp === 0
|
||||||
|
|| !Number.isFinite(partyDPS)
|
||||||
|
|| !Number.isFinite(partyMaxHp)
|
||||||
|
) {
|
||||||
|
return context.json(
|
||||||
|
{ error: "Your party has no adventurers ready to fight" },
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bossHpBefore = boss.currentHp;
|
||||||
|
const bossDPS = boss.damagePerSecond;
|
||||||
|
|
||||||
|
const timeToKillBoss = bossHpBefore / partyDPS;
|
||||||
|
const timeToKillParty = partyMaxHp / bossDPS;
|
||||||
|
|
||||||
|
const won = timeToKillBoss <= timeToKillParty;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
|
||||||
|
let partyHpRemaining: number;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
|
||||||
|
let bossHpAtBattleEnd: number;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
|
||||||
|
let bossUpdatedHp: number;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
|
||||||
|
let rewards: BossChallengeResponse["rewards"];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
|
||||||
|
let casualties: BossChallengeResponse["casualties"];
|
||||||
|
|
||||||
|
if (won) {
|
||||||
|
bossHpAtBattleEnd = 0;
|
||||||
|
bossUpdatedHp = 0;
|
||||||
|
const bossDamageDealt = bossDPS * timeToKillBoss;
|
||||||
|
partyHpRemaining = Math.max(0, partyMaxHp - bossDamageDealt);
|
||||||
|
|
||||||
|
boss.status = "defeated";
|
||||||
|
boss.currentHp = 0;
|
||||||
|
|
||||||
|
state.resources.gold = state.resources.gold + boss.goldReward;
|
||||||
|
state.resources.essence = state.resources.essence + boss.essenceReward;
|
||||||
|
state.resources.crystals = state.resources.crystals + boss.crystalReward;
|
||||||
|
state.player.totalGoldEarned = state.player.totalGoldEarned + boss.goldReward;
|
||||||
|
|
||||||
|
for (const upgradeId of boss.upgradeRewards) {
|
||||||
|
const upgrade = state.upgrades.find((u) => {
|
||||||
|
return u.id === upgradeId;
|
||||||
});
|
});
|
||||||
if (!slotAlreadyEquipped) {
|
if (upgrade) {
|
||||||
equipment.equipped = true;
|
upgrade.unlocked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant equipment rewards — auto-equip if the slot is currently empty
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 14 -- @preserve */
|
||||||
|
for (const equipmentId of boss.equipmentRewards) {
|
||||||
|
const equipment = state.equipment.find((item) => {
|
||||||
|
return item.id === equipmentId;
|
||||||
|
});
|
||||||
|
if (equipment) {
|
||||||
|
equipment.owned = true;
|
||||||
|
|
||||||
|
const slotAlreadyEquipped = state.equipment.some((item) => {
|
||||||
|
return item.type === equipment.type && item.equipped;
|
||||||
|
});
|
||||||
|
if (!slotAlreadyEquipped) {
|
||||||
|
equipment.equipped = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock next boss in the same zone (zone-based sequential progression)
|
||||||
|
const zoneBosses = state.bosses.filter((b) => {
|
||||||
|
return b.zoneId === boss.zoneId;
|
||||||
|
});
|
||||||
|
const zoneIndex = zoneBosses.findIndex((b) => {
|
||||||
|
return b.id === body.bossId;
|
||||||
|
});
|
||||||
|
const [ nextZoneBoss ] = zoneBosses.slice(zoneIndex + 1);
|
||||||
|
if (
|
||||||
|
nextZoneBoss
|
||||||
|
&& nextZoneBoss.prestigeRequirement <= state.prestige.count
|
||||||
|
) {
|
||||||
|
const nextBossInState = state.bosses.find((b) => {
|
||||||
|
return b.id === nextZoneBoss.id;
|
||||||
|
});
|
||||||
|
if (nextBossInState) {
|
||||||
|
nextBossInState.status = "available";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Unlock any zone whose unlock conditions are now both satisfied
|
||||||
|
* (final boss defeated AND final quest completed)
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
for (const zone of state.zones) {
|
||||||
|
if (zone.status === "unlocked") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (zone.unlockBossId !== body.bossId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boss condition just became satisfied — check the quest condition too
|
||||||
|
const questSatisfied
|
||||||
|
= zone.unlockQuestId === null
|
||||||
|
|| state.quests.some((q) => {
|
||||||
|
return q.id === zone.unlockQuestId && q.status === "completed";
|
||||||
|
});
|
||||||
|
if (!questSatisfied) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
zone.status = "unlocked";
|
||||||
|
const updatedZoneBosses = state.bosses.filter((b) => {
|
||||||
|
return b.zoneId === zone.id;
|
||||||
|
});
|
||||||
|
const [ firstUpdatedBoss ] = updatedZoneBosses;
|
||||||
|
if (
|
||||||
|
firstUpdatedBoss
|
||||||
|
&& firstUpdatedBoss.prestigeRequirement <= state.prestige.count
|
||||||
|
) {
|
||||||
|
firstUpdatedBoss.status = "available";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update daily boss challenge progress
|
||||||
|
if (state.dailyChallenges) {
|
||||||
|
const { crystalsAwarded, updatedChallenges } = updateChallengeProgress(
|
||||||
|
state.dailyChallenges,
|
||||||
|
"bossesDefeated",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
state.dailyChallenges = updatedChallenges;
|
||||||
|
state.resources.crystals = state.resources.crystals + crystalsAwarded;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First-kill bounty — look up authoritative bounty from static data
|
||||||
|
const staticBoss = defaultBosses.find((b) => {
|
||||||
|
return b.id === body.bossId;
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
const bountyRunestones = staticBoss?.bountyRunestones ?? 0;
|
||||||
|
state.prestige.runestones = state.prestige.runestones + bountyRunestones;
|
||||||
|
|
||||||
|
rewards = {
|
||||||
|
bountyRunestones: bountyRunestones,
|
||||||
|
crystals: boss.crystalReward,
|
||||||
|
equipmentIds: boss.equipmentRewards,
|
||||||
|
essence: boss.essenceReward,
|
||||||
|
gold: boss.goldReward,
|
||||||
|
upgradeIds: boss.upgradeRewards,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const partyDamageDealt = partyDPS * timeToKillParty;
|
||||||
|
bossHpAtBattleEnd = Math.max(0, bossHpBefore - partyDamageDealt);
|
||||||
|
bossUpdatedHp = boss.maxHp;
|
||||||
|
partyHpRemaining = 0;
|
||||||
|
|
||||||
|
boss.status = "available";
|
||||||
|
boss.currentHp = boss.maxHp;
|
||||||
|
|
||||||
|
// How close was the party to winning? (0 = hopeless, 1 = nearly won)
|
||||||
|
const victoryProgress = Math.min(1, timeToKillParty / timeToKillBoss);
|
||||||
|
// Casualty rate scales from ~0% (nearly won) to 60% (completely outmatched)
|
||||||
|
const casualtyFraction = (1 - victoryProgress) * 0.6;
|
||||||
|
|
||||||
|
casualties = [];
|
||||||
|
for (const adventurer of state.adventurers) {
|
||||||
|
if (adventurer.count === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const killed = Math.floor(adventurer.count * casualtyFraction);
|
||||||
|
if (killed > 0) {
|
||||||
|
adventurer.count = Math.max(1, adventurer.count - killed);
|
||||||
|
casualties.push({ adventurerId: adventurer.id, killed: killed });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unlock next boss in the same zone (zone-based sequential progression)
|
const now = Date.now();
|
||||||
const zoneBosses = state.bosses.filter((b) => {
|
await prisma.gameState.update({
|
||||||
return b.zoneId === boss.zoneId;
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
});
|
data: { state: state as object, updatedAt: now },
|
||||||
const zoneIndex = zoneBosses.findIndex((b) => {
|
where: { discordId },
|
||||||
return b.id === body.bossId;
|
|
||||||
});
|
|
||||||
const [ nextZoneBoss ] = zoneBosses.slice(zoneIndex + 1);
|
|
||||||
if (
|
|
||||||
nextZoneBoss
|
|
||||||
&& nextZoneBoss.prestigeRequirement <= state.prestige.count
|
|
||||||
) {
|
|
||||||
const nextBossInState = state.bosses.find((b) => {
|
|
||||||
return b.id === nextZoneBoss.id;
|
|
||||||
});
|
|
||||||
if (nextBossInState) {
|
|
||||||
nextBossInState.status = "available";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Unlock any zone whose unlock conditions are now both satisfied
|
|
||||||
* (final boss defeated AND final quest completed)
|
|
||||||
*/
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next -- @preserve */
|
|
||||||
for (const zone of state.zones) {
|
|
||||||
if (zone.status === "unlocked") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (zone.unlockBossId !== body.bossId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Boss condition just became satisfied — check the quest condition too
|
|
||||||
const questSatisfied
|
|
||||||
= zone.unlockQuestId === null
|
|
||||||
|| state.quests.some((q) => {
|
|
||||||
return q.id === zone.unlockQuestId && q.status === "completed";
|
|
||||||
});
|
|
||||||
if (!questSatisfied) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
zone.status = "unlocked";
|
|
||||||
const updatedZoneBosses = state.bosses.filter((b) => {
|
|
||||||
return b.zoneId === zone.id;
|
|
||||||
});
|
|
||||||
const [ firstUpdatedBoss ] = updatedZoneBosses;
|
|
||||||
if (
|
|
||||||
firstUpdatedBoss
|
|
||||||
&& firstUpdatedBoss.prestigeRequirement <= state.prestige.count
|
|
||||||
) {
|
|
||||||
firstUpdatedBoss.status = "available";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update daily boss challenge progress
|
|
||||||
if (state.dailyChallenges) {
|
|
||||||
const { crystalsAwarded, updatedChallenges } = updateChallengeProgress(
|
|
||||||
state.dailyChallenges,
|
|
||||||
"bossesDefeated",
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
state.dailyChallenges = updatedChallenges;
|
|
||||||
state.resources.crystals = state.resources.crystals + crystalsAwarded;
|
|
||||||
}
|
|
||||||
|
|
||||||
// First-kill bounty — look up authoritative bounty from static data
|
|
||||||
const staticBoss = defaultBosses.find((b) => {
|
|
||||||
return b.id === body.bossId;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
const { bossId } = body;
|
||||||
/* v8 ignore next -- @preserve */
|
void logger.metric("boss_challenge", 1, { bossId, discordId, won });
|
||||||
const bountyRunestones = staticBoss?.bountyRunestones ?? 0;
|
|
||||||
state.prestige.runestones = state.prestige.runestones + bountyRunestones;
|
|
||||||
|
|
||||||
rewards = {
|
const bossMaxHp = boss.maxHp;
|
||||||
bountyRunestones: bountyRunestones,
|
const bossNewHp = bossUpdatedHp;
|
||||||
crystals: boss.crystalReward,
|
const response: BossChallengeResponse = {
|
||||||
equipmentIds: boss.equipmentRewards,
|
bossDPS,
|
||||||
essence: boss.essenceReward,
|
bossHpAtBattleEnd,
|
||||||
gold: boss.goldReward,
|
bossHpBefore,
|
||||||
upgradeIds: boss.upgradeRewards,
|
bossMaxHp,
|
||||||
|
bossNewHp,
|
||||||
|
partyDPS,
|
||||||
|
partyHpRemaining,
|
||||||
|
partyMaxHp,
|
||||||
|
won,
|
||||||
};
|
};
|
||||||
} else {
|
if (rewards !== undefined) {
|
||||||
const partyDamageDealt = partyDPS * timeToKillParty;
|
response.rewards = rewards;
|
||||||
bossHpAtBattleEnd = Math.max(0, bossHpBefore - partyDamageDealt);
|
}
|
||||||
bossUpdatedHp = boss.maxHp;
|
if (casualties !== undefined) {
|
||||||
partyHpRemaining = 0;
|
response.casualties = casualties;
|
||||||
|
|
||||||
boss.status = "available";
|
|
||||||
boss.currentHp = boss.maxHp;
|
|
||||||
|
|
||||||
// How close was the party to winning? (0 = hopeless, 1 = nearly won)
|
|
||||||
const victoryProgress = Math.min(1, timeToKillParty / timeToKillBoss);
|
|
||||||
// Casualty rate scales from ~0% (nearly won) to 60% (completely outmatched)
|
|
||||||
const casualtyFraction = (1 - victoryProgress) * 0.6;
|
|
||||||
|
|
||||||
casualties = [];
|
|
||||||
for (const adventurer of state.adventurers) {
|
|
||||||
if (adventurer.count === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const killed = Math.floor(adventurer.count * casualtyFraction);
|
|
||||||
if (killed > 0) {
|
|
||||||
adventurer.count = Math.max(1, adventurer.count - killed);
|
|
||||||
casualties.push({ adventurerId: adventurer.id, killed: killed });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
return context.json(response);
|
||||||
await prisma.gameState.update({
|
} catch (error) {
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
void logger.error(
|
||||||
data: { state: state as object, updatedAt: now },
|
"boss_challenge",
|
||||||
where: { discordId },
|
error instanceof Error
|
||||||
});
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
const bossMaxHp = boss.maxHp;
|
);
|
||||||
const bossNewHp = bossUpdatedHp;
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
const response: BossChallengeResponse = {
|
|
||||||
bossDPS,
|
|
||||||
bossHpAtBattleEnd,
|
|
||||||
bossHpBefore,
|
|
||||||
bossMaxHp,
|
|
||||||
bossNewHp,
|
|
||||||
partyDPS,
|
|
||||||
partyHpRemaining,
|
|
||||||
partyMaxHp,
|
|
||||||
won,
|
|
||||||
};
|
|
||||||
if (rewards !== undefined) {
|
|
||||||
response.rewards = rewards;
|
|
||||||
}
|
}
|
||||||
if (casualties !== undefined) {
|
|
||||||
response.casualties = casualties;
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json(response);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export { bossRouter };
|
export { bossRouter };
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Hono } from "hono";
|
|||||||
import { defaultRecipes } from "../data/recipes.js";
|
import { defaultRecipes } from "../data/recipes.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
import type {
|
import type {
|
||||||
CraftRecipeRequest,
|
CraftRecipeRequest,
|
||||||
@@ -63,94 +64,106 @@ const recomputeCraftedMultipliers = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
craftRouter.post("/", async(context) => {
|
craftRouter.post("/", async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
const body = await context.req.json<CraftRecipeRequest>();
|
const discordId = context.get("discordId");
|
||||||
|
const body = await context.req.json<CraftRecipeRequest>();
|
||||||
|
|
||||||
const { recipeId } = body;
|
const { recipeId } = body;
|
||||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
if (!recipeId) {
|
if (!recipeId) {
|
||||||
return context.json({ error: "recipeId is required" }, 400);
|
return context.json({ error: "recipeId is required" }, 400);
|
||||||
}
|
|
||||||
|
|
||||||
const recipe = defaultRecipes.find((r) => {
|
|
||||||
return r.id === recipeId;
|
|
||||||
});
|
|
||||||
if (!recipe) {
|
|
||||||
return context.json({ error: "Unknown recipe" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
|
||||||
if (!record) {
|
|
||||||
return context.json({ error: "No save found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
if (!state.exploration) {
|
|
||||||
return context.json({ error: "No exploration state found" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.exploration.craftedRecipeIds.includes(recipeId)) {
|
|
||||||
return context.json({ error: "Recipe already crafted" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the player has all required materials
|
|
||||||
for (const requirement of recipe.requiredMaterials) {
|
|
||||||
const material = state.exploration.materials.find((m) => {
|
|
||||||
return m.materialId === requirement.materialId;
|
|
||||||
});
|
|
||||||
const quantity = material?.quantity ?? 0;
|
|
||||||
if (quantity < requirement.quantity) {
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
error: `Not enough ${requirement.materialId} (need ${String(requirement.quantity)}, have ${String(quantity)})`,
|
|
||||||
},
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Deduct materials
|
const recipe = defaultRecipes.find((r) => {
|
||||||
for (const requirement of recipe.requiredMaterials) {
|
return r.id === recipeId;
|
||||||
const material = state.exploration.materials.find((m) => {
|
|
||||||
return m.materialId === requirement.materialId;
|
|
||||||
});
|
});
|
||||||
if (material) {
|
if (!recipe) {
|
||||||
material.quantity = material.quantity - requirement.quantity;
|
return context.json({ error: "Unknown recipe" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
|
if (!record) {
|
||||||
|
return context.json({ error: "No save found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (!state.exploration) {
|
||||||
|
return context.json({ error: "No exploration state found" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.exploration.craftedRecipeIds.includes(recipeId)) {
|
||||||
|
return context.json({ error: "Recipe already crafted" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the player has all required materials
|
||||||
|
for (const requirement of recipe.requiredMaterials) {
|
||||||
|
const material = state.exploration.materials.find((m) => {
|
||||||
|
return m.materialId === requirement.materialId;
|
||||||
|
});
|
||||||
|
const quantity = material?.quantity ?? 0;
|
||||||
|
if (quantity < requirement.quantity) {
|
||||||
|
return context.json(
|
||||||
|
{
|
||||||
|
error: `Not enough ${requirement.materialId} (need ${String(requirement.quantity)}, have ${String(quantity)})`,
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduct materials
|
||||||
|
for (const requirement of recipe.requiredMaterials) {
|
||||||
|
const material = state.exploration.materials.find((m) => {
|
||||||
|
return m.materialId === requirement.materialId;
|
||||||
|
});
|
||||||
|
if (material) {
|
||||||
|
material.quantity = material.quantity - requirement.quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add recipe and recompute all multipliers from scratch
|
||||||
|
state.exploration.craftedRecipeIds.push(recipeId);
|
||||||
|
const updatedMultipliers = recomputeCraftedMultipliers(
|
||||||
|
state.exploration.craftedRecipeIds,
|
||||||
|
);
|
||||||
|
state.exploration.craftedGoldMultiplier
|
||||||
|
= updatedMultipliers.craftedGoldMultiplier;
|
||||||
|
state.exploration.craftedEssenceMultiplier
|
||||||
|
= updatedMultipliers.craftedEssenceMultiplier;
|
||||||
|
state.exploration.craftedClickMultiplier
|
||||||
|
= updatedMultipliers.craftedClickMultiplier;
|
||||||
|
state.exploration.craftedCombatMultiplier
|
||||||
|
= updatedMultipliers.craftedCombatMultiplier;
|
||||||
|
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: state as object, updatedAt: Date.now() },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
void logger.metric("recipe_crafted", 1, { discordId, recipeId });
|
||||||
|
|
||||||
|
const bonusType = recipe.bonus.type;
|
||||||
|
const bonusValue = recipe.bonus.value;
|
||||||
|
const response: CraftRecipeResponse = {
|
||||||
|
bonusType,
|
||||||
|
bonusValue,
|
||||||
|
recipeId,
|
||||||
|
...updatedMultipliers,
|
||||||
|
};
|
||||||
|
return context.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"craft",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add recipe and recompute all multipliers from scratch
|
|
||||||
state.exploration.craftedRecipeIds.push(recipeId);
|
|
||||||
const updatedMultipliers = recomputeCraftedMultipliers(
|
|
||||||
state.exploration.craftedRecipeIds,
|
|
||||||
);
|
|
||||||
state.exploration.craftedGoldMultiplier
|
|
||||||
= updatedMultipliers.craftedGoldMultiplier;
|
|
||||||
state.exploration.craftedEssenceMultiplier
|
|
||||||
= updatedMultipliers.craftedEssenceMultiplier;
|
|
||||||
state.exploration.craftedClickMultiplier
|
|
||||||
= updatedMultipliers.craftedClickMultiplier;
|
|
||||||
state.exploration.craftedCombatMultiplier
|
|
||||||
= updatedMultipliers.craftedCombatMultiplier;
|
|
||||||
|
|
||||||
await prisma.gameState.update({
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
|
||||||
data: { state: state as object, updatedAt: Date.now() },
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
const bonusType = recipe.bonus.type;
|
|
||||||
const bonusValue = recipe.bonus.value;
|
|
||||||
const response: CraftRecipeResponse = {
|
|
||||||
bonusType,
|
|
||||||
bonusValue,
|
|
||||||
recipeId,
|
|
||||||
...updatedMultipliers,
|
|
||||||
};
|
|
||||||
return context.json(response);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export { craftRouter };
|
export { craftRouter };
|
||||||
|
|||||||
+280
-254
@@ -12,6 +12,7 @@ import { defaultExplorations } from "../data/explorations.js";
|
|||||||
import { initialExploration } from "../data/initialState.js";
|
import { initialExploration } from "../data/initialState.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
import type {
|
import type {
|
||||||
ExploreCollectEventResult,
|
ExploreCollectEventResult,
|
||||||
@@ -49,280 +50,233 @@ const pickNothingMessage = (): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
exploreRouter.post("/start", async(context) => {
|
exploreRouter.post("/start", async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
const body = await context.req.json<ExploreStartRequest>();
|
const discordId = context.get("discordId");
|
||||||
|
const body = await context.req.json<ExploreStartRequest>();
|
||||||
|
|
||||||
const { areaId } = body;
|
const { areaId } = body;
|
||||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
if (!areaId) {
|
if (!areaId) {
|
||||||
return context.json({ error: "areaId is required" }, 400);
|
return context.json({ error: "areaId is required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const explorationArea = defaultExplorations.find((a) => {
|
const explorationArea = defaultExplorations.find((a) => {
|
||||||
return a.id === areaId;
|
return a.id === areaId;
|
||||||
});
|
});
|
||||||
if (!explorationArea) {
|
if (!explorationArea) {
|
||||||
return context.json({ error: "Unknown exploration area" }, 404);
|
return context.json({ error: "Unknown exploration area" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return context.json({ error: "No save found" }, 404);
|
return context.json({ error: "No save found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawState: unknown = record.state;
|
const rawState: unknown = record.state;
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||||
const state = rawState as GameState;
|
const state = rawState as GameState;
|
||||||
|
|
||||||
// Backfill exploration state for old saves that predate this feature
|
// Backfill exploration state for old saves that predate this feature
|
||||||
if (!state.exploration) {
|
if (!state.exploration) {
|
||||||
state.exploration = structuredClone(initialExploration);
|
state.exploration = structuredClone(initialExploration);
|
||||||
// Unlock areas for zones already unlocked in this save
|
// Unlock areas for zones already unlocked in this save
|
||||||
for (const area of state.exploration.areas) {
|
for (const area of state.exploration.areas) {
|
||||||
const areaData = defaultExplorations.find((areaItem) => {
|
const areaData = defaultExplorations.find((areaItem) => {
|
||||||
return areaItem.id === area.id;
|
return areaItem.id === area.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next 3 -- @preserve */
|
/* v8 ignore next 3 -- @preserve */
|
||||||
if (!areaData) {
|
if (!areaData) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const zone = state.zones.find((z) => {
|
const zone = state.zones.find((z) => {
|
||||||
return z.id === areaData.zoneId;
|
return z.id === areaData.zoneId;
|
||||||
});
|
});
|
||||||
if (zone?.status === "unlocked") {
|
if (zone?.status === "unlocked") {
|
||||||
area.status = "available";
|
area.status = "available";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const zone = state.zones.find((z) => {
|
const zone = state.zones.find((z) => {
|
||||||
return z.id === explorationArea.zoneId;
|
return z.id === explorationArea.zoneId;
|
||||||
});
|
});
|
||||||
if (!zone || zone.status !== "unlocked") {
|
if (!zone || zone.status !== "unlocked") {
|
||||||
return context.json({ error: "Zone is not unlocked" }, 400);
|
return context.json({ error: "Zone is not unlocked" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const area = state.exploration.areas.find((a) => {
|
const area = state.exploration.areas.find((a) => {
|
||||||
return a.id === areaId;
|
return a.id === areaId;
|
||||||
});
|
});
|
||||||
if (!area) {
|
if (!area) {
|
||||||
return context.json({ error: "Exploration area not found in state" }, 404);
|
return context.json(
|
||||||
}
|
{ error: "Exploration area not found in state" },
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const anyInProgress = state.exploration.areas.some((a) => {
|
const anyInProgress = state.exploration.areas.some((a) => {
|
||||||
return a.status === "in_progress";
|
return a.status === "in_progress";
|
||||||
});
|
});
|
||||||
if (anyInProgress) {
|
if (anyInProgress) {
|
||||||
return context.json(
|
return context.json(
|
||||||
{ error: "An exploration is already in progress" },
|
{ error: "An exploration is already in progress" },
|
||||||
400,
|
400,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (area.status === "locked") {
|
if (area.status === "locked") {
|
||||||
return context.json({ error: "Exploration area is locked" }, 400);
|
return context.json({ error: "Exploration area is locked" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
area.status = "in_progress";
|
area.status = "in_progress";
|
||||||
area.startedAt = now;
|
area.startedAt = now;
|
||||||
|
|
||||||
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 },
|
|
||||||
});
|
|
||||||
|
|
||||||
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
|
|
||||||
const endsAt = now + explorationArea.durationSeconds * 1000;
|
|
||||||
const response: ExploreStartResponse = {
|
|
||||||
areaId,
|
|
||||||
endsAt,
|
|
||||||
};
|
|
||||||
return context.json(response);
|
|
||||||
});
|
|
||||||
|
|
||||||
exploreRouter.post("/collect", async(context) => {
|
|
||||||
const discordId = context.get("discordId");
|
|
||||||
const body = await context.req.json<ExploreCollectRequest>();
|
|
||||||
|
|
||||||
const { areaId } = body;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
|
||||||
if (!areaId) {
|
|
||||||
return context.json({ error: "areaId is required" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const explorationArea = defaultExplorations.find((a) => {
|
|
||||||
return a.id === areaId;
|
|
||||||
});
|
|
||||||
if (!explorationArea) {
|
|
||||||
return context.json({ error: "Unknown exploration area" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
|
||||||
if (!record) {
|
|
||||||
return context.json({ error: "No save found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
if (!state.exploration) {
|
|
||||||
return context.json({ error: "No exploration state found" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const area = state.exploration.areas.find((a) => {
|
|
||||||
return a.id === areaId;
|
|
||||||
});
|
|
||||||
if (!area) {
|
|
||||||
return context.json({ error: "Exploration area not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (area.status !== "in_progress") {
|
|
||||||
return context.json({ error: "Exploration is not in progress" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next -- @preserve */
|
|
||||||
const startedAt = area.startedAt ?? 0;
|
|
||||||
const durationMs = explorationArea.durationSeconds * 1000;
|
|
||||||
const expiresAt = startedAt + durationMs;
|
|
||||||
|
|
||||||
if (now < expiresAt) {
|
|
||||||
return context.json({ error: "Exploration is not yet complete" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
area.status = "available";
|
|
||||||
area.completedOnce = true;
|
|
||||||
|
|
||||||
// 20% chance of finding nothing
|
|
||||||
if (Math.random() < nothingProbability) {
|
|
||||||
await prisma.gameState.update({
|
await prisma.gameState.update({
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
data: { state: state as object, updatedAt: now },
|
data: { state: state as object, updatedAt: now },
|
||||||
where: { discordId },
|
where: { discordId },
|
||||||
});
|
});
|
||||||
|
|
||||||
const response: ExploreCollectResponse = {
|
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
|
||||||
event: null,
|
const endsAt = now + explorationArea.durationSeconds * 1000;
|
||||||
foundNothing: true,
|
const response: ExploreStartResponse = {
|
||||||
materialsFound: [],
|
areaId,
|
||||||
nothingMessage: pickNothingMessage(),
|
endsAt,
|
||||||
};
|
};
|
||||||
return context.json(response);
|
return context.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"explore_start",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Pick a random event
|
exploreRouter.post("/collect", async(context) => {
|
||||||
const eventIndex = Math.floor(Math.random() * explorationArea.events.length);
|
try {
|
||||||
const event = explorationArea.events[eventIndex];
|
const discordId = context.get("discordId");
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
const body = await context.req.json<ExploreCollectRequest>();
|
||||||
/* v8 ignore next 3 -- @preserve */
|
|
||||||
if (!event) {
|
|
||||||
return context.json({ error: "No events available" }, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply event effects and build the result summary
|
const { areaId } = body;
|
||||||
let goldChange = 0;
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
let essenceChange = 0;
|
if (!areaId) {
|
||||||
let materialGained: { materialId: string; quantity: number } | null = null;
|
return context.json({ error: "areaId is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
if (event.effect.type === "gold_gain") {
|
const explorationArea = defaultExplorations.find((a) => {
|
||||||
// Gold gain — amount may be undefined in edge cases
|
return a.id === areaId;
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
});
|
||||||
/* v8 ignore next -- @preserve */
|
if (!explorationArea) {
|
||||||
const amount = event.effect.amount ?? 0;
|
return context.json({ error: "Unknown exploration area" }, 404);
|
||||||
state.resources.gold = state.resources.gold + amount;
|
}
|
||||||
state.player.totalGoldEarned = state.player.totalGoldEarned + amount;
|
|
||||||
goldChange = amount;
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
} else if (event.effect.type === "gold_loss") {
|
if (!record) {
|
||||||
// Gold loss — amount may be undefined in edge cases
|
return context.json({ error: "No save found" }, 404);
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
}
|
||||||
/* v8 ignore next -- @preserve */
|
|
||||||
const amount = Math.min(state.resources.gold, event.effect.amount ?? 0);
|
const rawState: unknown = record.state;
|
||||||
state.resources.gold = state.resources.gold - amount;
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||||
goldChange = -amount;
|
const state = rawState as GameState;
|
||||||
} else if (event.effect.type === "essence_gain") {
|
|
||||||
// Essence gain — amount may be undefined in edge cases
|
if (!state.exploration) {
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
return context.json({ error: "No exploration state found" }, 400);
|
||||||
/* v8 ignore next -- @preserve */
|
}
|
||||||
const amount = event.effect.amount ?? 0;
|
|
||||||
state.resources.essence = state.resources.essence + amount;
|
const area = state.exploration.areas.find((a) => {
|
||||||
essenceChange = amount;
|
return a.id === areaId;
|
||||||
} else if (event.effect.type === "material_gain") {
|
});
|
||||||
const { materialId } = event.effect;
|
if (!area) {
|
||||||
|
return context.json({ error: "Exploration area not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (area.status !== "in_progress") {
|
||||||
|
return context.json({ error: "Exploration is not in progress" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next -- @preserve */
|
||||||
const quantity = event.effect.quantity ?? 1;
|
const startedAt = area.startedAt ?? 0;
|
||||||
if (materialId !== undefined && materialId !== "") {
|
const durationMs = explorationArea.durationSeconds * 1000;
|
||||||
const existing = state.exploration.materials.find((m) => {
|
const expiresAt = startedAt + durationMs;
|
||||||
return m.materialId === materialId;
|
|
||||||
|
if (now < expiresAt) {
|
||||||
|
return context.json({ error: "Exploration is not yet complete" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
area.status = "available";
|
||||||
|
area.completedOnce = true;
|
||||||
|
|
||||||
|
// 20% chance of finding nothing
|
||||||
|
if (Math.random() < nothingProbability) {
|
||||||
|
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 },
|
||||||
});
|
});
|
||||||
if (existing) {
|
|
||||||
existing.quantity = existing.quantity + quantity;
|
const response: ExploreCollectResponse = {
|
||||||
} else {
|
event: null,
|
||||||
state.exploration.materials.push({ materialId, quantity });
|
foundNothing: true,
|
||||||
}
|
materialsFound: [],
|
||||||
materialGained = { materialId, quantity };
|
nothingMessage: pickNothingMessage(),
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
};
|
||||||
/* v8 ignore next 13 -- @preserve */
|
return context.json(response);
|
||||||
}
|
}
|
||||||
} else if (event.effect.type === "adventurer_loss") { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- exhausted all other union members above
|
|
||||||
// Adventurer loss — fraction and loop are defensive
|
// Pick a random event
|
||||||
|
const eventIndex = Math.floor(
|
||||||
|
Math.random() * explorationArea.events.length,
|
||||||
|
);
|
||||||
|
const event = explorationArea.events[eventIndex];
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next 8 -- @preserve */
|
/* v8 ignore next 3 -- @preserve */
|
||||||
const fraction = event.effect.fraction ?? 0.05;
|
if (!event) {
|
||||||
for (const adventurer of state.adventurers) {
|
return context.json({ error: "No events available" }, 500);
|
||||||
const lost = Math.floor(adventurer.count * fraction);
|
|
||||||
if (lost > 0) {
|
|
||||||
adventurer.count = Math.max(0, adventurer.count - lost);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// Apply event effects and build the result summary
|
||||||
/* v8 ignore next 8 -- @preserve */
|
let goldChange = 0;
|
||||||
let adventurerLostCount = 0;
|
let essenceChange = 0;
|
||||||
if (event.effect.type === "adventurer_loss") {
|
let materialGained: { materialId: string; quantity: number } | null = null;
|
||||||
const fraction = event.effect.fraction ?? 0.05;
|
|
||||||
for (const adv of state.adventurers) {
|
|
||||||
const lost = Math.floor(adv.count * fraction);
|
|
||||||
adventurerLostCount = adventurerLostCount + lost;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventResult: ExploreCollectEventResult = {
|
if (event.effect.type === "gold_gain") {
|
||||||
adventurerLostCount: adventurerLostCount,
|
// Gold gain — amount may be undefined in edge cases
|
||||||
essenceChange: essenceChange,
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
goldChange: goldChange,
|
/* v8 ignore next -- @preserve */
|
||||||
materialGained: materialGained,
|
const amount = event.effect.amount ?? 0;
|
||||||
text: event.text,
|
state.resources.gold = state.resources.gold + amount;
|
||||||
};
|
state.player.totalGoldEarned = state.player.totalGoldEarned + amount;
|
||||||
|
goldChange = amount;
|
||||||
// Roll for material drops from possibleMaterials (weighted random selection)
|
} else if (event.effect.type === "gold_loss") {
|
||||||
const materialsFound: Array<{ materialId: string; quantity: number }> = [];
|
// Gold loss — amount may be undefined in edge cases
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
if (explorationArea.possibleMaterials.length > 0) {
|
/* v8 ignore next -- @preserve */
|
||||||
let totalWeight = 0;
|
const amount = Math.min(state.resources.gold, event.effect.amount ?? 0);
|
||||||
for (const materialDrop of explorationArea.possibleMaterials) {
|
state.resources.gold = state.resources.gold - amount;
|
||||||
totalWeight = totalWeight + materialDrop.weight;
|
goldChange = -amount;
|
||||||
}
|
} else if (event.effect.type === "essence_gain") {
|
||||||
let roll = Math.random() * totalWeight;
|
// Essence gain — amount may be undefined in edge cases
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
for (const possible of explorationArea.possibleMaterials) {
|
/* v8 ignore next -- @preserve */
|
||||||
roll = roll - possible.weight;
|
const amount = event.effect.amount ?? 0;
|
||||||
if (roll <= 0) {
|
state.resources.essence = state.resources.essence + amount;
|
||||||
const maxMinDiff = possible.maxQuantity - possible.minQuantity;
|
essenceChange = amount;
|
||||||
const range = maxMinDiff + 1;
|
} else if (event.effect.type === "material_gain") {
|
||||||
const randomOffset = Math.floor(Math.random() * range);
|
const { materialId } = event.effect;
|
||||||
const quantity = randomOffset + possible.minQuantity;
|
|
||||||
const { materialId } = possible;
|
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
const quantity = event.effect.quantity ?? 1;
|
||||||
|
if (materialId !== undefined && materialId !== "") {
|
||||||
const existing = state.exploration.materials.find((m) => {
|
const existing = state.exploration.materials.find((m) => {
|
||||||
return m.materialId === materialId;
|
return m.materialId === materialId;
|
||||||
});
|
});
|
||||||
@@ -331,25 +285,97 @@ exploreRouter.post("/collect", async(context) => {
|
|||||||
} else {
|
} else {
|
||||||
state.exploration.materials.push({ materialId, quantity });
|
state.exploration.materials.push({ materialId, quantity });
|
||||||
}
|
}
|
||||||
|
materialGained = { materialId, quantity };
|
||||||
materialsFound.push({ materialId, quantity });
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
break;
|
/* v8 ignore next 13 -- @preserve */
|
||||||
|
}
|
||||||
|
} else if (event.effect.type === "adventurer_loss") { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- exhausted all other union members above
|
||||||
|
// Adventurer loss — fraction and loop are defensive
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 8 -- @preserve */
|
||||||
|
const fraction = event.effect.fraction ?? 0.05;
|
||||||
|
for (const adventurer of state.adventurers) {
|
||||||
|
const lost = Math.floor(adventurer.count * fraction);
|
||||||
|
if (lost > 0) {
|
||||||
|
adventurer.count = Math.max(0, adventurer.count - lost);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 8 -- @preserve */
|
||||||
|
let adventurerLostCount = 0;
|
||||||
|
if (event.effect.type === "adventurer_loss") {
|
||||||
|
const fraction = event.effect.fraction ?? 0.05;
|
||||||
|
for (const adv of state.adventurers) {
|
||||||
|
const lost = Math.floor(adv.count * fraction);
|
||||||
|
adventurerLostCount = adventurerLostCount + lost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventResult: ExploreCollectEventResult = {
|
||||||
|
adventurerLostCount: adventurerLostCount,
|
||||||
|
essenceChange: essenceChange,
|
||||||
|
goldChange: goldChange,
|
||||||
|
materialGained: materialGained,
|
||||||
|
text: event.text,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Roll for material drops from possibleMaterials (weighted random selection)
|
||||||
|
const materialsFound: Array<{ materialId: string; quantity: number }> = [];
|
||||||
|
|
||||||
|
if (explorationArea.possibleMaterials.length > 0) {
|
||||||
|
let totalWeight = 0;
|
||||||
|
for (const materialDrop of explorationArea.possibleMaterials) {
|
||||||
|
totalWeight = totalWeight + materialDrop.weight;
|
||||||
|
}
|
||||||
|
let roll = Math.random() * totalWeight;
|
||||||
|
|
||||||
|
for (const possible of explorationArea.possibleMaterials) {
|
||||||
|
roll = roll - possible.weight;
|
||||||
|
if (roll <= 0) {
|
||||||
|
const maxMinDiff = possible.maxQuantity - possible.minQuantity;
|
||||||
|
const range = maxMinDiff + 1;
|
||||||
|
const randomOffset = Math.floor(Math.random() * range);
|
||||||
|
const quantity = randomOffset + possible.minQuantity;
|
||||||
|
const { materialId } = possible;
|
||||||
|
|
||||||
|
const existing = state.exploration.materials.find((m) => {
|
||||||
|
return m.materialId === materialId;
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
existing.quantity = existing.quantity + quantity;
|
||||||
|
} else {
|
||||||
|
state.exploration.materials.push({ materialId, quantity });
|
||||||
|
}
|
||||||
|
|
||||||
|
materialsFound.push({ materialId, quantity });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: ExploreCollectResponse = {
|
||||||
|
event: eventResult,
|
||||||
|
foundNothing: false,
|
||||||
|
materialsFound: materialsFound,
|
||||||
|
};
|
||||||
|
return context.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"explore_collect",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const response: ExploreCollectResponse = {
|
|
||||||
event: eventResult,
|
|
||||||
foundNothing: false,
|
|
||||||
materialsFound: materialsFound,
|
|
||||||
};
|
|
||||||
return context.json(response);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export { exploreRouter };
|
export { exploreRouter };
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* @file Frontend logging routes that pipe client-side logs to the telemetry service.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
|
|
||||||
|
const validLevels = new Set([ "debug", "info", "warn" ]);
|
||||||
|
|
||||||
|
const frontendRouter = new Hono();
|
||||||
|
|
||||||
|
frontendRouter.post("/log", async(context) => {
|
||||||
|
try {
|
||||||
|
const body = await context.req.json<{ level: string; message: string }>();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
|
if (!body.level || !body.message || !validLevels.has(body.level)) {
|
||||||
|
return context.json({ error: "level and message are required" }, 400);
|
||||||
|
}
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- validated above */
|
||||||
|
void logger.log(body.level as "debug" | "info" | "warn", `[FE] ${body.message}`);
|
||||||
|
return context.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"frontend_log",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
frontendRouter.post("/error", async(context) => {
|
||||||
|
try {
|
||||||
|
const body = await context.req.json<{ context: string; message: string }>();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
|
if (!body.context || !body.message) {
|
||||||
|
return context.json({ error: "context and message are required" }, 400);
|
||||||
|
}
|
||||||
|
void logger.error(`[FE] ${body.context}`, new Error(body.message));
|
||||||
|
return context.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"frontend_error",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { frontendRouter };
|
||||||
+392
-356
@@ -27,6 +27,7 @@ import { currentSchemaVersion } from "../data/schemaVersion.js";
|
|||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
|
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import { calculateOfflineEarnings } from "../services/offlineProgress.js";
|
import { calculateOfflineEarnings } from "../services/offlineProgress.js";
|
||||||
import {
|
import {
|
||||||
checkAndUnlockTitles,
|
checkAndUnlockTitles,
|
||||||
@@ -681,18 +682,387 @@ const gameRouter = new Hono<HonoEnvironment>();
|
|||||||
gameRouter.use("*", authMiddleware);
|
gameRouter.use("*", authMiddleware);
|
||||||
|
|
||||||
gameRouter.get("/load", async(context) => {
|
gameRouter.get("/load", async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
const [ record, playerRecord ] = await Promise.all([
|
const [ record, playerRecord ] = await Promise.all([
|
||||||
prisma.gameState.findUnique({ where: { discordId } }),
|
prisma.gameState.findUnique({ where: { discordId } }),
|
||||||
prisma.player.findUnique({ where: { discordId } }),
|
prisma.player.findUnique({ where: { discordId } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
// No save found — create a fresh state (handles nuked DB or first-time load race)
|
// 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) {
|
if (!playerRecord) {
|
||||||
return context.json({ error: "No player found" }, 404);
|
return context.json({ error: "No player found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const freshState = initialGameState(
|
const freshState = initialGameState(
|
||||||
{
|
{
|
||||||
avatar: playerRecord.avatar,
|
avatar: playerRecord.avatar,
|
||||||
@@ -713,23 +1083,25 @@ gameRouter.get("/load", async(context) => {
|
|||||||
},
|
},
|
||||||
playerRecord.characterName,
|
playerRecord.characterName,
|
||||||
);
|
);
|
||||||
|
|
||||||
const createdAt = Date.now();
|
const createdAt = Date.now();
|
||||||
await prisma.gameState.create({
|
await prisma.gameState.upsert({
|
||||||
data: {
|
create: {
|
||||||
discordId: discordId,
|
discordId: discordId,
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
state: freshState as object,
|
state: freshState as object,
|
||||||
updatedAt: createdAt,
|
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
|
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 3 -- @preserve */
|
|
||||||
const signature = secret === undefined
|
const signature = secret === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: computeHmac(JSON.stringify(freshState), secret);
|
: computeHmac(JSON.stringify(freshState), secret);
|
||||||
|
|
||||||
return context.json({
|
return context.json({
|
||||||
currentSchemaVersion: currentSchemaVersion,
|
currentSchemaVersion: currentSchemaVersion,
|
||||||
loginBonus: null,
|
loginBonus: null,
|
||||||
@@ -741,351 +1113,15 @@ gameRouter.get("/load", async(context) => {
|
|||||||
signature: signature,
|
signature: signature,
|
||||||
state: freshState,
|
state: freshState,
|
||||||
});
|
});
|
||||||
}
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
const rawState: unknown = record.state;
|
"game_reset",
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
error instanceof Error
|
||||||
const state = rawState as GameState;
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
/*
|
|
||||||
* 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;
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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 };
|
export { gameRouter };
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { gameTitles } from "../data/titles.js";
|
import { gameTitles } from "../data/titles.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
import type { GameState } from "@elysium/types";
|
import type { GameState } from "@elysium/types";
|
||||||
|
|
||||||
@@ -58,70 +59,80 @@ const resolveTitleName = (titleId: string | null): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
leaderboardRouter.get("/", async(context) => {
|
leaderboardRouter.get("/", async(context) => {
|
||||||
const category = context.req.query("category") ?? "totalGold";
|
try {
|
||||||
const limitRaw = Number(context.req.query("limit") ?? "100");
|
const category = context.req.query("category") ?? "totalGold";
|
||||||
const limit = Math.min(Math.max(1, limitRaw), 100);
|
const limitRaw = Number(context.req.query("limit") ?? "100");
|
||||||
|
const limit = Math.min(Math.max(1, limitRaw), 100);
|
||||||
|
|
||||||
if (!validCategories.has(category)) {
|
if (!validCategories.has(category)) {
|
||||||
return context.json({ error: "Invalid category" }, 400);
|
return context.json({ error: "Invalid category" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [ players, gameStates ] = await Promise.all([
|
const [ players, gameStates ] = await Promise.all([
|
||||||
prisma.player.findMany(),
|
prisma.player.findMany(),
|
||||||
gameStateCategories.has(category)
|
gameStateCategories.has(category)
|
||||||
? prisma.gameState.findMany()
|
? prisma.gameState.findMany()
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const stateMap = new Map(
|
const stateMap = new Map(
|
||||||
gameStates.map((gs) => {
|
gameStates.map((gs) => {
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||||
return [ gs.discordId, gs.state as unknown as GameState ];
|
return [ gs.discordId, gs.state as unknown as GameState ];
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const entries = players.
|
const entries = players.
|
||||||
filter((player) => {
|
filter((player) => {
|
||||||
return parseShowOnLeaderboards(player.profileSettings);
|
return parseShowOnLeaderboards(player.profileSettings);
|
||||||
}).
|
}).
|
||||||
map((player) => {
|
map((player) => {
|
||||||
let value = 0;
|
let value = 0;
|
||||||
if (category === "totalGold") {
|
if (category === "totalGold") {
|
||||||
value = player.lifetimeGoldEarned;
|
value = player.lifetimeGoldEarned;
|
||||||
} else if (category === "bossesDefeated") {
|
} else if (category === "bossesDefeated") {
|
||||||
value = player.lifetimeBossesDefeated;
|
value = player.lifetimeBossesDefeated;
|
||||||
} else if (category === "questsCompleted") {
|
} else if (category === "questsCompleted") {
|
||||||
value = player.lifetimeQuestsCompleted;
|
value = player.lifetimeQuestsCompleted;
|
||||||
} else if (category === "achievementsUnlocked") {
|
} else if (category === "achievementsUnlocked") {
|
||||||
value = player.lifetimeAchievementsUnlocked;
|
value = player.lifetimeAchievementsUnlocked;
|
||||||
} else {
|
} else {
|
||||||
const state = stateMap.get(player.discordId);
|
const state = stateMap.get(player.discordId);
|
||||||
if (category === "prestigeCount") {
|
if (category === "prestigeCount") {
|
||||||
value = state?.prestige.count ?? 0;
|
value = state?.prestige.count ?? 0;
|
||||||
} else if (category === "transcendenceCount") {
|
} else if (category === "transcendenceCount") {
|
||||||
value = state?.transcendence?.count ?? 0;
|
value = state?.transcendence?.count ?? 0;
|
||||||
} else if (category === "apotheosisCount") {
|
} else if (category === "apotheosisCount") {
|
||||||
value = state?.apotheosis?.count ?? 0;
|
value = state?.apotheosis?.count ?? 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return {
|
||||||
return {
|
activeTitle: resolveTitleName(player.activeTitle),
|
||||||
activeTitle: resolveTitleName(player.activeTitle),
|
avatar: player.avatar ?? null,
|
||||||
avatar: player.avatar ?? null,
|
characterName: player.characterName,
|
||||||
characterName: player.characterName,
|
discordId: player.discordId,
|
||||||
discordId: player.discordId,
|
username: player.username,
|
||||||
username: player.username,
|
value: value,
|
||||||
value: value,
|
};
|
||||||
};
|
}).
|
||||||
}).
|
sort((a, b) => {
|
||||||
sort((a, b) => {
|
return b.value - a.value;
|
||||||
return b.value - a.value;
|
}).
|
||||||
}).
|
slice(0, limit).
|
||||||
slice(0, limit).
|
map((entry, index) => {
|
||||||
map((entry, index) => {
|
return { ...entry, rank: index + 1 };
|
||||||
return { ...entry, rank: index + 1 };
|
});
|
||||||
});
|
|
||||||
|
|
||||||
return context.json({ category, entries });
|
return context.json({ category, entries });
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"leaderboards",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { leaderboardRouter };
|
export { leaderboardRouter };
|
||||||
|
|||||||
+192
-163
@@ -6,11 +6,13 @@
|
|||||||
*/
|
*/
|
||||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||||
|
/* eslint-disable complexity -- Route handlers have inherent complexity */
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
|
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import {
|
import {
|
||||||
buildPostPrestigeState,
|
buildPostPrestigeState,
|
||||||
computeRunestoneMultipliers,
|
computeRunestoneMultipliers,
|
||||||
@@ -25,190 +27,217 @@ const prestigeRouter = new Hono<HonoEnvironment>();
|
|||||||
prestigeRouter.use("*", authMiddleware);
|
prestigeRouter.use("*", authMiddleware);
|
||||||
|
|
||||||
prestigeRouter.post("/", async(context) => {
|
prestigeRouter.post("/", async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return context.json({ error: "No save found" }, 404);
|
return context.json({ error: "No save found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||||
const state = record.state as unknown as GameState;
|
const state = record.state as unknown as GameState;
|
||||||
|
|
||||||
if (!isEligibleForPrestige(state)) {
|
if (!isEligibleForPrestige(state)) {
|
||||||
return context.json(
|
return context.json(
|
||||||
{
|
{
|
||||||
error: "Not eligible for prestige — collect 1,000,000 total gold first",
|
// eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened
|
||||||
|
error: "Not eligible for prestige — collect 1,000,000 total gold first",
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update daily prestige challenge progress before resetting the run
|
||||||
|
let updatedDailyChallenges = state.dailyChallenges;
|
||||||
|
let challengeCrystals = 0;
|
||||||
|
if (updatedDailyChallenges) {
|
||||||
|
const result = updateChallengeProgress(
|
||||||
|
updatedDailyChallenges,
|
||||||
|
"prestige",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
updatedDailyChallenges = result.updatedChallenges;
|
||||||
|
challengeCrystals = result.crystalsAwarded;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
milestoneRunestones,
|
||||||
|
prestigeData,
|
||||||
|
prestigeState,
|
||||||
|
runestonesEarned,
|
||||||
|
} = buildPostPrestigeState(state, state.player.characterName);
|
||||||
|
|
||||||
|
// Preserve daily challenges across the prestige reset and apply any crystal rewards
|
||||||
|
const finalState: GameState = {
|
||||||
|
...prestigeState,
|
||||||
|
...updatedDailyChallenges === undefined
|
||||||
|
? {}
|
||||||
|
: { dailyChallenges: updatedDailyChallenges },
|
||||||
|
resources: {
|
||||||
|
...prestigeState.resources,
|
||||||
|
crystals: prestigeState.resources.crystals + challengeCrystals,
|
||||||
},
|
},
|
||||||
400,
|
};
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update daily prestige challenge progress before resetting the run
|
// Capture current-run stats to accumulate into lifetime totals before resetting
|
||||||
let updatedDailyChallenges = state.dailyChallenges;
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
let challengeCrystals = 0;
|
/* v8 ignore next 10 -- @preserve */
|
||||||
if (updatedDailyChallenges) {
|
const runBossesDefeated = state.bosses.filter((boss) => {
|
||||||
const result = updateChallengeProgress(
|
return boss.status === "defeated";
|
||||||
updatedDailyChallenges,
|
}).length;
|
||||||
|
const runQuestsCompleted = state.quests.filter((quest) => {
|
||||||
|
return quest.status === "completed";
|
||||||
|
}).length;
|
||||||
|
let runAdventurersRecruited = 0;
|
||||||
|
for (const adventurer of state.adventurers) {
|
||||||
|
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 3 -- @preserve */
|
||||||
|
const runAchievementsUnlocked = state.achievements.filter((achievement) => {
|
||||||
|
return achievement.unlockedAt !== null;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: finalState as object, updatedAt: now },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.player.update({
|
||||||
|
data: {
|
||||||
|
characterName: state.player.characterName,
|
||||||
|
|
||||||
|
lastSavedAt: now,
|
||||||
|
|
||||||
|
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
|
||||||
|
|
||||||
|
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
|
||||||
|
|
||||||
|
lifetimeBossesDefeated: { increment: runBossesDefeated },
|
||||||
|
|
||||||
|
lifetimeClicks: { increment: state.player.totalClicks },
|
||||||
|
|
||||||
|
// Accumulate into lifetime totals — never reset
|
||||||
|
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
|
||||||
|
|
||||||
|
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
|
||||||
|
|
||||||
|
totalClicks: 0,
|
||||||
|
// Reset current-run counters
|
||||||
|
totalGoldEarned: 0,
|
||||||
|
},
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const prestigeCount = prestigeData.count;
|
||||||
|
void logger.metric("prestige", 1, { discordId, prestigeCount });
|
||||||
|
void postMilestoneWebhook(discordId, "prestige", {
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
apotheosis: prestigeState.apotheosis?.count ?? 0,
|
||||||
|
|
||||||
|
prestige: prestigeData.count,
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 2 -- @preserve */
|
||||||
|
transcendence: prestigeState.transcendence?.count ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return context.json({
|
||||||
|
milestoneRunestones: milestoneRunestones,
|
||||||
|
newPrestigeCount: prestigeData.count, // eslint-disable-line unicorn/no-keyword-prefix -- API response field name required by client
|
||||||
|
runestones: runestonesEarned,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
"prestige",
|
"prestige",
|
||||||
1,
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
);
|
);
|
||||||
updatedDailyChallenges = result.updatedChallenges;
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
challengeCrystals = result.crystalsAwarded;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
|
||||||
milestoneRunestones,
|
|
||||||
prestigeData,
|
|
||||||
prestigeState,
|
|
||||||
runestonesEarned,
|
|
||||||
} = buildPostPrestigeState(state, state.player.characterName);
|
|
||||||
|
|
||||||
// Preserve daily challenges across the prestige reset and apply any crystal rewards
|
|
||||||
const finalState: GameState = {
|
|
||||||
...prestigeState,
|
|
||||||
...updatedDailyChallenges === undefined
|
|
||||||
? {}
|
|
||||||
: { dailyChallenges: updatedDailyChallenges },
|
|
||||||
resources: {
|
|
||||||
...prestigeState.resources,
|
|
||||||
crystals: prestigeState.resources.crystals + challengeCrystals,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Capture current-run stats to accumulate into lifetime totals before resetting
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 10 -- @preserve */
|
|
||||||
const runBossesDefeated = state.bosses.filter((boss) => {
|
|
||||||
return boss.status === "defeated";
|
|
||||||
}).length;
|
|
||||||
const runQuestsCompleted = state.quests.filter((quest) => {
|
|
||||||
return quest.status === "completed";
|
|
||||||
}).length;
|
|
||||||
let runAdventurersRecruited = 0;
|
|
||||||
for (const adventurer of state.adventurers) {
|
|
||||||
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 3 -- @preserve */
|
|
||||||
const runAchievementsUnlocked = state.achievements.filter((achievement) => {
|
|
||||||
return achievement.unlockedAt !== null;
|
|
||||||
}).length;
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
await prisma.gameState.update({
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
|
||||||
data: { state: finalState as object, updatedAt: now },
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.player.update({
|
|
||||||
data: {
|
|
||||||
characterName: state.player.characterName,
|
|
||||||
|
|
||||||
lastSavedAt: now,
|
|
||||||
|
|
||||||
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
|
|
||||||
|
|
||||||
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
|
|
||||||
|
|
||||||
lifetimeBossesDefeated: { increment: runBossesDefeated },
|
|
||||||
|
|
||||||
lifetimeClicks: { increment: state.player.totalClicks },
|
|
||||||
|
|
||||||
// Accumulate into lifetime totals — never reset
|
|
||||||
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
|
|
||||||
|
|
||||||
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
|
|
||||||
|
|
||||||
totalClicks: 0,
|
|
||||||
// Reset current-run counters
|
|
||||||
totalGoldEarned: 0,
|
|
||||||
},
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
void postMilestoneWebhook(discordId, "prestige", {
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next -- @preserve */
|
|
||||||
apotheosis: prestigeState.apotheosis?.count ?? 0,
|
|
||||||
|
|
||||||
prestige: prestigeData.count,
|
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 2 -- @preserve */
|
|
||||||
transcendence: prestigeState.transcendence?.count ?? 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
return context.json({
|
|
||||||
milestoneRunestones: milestoneRunestones,
|
|
||||||
newPrestigeCount: prestigeData.count, // eslint-disable-line unicorn/no-keyword-prefix -- API response field name required by client
|
|
||||||
runestones: runestonesEarned,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
prestigeRouter.post("/buy-upgrade", async(context) => {
|
prestigeRouter.post("/buy-upgrade", async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
const body = await context.req.json<BuyPrestigeUpgradeRequest>();
|
const discordId = context.get("discordId");
|
||||||
|
const body = await context.req.json<BuyPrestigeUpgradeRequest>();
|
||||||
|
|
||||||
const { upgradeId } = body;
|
const { upgradeId } = body;
|
||||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
if (!upgradeId) {
|
if (!upgradeId) {
|
||||||
return context.json({ error: "upgradeId is required" }, 400);
|
return context.json({ error: "upgradeId is required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const upgrade = defaultPrestigeUpgrades.find((prestigeUpgrade) => {
|
const upgrade = defaultPrestigeUpgrades.find((prestigeUpgrade) => {
|
||||||
return prestigeUpgrade.id === upgradeId;
|
return prestigeUpgrade.id === upgradeId;
|
||||||
});
|
});
|
||||||
if (!upgrade) {
|
if (!upgrade) {
|
||||||
return context.json({ error: "Unknown prestige upgrade" }, 404);
|
return context.json({ error: "Unknown prestige upgrade" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return context.json({ error: "No save found" }, 404);
|
return context.json({ error: "No save found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||||
const state = record.state as unknown as GameState;
|
const state = record.state as unknown as GameState;
|
||||||
const { purchasedUpgradeIds, runestones } = state.prestige;
|
const { purchasedUpgradeIds, runestones } = state.prestige;
|
||||||
|
|
||||||
if (purchasedUpgradeIds.includes(upgradeId)) {
|
if (purchasedUpgradeIds.includes(upgradeId)) {
|
||||||
return context.json({ error: "Upgrade already purchased" }, 400);
|
return context.json({ error: "Upgrade already purchased" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runestones < upgrade.runestonesCost) {
|
if (runestones < upgrade.runestonesCost) {
|
||||||
return context.json({ error: "Not enough runestones" }, 400);
|
return context.json({ error: "Not enough runestones" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedRunestones = runestones - upgrade.runestonesCost;
|
const updatedRunestones = runestones - upgrade.runestonesCost;
|
||||||
const updatedPurchasedUpgradeIds = [ ...purchasedUpgradeIds, upgradeId ];
|
const updatedPurchasedUpgradeIds = [ ...purchasedUpgradeIds, upgradeId ];
|
||||||
|
|
||||||
const updatedState: GameState = {
|
const updatedState: GameState = {
|
||||||
...state,
|
...state,
|
||||||
prestige: {
|
prestige: {
|
||||||
...state.prestige,
|
...state.prestige,
|
||||||
|
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
|
||||||
|
runestones: updatedRunestones,
|
||||||
|
...computeRunestoneMultipliers(updatedPurchasedUpgradeIds),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: updatedState as object, updatedAt: Date.now() },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const multipliers = computeRunestoneMultipliers(updatedPurchasedUpgradeIds);
|
||||||
|
|
||||||
|
void logger.metric("prestige_upgrade_purchased", 1, {
|
||||||
|
discordId,
|
||||||
|
upgradeId,
|
||||||
|
});
|
||||||
|
return context.json({
|
||||||
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
|
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
|
||||||
runestones: updatedRunestones,
|
runestonesRemaining: updatedRunestones,
|
||||||
...computeRunestoneMultipliers(updatedPurchasedUpgradeIds),
|
...multipliers,
|
||||||
},
|
});
|
||||||
};
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
await prisma.gameState.update({
|
"prestige_buy_upgrade",
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
error instanceof Error
|
||||||
data: { state: updatedState as object, updatedAt: Date.now() },
|
? error
|
||||||
where: { discordId },
|
: new Error(String(error)),
|
||||||
});
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
const multipliers = computeRunestoneMultipliers(updatedPurchasedUpgradeIds);
|
}
|
||||||
|
|
||||||
return context.json({
|
|
||||||
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
|
|
||||||
runestonesRemaining: updatedRunestones,
|
|
||||||
...multipliers,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export { prestigeRouter };
|
export { prestigeRouter };
|
||||||
|
|||||||
+183
-162
@@ -20,6 +20,7 @@ import { Hono } from "hono";
|
|||||||
import { gameTitles } from "../data/titles.js";
|
import { gameTitles } from "../data/titles.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import { parseUnlockedTitles } from "../services/titles.js";
|
import { parseUnlockedTitles } from "../services/titles.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
|
|
||||||
@@ -81,190 +82,210 @@ const resolveTitle = (id: string): { id: string; name: string } => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
profileRouter.get("/:discordId", async(context) => {
|
profileRouter.get("/:discordId", async(context) => {
|
||||||
const { discordId } = context.req.param();
|
try {
|
||||||
|
const { discordId } = context.req.param();
|
||||||
|
|
||||||
const [ player, gameStateRecord ] = await Promise.all([
|
const [ player, gameStateRecord ] = await Promise.all([
|
||||||
prisma.player.findUnique({ where: { discordId } }),
|
prisma.player.findUnique({ where: { discordId } }),
|
||||||
prisma.gameState.findUnique({ where: { discordId } }),
|
prisma.gameState.findUnique({ where: { discordId } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!player) {
|
if (!player) {
|
||||||
return context.json({ error: "Player not found" }, 404);
|
return context.json({ error: "Player not found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||||
const state = gameStateRecord?.state as unknown as GameState | undefined;
|
const state = gameStateRecord?.state as unknown as GameState | undefined;
|
||||||
const prestigeCount = state?.prestige.count ?? 0;
|
const prestigeCount = state?.prestige.count ?? 0;
|
||||||
const transcendenceCount = state?.transcendence?.count ?? 0;
|
const transcendenceCount = state?.transcendence?.count ?? 0;
|
||||||
const apotheosisCount = state?.apotheosis?.count ?? 0;
|
const apotheosisCount = state?.apotheosis?.count ?? 0;
|
||||||
const profileSettings = parseProfileSettings(player.profileSettings);
|
const profileSettings = parseProfileSettings(player.profileSettings);
|
||||||
|
|
||||||
const bossesDefeated
|
const bossesDefeated
|
||||||
= state?.bosses.filter((boss) => {
|
= state?.bosses.filter((boss) => {
|
||||||
return boss.status === "defeated";
|
return boss.status === "defeated";
|
||||||
}).length ?? 0;
|
}).length ?? 0;
|
||||||
const questsCompleted
|
const questsCompleted
|
||||||
= state?.quests.filter((quest) => {
|
= state?.quests.filter((quest) => {
|
||||||
return quest.status === "completed";
|
return quest.status === "completed";
|
||||||
}).length ?? 0;
|
}).length ?? 0;
|
||||||
|
|
||||||
|
let adventurersRecruited = 0;
|
||||||
|
if (state) {
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 3 -- @preserve */
|
||||||
|
for (const adventurer of state.adventurers) {
|
||||||
|
adventurersRecruited = adventurersRecruited + adventurer.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let adventurersRecruited = 0;
|
|
||||||
if (state) {
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next 3 -- @preserve */
|
/* v8 ignore next 3 -- @preserve */
|
||||||
for (const adventurer of state.adventurers) {
|
const achievementsUnlocked = (state?.achievements ?? []).filter((achievement) => {
|
||||||
adventurersRecruited = adventurersRecruited + adventurer.count;
|
return achievement.unlockedAt !== null;
|
||||||
}
|
}).length;
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
const unlockedTitleIds = parseUnlockedTitles(player.unlockedTitles);
|
||||||
/* v8 ignore next 3 -- @preserve */
|
const unlockedTitles = unlockedTitleIds.map((id) => {
|
||||||
const achievementsUnlocked = (state?.achievements ?? []).filter((achievement) => {
|
return resolveTitle(id);
|
||||||
return achievement.unlockedAt !== null;
|
|
||||||
}).length;
|
|
||||||
|
|
||||||
const unlockedTitleIds = parseUnlockedTitles(player.unlockedTitles);
|
|
||||||
const unlockedTitles = unlockedTitleIds.map((id) => {
|
|
||||||
return resolveTitle(id);
|
|
||||||
});
|
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 12 -- @preserve */
|
|
||||||
const equippedItems = (state?.equipment ?? []).
|
|
||||||
filter((item) => {
|
|
||||||
return item.owned && item.equipped;
|
|
||||||
}).
|
|
||||||
map((item) => {
|
|
||||||
return {
|
|
||||||
bonus: item.bonus,
|
|
||||||
name: item.name,
|
|
||||||
rarity: item.rarity,
|
|
||||||
type: item.type,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const completedChapters = state?.story?.completedChapters ?? [];
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 12 -- @preserve */
|
||||||
|
const equippedItems = (state?.equipment ?? []).
|
||||||
|
filter((item) => {
|
||||||
|
return item.owned && item.equipped;
|
||||||
|
}).
|
||||||
|
map((item) => {
|
||||||
|
return {
|
||||||
|
bonus: item.bonus,
|
||||||
|
name: item.name,
|
||||||
|
rarity: item.rarity,
|
||||||
|
type: item.type,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return context.json({
|
const completedChapters = state?.story?.completedChapters ?? [];
|
||||||
achievementsUnlocked: achievementsUnlocked,
|
|
||||||
activeTitle: player.activeTitle,
|
return context.json({
|
||||||
adventurersRecruited: adventurersRecruited,
|
achievementsUnlocked: achievementsUnlocked,
|
||||||
apotheosisCount: apotheosisCount,
|
activeTitle: player.activeTitle,
|
||||||
avatar: player.avatar,
|
adventurersRecruited: adventurersRecruited,
|
||||||
bio: player.bio ?? "",
|
apotheosisCount: apotheosisCount,
|
||||||
bossesDefeated: bossesDefeated,
|
avatar: player.avatar,
|
||||||
characterClass: player.characterClass,
|
bio: player.bio ?? "",
|
||||||
characterName: player.characterName,
|
bossesDefeated: bossesDefeated,
|
||||||
characterRace: player.characterRace ?? "",
|
characterClass: player.characterClass,
|
||||||
completedChapters: completedChapters,
|
characterName: player.characterName,
|
||||||
createdAt: player.createdAt,
|
characterRace: player.characterRace ?? "",
|
||||||
currentRunClicks: state?.player.totalClicks ?? 0,
|
completedChapters: completedChapters,
|
||||||
currentRunGold: state?.player.totalGoldEarned ?? 0,
|
createdAt: player.createdAt,
|
||||||
equippedItems: equippedItems,
|
currentRunClicks: state?.player.totalClicks ?? 0,
|
||||||
guildDescription: player.guildDescription,
|
currentRunGold: state?.player.totalGoldEarned ?? 0,
|
||||||
guildName: player.guildName,
|
equippedItems: equippedItems,
|
||||||
lifetimeAchievementsUnlocked: player.lifetimeAchievementsUnlocked,
|
guildDescription: player.guildDescription,
|
||||||
lifetimeAdventurersRecruited: player.lifetimeAdventurersRecruited,
|
guildName: player.guildName,
|
||||||
lifetimeBossesDefeated: player.lifetimeBossesDefeated,
|
lifetimeAchievementsUnlocked: player.lifetimeAchievementsUnlocked,
|
||||||
lifetimeQuestsCompleted: player.lifetimeQuestsCompleted,
|
lifetimeAdventurersRecruited: player.lifetimeAdventurersRecruited,
|
||||||
prestigeCount: prestigeCount,
|
lifetimeBossesDefeated: player.lifetimeBossesDefeated,
|
||||||
profileSettings: profileSettings,
|
lifetimeQuestsCompleted: player.lifetimeQuestsCompleted,
|
||||||
pronouns: player.pronouns ?? "",
|
prestigeCount: prestigeCount,
|
||||||
questsCompleted: questsCompleted,
|
profileSettings: profileSettings,
|
||||||
totalClicks: player.lifetimeClicks,
|
pronouns: player.pronouns ?? "",
|
||||||
totalGoldEarned: player.lifetimeGoldEarned,
|
questsCompleted: questsCompleted,
|
||||||
transcendenceCount: transcendenceCount,
|
totalClicks: player.lifetimeClicks,
|
||||||
unlockedTitles: unlockedTitles,
|
totalGoldEarned: player.lifetimeGoldEarned,
|
||||||
username: player.username,
|
transcendenceCount: transcendenceCount,
|
||||||
});
|
unlockedTitles: unlockedTitles,
|
||||||
|
username: player.username,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"profile_get",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
profileRouter.put("/", authMiddleware, async(context) => {
|
profileRouter.put("/", authMiddleware, async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
const body = await context.req.json<UpdateProfileRequest>();
|
const discordId = context.get("discordId");
|
||||||
|
const body = await context.req.json<UpdateProfileRequest>();
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
if (!body.characterName) {
|
if (!body.characterName) {
|
||||||
return context.json({ error: "Character name cannot be empty" }, 400);
|
return context.json({ error: "Character name cannot be empty" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const characterName = body.characterName.trim().slice(0, 32);
|
const characterName = body.characterName.trim().slice(0, 32);
|
||||||
|
|
||||||
if (characterName === "") {
|
if (characterName === "") {
|
||||||
return context.json({ error: "Character name cannot be empty" }, 400);
|
return context.json({ error: "Character name cannot be empty" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pronouns = (body.pronouns ?? "").trim().slice(0, 20);
|
const pronouns = (body.pronouns ?? "").trim().slice(0, 20);
|
||||||
const characterRace = (body.characterRace ?? "").trim().slice(0, 32);
|
const characterRace = (body.characterRace ?? "").trim().slice(0, 32);
|
||||||
const characterClass = (body.characterClass ?? "").trim().slice(0, 32);
|
const characterClass = (body.characterClass ?? "").trim().slice(0, 32);
|
||||||
const bio = (body.bio ?? "").trim().slice(0, 200);
|
const bio = (body.bio ?? "").trim().slice(0, 200);
|
||||||
const guildName = (body.guildName ?? "").trim().slice(0, 64);
|
const guildName = (body.guildName ?? "").trim().slice(0, 64);
|
||||||
const guildDescription = (body.guildDescription ?? "").trim().slice(0, 500);
|
const guildDescription = (body.guildDescription ?? "").trim().slice(0, 500);
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next 2 -- @preserve */
|
/* v8 ignore next 2 -- @preserve */
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
|
|
||||||
const parsedNumberFormat = (body.profileSettings.numberFormat ?? "") as string;
|
|
||||||
const numberFormat = validNumberFormats.has(parsedNumberFormat)
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
|
||||||
? (parsedNumberFormat as ProfileSettings["numberFormat"])
|
const parsedNumberFormat = (body.profileSettings.numberFormat ?? "") as string;
|
||||||
: "suffix";
|
const numberFormat = validNumberFormats.has(parsedNumberFormat)
|
||||||
const profileSettings: ProfileSettings = {
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
|
||||||
enableNotifications: body.profileSettings.enableNotifications ?? false,
|
? (parsedNumberFormat as ProfileSettings["numberFormat"])
|
||||||
enableSounds: body.profileSettings.enableSounds ?? false,
|
: "suffix";
|
||||||
numberFormat: numberFormat,
|
const profileSettings: ProfileSettings = {
|
||||||
showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true,
|
enableNotifications: body.profileSettings.enableNotifications ?? false,
|
||||||
showAdventurersRecruited: body.profileSettings.showAdventurersRecruited ?? true,
|
enableSounds: body.profileSettings.enableSounds ?? false,
|
||||||
showApotheosis: body.profileSettings.showApotheosis ?? true,
|
numberFormat: numberFormat,
|
||||||
showBossesDefeated: body.profileSettings.showBossesDefeated ?? true,
|
showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true,
|
||||||
showCurrentClicks: body.profileSettings.showCurrentClicks ?? true,
|
showAdventurersRecruited: body.profileSettings.showAdventurersRecruited ?? true,
|
||||||
showCurrentGold: body.profileSettings.showCurrentGold ?? true,
|
showApotheosis: body.profileSettings.showApotheosis ?? true,
|
||||||
showGuildFounded: body.profileSettings.showGuildFounded ?? true,
|
showBossesDefeated: body.profileSettings.showBossesDefeated ?? true,
|
||||||
showLifetimeAchievementsUnlocked: body.profileSettings.showLifetimeAchievementsUnlocked ?? true,
|
showCurrentClicks: body.profileSettings.showCurrentClicks ?? true,
|
||||||
showLifetimeAdventurersRecruited: body.profileSettings.showLifetimeAdventurersRecruited ?? true,
|
showCurrentGold: body.profileSettings.showCurrentGold ?? true,
|
||||||
showLifetimeBossesDefeated: body.profileSettings.showLifetimeBossesDefeated ?? true,
|
showGuildFounded: body.profileSettings.showGuildFounded ?? true,
|
||||||
showLifetimeQuestsCompleted: body.profileSettings.showLifetimeQuestsCompleted ?? true,
|
showLifetimeAchievementsUnlocked: body.profileSettings.showLifetimeAchievementsUnlocked ?? true,
|
||||||
showOnLeaderboards: body.profileSettings.showOnLeaderboards ?? true,
|
showLifetimeAdventurersRecruited: body.profileSettings.showLifetimeAdventurersRecruited ?? true,
|
||||||
showPrestige: body.profileSettings.showPrestige ?? true,
|
showLifetimeBossesDefeated: body.profileSettings.showLifetimeBossesDefeated ?? true,
|
||||||
showQuestsCompleted: body.profileSettings.showQuestsCompleted ?? true,
|
showLifetimeQuestsCompleted: body.profileSettings.showLifetimeQuestsCompleted ?? true,
|
||||||
showTotalClicks: body.profileSettings.showTotalClicks ?? true,
|
showOnLeaderboards: body.profileSettings.showOnLeaderboards ?? true,
|
||||||
showTotalGold: body.profileSettings.showTotalGold ?? true,
|
showPrestige: body.profileSettings.showPrestige ?? true,
|
||||||
showTranscendence: body.profileSettings.showTranscendence ?? true,
|
showQuestsCompleted: body.profileSettings.showQuestsCompleted ?? true,
|
||||||
};
|
showTotalClicks: body.profileSettings.showTotalClicks ?? true,
|
||||||
|
showTotalGold: body.profileSettings.showTotalGold ?? true,
|
||||||
|
showTranscendence: body.profileSettings.showTranscendence ?? true,
|
||||||
|
};
|
||||||
|
|
||||||
const activeTitle
|
const activeTitle
|
||||||
= typeof body.activeTitle === "string"
|
= typeof body.activeTitle === "string"
|
||||||
? body.activeTitle.slice(0, 64)
|
? body.activeTitle.slice(0, 64)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const updated = await prisma.player.update({
|
const updated = await prisma.player.update({
|
||||||
data: {
|
data: {
|
||||||
bio: bio,
|
bio: bio,
|
||||||
characterClass: characterClass,
|
characterClass: characterClass,
|
||||||
characterName: characterName,
|
characterName: characterName,
|
||||||
characterRace: characterRace,
|
characterRace: characterRace,
|
||||||
guildDescription: guildDescription,
|
guildDescription: guildDescription,
|
||||||
guildName: guildName,
|
guildName: guildName,
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
profileSettings: profileSettings as object,
|
profileSettings: profileSettings as object,
|
||||||
pronouns: pronouns,
|
pronouns: pronouns,
|
||||||
...activeTitle === undefined
|
...activeTitle === undefined
|
||||||
? {}
|
? {}
|
||||||
: { activeTitle },
|
: { activeTitle },
|
||||||
},
|
},
|
||||||
where: { discordId },
|
where: { discordId },
|
||||||
});
|
});
|
||||||
|
|
||||||
return context.json({
|
return context.json({
|
||||||
activeTitle: updated.activeTitle,
|
activeTitle: updated.activeTitle,
|
||||||
bio: updated.bio,
|
bio: updated.bio,
|
||||||
characterClass: updated.characterClass,
|
characterClass: updated.characterClass,
|
||||||
characterName: updated.characterName,
|
characterName: updated.characterName,
|
||||||
characterRace: updated.characterRace,
|
characterRace: updated.characterRace,
|
||||||
guildDescription: updated.guildDescription,
|
guildDescription: updated.guildDescription,
|
||||||
guildName: updated.guildName,
|
guildName: updated.guildName,
|
||||||
profileSettings: profileSettings,
|
profileSettings: profileSettings,
|
||||||
pronouns: updated.pronouns,
|
pronouns: updated.pronouns,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"profile_update",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { profileRouter };
|
export { profileRouter };
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
*/
|
*/
|
||||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||||
|
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js";
|
import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import {
|
import {
|
||||||
buildPostTranscendenceState,
|
buildPostTranscendenceState,
|
||||||
computeTranscendenceMultipliers,
|
computeTranscendenceMultipliers,
|
||||||
@@ -24,168 +26,196 @@ const transcendenceRouter = new Hono<HonoEnvironment>();
|
|||||||
transcendenceRouter.use("*", authMiddleware);
|
transcendenceRouter.use("*", authMiddleware);
|
||||||
|
|
||||||
transcendenceRouter.post("/", async(context) => {
|
transcendenceRouter.post("/", async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return context.json({ error: "No save found" }, 404);
|
return context.json({ error: "No save found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||||
const state = record.state as unknown as GameState;
|
const state = record.state as unknown as GameState;
|
||||||
|
|
||||||
if (!isEligibleForTranscendence(state)) {
|
if (!isEligibleForTranscendence(state)) {
|
||||||
return context.json(
|
return context.json(
|
||||||
{
|
{
|
||||||
error: "Not eligible for transcendence — defeat The Absolute One first",
|
// eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened
|
||||||
|
error: "Not eligible for transcendence — defeat The Absolute One first",
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
echoesEarned,
|
||||||
|
transcendenceData,
|
||||||
|
transcendenceState,
|
||||||
|
} = buildPostTranscendenceState(state, state.player.characterName);
|
||||||
|
|
||||||
|
// Capture current-run stats before the nuclear reset
|
||||||
|
const runBossesDefeated = state.bosses.filter((boss) => {
|
||||||
|
return boss.status === "defeated";
|
||||||
|
}).length;
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 7 -- @preserve */
|
||||||
|
const runQuestsCompleted = state.quests.filter((quest) => {
|
||||||
|
return quest.status === "completed";
|
||||||
|
}).length;
|
||||||
|
let runAdventurersRecruited = 0;
|
||||||
|
for (const adventurer of state.adventurers) {
|
||||||
|
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 3 -- @preserve */
|
||||||
|
const runAchievementsUnlocked = state.achievements.filter((achievement) => {
|
||||||
|
return achievement.unlockedAt !== null;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: transcendenceState as object, updatedAt: now },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.player.update({
|
||||||
|
data: {
|
||||||
|
characterName: state.player.characterName,
|
||||||
|
|
||||||
|
lastSavedAt: now,
|
||||||
|
|
||||||
|
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
|
||||||
|
|
||||||
|
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
|
||||||
|
|
||||||
|
lifetimeBossesDefeated: { increment: runBossesDefeated },
|
||||||
|
|
||||||
|
lifetimeClicks: { increment: state.player.totalClicks },
|
||||||
|
|
||||||
|
// Accumulate into lifetime totals
|
||||||
|
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
|
||||||
|
|
||||||
|
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
|
||||||
|
|
||||||
|
totalClicks: 0,
|
||||||
|
// Reset current-run counters (same as prestige)
|
||||||
|
totalGoldEarned: 0,
|
||||||
},
|
},
|
||||||
400,
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const transcendenceCount = transcendenceData.count;
|
||||||
|
void logger.metric("transcendence", 1, { discordId, transcendenceCount });
|
||||||
|
void postMilestoneWebhook(discordId, "transcendence", {
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
apotheosis: transcendenceState.apotheosis?.count ?? 0,
|
||||||
|
|
||||||
|
prestige: transcendenceState.prestige.count,
|
||||||
|
|
||||||
|
transcendence: transcendenceData.count,
|
||||||
|
});
|
||||||
|
|
||||||
|
return context.json({
|
||||||
|
echoes: echoesEarned,
|
||||||
|
// eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client
|
||||||
|
newTranscendenceCount: transcendenceData.count,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"transcendence",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
);
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
|
||||||
echoesEarned,
|
|
||||||
transcendenceData,
|
|
||||||
transcendenceState,
|
|
||||||
} = buildPostTranscendenceState(state, state.player.characterName);
|
|
||||||
|
|
||||||
// Capture current-run stats before the nuclear reset
|
|
||||||
const runBossesDefeated = state.bosses.filter((boss) => {
|
|
||||||
return boss.status === "defeated";
|
|
||||||
}).length;
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 7 -- @preserve */
|
|
||||||
const runQuestsCompleted = state.quests.filter((quest) => {
|
|
||||||
return quest.status === "completed";
|
|
||||||
}).length;
|
|
||||||
let runAdventurersRecruited = 0;
|
|
||||||
for (const adventurer of state.adventurers) {
|
|
||||||
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 3 -- @preserve */
|
|
||||||
const runAchievementsUnlocked = state.achievements.filter((achievement) => {
|
|
||||||
return achievement.unlockedAt !== null;
|
|
||||||
}).length;
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
await prisma.gameState.update({
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
|
||||||
data: { state: transcendenceState as object, updatedAt: now },
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.player.update({
|
|
||||||
data: {
|
|
||||||
characterName: state.player.characterName,
|
|
||||||
|
|
||||||
lastSavedAt: now,
|
|
||||||
|
|
||||||
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
|
|
||||||
|
|
||||||
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
|
|
||||||
|
|
||||||
lifetimeBossesDefeated: { increment: runBossesDefeated },
|
|
||||||
|
|
||||||
lifetimeClicks: { increment: state.player.totalClicks },
|
|
||||||
|
|
||||||
// Accumulate into lifetime totals
|
|
||||||
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
|
|
||||||
|
|
||||||
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
|
|
||||||
|
|
||||||
totalClicks: 0,
|
|
||||||
// Reset current-run counters (same as prestige)
|
|
||||||
totalGoldEarned: 0,
|
|
||||||
},
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
void postMilestoneWebhook(discordId, "transcendence", {
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next -- @preserve */
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next -- @preserve */
|
|
||||||
apotheosis: transcendenceState.apotheosis?.count ?? 0,
|
|
||||||
|
|
||||||
prestige: transcendenceState.prestige.count,
|
|
||||||
|
|
||||||
transcendence: transcendenceData.count,
|
|
||||||
});
|
|
||||||
|
|
||||||
return context.json({
|
|
||||||
echoes: echoesEarned,
|
|
||||||
// eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client
|
|
||||||
newTranscendenceCount: transcendenceData.count,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
transcendenceRouter.post("/buy-upgrade", async(context) => {
|
transcendenceRouter.post("/buy-upgrade", async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
const body = await context.req.json<BuyEchoUpgradeRequest>();
|
const discordId = context.get("discordId");
|
||||||
|
const body = await context.req.json<BuyEchoUpgradeRequest>();
|
||||||
|
|
||||||
const { upgradeId } = body;
|
const { upgradeId } = body;
|
||||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
if (!upgradeId) {
|
if (!upgradeId) {
|
||||||
return context.json({ error: "upgradeId is required" }, 400);
|
return context.json({ error: "upgradeId is required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const upgrade = defaultTranscendenceUpgrades.find((transcendenceUpgrade) => {
|
// eslint-disable-next-line stylistic/max-len -- Variable name mirrors the data source for clarity
|
||||||
return transcendenceUpgrade.id === upgradeId;
|
const upgrade = defaultTranscendenceUpgrades.find((transcendenceUpgrade) => {
|
||||||
});
|
return transcendenceUpgrade.id === upgradeId;
|
||||||
if (!upgrade) {
|
});
|
||||||
return context.json({ error: "Unknown echo upgrade" }, 404);
|
if (!upgrade) {
|
||||||
}
|
return context.json({ error: "Unknown echo upgrade" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return context.json({ error: "No save found" }, 404);
|
return context.json({ error: "No save found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||||
const state = record.state as unknown as GameState;
|
const state = record.state as unknown as GameState;
|
||||||
|
|
||||||
if (!state.transcendence) {
|
if (!state.transcendence) {
|
||||||
return context.json({ error: "No transcendence data found" }, 400);
|
return context.json({ error: "No transcendence data found" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { purchasedUpgradeIds, echoes } = state.transcendence;
|
const { purchasedUpgradeIds, echoes } = state.transcendence;
|
||||||
|
|
||||||
if (purchasedUpgradeIds.includes(upgradeId)) {
|
if (purchasedUpgradeIds.includes(upgradeId)) {
|
||||||
return context.json({ error: "Upgrade already purchased" }, 400);
|
return context.json({ error: "Upgrade already purchased" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (echoes < upgrade.cost) {
|
if (echoes < upgrade.cost) {
|
||||||
return context.json({ error: "Not enough echoes" }, 400);
|
return context.json({ error: "Not enough echoes" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedEchoes = echoes - upgrade.cost;
|
const updatedEchoes = echoes - upgrade.cost;
|
||||||
const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ];
|
const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ];
|
||||||
const updatedMultipliers
|
const updatedMultipliers
|
||||||
= computeTranscendenceMultipliers(updatedPurchasedIds);
|
= computeTranscendenceMultipliers(updatedPurchasedIds);
|
||||||
|
|
||||||
const updatedState: GameState = {
|
const updatedState: GameState = {
|
||||||
...state,
|
...state,
|
||||||
transcendence: {
|
transcendence: {
|
||||||
...state.transcendence,
|
...state.transcendence,
|
||||||
echoes: updatedEchoes,
|
echoes: updatedEchoes,
|
||||||
|
purchasedUpgradeIds: updatedPurchasedIds,
|
||||||
|
...updatedMultipliers,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: updatedState as object, updatedAt: Date.now() },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
void logger.metric("transcendence_upgrade_purchased", 1, {
|
||||||
|
discordId,
|
||||||
|
upgradeId,
|
||||||
|
});
|
||||||
|
return context.json({
|
||||||
|
echoesRemaining: updatedEchoes,
|
||||||
purchasedUpgradeIds: updatedPurchasedIds,
|
purchasedUpgradeIds: updatedPurchasedIds,
|
||||||
...updatedMultipliers,
|
...updatedMultipliers,
|
||||||
},
|
});
|
||||||
};
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
await prisma.gameState.update({
|
"transcendence_buy_upgrade",
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
error instanceof Error
|
||||||
data: { state: updatedState as object, updatedAt: Date.now() },
|
? error
|
||||||
where: { discordId },
|
: new Error(String(error)),
|
||||||
});
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
return context.json({
|
}
|
||||||
echoesRemaining: updatedEchoes,
|
|
||||||
purchasedUpgradeIds: updatedPurchasedIds,
|
|
||||||
...updatedMultipliers,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export { transcendenceRouter };
|
export { transcendenceRouter };
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */
|
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */
|
||||||
|
import { logger } from "./logger.js";
|
||||||
|
|
||||||
interface DiscordTokenResponse {
|
interface DiscordTokenResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
@@ -50,18 +51,28 @@ const exchangeCode = async(
|
|||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch("https://discord.com/api/v10/oauth2/token", {
|
try {
|
||||||
body: parameters.toString(),
|
const response = await fetch("https://discord.com/api/v10/oauth2/token", {
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
body: parameters.toString(),
|
||||||
method: "POST",
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
});
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Discord token exchange failed: ${response.statusText}`);
|
throw new Error(`Discord token exchange failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordTokenResponse shape */
|
||||||
|
return await (response.json() as Promise<DiscordTokenResponse>);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"discord_exchange_code",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordTokenResponse shape */
|
|
||||||
return await (response.json() as Promise<DiscordTokenResponse>);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,16 +84,26 @@ const exchangeCode = async(
|
|||||||
const fetchDiscordUser = async(
|
const fetchDiscordUser = async(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
): Promise<DiscordUser> => {
|
): Promise<DiscordUser> => {
|
||||||
const response = await fetch("https://discord.com/api/v10/users/@me", {
|
try {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
const response = await fetch("https://discord.com/api/v10/users/@me", {
|
||||||
});
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Discord user fetch failed: ${response.statusText}`);
|
throw new Error(`Discord user fetch failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */
|
||||||
|
return await (response.json() as Promise<DiscordUser>);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"discord_fetch_user",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */
|
|
||||||
return await (response.json() as Promise<DiscordUser>);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* @file Logger service for handling logging.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Logger } from "@nhcarrigan/logger";
|
||||||
|
|
||||||
|
const logger = new Logger("Elysium", process.env.LOG_TOKEN ?? "");
|
||||||
|
|
||||||
|
export { logger };
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case and Pascal-case header names */
|
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case and Pascal-case header names */
|
||||||
|
import { logger } from "./logger.js";
|
||||||
|
|
||||||
const discordApi = "https://discord.com/api/v10";
|
const discordApi = "https://discord.com/api/v10";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,7 +36,13 @@ const grantApotheosisRole = async(discordId: string): Promise<void> => {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"webhook_apotheosis_role",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
// Graceful degradation — role grant failure must not affect the apotheosis
|
// Graceful degradation — role grant failure must not affect the apotheosis
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -81,7 +89,13 @@ const postMilestoneWebhook = async(
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"webhook_milestone",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
// Graceful degradation — webhook failure must not affect the game action
|
// Graceful degradation — webhook failure must not affect the game action
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -55,4 +55,15 @@ describe("authMiddleware", () => {
|
|||||||
}));
|
}));
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 401 when verifyToken throws a non-Error value", async () => {
|
||||||
|
const { app, verifyToken } = await makeApp();
|
||||||
|
vi.mocked(verifyToken).mockImplementationOnce(() => {
|
||||||
|
throw "raw string error";
|
||||||
|
});
|
||||||
|
const res = await app.fetch(new Request("http://localhost/test", {
|
||||||
|
headers: { Authorization: "Bearer bad_token" },
|
||||||
|
}));
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,6 +80,18 @@ describe("apotheosis route", () => {
|
|||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await post();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns apotheosis count on success", async () => {
|
it("returns apotheosis count on success", async () => {
|
||||||
// Need all 15 transcendence upgrades purchased for eligibility
|
// Need all 15 transcendence upgrades purchased for eligibility
|
||||||
const allUpgradeIds = [
|
const allUpgradeIds = [
|
||||||
|
|||||||
@@ -113,5 +113,14 @@ describe("auth route", () => {
|
|||||||
const location = res.headers.get("Location") ?? "";
|
const location = res.headers.get("Location") ?? "";
|
||||||
expect(location).toContain("error=auth_failed");
|
expect(location).toContain("error=auth_failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("redirects with error when callback throws a non-Error value", async () => {
|
||||||
|
const { app, exchangeCode } = await makeApp();
|
||||||
|
exchangeCode.mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await app.fetch(new Request("http://localhost/auth/callback?code=bad_code"));
|
||||||
|
expect(res.status).toBe(302);
|
||||||
|
const location = res.headers.get("Location") ?? "";
|
||||||
|
expect(location).toContain("error=auth_failed");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -293,4 +293,16 @@ describe("boss route", () => {
|
|||||||
const body = await res.json() as { won: boolean };
|
const body = await res.json() as { won: boolean };
|
||||||
expect(body.won).toBe(true);
|
expect(body.won).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await challenge({ bossId: "test_boss" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await challenge({ bossId: "test_boss" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -143,4 +143,16 @@ describe("craft route", () => {
|
|||||||
expect(body.recipeId).toBe(TEST_RECIPE_ID);
|
expect(body.recipeId).toBe(TEST_RECIPE_ID);
|
||||||
expect(body.bonusType).toBe("gold_income");
|
expect(body.bonusType).toBe("gold_income");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -406,5 +406,31 @@ describe("explore route", () => {
|
|||||||
expect(body.materialsFound.some((m) => m.materialId === "verdant_sap")).toBe(true);
|
expect(body.materialsFound.some((m) => m.materialId === "verdant_sap")).toBe(true);
|
||||||
mockRandom.mockRestore();
|
mockRandom.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws on collect", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value on collect", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /start error path", () => {
|
||||||
|
it("returns 500 when the database throws on start", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value on start", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
vi.mock("../../src/services/logger.js", () => ({
|
||||||
|
logger: {
|
||||||
|
log: vi.fn().mockResolvedValue(undefined),
|
||||||
|
error: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("frontend route", () => {
|
||||||
|
let loggerMock: { log: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
const { logger } = await import("../../src/services/logger.js");
|
||||||
|
loggerMock = logger as typeof loggerMock;
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeApp = async () => {
|
||||||
|
const { frontendRouter } = await import("../../src/routes/frontend.js");
|
||||||
|
const app = new Hono();
|
||||||
|
app.route("/frontend", frontendRouter);
|
||||||
|
return app;
|
||||||
|
};
|
||||||
|
|
||||||
|
const postLog = async (body: unknown, contentType = "application/json") => {
|
||||||
|
const app = await makeApp();
|
||||||
|
return app.fetch(new Request("http://localhost/frontend/log", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": contentType },
|
||||||
|
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const postError = async (body: unknown, contentType = "application/json") => {
|
||||||
|
const app = await makeApp();
|
||||||
|
return app.fetch(new Request("http://localhost/frontend/error", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": contentType },
|
||||||
|
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("POST /log", () => {
|
||||||
|
it("returns 200 when level is debug and message is present", async () => {
|
||||||
|
const res = await postLog({ level: "debug", message: "test debug" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 when level is info and message is present", async () => {
|
||||||
|
const res = await postLog({ level: "info", message: "test info" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 when level is warn and message is present", async () => {
|
||||||
|
const res = await postLog({ level: "warn", message: "test warn" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when level is invalid", async () => {
|
||||||
|
const res = await postLog({ level: "error", message: "test" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("level and message are required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when level is missing", async () => {
|
||||||
|
const res = await postLog({ message: "test" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when message is missing", async () => {
|
||||||
|
const res = await postLog({ level: "info" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when request body is invalid JSON", async () => {
|
||||||
|
const res = await postLog("not valid json at all", "application/json");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("Internal server error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 and covers non-Error branch when logger throws a raw value", async () => {
|
||||||
|
loggerMock.log.mockImplementationOnce(() => { throw "raw string error"; });
|
||||||
|
const res = await postLog({ level: "info", message: "test" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("Internal server error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /error", () => {
|
||||||
|
it("returns 200 with valid context and message", async () => {
|
||||||
|
const res = await postError({ context: "SomeComponent", message: "Something went wrong" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when context field is missing", async () => {
|
||||||
|
const res = await postError({ message: "Something went wrong" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("context and message are required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when message field is missing", async () => {
|
||||||
|
const res = await postError({ context: "SomeComponent" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when request body is invalid JSON", async () => {
|
||||||
|
const res = await postError("not valid json at all", "application/json");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("Internal server error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 and covers non-Error branch when logger throws a raw value", async () => {
|
||||||
|
loggerMock.error.mockImplementationOnce(() => { throw "raw string error"; });
|
||||||
|
const res = await postError({ context: "SomeComponent", message: "Something went wrong" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("Internal server error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -420,6 +420,45 @@ describe("game route", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GET /load error path", () => {
|
||||||
|
it("returns 500 when the database throws during load", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during load", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /save error path", () => {
|
||||||
|
const save = (body: Record<string, unknown>) =>
|
||||||
|
app.fetch(new Request("http://localhost/game/save", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}));
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during save", async () => {
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await save({ state });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during save", async () => {
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await save({ state });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("POST /reset", () => {
|
describe("POST /reset", () => {
|
||||||
const reset = () =>
|
const reset = () =>
|
||||||
app.fetch(new Request("http://localhost/game/reset", { method: "POST" }));
|
app.fetch(new Request("http://localhost/game/reset", { method: "POST" }));
|
||||||
@@ -450,5 +489,17 @@ describe("game route", () => {
|
|||||||
const body = await res.json() as { signature: string | undefined };
|
const body = await res.json() as { signature: string | undefined };
|
||||||
expect(typeof body.signature).toBe("string");
|
expect(typeof body.signature).toBe("string");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during reset", async () => {
|
||||||
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await reset();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during reset", async () => {
|
||||||
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await reset();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -152,6 +152,18 @@ describe("leaderboards route", () => {
|
|||||||
expect(typeof body.entries[0]?.activeTitle).toBe("string");
|
expect(typeof body.entries[0]?.activeTitle).toBe("string");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws", async () => {
|
||||||
|
vi.mocked(prisma.player.findMany).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await get();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value", async () => {
|
||||||
|
vi.mocked(prisma.player.findMany).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await get();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults to 0 for game-state categories when state is missing", async () => {
|
it("defaults to 0 for game-state categories when state is missing", async () => {
|
||||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
|
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
|
||||||
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([] as never);
|
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([] as never);
|
||||||
|
|||||||
@@ -93,6 +93,18 @@ describe("prestige route", () => {
|
|||||||
expect(body.runestones).toBeGreaterThanOrEqual(0);
|
expect(body.runestones).toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during prestige", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post("");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during prestige", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await post("");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
it("updates daily challenge progress when dailyChallenges are set", async () => {
|
it("updates daily challenge progress when dailyChallenges are set", async () => {
|
||||||
const state = makeState({
|
const state = makeState({
|
||||||
dailyChallenges: {
|
dailyChallenges: {
|
||||||
@@ -152,5 +164,17 @@ describe("prestige route", () => {
|
|||||||
expect(body.runestonesRemaining).toBe(90); // 100 - 10
|
expect(body.runestonesRemaining).toBe(90); // 100 - 10
|
||||||
expect(body.purchasedUpgradeIds).toContain("income_1");
|
expect(body.purchasedUpgradeIds).toContain("income_1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during buy-upgrade", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post("/buy-upgrade", { upgradeId: "income_1" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during buy-upgrade", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await post("/buy-upgrade", { upgradeId: "income_1" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -182,6 +182,18 @@ describe("profile route", () => {
|
|||||||
expect(unknown?.name).toBe("unknown_title_id");
|
expect(unknown?.name).toBe("unknown_title_id");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during profile get", async () => {
|
||||||
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during profile get", async () => {
|
||||||
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
it("includes completed story chapters in profile response", async () => {
|
it("includes completed story chapters in profile response", async () => {
|
||||||
const state = makeState({
|
const state = makeState({
|
||||||
story: {
|
story: {
|
||||||
@@ -256,5 +268,23 @@ describe("profile route", () => {
|
|||||||
const body = await res.json() as { profileSettings: { numberFormat: string } };
|
const body = await res.json() as { profileSettings: { numberFormat: string } };
|
||||||
expect(body.profileSettings.numberFormat).toBe("suffix");
|
expect(body.profileSettings.numberFormat).toBe("suffix");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during profile update", async () => {
|
||||||
|
vi.mocked(prisma.player.update).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await put({
|
||||||
|
characterName: "NewName",
|
||||||
|
profileSettings: { numberFormat: "suffix" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during profile update", async () => {
|
||||||
|
vi.mocked(prisma.player.update).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await put({
|
||||||
|
characterName: "NewName",
|
||||||
|
profileSettings: { numberFormat: "suffix" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -92,6 +92,18 @@ describe("transcendence route", () => {
|
|||||||
expect(body.newTranscendenceCount).toBe(1);
|
expect(body.newTranscendenceCount).toBe(1);
|
||||||
expect(body.echoes).toBeGreaterThanOrEqual(0);
|
expect(body.echoes).toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during transcendence", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post("");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during transcendence", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await post("");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /buy-upgrade", () => {
|
describe("POST /buy-upgrade", () => {
|
||||||
@@ -149,5 +161,17 @@ describe("transcendence route", () => {
|
|||||||
expect(body.echoesRemaining).toBe(95); // 100 - 5
|
expect(body.echoesRemaining).toBe(95); // 100 - 5
|
||||||
expect(body.purchasedUpgradeIds).toContain("echo_income_1");
|
expect(body.purchasedUpgradeIds).toContain("echo_income_1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during buy-upgrade", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during buy-upgrade", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -86,5 +86,22 @@ describe("discord service", () => {
|
|||||||
expect(result.id).toBe("123");
|
expect(result.id).toBe("123");
|
||||||
expect(result.username).toBe("testuser");
|
expect(result.username).toBe("testuser");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("re-throws when fetch rejects with a non-Error value", async () => {
|
||||||
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
|
const { fetchDiscordUser } = await import("../../src/services/discord.js");
|
||||||
|
await expect(fetchDiscordUser("some_token")).rejects.toBe("raw string error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exchangeCode non-Error throw", () => {
|
||||||
|
it("re-throws when fetch rejects with a non-Error value", async () => {
|
||||||
|
process.env["DISCORD_CLIENT_ID"] = "cid";
|
||||||
|
process.env["DISCORD_CLIENT_SECRET"] = "secret";
|
||||||
|
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
|
||||||
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
|
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||||
|
await expect(exchangeCode("some_code")).rejects.toBe("raw string error");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -69,6 +69,15 @@ describe("webhook service", () => {
|
|||||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||||
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
|
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("swallows non-Error fetch rejections gracefully", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||||
|
process.env["DISCORD_GUILD_ID"] = "g";
|
||||||
|
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r";
|
||||||
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
|
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||||
|
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("postMilestoneWebhook", () => {
|
describe("postMilestoneWebhook", () => {
|
||||||
@@ -119,5 +128,12 @@ describe("webhook service", () => {
|
|||||||
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
||||||
await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined();
|
await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("swallows non-Error fetch rejections gracefully", async () => {
|
||||||
|
process.env["DISCORD_MILESTONE_WEBHOOK"] = "https://discord.com/webhook/abc";
|
||||||
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
|
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
||||||
|
await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,39 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Elysium — Idle RPG</title>
|
<title>Elysium — Idle RPG</title>
|
||||||
<meta name="description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
|
<meta name="description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
|
||||||
|
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:title" content="Elysium — Idle RPG" />
|
||||||
|
<meta property="og:description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://elysium.nhcarrigan.com" />
|
||||||
|
<meta property="og:image" content="https://cdn.nhcarrigan.com/elysium/background.jpg" />
|
||||||
|
<meta property="og:site_name" content="Elysium" />
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Elysium — Idle RPG" />
|
||||||
|
<meta name="twitter:description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
|
||||||
|
<meta name="twitter:image" content="https://cdn.nhcarrigan.com/elysium/background.jpg" />
|
||||||
|
|
||||||
|
<!-- Plausible Analytics -->
|
||||||
|
<script defer data-domain="elysium.nhcarrigan.com" src="https://plausible.io/js/script.js"></script>
|
||||||
|
|
||||||
|
<!-- Tree-Nation -->
|
||||||
|
<script defer src="https://widgets.tree-nation.com/js/widgets/v1/widgets.min.js?v=1.0"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var interval = setInterval(function () {
|
||||||
|
if (typeof TreeNation !== "undefined") {
|
||||||
|
clearInterval(interval);
|
||||||
|
TreeNation.renderAll();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Google Ads -->
|
||||||
|
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-3569924701890974" crossorigin="anonymous"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* @file React Error Boundary for catching unhandled render-time errors.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||||
|
import { logError } from "../utils/logError.js";
|
||||||
|
|
||||||
|
interface ErrorBoundaryProperties {
|
||||||
|
readonly children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catches unhandled render-time errors in the React tree, logs them to the
|
||||||
|
* backend telemetry service, and renders a fallback UI.
|
||||||
|
*/
|
||||||
|
class ErrorBoundary extends Component<
|
||||||
|
ErrorBoundaryProperties,
|
||||||
|
ErrorBoundaryState
|
||||||
|
> {
|
||||||
|
// eslint-disable-next-line jsdoc/require-jsdoc -- React Error Boundary constructor is standard boilerplate
|
||||||
|
public constructor(properties: ErrorBoundaryProperties) {
|
||||||
|
super(properties);
|
||||||
|
this.state = { hasError: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates state so the next render shows the fallback UI.
|
||||||
|
* @returns The updated error boundary state.
|
||||||
|
*/
|
||||||
|
public static getDerivedStateFromError(): ErrorBoundaryState {
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs the error to the backend telemetry service.
|
||||||
|
* @param error - The error that was thrown during render.
|
||||||
|
* @param info - React error info containing the component stack trace.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/class-methods-use-this -- React lifecycle method cannot be static
|
||||||
|
public override componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||||
|
logError("react_error_boundary", error, info.componentStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the fallback UI when an error is caught, otherwise renders children.
|
||||||
|
* @returns The JSX element.
|
||||||
|
*/
|
||||||
|
public override render(): ReactNode {
|
||||||
|
const { hasError } = this.state;
|
||||||
|
const { children } = this.props;
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
return (
|
||||||
|
<div className="error-screen">
|
||||||
|
<p>{"Something went wrong. Please refresh the page."}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ErrorBoundary };
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type PublicProfileResponse,
|
type PublicProfileResponse,
|
||||||
} from "@elysium/types";
|
} from "@elysium/types";
|
||||||
import { type JSX, useEffect, useState } from "react";
|
import { type JSX, useEffect, useState } from "react";
|
||||||
|
import { logError } from "../../utils/logError.js";
|
||||||
|
|
||||||
interface CharacterPageProperties {
|
interface CharacterPageProperties {
|
||||||
readonly discordId: string;
|
readonly discordId: string;
|
||||||
@@ -78,12 +79,16 @@ const CharacterPage = ({ discordId }: CharacterPageProperties): JSX.Element => {
|
|||||||
}, [ discordId ]);
|
}, [ discordId ]);
|
||||||
|
|
||||||
function handleCopy(): void {
|
function handleCopy(): void {
|
||||||
void navigator.clipboard.writeText(window.location.href).then(() => {
|
void navigator.clipboard.writeText(window.location.href).
|
||||||
setCopied(true);
|
then(() => {
|
||||||
setTimeout(() => {
|
setCopied(true);
|
||||||
setCopied(false);
|
setTimeout(() => {
|
||||||
}, 2000);
|
setCopied(false);
|
||||||
});
|
}, 2000);
|
||||||
|
}).
|
||||||
|
catch((error_: unknown) => {
|
||||||
|
logError("clipboard_copy", error_);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error !== null) {
|
if (error !== null) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { type ChangeEvent, type JSX, useEffect, useRef, useState } from "react";
|
import { type ChangeEvent, type JSX, useEffect, useRef, useState } from "react";
|
||||||
import { updateProfile } from "../../api/client.js";
|
import { updateProfile } from "../../api/client.js";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { logError } from "../../utils/logError.js";
|
||||||
|
|
||||||
interface EquippedItem {
|
interface EquippedItem {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -205,12 +206,16 @@ const CharacterSheetPanel = (): JSX.Element => {
|
|||||||
function handleShareClick(): void {
|
function handleShareClick(): void {
|
||||||
const discordId = player?.discordId ?? "";
|
const discordId = player?.discordId ?? "";
|
||||||
const url = `${window.location.origin}/character/${discordId}`;
|
const url = `${window.location.origin}/character/${discordId}`;
|
||||||
void navigator.clipboard.writeText(url).then(() => {
|
void navigator.clipboard.writeText(url).
|
||||||
setCopied(true);
|
then(() => {
|
||||||
setTimeout(() => {
|
setCopied(true);
|
||||||
setCopied(false);
|
setTimeout(() => {
|
||||||
}, 2000);
|
setCopied(false);
|
||||||
});
|
}, 2000);
|
||||||
|
}).
|
||||||
|
catch((error_: unknown) => {
|
||||||
|
logError("clipboard_copy", error_);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNameChange(event: ChangeEvent<HTMLInputElement>): void {
|
function handleNameChange(event: ChangeEvent<HTMLInputElement>): void {
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ const GameLayout = (): JSX.Element => {
|
|||||||
<div className="game-main">
|
<div className="game-main">
|
||||||
<aside className="game-sidebar">
|
<aside className="game-sidebar">
|
||||||
<ClickArea />
|
<ClickArea />
|
||||||
|
<div id="tree-nation-offset-website" />
|
||||||
<p className="game-copyright">{"© NHCarrigan"}</p>
|
<p className="game-copyright">{"© NHCarrigan"}</p>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
/* eslint-disable complexity -- Many conditional stat visibility checks */
|
/* eslint-disable complexity -- Many conditional stat visibility checks */
|
||||||
import { useEffect, useState, type JSX } from "react";
|
import { useEffect, useState, type JSX } from "react";
|
||||||
import { formatNumber } from "../../utils/format.js";
|
import { formatNumber } from "../../utils/format.js";
|
||||||
|
import { logError } from "../../utils/logError.js";
|
||||||
import type { PublicProfileResponse } from "@elysium/types";
|
import type { PublicProfileResponse } from "@elysium/types";
|
||||||
|
|
||||||
interface ProfilePageProperties {
|
interface ProfilePageProperties {
|
||||||
@@ -52,12 +53,16 @@ const ProfilePage = ({ discordId }: ProfilePageProperties): JSX.Element => {
|
|||||||
}, [ discordId ]);
|
}, [ discordId ]);
|
||||||
|
|
||||||
function handleCopy(): void {
|
function handleCopy(): void {
|
||||||
void navigator.clipboard.writeText(window.location.href).then(() => {
|
void navigator.clipboard.writeText(window.location.href).
|
||||||
setCopied(true);
|
then(() => {
|
||||||
setTimeout(() => {
|
setCopied(true);
|
||||||
setCopied(false);
|
setTimeout(() => {
|
||||||
}, 2000);
|
setCopied(false);
|
||||||
});
|
}, 2000);
|
||||||
|
}).
|
||||||
|
catch((error_: unknown) => {
|
||||||
|
logError("clipboard_copy", error_);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error !== null) {
|
if (error !== null) {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import {
|
|||||||
} from "../engine/tick.js";
|
} from "../engine/tick.js";
|
||||||
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
||||||
import { formatNumber as formatNumberUtil } from "../utils/format.js";
|
import { formatNumber as formatNumberUtil } from "../utils/format.js";
|
||||||
|
import { logError } from "../utils/logError.js";
|
||||||
import { sendNotification } from "../utils/notification.js";
|
import { sendNotification } from "../utils/notification.js";
|
||||||
import { playSound } from "../utils/sound.js";
|
import { playSound } from "../utils/sound.js";
|
||||||
|
|
||||||
@@ -1130,6 +1131,8 @@ export const GameProvider = ({
|
|||||||
) {
|
) {
|
||||||
signatureReference.current = null;
|
signatureReference.current = null;
|
||||||
localStorage.removeItem("elysium_save_signature");
|
localStorage.removeItem("elysium_save_signature");
|
||||||
|
} else {
|
||||||
|
logError("auto_save", error_);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1158,7 +1161,8 @@ export const GameProvider = ({
|
|||||||
}
|
}
|
||||||
await reloadReference.current();
|
await reloadReference.current();
|
||||||
}).
|
}).
|
||||||
catch(() => {
|
catch((error_: unknown) => {
|
||||||
|
logError("auto_prestige", error_);
|
||||||
|
|
||||||
/* Silently ignore — will retry next tick */
|
/* Silently ignore — will retry next tick */
|
||||||
}).
|
}).
|
||||||
@@ -1200,7 +1204,8 @@ export const GameProvider = ({
|
|||||||
});
|
});
|
||||||
setBattleResult({ bossName, result });
|
setBattleResult({ bossName, result });
|
||||||
}).
|
}).
|
||||||
catch(() => {
|
catch((error_: unknown) => {
|
||||||
|
logError("auto_boss", error_);
|
||||||
|
|
||||||
/* Silently ignore — will retry next tick */
|
/* Silently ignore — will retry next tick */
|
||||||
}).
|
}).
|
||||||
@@ -1521,35 +1526,46 @@ export const GameProvider = ({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (error_: unknown) {
|
||||||
|
logError("buy_prestige_upgrade", error_);
|
||||||
// Silently ignore — server errors shouldn't crash the UI
|
// Silently ignore — server errors shouldn't crash the UI
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const transcend = useCallback(async() => {
|
const transcend = useCallback(async() => {
|
||||||
const result = await transcendApi({});
|
try {
|
||||||
setShowTranscendenceToast(true);
|
const result = await transcendApi({});
|
||||||
if (enableSoundsReference.current) {
|
setShowTranscendenceToast(true);
|
||||||
playSound("transcendence");
|
if (enableSoundsReference.current) {
|
||||||
|
playSound("transcendence");
|
||||||
|
}
|
||||||
|
if (enableNotificationsReference.current) {
|
||||||
|
sendNotification("🌌 Transcendence!", "You have transcended reality!");
|
||||||
|
}
|
||||||
|
await reload();
|
||||||
|
return result;
|
||||||
|
} catch (error_: unknown) {
|
||||||
|
logError("transcend", error_);
|
||||||
|
throw error_;
|
||||||
}
|
}
|
||||||
if (enableNotificationsReference.current) {
|
|
||||||
sendNotification("🌌 Transcendence!", "You have transcended reality!");
|
|
||||||
}
|
|
||||||
await reload();
|
|
||||||
return result;
|
|
||||||
}, [ reload ]);
|
}, [ reload ]);
|
||||||
|
|
||||||
const apotheosis = useCallback(async() => {
|
const apotheosis = useCallback(async() => {
|
||||||
const result = await achieveApotheosisApi({});
|
try {
|
||||||
setShowApotheosisToast(true);
|
const result = await achieveApotheosisApi({});
|
||||||
if (enableSoundsReference.current) {
|
setShowApotheosisToast(true);
|
||||||
playSound("apotheosis");
|
if (enableSoundsReference.current) {
|
||||||
|
playSound("apotheosis");
|
||||||
|
}
|
||||||
|
if (enableNotificationsReference.current) {
|
||||||
|
sendNotification("✨ Apotheosis!", "You have achieved godhood!");
|
||||||
|
}
|
||||||
|
await reload();
|
||||||
|
return result;
|
||||||
|
} catch (error_: unknown) {
|
||||||
|
logError("apotheosis", error_);
|
||||||
|
throw error_;
|
||||||
}
|
}
|
||||||
if (enableNotificationsReference.current) {
|
|
||||||
sendNotification("✨ Apotheosis!", "You have achieved godhood!");
|
|
||||||
}
|
|
||||||
await reload();
|
|
||||||
return result;
|
|
||||||
}, [ reload ]);
|
}, [ reload ]);
|
||||||
|
|
||||||
const buyEchoUpgrade = useCallback(async(upgradeId: string) => {
|
const buyEchoUpgrade = useCallback(async(upgradeId: string) => {
|
||||||
@@ -1575,114 +1591,125 @@ export const GameProvider = ({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (error_: unknown) {
|
||||||
// Silently ignore server errors
|
logError("buy_echo_upgrade", error_);
|
||||||
|
// Silently ignore — server errors shouldn't crash the UI
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startExploration = useCallback(async(areaId: string) => {
|
const startExploration = useCallback(async(areaId: string) => {
|
||||||
const response = await startExplorationApi({ areaId });
|
try {
|
||||||
const areaData = EXPLORATION_AREAS.find((a) => {
|
const response = await startExplorationApi({ areaId });
|
||||||
return a.id === areaId;
|
const areaData = EXPLORATION_AREAS.find((a) => {
|
||||||
});
|
return a.id === areaId;
|
||||||
if (areaData === undefined) {
|
});
|
||||||
return;
|
if (areaData === undefined) {
|
||||||
}
|
return;
|
||||||
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
|
|
||||||
const startedAt = response.endsAt - areaData.durationSeconds * 1000;
|
|
||||||
setState((previous) => {
|
|
||||||
if (previous?.exploration === undefined) {
|
|
||||||
return previous;
|
|
||||||
}
|
}
|
||||||
return {
|
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
|
||||||
...previous,
|
const startedAt = response.endsAt - areaData.durationSeconds * 1000;
|
||||||
exploration: {
|
|
||||||
...previous.exploration,
|
|
||||||
areas: previous.exploration.areas.map((a) => {
|
|
||||||
return a.id === areaId
|
|
||||||
? { ...a, startedAt: startedAt, status: "in_progress" as const }
|
|
||||||
: a;
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const collectExploration = useCallback(
|
|
||||||
async(areaId: string): Promise<ExploreCollectResponse> => {
|
|
||||||
const result = await collectExplorationApi({ areaId });
|
|
||||||
setState((previous) => {
|
setState((previous) => {
|
||||||
if (previous?.exploration === undefined) {
|
if (previous?.exploration === undefined) {
|
||||||
return previous;
|
return previous;
|
||||||
}
|
}
|
||||||
let materials = [ ...previous.exploration.materials ];
|
|
||||||
|
|
||||||
// Apply material drops from the random loot roll
|
|
||||||
for (const drop of result.materialsFound) {
|
|
||||||
const existing = materials.find((mat) => {
|
|
||||||
return mat.materialId === drop.materialId;
|
|
||||||
});
|
|
||||||
if (existing === undefined) {
|
|
||||||
materials = [
|
|
||||||
...materials,
|
|
||||||
{ materialId: drop.materialId, quantity: drop.quantity },
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
materials = materials.map((mat) => {
|
|
||||||
return mat.materialId === drop.materialId
|
|
||||||
? { ...mat, quantity: mat.quantity + drop.quantity }
|
|
||||||
: mat;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply material from event (if any)
|
|
||||||
const materialGained = result.event?.materialGained;
|
|
||||||
if (materialGained !== null && materialGained !== undefined) {
|
|
||||||
const { materialId, quantity } = materialGained;
|
|
||||||
const existing = materials.find((mat) => {
|
|
||||||
return mat.materialId === materialId;
|
|
||||||
});
|
|
||||||
if (existing === undefined) {
|
|
||||||
materials = [ ...materials, { materialId, quantity } ];
|
|
||||||
} else {
|
|
||||||
materials = materials.map((mat) => {
|
|
||||||
return mat.materialId === materialId
|
|
||||||
? { ...mat, quantity: mat.quantity + quantity }
|
|
||||||
: mat;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...previous,
|
...previous,
|
||||||
exploration: {
|
exploration: {
|
||||||
...previous.exploration,
|
...previous.exploration,
|
||||||
areas: previous.exploration.areas.map((a) => {
|
areas: previous.exploration.areas.map((a) => {
|
||||||
return a.id === areaId
|
return a.id === areaId
|
||||||
? { ...a, completedOnce: true, status: "available" as const }
|
? { ...a, startedAt: startedAt, status: "in_progress" as const }
|
||||||
: a;
|
: a;
|
||||||
}),
|
}),
|
||||||
materials: materials,
|
|
||||||
},
|
|
||||||
player: {
|
|
||||||
...previous.player,
|
|
||||||
totalGoldEarned:
|
|
||||||
previous.player.totalGoldEarned
|
|
||||||
+ Math.max(0, result.event?.goldChange ?? 0),
|
|
||||||
},
|
|
||||||
resources: {
|
|
||||||
...previous.resources,
|
|
||||||
essence:
|
|
||||||
previous.resources.essence + (result.event?.essenceChange ?? 0),
|
|
||||||
gold: Math.max(
|
|
||||||
0,
|
|
||||||
previous.resources.gold + (result.event?.goldChange ?? 0),
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return result;
|
} catch (error_: unknown) {
|
||||||
|
logError("start_exploration", error_);
|
||||||
|
throw error_;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const collectExploration = useCallback(
|
||||||
|
async(areaId: string): Promise<ExploreCollectResponse> => {
|
||||||
|
try {
|
||||||
|
const result = await collectExplorationApi({ areaId });
|
||||||
|
setState((previous) => {
|
||||||
|
if (previous?.exploration === undefined) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
let materials = [ ...previous.exploration.materials ];
|
||||||
|
|
||||||
|
// Apply material drops from the random loot roll
|
||||||
|
for (const drop of result.materialsFound) {
|
||||||
|
const existing = materials.find((mat) => {
|
||||||
|
return mat.materialId === drop.materialId;
|
||||||
|
});
|
||||||
|
if (existing === undefined) {
|
||||||
|
materials = [
|
||||||
|
...materials,
|
||||||
|
{ materialId: drop.materialId, quantity: drop.quantity },
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
materials = materials.map((mat) => {
|
||||||
|
return mat.materialId === drop.materialId
|
||||||
|
? { ...mat, quantity: mat.quantity + drop.quantity }
|
||||||
|
: mat;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply material from event (if any)
|
||||||
|
const materialGained = result.event?.materialGained;
|
||||||
|
if (materialGained !== null && materialGained !== undefined) {
|
||||||
|
const { materialId, quantity } = materialGained;
|
||||||
|
const existing = materials.find((mat) => {
|
||||||
|
return mat.materialId === materialId;
|
||||||
|
});
|
||||||
|
if (existing === undefined) {
|
||||||
|
materials = [ ...materials, { materialId, quantity } ];
|
||||||
|
} else {
|
||||||
|
materials = materials.map((mat) => {
|
||||||
|
return mat.materialId === materialId
|
||||||
|
? { ...mat, quantity: mat.quantity + quantity }
|
||||||
|
: mat;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...previous,
|
||||||
|
exploration: {
|
||||||
|
...previous.exploration,
|
||||||
|
areas: previous.exploration.areas.map((a) => {
|
||||||
|
return a.id === areaId
|
||||||
|
? { ...a, completedOnce: true, status: "available" as const }
|
||||||
|
: a;
|
||||||
|
}),
|
||||||
|
materials: materials,
|
||||||
|
},
|
||||||
|
player: {
|
||||||
|
...previous.player,
|
||||||
|
totalGoldEarned:
|
||||||
|
previous.player.totalGoldEarned
|
||||||
|
+ Math.max(0, result.event?.goldChange ?? 0),
|
||||||
|
},
|
||||||
|
resources: {
|
||||||
|
...previous.resources,
|
||||||
|
essence:
|
||||||
|
previous.resources.essence + (result.event?.essenceChange ?? 0),
|
||||||
|
gold: Math.max(
|
||||||
|
0,
|
||||||
|
previous.resources.gold + (result.event?.goldChange ?? 0),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error_: unknown) {
|
||||||
|
logError("collect_exploration", error_);
|
||||||
|
throw error_;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -1694,35 +1721,40 @@ export const GameProvider = ({
|
|||||||
if (recipe === undefined) {
|
if (recipe === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await craftRecipeApi({ recipeId });
|
try {
|
||||||
setState((previous) => {
|
const result = await craftRecipeApi({ recipeId });
|
||||||
if (previous?.exploration === undefined) {
|
setState((previous) => {
|
||||||
return previous;
|
if (previous?.exploration === undefined) {
|
||||||
}
|
return previous;
|
||||||
let materials = [ ...previous.exploration.materials ];
|
}
|
||||||
for (const request of recipe.requiredMaterials) {
|
let materials = [ ...previous.exploration.materials ];
|
||||||
materials = materials.map((mat) => {
|
for (const request of recipe.requiredMaterials) {
|
||||||
return mat.materialId === request.materialId
|
materials = materials.map((mat) => {
|
||||||
? { ...mat, quantity: mat.quantity - request.quantity }
|
return mat.materialId === request.materialId
|
||||||
: mat;
|
? { ...mat, quantity: mat.quantity - request.quantity }
|
||||||
});
|
: mat;
|
||||||
}
|
});
|
||||||
return {
|
}
|
||||||
...previous,
|
return {
|
||||||
exploration: {
|
...previous,
|
||||||
...previous.exploration,
|
exploration: {
|
||||||
craftedClickMultiplier: result.craftedClickMultiplier,
|
...previous.exploration,
|
||||||
craftedCombatMultiplier: result.craftedCombatMultiplier,
|
craftedClickMultiplier: result.craftedClickMultiplier,
|
||||||
craftedEssenceMultiplier: result.craftedEssenceMultiplier,
|
craftedCombatMultiplier: result.craftedCombatMultiplier,
|
||||||
craftedGoldMultiplier: result.craftedGoldMultiplier,
|
craftedEssenceMultiplier: result.craftedEssenceMultiplier,
|
||||||
craftedRecipeIds: [
|
craftedGoldMultiplier: result.craftedGoldMultiplier,
|
||||||
...previous.exploration.craftedRecipeIds,
|
craftedRecipeIds: [
|
||||||
recipeId,
|
...previous.exploration.craftedRecipeIds,
|
||||||
],
|
recipeId,
|
||||||
materials: materials,
|
],
|
||||||
},
|
materials: materials,
|
||||||
};
|
},
|
||||||
});
|
};
|
||||||
|
});
|
||||||
|
} catch (error_: unknown) {
|
||||||
|
logError("craft_recipe", error_);
|
||||||
|
throw error_;
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleAutoPrestige = useCallback(() => {
|
const toggleAutoPrestige = useCallback(() => {
|
||||||
@@ -1798,7 +1830,8 @@ export const GameProvider = ({
|
|||||||
return applyBossResult(previous, bossId, result);
|
return applyBossResult(previous, bossId, result);
|
||||||
});
|
});
|
||||||
setBattleResult({ bossName: boss.name, result: result });
|
setBattleResult({ bossName: boss.name, result: result });
|
||||||
} catch {
|
} catch (error_: unknown) {
|
||||||
|
logError("challenge_boss", error_);
|
||||||
// Silently ignore — server errors shouldn't crash the UI
|
// Silently ignore — server errors shouldn't crash the UI
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -8,8 +8,12 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { App } from "./app.js";
|
import { App } from "./app.js";
|
||||||
|
import { ErrorBoundary } from "./components/errorBoundary.js";
|
||||||
|
import { initialiseFrontendLogger } from "./utils/logger.js";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
|
initialiseFrontendLogger();
|
||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
|
|
||||||
if (!rootElement) {
|
if (!rootElement) {
|
||||||
@@ -18,6 +22,8 @@ if (!rootElement) {
|
|||||||
|
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<ErrorBoundary>
|
||||||
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
--radius-lg: 12px;
|
--radius-lg: 12px;
|
||||||
--font: "Segoe UI", system-ui, sans-serif;
|
--font: "Segoe UI", system-ui, sans-serif;
|
||||||
|
--resource-bar-height: 3.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -136,6 +137,10 @@ body::before {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
position: sticky;
|
||||||
|
top: var(--resource-bar-height);
|
||||||
|
height: calc(100vh - var(--resource-bar-height));
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-content {
|
.game-content {
|
||||||
@@ -3181,8 +3186,11 @@ body::before {
|
|||||||
border-right: none;
|
border-right: none;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
height: auto;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
|
position: static;
|
||||||
|
top: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* @file Frontend error logging utility that forwards errors to the backend telemetry service.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
/* eslint-disable no-console -- Errors are forwarded to backend via the overridden console.error */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs an error to the backend telemetry service.
|
||||||
|
* Accepts the same arguments as console.error — conventionally a context string
|
||||||
|
* followed by the error value.
|
||||||
|
* @param logArguments - The values to log, forwarded directly to console.error.
|
||||||
|
*/
|
||||||
|
const logError = (...logArguments: Array<unknown>): void => {
|
||||||
|
console.error(...logArguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { logError };
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* @file Frontend logger that forwards console output to the backend telemetry service.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
/* eslint-disable no-console -- This file intentionally overrides console methods */
|
||||||
|
|
||||||
|
type Level = "debug" | "info" | "warn";
|
||||||
|
|
||||||
|
const post = (path: string, body: object): void => {
|
||||||
|
void fetch(path, {
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header names use kebab-case
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
method: "POST",
|
||||||
|
}).catch(() => {
|
||||||
|
// Intentionally swallowed — we cannot log logger failures without infinite recursion.
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overrides the global console.log and console.error methods so that all
|
||||||
|
* frontend log output is forwarded to the backend telemetry endpoints.
|
||||||
|
* Must be called once at application startup before any other code runs.
|
||||||
|
*/
|
||||||
|
const initialiseFrontendLogger = (): void => {
|
||||||
|
const originalLog = console.log.bind(console);
|
||||||
|
const originalError = console.error.bind(console);
|
||||||
|
|
||||||
|
console.log = (...consoleArguments: Array<unknown>): void => {
|
||||||
|
originalLog(...consoleArguments);
|
||||||
|
const level: Level = "info";
|
||||||
|
const message = consoleArguments.map((argument) => {
|
||||||
|
return typeof argument === "string"
|
||||||
|
? argument
|
||||||
|
: JSON.stringify(argument);
|
||||||
|
}).join(" ");
|
||||||
|
post("/api/fe/log", { level, message });
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error = (...consoleArguments: Array<unknown>): void => {
|
||||||
|
originalError(...consoleArguments);
|
||||||
|
const message = consoleArguments.map((argument) => {
|
||||||
|
if (argument instanceof Error) {
|
||||||
|
return `${argument.message}\n${argument.stack ?? ""}`;
|
||||||
|
}
|
||||||
|
return typeof argument === "string"
|
||||||
|
? argument
|
||||||
|
: JSON.stringify(argument);
|
||||||
|
}).join(" ");
|
||||||
|
const context = "console.error";
|
||||||
|
post("/api/fe/error", { context, message });
|
||||||
|
};
|
||||||
|
|
||||||
|
console.warn = (...consoleArguments: Array<unknown>): void => {
|
||||||
|
originalLog(...consoleArguments);
|
||||||
|
const level: Level = "warn";
|
||||||
|
const message = consoleArguments.map((argument) => {
|
||||||
|
return typeof argument === "string"
|
||||||
|
? argument
|
||||||
|
: JSON.stringify(argument);
|
||||||
|
}).join(" ");
|
||||||
|
post("/api/fe/log", { level, message });
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export { initialiseFrontendLogger };
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
import { logError } from "./logError.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Requests browser notification permission from the user.
|
* Requests browser notification permission from the user.
|
||||||
@@ -38,7 +39,8 @@ const sendNotification = (title: string, body: string): void => {
|
|||||||
try {
|
try {
|
||||||
// eslint-disable-next-line no-new -- Notification constructor has side effects
|
// eslint-disable-next-line no-new -- Notification constructor has side effects
|
||||||
new Notification(title, { body: body, icon: "/favicon.ico" });
|
new Notification(title, { body: body, icon: "/favicon.ico" });
|
||||||
} catch {
|
} catch (error_: unknown) {
|
||||||
|
logError("send_notification", error_);
|
||||||
// Silently ignore — notifications may fail silently
|
// Silently ignore — notifications may fail silently
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
import { logError } from "./logError.js";
|
||||||
|
|
||||||
type SoundEvent =
|
type SoundEvent =
|
||||||
| "achievement"
|
| "achievement"
|
||||||
@@ -101,7 +102,8 @@ const playSound = (event: SoundEvent): void => {
|
|||||||
oscillator.start(startTime);
|
oscillator.start(startTime);
|
||||||
oscillator.stop(endTime);
|
oscillator.stop(endTime);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error_: unknown) {
|
||||||
|
logError("play_sound", error_);
|
||||||
// Silently ignore — audio may not be available in all environments
|
// Silently ignore — audio may not be available in all environments
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Generated
+8
@@ -23,6 +23,9 @@ importers:
|
|||||||
'@hono/node-server':
|
'@hono/node-server':
|
||||||
specifier: 1.13.7
|
specifier: 1.13.7
|
||||||
version: 1.13.7(hono@4.7.4)
|
version: 1.13.7(hono@4.7.4)
|
||||||
|
'@nhcarrigan/logger':
|
||||||
|
specifier: 1.1.1
|
||||||
|
version: 1.1.1
|
||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: 6.5.0
|
specifier: 6.5.0
|
||||||
version: 6.5.0(prisma@6.5.0(typescript@5.8.2))(typescript@5.8.2)
|
version: 6.5.0(prisma@6.5.0(typescript@5.8.2))(typescript@5.8.2)
|
||||||
@@ -689,6 +692,9 @@ packages:
|
|||||||
typescript: '>=5'
|
typescript: '>=5'
|
||||||
vitest: '>=2'
|
vitest: '>=2'
|
||||||
|
|
||||||
|
'@nhcarrigan/logger@1.1.1':
|
||||||
|
resolution: {integrity: sha512-P6OEQFHDtf6psybYGljuCxkSW6DLQCsx1aZZ3w4YKBXHBFjDbhuvpM9K1kPhVN48hakitx2WPLEoIFr6YZELYw==}
|
||||||
|
|
||||||
'@nhcarrigan/typescript-config@4.0.0':
|
'@nhcarrigan/typescript-config@4.0.0':
|
||||||
resolution: {integrity: sha512-969HVha7A/Sg77fuMwOm6p14a+7C5iE6g55OD71srqwKIgksQl+Ex/hAI/pyzTQFDQ/FBJbpnHlR4Ov25QV/rw==}
|
resolution: {integrity: sha512-969HVha7A/Sg77fuMwOm6p14a+7C5iE6g55OD71srqwKIgksQl+Ex/hAI/pyzTQFDQ/FBJbpnHlR4Ov25QV/rw==}
|
||||||
engines: {node: '20', pnpm: '9'}
|
engines: {node: '20', pnpm: '9'}
|
||||||
@@ -3490,6 +3496,8 @@ snapshots:
|
|||||||
- eslint-import-resolver-webpack
|
- eslint-import-resolver-webpack
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@nhcarrigan/logger@1.1.1': {}
|
||||||
|
|
||||||
'@nhcarrigan/typescript-config@4.0.0(typescript@5.8.2)':
|
'@nhcarrigan/typescript-config@4.0.0(typescript@5.8.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
typescript: 5.8.2
|
typescript: 5.8.2
|
||||||
|
|||||||
Reference in New Issue
Block a user