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": {
|
||||
"@elysium/types": "workspace:*",
|
||||
"@hono/node-server": "1.13.7",
|
||||
"@nhcarrigan/logger": "1.1.1",
|
||||
"@prisma/client": "6.5.0",
|
||||
"hono": "4.7.4",
|
||||
"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_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token"
|
||||
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 { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { logger } from "hono/logger";
|
||||
import { logger as honoLogger } from "hono/logger";
|
||||
import { aboutRouter } from "./routes/about.js";
|
||||
import { apotheosisRouter } from "./routes/apotheosis.js";
|
||||
import { authRouter } from "./routes/auth.js";
|
||||
import { bossRouter } from "./routes/boss.js";
|
||||
import { craftRouter } from "./routes/craft.js";
|
||||
import { exploreRouter } from "./routes/explore.js";
|
||||
import { frontendRouter } from "./routes/frontend.js";
|
||||
import { gameRouter } from "./routes/game.js";
|
||||
import { leaderboardRouter } from "./routes/leaderboards.js";
|
||||
import { prestigeRouter } from "./routes/prestige.js";
|
||||
import { profileRouter } from "./routes/profile.js";
|
||||
import { transcendenceRouter } from "./routes/transcendence.js";
|
||||
import { logger } from "./services/logger.js";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.use("*", logger());
|
||||
app.use("*", honoLogger());
|
||||
app.use(
|
||||
"*",
|
||||
cors({
|
||||
@@ -33,6 +35,7 @@ app.use(
|
||||
);
|
||||
|
||||
app.route("/about", aboutRouter);
|
||||
app.route("/fe", frontendRouter);
|
||||
app.route("/auth", authRouter);
|
||||
app.route("/game", gameRouter);
|
||||
app.route("/boss", bossRouter);
|
||||
@@ -48,8 +51,27 @@ app.get("/health", (context) => {
|
||||
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);
|
||||
|
||||
serve({ fetch: app.fetch, port: port }, () => {
|
||||
process.stdout.write(`Elysium API running on port ${String(port)}\n`);
|
||||
});
|
||||
try {
|
||||
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 { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
|
||||
@@ -33,7 +34,13 @@ export const authMiddleware: MiddlewareHandler<HonoEnvironment> = async(
|
||||
try {
|
||||
const payload = verifyToken(token);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
/* eslint-disable stylistic/max-len -- URL cannot be shortened */
|
||||
/* eslint-disable require-atomic-updates -- Simple cache; race condition is acceptable */
|
||||
import { Hono } from "hono";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { AboutResponse, GiteaRelease } from "@elysium/types";
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
@@ -46,12 +47,24 @@ const fetchReleases = async(): Promise<Array<GiteaRelease>> => {
|
||||
const aboutRouter = new Hono();
|
||||
|
||||
aboutRouter.get("/", async(context) => {
|
||||
const releases = await fetchReleases();
|
||||
const body: AboutResponse = {
|
||||
apiVersion,
|
||||
releases,
|
||||
};
|
||||
return context.json(body);
|
||||
try {
|
||||
const releases = await fetchReleases();
|
||||
const body: AboutResponse = {
|
||||
apiVersion,
|
||||
releases,
|
||||
};
|
||||
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 };
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* 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 */
|
||||
import { Hono } from "hono";
|
||||
import { prisma } from "../db/client.js";
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
buildPostApotheosisState,
|
||||
isEligibleForApotheosis,
|
||||
} from "../services/apotheosis.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import {
|
||||
grantApotheosisRole,
|
||||
postMilestoneWebhook,
|
||||
@@ -25,94 +28,106 @@ const apotheosisRouter = new Hono<HonoEnvironment>();
|
||||
apotheosisRouter.use("*", authMiddleware);
|
||||
|
||||
apotheosisRouter.post("/", async(context) => {
|
||||
const discordId = context.get("discordId");
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 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;
|
||||
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 (!isEligibleForApotheosis(state)) {
|
||||
return context.json(
|
||||
{
|
||||
error:
|
||||
"Not eligible for Apotheosis — purchase all Transcendence upgrades first",
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (!isEligibleForApotheosis(state)) {
|
||||
return context.json(
|
||||
{
|
||||
error:
|
||||
"Not eligible for Apotheosis — purchase all Transcendence upgrades first",
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
// Capture current-run stats before the nuclear reset
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
transcendence: updatedState.transcendence?.count ?? 0,
|
||||
});
|
||||
/* 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);
|
||||
|
||||
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 };
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
fetchDiscordUser,
|
||||
} from "../services/discord.js";
|
||||
import { signToken } from "../services/jwt.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { Player } from "@elysium/types";
|
||||
|
||||
const authRouter = new Hono();
|
||||
@@ -92,6 +93,8 @@ authRouter.get("/callback", async(context) => {
|
||||
});
|
||||
|
||||
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
|
||||
/* v8 ignore next -- @preserve */
|
||||
@@ -111,6 +114,8 @@ authRouter.get("/callback", async(context) => {
|
||||
});
|
||||
|
||||
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
|
||||
/* v8 ignore next -- @preserve */
|
||||
@@ -118,7 +123,13 @@ authRouter.get("/callback", async(context) => {
|
||||
return context.redirect(
|
||||
`${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
|
||||
/* v8 ignore next -- @preserve */
|
||||
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 { authMiddleware } from "../middleware/auth.js";
|
||||
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
|
||||
const bossRouter = new Hono<HonoEnvironment>();
|
||||
@@ -121,254 +122,267 @@ const calculatePartyStats = (
|
||||
};
|
||||
|
||||
bossRouter.post("/challenge", async(context) => {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<{ bossId: string }>();
|
||||
try {
|
||||
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
|
||||
if (!body.bossId) {
|
||||
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;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!body.bossId) {
|
||||
return context.json({ error: "Invalid request body" }, 400);
|
||||
}
|
||||
|
||||
// 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 record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
|
||||
const slotAlreadyEquipped = state.equipment.some((item) => {
|
||||
return item.type === equipment.type && item.equipped;
|
||||
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 (!slotAlreadyEquipped) {
|
||||
equipment.equipped = true;
|
||||
if (upgrade) {
|
||||
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 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;
|
||||
const now = Date.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 capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const bountyRunestones = staticBoss?.bountyRunestones ?? 0;
|
||||
state.prestige.runestones = state.prestige.runestones + bountyRunestones;
|
||||
const { bossId } = body;
|
||||
void logger.metric("boss_challenge", 1, { bossId, discordId, won });
|
||||
|
||||
rewards = {
|
||||
bountyRunestones: bountyRunestones,
|
||||
crystals: boss.crystalReward,
|
||||
equipmentIds: boss.equipmentRewards,
|
||||
essence: boss.essenceReward,
|
||||
gold: boss.goldReward,
|
||||
upgradeIds: boss.upgradeRewards,
|
||||
const bossMaxHp = boss.maxHp;
|
||||
const bossNewHp = bossUpdatedHp;
|
||||
const response: BossChallengeResponse = {
|
||||
bossDPS,
|
||||
bossHpAtBattleEnd,
|
||||
bossHpBefore,
|
||||
bossMaxHp,
|
||||
bossNewHp,
|
||||
partyDPS,
|
||||
partyHpRemaining,
|
||||
partyMaxHp,
|
||||
won,
|
||||
};
|
||||
} 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 });
|
||||
}
|
||||
if (rewards !== undefined) {
|
||||
response.rewards = rewards;
|
||||
}
|
||||
if (casualties !== undefined) {
|
||||
response.casualties = casualties;
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.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 },
|
||||
});
|
||||
|
||||
const bossMaxHp = boss.maxHp;
|
||||
const bossNewHp = bossUpdatedHp;
|
||||
const response: BossChallengeResponse = {
|
||||
bossDPS,
|
||||
bossHpAtBattleEnd,
|
||||
bossHpBefore,
|
||||
bossMaxHp,
|
||||
bossNewHp,
|
||||
partyDPS,
|
||||
partyHpRemaining,
|
||||
partyMaxHp,
|
||||
won,
|
||||
};
|
||||
if (rewards !== undefined) {
|
||||
response.rewards = rewards;
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"boss_challenge",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
if (casualties !== undefined) {
|
||||
response.casualties = casualties;
|
||||
}
|
||||
|
||||
return context.json(response);
|
||||
});
|
||||
|
||||
export { bossRouter };
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Hono } from "hono";
|
||||
import { defaultRecipes } from "../data/recipes.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type {
|
||||
CraftRecipeRequest,
|
||||
@@ -63,94 +64,106 @@ const recomputeCraftedMultipliers = (
|
||||
};
|
||||
|
||||
craftRouter.post("/", async(context) => {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<CraftRecipeRequest>();
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<CraftRecipeRequest>();
|
||||
|
||||
const { recipeId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!recipeId) {
|
||||
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,
|
||||
);
|
||||
const { recipeId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!recipeId) {
|
||||
return context.json({ error: "recipeId is required" }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Deduct materials
|
||||
for (const requirement of recipe.requiredMaterials) {
|
||||
const material = state.exploration.materials.find((m) => {
|
||||
return m.materialId === requirement.materialId;
|
||||
const recipe = defaultRecipes.find((r) => {
|
||||
return r.id === recipeId;
|
||||
});
|
||||
if (material) {
|
||||
material.quantity = material.quantity - requirement.quantity;
|
||||
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
|
||||
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 };
|
||||
|
||||
+280
-254
@@ -12,6 +12,7 @@ import { defaultExplorations } from "../data/explorations.js";
|
||||
import { initialExploration } from "../data/initialState.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type {
|
||||
ExploreCollectEventResult,
|
||||
@@ -49,280 +50,233 @@ const pickNothingMessage = (): string => {
|
||||
};
|
||||
|
||||
exploreRouter.post("/start", async(context) => {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<ExploreStartRequest>();
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<ExploreStartRequest>();
|
||||
|
||||
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 { 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 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 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 rawState: unknown = record.state;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||
const state = rawState as GameState;
|
||||
|
||||
// Backfill exploration state for old saves that predate this feature
|
||||
if (!state.exploration) {
|
||||
state.exploration = structuredClone(initialExploration);
|
||||
// Unlock areas for zones already unlocked in this save
|
||||
for (const area of state.exploration.areas) {
|
||||
const areaData = defaultExplorations.find((areaItem) => {
|
||||
return areaItem.id === area.id;
|
||||
});
|
||||
// Backfill exploration state for old saves that predate this feature
|
||||
if (!state.exploration) {
|
||||
state.exploration = structuredClone(initialExploration);
|
||||
// Unlock areas for zones already unlocked in this save
|
||||
for (const area of state.exploration.areas) {
|
||||
const areaData = defaultExplorations.find((areaItem) => {
|
||||
return areaItem.id === area.id;
|
||||
});
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
if (!areaData) {
|
||||
continue;
|
||||
}
|
||||
const zone = state.zones.find((z) => {
|
||||
return z.id === areaData.zoneId;
|
||||
});
|
||||
if (zone?.status === "unlocked") {
|
||||
area.status = "available";
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
if (!areaData) {
|
||||
continue;
|
||||
}
|
||||
const zone = state.zones.find((z) => {
|
||||
return z.id === areaData.zoneId;
|
||||
});
|
||||
if (zone?.status === "unlocked") {
|
||||
area.status = "available";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const zone = state.zones.find((z) => {
|
||||
return z.id === explorationArea.zoneId;
|
||||
});
|
||||
if (!zone || zone.status !== "unlocked") {
|
||||
return context.json({ error: "Zone is not unlocked" }, 400);
|
||||
}
|
||||
const zone = state.zones.find((z) => {
|
||||
return z.id === explorationArea.zoneId;
|
||||
});
|
||||
if (!zone || zone.status !== "unlocked") {
|
||||
return context.json({ error: "Zone is not unlocked" }, 400);
|
||||
}
|
||||
|
||||
const area = state.exploration.areas.find((a) => {
|
||||
return a.id === areaId;
|
||||
});
|
||||
if (!area) {
|
||||
return context.json({ error: "Exploration area not found in state" }, 404);
|
||||
}
|
||||
const area = state.exploration.areas.find((a) => {
|
||||
return a.id === areaId;
|
||||
});
|
||||
if (!area) {
|
||||
return context.json(
|
||||
{ error: "Exploration area not found in state" },
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
const anyInProgress = state.exploration.areas.some((a) => {
|
||||
return a.status === "in_progress";
|
||||
});
|
||||
if (anyInProgress) {
|
||||
return context.json(
|
||||
{ error: "An exploration is already in progress" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
const anyInProgress = state.exploration.areas.some((a) => {
|
||||
return a.status === "in_progress";
|
||||
});
|
||||
if (anyInProgress) {
|
||||
return context.json(
|
||||
{ error: "An exploration is already in progress" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (area.status === "locked") {
|
||||
return context.json({ error: "Exploration area is locked" }, 400);
|
||||
}
|
||||
if (area.status === "locked") {
|
||||
return context.json({ error: "Exploration area is locked" }, 400);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
area.status = "in_progress";
|
||||
area.startedAt = now;
|
||||
const now = Date.now();
|
||||
area.status = "in_progress";
|
||||
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({
|
||||
/* 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: null,
|
||||
foundNothing: true,
|
||||
materialsFound: [],
|
||||
nothingMessage: pickNothingMessage(),
|
||||
// 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);
|
||||
} 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
|
||||
const eventIndex = Math.floor(Math.random() * explorationArea.events.length);
|
||||
const event = explorationArea.events[eventIndex];
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
if (!event) {
|
||||
return context.json({ error: "No events available" }, 500);
|
||||
}
|
||||
exploreRouter.post("/collect", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<ExploreCollectRequest>();
|
||||
|
||||
// Apply event effects and build the result summary
|
||||
let goldChange = 0;
|
||||
let essenceChange = 0;
|
||||
let materialGained: { materialId: string; quantity: number } | null = null;
|
||||
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);
|
||||
}
|
||||
|
||||
if (event.effect.type === "gold_gain") {
|
||||
// Gold gain — amount may be undefined in edge cases
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const amount = event.effect.amount ?? 0;
|
||||
state.resources.gold = state.resources.gold + amount;
|
||||
state.player.totalGoldEarned = state.player.totalGoldEarned + amount;
|
||||
goldChange = amount;
|
||||
} else if (event.effect.type === "gold_loss") {
|
||||
// Gold loss — amount may be undefined in edge cases
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const amount = Math.min(state.resources.gold, event.effect.amount ?? 0);
|
||||
state.resources.gold = state.resources.gold - amount;
|
||||
goldChange = -amount;
|
||||
} else if (event.effect.type === "essence_gain") {
|
||||
// Essence gain — amount may be undefined in edge cases
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const amount = event.effect.amount ?? 0;
|
||||
state.resources.essence = state.resources.essence + amount;
|
||||
essenceChange = amount;
|
||||
} else if (event.effect.type === "material_gain") {
|
||||
const { materialId } = event.effect;
|
||||
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 quantity = event.effect.quantity ?? 1;
|
||||
if (materialId !== undefined && materialId !== "") {
|
||||
const existing = state.exploration.materials.find((m) => {
|
||||
return m.materialId === materialId;
|
||||
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({
|
||||
/* 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;
|
||||
} else {
|
||||
state.exploration.materials.push({ materialId, quantity });
|
||||
}
|
||||
materialGained = { materialId, quantity };
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 13 -- @preserve */
|
||||
|
||||
const response: ExploreCollectResponse = {
|
||||
event: null,
|
||||
foundNothing: true,
|
||||
materialsFound: [],
|
||||
nothingMessage: pickNothingMessage(),
|
||||
};
|
||||
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
|
||||
/* 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);
|
||||
}
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
if (!event) {
|
||||
return context.json({ error: "No events available" }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
// Apply event effects and build the result summary
|
||||
let goldChange = 0;
|
||||
let essenceChange = 0;
|
||||
let materialGained: { materialId: string; quantity: number } | null = null;
|
||||
|
||||
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;
|
||||
if (event.effect.type === "gold_gain") {
|
||||
// Gold gain — amount may be undefined in edge cases
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const amount = event.effect.amount ?? 0;
|
||||
state.resources.gold = state.resources.gold + amount;
|
||||
state.player.totalGoldEarned = state.player.totalGoldEarned + amount;
|
||||
goldChange = amount;
|
||||
} else if (event.effect.type === "gold_loss") {
|
||||
// Gold loss — amount may be undefined in edge cases
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const amount = Math.min(state.resources.gold, event.effect.amount ?? 0);
|
||||
state.resources.gold = state.resources.gold - amount;
|
||||
goldChange = -amount;
|
||||
} else if (event.effect.type === "essence_gain") {
|
||||
// Essence gain — amount may be undefined in edge cases
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const amount = event.effect.amount ?? 0;
|
||||
state.resources.essence = state.resources.essence + amount;
|
||||
essenceChange = amount;
|
||||
} else if (event.effect.type === "material_gain") {
|
||||
const { materialId } = event.effect;
|
||||
|
||||
// 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) => {
|
||||
return m.materialId === materialId;
|
||||
});
|
||||
@@ -331,25 +285,97 @@ exploreRouter.post("/collect", async(context) => {
|
||||
} else {
|
||||
state.exploration.materials.push({ materialId, quantity });
|
||||
}
|
||||
|
||||
materialsFound.push({ materialId, quantity });
|
||||
break;
|
||||
materialGained = { materialId, quantity };
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* 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 };
|
||||
|
||||
@@ -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 { authMiddleware } from "../middleware/auth.js";
|
||||
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import { calculateOfflineEarnings } from "../services/offlineProgress.js";
|
||||
import {
|
||||
checkAndUnlockTitles,
|
||||
@@ -681,18 +682,387 @@ const gameRouter = new Hono<HonoEnvironment>();
|
||||
gameRouter.use("*", authMiddleware);
|
||||
|
||||
gameRouter.get("/load", async(context) => {
|
||||
const discordId = context.get("discordId");
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const [ record, playerRecord ] = await Promise.all([
|
||||
prisma.gameState.findUnique({ where: { discordId } }),
|
||||
prisma.player.findUnique({ where: { discordId } }),
|
||||
]);
|
||||
const [ record, playerRecord ] = await Promise.all([
|
||||
prisma.gameState.findUnique({ where: { discordId } }),
|
||||
prisma.player.findUnique({ where: { discordId } }),
|
||||
]);
|
||||
|
||||
if (!record) {
|
||||
// No save found — create a fresh state (handles nuked DB or first-time load race)
|
||||
if (!record) {
|
||||
// No save found — create a fresh state (handles nuked DB or first-time load race)
|
||||
if (!playerRecord) {
|
||||
return context.json({ error: "No player found" }, 404);
|
||||
}
|
||||
const freshState = initialGameState(
|
||||
{
|
||||
avatar: playerRecord.avatar,
|
||||
characterName: playerRecord.characterName,
|
||||
createdAt: playerRecord.createdAt,
|
||||
discordId: playerRecord.discordId,
|
||||
discriminator: playerRecord.discriminator,
|
||||
lastSavedAt: Date.now(),
|
||||
// eslint-disable-next-line stylistic/max-len -- Long property names exceed limit after try-block indent
|
||||
lifetimeAchievementsUnlocked: playerRecord.lifetimeAchievementsUnlocked,
|
||||
// eslint-disable-next-line stylistic/max-len -- Long property names exceed limit after try-block indent
|
||||
lifetimeAdventurersRecruited: playerRecord.lifetimeAdventurersRecruited,
|
||||
lifetimeBossesDefeated: playerRecord.lifetimeBossesDefeated,
|
||||
lifetimeClicks: playerRecord.lifetimeClicks,
|
||||
lifetimeGoldEarned: playerRecord.lifetimeGoldEarned,
|
||||
lifetimeQuestsCompleted: playerRecord.lifetimeQuestsCompleted,
|
||||
totalClicks: 0,
|
||||
totalGoldEarned: 0,
|
||||
username: playerRecord.username,
|
||||
},
|
||||
playerRecord.characterName,
|
||||
);
|
||||
const createdAt = Date.now();
|
||||
await prisma.gameState.create({
|
||||
data: {
|
||||
discordId: discordId,
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
state: freshState as object,
|
||||
updatedAt: createdAt,
|
||||
},
|
||||
});
|
||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||
|
||||
// Sign the state for anti-cheat verification
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
const signature = secret === undefined
|
||||
? undefined
|
||||
: computeHmac(JSON.stringify(freshState), secret);
|
||||
return context.json({
|
||||
currentSchemaVersion: currentSchemaVersion,
|
||||
loginBonus: null,
|
||||
loginStreak: playerRecord.loginStreak,
|
||||
offlineEssence: 0,
|
||||
offlineGold: 0,
|
||||
offlineSeconds: 0,
|
||||
schemaOutdated: false,
|
||||
signature: signature,
|
||||
state: freshState,
|
||||
});
|
||||
}
|
||||
|
||||
const rawState: unknown = record.state;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||
const state = rawState as GameState;
|
||||
|
||||
/*
|
||||
* Always sync character name from the Player record — the profile update route
|
||||
* writes to Player.characterName directly, bypassing the game state blob.
|
||||
*/
|
||||
if (playerRecord !== null) {
|
||||
state.player.characterName = playerRecord.characterName;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const { offlineGold, offlineEssence, offlineSeconds }
|
||||
= calculateOfflineEarnings(state, now);
|
||||
|
||||
if (offlineGold > 0) {
|
||||
state.resources.gold = state.resources.gold + offlineGold;
|
||||
state.player.totalGoldEarned = state.player.totalGoldEarned + offlineGold;
|
||||
}
|
||||
|
||||
if (offlineEssence > 0) {
|
||||
state.resources.essence = state.resources.essence + offlineEssence;
|
||||
}
|
||||
|
||||
// Generate or reset daily challenges if a new day has begun
|
||||
state.dailyChallenges = getOrResetDailyChallenges(state);
|
||||
|
||||
// Daily login bonus — award once per calendar day (UTC)
|
||||
const todayUTC = new Date().toISOString().
|
||||
slice(0, 10);
|
||||
const yesterdayUTC = new Date(now - 86_400_000).toISOString().
|
||||
slice(0, 10);
|
||||
let loginBonus: LoginBonusResult | null = null;
|
||||
|
||||
// Default loginStreak to 1 for brand-new accounts
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
let loginStreak = playerRecord?.loginStreak ?? 1;
|
||||
|
||||
if (playerRecord && playerRecord.lastLoginDate !== todayUTC) {
|
||||
const previousStreak = playerRecord.loginStreak;
|
||||
const updatedStreak
|
||||
= playerRecord.lastLoginDate === yesterdayUTC
|
||||
? previousStreak + 1
|
||||
: 1;
|
||||
const dayIndex = (updatedStreak - 1) % 7;
|
||||
const weekMultiplier = Math.floor((updatedStreak - 1) / 7) + 1;
|
||||
const reward = dailyRewards[dayIndex];
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 2 -- @preserve */
|
||||
const goldEarned = (reward?.goldBase ?? 500) * weekMultiplier;
|
||||
const crystalsEarned = (reward?.crystals ?? 0) * weekMultiplier;
|
||||
|
||||
state.resources.gold = Math.min(
|
||||
state.resources.gold + goldEarned,
|
||||
resourceCap,
|
||||
);
|
||||
state.player.totalGoldEarned = state.player.totalGoldEarned + goldEarned;
|
||||
state.resources.crystals = Math.min(
|
||||
state.resources.crystals + crystalsEarned,
|
||||
resourceCap,
|
||||
);
|
||||
|
||||
loginStreak = updatedStreak;
|
||||
loginBonus = {
|
||||
crystalsEarned: crystalsEarned,
|
||||
day: dayIndex + 1,
|
||||
goldEarned: goldEarned,
|
||||
streak: updatedStreak,
|
||||
weekMultiplier: weekMultiplier,
|
||||
};
|
||||
|
||||
await prisma.player.
|
||||
update({
|
||||
data: { lastLoginDate: todayUTC, loginStreak: updatedStreak },
|
||||
where: { discordId },
|
||||
}).
|
||||
catch((error: unknown) => {
|
||||
// Ignore write-conflict errors (P2034) — rethrow anything else
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 5 -- @preserve */
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */
|
||||
const { code } = error as { code?: string };
|
||||
if (code !== "P2034") {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
state.lastTickAt = now;
|
||||
|
||||
if (offlineGold > 0 || offlineEssence > 0 || loginBonus !== null) {
|
||||
// Persist updated state immediately so offline/login rewards aren't double-counted.
|
||||
/*
|
||||
* Swallow write conflicts (P2034): offline earnings and login bonus are applied
|
||||
* server-side and must be persisted immediately so they aren't double-counted.
|
||||
*/
|
||||
await prisma.gameState.
|
||||
update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: state as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
}).
|
||||
catch((error: unknown) => {
|
||||
// Ignore write-conflict errors (P2034) — rethrow anything else
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 5 -- @preserve */
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */
|
||||
const { code } = error as { code?: string };
|
||||
if (code !== "P2034") {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const schemaOutdated = (state.schemaVersion ?? 0) < currentSchemaVersion;
|
||||
|
||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||
const signature = secret === undefined
|
||||
? undefined
|
||||
: computeHmac(JSON.stringify(state), secret);
|
||||
return context.json({
|
||||
currentSchemaVersion,
|
||||
loginBonus,
|
||||
loginStreak,
|
||||
offlineEssence,
|
||||
offlineGold,
|
||||
offlineSeconds,
|
||||
schemaOutdated,
|
||||
signature,
|
||||
state,
|
||||
});
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"game_load",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
gameRouter.post("/save", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<SaveRequest>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Defensive check for malformed requests
|
||||
if (body.state === null || body.state === undefined) {
|
||||
return context.json({ error: "Missing state in request body" }, 400);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
if ((body.state.schemaVersion ?? 0) < currentSchemaVersion) {
|
||||
return context.json(
|
||||
{
|
||||
// eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened
|
||||
error: "Save rejected: outdated save. Reset your progress to continue.",
|
||||
},
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||
const [ record, playerRecord ] = await Promise.all([
|
||||
prisma.gameState.findUnique({ where: { discordId } }),
|
||||
prisma.player.findUnique({ where: { discordId } }),
|
||||
]);
|
||||
|
||||
let stateToSave = body.state;
|
||||
|
||||
if (record) {
|
||||
const rawPreviousState: unknown = record.state;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||
const previousState = rawPreviousState as GameState;
|
||||
|
||||
// Option D: verify HMAC signature if the secret is configured and client sent one
|
||||
if (secret !== undefined && body.signature !== undefined) {
|
||||
const expectedSig = computeHmac(JSON.stringify(previousState), secret);
|
||||
if (body.signature !== expectedSig) {
|
||||
return context.json(
|
||||
{ error: "Save rejected: signature mismatch" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Option A: sanitise the incoming state against the previous to block rollbacks and cap cheats
|
||||
stateToSave = validateAndSanitize(body.state, previousState);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
/*
|
||||
* Stamp the authoritative save timestamp into the state blob so that on the
|
||||
* next load the client reads the correct value from state.player.lastSavedAt.
|
||||
*/
|
||||
stateToSave = {
|
||||
...stateToSave,
|
||||
player: { ...stateToSave.player, lastSavedAt: now },
|
||||
};
|
||||
|
||||
/*
|
||||
* Preserve the Player record's character name so that profile updates are not
|
||||
* overwritten by the next auto-save (profile PUT writes to Player, not the blob).
|
||||
*/
|
||||
stateToSave = {
|
||||
...stateToSave,
|
||||
player: {
|
||||
...stateToSave.player,
|
||||
characterName:
|
||||
playerRecord?.characterName ?? stateToSave.player.characterName,
|
||||
},
|
||||
};
|
||||
|
||||
/*
|
||||
* Recompute companion unlocks server-side using DB-authoritative player lifetime stats.
|
||||
* This prevents clients from claiming companions they haven't legitimately unlocked.
|
||||
*/
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 8 -- @preserve */
|
||||
const companionUnlocks = computeUnlockedCompanionIds({
|
||||
apotheosisCount: stateToSave.apotheosis?.count ?? 0,
|
||||
lifetimeBossesDefeated: playerRecord?.lifetimeBossesDefeated ?? 0,
|
||||
lifetimeGoldEarned: playerRecord?.lifetimeGoldEarned ?? 0,
|
||||
lifetimeQuestsCompleted: playerRecord?.lifetimeQuestsCompleted ?? 0,
|
||||
prestigeCount: stateToSave.prestige.count,
|
||||
transcendenceCount: stateToSave.transcendence?.count ?? 0,
|
||||
});
|
||||
const clientActiveCompanionId
|
||||
= stateToSave.companions?.activeCompanionId ?? null;
|
||||
const validatedActiveCompanionId
|
||||
= clientActiveCompanionId !== null
|
||||
&& companionUnlocks.includes(clientActiveCompanionId)
|
||||
? clientActiveCompanionId
|
||||
: null;
|
||||
stateToSave = {
|
||||
...stateToSave,
|
||||
companions: {
|
||||
activeCompanionId: validatedActiveCompanionId,
|
||||
unlockedCompanionIds: companionUnlocks,
|
||||
},
|
||||
};
|
||||
|
||||
const currentUnlocked = parseUnlockedTitles(playerRecord?.unlockedTitles);
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 6 -- @preserve */
|
||||
const updatedTitles = checkAndUnlockTitles({
|
||||
createdAt: playerRecord?.createdAt ?? Date.now(),
|
||||
currentUnlocked: currentUnlocked,
|
||||
guildName: playerRecord?.guildName ?? "",
|
||||
state: stateToSave,
|
||||
});
|
||||
const updatedUnlocked
|
||||
= updatedTitles.length > 0
|
||||
? [ ...currentUnlocked, ...updatedTitles ]
|
||||
: undefined;
|
||||
|
||||
await prisma.player.update({
|
||||
data: {
|
||||
characterName: stateToSave.player.characterName,
|
||||
lastSavedAt: now,
|
||||
totalClicks: stateToSave.player.totalClicks,
|
||||
totalGoldEarned: stateToSave.player.totalGoldEarned,
|
||||
...updatedUnlocked
|
||||
? { unlockedTitles: updatedUnlocked }
|
||||
: {},
|
||||
},
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
await prisma.gameState.upsert({
|
||||
create: {
|
||||
discordId: discordId,
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */
|
||||
state: stateToSave as unknown as never,
|
||||
updatedAt: now,
|
||||
},
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */
|
||||
update: { state: stateToSave as unknown as never, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
const signature = secret === undefined
|
||||
? undefined
|
||||
: computeHmac(JSON.stringify(stateToSave), secret);
|
||||
return context.json({ savedAt: now, signature: signature });
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"game_save",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
gameRouter.post("/reset", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const playerRecord = await prisma.player.findUnique({
|
||||
where: { discordId },
|
||||
});
|
||||
if (!playerRecord) {
|
||||
return context.json({ error: "No player found" }, 404);
|
||||
}
|
||||
|
||||
const freshState = initialGameState(
|
||||
{
|
||||
avatar: playerRecord.avatar,
|
||||
@@ -713,23 +1083,25 @@ gameRouter.get("/load", async(context) => {
|
||||
},
|
||||
playerRecord.characterName,
|
||||
);
|
||||
|
||||
const createdAt = Date.now();
|
||||
await prisma.gameState.create({
|
||||
data: {
|
||||
await prisma.gameState.upsert({
|
||||
create: {
|
||||
discordId: discordId,
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
state: freshState as object,
|
||||
updatedAt: createdAt,
|
||||
},
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
update: { state: freshState as object, updatedAt: createdAt },
|
||||
where: { discordId },
|
||||
});
|
||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||
|
||||
// Sign the state for anti-cheat verification
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||
const signature = secret === undefined
|
||||
? undefined
|
||||
: computeHmac(JSON.stringify(freshState), secret);
|
||||
|
||||
return context.json({
|
||||
currentSchemaVersion: currentSchemaVersion,
|
||||
loginBonus: null,
|
||||
@@ -741,351 +1113,15 @@ gameRouter.get("/load", async(context) => {
|
||||
signature: signature,
|
||||
state: freshState,
|
||||
});
|
||||
}
|
||||
|
||||
const rawState: unknown = record.state;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||
const state = rawState as GameState;
|
||||
|
||||
/*
|
||||
* Always sync character name from the Player record — the profile update route
|
||||
* writes to Player.characterName directly, bypassing the game state blob.
|
||||
*/
|
||||
if (playerRecord !== null) {
|
||||
state.player.characterName = playerRecord.characterName;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const { offlineGold, offlineEssence, offlineSeconds }
|
||||
= calculateOfflineEarnings(state, now);
|
||||
|
||||
if (offlineGold > 0) {
|
||||
state.resources.gold = state.resources.gold + offlineGold;
|
||||
state.player.totalGoldEarned = state.player.totalGoldEarned + offlineGold;
|
||||
}
|
||||
|
||||
if (offlineEssence > 0) {
|
||||
state.resources.essence = state.resources.essence + offlineEssence;
|
||||
}
|
||||
|
||||
// Generate or reset daily challenges if a new day has begun
|
||||
state.dailyChallenges = getOrResetDailyChallenges(state);
|
||||
|
||||
// Daily login bonus — award once per calendar day (UTC)
|
||||
const todayUTC = new Date().toISOString().
|
||||
slice(0, 10);
|
||||
const yesterdayUTC = new Date(now - 86_400_000).toISOString().
|
||||
slice(0, 10);
|
||||
let loginBonus: LoginBonusResult | null = null;
|
||||
|
||||
// Default loginStreak to 1 for brand-new accounts
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
let loginStreak = playerRecord?.loginStreak ?? 1;
|
||||
|
||||
if (playerRecord && playerRecord.lastLoginDate !== todayUTC) {
|
||||
const previousStreak = playerRecord.loginStreak;
|
||||
const updatedStreak
|
||||
= playerRecord.lastLoginDate === yesterdayUTC
|
||||
? previousStreak + 1
|
||||
: 1;
|
||||
const dayIndex = (updatedStreak - 1) % 7;
|
||||
const weekMultiplier = Math.floor((updatedStreak - 1) / 7) + 1;
|
||||
const reward = dailyRewards[dayIndex];
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 2 -- @preserve */
|
||||
const goldEarned = (reward?.goldBase ?? 500) * weekMultiplier;
|
||||
const crystalsEarned = (reward?.crystals ?? 0) * weekMultiplier;
|
||||
|
||||
state.resources.gold = Math.min(
|
||||
state.resources.gold + goldEarned,
|
||||
resourceCap,
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"game_reset",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
state.player.totalGoldEarned = state.player.totalGoldEarned + goldEarned;
|
||||
state.resources.crystals = Math.min(
|
||||
state.resources.crystals + crystalsEarned,
|
||||
resourceCap,
|
||||
);
|
||||
|
||||
loginStreak = updatedStreak;
|
||||
loginBonus = {
|
||||
crystalsEarned: crystalsEarned,
|
||||
day: dayIndex + 1,
|
||||
goldEarned: goldEarned,
|
||||
streak: updatedStreak,
|
||||
weekMultiplier: weekMultiplier,
|
||||
};
|
||||
|
||||
await prisma.player.
|
||||
update({
|
||||
data: { lastLoginDate: todayUTC, loginStreak: updatedStreak },
|
||||
where: { discordId },
|
||||
}).
|
||||
catch((error: unknown) => {
|
||||
// Ignore write-conflict errors (P2034) — rethrow anything else
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 5 -- @preserve */
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */
|
||||
const { code } = error as { code?: string };
|
||||
if (code !== "P2034") {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
|
||||
state.lastTickAt = now;
|
||||
|
||||
if (offlineGold > 0 || offlineEssence > 0 || loginBonus !== null) {
|
||||
// Persist updated state immediately so offline/login rewards aren't double-counted.
|
||||
/*
|
||||
* Swallow write conflicts (P2034): offline earnings and login bonus are applied
|
||||
* server-side and must be persisted immediately so they aren't double-counted.
|
||||
*/
|
||||
await prisma.gameState.
|
||||
update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: state as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
}).
|
||||
catch((error: unknown) => {
|
||||
// Ignore write-conflict errors (P2034) — rethrow anything else
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 5 -- @preserve */
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */
|
||||
const { code } = error as { code?: string };
|
||||
if (code !== "P2034") {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const schemaOutdated = (state.schemaVersion ?? 0) < currentSchemaVersion;
|
||||
|
||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||
const signature = secret === undefined
|
||||
? undefined
|
||||
: computeHmac(JSON.stringify(state), secret);
|
||||
return context.json({
|
||||
currentSchemaVersion,
|
||||
loginBonus,
|
||||
loginStreak,
|
||||
offlineEssence,
|
||||
offlineGold,
|
||||
offlineSeconds,
|
||||
schemaOutdated,
|
||||
signature,
|
||||
state,
|
||||
});
|
||||
});
|
||||
|
||||
gameRouter.post("/save", async(context) => {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<SaveRequest>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Defensive check for malformed requests
|
||||
if (body.state === null || body.state === undefined) {
|
||||
return context.json({ error: "Missing state in request body" }, 400);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
if ((body.state.schemaVersion ?? 0) < currentSchemaVersion) {
|
||||
return context.json(
|
||||
{
|
||||
error: "Save rejected: outdated save. Reset your progress to continue.",
|
||||
},
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||
const [ record, playerRecord ] = await Promise.all([
|
||||
prisma.gameState.findUnique({ where: { discordId } }),
|
||||
prisma.player.findUnique({ where: { discordId } }),
|
||||
]);
|
||||
|
||||
let stateToSave = body.state;
|
||||
|
||||
if (record) {
|
||||
const rawPreviousState: unknown = record.state;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||
const previousState = rawPreviousState as GameState;
|
||||
|
||||
// Option D: verify HMAC signature if the secret is configured and client sent one
|
||||
if (secret !== undefined && body.signature !== undefined) {
|
||||
const expectedSig = computeHmac(JSON.stringify(previousState), secret);
|
||||
if (body.signature !== expectedSig) {
|
||||
return context.json(
|
||||
{ error: "Save rejected: signature mismatch" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Option A: sanitise the incoming state against the previous to block rollbacks and cap cheats
|
||||
stateToSave = validateAndSanitize(body.state, previousState);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
/*
|
||||
* Stamp the authoritative save timestamp into the state blob so that on the
|
||||
* next load the client reads the correct value from state.player.lastSavedAt.
|
||||
*/
|
||||
stateToSave = {
|
||||
...stateToSave,
|
||||
player: { ...stateToSave.player, lastSavedAt: now },
|
||||
};
|
||||
|
||||
/*
|
||||
* Preserve the Player record's character name so that profile updates are not
|
||||
* overwritten by the next auto-save (profile PUT writes to Player, not the blob).
|
||||
*/
|
||||
stateToSave = {
|
||||
...stateToSave,
|
||||
player: {
|
||||
...stateToSave.player,
|
||||
characterName:
|
||||
playerRecord?.characterName ?? stateToSave.player.characterName,
|
||||
},
|
||||
};
|
||||
|
||||
/*
|
||||
* Recompute companion unlocks server-side using DB-authoritative player lifetime stats.
|
||||
* This prevents clients from claiming companions they haven't legitimately unlocked.
|
||||
*/
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 8 -- @preserve */
|
||||
const companionUnlocks = computeUnlockedCompanionIds({
|
||||
apotheosisCount: stateToSave.apotheosis?.count ?? 0,
|
||||
lifetimeBossesDefeated: playerRecord?.lifetimeBossesDefeated ?? 0,
|
||||
lifetimeGoldEarned: playerRecord?.lifetimeGoldEarned ?? 0,
|
||||
lifetimeQuestsCompleted: playerRecord?.lifetimeQuestsCompleted ?? 0,
|
||||
prestigeCount: stateToSave.prestige.count,
|
||||
transcendenceCount: stateToSave.transcendence?.count ?? 0,
|
||||
});
|
||||
const clientActiveCompanionId
|
||||
= stateToSave.companions?.activeCompanionId ?? null;
|
||||
const validatedActiveCompanionId
|
||||
= clientActiveCompanionId !== null
|
||||
&& companionUnlocks.includes(clientActiveCompanionId)
|
||||
? clientActiveCompanionId
|
||||
: null;
|
||||
stateToSave = {
|
||||
...stateToSave,
|
||||
companions: {
|
||||
activeCompanionId: validatedActiveCompanionId,
|
||||
unlockedCompanionIds: companionUnlocks,
|
||||
},
|
||||
};
|
||||
|
||||
const currentUnlocked = parseUnlockedTitles(playerRecord?.unlockedTitles);
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 6 -- @preserve */
|
||||
const updatedTitles = checkAndUnlockTitles({
|
||||
createdAt: playerRecord?.createdAt ?? Date.now(),
|
||||
currentUnlocked: currentUnlocked,
|
||||
guildName: playerRecord?.guildName ?? "",
|
||||
state: stateToSave,
|
||||
});
|
||||
const updatedUnlocked
|
||||
= updatedTitles.length > 0
|
||||
? [ ...currentUnlocked, ...updatedTitles ]
|
||||
: undefined;
|
||||
|
||||
await prisma.player.update({
|
||||
data: {
|
||||
characterName: stateToSave.player.characterName,
|
||||
lastSavedAt: now,
|
||||
totalClicks: stateToSave.player.totalClicks,
|
||||
totalGoldEarned: stateToSave.player.totalGoldEarned,
|
||||
...updatedUnlocked
|
||||
? { unlockedTitles: updatedUnlocked }
|
||||
: {},
|
||||
},
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
await prisma.gameState.upsert({
|
||||
create: {
|
||||
discordId: discordId,
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */
|
||||
state: stateToSave as unknown as never,
|
||||
updatedAt: now,
|
||||
},
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */
|
||||
update: { state: stateToSave as unknown as never, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
const signature = secret === undefined
|
||||
? undefined
|
||||
: computeHmac(JSON.stringify(stateToSave), secret);
|
||||
return context.json({ savedAt: now, signature: signature });
|
||||
});
|
||||
|
||||
gameRouter.post("/reset", async(context) => {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const playerRecord = await prisma.player.findUnique({ where: { discordId } });
|
||||
if (!playerRecord) {
|
||||
return context.json({ error: "No player found" }, 404);
|
||||
}
|
||||
|
||||
const freshState = initialGameState(
|
||||
{
|
||||
avatar: playerRecord.avatar,
|
||||
characterName: playerRecord.characterName,
|
||||
createdAt: playerRecord.createdAt,
|
||||
discordId: playerRecord.discordId,
|
||||
discriminator: playerRecord.discriminator,
|
||||
lastSavedAt: Date.now(),
|
||||
lifetimeAchievementsUnlocked: playerRecord.lifetimeAchievementsUnlocked,
|
||||
lifetimeAdventurersRecruited: playerRecord.lifetimeAdventurersRecruited,
|
||||
lifetimeBossesDefeated: playerRecord.lifetimeBossesDefeated,
|
||||
lifetimeClicks: playerRecord.lifetimeClicks,
|
||||
lifetimeGoldEarned: playerRecord.lifetimeGoldEarned,
|
||||
lifetimeQuestsCompleted: playerRecord.lifetimeQuestsCompleted,
|
||||
totalClicks: 0,
|
||||
totalGoldEarned: 0,
|
||||
username: playerRecord.username,
|
||||
},
|
||||
playerRecord.characterName,
|
||||
);
|
||||
|
||||
const createdAt = Date.now();
|
||||
await prisma.gameState.upsert({
|
||||
create: {
|
||||
discordId: discordId,
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
state: freshState as object,
|
||||
updatedAt: createdAt,
|
||||
},
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
update: { state: freshState as object, updatedAt: createdAt },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||
const signature = secret === undefined
|
||||
? undefined
|
||||
: computeHmac(JSON.stringify(freshState), secret);
|
||||
|
||||
return context.json({
|
||||
currentSchemaVersion: currentSchemaVersion,
|
||||
loginBonus: null,
|
||||
loginStreak: playerRecord.loginStreak,
|
||||
offlineEssence: 0,
|
||||
offlineGold: 0,
|
||||
offlineSeconds: 0,
|
||||
schemaOutdated: false,
|
||||
signature: signature,
|
||||
state: freshState,
|
||||
});
|
||||
});
|
||||
|
||||
export { gameRouter };
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { gameTitles } from "../data/titles.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
@@ -58,70 +59,80 @@ const resolveTitleName = (titleId: string | null): string => {
|
||||
};
|
||||
|
||||
leaderboardRouter.get("/", async(context) => {
|
||||
const category = context.req.query("category") ?? "totalGold";
|
||||
const limitRaw = Number(context.req.query("limit") ?? "100");
|
||||
const limit = Math.min(Math.max(1, limitRaw), 100);
|
||||
try {
|
||||
const category = context.req.query("category") ?? "totalGold";
|
||||
const limitRaw = Number(context.req.query("limit") ?? "100");
|
||||
const limit = Math.min(Math.max(1, limitRaw), 100);
|
||||
|
||||
if (!validCategories.has(category)) {
|
||||
return context.json({ error: "Invalid category" }, 400);
|
||||
}
|
||||
if (!validCategories.has(category)) {
|
||||
return context.json({ error: "Invalid category" }, 400);
|
||||
}
|
||||
|
||||
const [ players, gameStates ] = await Promise.all([
|
||||
prisma.player.findMany(),
|
||||
gameStateCategories.has(category)
|
||||
? prisma.gameState.findMany()
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const [ players, gameStates ] = await Promise.all([
|
||||
prisma.player.findMany(),
|
||||
gameStateCategories.has(category)
|
||||
? prisma.gameState.findMany()
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const stateMap = new Map(
|
||||
gameStates.map((gs) => {
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
return [ gs.discordId, gs.state as unknown as GameState ];
|
||||
}),
|
||||
);
|
||||
const stateMap = new Map(
|
||||
gameStates.map((gs) => {
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
return [ gs.discordId, gs.state as unknown as GameState ];
|
||||
}),
|
||||
);
|
||||
|
||||
const entries = players.
|
||||
filter((player) => {
|
||||
return parseShowOnLeaderboards(player.profileSettings);
|
||||
}).
|
||||
map((player) => {
|
||||
let value = 0;
|
||||
if (category === "totalGold") {
|
||||
value = player.lifetimeGoldEarned;
|
||||
} else if (category === "bossesDefeated") {
|
||||
value = player.lifetimeBossesDefeated;
|
||||
} else if (category === "questsCompleted") {
|
||||
value = player.lifetimeQuestsCompleted;
|
||||
} else if (category === "achievementsUnlocked") {
|
||||
value = player.lifetimeAchievementsUnlocked;
|
||||
} else {
|
||||
const state = stateMap.get(player.discordId);
|
||||
if (category === "prestigeCount") {
|
||||
value = state?.prestige.count ?? 0;
|
||||
} else if (category === "transcendenceCount") {
|
||||
value = state?.transcendence?.count ?? 0;
|
||||
} else if (category === "apotheosisCount") {
|
||||
value = state?.apotheosis?.count ?? 0;
|
||||
const entries = players.
|
||||
filter((player) => {
|
||||
return parseShowOnLeaderboards(player.profileSettings);
|
||||
}).
|
||||
map((player) => {
|
||||
let value = 0;
|
||||
if (category === "totalGold") {
|
||||
value = player.lifetimeGoldEarned;
|
||||
} else if (category === "bossesDefeated") {
|
||||
value = player.lifetimeBossesDefeated;
|
||||
} else if (category === "questsCompleted") {
|
||||
value = player.lifetimeQuestsCompleted;
|
||||
} else if (category === "achievementsUnlocked") {
|
||||
value = player.lifetimeAchievementsUnlocked;
|
||||
} else {
|
||||
const state = stateMap.get(player.discordId);
|
||||
if (category === "prestigeCount") {
|
||||
value = state?.prestige.count ?? 0;
|
||||
} else if (category === "transcendenceCount") {
|
||||
value = state?.transcendence?.count ?? 0;
|
||||
} else if (category === "apotheosisCount") {
|
||||
value = state?.apotheosis?.count ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
activeTitle: resolveTitleName(player.activeTitle),
|
||||
avatar: player.avatar ?? null,
|
||||
characterName: player.characterName,
|
||||
discordId: player.discordId,
|
||||
username: player.username,
|
||||
value: value,
|
||||
};
|
||||
}).
|
||||
sort((a, b) => {
|
||||
return b.value - a.value;
|
||||
}).
|
||||
slice(0, limit).
|
||||
map((entry, index) => {
|
||||
return { ...entry, rank: index + 1 };
|
||||
});
|
||||
return {
|
||||
activeTitle: resolveTitleName(player.activeTitle),
|
||||
avatar: player.avatar ?? null,
|
||||
characterName: player.characterName,
|
||||
discordId: player.discordId,
|
||||
username: player.username,
|
||||
value: value,
|
||||
};
|
||||
}).
|
||||
sort((a, b) => {
|
||||
return b.value - a.value;
|
||||
}).
|
||||
slice(0, limit).
|
||||
map((entry, index) => {
|
||||
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 };
|
||||
|
||||
+192
-163
@@ -6,11 +6,13 @@
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||
/* eslint-disable complexity -- Route handlers have inherent complexity */
|
||||
import { Hono } from "hono";
|
||||
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import {
|
||||
buildPostPrestigeState,
|
||||
computeRunestoneMultipliers,
|
||||
@@ -25,190 +27,217 @@ const prestigeRouter = new Hono<HonoEnvironment>();
|
||||
prestigeRouter.use("*", authMiddleware);
|
||||
|
||||
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) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
|
||||
if (!isEligibleForPrestige(state)) {
|
||||
return context.json(
|
||||
{
|
||||
error: "Not eligible for prestige — collect 1,000,000 total gold first",
|
||||
if (!isEligibleForPrestige(state)) {
|
||||
return context.json(
|
||||
{
|
||||
// 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
|
||||
let updatedDailyChallenges = state.dailyChallenges;
|
||||
let challengeCrystals = 0;
|
||||
if (updatedDailyChallenges) {
|
||||
const result = updateChallengeProgress(
|
||||
updatedDailyChallenges,
|
||||
// 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 },
|
||||
});
|
||||
|
||||
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",
|
||||
1,
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
updatedDailyChallenges = result.updatedChallenges;
|
||||
challengeCrystals = result.crystalsAwarded;
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
|
||||
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) => {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<BuyPrestigeUpgradeRequest>();
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<BuyPrestigeUpgradeRequest>();
|
||||
|
||||
const { upgradeId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!upgradeId) {
|
||||
return context.json({ error: "upgradeId is required" }, 400);
|
||||
}
|
||||
const { upgradeId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!upgradeId) {
|
||||
return context.json({ error: "upgradeId is required" }, 400);
|
||||
}
|
||||
|
||||
const upgrade = defaultPrestigeUpgrades.find((prestigeUpgrade) => {
|
||||
return prestigeUpgrade.id === upgradeId;
|
||||
});
|
||||
if (!upgrade) {
|
||||
return context.json({ error: "Unknown prestige upgrade" }, 404);
|
||||
}
|
||||
const upgrade = defaultPrestigeUpgrades.find((prestigeUpgrade) => {
|
||||
return prestigeUpgrade.id === upgradeId;
|
||||
});
|
||||
if (!upgrade) {
|
||||
return context.json({ error: "Unknown prestige upgrade" }, 404);
|
||||
}
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
const { purchasedUpgradeIds, runestones } = state.prestige;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
const { purchasedUpgradeIds, runestones } = state.prestige;
|
||||
|
||||
if (purchasedUpgradeIds.includes(upgradeId)) {
|
||||
return context.json({ error: "Upgrade already purchased" }, 400);
|
||||
}
|
||||
if (purchasedUpgradeIds.includes(upgradeId)) {
|
||||
return context.json({ error: "Upgrade already purchased" }, 400);
|
||||
}
|
||||
|
||||
if (runestones < upgrade.runestonesCost) {
|
||||
return context.json({ error: "Not enough runestones" }, 400);
|
||||
}
|
||||
if (runestones < upgrade.runestonesCost) {
|
||||
return context.json({ error: "Not enough runestones" }, 400);
|
||||
}
|
||||
|
||||
const updatedRunestones = runestones - upgrade.runestonesCost;
|
||||
const updatedPurchasedUpgradeIds = [ ...purchasedUpgradeIds, upgradeId ];
|
||||
const updatedRunestones = runestones - upgrade.runestonesCost;
|
||||
const updatedPurchasedUpgradeIds = [ ...purchasedUpgradeIds, upgradeId ];
|
||||
|
||||
const updatedState: GameState = {
|
||||
...state,
|
||||
prestige: {
|
||||
...state.prestige,
|
||||
const updatedState: GameState = {
|
||||
...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,
|
||||
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);
|
||||
|
||||
return context.json({
|
||||
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
|
||||
runestonesRemaining: updatedRunestones,
|
||||
...multipliers,
|
||||
});
|
||||
runestonesRemaining: updatedRunestones,
|
||||
...multipliers,
|
||||
});
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"prestige_buy_upgrade",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { prestigeRouter };
|
||||
|
||||
+183
-162
@@ -20,6 +20,7 @@ import { Hono } from "hono";
|
||||
import { gameTitles } from "../data/titles.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import { parseUnlockedTitles } from "../services/titles.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) => {
|
||||
const { discordId } = context.req.param();
|
||||
try {
|
||||
const { discordId } = context.req.param();
|
||||
|
||||
const [ player, gameStateRecord ] = await Promise.all([
|
||||
prisma.player.findUnique({ where: { discordId } }),
|
||||
prisma.gameState.findUnique({ where: { discordId } }),
|
||||
]);
|
||||
const [ player, gameStateRecord ] = await Promise.all([
|
||||
prisma.player.findUnique({ where: { discordId } }),
|
||||
prisma.gameState.findUnique({ where: { discordId } }),
|
||||
]);
|
||||
|
||||
if (!player) {
|
||||
return context.json({ error: "Player not found" }, 404);
|
||||
}
|
||||
if (!player) {
|
||||
return context.json({ error: "Player not found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = gameStateRecord?.state as unknown as GameState | undefined;
|
||||
const prestigeCount = state?.prestige.count ?? 0;
|
||||
const transcendenceCount = state?.transcendence?.count ?? 0;
|
||||
const apotheosisCount = state?.apotheosis?.count ?? 0;
|
||||
const profileSettings = parseProfileSettings(player.profileSettings);
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = gameStateRecord?.state as unknown as GameState | undefined;
|
||||
const prestigeCount = state?.prestige.count ?? 0;
|
||||
const transcendenceCount = state?.transcendence?.count ?? 0;
|
||||
const apotheosisCount = state?.apotheosis?.count ?? 0;
|
||||
const profileSettings = parseProfileSettings(player.profileSettings);
|
||||
|
||||
const bossesDefeated
|
||||
= state?.bosses.filter((boss) => {
|
||||
return boss.status === "defeated";
|
||||
}).length ?? 0;
|
||||
const questsCompleted
|
||||
= state?.quests.filter((quest) => {
|
||||
return quest.status === "completed";
|
||||
}).length ?? 0;
|
||||
const bossesDefeated
|
||||
= state?.bosses.filter((boss) => {
|
||||
return boss.status === "defeated";
|
||||
}).length ?? 0;
|
||||
const questsCompleted
|
||||
= state?.quests.filter((quest) => {
|
||||
return quest.status === "completed";
|
||||
}).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
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
for (const adventurer of state.adventurers) {
|
||||
adventurersRecruited = adventurersRecruited + adventurer.count;
|
||||
}
|
||||
}
|
||||
const achievementsUnlocked = (state?.achievements ?? []).filter((achievement) => {
|
||||
return achievement.unlockedAt !== null;
|
||||
}).length;
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
const achievementsUnlocked = (state?.achievements ?? []).filter((achievement) => {
|
||||
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 unlockedTitleIds = parseUnlockedTitles(player.unlockedTitles);
|
||||
const unlockedTitles = unlockedTitleIds.map((id) => {
|
||||
return resolveTitle(id);
|
||||
});
|
||||
|
||||
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({
|
||||
achievementsUnlocked: achievementsUnlocked,
|
||||
activeTitle: player.activeTitle,
|
||||
adventurersRecruited: adventurersRecruited,
|
||||
apotheosisCount: apotheosisCount,
|
||||
avatar: player.avatar,
|
||||
bio: player.bio ?? "",
|
||||
bossesDefeated: bossesDefeated,
|
||||
characterClass: player.characterClass,
|
||||
characterName: player.characterName,
|
||||
characterRace: player.characterRace ?? "",
|
||||
completedChapters: completedChapters,
|
||||
createdAt: player.createdAt,
|
||||
currentRunClicks: state?.player.totalClicks ?? 0,
|
||||
currentRunGold: state?.player.totalGoldEarned ?? 0,
|
||||
equippedItems: equippedItems,
|
||||
guildDescription: player.guildDescription,
|
||||
guildName: player.guildName,
|
||||
lifetimeAchievementsUnlocked: player.lifetimeAchievementsUnlocked,
|
||||
lifetimeAdventurersRecruited: player.lifetimeAdventurersRecruited,
|
||||
lifetimeBossesDefeated: player.lifetimeBossesDefeated,
|
||||
lifetimeQuestsCompleted: player.lifetimeQuestsCompleted,
|
||||
prestigeCount: prestigeCount,
|
||||
profileSettings: profileSettings,
|
||||
pronouns: player.pronouns ?? "",
|
||||
questsCompleted: questsCompleted,
|
||||
totalClicks: player.lifetimeClicks,
|
||||
totalGoldEarned: player.lifetimeGoldEarned,
|
||||
transcendenceCount: transcendenceCount,
|
||||
unlockedTitles: unlockedTitles,
|
||||
username: player.username,
|
||||
});
|
||||
const completedChapters = state?.story?.completedChapters ?? [];
|
||||
|
||||
return context.json({
|
||||
achievementsUnlocked: achievementsUnlocked,
|
||||
activeTitle: player.activeTitle,
|
||||
adventurersRecruited: adventurersRecruited,
|
||||
apotheosisCount: apotheosisCount,
|
||||
avatar: player.avatar,
|
||||
bio: player.bio ?? "",
|
||||
bossesDefeated: bossesDefeated,
|
||||
characterClass: player.characterClass,
|
||||
characterName: player.characterName,
|
||||
characterRace: player.characterRace ?? "",
|
||||
completedChapters: completedChapters,
|
||||
createdAt: player.createdAt,
|
||||
currentRunClicks: state?.player.totalClicks ?? 0,
|
||||
currentRunGold: state?.player.totalGoldEarned ?? 0,
|
||||
equippedItems: equippedItems,
|
||||
guildDescription: player.guildDescription,
|
||||
guildName: player.guildName,
|
||||
lifetimeAchievementsUnlocked: player.lifetimeAchievementsUnlocked,
|
||||
lifetimeAdventurersRecruited: player.lifetimeAdventurersRecruited,
|
||||
lifetimeBossesDefeated: player.lifetimeBossesDefeated,
|
||||
lifetimeQuestsCompleted: player.lifetimeQuestsCompleted,
|
||||
prestigeCount: prestigeCount,
|
||||
profileSettings: profileSettings,
|
||||
pronouns: player.pronouns ?? "",
|
||||
questsCompleted: questsCompleted,
|
||||
totalClicks: player.lifetimeClicks,
|
||||
totalGoldEarned: player.lifetimeGoldEarned,
|
||||
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) => {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<UpdateProfileRequest>();
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<UpdateProfileRequest>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!body.characterName) {
|
||||
return context.json({ error: "Character name cannot be empty" }, 400);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!body.characterName) {
|
||||
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 === "") {
|
||||
return context.json({ error: "Character name cannot be empty" }, 400);
|
||||
}
|
||||
if (characterName === "") {
|
||||
return context.json({ error: "Character name cannot be empty" }, 400);
|
||||
}
|
||||
|
||||
const pronouns = (body.pronouns ?? "").trim().slice(0, 20);
|
||||
const characterRace = (body.characterRace ?? "").trim().slice(0, 32);
|
||||
const characterClass = (body.characterClass ?? "").trim().slice(0, 32);
|
||||
const bio = (body.bio ?? "").trim().slice(0, 200);
|
||||
const guildName = (body.guildName ?? "").trim().slice(0, 64);
|
||||
const guildDescription = (body.guildDescription ?? "").trim().slice(0, 500);
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* 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)
|
||||
const pronouns = (body.pronouns ?? "").trim().slice(0, 20);
|
||||
const characterRace = (body.characterRace ?? "").trim().slice(0, 32);
|
||||
const characterClass = (body.characterClass ?? "").trim().slice(0, 32);
|
||||
const bio = (body.bio ?? "").trim().slice(0, 200);
|
||||
const guildName = (body.guildName ?? "").trim().slice(0, 64);
|
||||
const guildDescription = (body.guildDescription ?? "").trim().slice(0, 500);
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 2 -- @preserve */
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
|
||||
? (parsedNumberFormat as ProfileSettings["numberFormat"])
|
||||
: "suffix";
|
||||
const profileSettings: ProfileSettings = {
|
||||
enableNotifications: body.profileSettings.enableNotifications ?? false,
|
||||
enableSounds: body.profileSettings.enableSounds ?? false,
|
||||
numberFormat: numberFormat,
|
||||
showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true,
|
||||
showAdventurersRecruited: body.profileSettings.showAdventurersRecruited ?? true,
|
||||
showApotheosis: body.profileSettings.showApotheosis ?? true,
|
||||
showBossesDefeated: body.profileSettings.showBossesDefeated ?? true,
|
||||
showCurrentClicks: body.profileSettings.showCurrentClicks ?? true,
|
||||
showCurrentGold: body.profileSettings.showCurrentGold ?? true,
|
||||
showGuildFounded: body.profileSettings.showGuildFounded ?? true,
|
||||
showLifetimeAchievementsUnlocked: body.profileSettings.showLifetimeAchievementsUnlocked ?? true,
|
||||
showLifetimeAdventurersRecruited: body.profileSettings.showLifetimeAdventurersRecruited ?? true,
|
||||
showLifetimeBossesDefeated: body.profileSettings.showLifetimeBossesDefeated ?? true,
|
||||
showLifetimeQuestsCompleted: body.profileSettings.showLifetimeQuestsCompleted ?? true,
|
||||
showOnLeaderboards: body.profileSettings.showOnLeaderboards ?? true,
|
||||
showPrestige: body.profileSettings.showPrestige ?? true,
|
||||
showQuestsCompleted: body.profileSettings.showQuestsCompleted ?? true,
|
||||
showTotalClicks: body.profileSettings.showTotalClicks ?? true,
|
||||
showTotalGold: body.profileSettings.showTotalGold ?? true,
|
||||
showTranscendence: body.profileSettings.showTranscendence ?? true,
|
||||
};
|
||||
const parsedNumberFormat = (body.profileSettings.numberFormat ?? "") as string;
|
||||
const numberFormat = validNumberFormats.has(parsedNumberFormat)
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
|
||||
? (parsedNumberFormat as ProfileSettings["numberFormat"])
|
||||
: "suffix";
|
||||
const profileSettings: ProfileSettings = {
|
||||
enableNotifications: body.profileSettings.enableNotifications ?? false,
|
||||
enableSounds: body.profileSettings.enableSounds ?? false,
|
||||
numberFormat: numberFormat,
|
||||
showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true,
|
||||
showAdventurersRecruited: body.profileSettings.showAdventurersRecruited ?? true,
|
||||
showApotheosis: body.profileSettings.showApotheosis ?? true,
|
||||
showBossesDefeated: body.profileSettings.showBossesDefeated ?? true,
|
||||
showCurrentClicks: body.profileSettings.showCurrentClicks ?? true,
|
||||
showCurrentGold: body.profileSettings.showCurrentGold ?? true,
|
||||
showGuildFounded: body.profileSettings.showGuildFounded ?? true,
|
||||
showLifetimeAchievementsUnlocked: body.profileSettings.showLifetimeAchievementsUnlocked ?? true,
|
||||
showLifetimeAdventurersRecruited: body.profileSettings.showLifetimeAdventurersRecruited ?? true,
|
||||
showLifetimeBossesDefeated: body.profileSettings.showLifetimeBossesDefeated ?? true,
|
||||
showLifetimeQuestsCompleted: body.profileSettings.showLifetimeQuestsCompleted ?? true,
|
||||
showOnLeaderboards: body.profileSettings.showOnLeaderboards ?? true,
|
||||
showPrestige: body.profileSettings.showPrestige ?? true,
|
||||
showQuestsCompleted: body.profileSettings.showQuestsCompleted ?? true,
|
||||
showTotalClicks: body.profileSettings.showTotalClicks ?? true,
|
||||
showTotalGold: body.profileSettings.showTotalGold ?? true,
|
||||
showTranscendence: body.profileSettings.showTranscendence ?? true,
|
||||
};
|
||||
|
||||
const activeTitle
|
||||
= typeof body.activeTitle === "string"
|
||||
? body.activeTitle.slice(0, 64)
|
||||
: undefined;
|
||||
const activeTitle
|
||||
= typeof body.activeTitle === "string"
|
||||
? body.activeTitle.slice(0, 64)
|
||||
: undefined;
|
||||
|
||||
const updated = await prisma.player.update({
|
||||
data: {
|
||||
bio: bio,
|
||||
characterClass: characterClass,
|
||||
characterName: characterName,
|
||||
characterRace: characterRace,
|
||||
guildDescription: guildDescription,
|
||||
guildName: guildName,
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
profileSettings: profileSettings as object,
|
||||
pronouns: pronouns,
|
||||
...activeTitle === undefined
|
||||
? {}
|
||||
: { activeTitle },
|
||||
},
|
||||
where: { discordId },
|
||||
});
|
||||
const updated = await prisma.player.update({
|
||||
data: {
|
||||
bio: bio,
|
||||
characterClass: characterClass,
|
||||
characterName: characterName,
|
||||
characterRace: characterRace,
|
||||
guildDescription: guildDescription,
|
||||
guildName: guildName,
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
profileSettings: profileSettings as object,
|
||||
pronouns: pronouns,
|
||||
...activeTitle === undefined
|
||||
? {}
|
||||
: { activeTitle },
|
||||
},
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
return context.json({
|
||||
activeTitle: updated.activeTitle,
|
||||
bio: updated.bio,
|
||||
characterClass: updated.characterClass,
|
||||
characterName: updated.characterName,
|
||||
characterRace: updated.characterRace,
|
||||
guildDescription: updated.guildDescription,
|
||||
guildName: updated.guildName,
|
||||
profileSettings: profileSettings,
|
||||
pronouns: updated.pronouns,
|
||||
});
|
||||
return context.json({
|
||||
activeTitle: updated.activeTitle,
|
||||
bio: updated.bio,
|
||||
characterClass: updated.characterClass,
|
||||
characterName: updated.characterName,
|
||||
characterRace: updated.characterRace,
|
||||
guildDescription: updated.guildDescription,
|
||||
guildName: updated.guildName,
|
||||
profileSettings: profileSettings,
|
||||
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 };
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import {
|
||||
buildPostTranscendenceState,
|
||||
computeTranscendenceMultipliers,
|
||||
@@ -24,168 +26,196 @@ const transcendenceRouter = new Hono<HonoEnvironment>();
|
||||
transcendenceRouter.use("*", authMiddleware);
|
||||
|
||||
transcendenceRouter.post("/", async(context) => {
|
||||
const discordId = context.get("discordId");
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
|
||||
if (!isEligibleForTranscendence(state)) {
|
||||
return context.json(
|
||||
{
|
||||
error: "Not eligible for transcendence — defeat The Absolute One first",
|
||||
if (!isEligibleForTranscendence(state)) {
|
||||
return context.json(
|
||||
{
|
||||
// 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) => {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<BuyEchoUpgradeRequest>();
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<BuyEchoUpgradeRequest>();
|
||||
|
||||
const { upgradeId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!upgradeId) {
|
||||
return context.json({ error: "upgradeId is required" }, 400);
|
||||
}
|
||||
const { upgradeId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!upgradeId) {
|
||||
return context.json({ error: "upgradeId is required" }, 400);
|
||||
}
|
||||
|
||||
const upgrade = defaultTranscendenceUpgrades.find((transcendenceUpgrade) => {
|
||||
return transcendenceUpgrade.id === upgradeId;
|
||||
});
|
||||
if (!upgrade) {
|
||||
return context.json({ error: "Unknown echo upgrade" }, 404);
|
||||
}
|
||||
// eslint-disable-next-line stylistic/max-len -- Variable name mirrors the data source for clarity
|
||||
const upgrade = defaultTranscendenceUpgrades.find((transcendenceUpgrade) => {
|
||||
return transcendenceUpgrade.id === upgradeId;
|
||||
});
|
||||
if (!upgrade) {
|
||||
return context.json({ error: "Unknown echo upgrade" }, 404);
|
||||
}
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
|
||||
if (!state.transcendence) {
|
||||
return context.json({ error: "No transcendence data found" }, 400);
|
||||
}
|
||||
if (!state.transcendence) {
|
||||
return context.json({ error: "No transcendence data found" }, 400);
|
||||
}
|
||||
|
||||
const { purchasedUpgradeIds, echoes } = state.transcendence;
|
||||
const { purchasedUpgradeIds, echoes } = state.transcendence;
|
||||
|
||||
if (purchasedUpgradeIds.includes(upgradeId)) {
|
||||
return context.json({ error: "Upgrade already purchased" }, 400);
|
||||
}
|
||||
if (purchasedUpgradeIds.includes(upgradeId)) {
|
||||
return context.json({ error: "Upgrade already purchased" }, 400);
|
||||
}
|
||||
|
||||
if (echoes < upgrade.cost) {
|
||||
return context.json({ error: "Not enough echoes" }, 400);
|
||||
}
|
||||
if (echoes < upgrade.cost) {
|
||||
return context.json({ error: "Not enough echoes" }, 400);
|
||||
}
|
||||
|
||||
const updatedEchoes = echoes - upgrade.cost;
|
||||
const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ];
|
||||
const updatedMultipliers
|
||||
= computeTranscendenceMultipliers(updatedPurchasedIds);
|
||||
const updatedEchoes = echoes - upgrade.cost;
|
||||
const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ];
|
||||
const updatedMultipliers
|
||||
= computeTranscendenceMultipliers(updatedPurchasedIds);
|
||||
|
||||
const updatedState: GameState = {
|
||||
...state,
|
||||
transcendence: {
|
||||
...state.transcendence,
|
||||
echoes: updatedEchoes,
|
||||
const updatedState: GameState = {
|
||||
...state,
|
||||
transcendence: {
|
||||
...state.transcendence,
|
||||
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,
|
||||
...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 },
|
||||
});
|
||||
|
||||
return context.json({
|
||||
echoesRemaining: updatedEchoes,
|
||||
purchasedUpgradeIds: updatedPurchasedIds,
|
||||
...updatedMultipliers,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"transcendence_buy_upgrade",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { transcendenceRouter };
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* 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 {
|
||||
access_token: string;
|
||||
@@ -50,18 +51,28 @@ const exchangeCode = async(
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
const response = await fetch("https://discord.com/api/v10/oauth2/token", {
|
||||
body: parameters.toString(),
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
method: "POST",
|
||||
});
|
||||
try {
|
||||
const response = await fetch("https://discord.com/api/v10/oauth2/token", {
|
||||
body: parameters.toString(),
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Discord token exchange failed: ${response.statusText}`);
|
||||
if (!response.ok) {
|
||||
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(
|
||||
accessToken: string,
|
||||
): Promise<DiscordUser> => {
|
||||
const response = await fetch("https://discord.com/api/v10/users/@me", {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
try {
|
||||
const response = await fetch("https://discord.com/api/v10/users/@me", {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Discord user fetch failed: ${response.statusText}`);
|
||||
if (!response.ok) {
|
||||
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
|
||||
*/
|
||||
/* 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";
|
||||
|
||||
/**
|
||||
@@ -34,7 +36,13 @@ const grantApotheosisRole = async(discordId: string): Promise<void> => {
|
||||
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
|
||||
}
|
||||
};
|
||||
@@ -81,7 +89,13 @@ const postMilestoneWebhook = async(
|
||||
headers: { "Content-Type": "application/json" },
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -55,4 +55,15 @@ describe("authMiddleware", () => {
|
||||
}));
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
// Need all 15 transcendence upgrades purchased for eligibility
|
||||
const allUpgradeIds = [
|
||||
|
||||
@@ -113,5 +113,14 @@ describe("auth route", () => {
|
||||
const location = res.headers.get("Location") ?? "";
|
||||
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 };
|
||||
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.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);
|
||||
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", () => {
|
||||
const reset = () =>
|
||||
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 };
|
||||
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");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
|
||||
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([] as never);
|
||||
|
||||
@@ -93,6 +93,18 @@ describe("prestige route", () => {
|
||||
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 () => {
|
||||
const state = makeState({
|
||||
dailyChallenges: {
|
||||
@@ -152,5 +164,17 @@ describe("prestige route", () => {
|
||||
expect(body.runestonesRemaining).toBe(90); // 100 - 10
|
||||
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");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const state = makeState({
|
||||
story: {
|
||||
@@ -256,5 +268,23 @@ describe("profile route", () => {
|
||||
const body = await res.json() as { profileSettings: { numberFormat: string } };
|
||||
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.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", () => {
|
||||
@@ -149,5 +161,17 @@ describe("transcendence route", () => {
|
||||
expect(body.echoesRemaining).toBe(95); // 100 - 5
|
||||
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.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");
|
||||
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", () => {
|
||||
@@ -119,5 +128,12 @@ describe("webhook service", () => {
|
||||
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
||||
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" />
|
||||
<title>Elysium — Idle RPG</title>
|
||||
<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>
|
||||
<body>
|
||||
<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,
|
||||
} from "@elysium/types";
|
||||
import { type JSX, useEffect, useState } from "react";
|
||||
import { logError } from "../../utils/logError.js";
|
||||
|
||||
interface CharacterPageProperties {
|
||||
readonly discordId: string;
|
||||
@@ -78,12 +79,16 @@ const CharacterPage = ({ discordId }: CharacterPageProperties): JSX.Element => {
|
||||
}, [ discordId ]);
|
||||
|
||||
function handleCopy(): void {
|
||||
void navigator.clipboard.writeText(window.location.href).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
});
|
||||
void navigator.clipboard.writeText(window.location.href).
|
||||
then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
}).
|
||||
catch((error_: unknown) => {
|
||||
logError("clipboard_copy", error_);
|
||||
});
|
||||
}
|
||||
|
||||
if (error !== null) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { type ChangeEvent, type JSX, useEffect, useRef, useState } from "react";
|
||||
import { updateProfile } from "../../api/client.js";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { logError } from "../../utils/logError.js";
|
||||
|
||||
interface EquippedItem {
|
||||
name: string;
|
||||
@@ -205,12 +206,16 @@ const CharacterSheetPanel = (): JSX.Element => {
|
||||
function handleShareClick(): void {
|
||||
const discordId = player?.discordId ?? "";
|
||||
const url = `${window.location.origin}/character/${discordId}`;
|
||||
void navigator.clipboard.writeText(url).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
});
|
||||
void navigator.clipboard.writeText(url).
|
||||
then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
}).
|
||||
catch((error_: unknown) => {
|
||||
logError("clipboard_copy", error_);
|
||||
});
|
||||
}
|
||||
|
||||
function handleNameChange(event: ChangeEvent<HTMLInputElement>): void {
|
||||
|
||||
@@ -189,6 +189,7 @@ const GameLayout = (): JSX.Element => {
|
||||
<div className="game-main">
|
||||
<aside className="game-sidebar">
|
||||
<ClickArea />
|
||||
<div id="tree-nation-offset-website" />
|
||||
<p className="game-copyright">{"© NHCarrigan"}</p>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
/* eslint-disable complexity -- Many conditional stat visibility checks */
|
||||
import { useEffect, useState, type JSX } from "react";
|
||||
import { formatNumber } from "../../utils/format.js";
|
||||
import { logError } from "../../utils/logError.js";
|
||||
import type { PublicProfileResponse } from "@elysium/types";
|
||||
|
||||
interface ProfilePageProperties {
|
||||
@@ -52,12 +53,16 @@ const ProfilePage = ({ discordId }: ProfilePageProperties): JSX.Element => {
|
||||
}, [ discordId ]);
|
||||
|
||||
function handleCopy(): void {
|
||||
void navigator.clipboard.writeText(window.location.href).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
});
|
||||
void navigator.clipboard.writeText(window.location.href).
|
||||
then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
}).
|
||||
catch((error_: unknown) => {
|
||||
logError("clipboard_copy", error_);
|
||||
});
|
||||
}
|
||||
|
||||
if (error !== null) {
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
} from "../engine/tick.js";
|
||||
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
||||
import { formatNumber as formatNumberUtil } from "../utils/format.js";
|
||||
import { logError } from "../utils/logError.js";
|
||||
import { sendNotification } from "../utils/notification.js";
|
||||
import { playSound } from "../utils/sound.js";
|
||||
|
||||
@@ -1130,6 +1131,8 @@ export const GameProvider = ({
|
||||
) {
|
||||
signatureReference.current = null;
|
||||
localStorage.removeItem("elysium_save_signature");
|
||||
} else {
|
||||
logError("auto_save", error_);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1158,7 +1161,8 @@ export const GameProvider = ({
|
||||
}
|
||||
await reloadReference.current();
|
||||
}).
|
||||
catch(() => {
|
||||
catch((error_: unknown) => {
|
||||
logError("auto_prestige", error_);
|
||||
|
||||
/* Silently ignore — will retry next tick */
|
||||
}).
|
||||
@@ -1200,7 +1204,8 @@ export const GameProvider = ({
|
||||
});
|
||||
setBattleResult({ bossName, result });
|
||||
}).
|
||||
catch(() => {
|
||||
catch((error_: unknown) => {
|
||||
logError("auto_boss", error_);
|
||||
|
||||
/* 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
|
||||
}
|
||||
}, []);
|
||||
|
||||
const transcend = useCallback(async() => {
|
||||
const result = await transcendApi({});
|
||||
setShowTranscendenceToast(true);
|
||||
if (enableSoundsReference.current) {
|
||||
playSound("transcendence");
|
||||
try {
|
||||
const result = await transcendApi({});
|
||||
setShowTranscendenceToast(true);
|
||||
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 ]);
|
||||
|
||||
const apotheosis = useCallback(async() => {
|
||||
const result = await achieveApotheosisApi({});
|
||||
setShowApotheosisToast(true);
|
||||
if (enableSoundsReference.current) {
|
||||
playSound("apotheosis");
|
||||
try {
|
||||
const result = await achieveApotheosisApi({});
|
||||
setShowApotheosisToast(true);
|
||||
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 ]);
|
||||
|
||||
const buyEchoUpgrade = useCallback(async(upgradeId: string) => {
|
||||
@@ -1575,114 +1591,125 @@ export const GameProvider = ({
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// Silently ignore server errors
|
||||
} catch (error_: unknown) {
|
||||
logError("buy_echo_upgrade", error_);
|
||||
// Silently ignore — server errors shouldn't crash the UI
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startExploration = useCallback(async(areaId: string) => {
|
||||
const response = await startExplorationApi({ areaId });
|
||||
const areaData = EXPLORATION_AREAS.find((a) => {
|
||||
return a.id === areaId;
|
||||
});
|
||||
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;
|
||||
try {
|
||||
const response = await startExplorationApi({ areaId });
|
||||
const areaData = EXPLORATION_AREAS.find((a) => {
|
||||
return a.id === areaId;
|
||||
});
|
||||
if (areaData === undefined) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
...previous,
|
||||
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 });
|
||||
// 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;
|
||||
}
|
||||
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, startedAt: startedAt, status: "in_progress" 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("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) {
|
||||
return;
|
||||
}
|
||||
const result = await craftRecipeApi({ recipeId });
|
||||
setState((previous) => {
|
||||
if (previous?.exploration === undefined) {
|
||||
return previous;
|
||||
}
|
||||
let materials = [ ...previous.exploration.materials ];
|
||||
for (const request of recipe.requiredMaterials) {
|
||||
materials = materials.map((mat) => {
|
||||
return mat.materialId === request.materialId
|
||||
? { ...mat, quantity: mat.quantity - request.quantity }
|
||||
: mat;
|
||||
});
|
||||
}
|
||||
return {
|
||||
...previous,
|
||||
exploration: {
|
||||
...previous.exploration,
|
||||
craftedClickMultiplier: result.craftedClickMultiplier,
|
||||
craftedCombatMultiplier: result.craftedCombatMultiplier,
|
||||
craftedEssenceMultiplier: result.craftedEssenceMultiplier,
|
||||
craftedGoldMultiplier: result.craftedGoldMultiplier,
|
||||
craftedRecipeIds: [
|
||||
...previous.exploration.craftedRecipeIds,
|
||||
recipeId,
|
||||
],
|
||||
materials: materials,
|
||||
},
|
||||
};
|
||||
});
|
||||
try {
|
||||
const result = await craftRecipeApi({ recipeId });
|
||||
setState((previous) => {
|
||||
if (previous?.exploration === undefined) {
|
||||
return previous;
|
||||
}
|
||||
let materials = [ ...previous.exploration.materials ];
|
||||
for (const request of recipe.requiredMaterials) {
|
||||
materials = materials.map((mat) => {
|
||||
return mat.materialId === request.materialId
|
||||
? { ...mat, quantity: mat.quantity - request.quantity }
|
||||
: mat;
|
||||
});
|
||||
}
|
||||
return {
|
||||
...previous,
|
||||
exploration: {
|
||||
...previous.exploration,
|
||||
craftedClickMultiplier: result.craftedClickMultiplier,
|
||||
craftedCombatMultiplier: result.craftedCombatMultiplier,
|
||||
craftedEssenceMultiplier: result.craftedEssenceMultiplier,
|
||||
craftedGoldMultiplier: result.craftedGoldMultiplier,
|
||||
craftedRecipeIds: [
|
||||
...previous.exploration.craftedRecipeIds,
|
||||
recipeId,
|
||||
],
|
||||
materials: materials,
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch (error_: unknown) {
|
||||
logError("craft_recipe", error_);
|
||||
throw error_;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleAutoPrestige = useCallback(() => {
|
||||
@@ -1798,7 +1830,8 @@ export const GameProvider = ({
|
||||
return applyBossResult(previous, bossId, result);
|
||||
});
|
||||
setBattleResult({ bossName: boss.name, result: result });
|
||||
} catch {
|
||||
} catch (error_: unknown) {
|
||||
logError("challenge_boss", error_);
|
||||
// Silently ignore — server errors shouldn't crash the UI
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -8,8 +8,12 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./app.js";
|
||||
import { ErrorBoundary } from "./components/errorBoundary.js";
|
||||
import { initialiseFrontendLogger } from "./utils/logger.js";
|
||||
import "./styles.css";
|
||||
|
||||
initialiseFrontendLogger();
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
|
||||
if (!rootElement) {
|
||||
@@ -18,6 +22,8 @@ if (!rootElement) {
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
--radius: 8px;
|
||||
--radius-lg: 12px;
|
||||
--font: "Segoe UI", system-ui, sans-serif;
|
||||
--resource-bar-height: 3.5rem;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -136,6 +137,10 @@ body::before {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
position: sticky;
|
||||
top: var(--resource-bar-height);
|
||||
height: calc(100vh - var(--resource-bar-height));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.game-content {
|
||||
@@ -3181,8 +3186,11 @@ body::before {
|
||||
border-right: none;
|
||||
flex-direction: row;
|
||||
gap: 0.75rem;
|
||||
height: auto;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
position: static;
|
||||
top: auto;
|
||||
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
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { logError } from "./logError.js";
|
||||
|
||||
/**
|
||||
* Requests browser notification permission from the user.
|
||||
@@ -38,7 +39,8 @@ const sendNotification = (title: string, body: string): void => {
|
||||
try {
|
||||
// eslint-disable-next-line no-new -- Notification constructor has side effects
|
||||
new Notification(title, { body: body, icon: "/favicon.ico" });
|
||||
} catch {
|
||||
} catch (error_: unknown) {
|
||||
logError("send_notification", error_);
|
||||
// Silently ignore — notifications may fail silently
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { logError } from "./logError.js";
|
||||
|
||||
type SoundEvent =
|
||||
| "achievement"
|
||||
@@ -101,7 +102,8 @@ const playSound = (event: SoundEvent): void => {
|
||||
oscillator.start(startTime);
|
||||
oscillator.stop(endTime);
|
||||
}
|
||||
} catch {
|
||||
} catch (error_: unknown) {
|
||||
logError("play_sound", error_);
|
||||
// Silently ignore — audio may not be available in all environments
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user