generated from nhcarrigan/template
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9860a2cb1f | |||
| 404b31bd13 | |||
| d0790890ee | |||
| 4d7e624358 | |||
| ac94f67797 | |||
| a36c8e72a5 | |||
| 11e97325cb | |||
| 7a1c57be9a |
@@ -7,6 +7,41 @@
|
|||||||
2. `pnpm build` — all packages build cleanly
|
2. `pnpm build` — all packages build cleanly
|
||||||
3. `pnpm test` — all tests pass with 100% coverage on `apps/api` and `packages/types`
|
3. `pnpm test` — all tests pass with 100% coverage on `apps/api` and `packages/types`
|
||||||
|
|
||||||
|
## Art Assets
|
||||||
|
|
||||||
|
Game art is generated via the Gemini API (`gemini-3-pro-image-preview`, ~$0.134/image at 1K resolution) and hosted on the CDN at `https://cdn.nhcarrigan.com/elysium/`.
|
||||||
|
|
||||||
|
### Process
|
||||||
|
1. Generate images with `curl` to `https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent?key=<API_KEY>`, requesting soft-shaded anime style
|
||||||
|
2. Save responses to `/home/naomi/code/naomi/elysium/img/<category>/<id>.jpg`
|
||||||
|
3. Upload to R2 with: `AWS_ACCESS_KEY_ID=dd0a3d73969143ada84d50f8940cc5e2 AWS_SECRET_ACCESS_KEY=f73e9907da1b2297e93e17f786d6446d33d4ac60e185879578a0d5020899b18e aws s3 sync img/ s3://nhcarrigan-cdn/elysium/ --endpoint-url https://751c386661d378cc032093493cfb0869.r2.cloudflarestorage.com`
|
||||||
|
4. Delete the local `img/` directory before committing (images live on CDN only)
|
||||||
|
|
||||||
|
### CDN URL Helper
|
||||||
|
`apps/web/src/utils/cdn.ts` exports `cdnImage(folder, id)` → `https://cdn.nhcarrigan.com/elysium/<folder>/<id>.jpg`
|
||||||
|
|
||||||
|
### Directory → Category Mapping
|
||||||
|
| Game entity | CDN folder |
|
||||||
|
|---|---|
|
||||||
|
| Zones | `zones` |
|
||||||
|
| Bosses | `bosses` |
|
||||||
|
| Quests | `quests` |
|
||||||
|
| Adventurers | `adventurers` |
|
||||||
|
| Companions | `companions` |
|
||||||
|
| Equipment | `equipment` |
|
||||||
|
| Upgrades | `upgrades` |
|
||||||
|
| Prestige upgrades | `prestige-upgrades` |
|
||||||
|
| Transcendence upgrades | `transcendence-upgrades` |
|
||||||
|
| Achievements | `achievements` |
|
||||||
|
| Explorations | `explorations` |
|
||||||
|
| Materials | `materials` |
|
||||||
|
| Recipes | `recipes` |
|
||||||
|
| Story chapter banners | `story-chapters` |
|
||||||
|
|
||||||
|
### API Rate Limits
|
||||||
|
- 250 images/day per API key — use a second key if quota is hit
|
||||||
|
- Free-tier keys cannot use `gemini-3-pro-image-preview`; key must be on a billing-linked project
|
||||||
|
|
||||||
## About Page
|
## About Page
|
||||||
|
|
||||||
The About page (`apps/web/src/components/game/aboutPanel.tsx`) contains a **How to Play** guide that should be kept up to date as new features are added to the game. When implementing new game systems, zones, mechanics, or significant UI features, update the `HOW_TO_PLAY` array in `aboutPanel.tsx` to include a description of the new feature.
|
The About page (`apps/web/src/components/game/aboutPanel.tsx`) contains a **How to Play** guide that should be kept up to date as new features are added to the game. When implementing new game systems, zones, mechanics, or significant UI features, update the `HOW_TO_PLAY` array in `aboutPanel.tsx` to include a description of the new feature.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@elysium/types": "workspace:*",
|
"@elysium/types": "workspace:*",
|
||||||
"@hono/node-server": "1.13.7",
|
"@hono/node-server": "1.13.7",
|
||||||
|
"@nhcarrigan/logger": "1.1.1",
|
||||||
"@prisma/client": "6.5.0",
|
"@prisma/client": "6.5.0",
|
||||||
"hono": "4.7.4",
|
"hono": "4.7.4",
|
||||||
"prisma": "6.5.0"
|
"prisma": "6.5.0"
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord mi
|
|||||||
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token"
|
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token"
|
||||||
DISCORD_GUILD_ID="op://Environment Variables - Naomi/Elysium/discord guild id"
|
DISCORD_GUILD_ID="op://Environment Variables - Naomi/Elysium/discord guild id"
|
||||||
DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id"
|
DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id"
|
||||||
|
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
|
||||||
@@ -76,6 +76,8 @@ const initialGameState = (
|
|||||||
achievements: structuredClone(defaultAchievements),
|
achievements: structuredClone(defaultAchievements),
|
||||||
adventurers: structuredClone(defaultAdventurers),
|
adventurers: structuredClone(defaultAdventurers),
|
||||||
apotheosis: { ...initialApotheosis },
|
apotheosis: { ...initialApotheosis },
|
||||||
|
autoBoss: false,
|
||||||
|
autoQuest: false,
|
||||||
baseClickPower: 1,
|
baseClickPower: 1,
|
||||||
bosses: structuredClone(defaultBosses),
|
bosses: structuredClone(defaultBosses),
|
||||||
companions: { activeCompanionId: null, unlockedCompanionIds: [] },
|
companions: { activeCompanionId: null, unlockedCompanionIds: [] },
|
||||||
|
|||||||
+26
-4
@@ -7,22 +7,24 @@
|
|||||||
import { serve } from "@hono/node-server";
|
import { serve } from "@hono/node-server";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { logger } from "hono/logger";
|
import { logger as honoLogger } from "hono/logger";
|
||||||
import { aboutRouter } from "./routes/about.js";
|
import { aboutRouter } from "./routes/about.js";
|
||||||
import { apotheosisRouter } from "./routes/apotheosis.js";
|
import { apotheosisRouter } from "./routes/apotheosis.js";
|
||||||
import { authRouter } from "./routes/auth.js";
|
import { authRouter } from "./routes/auth.js";
|
||||||
import { bossRouter } from "./routes/boss.js";
|
import { bossRouter } from "./routes/boss.js";
|
||||||
import { craftRouter } from "./routes/craft.js";
|
import { craftRouter } from "./routes/craft.js";
|
||||||
import { exploreRouter } from "./routes/explore.js";
|
import { exploreRouter } from "./routes/explore.js";
|
||||||
|
import { frontendRouter } from "./routes/frontend.js";
|
||||||
import { gameRouter } from "./routes/game.js";
|
import { gameRouter } from "./routes/game.js";
|
||||||
import { leaderboardRouter } from "./routes/leaderboards.js";
|
import { leaderboardRouter } from "./routes/leaderboards.js";
|
||||||
import { prestigeRouter } from "./routes/prestige.js";
|
import { prestigeRouter } from "./routes/prestige.js";
|
||||||
import { profileRouter } from "./routes/profile.js";
|
import { profileRouter } from "./routes/profile.js";
|
||||||
import { transcendenceRouter } from "./routes/transcendence.js";
|
import { transcendenceRouter } from "./routes/transcendence.js";
|
||||||
|
import { logger } from "./services/logger.js";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
app.use("*", logger());
|
app.use("*", honoLogger());
|
||||||
app.use(
|
app.use(
|
||||||
"*",
|
"*",
|
||||||
cors({
|
cors({
|
||||||
@@ -33,6 +35,7 @@ app.use(
|
|||||||
);
|
);
|
||||||
|
|
||||||
app.route("/about", aboutRouter);
|
app.route("/about", aboutRouter);
|
||||||
|
app.route("/fe", frontendRouter);
|
||||||
app.route("/auth", authRouter);
|
app.route("/auth", authRouter);
|
||||||
app.route("/game", gameRouter);
|
app.route("/game", gameRouter);
|
||||||
app.route("/boss", bossRouter);
|
app.route("/boss", bossRouter);
|
||||||
@@ -48,8 +51,27 @@ app.get("/health", (context) => {
|
|||||||
return context.json({ status: "ok" });
|
return context.json({ status: "ok" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.onError((error, context) => {
|
||||||
|
void logger.error(
|
||||||
|
"hono_unhandled_error",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
});
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 3001);
|
const port = Number(process.env.PORT ?? 3001);
|
||||||
|
|
||||||
serve({ fetch: app.fetch, port: port }, () => {
|
try {
|
||||||
|
serve({ fetch: app.fetch, port: port }, () => {
|
||||||
process.stdout.write(`Elysium API running on port ${String(port)}\n`);
|
process.stdout.write(`Elysium API running on port ${String(port)}\n`);
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"server_startup",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { verifyToken } from "../services/jwt.js";
|
import { verifyToken } from "../services/jwt.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
import type { MiddlewareHandler } from "hono";
|
import type { MiddlewareHandler } from "hono";
|
||||||
|
|
||||||
@@ -33,7 +34,13 @@ export const authMiddleware: MiddlewareHandler<HonoEnvironment> = async(
|
|||||||
try {
|
try {
|
||||||
const payload = verifyToken(token);
|
const payload = verifyToken(token);
|
||||||
context.set("discordId", payload.discordId);
|
context.set("discordId", payload.discordId);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"auth_middleware",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
return context.json({ error: "Invalid or expired token" }, 401);
|
return context.json({ error: "Invalid or expired token" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
/* eslint-disable stylistic/max-len -- URL cannot be shortened */
|
/* eslint-disable stylistic/max-len -- URL cannot be shortened */
|
||||||
/* eslint-disable require-atomic-updates -- Simple cache; race condition is acceptable */
|
/* eslint-disable require-atomic-updates -- Simple cache; race condition is acceptable */
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { AboutResponse, GiteaRelease } from "@elysium/types";
|
import type { AboutResponse, GiteaRelease } from "@elysium/types";
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
@@ -46,12 +47,24 @@ const fetchReleases = async(): Promise<Array<GiteaRelease>> => {
|
|||||||
const aboutRouter = new Hono();
|
const aboutRouter = new Hono();
|
||||||
|
|
||||||
aboutRouter.get("/", async(context) => {
|
aboutRouter.get("/", async(context) => {
|
||||||
|
try {
|
||||||
const releases = await fetchReleases();
|
const releases = await fetchReleases();
|
||||||
const body: AboutResponse = {
|
const body: AboutResponse = {
|
||||||
apiVersion,
|
apiVersion,
|
||||||
releases,
|
releases,
|
||||||
};
|
};
|
||||||
return context.json(body);
|
return context.json(body);
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 9 -- @preserve */
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"about",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { aboutRouter };
|
export { aboutRouter };
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
/* eslint-disable max-lines-per-function -- Route handler requires many steps */
|
/* eslint-disable max-lines-per-function -- Route handler requires many steps */
|
||||||
|
/* eslint-disable max-statements -- Route handler requires many statements */
|
||||||
|
|
||||||
/* eslint-disable stylistic/max-len -- Description string cannot be shortened */
|
/* eslint-disable stylistic/max-len -- Description string cannot be shortened */
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
@@ -13,6 +15,7 @@ import {
|
|||||||
buildPostApotheosisState,
|
buildPostApotheosisState,
|
||||||
isEligibleForApotheosis,
|
isEligibleForApotheosis,
|
||||||
} from "../services/apotheosis.js";
|
} from "../services/apotheosis.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import {
|
import {
|
||||||
grantApotheosisRole,
|
grantApotheosisRole,
|
||||||
postMilestoneWebhook,
|
postMilestoneWebhook,
|
||||||
@@ -25,6 +28,7 @@ const apotheosisRouter = new Hono<HonoEnvironment>();
|
|||||||
apotheosisRouter.use("*", authMiddleware);
|
apotheosisRouter.use("*", authMiddleware);
|
||||||
|
|
||||||
apotheosisRouter.post("/", async(context) => {
|
apotheosisRouter.post("/", async(context) => {
|
||||||
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
@@ -103,6 +107,8 @@ apotheosisRouter.post("/", async(context) => {
|
|||||||
where: { discordId },
|
where: { discordId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const apotheosisCount = updatedApotheosisData.count;
|
||||||
|
void logger.metric("apotheosis", 1, { apotheosisCount, discordId });
|
||||||
void grantApotheosisRole(discordId);
|
void grantApotheosisRole(discordId);
|
||||||
void postMilestoneWebhook(discordId, "apotheosis", {
|
void postMilestoneWebhook(discordId, "apotheosis", {
|
||||||
apotheosis: updatedApotheosisData.count,
|
apotheosis: updatedApotheosisData.count,
|
||||||
@@ -113,6 +119,15 @@ apotheosisRouter.post("/", async(context) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return context.json({ apotheosisCount: updatedApotheosisData.count });
|
return context.json({ apotheosisCount: updatedApotheosisData.count });
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"apotheosis",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { apotheosisRouter };
|
export { apotheosisRouter };
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
fetchDiscordUser,
|
fetchDiscordUser,
|
||||||
} from "../services/discord.js";
|
} from "../services/discord.js";
|
||||||
import { signToken } from "../services/jwt.js";
|
import { signToken } from "../services/jwt.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { Player } from "@elysium/types";
|
import type { Player } from "@elysium/types";
|
||||||
|
|
||||||
const authRouter = new Hono();
|
const authRouter = new Hono();
|
||||||
@@ -92,6 +93,8 @@ authRouter.get("/callback", async(context) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const jwtToken = signToken(player.discordId);
|
const jwtToken = signToken(player.discordId);
|
||||||
|
void logger.log("info", `New player registered: ${player.discordId}`);
|
||||||
|
void logger.metric("user_registered", 1, { discordId: player.discordId });
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next -- @preserve */
|
||||||
@@ -111,6 +114,8 @@ authRouter.get("/callback", async(context) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const jwtToken = signToken(updated.discordId);
|
const jwtToken = signToken(updated.discordId);
|
||||||
|
void logger.log("info", `Player logged in: ${updated.discordId}`);
|
||||||
|
void logger.metric("user_login", 1, { discordId: updated.discordId });
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next -- @preserve */
|
||||||
@@ -118,7 +123,13 @@ authRouter.get("/callback", async(context) => {
|
|||||||
return context.redirect(
|
return context.redirect(
|
||||||
`${clientUrl}/auth/callback?token=${jwtToken}&isNew=false`,
|
`${clientUrl}/auth/callback?token=${jwtToken}&isNew=false`,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"auth_callback",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next -- @preserve */
|
||||||
const clientUrl = process.env.CORS_ORIGIN ?? "http://localhost:5173";
|
const clientUrl = process.env.CORS_ORIGIN ?? "http://localhost:5173";
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
/* eslint-disable max-statements -- Boss handler requires many statements */
|
/* eslint-disable max-statements -- Boss handler requires many statements */
|
||||||
/* eslint-disable complexity -- Boss handler has inherent complexity */
|
/* eslint-disable complexity -- Boss handler has inherent complexity */
|
||||||
/* eslint-disable stylistic/max-len -- Long lines in combat logic */
|
/* eslint-disable stylistic/max-len -- Long lines in combat logic */
|
||||||
|
/* eslint-disable max-lines -- Boss route with full combat logic and helpers exceeds line limit */
|
||||||
import {
|
import {
|
||||||
computeSetBonuses,
|
computeSetBonuses,
|
||||||
getActiveCompanionBonus,
|
getActiveCompanionBonus,
|
||||||
@@ -20,6 +21,7 @@ import { defaultEquipmentSets } from "../data/equipmentSets.js";
|
|||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
|
|
||||||
const bossRouter = new Hono<HonoEnvironment>();
|
const bossRouter = new Hono<HonoEnvironment>();
|
||||||
@@ -121,6 +123,7 @@ const calculatePartyStats = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
bossRouter.post("/challenge", async(context) => {
|
bossRouter.post("/challenge", async(context) => {
|
||||||
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
const body = await context.req.json<{ bossId: string }>();
|
const body = await context.req.json<{ bossId: string }>();
|
||||||
|
|
||||||
@@ -296,14 +299,20 @@ bossRouter.post("/challenge", async(context) => {
|
|||||||
state.resources.crystals = state.resources.crystals + crystalsAwarded;
|
state.resources.crystals = state.resources.crystals + crystalsAwarded;
|
||||||
}
|
}
|
||||||
|
|
||||||
// First-kill bounty — look up authoritative bounty from static data
|
// First-kill bounty — only awarded once across all prestiges
|
||||||
const staticBoss = defaultBosses.find((b) => {
|
const staticBoss = defaultBosses.find((b) => {
|
||||||
return b.id === body.bossId;
|
return b.id === body.bossId;
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next 7 -- @preserve */
|
||||||
const bountyRunestones = staticBoss?.bountyRunestones ?? 0;
|
const bountyRunestones
|
||||||
|
= boss.bountyRunestonesClaimed === true
|
||||||
|
? 0
|
||||||
|
: staticBoss?.bountyRunestones ?? 0;
|
||||||
|
if (bountyRunestones > 0) {
|
||||||
|
boss.bountyRunestonesClaimed = true;
|
||||||
|
}
|
||||||
state.prestige.runestones = state.prestige.runestones + bountyRunestones;
|
state.prestige.runestones = state.prestige.runestones + bountyRunestones;
|
||||||
|
|
||||||
rewards = {
|
rewards = {
|
||||||
@@ -348,6 +357,9 @@ bossRouter.post("/challenge", async(context) => {
|
|||||||
where: { discordId },
|
where: { discordId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { bossId } = body;
|
||||||
|
void logger.metric("boss_challenge", 1, { bossId, discordId, won });
|
||||||
|
|
||||||
const bossMaxHp = boss.maxHp;
|
const bossMaxHp = boss.maxHp;
|
||||||
const bossNewHp = bossUpdatedHp;
|
const bossNewHp = bossUpdatedHp;
|
||||||
const response: BossChallengeResponse = {
|
const response: BossChallengeResponse = {
|
||||||
@@ -369,6 +381,15 @@ bossRouter.post("/challenge", async(context) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return context.json(response);
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { bossRouter };
|
export { bossRouter };
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Hono } from "hono";
|
|||||||
import { defaultRecipes } from "../data/recipes.js";
|
import { defaultRecipes } from "../data/recipes.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
import type {
|
import type {
|
||||||
CraftRecipeRequest,
|
CraftRecipeRequest,
|
||||||
@@ -63,6 +64,7 @@ const recomputeCraftedMultipliers = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
craftRouter.post("/", async(context) => {
|
craftRouter.post("/", async(context) => {
|
||||||
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
const body = await context.req.json<CraftRecipeRequest>();
|
const body = await context.req.json<CraftRecipeRequest>();
|
||||||
|
|
||||||
@@ -142,6 +144,8 @@ craftRouter.post("/", async(context) => {
|
|||||||
where: { discordId },
|
where: { discordId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void logger.metric("recipe_crafted", 1, { discordId, recipeId });
|
||||||
|
|
||||||
const bonusType = recipe.bonus.type;
|
const bonusType = recipe.bonus.type;
|
||||||
const bonusValue = recipe.bonus.value;
|
const bonusValue = recipe.bonus.value;
|
||||||
const response: CraftRecipeResponse = {
|
const response: CraftRecipeResponse = {
|
||||||
@@ -151,6 +155,15 @@ craftRouter.post("/", async(context) => {
|
|||||||
...updatedMultipliers,
|
...updatedMultipliers,
|
||||||
};
|
};
|
||||||
return context.json(response);
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { craftRouter };
|
export { craftRouter };
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { defaultExplorations } from "../data/explorations.js";
|
|||||||
import { initialExploration } from "../data/initialState.js";
|
import { initialExploration } from "../data/initialState.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
import type {
|
import type {
|
||||||
ExploreCollectEventResult,
|
ExploreCollectEventResult,
|
||||||
@@ -49,6 +50,7 @@ const pickNothingMessage = (): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
exploreRouter.post("/start", async(context) => {
|
exploreRouter.post("/start", async(context) => {
|
||||||
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
const body = await context.req.json<ExploreStartRequest>();
|
const body = await context.req.json<ExploreStartRequest>();
|
||||||
|
|
||||||
@@ -108,7 +110,10 @@ exploreRouter.post("/start", async(context) => {
|
|||||||
return a.id === areaId;
|
return a.id === areaId;
|
||||||
});
|
});
|
||||||
if (!area) {
|
if (!area) {
|
||||||
return context.json({ error: "Exploration area not found in state" }, 404);
|
return context.json(
|
||||||
|
{ error: "Exploration area not found in state" },
|
||||||
|
404,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const anyInProgress = state.exploration.areas.some((a) => {
|
const anyInProgress = state.exploration.areas.some((a) => {
|
||||||
@@ -142,9 +147,19 @@ exploreRouter.post("/start", async(context) => {
|
|||||||
endsAt,
|
endsAt,
|
||||||
};
|
};
|
||||||
return context.json(response);
|
return context.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"explore_start",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
exploreRouter.post("/collect", async(context) => {
|
exploreRouter.post("/collect", async(context) => {
|
||||||
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
const body = await context.req.json<ExploreCollectRequest>();
|
const body = await context.req.json<ExploreCollectRequest>();
|
||||||
|
|
||||||
@@ -218,7 +233,9 @@ exploreRouter.post("/collect", async(context) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pick a random event
|
// Pick a random event
|
||||||
const eventIndex = Math.floor(Math.random() * explorationArea.events.length);
|
const eventIndex = Math.floor(
|
||||||
|
Math.random() * explorationArea.events.length,
|
||||||
|
);
|
||||||
const event = explorationArea.events[eventIndex];
|
const event = explorationArea.events[eventIndex];
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next 3 -- @preserve */
|
/* v8 ignore next 3 -- @preserve */
|
||||||
@@ -350,6 +367,15 @@ exploreRouter.post("/collect", async(context) => {
|
|||||||
materialsFound: materialsFound,
|
materialsFound: materialsFound,
|
||||||
};
|
};
|
||||||
return context.json(response);
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { exploreRouter };
|
export { exploreRouter };
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* @file Frontend logging routes that pipe client-side logs to the telemetry service.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
|
|
||||||
|
const validLevels = new Set([ "debug", "info", "warn" ]);
|
||||||
|
|
||||||
|
const frontendRouter = new Hono();
|
||||||
|
|
||||||
|
frontendRouter.post("/log", async(context) => {
|
||||||
|
try {
|
||||||
|
const body = await context.req.json<{ level: string; message: string }>();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
|
if (!body.level || !body.message || !validLevels.has(body.level)) {
|
||||||
|
return context.json({ error: "level and message are required" }, 400);
|
||||||
|
}
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- validated above */
|
||||||
|
void logger.log(body.level as "debug" | "info" | "warn", `[FE] ${body.message}`);
|
||||||
|
return context.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"frontend_log",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
frontendRouter.post("/error", async(context) => {
|
||||||
|
try {
|
||||||
|
const body = await context.req.json<{ context: string; message: string }>();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
|
if (!body.context || !body.message) {
|
||||||
|
return context.json({ error: "context and message are required" }, 400);
|
||||||
|
}
|
||||||
|
void logger.error(`[FE] ${body.context}`, new Error(body.message));
|
||||||
|
return context.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"frontend_error",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { frontendRouter };
|
||||||
@@ -27,6 +27,7 @@ import { currentSchemaVersion } from "../data/schemaVersion.js";
|
|||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
|
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import { calculateOfflineEarnings } from "../services/offlineProgress.js";
|
import { calculateOfflineEarnings } from "../services/offlineProgress.js";
|
||||||
import {
|
import {
|
||||||
checkAndUnlockTitles,
|
checkAndUnlockTitles,
|
||||||
@@ -681,6 +682,7 @@ const gameRouter = new Hono<HonoEnvironment>();
|
|||||||
gameRouter.use("*", authMiddleware);
|
gameRouter.use("*", authMiddleware);
|
||||||
|
|
||||||
gameRouter.get("/load", async(context) => {
|
gameRouter.get("/load", async(context) => {
|
||||||
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
const [ record, playerRecord ] = await Promise.all([
|
const [ record, playerRecord ] = await Promise.all([
|
||||||
@@ -701,7 +703,9 @@ gameRouter.get("/load", async(context) => {
|
|||||||
discordId: playerRecord.discordId,
|
discordId: playerRecord.discordId,
|
||||||
discriminator: playerRecord.discriminator,
|
discriminator: playerRecord.discriminator,
|
||||||
lastSavedAt: Date.now(),
|
lastSavedAt: Date.now(),
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Long property names exceed limit after try-block indent
|
||||||
lifetimeAchievementsUnlocked: playerRecord.lifetimeAchievementsUnlocked,
|
lifetimeAchievementsUnlocked: playerRecord.lifetimeAchievementsUnlocked,
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Long property names exceed limit after try-block indent
|
||||||
lifetimeAdventurersRecruited: playerRecord.lifetimeAdventurersRecruited,
|
lifetimeAdventurersRecruited: playerRecord.lifetimeAdventurersRecruited,
|
||||||
lifetimeBossesDefeated: playerRecord.lifetimeBossesDefeated,
|
lifetimeBossesDefeated: playerRecord.lifetimeBossesDefeated,
|
||||||
lifetimeClicks: playerRecord.lifetimeClicks,
|
lifetimeClicks: playerRecord.lifetimeClicks,
|
||||||
@@ -880,9 +884,19 @@ gameRouter.get("/load", async(context) => {
|
|||||||
signature,
|
signature,
|
||||||
state,
|
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) => {
|
gameRouter.post("/save", async(context) => {
|
||||||
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
const body = await context.req.json<SaveRequest>();
|
const body = await context.req.json<SaveRequest>();
|
||||||
|
|
||||||
@@ -896,6 +910,7 @@ gameRouter.post("/save", async(context) => {
|
|||||||
if ((body.state.schemaVersion ?? 0) < currentSchemaVersion) {
|
if ((body.state.schemaVersion ?? 0) < currentSchemaVersion) {
|
||||||
return context.json(
|
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.",
|
error: "Save rejected: outdated save. Reset your progress to continue.",
|
||||||
},
|
},
|
||||||
409,
|
409,
|
||||||
@@ -1026,12 +1041,24 @@ gameRouter.post("/save", async(context) => {
|
|||||||
? undefined
|
? undefined
|
||||||
: computeHmac(JSON.stringify(stateToSave), secret);
|
: computeHmac(JSON.stringify(stateToSave), secret);
|
||||||
return context.json({ savedAt: now, signature: signature });
|
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) => {
|
gameRouter.post("/reset", async(context) => {
|
||||||
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
const playerRecord = await prisma.player.findUnique({ where: { discordId } });
|
const playerRecord = await prisma.player.findUnique({
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
if (!playerRecord) {
|
if (!playerRecord) {
|
||||||
return context.json({ error: "No player found" }, 404);
|
return context.json({ error: "No player found" }, 404);
|
||||||
}
|
}
|
||||||
@@ -1086,6 +1113,15 @@ gameRouter.post("/reset", async(context) => {
|
|||||||
signature: signature,
|
signature: signature,
|
||||||
state: freshState,
|
state: freshState,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"game_reset",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { gameRouter };
|
export { gameRouter };
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { gameTitles } from "../data/titles.js";
|
import { gameTitles } from "../data/titles.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
import type { GameState } from "@elysium/types";
|
import type { GameState } from "@elysium/types";
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ const resolveTitleName = (titleId: string | null): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
leaderboardRouter.get("/", async(context) => {
|
leaderboardRouter.get("/", async(context) => {
|
||||||
|
try {
|
||||||
const category = context.req.query("category") ?? "totalGold";
|
const category = context.req.query("category") ?? "totalGold";
|
||||||
const limitRaw = Number(context.req.query("limit") ?? "100");
|
const limitRaw = Number(context.req.query("limit") ?? "100");
|
||||||
const limit = Math.min(Math.max(1, limitRaw), 100);
|
const limit = Math.min(Math.max(1, limitRaw), 100);
|
||||||
@@ -122,6 +124,15 @@ leaderboardRouter.get("/", async(context) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return context.json({ category, entries });
|
return context.json({ category, entries });
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"leaderboards",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { leaderboardRouter };
|
export { leaderboardRouter };
|
||||||
|
|||||||
@@ -6,11 +6,13 @@
|
|||||||
*/
|
*/
|
||||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||||
|
/* eslint-disable complexity -- Route handlers have inherent complexity */
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
|
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import {
|
import {
|
||||||
buildPostPrestigeState,
|
buildPostPrestigeState,
|
||||||
computeRunestoneMultipliers,
|
computeRunestoneMultipliers,
|
||||||
@@ -25,6 +27,7 @@ const prestigeRouter = new Hono<HonoEnvironment>();
|
|||||||
prestigeRouter.use("*", authMiddleware);
|
prestigeRouter.use("*", authMiddleware);
|
||||||
|
|
||||||
prestigeRouter.post("/", async(context) => {
|
prestigeRouter.post("/", async(context) => {
|
||||||
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
@@ -39,6 +42,7 @@ prestigeRouter.post("/", async(context) => {
|
|||||||
if (!isEligibleForPrestige(state)) {
|
if (!isEligibleForPrestige(state)) {
|
||||||
return context.json(
|
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",
|
error: "Not eligible for prestige — collect 1,000,000 total gold first",
|
||||||
},
|
},
|
||||||
400,
|
400,
|
||||||
@@ -130,6 +134,8 @@ prestigeRouter.post("/", async(context) => {
|
|||||||
where: { discordId },
|
where: { discordId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const prestigeCount = prestigeData.count;
|
||||||
|
void logger.metric("prestige", 1, { discordId, prestigeCount });
|
||||||
void postMilestoneWebhook(discordId, "prestige", {
|
void postMilestoneWebhook(discordId, "prestige", {
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next -- @preserve */
|
||||||
@@ -147,9 +153,19 @@ prestigeRouter.post("/", async(context) => {
|
|||||||
newPrestigeCount: prestigeData.count, // eslint-disable-line unicorn/no-keyword-prefix -- API response field name required by client
|
newPrestigeCount: prestigeData.count, // eslint-disable-line unicorn/no-keyword-prefix -- API response field name required by client
|
||||||
runestones: runestonesEarned,
|
runestones: runestonesEarned,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"prestige",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
prestigeRouter.post("/buy-upgrade", async(context) => {
|
prestigeRouter.post("/buy-upgrade", async(context) => {
|
||||||
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
const body = await context.req.json<BuyPrestigeUpgradeRequest>();
|
const body = await context.req.json<BuyPrestigeUpgradeRequest>();
|
||||||
|
|
||||||
@@ -204,11 +220,24 @@ prestigeRouter.post("/buy-upgrade", async(context) => {
|
|||||||
|
|
||||||
const multipliers = computeRunestoneMultipliers(updatedPurchasedUpgradeIds);
|
const multipliers = computeRunestoneMultipliers(updatedPurchasedUpgradeIds);
|
||||||
|
|
||||||
|
void logger.metric("prestige_upgrade_purchased", 1, {
|
||||||
|
discordId,
|
||||||
|
upgradeId,
|
||||||
|
});
|
||||||
return context.json({
|
return context.json({
|
||||||
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
|
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
|
||||||
runestonesRemaining: updatedRunestones,
|
runestonesRemaining: updatedRunestones,
|
||||||
...multipliers,
|
...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 };
|
export { prestigeRouter };
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Hono } from "hono";
|
|||||||
import { gameTitles } from "../data/titles.js";
|
import { gameTitles } from "../data/titles.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import { parseUnlockedTitles } from "../services/titles.js";
|
import { parseUnlockedTitles } from "../services/titles.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
|
|
||||||
@@ -81,6 +82,7 @@ const resolveTitle = (id: string): { id: string; name: string } => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
profileRouter.get("/:discordId", async(context) => {
|
profileRouter.get("/:discordId", async(context) => {
|
||||||
|
try {
|
||||||
const { discordId } = context.req.param();
|
const { discordId } = context.req.param();
|
||||||
|
|
||||||
const [ player, gameStateRecord ] = await Promise.all([
|
const [ player, gameStateRecord ] = await Promise.all([
|
||||||
@@ -177,9 +179,19 @@ profileRouter.get("/:discordId", async(context) => {
|
|||||||
unlockedTitles: unlockedTitles,
|
unlockedTitles: unlockedTitles,
|
||||||
username: player.username,
|
username: player.username,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"profile_get",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
profileRouter.put("/", authMiddleware, async(context) => {
|
profileRouter.put("/", authMiddleware, async(context) => {
|
||||||
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
const body = await context.req.json<UpdateProfileRequest>();
|
const body = await context.req.json<UpdateProfileRequest>();
|
||||||
|
|
||||||
@@ -265,6 +277,15 @@ profileRouter.put("/", authMiddleware, async(context) => {
|
|||||||
profileSettings: profileSettings,
|
profileSettings: profileSettings,
|
||||||
pronouns: updated.pronouns,
|
pronouns: updated.pronouns,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"profile_update",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { profileRouter };
|
export { profileRouter };
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
*/
|
*/
|
||||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||||
|
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js";
|
import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import {
|
import {
|
||||||
buildPostTranscendenceState,
|
buildPostTranscendenceState,
|
||||||
computeTranscendenceMultipliers,
|
computeTranscendenceMultipliers,
|
||||||
@@ -24,6 +26,7 @@ const transcendenceRouter = new Hono<HonoEnvironment>();
|
|||||||
transcendenceRouter.use("*", authMiddleware);
|
transcendenceRouter.use("*", authMiddleware);
|
||||||
|
|
||||||
transcendenceRouter.post("/", async(context) => {
|
transcendenceRouter.post("/", async(context) => {
|
||||||
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
@@ -37,6 +40,7 @@ transcendenceRouter.post("/", async(context) => {
|
|||||||
if (!isEligibleForTranscendence(state)) {
|
if (!isEligibleForTranscendence(state)) {
|
||||||
return context.json(
|
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",
|
error: "Not eligible for transcendence — defeat The Absolute One first",
|
||||||
},
|
},
|
||||||
400,
|
400,
|
||||||
@@ -102,6 +106,8 @@ transcendenceRouter.post("/", async(context) => {
|
|||||||
where: { discordId },
|
where: { discordId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const transcendenceCount = transcendenceData.count;
|
||||||
|
void logger.metric("transcendence", 1, { discordId, transcendenceCount });
|
||||||
void postMilestoneWebhook(discordId, "transcendence", {
|
void postMilestoneWebhook(discordId, "transcendence", {
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next -- @preserve */
|
||||||
@@ -119,9 +125,19 @@ transcendenceRouter.post("/", async(context) => {
|
|||||||
// eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client
|
// eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client
|
||||||
newTranscendenceCount: transcendenceData.count,
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
transcendenceRouter.post("/buy-upgrade", async(context) => {
|
transcendenceRouter.post("/buy-upgrade", async(context) => {
|
||||||
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
const body = await context.req.json<BuyEchoUpgradeRequest>();
|
const body = await context.req.json<BuyEchoUpgradeRequest>();
|
||||||
|
|
||||||
@@ -131,6 +147,7 @@ transcendenceRouter.post("/buy-upgrade", async(context) => {
|
|||||||
return context.json({ error: "upgradeId is required" }, 400);
|
return context.json({ error: "upgradeId is required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Variable name mirrors the data source for clarity
|
||||||
const upgrade = defaultTranscendenceUpgrades.find((transcendenceUpgrade) => {
|
const upgrade = defaultTranscendenceUpgrades.find((transcendenceUpgrade) => {
|
||||||
return transcendenceUpgrade.id === upgradeId;
|
return transcendenceUpgrade.id === upgradeId;
|
||||||
});
|
});
|
||||||
@@ -181,11 +198,24 @@ transcendenceRouter.post("/buy-upgrade", async(context) => {
|
|||||||
where: { discordId },
|
where: { discordId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void logger.metric("transcendence_upgrade_purchased", 1, {
|
||||||
|
discordId,
|
||||||
|
upgradeId,
|
||||||
|
});
|
||||||
return context.json({
|
return context.json({
|
||||||
echoesRemaining: updatedEchoes,
|
echoesRemaining: updatedEchoes,
|
||||||
purchasedUpgradeIds: updatedPurchasedIds,
|
purchasedUpgradeIds: updatedPurchasedIds,
|
||||||
...updatedMultipliers,
|
...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 };
|
export { transcendenceRouter };
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */
|
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */
|
||||||
|
import { logger } from "./logger.js";
|
||||||
|
|
||||||
interface DiscordTokenResponse {
|
interface DiscordTokenResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
@@ -50,6 +51,7 @@ const exchangeCode = async(
|
|||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
const response = await fetch("https://discord.com/api/v10/oauth2/token", {
|
const response = await fetch("https://discord.com/api/v10/oauth2/token", {
|
||||||
body: parameters.toString(),
|
body: parameters.toString(),
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
@@ -62,6 +64,15 @@ const exchangeCode = async(
|
|||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordTokenResponse shape */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordTokenResponse shape */
|
||||||
return await (response.json() as Promise<DiscordTokenResponse>);
|
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;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,6 +84,7 @@ const exchangeCode = async(
|
|||||||
const fetchDiscordUser = async(
|
const fetchDiscordUser = async(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
): Promise<DiscordUser> => {
|
): Promise<DiscordUser> => {
|
||||||
|
try {
|
||||||
const response = await fetch("https://discord.com/api/v10/users/@me", {
|
const response = await fetch("https://discord.com/api/v10/users/@me", {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
});
|
});
|
||||||
@@ -83,6 +95,15 @@ const fetchDiscordUser = async(
|
|||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */
|
||||||
return await (response.json() as Promise<DiscordUser>);
|
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;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -205,9 +205,68 @@ const buildPostPrestigeState = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const freshState = initialGameState(currentState.player, characterName);
|
const freshState = initialGameState(currentState.player, characterName);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Preserve first-kill (bounty claimed) status across the prestige reset so
|
||||||
|
* the one-time bounty is never re-awarded in subsequent runs.
|
||||||
|
*/
|
||||||
|
const bossesWithBountyClaimed = freshState.bosses.map((freshBoss) => {
|
||||||
|
const currentBoss = currentState.bosses.find((candidate) => {
|
||||||
|
return candidate.id === freshBoss.id;
|
||||||
|
});
|
||||||
|
if (currentBoss?.bountyRunestonesClaimed === true) {
|
||||||
|
return { ...freshBoss, bountyRunestonesClaimed: true };
|
||||||
|
}
|
||||||
|
return freshBoss;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compute current-run contributions to accumulate into lifetime totals
|
||||||
|
const runBossesDefeated = currentState.bosses.filter((boss) => {
|
||||||
|
return boss.status === "defeated";
|
||||||
|
}).length;
|
||||||
|
const runQuestsCompleted = currentState.quests.filter((quest) => {
|
||||||
|
return quest.status === "completed";
|
||||||
|
}).length;
|
||||||
|
let runAdventurersRecruited = 0;
|
||||||
|
for (const adventurer of currentState.adventurers) {
|
||||||
|
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
|
||||||
|
}
|
||||||
|
const runAchievementsUnlocked = currentState.achievements.filter(
|
||||||
|
(achievement) => {
|
||||||
|
return achievement.unlockedAt !== null;
|
||||||
|
},
|
||||||
|
).length;
|
||||||
|
|
||||||
const prestigeState: GameState = {
|
const prestigeState: GameState = {
|
||||||
...freshState,
|
...freshState,
|
||||||
|
// Achievements are permanent — earned achievements survive all prestiges
|
||||||
|
achievements: currentState.achievements,
|
||||||
|
// Boss statuses reset for gameplay, but first-kill claimed flag is preserved
|
||||||
|
bosses: bossesWithBountyClaimed,
|
||||||
lastTickAt: Date.now(),
|
lastTickAt: Date.now(),
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Fold current-run totals into lifetime stats so the GameState reflects
|
||||||
|
* the true all-time values immediately after prestige.
|
||||||
|
*/
|
||||||
|
player: {
|
||||||
|
...freshState.player,
|
||||||
|
lifetimeAchievementsUnlocked:
|
||||||
|
freshState.player.lifetimeAchievementsUnlocked
|
||||||
|
+ runAchievementsUnlocked,
|
||||||
|
lifetimeAdventurersRecruited:
|
||||||
|
freshState.player.lifetimeAdventurersRecruited
|
||||||
|
+ runAdventurersRecruited,
|
||||||
|
lifetimeBossesDefeated:
|
||||||
|
freshState.player.lifetimeBossesDefeated + runBossesDefeated,
|
||||||
|
lifetimeClicks:
|
||||||
|
freshState.player.lifetimeClicks + currentState.player.totalClicks,
|
||||||
|
lifetimeGoldEarned:
|
||||||
|
freshState.player.lifetimeGoldEarned
|
||||||
|
+ currentState.player.totalGoldEarned,
|
||||||
|
lifetimeQuestsCompleted:
|
||||||
|
freshState.player.lifetimeQuestsCompleted + runQuestsCompleted,
|
||||||
|
},
|
||||||
prestige: prestigeData,
|
prestige: prestigeData,
|
||||||
// Codex lore persists across prestiges — players keep their discovered entries
|
// Codex lore persists across prestiges — players keep their discovered entries
|
||||||
...currentState.codex === undefined
|
...currentState.codex === undefined
|
||||||
|
|||||||
@@ -5,8 +5,16 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case and Pascal-case header names */
|
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case and Pascal-case header names */
|
||||||
|
import { logger } from "./logger.js";
|
||||||
|
|
||||||
const discordApi = "https://discord.com/api/v10";
|
const discordApi = "https://discord.com/api/v10";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discord MessageFlags.SUPPRESS_NOTIFICATIONS — messages are delivered without
|
||||||
|
* triggering desktop or mobile push notifications.
|
||||||
|
*/
|
||||||
|
const suppressNotifications = 4096;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Grants the apotheosis Discord role to the given player if configured.
|
* Grants the apotheosis Discord role to the given player if configured.
|
||||||
* Fails silently so role grant errors do not affect the game action.
|
* Fails silently so role grant errors do not affect the game action.
|
||||||
@@ -34,7 +42,13 @@ const grantApotheosisRole = async(discordId: string): Promise<void> => {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"webhook_apotheosis_role",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
// Graceful degradation — role grant failure must not affect the apotheosis
|
// Graceful degradation — role grant failure must not affect the apotheosis
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -77,11 +91,20 @@ const postMilestoneWebhook = async(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(webhookUrl, {
|
await fetch(webhookUrl, {
|
||||||
body: JSON.stringify({ content }),
|
body: JSON.stringify({
|
||||||
|
content: content,
|
||||||
|
flags: suppressNotifications,
|
||||||
|
}),
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"webhook_milestone",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
// Graceful degradation — webhook failure must not affect the game action
|
// Graceful degradation — webhook failure must not affect the game action
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -55,4 +55,15 @@ describe("authMiddleware", () => {
|
|||||||
}));
|
}));
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 401 when verifyToken throws a non-Error value", async () => {
|
||||||
|
const { app, verifyToken } = await makeApp();
|
||||||
|
vi.mocked(verifyToken).mockImplementationOnce(() => {
|
||||||
|
throw "raw string error";
|
||||||
|
});
|
||||||
|
const res = await app.fetch(new Request("http://localhost/test", {
|
||||||
|
headers: { Authorization: "Bearer bad_token" },
|
||||||
|
}));
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,6 +80,18 @@ describe("apotheosis route", () => {
|
|||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await post();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns apotheosis count on success", async () => {
|
it("returns apotheosis count on success", async () => {
|
||||||
// Need all 15 transcendence upgrades purchased for eligibility
|
// Need all 15 transcendence upgrades purchased for eligibility
|
||||||
const allUpgradeIds = [
|
const allUpgradeIds = [
|
||||||
|
|||||||
@@ -113,5 +113,14 @@ describe("auth route", () => {
|
|||||||
const location = res.headers.get("Location") ?? "";
|
const location = res.headers.get("Location") ?? "";
|
||||||
expect(location).toContain("error=auth_failed");
|
expect(location).toContain("error=auth_failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("redirects with error when callback throws a non-Error value", async () => {
|
||||||
|
const { app, exchangeCode } = await makeApp();
|
||||||
|
exchangeCode.mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await app.fetch(new Request("http://localhost/auth/callback?code=bad_code"));
|
||||||
|
expect(res.status).toBe(302);
|
||||||
|
const location = res.headers.get("Location") ?? "";
|
||||||
|
expect(location).toContain("error=auth_failed");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -293,4 +293,37 @@ describe("boss route", () => {
|
|||||||
const body = await res.json() as { won: boolean };
|
const body = await res.json() as { won: boolean };
|
||||||
expect(body.won).toBe(true);
|
expect(body.won).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await challenge({ bossId: "test_boss" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await challenge({ bossId: "test_boss" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-award bounty runestones when bountyRunestonesClaimed is true", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [makeBoss({
|
||||||
|
bountyRunestonesClaimed: true,
|
||||||
|
currentHp: 100,
|
||||||
|
damagePerSecond: 1,
|
||||||
|
maxHp: 100,
|
||||||
|
})] as GameState["bosses"],
|
||||||
|
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
||||||
|
prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 5 },
|
||||||
|
zones: [],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await challenge({ bossId: "test_boss" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { won: boolean; rewards: { bountyRunestones: number } };
|
||||||
|
expect(body.won).toBe(true);
|
||||||
|
expect(body.rewards.bountyRunestones).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -143,4 +143,16 @@ describe("craft route", () => {
|
|||||||
expect(body.recipeId).toBe(TEST_RECIPE_ID);
|
expect(body.recipeId).toBe(TEST_RECIPE_ID);
|
||||||
expect(body.bonusType).toBe("gold_income");
|
expect(body.bonusType).toBe("gold_income");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -406,5 +406,31 @@ describe("explore route", () => {
|
|||||||
expect(body.materialsFound.some((m) => m.materialId === "verdant_sap")).toBe(true);
|
expect(body.materialsFound.some((m) => m.materialId === "verdant_sap")).toBe(true);
|
||||||
mockRandom.mockRestore();
|
mockRandom.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws on collect", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value on collect", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /start error path", () => {
|
||||||
|
it("returns 500 when the database throws on start", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value on start", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
vi.mock("../../src/services/logger.js", () => ({
|
||||||
|
logger: {
|
||||||
|
log: vi.fn().mockResolvedValue(undefined),
|
||||||
|
error: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("frontend route", () => {
|
||||||
|
let loggerMock: { log: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
const { logger } = await import("../../src/services/logger.js");
|
||||||
|
loggerMock = logger as typeof loggerMock;
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeApp = async () => {
|
||||||
|
const { frontendRouter } = await import("../../src/routes/frontend.js");
|
||||||
|
const app = new Hono();
|
||||||
|
app.route("/frontend", frontendRouter);
|
||||||
|
return app;
|
||||||
|
};
|
||||||
|
|
||||||
|
const postLog = async (body: unknown, contentType = "application/json") => {
|
||||||
|
const app = await makeApp();
|
||||||
|
return app.fetch(new Request("http://localhost/frontend/log", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": contentType },
|
||||||
|
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const postError = async (body: unknown, contentType = "application/json") => {
|
||||||
|
const app = await makeApp();
|
||||||
|
return app.fetch(new Request("http://localhost/frontend/error", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": contentType },
|
||||||
|
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("POST /log", () => {
|
||||||
|
it("returns 200 when level is debug and message is present", async () => {
|
||||||
|
const res = await postLog({ level: "debug", message: "test debug" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 when level is info and message is present", async () => {
|
||||||
|
const res = await postLog({ level: "info", message: "test info" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 when level is warn and message is present", async () => {
|
||||||
|
const res = await postLog({ level: "warn", message: "test warn" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when level is invalid", async () => {
|
||||||
|
const res = await postLog({ level: "error", message: "test" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("level and message are required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when level is missing", async () => {
|
||||||
|
const res = await postLog({ message: "test" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when message is missing", async () => {
|
||||||
|
const res = await postLog({ level: "info" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when request body is invalid JSON", async () => {
|
||||||
|
const res = await postLog("not valid json at all", "application/json");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("Internal server error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 and covers non-Error branch when logger throws a raw value", async () => {
|
||||||
|
loggerMock.log.mockImplementationOnce(() => { throw "raw string error"; });
|
||||||
|
const res = await postLog({ level: "info", message: "test" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("Internal server error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /error", () => {
|
||||||
|
it("returns 200 with valid context and message", async () => {
|
||||||
|
const res = await postError({ context: "SomeComponent", message: "Something went wrong" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when context field is missing", async () => {
|
||||||
|
const res = await postError({ message: "Something went wrong" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("context and message are required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when message field is missing", async () => {
|
||||||
|
const res = await postError({ context: "SomeComponent" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when request body is invalid JSON", async () => {
|
||||||
|
const res = await postError("not valid json at all", "application/json");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("Internal server error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 and covers non-Error branch when logger throws a raw value", async () => {
|
||||||
|
loggerMock.error.mockImplementationOnce(() => { throw "raw string error"; });
|
||||||
|
const res = await postError({ context: "SomeComponent", message: "Something went wrong" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("Internal server error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -420,6 +420,45 @@ describe("game route", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GET /load error path", () => {
|
||||||
|
it("returns 500 when the database throws during load", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during load", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /save error path", () => {
|
||||||
|
const save = (body: Record<string, unknown>) =>
|
||||||
|
app.fetch(new Request("http://localhost/game/save", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}));
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during save", async () => {
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await save({ state });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during save", async () => {
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await save({ state });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("POST /reset", () => {
|
describe("POST /reset", () => {
|
||||||
const reset = () =>
|
const reset = () =>
|
||||||
app.fetch(new Request("http://localhost/game/reset", { method: "POST" }));
|
app.fetch(new Request("http://localhost/game/reset", { method: "POST" }));
|
||||||
@@ -450,5 +489,17 @@ describe("game route", () => {
|
|||||||
const body = await res.json() as { signature: string | undefined };
|
const body = await res.json() as { signature: string | undefined };
|
||||||
expect(typeof body.signature).toBe("string");
|
expect(typeof body.signature).toBe("string");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during reset", async () => {
|
||||||
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await reset();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during reset", async () => {
|
||||||
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await reset();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -152,6 +152,18 @@ describe("leaderboards route", () => {
|
|||||||
expect(typeof body.entries[0]?.activeTitle).toBe("string");
|
expect(typeof body.entries[0]?.activeTitle).toBe("string");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws", async () => {
|
||||||
|
vi.mocked(prisma.player.findMany).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await get();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value", async () => {
|
||||||
|
vi.mocked(prisma.player.findMany).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await get();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults to 0 for game-state categories when state is missing", async () => {
|
it("defaults to 0 for game-state categories when state is missing", async () => {
|
||||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
|
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
|
||||||
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([] as never);
|
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([] as never);
|
||||||
|
|||||||
@@ -93,6 +93,18 @@ describe("prestige route", () => {
|
|||||||
expect(body.runestones).toBeGreaterThanOrEqual(0);
|
expect(body.runestones).toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during prestige", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post("");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during prestige", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await post("");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
it("updates daily challenge progress when dailyChallenges are set", async () => {
|
it("updates daily challenge progress when dailyChallenges are set", async () => {
|
||||||
const state = makeState({
|
const state = makeState({
|
||||||
dailyChallenges: {
|
dailyChallenges: {
|
||||||
@@ -152,5 +164,17 @@ describe("prestige route", () => {
|
|||||||
expect(body.runestonesRemaining).toBe(90); // 100 - 10
|
expect(body.runestonesRemaining).toBe(90); // 100 - 10
|
||||||
expect(body.purchasedUpgradeIds).toContain("income_1");
|
expect(body.purchasedUpgradeIds).toContain("income_1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during buy-upgrade", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post("/buy-upgrade", { upgradeId: "income_1" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during buy-upgrade", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await post("/buy-upgrade", { upgradeId: "income_1" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -182,6 +182,18 @@ describe("profile route", () => {
|
|||||||
expect(unknown?.name).toBe("unknown_title_id");
|
expect(unknown?.name).toBe("unknown_title_id");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during profile get", async () => {
|
||||||
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during profile get", async () => {
|
||||||
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
it("includes completed story chapters in profile response", async () => {
|
it("includes completed story chapters in profile response", async () => {
|
||||||
const state = makeState({
|
const state = makeState({
|
||||||
story: {
|
story: {
|
||||||
@@ -256,5 +268,23 @@ describe("profile route", () => {
|
|||||||
const body = await res.json() as { profileSettings: { numberFormat: string } };
|
const body = await res.json() as { profileSettings: { numberFormat: string } };
|
||||||
expect(body.profileSettings.numberFormat).toBe("suffix");
|
expect(body.profileSettings.numberFormat).toBe("suffix");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during profile update", async () => {
|
||||||
|
vi.mocked(prisma.player.update).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await put({
|
||||||
|
characterName: "NewName",
|
||||||
|
profileSettings: { numberFormat: "suffix" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during profile update", async () => {
|
||||||
|
vi.mocked(prisma.player.update).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await put({
|
||||||
|
characterName: "NewName",
|
||||||
|
profileSettings: { numberFormat: "suffix" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -92,6 +92,18 @@ describe("transcendence route", () => {
|
|||||||
expect(body.newTranscendenceCount).toBe(1);
|
expect(body.newTranscendenceCount).toBe(1);
|
||||||
expect(body.echoes).toBeGreaterThanOrEqual(0);
|
expect(body.echoes).toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during transcendence", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post("");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during transcendence", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await post("");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /buy-upgrade", () => {
|
describe("POST /buy-upgrade", () => {
|
||||||
@@ -149,5 +161,17 @@ describe("transcendence route", () => {
|
|||||||
expect(body.echoesRemaining).toBe(95); // 100 - 5
|
expect(body.echoesRemaining).toBe(95); // 100 - 5
|
||||||
expect(body.purchasedUpgradeIds).toContain("echo_income_1");
|
expect(body.purchasedUpgradeIds).toContain("echo_income_1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during buy-upgrade", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during buy-upgrade", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -86,5 +86,22 @@ describe("discord service", () => {
|
|||||||
expect(result.id).toBe("123");
|
expect(result.id).toBe("123");
|
||||||
expect(result.username).toBe("testuser");
|
expect(result.username).toBe("testuser");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("re-throws when fetch rejects with a non-Error value", async () => {
|
||||||
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
|
const { fetchDiscordUser } = await import("../../src/services/discord.js");
|
||||||
|
await expect(fetchDiscordUser("some_token")).rejects.toBe("raw string error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exchangeCode non-Error throw", () => {
|
||||||
|
it("re-throws when fetch rejects with a non-Error value", async () => {
|
||||||
|
process.env["DISCORD_CLIENT_ID"] = "cid";
|
||||||
|
process.env["DISCORD_CLIENT_SECRET"] = "secret";
|
||||||
|
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
|
||||||
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
|
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||||
|
await expect(exchangeCode("some_code")).rejects.toBe("raw string error");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,14 +13,24 @@ import {
|
|||||||
} from "../../src/services/prestige.js";
|
} from "../../src/services/prestige.js";
|
||||||
import type { GameState } from "@elysium/types";
|
import type { GameState } from "@elysium/types";
|
||||||
|
|
||||||
const makePlayer = (totalGoldEarned: number) => ({
|
const makePlayer = (
|
||||||
discordId: "test_id",
|
totalGoldEarned: number,
|
||||||
username: "testuser",
|
lifetimeGoldEarned = 0,
|
||||||
discriminator: "0",
|
totalClicks = 0,
|
||||||
|
) => ({
|
||||||
avatar: null,
|
avatar: null,
|
||||||
totalGoldEarned,
|
|
||||||
totalClicks: 0,
|
|
||||||
characterName: "Tester",
|
characterName: "Tester",
|
||||||
|
discordId: "test_id",
|
||||||
|
discriminator: "0",
|
||||||
|
lifetimeAchievementsUnlocked: 0,
|
||||||
|
lifetimeAdventurersRecruited: 0,
|
||||||
|
lifetimeBossesDefeated: 0,
|
||||||
|
lifetimeClicks: 0,
|
||||||
|
lifetimeGoldEarned: lifetimeGoldEarned,
|
||||||
|
lifetimeQuestsCompleted: 0,
|
||||||
|
totalClicks: totalClicks,
|
||||||
|
totalGoldEarned: totalGoldEarned,
|
||||||
|
username: "testuser",
|
||||||
});
|
});
|
||||||
|
|
||||||
const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
|
const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
|
||||||
@@ -242,4 +252,126 @@ describe("buildPostPrestigeState", () => {
|
|||||||
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
expect(prestigeState.apotheosis).toEqual(apotheosis);
|
expect(prestigeState.apotheosis).toEqual(apotheosis);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accumulates current-run gold into lifetime total", () => {
|
||||||
|
const state = makeMinimalState({
|
||||||
|
player: makePlayer(4_000_000, 1_000_000),
|
||||||
|
});
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeState.player.lifetimeGoldEarned).toBe(5_000_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates current-run clicks into lifetime total", () => {
|
||||||
|
const state = makeMinimalState({
|
||||||
|
player: makePlayer(4_000_000, 0, 500),
|
||||||
|
});
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeState.player.lifetimeClicks).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates defeated bosses into lifetime total", () => {
|
||||||
|
const defeatedBoss = {
|
||||||
|
bountyRunestones: 0,
|
||||||
|
crystalReward: 0,
|
||||||
|
currentHp: 0,
|
||||||
|
damagePerSecond: 10,
|
||||||
|
description: "A boss",
|
||||||
|
equipmentRewards: [] as string[],
|
||||||
|
essenceReward: 0,
|
||||||
|
goldReward: 100,
|
||||||
|
id: "boss_1",
|
||||||
|
maxHp: 100,
|
||||||
|
name: "Boss One",
|
||||||
|
prestigeRequirement: 0,
|
||||||
|
status: "defeated" as const,
|
||||||
|
upgradeRewards: [] as string[],
|
||||||
|
zoneId: "zone_1",
|
||||||
|
};
|
||||||
|
const state = makeMinimalState({ bosses: [ defeatedBoss ] });
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeState.player.lifetimeBossesDefeated).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves bountyRunestonesClaimed flag on bosses across prestige", () => {
|
||||||
|
const claimedBoss = {
|
||||||
|
bountyRunestones: 5,
|
||||||
|
bountyRunestonesClaimed: true,
|
||||||
|
crystalReward: 0,
|
||||||
|
currentHp: 0,
|
||||||
|
damagePerSecond: 10,
|
||||||
|
description: "A boss",
|
||||||
|
equipmentRewards: [] as string[],
|
||||||
|
essenceReward: 0,
|
||||||
|
goldReward: 100,
|
||||||
|
id: "troll_king",
|
||||||
|
maxHp: 100,
|
||||||
|
name: "Troll King",
|
||||||
|
prestigeRequirement: 0,
|
||||||
|
status: "defeated" as const,
|
||||||
|
upgradeRewards: [] as string[],
|
||||||
|
zoneId: "verdant_vale",
|
||||||
|
};
|
||||||
|
const state = makeMinimalState({ bosses: [ claimedBoss ] as GameState["bosses"] });
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
const matchingBoss = prestigeState.bosses.find((boss) => {
|
||||||
|
return boss.id === "troll_king";
|
||||||
|
});
|
||||||
|
expect(matchingBoss?.bountyRunestonesClaimed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates completed quests into lifetime total", () => {
|
||||||
|
const quest = {
|
||||||
|
id: "q_1",
|
||||||
|
name: "A Quest",
|
||||||
|
description: "Do the thing",
|
||||||
|
status: "completed" as const,
|
||||||
|
zoneId: "zone_1",
|
||||||
|
};
|
||||||
|
const state = makeMinimalState({ quests: [ quest ] as GameState["quests"] });
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeState.player.lifetimeQuestsCompleted).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates recruited adventurers into lifetime total", () => {
|
||||||
|
const adventurer = {
|
||||||
|
combatPower: 10,
|
||||||
|
count: 5,
|
||||||
|
essencePerSecond: 0,
|
||||||
|
goldPerSecond: 1,
|
||||||
|
id: "adv_1",
|
||||||
|
level: 1,
|
||||||
|
unlocked: true,
|
||||||
|
};
|
||||||
|
const state = makeMinimalState({ adventurers: [ adventurer ] as GameState["adventurers"] });
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeState.player.lifetimeAdventurersRecruited).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves achievements from current state across prestige", () => {
|
||||||
|
const achievement = {
|
||||||
|
description: "Did a thing",
|
||||||
|
id: "ach_persisted",
|
||||||
|
name: "Achiever",
|
||||||
|
requirement: 1,
|
||||||
|
type: "totalClicks" as const,
|
||||||
|
unlockedAt: Date.now(),
|
||||||
|
};
|
||||||
|
const state = makeMinimalState({ achievements: [ achievement ] as GameState["achievements"] });
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeState.achievements).toEqual([ achievement ]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates unlocked achievements into lifetime total", () => {
|
||||||
|
const achievement = {
|
||||||
|
description: "Did a thing",
|
||||||
|
id: "ach_1",
|
||||||
|
name: "Achiever",
|
||||||
|
requirement: 1,
|
||||||
|
type: "totalClicks" as const,
|
||||||
|
unlockedAt: Date.now(),
|
||||||
|
};
|
||||||
|
const state = makeMinimalState({ achievements: [ achievement ] as GameState["achievements"] });
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeState.player.lifetimeAchievementsUnlocked).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -69,6 +69,15 @@ describe("webhook service", () => {
|
|||||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||||
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
|
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("swallows non-Error fetch rejections gracefully", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||||
|
process.env["DISCORD_GUILD_ID"] = "g";
|
||||||
|
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r";
|
||||||
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
|
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||||
|
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("postMilestoneWebhook", () => {
|
describe("postMilestoneWebhook", () => {
|
||||||
@@ -88,9 +97,10 @@ describe("webhook service", () => {
|
|||||||
await postMilestoneWebhook("user123", "prestige", counts);
|
await postMilestoneWebhook("user123", "prestige", counts);
|
||||||
const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit];
|
const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
expect(url).toBe("https://discord.com/webhook/abc");
|
expect(url).toBe("https://discord.com/webhook/abc");
|
||||||
const body = JSON.parse(options.body as string) as { content: string };
|
const body = JSON.parse(options.body as string) as { content: string; flags: number };
|
||||||
expect(body.content).toContain("<@user123>");
|
expect(body.content).toContain("<@user123>");
|
||||||
expect(body.content).toContain("prestiged");
|
expect(body.content).toContain("prestiged");
|
||||||
|
expect(body.flags).toBe(4096);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("posts transcendence message correctly", async () => {
|
it("posts transcendence message correctly", async () => {
|
||||||
@@ -119,5 +129,12 @@ describe("webhook service", () => {
|
|||||||
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
||||||
await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined();
|
await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("swallows non-Error fetch rejections gracefully", async () => {
|
||||||
|
process.env["DISCORD_MILESTONE_WEBHOOK"] = "https://discord.com/webhook/abc";
|
||||||
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
|
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
||||||
|
await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,39 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Elysium — Idle RPG</title>
|
<title>Elysium — Idle RPG</title>
|
||||||
<meta name="description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
|
<meta name="description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
|
||||||
|
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:title" content="Elysium — Idle RPG" />
|
||||||
|
<meta property="og:description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://elysium.nhcarrigan.com" />
|
||||||
|
<meta property="og:image" content="https://cdn.nhcarrigan.com/elysium/background.jpg" />
|
||||||
|
<meta property="og:site_name" content="Elysium" />
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Elysium — Idle RPG" />
|
||||||
|
<meta name="twitter:description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
|
||||||
|
<meta name="twitter:image" content="https://cdn.nhcarrigan.com/elysium/background.jpg" />
|
||||||
|
|
||||||
|
<!-- Plausible Analytics -->
|
||||||
|
<script defer data-domain="elysium.nhcarrigan.com" src="https://plausible.io/js/script.js"></script>
|
||||||
|
|
||||||
|
<!-- Tree-Nation -->
|
||||||
|
<script defer src="https://widgets.tree-nation.com/js/widgets/v1/widgets.min.js?v=1.0"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var interval = setInterval(function () {
|
||||||
|
if (typeof TreeNation !== "undefined") {
|
||||||
|
clearInterval(interval);
|
||||||
|
TreeNation.renderAll();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Google Ads -->
|
||||||
|
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-3569924701890974" crossorigin="anonymous"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@elysium/types": "workspace:*",
|
"@elysium/types": "workspace:*",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0"
|
"react-dom": "19.0.0",
|
||||||
|
"react-markdown": "10.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nhcarrigan/eslint-config": "5.2.0",
|
"@nhcarrigan/eslint-config": "5.2.0",
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
/* eslint-disable max-lines-per-function -- HOW_TO_PLAY data and render logic */
|
/* eslint-disable max-lines-per-function -- HOW_TO_PLAY data and render logic */
|
||||||
/* eslint-disable max-lines -- HOW_TO_PLAY data makes this file long */
|
/* eslint-disable max-lines -- HOW_TO_PLAY data makes this file long */
|
||||||
import { type JSX, useEffect, useState } from "react";
|
import { type JSX, useEffect, useState } from "react";
|
||||||
|
import Markdown from "react-markdown";
|
||||||
import { getAbout } from "../../api/client.js";
|
import { getAbout } from "../../api/client.js";
|
||||||
import type { AboutResponse } from "@elysium/types";
|
import type { AboutResponse } from "@elysium/types";
|
||||||
|
|
||||||
@@ -331,7 +332,9 @@ const aboutPanel = (): JSX.Element => {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{expandedRelease === release.tag_name
|
{expandedRelease === release.tag_name
|
||||||
&& <pre className="about-release-body">{release.body}</pre>
|
&& <div className="about-release-body">
|
||||||
|
<Markdown>{release.body}</Markdown>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */
|
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */
|
||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import type { Achievement } from "@elysium/types";
|
import type { Achievement } from "@elysium/types";
|
||||||
|
|
||||||
@@ -76,7 +77,11 @@ const AchievementCard = ({
|
|||||||
<div className={`achievement-card ${isUnlocked
|
<div className={`achievement-card ${isUnlocked
|
||||||
? "unlocked"
|
? "unlocked"
|
||||||
: "locked"}`}>
|
: "locked"}`}>
|
||||||
<div className="achievement-icon">{achievement.icon}</div>
|
<img
|
||||||
|
alt={achievement.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("achievements", achievement.id)}
|
||||||
|
/>
|
||||||
<div className="achievement-info">
|
<div className="achievement-info">
|
||||||
<h3>{achievement.name}</h3>
|
<h3>{achievement.name}</h3>
|
||||||
<p>{achievement.description}</p>
|
<p>{achievement.description}</p>
|
||||||
|
|||||||
@@ -9,21 +9,38 @@
|
|||||||
/* eslint-disable complexity -- Complex component with many render paths */
|
/* eslint-disable complexity -- Complex component with many render paths */
|
||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import type { Adventurer } from "@elysium/types";
|
import type { Adventurer } from "@elysium/types";
|
||||||
|
|
||||||
const iconByClass: Record<string, string> = {
|
|
||||||
cleric: "✝️",
|
|
||||||
mage: "🔮",
|
|
||||||
paladin: "🛡️",
|
|
||||||
ranger: "🏹",
|
|
||||||
rogue: "🗝️",
|
|
||||||
warrior: "🗡️",
|
|
||||||
};
|
|
||||||
|
|
||||||
type BatchSize = 1 | 5 | 10 | 25 | 100 | "max";
|
type BatchSize = 1 | 5 | 10 | 25 | 100 | "max";
|
||||||
const batchOptions: Array<BatchSize> = [ 1, 5, 10, 25, 100, "max" ];
|
const batchOptions: Array<BatchSize> = [ 1, 5, 10, 25, 100, "max" ];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a localStorage string back into a valid BatchSize, defaulting to 1.
|
||||||
|
* @param stored - The raw string from localStorage (or null if absent).
|
||||||
|
* @returns A valid BatchSize value.
|
||||||
|
*/
|
||||||
|
const parseBatchSize = (stored: string | null): BatchSize => {
|
||||||
|
if (stored === "max") {
|
||||||
|
return "max";
|
||||||
|
}
|
||||||
|
const numeric = Number(stored);
|
||||||
|
if (numeric === 5) {
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
if (numeric === 10) {
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
if (numeric === 25) {
|
||||||
|
return 25;
|
||||||
|
}
|
||||||
|
if (numeric === 100) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes the total cost to buy a batch of adventurers.
|
* Computes the total cost to buy a batch of adventurers.
|
||||||
* @param adventurer - The adventurer to buy.
|
* @param adventurer - The adventurer to buy.
|
||||||
@@ -105,14 +122,15 @@ const AdventurerCard = ({
|
|||||||
? `🪙 ${formatNumber(Math.ceil(cost))}${maxSuffix}`
|
? `🪙 ${formatNumber(Math.ceil(cost))}${maxSuffix}`
|
||||||
: "🔒 Locked";
|
: "🔒 Locked";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/dot-notation -- "class" is a reserved word
|
|
||||||
const adventurerIcon = iconByClass[adventurer["class"]] ?? "⚔️";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`adventurer-card ${adventurer.unlocked
|
<div className={`adventurer-card ${adventurer.unlocked
|
||||||
? ""
|
? ""
|
||||||
: "locked"}`}>
|
: "locked"}`}>
|
||||||
<div className="adventurer-icon">{adventurerIcon}</div>
|
<img
|
||||||
|
alt={adventurer.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("adventurers", adventurer.id)}
|
||||||
|
/>
|
||||||
<div className="adventurer-info">
|
<div className="adventurer-info">
|
||||||
<h3>{adventurer.name}</h3>
|
<h3>{adventurer.name}</h3>
|
||||||
<p>
|
<p>
|
||||||
@@ -155,7 +173,9 @@ const AdventurerCard = ({
|
|||||||
const AdventurerPanel = (): JSX.Element => {
|
const AdventurerPanel = (): JSX.Element => {
|
||||||
const { state, formatNumber } = useGame();
|
const { state, formatNumber } = useGame();
|
||||||
const [ showLocked, setShowLocked ] = useState(true);
|
const [ showLocked, setShowLocked ] = useState(true);
|
||||||
const [ batchSize, setBatchSize ] = useState<BatchSize>(1);
|
const [ batchSize, setBatchSize ] = useState<BatchSize>(() => {
|
||||||
|
return parseBatchSize(localStorage.getItem("elysium_batch_size"));
|
||||||
|
});
|
||||||
|
|
||||||
if (state === null) {
|
if (state === null) {
|
||||||
return (
|
return (
|
||||||
@@ -203,6 +223,7 @@ const AdventurerPanel = (): JSX.Element => {
|
|||||||
{batchOptions.map((option) => {
|
{batchOptions.map((option) => {
|
||||||
function handleBatchSelect(): void {
|
function handleBatchSelect(): void {
|
||||||
setBatchSize(option);
|
setBatchSize(option);
|
||||||
|
localStorage.setItem("elysium_batch_size", String(option));
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */
|
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */
|
||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import { ZoneSelector } from "./zoneSelector.js";
|
import { ZoneSelector } from "./zoneSelector.js";
|
||||||
import type { Boss, GameState } from "@elysium/types";
|
import type { Boss, GameState } from "@elysium/types";
|
||||||
@@ -56,6 +57,11 @@ const BossCard = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`boss-card boss-${boss.status}`}>
|
<div className={`boss-card boss-${boss.status}`}>
|
||||||
|
<img
|
||||||
|
alt={boss.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("bosses", boss.id)}
|
||||||
|
/>
|
||||||
<div className="boss-info">
|
<div className="boss-info">
|
||||||
<h3>{boss.name}</h3>
|
<h3>{boss.name}</h3>
|
||||||
<p>{boss.description}</p>
|
<p>{boss.description}</p>
|
||||||
@@ -120,7 +126,9 @@ const BossCard = ({
|
|||||||
{" Equipment"}
|
{" Equipment"}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
{boss.status !== "defeated" && boss.bountyRunestones > 0
|
{boss.status !== "defeated"
|
||||||
|
&& boss.bountyRunestones > 0
|
||||||
|
&& boss.bountyRunestonesClaimed !== true
|
||||||
&& <span className="boss-bounty">
|
&& <span className="boss-bounty">
|
||||||
{"🔮 "}
|
{"🔮 "}
|
||||||
{boss.bountyRunestones}
|
{boss.bountyRunestones}
|
||||||
@@ -220,11 +228,20 @@ const computePartyStats = (
|
|||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
*/
|
*/
|
||||||
const BossPanel = (): JSX.Element => {
|
const BossPanel = (): JSX.Element => {
|
||||||
const { state, challengeBoss, formatNumber, toggleAutoBoss } = useGame();
|
const {
|
||||||
|
state,
|
||||||
|
challengeBoss,
|
||||||
|
formatNumber,
|
||||||
|
toggleAutoBoss,
|
||||||
|
autoBossLastResult,
|
||||||
|
autoBossError,
|
||||||
|
} = useGame();
|
||||||
const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
|
const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
||||||
|
return sessionStorage.getItem("elysium_boss_zone") ?? "verdant_vale";
|
||||||
|
});
|
||||||
const [ showLocked, setShowLocked ] = useState(true);
|
const [ showLocked, setShowLocked ] = useState(true);
|
||||||
|
|
||||||
if (state === null) {
|
if (state === null) {
|
||||||
@@ -302,6 +319,11 @@ const BossPanel = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleZoneSelect(zoneId: string): void {
|
||||||
|
setActiveZoneId(zoneId);
|
||||||
|
sessionStorage.setItem("elysium_boss_zone", zoneId);
|
||||||
|
}
|
||||||
|
|
||||||
function handleToggle(): void {
|
function handleToggle(): void {
|
||||||
setShowLocked((current) => {
|
setShowLocked((current) => {
|
||||||
return !current;
|
return !current;
|
||||||
@@ -340,9 +362,26 @@ const BossPanel = (): JSX.Element => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{autoBossError === null
|
||||||
|
? null
|
||||||
|
: <p className="auto-boss-error">
|
||||||
|
{"⚠️ Auto-boss stopped: "}
|
||||||
|
{autoBossError}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
{autoBossLastResult !== null && autoBossError === null
|
||||||
|
? <p className="auto-boss-status">
|
||||||
|
{"🤖 Last fight: "}
|
||||||
|
{autoBossLastResult.bossName}
|
||||||
|
{autoBossLastResult.won
|
||||||
|
? " — ✅ Won"
|
||||||
|
: " — ❌ Lost"}
|
||||||
|
</p>
|
||||||
|
: null}
|
||||||
|
|
||||||
<ZoneSelector
|
<ZoneSelector
|
||||||
activeZoneId={activeZoneId}
|
activeZoneId={activeZoneId}
|
||||||
onSelectZone={setActiveZoneId}
|
onSelectZone={handleZoneSelect}
|
||||||
zones={zones}
|
zones={zones}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type PublicProfileResponse,
|
type PublicProfileResponse,
|
||||||
} from "@elysium/types";
|
} from "@elysium/types";
|
||||||
import { type JSX, useEffect, useState } from "react";
|
import { type JSX, useEffect, useState } from "react";
|
||||||
|
import { logError } from "../../utils/logError.js";
|
||||||
|
|
||||||
interface CharacterPageProperties {
|
interface CharacterPageProperties {
|
||||||
readonly discordId: string;
|
readonly discordId: string;
|
||||||
@@ -78,11 +79,15 @@ const CharacterPage = ({ discordId }: CharacterPageProperties): JSX.Element => {
|
|||||||
}, [ discordId ]);
|
}, [ discordId ]);
|
||||||
|
|
||||||
function handleCopy(): void {
|
function handleCopy(): void {
|
||||||
void navigator.clipboard.writeText(window.location.href).then(() => {
|
void navigator.clipboard.writeText(window.location.href).
|
||||||
|
then(() => {
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setCopied(false);
|
setCopied(false);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
}).
|
||||||
|
catch((error_: unknown) => {
|
||||||
|
logError("clipboard_copy", error_);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +243,7 @@ const CharacterPage = ({ discordId }: CharacterPageProperties): JSX.Element => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="character-page-equipment-item"
|
className="character-page-equipment-item"
|
||||||
key={item.type}
|
key={item.name}
|
||||||
>
|
>
|
||||||
<div className="character-page-equipment-header">
|
<div className="character-page-equipment-header">
|
||||||
<span className="character-page-equipment-slot">
|
<span className="character-page-equipment-slot">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { type ChangeEvent, type JSX, useEffect, useRef, useState } from "react";
|
import { type ChangeEvent, type JSX, useEffect, useRef, useState } from "react";
|
||||||
import { updateProfile } from "../../api/client.js";
|
import { updateProfile } from "../../api/client.js";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { logError } from "../../utils/logError.js";
|
||||||
|
|
||||||
interface EquippedItem {
|
interface EquippedItem {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -205,11 +206,15 @@ const CharacterSheetPanel = (): JSX.Element => {
|
|||||||
function handleShareClick(): void {
|
function handleShareClick(): void {
|
||||||
const discordId = player?.discordId ?? "";
|
const discordId = player?.discordId ?? "";
|
||||||
const url = `${window.location.origin}/character/${discordId}`;
|
const url = `${window.location.origin}/character/${discordId}`;
|
||||||
void navigator.clipboard.writeText(url).then(() => {
|
void navigator.clipboard.writeText(url).
|
||||||
|
then(() => {
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setCopied(false);
|
setCopied(false);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
}).
|
||||||
|
catch((error_: unknown) => {
|
||||||
|
logError("clipboard_copy", error_);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import { CODEX_ENTRIES, ZONE_LABELS } from "../../data/codex.js";
|
import { CODEX_ENTRIES, ZONE_LABELS } from "../../data/codex.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import type { CodexEntry } from "@elysium/types";
|
import type { CodexEntry } from "@elysium/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +37,18 @@ const sourceBadge: Record<CodexEntry["sourceType"], string> = {
|
|||||||
zone: "🗺️",
|
zone: "🗺️",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sourceTypeFolder: Record<CodexEntry["sourceType"], string> = {
|
||||||
|
adventurer: "adventurers",
|
||||||
|
boss: "bosses",
|
||||||
|
equipment: "equipment",
|
||||||
|
exploration: "explorations",
|
||||||
|
prestige: "prestige-upgrades",
|
||||||
|
quest: "quests",
|
||||||
|
recipe: "recipes",
|
||||||
|
upgrade: "upgrades",
|
||||||
|
zone: "zones",
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the codex panel with lore entries grouped by zone.
|
* Renders the codex panel with lore entries grouped by zone.
|
||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
@@ -155,7 +168,17 @@ const CodexPanel = (): JSX.Element => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isExpanded
|
{isExpanded
|
||||||
? <p className="codex-entry-content">{entry.content}</p>
|
? <>
|
||||||
|
<img
|
||||||
|
alt={entry.title}
|
||||||
|
className="codex-entry-image"
|
||||||
|
src={cdnImage(
|
||||||
|
sourceTypeFolder[entry.sourceType],
|
||||||
|
entry.sourceId,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<p className="codex-entry-content">{entry.content}</p>
|
||||||
|
</>
|
||||||
: null}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
/* eslint-disable max-lines-per-function -- Complex companion card with conditional renders */
|
/* eslint-disable max-lines-per-function -- Complex companion card with conditional renders */
|
||||||
import { COMPANIONS, type Companion } from "@elysium/types";
|
import { COMPANIONS, type Companion } from "@elysium/types";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
|
|
||||||
const bonusLabels: Record<string, string> = {
|
const bonusLabels: Record<string, string> = {
|
||||||
@@ -96,6 +97,11 @@ const CompanionCard = ({
|
|||||||
: ""}`}
|
: ""}`}
|
||||||
>
|
>
|
||||||
<div className="companion-header">
|
<div className="companion-header">
|
||||||
|
<img
|
||||||
|
alt={companion.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("companions", companion.id)}
|
||||||
|
/>
|
||||||
<div className="companion-name-block">
|
<div className="companion-name-block">
|
||||||
<span className="companion-name">{companion.name}</span>
|
<span className="companion-name">{companion.name}</span>
|
||||||
<span className="companion-title">{companion.title}</span>
|
<span className="companion-title">{companion.title}</span>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { type JSX, useState } from "react";
|
|||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import { MATERIALS } from "../../data/materials.js";
|
import { MATERIALS } from "../../data/materials.js";
|
||||||
import { RECIPES } from "../../data/recipes.js";
|
import { RECIPES } from "../../data/recipes.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { ZoneSelector } from "./zoneSelector.js";
|
import { ZoneSelector } from "./zoneSelector.js";
|
||||||
|
|
||||||
const bonusLabel: Record<string, string> = {
|
const bonusLabel: Record<string, string> = {
|
||||||
@@ -25,7 +26,9 @@ const bonusLabel: Record<string, string> = {
|
|||||||
*/
|
*/
|
||||||
const CraftingPanel = (): JSX.Element => {
|
const CraftingPanel = (): JSX.Element => {
|
||||||
const { state, craftRecipe, formatNumber } = useGame();
|
const { state, craftRecipe, formatNumber } = useGame();
|
||||||
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
||||||
|
return sessionStorage.getItem("elysium_craft_zone") ?? "verdant_vale";
|
||||||
|
});
|
||||||
const [ pendingRecipeId, setPendingRecipeId ] = useState<string | null>(null);
|
const [ pendingRecipeId, setPendingRecipeId ] = useState<string | null>(null);
|
||||||
|
|
||||||
if (state === null) {
|
if (state === null) {
|
||||||
@@ -67,6 +70,11 @@ const CraftingPanel = (): JSX.Element => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleZoneSelect(zoneId: string): void {
|
||||||
|
setActiveZoneId(zoneId);
|
||||||
|
sessionStorage.setItem("elysium_craft_zone", zoneId);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCraft(recipeId: string): Promise<void> {
|
async function handleCraft(recipeId: string): Promise<void> {
|
||||||
setPendingRecipeId(recipeId);
|
setPendingRecipeId(recipeId);
|
||||||
try {
|
try {
|
||||||
@@ -84,7 +92,7 @@ const CraftingPanel = (): JSX.Element => {
|
|||||||
|
|
||||||
<ZoneSelector
|
<ZoneSelector
|
||||||
activeZoneId={activeZoneId}
|
activeZoneId={activeZoneId}
|
||||||
onSelectZone={setActiveZoneId}
|
onSelectZone={handleZoneSelect}
|
||||||
zones={zones}
|
zones={zones}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -105,6 +113,11 @@ const CraftingPanel = (): JSX.Element => {
|
|||||||
}`}
|
}`}
|
||||||
key={material.id}
|
key={material.id}
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
alt={material.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("materials", material.id)}
|
||||||
|
/>
|
||||||
<div className="material-info">
|
<div className="material-info">
|
||||||
<span className="material-name">{material.name}</span>
|
<span className="material-name">{material.name}</span>
|
||||||
<span className="material-rarity">{material.rarity}</span>
|
<span className="material-rarity">{material.rarity}</span>
|
||||||
@@ -144,6 +157,11 @@ const CraftingPanel = (): JSX.Element => {
|
|||||||
: ""}`}
|
: ""}`}
|
||||||
key={recipe.id}
|
key={recipe.id}
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
alt={recipe.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("recipes", recipe.id)}
|
||||||
|
/>
|
||||||
<div className="recipe-info">
|
<div className="recipe-info">
|
||||||
<h4>{recipe.name}</h4>
|
<h4>{recipe.name}</h4>
|
||||||
<p className="recipe-description">{recipe.description}</p>
|
<p className="recipe-description">{recipe.description}</p>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import { EQUIPMENT_SETS } from "../../data/equipmentSets.js";
|
import { EQUIPMENT_SETS } from "../../data/equipmentSets.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import type { Equipment, EquipmentType } from "@elysium/types";
|
import type { Equipment, EquipmentType } from "@elysium/types";
|
||||||
|
|
||||||
@@ -20,12 +21,6 @@ const rarityLabel: Record<string, string> = {
|
|||||||
rare: "Rare",
|
rare: "Rare",
|
||||||
};
|
};
|
||||||
|
|
||||||
const typeIcon: Record<EquipmentType, string> = {
|
|
||||||
armour: "🛡️",
|
|
||||||
trinket: "💍",
|
|
||||||
weapon: "⚔️",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes a human-readable bonus description for a piece of equipment.
|
* Computes a human-readable bonus description for a piece of equipment.
|
||||||
* @param item - The equipment item.
|
* @param item - The equipment item.
|
||||||
@@ -128,7 +123,11 @@ const EquipmentCard = ({
|
|||||||
<div
|
<div
|
||||||
className={`equipment-card rarity-${item.rarity} ${equippedClass} ${ownedClass}`}
|
className={`equipment-card rarity-${item.rarity} ${equippedClass} ${ownedClass}`}
|
||||||
>
|
>
|
||||||
<div className="equipment-icon">{typeIcon[item.type]}</div>
|
<img
|
||||||
|
alt={item.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("equipment", item.id)}
|
||||||
|
/>
|
||||||
<div className="equipment-info">
|
<div className="equipment-info">
|
||||||
<div className="equipment-name-row">
|
<div className="equipment-name-row">
|
||||||
<h3>{item.name}</h3>
|
<h3>{item.name}</h3>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import { EXPLORATION_AREAS } from "../../data/explorations.js";
|
import { EXPLORATION_AREAS } from "../../data/explorations.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { ZoneSelector } from "./zoneSelector.js";
|
import { ZoneSelector } from "./zoneSelector.js";
|
||||||
import type { ExploreCollectResponse } from "@elysium/types";
|
import type { ExploreCollectResponse } from "@elysium/types";
|
||||||
|
|
||||||
@@ -66,7 +67,9 @@ interface CollectResult {
|
|||||||
const ExplorationPanel = (): JSX.Element => {
|
const ExplorationPanel = (): JSX.Element => {
|
||||||
const { state, startExploration, collectExploration, formatNumber }
|
const { state, startExploration, collectExploration, formatNumber }
|
||||||
= useGame();
|
= useGame();
|
||||||
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
||||||
|
return sessionStorage.getItem("elysium_explore_zone") ?? "verdant_vale";
|
||||||
|
});
|
||||||
const [ pendingAreaId, setPendingAreaId ] = useState<string | null>(null);
|
const [ pendingAreaId, setPendingAreaId ] = useState<string | null>(null);
|
||||||
const [ lastResult, setLastResult ] = useState<CollectResult | null>(null);
|
const [ lastResult, setLastResult ] = useState<CollectResult | null>(null);
|
||||||
|
|
||||||
@@ -115,6 +118,7 @@ const ExplorationPanel = (): JSX.Element => {
|
|||||||
function handleZoneSelect(id: string): void {
|
function handleZoneSelect(id: string): void {
|
||||||
setActiveZoneId(id);
|
setActiveZoneId(id);
|
||||||
setLastResult(null);
|
setLastResult(null);
|
||||||
|
sessionStorage.setItem("elysium_explore_zone", id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const goldChange = lastResult?.response.event?.goldChange ?? 0;
|
const goldChange = lastResult?.response.event?.goldChange ?? 0;
|
||||||
@@ -230,6 +234,11 @@ const ExplorationPanel = (): JSX.Element => {
|
|||||||
className={`exploration-card exploration-${status}`}
|
className={`exploration-card exploration-${status}`}
|
||||||
key={area.id}
|
key={area.id}
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
alt={area.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("explorations", area.id)}
|
||||||
|
/>
|
||||||
<div className="exploration-info">
|
<div className="exploration-info">
|
||||||
<h3>
|
<h3>
|
||||||
{area.name}
|
{area.name}
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ const GameLayout = (): JSX.Element => {
|
|||||||
<div className="game-main">
|
<div className="game-main">
|
||||||
<aside className="game-sidebar">
|
<aside className="game-sidebar">
|
||||||
<ClickArea />
|
<ClickArea />
|
||||||
|
<div id="tree-nation-offset-website" />
|
||||||
<p className="game-copyright">{"© NHCarrigan"}</p>
|
<p className="game-copyright">{"© NHCarrigan"}</p>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
PRESTIGE_UPGRADES,
|
PRESTIGE_UPGRADES,
|
||||||
PRESTIGE_UPGRADE_CATEGORY_LABELS,
|
PRESTIGE_UPGRADE_CATEGORY_LABELS,
|
||||||
} from "../../data/prestigeUpgrades.js";
|
} from "../../data/prestigeUpgrades.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { sendNotification } from "../../utils/notification.js";
|
import { sendNotification } from "../../utils/notification.js";
|
||||||
import { playSound } from "../../utils/sound.js";
|
import { playSound } from "../../utils/sound.js";
|
||||||
import type { PrestigeUpgradeCategory } from "@elysium/types";
|
import type { PrestigeUpgradeCategory } from "@elysium/types";
|
||||||
@@ -366,6 +367,11 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
: ""}`}
|
: ""}`}
|
||||||
key={upgrade.id}
|
key={upgrade.id}
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
alt={upgrade.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("prestige-upgrades", upgrade.id)}
|
||||||
|
/>
|
||||||
<div className="shop-upgrade-info">
|
<div className="shop-upgrade-info">
|
||||||
<h4>{upgrade.name}</h4>
|
<h4>{upgrade.name}</h4>
|
||||||
<p>{upgrade.description}</p>
|
<p>{upgrade.description}</p>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
/* eslint-disable complexity -- Many conditional stat visibility checks */
|
/* eslint-disable complexity -- Many conditional stat visibility checks */
|
||||||
import { useEffect, useState, type JSX } from "react";
|
import { useEffect, useState, type JSX } from "react";
|
||||||
import { formatNumber } from "../../utils/format.js";
|
import { formatNumber } from "../../utils/format.js";
|
||||||
|
import { logError } from "../../utils/logError.js";
|
||||||
import type { PublicProfileResponse } from "@elysium/types";
|
import type { PublicProfileResponse } from "@elysium/types";
|
||||||
|
|
||||||
interface ProfilePageProperties {
|
interface ProfilePageProperties {
|
||||||
@@ -52,11 +53,15 @@ const ProfilePage = ({ discordId }: ProfilePageProperties): JSX.Element => {
|
|||||||
}, [ discordId ]);
|
}, [ discordId ]);
|
||||||
|
|
||||||
function handleCopy(): void {
|
function handleCopy(): void {
|
||||||
void navigator.clipboard.writeText(window.location.href).then(() => {
|
void navigator.clipboard.writeText(window.location.href).
|
||||||
|
then(() => {
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setCopied(false);
|
setCopied(false);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
}).
|
||||||
|
catch((error_: unknown) => {
|
||||||
|
logError("clipboard_copy", error_);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
/* eslint-disable max-statements -- Many local variables needed for quest state */
|
/* eslint-disable max-statements -- Many local variables needed for quest state */
|
||||||
import { useState, type JSX } from "react";
|
import { useState, type JSX } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import { ZoneSelector } from "./zoneSelector.js";
|
import { ZoneSelector } from "./zoneSelector.js";
|
||||||
import type { Quest } from "@elysium/types";
|
import type { Quest } from "@elysium/types";
|
||||||
@@ -81,6 +82,11 @@ const QuestCard = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`quest-card quest-${quest.status}`}>
|
<div className={`quest-card quest-${quest.status}`}>
|
||||||
|
<img
|
||||||
|
alt={quest.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("quests", quest.id)}
|
||||||
|
/>
|
||||||
<div className="quest-info">
|
<div className="quest-info">
|
||||||
<h3>{quest.name}</h3>
|
<h3>{quest.name}</h3>
|
||||||
<p>{quest.description}</p>
|
<p>{quest.description}</p>
|
||||||
@@ -102,9 +108,9 @@ const QuestCard = ({
|
|||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
<div className="quest-rewards">
|
<div className="quest-rewards">
|
||||||
{quest.rewards.map((reward) => {
|
{quest.rewards.map((reward, rewardIndex) => {
|
||||||
return (
|
return (
|
||||||
<span className="reward-tag" key={`${reward.type}-${String(reward.amount ?? "")}`}>
|
<span className="reward-tag" key={`${reward.type}-${reward.targetId ?? String(reward.amount ?? rewardIndex)}`}>
|
||||||
{reward.type === "gold"
|
{reward.type === "gold"
|
||||||
&& `🪙 ${formatNumber(reward.amount ?? 0)}`}
|
&& `🪙 ${formatNumber(reward.amount ?? 0)}`}
|
||||||
{reward.type === "essence"
|
{reward.type === "essence"
|
||||||
@@ -178,7 +184,9 @@ const QuestCard = ({
|
|||||||
*/
|
*/
|
||||||
const QuestPanel = (): JSX.Element => {
|
const QuestPanel = (): JSX.Element => {
|
||||||
const { state, toggleAutoQuest } = useGame();
|
const { state, toggleAutoQuest } = useGame();
|
||||||
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
||||||
|
return sessionStorage.getItem("elysium_quest_zone") ?? "verdant_vale";
|
||||||
|
});
|
||||||
const [ showLocked, setShowLocked ] = useState(true);
|
const [ showLocked, setShowLocked ] = useState(true);
|
||||||
|
|
||||||
if (state === null) {
|
if (state === null) {
|
||||||
@@ -237,6 +245,11 @@ const QuestPanel = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleZoneSelect(zoneId: string): void {
|
||||||
|
setActiveZoneId(zoneId);
|
||||||
|
sessionStorage.setItem("elysium_quest_zone", zoneId);
|
||||||
|
}
|
||||||
|
|
||||||
function handleToggle(): void {
|
function handleToggle(): void {
|
||||||
setShowLocked((current) => {
|
setShowLocked((current) => {
|
||||||
return !current;
|
return !current;
|
||||||
@@ -279,7 +292,7 @@ const QuestPanel = (): JSX.Element => {
|
|||||||
|
|
||||||
<ZoneSelector
|
<ZoneSelector
|
||||||
activeZoneId={activeZoneId}
|
activeZoneId={activeZoneId}
|
||||||
onSelectZone={setActiveZoneId}
|
onSelectZone={handleZoneSelect}
|
||||||
zones={zones}
|
zones={zones}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { STORY_CHAPTERS } from "@elysium/types";
|
import { STORY_CHAPTERS } from "@elysium/types";
|
||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Substitutes the character name placeholder in story text.
|
* Substitutes the character name placeholder in story text.
|
||||||
@@ -102,6 +103,11 @@ const StoryPanel = (): JSX.Element => {
|
|||||||
: <div className="story-chapter-view">
|
: <div className="story-chapter-view">
|
||||||
{isUnlocked
|
{isUnlocked
|
||||||
? <>
|
? <>
|
||||||
|
<img
|
||||||
|
alt={activeChapter.title}
|
||||||
|
className="story-chapter-banner"
|
||||||
|
src={cdnImage("story-chapters", activeChapter.id)}
|
||||||
|
/>
|
||||||
<h2 className="story-chapter-title">
|
<h2 className="story-chapter-title">
|
||||||
{"Chapter "}
|
{"Chapter "}
|
||||||
{activeChapterIndex + 1}
|
{activeChapterIndex + 1}
|
||||||
|
|||||||
@@ -7,12 +7,14 @@
|
|||||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||||
/* eslint-disable complexity -- Many conditional render paths */
|
/* eslint-disable complexity -- Many conditional render paths */
|
||||||
/* eslint-disable max-statements -- Transcendence panel manages many local state variables */
|
/* eslint-disable max-statements -- Transcendence panel manages many local state variables */
|
||||||
|
/* eslint-disable max-lines -- Transcendence panel with CDN images exceeds line limit */
|
||||||
import { useState, type JSX } from "react";
|
import { useState, type JSX } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import {
|
import {
|
||||||
TRANSCENDENCE_UPGRADES,
|
TRANSCENDENCE_UPGRADES,
|
||||||
TRANSCENDENCE_UPGRADE_CATEGORY_LABELS,
|
TRANSCENDENCE_UPGRADE_CATEGORY_LABELS,
|
||||||
} from "../../data/transcendenceUpgrades.js";
|
} from "../../data/transcendenceUpgrades.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import type { TranscendenceUpgradeCategory } from "@elysium/types";
|
import type { TranscendenceUpgradeCategory } from "@elysium/types";
|
||||||
|
|
||||||
const echoFormulaConstant = 853;
|
const echoFormulaConstant = 853;
|
||||||
@@ -301,6 +303,11 @@ const TranscendencePanel = (): JSX.Element => {
|
|||||||
: ""}`}
|
: ""}`}
|
||||||
key={upgrade.id}
|
key={upgrade.id}
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
alt={upgrade.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("transcendence-upgrades", upgrade.id)}
|
||||||
|
/>
|
||||||
<div className="shop-upgrade-info">
|
<div className="shop-upgrade-info">
|
||||||
<h4>{upgrade.name}</h4>
|
<h4>{upgrade.name}</h4>
|
||||||
<p>{upgrade.description}</p>
|
<p>{upgrade.description}</p>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
/* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */
|
/* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */
|
||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import type { Upgrade } from "@elysium/types";
|
import type { Upgrade } from "@elysium/types";
|
||||||
|
|
||||||
@@ -53,6 +54,11 @@ const UpgradeCard = ({
|
|||||||
if (upgrade.unlocked && upgrade.purchased) {
|
if (upgrade.unlocked && upgrade.purchased) {
|
||||||
return (
|
return (
|
||||||
<div className="upgrade-card purchased">
|
<div className="upgrade-card purchased">
|
||||||
|
<img
|
||||||
|
alt={upgrade.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("upgrades", upgrade.id)}
|
||||||
|
/>
|
||||||
<span className="upgrade-name">
|
<span className="upgrade-name">
|
||||||
{"✅ "}
|
{"✅ "}
|
||||||
{upgrade.name}
|
{upgrade.name}
|
||||||
@@ -65,6 +71,11 @@ const UpgradeCard = ({
|
|||||||
if (upgrade.unlocked) {
|
if (upgrade.unlocked) {
|
||||||
return (
|
return (
|
||||||
<div className="upgrade-card">
|
<div className="upgrade-card">
|
||||||
|
<img
|
||||||
|
alt={upgrade.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("upgrades", upgrade.id)}
|
||||||
|
/>
|
||||||
<div className="upgrade-info">
|
<div className="upgrade-info">
|
||||||
<h3>{upgrade.name}</h3>
|
<h3>{upgrade.name}</h3>
|
||||||
<p>{upgrade.description}</p>
|
<p>{upgrade.description}</p>
|
||||||
@@ -108,6 +119,11 @@ const UpgradeCard = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="upgrade-card locked">
|
<div className="upgrade-card locked">
|
||||||
|
<img
|
||||||
|
alt={upgrade.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("upgrades", upgrade.id)}
|
||||||
|
/>
|
||||||
<div className="upgrade-info">
|
<div className="upgrade-info">
|
||||||
<h3>
|
<h3>
|
||||||
{"🔒 "}
|
{"🔒 "}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import type { Zone } from "@elysium/types";
|
import type { Zone } from "@elysium/types";
|
||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
|
|
||||||
@@ -44,7 +45,11 @@ const ZoneSelector = ({
|
|||||||
title={zone.description}
|
title={zone.description}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span className="zone-emoji">{zone.emoji}</span>
|
<img
|
||||||
|
alt={zone.name}
|
||||||
|
className="zone-tab-image"
|
||||||
|
src={cdnImage("zones", zone.id)}
|
||||||
|
/>
|
||||||
<span className="zone-name">{zone.name}</span>
|
<span className="zone-name">{zone.name}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import {
|
|||||||
} from "../engine/tick.js";
|
} from "../engine/tick.js";
|
||||||
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
||||||
import { formatNumber as formatNumberUtil } from "../utils/format.js";
|
import { formatNumber as formatNumberUtil } from "../utils/format.js";
|
||||||
|
import { logError } from "../utils/logError.js";
|
||||||
import { sendNotification } from "../utils/notification.js";
|
import { sendNotification } from "../utils/notification.js";
|
||||||
import { playSound } from "../utils/sound.js";
|
import { playSound } from "../utils/sound.js";
|
||||||
|
|
||||||
@@ -544,6 +545,18 @@ interface GameContextValue {
|
|||||||
* Reset all progress to a fresh save state (resolves schema outdated).
|
* Reset all progress to a fresh save state (resolves schema outdated).
|
||||||
*/
|
*/
|
||||||
resetProgress: ()=> Promise<void>;
|
resetProgress: ()=> Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last auto-boss fight result — null until the first auto fight completes or
|
||||||
|
* when auto-boss is toggled off.
|
||||||
|
*/
|
||||||
|
autoBossLastResult: { bossName: string; won: boolean; at: number } | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error message set when auto-boss stopped due to a critical failure (null
|
||||||
|
* when no error). Cleared automatically when the player re-enables auto-boss.
|
||||||
|
*/
|
||||||
|
autoBossError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BattleResult {
|
export interface BattleResult {
|
||||||
@@ -587,6 +600,12 @@ export const GameProvider = ({
|
|||||||
const [ lastSavedAt, setLastSavedAt ] = useState<number | null>(null);
|
const [ lastSavedAt, setLastSavedAt ] = useState<number | null>(null);
|
||||||
const [ isSyncing, setIsSyncing ] = useState(false);
|
const [ isSyncing, setIsSyncing ] = useState(false);
|
||||||
const [ syncError, setSyncError ] = useState<string | null>(null);
|
const [ syncError, setSyncError ] = useState<string | null>(null);
|
||||||
|
const [ autoBossLastResult, setAutoBossLastResult ] = useState<{
|
||||||
|
bossName: string;
|
||||||
|
won: boolean;
|
||||||
|
at: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [ autoBossError, setAutoBossError ] = useState<string | null>(null);
|
||||||
const syncErrorTimerReference = useRef<ReturnType<typeof setTimeout> | null>(
|
const syncErrorTimerReference = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -1058,6 +1077,14 @@ export const GameProvider = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Quest failure — turn off auto-quest so the player can reassess
|
||||||
|
if (
|
||||||
|
newlyFailedQuestsReference.current.length > 0
|
||||||
|
&& next.autoQuest === true
|
||||||
|
) {
|
||||||
|
next = { ...next, autoQuest: false };
|
||||||
|
}
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1130,6 +1157,8 @@ export const GameProvider = ({
|
|||||||
) {
|
) {
|
||||||
signatureReference.current = null;
|
signatureReference.current = null;
|
||||||
localStorage.removeItem("elysium_save_signature");
|
localStorage.removeItem("elysium_save_signature");
|
||||||
|
} else {
|
||||||
|
logError("auto_save", error_);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1158,7 +1187,8 @@ export const GameProvider = ({
|
|||||||
}
|
}
|
||||||
await reloadReference.current();
|
await reloadReference.current();
|
||||||
}).
|
}).
|
||||||
catch(() => {
|
catch((error_: unknown) => {
|
||||||
|
logError("auto_prestige", error_);
|
||||||
|
|
||||||
/* Silently ignore — will retry next tick */
|
/* Silently ignore — will retry next tick */
|
||||||
}).
|
}).
|
||||||
@@ -1196,13 +1226,32 @@ export const GameProvider = ({
|
|||||||
if (previous === null) {
|
if (previous === null) {
|
||||||
return previous;
|
return previous;
|
||||||
}
|
}
|
||||||
return applyBossResult(previous, bossId, result);
|
const afterBoss = applyBossResult(previous, bossId, result);
|
||||||
|
// Defeat — turn off auto-boss so the player can reassess
|
||||||
|
if (!result.won) {
|
||||||
|
return { ...afterBoss, autoBoss: false };
|
||||||
|
}
|
||||||
|
return afterBoss;
|
||||||
|
});
|
||||||
|
setAutoBossLastResult({
|
||||||
|
at: Date.now(),
|
||||||
|
bossName: bossName,
|
||||||
|
won: result.won,
|
||||||
});
|
});
|
||||||
setBattleResult({ bossName, result });
|
|
||||||
}).
|
}).
|
||||||
catch(() => {
|
catch((error_: unknown) => {
|
||||||
|
logError("auto_boss", error_);
|
||||||
/* Silently ignore — will retry next tick */
|
const message
|
||||||
|
= error_ instanceof Error
|
||||||
|
? error_.message
|
||||||
|
: String(error_);
|
||||||
|
setAutoBossError(message);
|
||||||
|
setState((previous) => {
|
||||||
|
if (previous === null) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
return { ...previous, autoBoss: false };
|
||||||
|
});
|
||||||
}).
|
}).
|
||||||
finally(() => {
|
finally(() => {
|
||||||
isAutoBossingReference.current = false;
|
isAutoBossingReference.current = false;
|
||||||
@@ -1521,12 +1570,14 @@ export const GameProvider = ({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (error_: unknown) {
|
||||||
|
logError("buy_prestige_upgrade", error_);
|
||||||
// Silently ignore — server errors shouldn't crash the UI
|
// Silently ignore — server errors shouldn't crash the UI
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const transcend = useCallback(async() => {
|
const transcend = useCallback(async() => {
|
||||||
|
try {
|
||||||
const result = await transcendApi({});
|
const result = await transcendApi({});
|
||||||
setShowTranscendenceToast(true);
|
setShowTranscendenceToast(true);
|
||||||
if (enableSoundsReference.current) {
|
if (enableSoundsReference.current) {
|
||||||
@@ -1537,9 +1588,14 @@ export const GameProvider = ({
|
|||||||
}
|
}
|
||||||
await reload();
|
await reload();
|
||||||
return result;
|
return result;
|
||||||
|
} catch (error_: unknown) {
|
||||||
|
logError("transcend", error_);
|
||||||
|
throw error_;
|
||||||
|
}
|
||||||
}, [ reload ]);
|
}, [ reload ]);
|
||||||
|
|
||||||
const apotheosis = useCallback(async() => {
|
const apotheosis = useCallback(async() => {
|
||||||
|
try {
|
||||||
const result = await achieveApotheosisApi({});
|
const result = await achieveApotheosisApi({});
|
||||||
setShowApotheosisToast(true);
|
setShowApotheosisToast(true);
|
||||||
if (enableSoundsReference.current) {
|
if (enableSoundsReference.current) {
|
||||||
@@ -1550,6 +1606,10 @@ export const GameProvider = ({
|
|||||||
}
|
}
|
||||||
await reload();
|
await reload();
|
||||||
return result;
|
return result;
|
||||||
|
} catch (error_: unknown) {
|
||||||
|
logError("apotheosis", error_);
|
||||||
|
throw error_;
|
||||||
|
}
|
||||||
}, [ reload ]);
|
}, [ reload ]);
|
||||||
|
|
||||||
const buyEchoUpgrade = useCallback(async(upgradeId: string) => {
|
const buyEchoUpgrade = useCallback(async(upgradeId: string) => {
|
||||||
@@ -1575,12 +1635,14 @@ export const GameProvider = ({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (error_: unknown) {
|
||||||
// Silently ignore server errors
|
logError("buy_echo_upgrade", error_);
|
||||||
|
// Silently ignore — server errors shouldn't crash the UI
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startExploration = useCallback(async(areaId: string) => {
|
const startExploration = useCallback(async(areaId: string) => {
|
||||||
|
try {
|
||||||
const response = await startExplorationApi({ areaId });
|
const response = await startExplorationApi({ areaId });
|
||||||
const areaData = EXPLORATION_AREAS.find((a) => {
|
const areaData = EXPLORATION_AREAS.find((a) => {
|
||||||
return a.id === areaId;
|
return a.id === areaId;
|
||||||
@@ -1606,10 +1668,15 @@ export const GameProvider = ({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
} catch (error_: unknown) {
|
||||||
|
logError("start_exploration", error_);
|
||||||
|
throw error_;
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const collectExploration = useCallback(
|
const collectExploration = useCallback(
|
||||||
async(areaId: string): Promise<ExploreCollectResponse> => {
|
async(areaId: string): Promise<ExploreCollectResponse> => {
|
||||||
|
try {
|
||||||
const result = await collectExplorationApi({ areaId });
|
const result = await collectExplorationApi({ areaId });
|
||||||
setState((previous) => {
|
setState((previous) => {
|
||||||
if (previous?.exploration === undefined) {
|
if (previous?.exploration === undefined) {
|
||||||
@@ -1683,6 +1750,10 @@ export const GameProvider = ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
|
} catch (error_: unknown) {
|
||||||
|
logError("collect_exploration", error_);
|
||||||
|
throw error_;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -1694,6 +1765,7 @@ export const GameProvider = ({
|
|||||||
if (recipe === undefined) {
|
if (recipe === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
const result = await craftRecipeApi({ recipeId });
|
const result = await craftRecipeApi({ recipeId });
|
||||||
setState((previous) => {
|
setState((previous) => {
|
||||||
if (previous?.exploration === undefined) {
|
if (previous?.exploration === undefined) {
|
||||||
@@ -1723,6 +1795,10 @@ export const GameProvider = ({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
} catch (error_: unknown) {
|
||||||
|
logError("craft_recipe", error_);
|
||||||
|
throw error_;
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleAutoPrestige = useCallback(() => {
|
const toggleAutoPrestige = useCallback(() => {
|
||||||
@@ -1750,6 +1826,8 @@ export const GameProvider = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleAutoBoss = useCallback(() => {
|
const toggleAutoBoss = useCallback(() => {
|
||||||
|
setAutoBossError(null);
|
||||||
|
setAutoBossLastResult(null);
|
||||||
setState((previous) => {
|
setState((previous) => {
|
||||||
if (previous === null) {
|
if (previous === null) {
|
||||||
return previous;
|
return previous;
|
||||||
@@ -1798,7 +1876,8 @@ export const GameProvider = ({
|
|||||||
return applyBossResult(previous, bossId, result);
|
return applyBossResult(previous, bossId, result);
|
||||||
});
|
});
|
||||||
setBattleResult({ bossName: boss.name, result: result });
|
setBattleResult({ bossName: boss.name, result: result });
|
||||||
} catch {
|
} catch (error_: unknown) {
|
||||||
|
logError("challenge_boss", error_);
|
||||||
// Silently ignore — server errors shouldn't crash the UI
|
// Silently ignore — server errors shouldn't crash the UI
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -1941,6 +2020,8 @@ export const GameProvider = ({
|
|||||||
const contextValue = useMemo<GameContextValue>(() => {
|
const contextValue = useMemo<GameContextValue>(() => {
|
||||||
return {
|
return {
|
||||||
apotheosis,
|
apotheosis,
|
||||||
|
autoBossError,
|
||||||
|
autoBossLastResult,
|
||||||
battleResult,
|
battleResult,
|
||||||
buyAdventurer,
|
buyAdventurer,
|
||||||
buyEchoUpgrade,
|
buyEchoUpgrade,
|
||||||
@@ -2007,6 +2088,8 @@ export const GameProvider = ({
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
apotheosis,
|
apotheosis,
|
||||||
|
autoBossError,
|
||||||
|
autoBossLastResult,
|
||||||
battleResult,
|
battleResult,
|
||||||
completedQuestToasts,
|
completedQuestToasts,
|
||||||
failedQuestToasts,
|
failedQuestToasts,
|
||||||
|
|||||||
@@ -8,8 +8,12 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { App } from "./app.js";
|
import { App } from "./app.js";
|
||||||
|
import { ErrorBoundary } from "./components/errorBoundary.js";
|
||||||
|
import { initialiseFrontendLogger } from "./utils/logger.js";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
|
initialiseFrontendLogger();
|
||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
|
|
||||||
if (!rootElement) {
|
if (!rootElement) {
|
||||||
@@ -18,6 +22,8 @@ if (!rootElement) {
|
|||||||
|
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<ErrorBoundary>
|
||||||
<App />
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
+127
-5
@@ -26,6 +26,7 @@
|
|||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
--radius-lg: 12px;
|
--radius-lg: 12px;
|
||||||
--font: "Segoe UI", system-ui, sans-serif;
|
--font: "Segoe UI", system-ui, sans-serif;
|
||||||
|
--resource-bar-height: 3.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -33,6 +34,20 @@ body {
|
|||||||
color: var(--colour-text);
|
color: var(--colour-text);
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
background-attachment: fixed;
|
||||||
|
background-image: url("https://cdn.nhcarrigan.com/elysium/background.jpg");
|
||||||
|
background-position: center;
|
||||||
|
background-size: cover;
|
||||||
|
content: "";
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0.15;
|
||||||
|
pointer-events: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===================== RESOURCE BAR ===================== */
|
/* ===================== RESOURCE BAR ===================== */
|
||||||
@@ -122,6 +137,10 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
position: sticky;
|
||||||
|
top: var(--resource-bar-height);
|
||||||
|
height: calc(100vh - var(--resource-bar-height));
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-content {
|
.game-content {
|
||||||
@@ -2056,8 +2075,11 @@ body {
|
|||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zone-emoji {
|
.zone-tab-image {
|
||||||
font-size: 1.4rem;
|
aspect-ratio: 16 / 9;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 96px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zone-name {
|
.zone-name {
|
||||||
@@ -2285,9 +2307,6 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.about-release-body {
|
.about-release-body {
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--colour-text-secondary, #b0b0b0);
|
color: var(--colour-text-secondary, #b0b0b0);
|
||||||
padding: 0 1rem 0.75rem;
|
padding: 0 1rem 0.75rem;
|
||||||
@@ -2295,6 +2314,81 @@ body {
|
|||||||
border-top: 1px solid var(--colour-border, #0f3460);
|
border-top: 1px solid var(--colour-border, #0f3460);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.about-release-body p {
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-release-body p:first-child {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-release-body p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-release-body ul,
|
||||||
|
.about-release-body ol {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-release-body li {
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-release-body h1,
|
||||||
|
.about-release-body h2,
|
||||||
|
.about-release-body h3,
|
||||||
|
.about-release-body h4 {
|
||||||
|
color: var(--colour-accent);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0.75rem 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-release-body h1:first-child,
|
||||||
|
.about-release-body h2:first-child,
|
||||||
|
.about-release-body h3:first-child,
|
||||||
|
.about-release-body h4:first-child {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-release-body code {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.1em 0.3em;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-release-body pre {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-release-body pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-release-body a {
|
||||||
|
color: var(--colour-accent-light);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-release-body a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-release-body strong {
|
||||||
|
color: var(--colour-text);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
.about-how-to-play {
|
.about-how-to-play {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -3092,8 +3186,11 @@ body {
|
|||||||
border-right: none;
|
border-right: none;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
height: auto;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
|
position: static;
|
||||||
|
top: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4393,3 +4490,28 @@ body {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===================== CDN ASSET IMAGES ===================== */
|
||||||
|
.card-thumbnail {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 72px;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-chapter-banner {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
height: 220px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codex-entry-image {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
height: 80px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* @file CDN URL utility for Elysium game assets.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
const cdnBase = "https://cdn.nhcarrigan.com/elysium";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the CDN URL for a game asset image.
|
||||||
|
* @param folder - The asset category folder (e.g. "bosses", "companions").
|
||||||
|
* @param id - The asset identifier (file name without extension).
|
||||||
|
* @returns The full CDN URL for the asset.
|
||||||
|
*/
|
||||||
|
const cdnImage = (folder: string, id: string): string => {
|
||||||
|
return `${cdnBase}/${folder}/${id}.jpg`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { cdnImage };
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* @file Frontend error logging utility that forwards errors to the backend telemetry service.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
/* eslint-disable no-console -- Errors are forwarded to backend via the overridden console.error */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs an error to the backend telemetry service.
|
||||||
|
* Accepts the same arguments as console.error — conventionally a context string
|
||||||
|
* followed by the error value.
|
||||||
|
* @param logArguments - The values to log, forwarded directly to console.error.
|
||||||
|
*/
|
||||||
|
const logError = (...logArguments: Array<unknown>): void => {
|
||||||
|
console.error(...logArguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { logError };
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* @file Frontend logger that forwards console output to the backend telemetry service.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
/* eslint-disable no-console -- This file intentionally overrides console methods */
|
||||||
|
|
||||||
|
type Level = "debug" | "info" | "warn";
|
||||||
|
|
||||||
|
const post = (path: string, body: object): void => {
|
||||||
|
void fetch(path, {
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header names use kebab-case
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
method: "POST",
|
||||||
|
}).catch(() => {
|
||||||
|
// Intentionally swallowed — we cannot log logger failures without infinite recursion.
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overrides the global console.log and console.error methods so that all
|
||||||
|
* frontend log output is forwarded to the backend telemetry endpoints.
|
||||||
|
* Must be called once at application startup before any other code runs.
|
||||||
|
*/
|
||||||
|
const initialiseFrontendLogger = (): void => {
|
||||||
|
const originalLog = console.log.bind(console);
|
||||||
|
const originalError = console.error.bind(console);
|
||||||
|
|
||||||
|
console.log = (...consoleArguments: Array<unknown>): void => {
|
||||||
|
originalLog(...consoleArguments);
|
||||||
|
const level: Level = "info";
|
||||||
|
const message = consoleArguments.map((argument) => {
|
||||||
|
return typeof argument === "string"
|
||||||
|
? argument
|
||||||
|
: JSON.stringify(argument);
|
||||||
|
}).join(" ");
|
||||||
|
post("/api/fe/log", { level, message });
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error = (...consoleArguments: Array<unknown>): void => {
|
||||||
|
originalError(...consoleArguments);
|
||||||
|
const message = consoleArguments.map((argument) => {
|
||||||
|
if (argument instanceof Error) {
|
||||||
|
return `${argument.message}\n${argument.stack ?? ""}`;
|
||||||
|
}
|
||||||
|
return typeof argument === "string"
|
||||||
|
? argument
|
||||||
|
: JSON.stringify(argument);
|
||||||
|
}).join(" ");
|
||||||
|
const context = "console.error";
|
||||||
|
post("/api/fe/error", { context, message });
|
||||||
|
};
|
||||||
|
|
||||||
|
console.warn = (...consoleArguments: Array<unknown>): void => {
|
||||||
|
originalLog(...consoleArguments);
|
||||||
|
const level: Level = "warn";
|
||||||
|
const message = consoleArguments.map((argument) => {
|
||||||
|
return typeof argument === "string"
|
||||||
|
? argument
|
||||||
|
: JSON.stringify(argument);
|
||||||
|
}).join(" ");
|
||||||
|
post("/api/fe/log", { level, message });
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export { initialiseFrontendLogger };
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
import { logError } from "./logError.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Requests browser notification permission from the user.
|
* Requests browser notification permission from the user.
|
||||||
@@ -38,7 +39,8 @@ const sendNotification = (title: string, body: string): void => {
|
|||||||
try {
|
try {
|
||||||
// eslint-disable-next-line no-new -- Notification constructor has side effects
|
// eslint-disable-next-line no-new -- Notification constructor has side effects
|
||||||
new Notification(title, { body: body, icon: "/favicon.ico" });
|
new Notification(title, { body: body, icon: "/favicon.ico" });
|
||||||
} catch {
|
} catch (error_: unknown) {
|
||||||
|
logError("send_notification", error_);
|
||||||
// Silently ignore — notifications may fail silently
|
// Silently ignore — notifications may fail silently
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
import { logError } from "./logError.js";
|
||||||
|
|
||||||
type SoundEvent =
|
type SoundEvent =
|
||||||
| "achievement"
|
| "achievement"
|
||||||
@@ -101,7 +102,8 @@ const playSound = (event: SoundEvent): void => {
|
|||||||
oscillator.start(startTime);
|
oscillator.start(startTime);
|
||||||
oscillator.stop(endTime);
|
oscillator.stop(endTime);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error_: unknown) {
|
||||||
|
logError("play_sound", error_);
|
||||||
// Silently ignore — audio may not be available in all environments
|
// Silently ignore — audio may not be available in all environments
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ interface Boss {
|
|||||||
* One-time runestone bounty awarded on first-ever defeat.
|
* One-time runestone bounty awarded on first-ever defeat.
|
||||||
*/
|
*/
|
||||||
bountyRunestones: number;
|
bountyRunestones: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the first-kill runestone bounty has already been claimed.
|
||||||
|
* Set to true on first defeat and preserved across all prestiges so the
|
||||||
|
* bounty is never re-awarded in subsequent runs.
|
||||||
|
*/
|
||||||
|
bountyRunestonesClaimed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { Boss, BossStatus };
|
export type { Boss, BossStatus };
|
||||||
|
|||||||
Generated
+699
-17
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user