15 Commits

Author SHA1 Message Date
hikari 9860a2cb1f feat: persist crafting zone selection in sessionStorage (#49)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint, Build & Test (push) Successful in 1m8s
## Summary

- Applies the same sticky-zone pattern from #48 to the crafting panel (`elysium_craft_zone` key in sessionStorage)
- Introduces a `handleZoneSelect` wrapper so sessionStorage is updated alongside React state on every zone change
- Gracefully falls back to `verdant_vale` if no stored value exists

## Test plan

- [x] Lint — zero errors, zero warnings
- [x] Build — all packages build cleanly
- [ ] Manual: select a non-default zone in the crafting panel, navigate away and back — zone should still be selected
- [ ] Manual: log out and back in — zone should reset to Verdant Vale

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #49
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-09 22:25:18 -07:00
hikari 404b31bd13 fix: persist UI preferences across navigation and sessions (#48)
CI / Lint, Build & Test (push) Successful in 1m10s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m11s
## Summary

- **#35** — Adventure multiplier selection is now persisted in `localStorage` (`"elysium_batch_size"`). The chosen batch size is restored automatically on the next visit, with a graceful fallback to `1` for missing or unrecognisable values.
- **#36** — Zone selection in the boss panel and quest panel is now persisted in `sessionStorage` (`"elysium_boss_zone"` / `"elysium_quest_zone"`). The selected zone survives navigation within a session and resets cleanly when the session ends, defaulting to Verdant Vale if no stored value exists.

## Test plan

- [x] Lint — zero errors, zero warnings
- [x] Build — all packages build cleanly
- [x] Tests — 415 tests passing, 100% coverage across all packages
- [ ] Manual: select a non-default batch size, refresh the page — multiplier should be restored
- [ ] Manual: switch to a non-default zone in the boss panel, navigate away and back — zone should still be selected
- [ ] Manual: repeat for the quest panel
- [ ] Manual: log out and back in — zone selection should reset to Verdant Vale

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #48
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-09 22:17:12 -07:00
hikari d0790890ee fix: preserve all-time stats, achievements, and boss first-kill across prestige (#47)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint, Build & Test (push) Successful in 1m8s
Resolves #37, resolves #38, and resolves #39 — three related bugs where prestige incorrectly reset data that should survive all prestige resets.

## Changes

### fix: preserve lifetime player stats across prestige (#37)
After prestige, `GameState.player.lifetime*` fields were stale — they reflected values from *before* the current run. The Prisma Player record was incremented correctly, but the GameState JSON saved to the DB had old values, so the UI showed wrong all-time totals on reload.

`buildPostPrestigeState` now computes the run-stat contributions (bosses defeated, quests completed, adventurers recruited, achievements unlocked, gold earned, clicks) and folds them into the fresh player object before writing the prestige state.

### fix: preserve achievements across prestige (#38)
`buildPostPrestigeState` was reconstructing achievements from `defaultAchievements` (via `initialGameState`), resetting all unlocked achievements on every prestige. Achievements are now carried forward from `currentState.achievements` instead.

### fix: preserve boss first-kill state across prestige (#39)
Added `bountyRunestonesClaimed?: boolean` to the `Boss` type. The boss challenge route now:
- Only awards the first-kill bounty runestones if `bountyRunestonesClaimed !== true`
- Sets `bountyRunestonesClaimed = true` on first defeat

`buildPostPrestigeState` maps the fresh boss list and carries the `bountyRunestonesClaimed` flag forward from the current state, so the bounty is never re-awarded in subsequent prestige runs. The boss panel badge is also hidden for bosses whose bounty is already claimed.

## Test Coverage
All three fixes include new tests covering the new behaviours. API coverage remains at 100%.

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #47
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-09 21:53:58 -07:00
hikari 4d7e624358 fix: turn off auto-boss/auto-quest on failure and surface status (#46)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint, Build & Test (push) Successful in 1m7s
## Summary

- Auto-boss now turns itself **off** when a boss fight is **lost**, so the player can reassess rather than the system silently looping. A "🤖 Last fight: [Boss] —  Lost" status line appears in the boss panel.
- Auto-boss also turns off (with an ⚠️ error message) when the API call fails outright (e.g. party has no adventurers), replacing the previous behaviour of silently hammering the API every animation frame.
- Auto-quest now turns itself **off** whenever a quest fails the random-chance check, detected inside the tick's `setState` callback immediately after `applyTick`.
- `autoBoss: false` and `autoQuest: false` are now part of `initialGameState`, so these fields persist through save/load cycles from the very first session — preventing a race window where the boss-route DB write could strip them before the first auto-save.
- `toggleAutoBoss` clears both `autoBossLastResult` and `autoBossError` on each toggle so the panel always reflects the current session cleanly.

## Test plan

- [x] `pnpm lint` — 0 errors, 0 warnings
- [x] `pnpm build` — all packages clean
- [x] `pnpm test` — 100% coverage maintained across the board

Closes #40

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #46
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-09 21:12:03 -07:00
hikari ac94f67797 fix: send webhook milestone notifications silently (#45)
CI / Lint, Build & Test (push) Successful in 1m8s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m8s
## Summary

- Adds `flags: 4096` (`MessageFlags.SUPPRESS_NOTIFICATIONS`) to the Discord webhook payload in `postMilestoneWebhook`
- Milestone announcements (prestige, transcendence, apotheosis) will now appear in the channel without triggering desktop or mobile push notifications
- Defines the magic number as a documented `suppressNotifications` constant for self-documentation
- Updates the webhook test to assert `flags: 4096` is present in the outgoing payload

Closes #41

## Test plan

- [ ] Lint passes: `pnpm lint`
- [ ] Build passes: `pnpm build`
- [ ] Tests pass with 100% coverage: `pnpm test`
- [ ] Trigger a prestige/transcendence/apotheosis in-game and verify the Discord webhook message arrives without pinging anyone

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #45
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-09 20:24:13 -07:00
hikari a36c8e72a5 feat: error handling, logging, analytics, OG tags, and sticky sidebar (#44)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint, Build & Test (push) Successful in 1m8s
## Summary

- Add comprehensive try/catch error handling across all API routes, middleware, and the Hono global error handler, piping every unhandled error to the `@nhcarrigan/logger` service to prevent silent crashes and unhandled Promise rejections
- Add a `logError` utility on the frontend that forwards errors through the overridden `console.error` to the backend telemetry endpoint; apply it to every silent `catch {}` block in the game context, sound, notification, and clipboard utilities, and wrap the React tree in an `ErrorBoundary`
- Add Plausible analytics, Open Graph + Twitter Card meta tags, Tree-Nation widget, and Google Ads to `index.html`
- Make the game sidebar sticky with a `--resource-bar-height` CSS custom property offset so it stays viewport-height without overlapping the resource bar; reset sticky behaviour in the mobile responsive override

## Test plan

- [ ] Lint passes: `pnpm lint`
- [ ] Build passes: `pnpm build`
- [ ] Verify errors thrown in API routes appear in the logger service rather than crashing the process
- [ ] Verify frontend errors appear in the `/api/fe/error` backend log
- [ ] Verify Open Graph tags render correctly when sharing the URL
- [ ] Verify Plausible analytics fires on page load
- [ ] Verify Tree-Nation badge renders in the sidebar
- [ ] Verify sidebar stays fixed while the main content scrolls on desktop
- [ ] Verify mobile layout is unaffected

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #44
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-09 19:54:42 -07:00
hikari 11e97325cb feat: integrate art assets across all game panels (#43)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m2s
CI / Lint, Build & Test (push) Successful in 1m6s
## Summary

- Adds `apps/web/src/utils/cdn.ts` with a `cdnImage(folder, id)` helper that builds URLs from `https://cdn.nhcarrigan.com/elysium/`
- Wires CDN art into all 13 game panels (bosses, quests, adventurers, companions, equipment, upgrades, prestige, transcendence, achievements, explorations, crafting, story, codex)
- Zone selector tabs now display 16:9 zone art thumbnails in place of emoji icons
- Adds a fixed background image at 15% opacity via `body::before`
- Documents the art generation and CDN upload process in `CLAUDE.md` for future expansions

Resolves #15

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #43
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-09 16:21:44 -07:00
hikari 7a1c57be9a feat: render changelog as markdown in about panel (#33)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m2s
CI / Lint, Build & Test (push) Successful in 1m6s
## Summary

- Installs `react-markdown@10.1.0` in `apps/web`
- Replaces the `<pre>` tag in the changelog section with the `<Markdown>` component for proper rendering
- Updates CSS to style markdown elements (paragraphs, lists, headings, code blocks, links, bold text)

Closes #31

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #33
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-09 09:35:30 -07:00
naomi b604a4aa5c release: v0.1.1
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m7s
CI / Lint, Build & Test (push) Successful in 1m8s
2026-03-08 20:23:22 -07:00
hikari e10eabc8b5 fix: save character name correctly and show story on character sheet
CI / Lint, Build & Test (push) Successful in 1m9s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m9s
- Load route syncs characterName from Player record so profile updates
  are reflected immediately on next load
- Save route preserves Player record's characterName so auto-saves
  cannot overwrite profile updates
- Public profile response now includes completedChapters
- Character sheet panel displays completed story chapters with outcome
- Removed stale CSS for old achievement/codex toast classes
2026-03-08 20:19:40 -07:00
hikari c3d79e0c11 feat: add third-person choice descriptions to public character sheet
CI / Lint, Build & Test (push) Failing after 57s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m15s
Each story choice now has a concise third-person description used on
the public character page, keeping narrative spoilers out of the
profile view whilst still conveying the character's path.
2026-03-08 20:15:26 -07:00
hikari 6e2cb45553 fix: delay boss lore toasts until battle animation reveals result
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint, Build & Test (push) Successful in 1m6s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:18:46 -07:00
hikari 5a065998b6 fix: delay boss notifications until reveal and animate hp bar colours
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint, Build & Test (push) Successful in 1m5s
- Move bossVictory sound and notification from gameContext into BattleModal,
  fired at the 5.2s reveal timeout so the animation plays before the spoiler
- Replace CSS width transition with a setInterval tick (50ms steps over 5s)
  so bossHpPercent and partyHpPercent update incrementally during the animation
- Both bars now use a shared getHpColour helper: green >50%, yellow 25-50%,
  red <25%, causing colour to shift naturally as the bar visually drains
2026-03-08 19:07:04 -07:00
hikari f9c925b9fc feat: unify toast styles and add quest/milestone toast notifications
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint, Build & Test (push) Successful in 1m5s
- Merge .codex-toast and .achievement-toast into a single .game-toast class
- Fix storyToast inner class names and replace <button> wrapper with <div>
- Add QuestCompleteToast and QuestFailedToast components
- Add MilestoneToast for prestige, transcendence, and apotheosis events
- Move shared toast container to gameLayout so all toasts stack in one column
- Wire quest detection in GameContext to store full Quest objects for toast names
- Trigger prestige toast from both auto-prestige and manual prestige panel
2026-03-08 18:47:42 -07:00
hikari 290c06de83 fix: correct combat power calculation in quest panel
CI / Lint, Build & Test (push) Failing after 49s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m18s
2026-03-08 16:02:49 -07:00
79 changed files with 4984 additions and 2080 deletions
+35
View File
@@ -7,6 +7,41 @@
2. `pnpm build` — all packages build cleanly
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
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.
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@elysium/api",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"type": "module",
"main": "./prod/src/index.js",
@@ -15,6 +15,7 @@
"dependencies": {
"@elysium/types": "workspace:*",
"@hono/node-server": "1.13.7",
"@nhcarrigan/logger": "1.1.1",
"@prisma/client": "6.5.0",
"hono": "4.7.4",
"prisma": "6.5.0"
+1
View File
@@ -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_GUILD_ID="op://Environment Variables - Naomi/Elysium/discord guild id"
DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id"
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
+2
View File
@@ -76,6 +76,8 @@ const initialGameState = (
achievements: structuredClone(defaultAchievements),
adventurers: structuredClone(defaultAdventurers),
apotheosis: { ...initialApotheosis },
autoBoss: false,
autoQuest: false,
baseClickPower: 1,
bosses: structuredClone(defaultBosses),
companions: { activeCompanionId: null, unlockedCompanionIds: [] },
+26 -4
View File
@@ -7,22 +7,24 @@
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import { logger as honoLogger } from "hono/logger";
import { aboutRouter } from "./routes/about.js";
import { apotheosisRouter } from "./routes/apotheosis.js";
import { authRouter } from "./routes/auth.js";
import { bossRouter } from "./routes/boss.js";
import { craftRouter } from "./routes/craft.js";
import { exploreRouter } from "./routes/explore.js";
import { frontendRouter } from "./routes/frontend.js";
import { gameRouter } from "./routes/game.js";
import { leaderboardRouter } from "./routes/leaderboards.js";
import { prestigeRouter } from "./routes/prestige.js";
import { profileRouter } from "./routes/profile.js";
import { transcendenceRouter } from "./routes/transcendence.js";
import { logger } from "./services/logger.js";
const app = new Hono();
app.use("*", logger());
app.use("*", honoLogger());
app.use(
"*",
cors({
@@ -33,6 +35,7 @@ app.use(
);
app.route("/about", aboutRouter);
app.route("/fe", frontendRouter);
app.route("/auth", authRouter);
app.route("/game", gameRouter);
app.route("/boss", bossRouter);
@@ -48,8 +51,27 @@ app.get("/health", (context) => {
return context.json({ status: "ok" });
});
app.onError((error, context) => {
void logger.error(
"hono_unhandled_error",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
});
const port = Number(process.env.PORT ?? 3001);
serve({ fetch: app.fetch, port: port }, () => {
try {
serve({ fetch: app.fetch, port: port }, () => {
process.stdout.write(`Elysium API running on port ${String(port)}\n`);
});
});
} catch (error) {
void logger.error(
"server_startup",
error instanceof Error
? error
: new Error(String(error)),
);
}
+8 -1
View File
@@ -6,6 +6,7 @@
*/
import { verifyToken } from "../services/jwt.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js";
import type { MiddlewareHandler } from "hono";
@@ -33,7 +34,13 @@ export const authMiddleware: MiddlewareHandler<HonoEnvironment> = async(
try {
const payload = verifyToken(token);
context.set("discordId", payload.discordId);
} catch {
} catch (error) {
void logger.error(
"auth_middleware",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Invalid or expired token" }, 401);
}
+13
View File
@@ -7,6 +7,7 @@
/* eslint-disable stylistic/max-len -- URL cannot be shortened */
/* eslint-disable require-atomic-updates -- Simple cache; race condition is acceptable */
import { Hono } from "hono";
import { logger } from "../services/logger.js";
import type { AboutResponse, GiteaRelease } from "@elysium/types";
// eslint-disable-next-line capitalized-comments -- v8 ignore
@@ -46,12 +47,24 @@ const fetchReleases = async(): Promise<Array<GiteaRelease>> => {
const aboutRouter = new Hono();
aboutRouter.get("/", async(context) => {
try {
const releases = await fetchReleases();
const body: AboutResponse = {
apiVersion,
releases,
};
return context.json(body);
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 9 -- @preserve */
} catch (error) {
void logger.error(
"about",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
export { aboutRouter };
+15
View File
@@ -5,6 +5,8 @@
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Route handler requires many steps */
/* eslint-disable max-statements -- Route handler requires many statements */
/* eslint-disable stylistic/max-len -- Description string cannot be shortened */
import { Hono } from "hono";
import { prisma } from "../db/client.js";
@@ -13,6 +15,7 @@ import {
buildPostApotheosisState,
isEligibleForApotheosis,
} from "../services/apotheosis.js";
import { logger } from "../services/logger.js";
import {
grantApotheosisRole,
postMilestoneWebhook,
@@ -25,6 +28,7 @@ const apotheosisRouter = new Hono<HonoEnvironment>();
apotheosisRouter.use("*", authMiddleware);
apotheosisRouter.post("/", async(context) => {
try {
const discordId = context.get("discordId");
const record = await prisma.gameState.findUnique({ where: { discordId } });
@@ -103,6 +107,8 @@ apotheosisRouter.post("/", async(context) => {
where: { discordId },
});
const apotheosisCount = updatedApotheosisData.count;
void logger.metric("apotheosis", 1, { apotheosisCount, discordId });
void grantApotheosisRole(discordId);
void postMilestoneWebhook(discordId, "apotheosis", {
apotheosis: updatedApotheosisData.count,
@@ -113,6 +119,15 @@ apotheosisRouter.post("/", async(context) => {
});
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 };
+12 -1
View File
@@ -15,6 +15,7 @@ import {
fetchDiscordUser,
} from "../services/discord.js";
import { signToken } from "../services/jwt.js";
import { logger } from "../services/logger.js";
import type { Player } from "@elysium/types";
const authRouter = new Hono();
@@ -92,6 +93,8 @@ authRouter.get("/callback", async(context) => {
});
const jwtToken = signToken(player.discordId);
void logger.log("info", `New player registered: ${player.discordId}`);
void logger.metric("user_registered", 1, { discordId: player.discordId });
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
@@ -111,6 +114,8 @@ authRouter.get("/callback", async(context) => {
});
const jwtToken = signToken(updated.discordId);
void logger.log("info", `Player logged in: ${updated.discordId}`);
void logger.metric("user_login", 1, { discordId: updated.discordId });
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
@@ -118,7 +123,13 @@ authRouter.get("/callback", async(context) => {
return context.redirect(
`${clientUrl}/auth/callback?token=${jwtToken}&isNew=false`,
);
} catch {
} catch (error) {
void logger.error(
"auth_callback",
error instanceof Error
? error
: new Error(String(error)),
);
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const clientUrl = process.env.CORS_ORIGIN ?? "http://localhost:5173";
+24 -3
View File
@@ -8,6 +8,7 @@
/* eslint-disable max-statements -- Boss handler requires many statements */
/* eslint-disable complexity -- Boss handler has inherent complexity */
/* 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 {
computeSetBonuses,
getActiveCompanionBonus,
@@ -20,6 +21,7 @@ import { defaultEquipmentSets } from "../data/equipmentSets.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import { updateChallengeProgress } from "../services/dailyChallenges.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js";
const bossRouter = new Hono<HonoEnvironment>();
@@ -121,6 +123,7 @@ const calculatePartyStats = (
};
bossRouter.post("/challenge", async(context) => {
try {
const discordId = context.get("discordId");
const body = await context.req.json<{ bossId: string }>();
@@ -296,14 +299,20 @@ bossRouter.post("/challenge", async(context) => {
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) => {
return b.id === body.bossId;
});
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const bountyRunestones = staticBoss?.bountyRunestones ?? 0;
/* v8 ignore next 7 -- @preserve */
const bountyRunestones
= boss.bountyRunestonesClaimed === true
? 0
: staticBoss?.bountyRunestones ?? 0;
if (bountyRunestones > 0) {
boss.bountyRunestonesClaimed = true;
}
state.prestige.runestones = state.prestige.runestones + bountyRunestones;
rewards = {
@@ -348,6 +357,9 @@ bossRouter.post("/challenge", async(context) => {
where: { discordId },
});
const { bossId } = body;
void logger.metric("boss_challenge", 1, { bossId, discordId, won });
const bossMaxHp = boss.maxHp;
const bossNewHp = bossUpdatedHp;
const response: BossChallengeResponse = {
@@ -369,6 +381,15 @@ bossRouter.post("/challenge", async(context) => {
}
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 };
+13
View File
@@ -11,6 +11,7 @@ import { Hono } from "hono";
import { defaultRecipes } from "../data/recipes.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js";
import type {
CraftRecipeRequest,
@@ -63,6 +64,7 @@ const recomputeCraftedMultipliers = (
};
craftRouter.post("/", async(context) => {
try {
const discordId = context.get("discordId");
const body = await context.req.json<CraftRecipeRequest>();
@@ -142,6 +144,8 @@ craftRouter.post("/", async(context) => {
where: { discordId },
});
void logger.metric("recipe_crafted", 1, { discordId, recipeId });
const bonusType = recipe.bonus.type;
const bonusValue = recipe.bonus.value;
const response: CraftRecipeResponse = {
@@ -151,6 +155,15 @@ craftRouter.post("/", async(context) => {
...updatedMultipliers,
};
return context.json(response);
} catch (error) {
void logger.error(
"craft",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
export { craftRouter };
+28 -2
View File
@@ -12,6 +12,7 @@ import { defaultExplorations } from "../data/explorations.js";
import { initialExploration } from "../data/initialState.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js";
import type {
ExploreCollectEventResult,
@@ -49,6 +50,7 @@ const pickNothingMessage = (): string => {
};
exploreRouter.post("/start", async(context) => {
try {
const discordId = context.get("discordId");
const body = await context.req.json<ExploreStartRequest>();
@@ -108,7 +110,10 @@ exploreRouter.post("/start", async(context) => {
return a.id === areaId;
});
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) => {
@@ -142,9 +147,19 @@ exploreRouter.post("/start", async(context) => {
endsAt,
};
return context.json(response);
} catch (error) {
void logger.error(
"explore_start",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
exploreRouter.post("/collect", async(context) => {
try {
const discordId = context.get("discordId");
const body = await context.req.json<ExploreCollectRequest>();
@@ -218,7 +233,9 @@ exploreRouter.post("/collect", async(context) => {
}
// 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];
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
@@ -350,6 +367,15 @@ exploreRouter.post("/collect", async(context) => {
materialsFound: materialsFound,
};
return context.json(response);
} catch (error) {
void logger.error(
"explore_collect",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
export { exploreRouter };
+55
View File
@@ -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 };
+58 -1
View File
@@ -27,6 +27,7 @@ import { currentSchemaVersion } from "../data/schemaVersion.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
import { logger } from "../services/logger.js";
import { calculateOfflineEarnings } from "../services/offlineProgress.js";
import {
checkAndUnlockTitles,
@@ -681,6 +682,7 @@ const gameRouter = new Hono<HonoEnvironment>();
gameRouter.use("*", authMiddleware);
gameRouter.get("/load", async(context) => {
try {
const discordId = context.get("discordId");
const [ record, playerRecord ] = await Promise.all([
@@ -701,7 +703,9 @@ gameRouter.get("/load", async(context) => {
discordId: playerRecord.discordId,
discriminator: playerRecord.discriminator,
lastSavedAt: Date.now(),
// eslint-disable-next-line stylistic/max-len -- Long property names exceed limit after try-block indent
lifetimeAchievementsUnlocked: playerRecord.lifetimeAchievementsUnlocked,
// eslint-disable-next-line stylistic/max-len -- Long property names exceed limit after try-block indent
lifetimeAdventurersRecruited: playerRecord.lifetimeAdventurersRecruited,
lifetimeBossesDefeated: playerRecord.lifetimeBossesDefeated,
lifetimeClicks: playerRecord.lifetimeClicks,
@@ -747,6 +751,14 @@ gameRouter.get("/load", async(context) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState;
/*
* Always sync character name from the Player record — the profile update route
* writes to Player.characterName directly, bypassing the game state blob.
*/
if (playerRecord !== null) {
state.player.characterName = playerRecord.characterName;
}
const now = Date.now();
const { offlineGold, offlineEssence, offlineSeconds }
@@ -872,9 +884,19 @@ gameRouter.get("/load", async(context) => {
signature,
state,
});
} catch (error) {
void logger.error(
"game_load",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
gameRouter.post("/save", async(context) => {
try {
const discordId = context.get("discordId");
const body = await context.req.json<SaveRequest>();
@@ -888,6 +910,7 @@ gameRouter.post("/save", async(context) => {
if ((body.state.schemaVersion ?? 0) < currentSchemaVersion) {
return context.json(
{
// eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened
error: "Save rejected: outdated save. Reset your progress to continue.",
},
409,
@@ -933,6 +956,19 @@ gameRouter.post("/save", async(context) => {
player: { ...stateToSave.player, lastSavedAt: now },
};
/*
* Preserve the Player record's character name so that profile updates are not
* overwritten by the next auto-save (profile PUT writes to Player, not the blob).
*/
stateToSave = {
...stateToSave,
player: {
...stateToSave.player,
characterName:
playerRecord?.characterName ?? stateToSave.player.characterName,
},
};
/*
* Recompute companion unlocks server-side using DB-authoritative player lifetime stats.
* This prevents clients from claiming companions they haven't legitimately unlocked.
@@ -1005,12 +1041,24 @@ gameRouter.post("/save", async(context) => {
? undefined
: computeHmac(JSON.stringify(stateToSave), secret);
return context.json({ savedAt: now, signature: signature });
} catch (error) {
void logger.error(
"game_save",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
gameRouter.post("/reset", async(context) => {
try {
const discordId = context.get("discordId");
const playerRecord = await prisma.player.findUnique({ where: { discordId } });
const playerRecord = await prisma.player.findUnique({
where: { discordId },
});
if (!playerRecord) {
return context.json({ error: "No player found" }, 404);
}
@@ -1065,6 +1113,15 @@ gameRouter.post("/reset", async(context) => {
signature: signature,
state: freshState,
});
} catch (error) {
void logger.error(
"game_reset",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
export { gameRouter };
+11
View File
@@ -9,6 +9,7 @@
import { Hono } from "hono";
import { gameTitles } from "../data/titles.js";
import { prisma } from "../db/client.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js";
import type { GameState } from "@elysium/types";
@@ -58,6 +59,7 @@ const resolveTitleName = (titleId: string | null): string => {
};
leaderboardRouter.get("/", async(context) => {
try {
const category = context.req.query("category") ?? "totalGold";
const limitRaw = Number(context.req.query("limit") ?? "100");
const limit = Math.min(Math.max(1, limitRaw), 100);
@@ -122,6 +124,15 @@ leaderboardRouter.get("/", async(context) => {
});
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 };
+29
View File
@@ -6,11 +6,13 @@
*/
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
/* eslint-disable max-statements -- Route handlers require many statements */
/* eslint-disable complexity -- Route handlers have inherent complexity */
import { Hono } from "hono";
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import { updateChallengeProgress } from "../services/dailyChallenges.js";
import { logger } from "../services/logger.js";
import {
buildPostPrestigeState,
computeRunestoneMultipliers,
@@ -25,6 +27,7 @@ const prestigeRouter = new Hono<HonoEnvironment>();
prestigeRouter.use("*", authMiddleware);
prestigeRouter.post("/", async(context) => {
try {
const discordId = context.get("discordId");
const record = await prisma.gameState.findUnique({ where: { discordId } });
@@ -39,6 +42,7 @@ prestigeRouter.post("/", async(context) => {
if (!isEligibleForPrestige(state)) {
return context.json(
{
// eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened
error: "Not eligible for prestige — collect 1,000,000 total gold first",
},
400,
@@ -130,6 +134,8 @@ prestigeRouter.post("/", async(context) => {
where: { discordId },
});
const prestigeCount = prestigeData.count;
void logger.metric("prestige", 1, { discordId, prestigeCount });
void postMilestoneWebhook(discordId, "prestige", {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
@@ -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
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) => {
try {
const discordId = context.get("discordId");
const body = await context.req.json<BuyPrestigeUpgradeRequest>();
@@ -204,11 +220,24 @@ prestigeRouter.post("/buy-upgrade", async(context) => {
const multipliers = computeRunestoneMultipliers(updatedPurchasedUpgradeIds);
void logger.metric("prestige_upgrade_purchased", 1, {
discordId,
upgradeId,
});
return context.json({
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
runestonesRemaining: updatedRunestones,
...multipliers,
});
} catch (error) {
void logger.error(
"prestige_buy_upgrade",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
export { prestigeRouter };
+25
View File
@@ -5,6 +5,7 @@
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
/* eslint-disable max-statements -- Route handlers require many steps */
/* eslint-disable complexity -- Route handlers have inherent complexity */
/* eslint-disable stylistic/key-spacing -- ProfileSettings keys exceed max-len when aligned */
/* eslint-disable stylistic/max-len -- ProfileSettings key names exceed line length limit */
@@ -19,6 +20,7 @@ import { Hono } from "hono";
import { gameTitles } from "../data/titles.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import { logger } from "../services/logger.js";
import { parseUnlockedTitles } from "../services/titles.js";
import type { HonoEnvironment } from "../types/hono.js";
@@ -80,6 +82,7 @@ const resolveTitle = (id: string): { id: string; name: string } => {
};
profileRouter.get("/:discordId", async(context) => {
try {
const { discordId } = context.req.param();
const [ player, gameStateRecord ] = await Promise.all([
@@ -142,6 +145,8 @@ profileRouter.get("/:discordId", async(context) => {
};
});
const completedChapters = state?.story?.completedChapters ?? [];
return context.json({
achievementsUnlocked: achievementsUnlocked,
activeTitle: player.activeTitle,
@@ -153,6 +158,7 @@ profileRouter.get("/:discordId", async(context) => {
characterClass: player.characterClass,
characterName: player.characterName,
characterRace: player.characterRace ?? "",
completedChapters: completedChapters,
createdAt: player.createdAt,
currentRunClicks: state?.player.totalClicks ?? 0,
currentRunGold: state?.player.totalGoldEarned ?? 0,
@@ -173,9 +179,19 @@ profileRouter.get("/:discordId", async(context) => {
unlockedTitles: unlockedTitles,
username: player.username,
});
} catch (error) {
void logger.error(
"profile_get",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
profileRouter.put("/", authMiddleware, async(context) => {
try {
const discordId = context.get("discordId");
const body = await context.req.json<UpdateProfileRequest>();
@@ -261,6 +277,15 @@ profileRouter.put("/", authMiddleware, async(context) => {
profileSettings: profileSettings,
pronouns: updated.pronouns,
});
} catch (error) {
void logger.error(
"profile_update",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
export { profileRouter };
+30
View File
@@ -6,10 +6,12 @@
*/
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
/* eslint-disable max-statements -- Route handlers require many statements */
import { Hono } from "hono";
import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import { logger } from "../services/logger.js";
import {
buildPostTranscendenceState,
computeTranscendenceMultipliers,
@@ -24,6 +26,7 @@ const transcendenceRouter = new Hono<HonoEnvironment>();
transcendenceRouter.use("*", authMiddleware);
transcendenceRouter.post("/", async(context) => {
try {
const discordId = context.get("discordId");
const record = await prisma.gameState.findUnique({ where: { discordId } });
@@ -37,6 +40,7 @@ transcendenceRouter.post("/", async(context) => {
if (!isEligibleForTranscendence(state)) {
return context.json(
{
// eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened
error: "Not eligible for transcendence — defeat The Absolute One first",
},
400,
@@ -102,6 +106,8 @@ transcendenceRouter.post("/", async(context) => {
where: { discordId },
});
const transcendenceCount = transcendenceData.count;
void logger.metric("transcendence", 1, { discordId, transcendenceCount });
void postMilestoneWebhook(discordId, "transcendence", {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
@@ -119,9 +125,19 @@ transcendenceRouter.post("/", async(context) => {
// eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client
newTranscendenceCount: transcendenceData.count,
});
} catch (error) {
void logger.error(
"transcendence",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
transcendenceRouter.post("/buy-upgrade", async(context) => {
try {
const discordId = context.get("discordId");
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);
}
// eslint-disable-next-line stylistic/max-len -- Variable name mirrors the data source for clarity
const upgrade = defaultTranscendenceUpgrades.find((transcendenceUpgrade) => {
return transcendenceUpgrade.id === upgradeId;
});
@@ -181,11 +198,24 @@ transcendenceRouter.post("/buy-upgrade", async(context) => {
where: { discordId },
});
void logger.metric("transcendence_upgrade_purchased", 1, {
discordId,
upgradeId,
});
return context.json({
echoesRemaining: updatedEchoes,
purchasedUpgradeIds: updatedPurchasedIds,
...updatedMultipliers,
});
} catch (error) {
void logger.error(
"transcendence_buy_upgrade",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
export { transcendenceRouter };
+21
View File
@@ -5,6 +5,7 @@
* @author Naomi Carrigan
*/
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */
import { logger } from "./logger.js";
interface DiscordTokenResponse {
access_token: string;
@@ -50,6 +51,7 @@ const exchangeCode = async(
redirect_uri: redirectUri,
});
try {
const response = await fetch("https://discord.com/api/v10/oauth2/token", {
body: parameters.toString(),
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 */
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(
accessToken: string,
): Promise<DiscordUser> => {
try {
const response = await fetch("https://discord.com/api/v10/users/@me", {
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 */
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;
}
};
/**
+12
View File
@@ -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 };
+59
View File
@@ -205,9 +205,68 @@ const buildPostPrestigeState = (
};
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 = {
...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(),
/*
* 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,
// Codex lore persists across prestiges — players keep their discovered entries
...currentState.codex === undefined
+26 -3
View File
@@ -5,8 +5,16 @@
* @author Naomi Carrigan
*/
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case and Pascal-case header names */
import { logger } from "./logger.js";
const discordApi = "https://discord.com/api/v10";
/**
* 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.
* 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",
},
);
} 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
}
};
@@ -77,11 +91,20 @@ const postMilestoneWebhook = async(
try {
await fetch(webhookUrl, {
body: JSON.stringify({ content }),
body: JSON.stringify({
content: content,
flags: suppressNotifications,
}),
headers: { "Content-Type": "application/json" },
method: "POST",
});
} catch {
} catch (error) {
void logger.error(
"webhook_milestone",
error instanceof Error
? error
: new Error(String(error)),
);
// Graceful degradation — webhook failure must not affect the game action
}
};
+11
View File
@@ -55,4 +55,15 @@ describe("authMiddleware", () => {
}));
expect(res.status).toBe(401);
});
it("returns 401 when verifyToken throws a non-Error value", async () => {
const { app, verifyToken } = await makeApp();
vi.mocked(verifyToken).mockImplementationOnce(() => {
throw "raw string error";
});
const res = await app.fetch(new Request("http://localhost/test", {
headers: { Authorization: "Bearer bad_token" },
}));
expect(res.status).toBe(401);
});
});
+12
View File
@@ -80,6 +80,18 @@ describe("apotheosis route", () => {
expect(res.status).toBe(400);
});
it("returns 500 when the database throws", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post();
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post();
expect(res.status).toBe(500);
});
it("returns apotheosis count on success", async () => {
// Need all 15 transcendence upgrades purchased for eligibility
const allUpgradeIds = [
+9
View File
@@ -113,5 +113,14 @@ describe("auth route", () => {
const location = res.headers.get("Location") ?? "";
expect(location).toContain("error=auth_failed");
});
it("redirects with error when callback throws a non-Error value", async () => {
const { app, exchangeCode } = await makeApp();
exchangeCode.mockRejectedValueOnce("raw string error");
const res = await app.fetch(new Request("http://localhost/auth/callback?code=bad_code"));
expect(res.status).toBe(302);
const location = res.headers.get("Location") ?? "";
expect(location).toContain("error=auth_failed");
});
});
});
+33
View File
@@ -293,4 +293,37 @@ describe("boss route", () => {
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("returns 500 when the database throws", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(500);
});
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);
});
});
+12
View File
@@ -143,4 +143,16 @@ describe("craft route", () => {
expect(body.recipeId).toBe(TEST_RECIPE_ID);
expect(body.bonusType).toBe("gold_income");
});
it("returns 500 when the database throws", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(500);
});
});
+26
View File
@@ -406,5 +406,31 @@ describe("explore route", () => {
expect(body.materialsFound.some((m) => m.materialId === "verdant_sap")).toBe(true);
mockRandom.mockRestore();
});
it("returns 500 when the database throws on collect", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value on collect", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(500);
});
});
describe("POST /start error path", () => {
it("returns 500 when the database throws on start", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value on start", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(500);
});
});
});
+136
View File
@@ -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");
});
});
});
+61
View File
@@ -233,6 +233,16 @@ describe("game route", () => {
expect(body.savedAt).toBeGreaterThan(0);
});
it("falls back to state characterName when playerRecord is null", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const state = makeState();
const res = await save({ state });
expect(res.status).toBe(200);
});
it("validates and sanitizes state when previous record exists", async () => {
const prevState = makeState({ resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 } });
const incomingState = makeState({ resources: { gold: 1e400, essence: 0, crystals: 0, runestones: 9999 } });
@@ -410,6 +420,45 @@ describe("game route", () => {
});
});
describe("GET /load error path", () => {
it("returns 500 when the database throws during load", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during load", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error");
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(500);
});
});
describe("POST /save error path", () => {
const save = (body: Record<string, unknown>) =>
app.fetch(new Request("http://localhost/game/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}));
it("returns 500 when the database throws during save", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await save({ state });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during save", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await save({ state });
expect(res.status).toBe(500);
});
});
describe("POST /reset", () => {
const reset = () =>
app.fetch(new Request("http://localhost/game/reset", { method: "POST" }));
@@ -440,5 +489,17 @@ describe("game route", () => {
const body = await res.json() as { signature: string | undefined };
expect(typeof body.signature).toBe("string");
});
it("returns 500 when the database throws during reset", async () => {
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await reset();
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during reset", async () => {
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error");
const res = await reset();
expect(res.status).toBe(500);
});
});
});
+12
View File
@@ -152,6 +152,18 @@ describe("leaderboards route", () => {
expect(typeof body.entries[0]?.activeTitle).toBe("string");
});
it("returns 500 when the database throws", async () => {
vi.mocked(prisma.player.findMany).mockRejectedValueOnce(new Error("DB error"));
const res = await get();
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.player.findMany).mockRejectedValueOnce("raw string error");
const res = await get();
expect(res.status).toBe(500);
});
it("defaults to 0 for game-state categories when state is missing", async () => {
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([] as never);
+24
View File
@@ -93,6 +93,18 @@ describe("prestige route", () => {
expect(body.runestones).toBeGreaterThanOrEqual(0);
});
it("returns 500 when the database throws during prestige", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post("");
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during prestige", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post("");
expect(res.status).toBe(500);
});
it("updates daily challenge progress when dailyChallenges are set", async () => {
const state = makeState({
dailyChallenges: {
@@ -152,5 +164,17 @@ describe("prestige route", () => {
expect(body.runestonesRemaining).toBe(90); // 100 - 10
expect(body.purchasedUpgradeIds).toContain("income_1");
});
it("returns 500 when the database throws during buy-upgrade", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post("/buy-upgrade", { upgradeId: "income_1" });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during buy-upgrade", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post("/buy-upgrade", { upgradeId: "income_1" });
expect(res.status).toBe(500);
});
});
});
+48
View File
@@ -181,6 +181,36 @@ describe("profile route", () => {
const unknown = body.unlockedTitles.find((t) => t.id === "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 () => {
const state = makeState({
story: {
unlockedChapterIds: [ "boss_troll_king" ],
completedChapters: [ { chapterId: "boss_troll_king", choiceId: "fight" } ],
},
});
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
expect(res.status).toBe(200);
const body = await res.json() as {
completedChapters: Array<{ chapterId: string; choiceId: string }>;
};
expect(body.completedChapters).toHaveLength(1);
expect(body.completedChapters[0]).toMatchObject({ chapterId: "boss_troll_king", choiceId: "fight" });
});
});
describe("PUT /", () => {
@@ -238,5 +268,23 @@ describe("profile route", () => {
const body = await res.json() as { profileSettings: { numberFormat: string } };
expect(body.profileSettings.numberFormat).toBe("suffix");
});
it("returns 500 when the database throws during profile update", async () => {
vi.mocked(prisma.player.update).mockRejectedValueOnce(new Error("DB error"));
const res = await put({
characterName: "NewName",
profileSettings: { numberFormat: "suffix" },
});
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during profile update", async () => {
vi.mocked(prisma.player.update).mockRejectedValueOnce("raw string error");
const res = await put({
characterName: "NewName",
profileSettings: { numberFormat: "suffix" },
});
expect(res.status).toBe(500);
});
});
});
@@ -92,6 +92,18 @@ describe("transcendence route", () => {
expect(body.newTranscendenceCount).toBe(1);
expect(body.echoes).toBeGreaterThanOrEqual(0);
});
it("returns 500 when the database throws during transcendence", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post("");
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during transcendence", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post("");
expect(res.status).toBe(500);
});
});
describe("POST /buy-upgrade", () => {
@@ -149,5 +161,17 @@ describe("transcendence route", () => {
expect(body.echoesRemaining).toBe(95); // 100 - 5
expect(body.purchasedUpgradeIds).toContain("echo_income_1");
});
it("returns 500 when the database throws during buy-upgrade", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during buy-upgrade", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
expect(res.status).toBe(500);
});
});
});
+17
View File
@@ -86,5 +86,22 @@ describe("discord service", () => {
expect(result.id).toBe("123");
expect(result.username).toBe("testuser");
});
it("re-throws when fetch rejects with a non-Error value", async () => {
mockFetch.mockRejectedValueOnce("raw string error");
const { fetchDiscordUser } = await import("../../src/services/discord.js");
await expect(fetchDiscordUser("some_token")).rejects.toBe("raw string error");
});
});
describe("exchangeCode non-Error throw", () => {
it("re-throws when fetch rejects with a non-Error value", async () => {
process.env["DISCORD_CLIENT_ID"] = "cid";
process.env["DISCORD_CLIENT_SECRET"] = "secret";
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
mockFetch.mockRejectedValueOnce("raw string error");
const { exchangeCode } = await import("../../src/services/discord.js");
await expect(exchangeCode("some_code")).rejects.toBe("raw string error");
});
});
});
+138 -6
View File
@@ -13,14 +13,24 @@ import {
} from "../../src/services/prestige.js";
import type { GameState } from "@elysium/types";
const makePlayer = (totalGoldEarned: number) => ({
discordId: "test_id",
username: "testuser",
discriminator: "0",
const makePlayer = (
totalGoldEarned: number,
lifetimeGoldEarned = 0,
totalClicks = 0,
) => ({
avatar: null,
totalGoldEarned,
totalClicks: 0,
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 =>
@@ -242,4 +252,126 @@ describe("buildPostPrestigeState", () => {
const { prestigeState } = buildPostPrestigeState(state, "Tester");
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);
});
});
+18 -1
View File
@@ -69,6 +69,15 @@ describe("webhook service", () => {
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
});
it("swallows non-Error fetch rejections gracefully", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok";
process.env["DISCORD_GUILD_ID"] = "g";
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r";
mockFetch.mockRejectedValueOnce("raw string error");
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
});
});
describe("postMilestoneWebhook", () => {
@@ -88,9 +97,10 @@ describe("webhook service", () => {
await postMilestoneWebhook("user123", "prestige", counts);
const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit];
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("prestiged");
expect(body.flags).toBe(4096);
});
it("posts transcendence message correctly", async () => {
@@ -119,5 +129,12 @@ describe("webhook service", () => {
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined();
});
it("swallows non-Error fetch rejections gracefully", async () => {
process.env["DISCORD_MILESTONE_WEBHOOK"] = "https://discord.com/webhook/abc";
mockFetch.mockRejectedValueOnce("raw string error");
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined();
});
});
});
+33
View File
@@ -5,6 +5,39 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Elysium — Idle RPG</title>
<meta name="description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
<!-- Open Graph -->
<meta property="og:title" content="Elysium — Idle RPG" />
<meta property="og:description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://elysium.nhcarrigan.com" />
<meta property="og:image" content="https://cdn.nhcarrigan.com/elysium/background.jpg" />
<meta property="og:site_name" content="Elysium" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Elysium — Idle RPG" />
<meta name="twitter:description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
<meta name="twitter:image" content="https://cdn.nhcarrigan.com/elysium/background.jpg" />
<!-- Plausible Analytics -->
<script defer data-domain="elysium.nhcarrigan.com" src="https://plausible.io/js/script.js"></script>
<!-- Tree-Nation -->
<script defer src="https://widgets.tree-nation.com/js/widgets/v1/widgets.min.js?v=1.0"></script>
<script>
(function () {
var interval = setInterval(function () {
if (typeof TreeNation !== "undefined") {
clearInterval(interval);
TreeNation.renderAll();
}
}, 100);
}());
</script>
<!-- Google Ads -->
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-3569924701890974" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@elysium/web",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"type": "module",
"scripts": {
@@ -13,7 +13,8 @@
"dependencies": {
"@elysium/types": "workspace:*",
"react": "19.0.0",
"react-dom": "19.0.0"
"react-dom": "19.0.0",
"react-markdown": "10.1.0"
},
"devDependencies": {
"@nhcarrigan/eslint-config": "5.2.0",
+70
View File
@@ -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 };
+4 -1
View File
@@ -7,6 +7,7 @@
/* 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 */
import { type JSX, useEffect, useState } from "react";
import Markdown from "react-markdown";
import { getAbout } from "../../api/client.js";
import type { AboutResponse } from "@elysium/types";
@@ -331,7 +332,9 @@ const aboutPanel = (): JSX.Element => {
</span>
</button>
{expandedRelease === release.tag_name
&& <pre className="about-release-body">{release.body}</pre>
&& <div className="about-release-body">
<Markdown>{release.body}</Markdown>
</div>
}
</li>
);
@@ -7,6 +7,7 @@
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */
import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js";
import type { Achievement } from "@elysium/types";
@@ -76,7 +77,11 @@ const AchievementCard = ({
<div className={`achievement-card ${isUnlocked
? "unlocked"
: "locked"}`}>
<div className="achievement-icon">{achievement.icon}</div>
<img
alt={achievement.name}
className="card-thumbnail"
src={cdnImage("achievements", achievement.id)}
/>
<div className="achievement-info">
<h3>{achievement.name}</h3>
<p>{achievement.description}</p>
@@ -41,7 +41,7 @@ const ToastItem = ({
const crystals = achievement.reward?.crystals;
return (
<div className="achievement-toast" onClick={handleClick}>
<div className="game-toast" onClick={handleClick}>
<span className="toast-icon">{achievement.icon}</span>
<div className="toast-content">
<span className="toast-label">{"Achievement Unlocked!"}</span>
@@ -70,7 +70,7 @@ const AchievementToast = (): JSX.Element | null => {
}
return (
<div className="achievement-toast-container">
<>
{pendingAchievements.map((achievement) => {
return (
<ToastItem
@@ -80,7 +80,7 @@ const AchievementToast = (): JSX.Element | null => {
/>
);
})}
</div>
</>
);
};
@@ -9,21 +9,38 @@
/* eslint-disable complexity -- Complex component with many render paths */
import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js";
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";
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.
* @param adventurer - The adventurer to buy.
@@ -105,14 +122,15 @@ const AdventurerCard = ({
? `🪙 ${formatNumber(Math.ceil(cost))}${maxSuffix}`
: "🔒 Locked";
// eslint-disable-next-line @typescript-eslint/dot-notation -- "class" is a reserved word
const adventurerIcon = iconByClass[adventurer["class"]] ?? "⚔️";
return (
<div className={`adventurer-card ${adventurer.unlocked
? ""
: "locked"}`}>
<div className="adventurer-icon">{adventurerIcon}</div>
<img
alt={adventurer.name}
className="card-thumbnail"
src={cdnImage("adventurers", adventurer.id)}
/>
<div className="adventurer-info">
<h3>{adventurer.name}</h3>
<p>
@@ -155,7 +173,9 @@ const AdventurerCard = ({
const AdventurerPanel = (): JSX.Element => {
const { state, formatNumber } = useGame();
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) {
return (
@@ -203,6 +223,7 @@ const AdventurerPanel = (): JSX.Element => {
{batchOptions.map((option) => {
function handleBatchSelect(): void {
setBatchSize(option);
localStorage.setItem("elysium_batch_size", String(option));
}
return (
<button
+78 -23
View File
@@ -8,6 +8,8 @@
/* eslint-disable complexity -- Battle result display requires many conditional paths */
import { type JSX, useEffect, useState } from "react";
import { type BattleResult, useGame } from "../../context/gameContext.js";
import { sendNotification } from "../../utils/notification.js";
import { playSound } from "../../utils/sound.js";
/**
* Converts HP values to a percentage for display.
@@ -23,6 +25,22 @@ const toHpPercent = (current: number, maximum: number): number => {
return scaled / maximum;
};
/**
* Returns a colour hex string based on the HP percentage.
* Green above 50%, yellow 2550%, red below 25%.
* @param percent - Current HP as a percentage (0100).
* @returns A hex colour string.
*/
const getHpColour = (percent: number): string => {
if (percent > 50) {
return "#27ae60";
}
if (percent > 25) {
return "#f39c12";
}
return "#e74c3c";
};
interface BattleModalProperties {
readonly battle: BattleResult;
readonly onDismiss: ()=> void;
@@ -40,12 +58,16 @@ const BattleModal = ({
onDismiss,
}: BattleModalProperties): JSX.Element => {
const { result, bossName } = battle;
const { formatNumber } = useGame();
const {
enableNotifications,
enableSounds,
flushBossLoreToasts,
formatNumber,
} = useGame();
const [ phase, setPhase ] = useState<"animating" | "result">("animating");
const bossStartPercent = toHpPercent(result.bossHpBefore, result.bossMaxHp);
const partyStartPercent = 100;
const bossEndPercent = toHpPercent(
result.bossHpAtBattleEnd,
@@ -57,37 +79,72 @@ const BattleModal = ({
);
const [ bossHpPercent, setBossHpPercent ] = useState(bossStartPercent);
const [ partyHpPercent, setPartyHpPercent ] = useState(partyStartPercent);
const [ partyHpPercent, setPartyHpPercent ] = useState(100);
useEffect(() => {
const startAnimation = setTimeout(() => {
const animationDurationMs = 5000;
const intervalMs = 50;
const totalSteps = animationDurationMs / intervalMs;
const bossHpRange = bossEndPercent - bossStartPercent;
const bossDelta = bossHpRange / totalSteps;
const partyHpRange = partyEndPercent - 100;
const partyDelta = partyHpRange / totalSteps;
let currentStep = 0;
// eslint-disable-next-line @typescript-eslint/init-declarations -- assigned inside timeout
let intervalId: ReturnType<typeof setInterval> | undefined;
const tick = (): void => {
currentStep = currentStep + 1;
if (currentStep >= totalSteps) {
setBossHpPercent(bossEndPercent);
setPartyHpPercent(partyEndPercent);
clearInterval(intervalId);
} else {
const bossStep = bossDelta * currentStep;
setBossHpPercent(bossStartPercent + bossStep);
const partyStep = partyDelta * currentStep;
setPartyHpPercent(100 + partyStep);
}
};
const startTimeout = setTimeout(() => {
intervalId = setInterval(tick, intervalMs);
}, 200);
const revealResult = setTimeout(() => {
const revealTimeout = setTimeout(() => {
setPhase("result");
flushBossLoreToasts();
if (result.won) {
if (enableSounds) {
playSound("bossVictory");
}
if (enableNotifications) {
sendNotification("⚔️ Boss Defeated!", `You defeated ${bossName}!`);
}
}
}, 5200);
return (): void => {
clearTimeout(startAnimation);
clearTimeout(revealResult);
clearTimeout(startTimeout);
clearTimeout(revealTimeout);
clearInterval(intervalId);
};
}, [ bossEndPercent, partyEndPercent ]);
}, [
bossEndPercent,
bossName,
bossStartPercent,
enableNotifications,
enableSounds,
flushBossLoreToasts,
partyEndPercent,
result.won,
]);
let bossHpBarColour = "#c0392b";
if (bossHpPercent > 50) {
bossHpBarColour = "#e74c3c";
} else if (bossHpPercent > 25) {
bossHpBarColour = "#e67e22";
}
let partyHpBarColour = "#e74c3c";
if (partyHpPercent > 50) {
partyHpBarColour = "#27ae60";
} else if (partyHpPercent > 25) {
partyHpBarColour = "#f39c12";
}
const bossHpBarColour = getHpColour(bossHpPercent);
const partyHpBarColour = getHpColour(partyHpPercent);
return (
<div className="modal-overlay">
@@ -120,7 +177,6 @@ const BattleModal = ({
className="hp-bar-fill"
style={{
backgroundColor: bossHpBarColour,
transition: "width 5s ease-in-out",
width: `${bossHpPercent.toFixed(1)}%`,
}}
/>
@@ -141,7 +197,6 @@ const BattleModal = ({
className="hp-bar-fill party-hp"
style={{
backgroundColor: partyHpBarColour,
transition: "width 5s ease-in-out",
width: `${partyHpPercent.toFixed(1)}%`,
}}
/>
+43 -4
View File
@@ -11,6 +11,7 @@
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */
import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js";
import { ZoneSelector } from "./zoneSelector.js";
import type { Boss, GameState } from "@elysium/types";
@@ -56,6 +57,11 @@ const BossCard = ({
return (
<div className={`boss-card boss-${boss.status}`}>
<img
alt={boss.name}
className="card-thumbnail"
src={cdnImage("bosses", boss.id)}
/>
<div className="boss-info">
<h3>{boss.name}</h3>
<p>{boss.description}</p>
@@ -120,7 +126,9 @@ const BossCard = ({
{" Equipment"}
</span>
}
{boss.status !== "defeated" && boss.bountyRunestones > 0
{boss.status !== "defeated"
&& boss.bountyRunestones > 0
&& boss.bountyRunestonesClaimed !== true
&& <span className="boss-bounty">
{"🔮 "}
{boss.bountyRunestones}
@@ -220,11 +228,20 @@ const computePartyStats = (
* @returns The 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>(
null,
);
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
const [ activeZoneId, setActiveZoneId ] = useState(() => {
return sessionStorage.getItem("elysium_boss_zone") ?? "verdant_vale";
});
const [ showLocked, setShowLocked ] = useState(true);
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 {
setShowLocked((current) => {
return !current;
@@ -340,9 +362,26 @@ const BossPanel = (): JSX.Element => {
</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
activeZoneId={activeZoneId}
onSelectZone={setActiveZoneId}
onSelectZone={handleZoneSelect}
zones={zones}
/>
+51 -7
View File
@@ -5,13 +5,16 @@
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable max-lines -- Story section adds lines beyond the file limit */
/* eslint-disable complexity -- Many conditional render paths for optional fields */
import { type JSX, useEffect, useState } from "react";
import type {
EquipmentBonus,
EquipmentType,
PublicProfileResponse,
import {
STORY_CHAPTERS,
type EquipmentBonus,
type EquipmentType,
type PublicProfileResponse,
} from "@elysium/types";
import { type JSX, useEffect, useState } from "react";
import { logError } from "../../utils/logError.js";
interface CharacterPageProperties {
readonly discordId: string;
@@ -76,11 +79,15 @@ const CharacterPage = ({ discordId }: CharacterPageProperties): JSX.Element => {
}, [ discordId ]);
function handleCopy(): void {
void navigator.clipboard.writeText(window.location.href).then(() => {
void navigator.clipboard.writeText(window.location.href).
then(() => {
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
}).
catch((error_: unknown) => {
logError("clipboard_copy", error_);
});
}
@@ -236,7 +243,7 @@ const CharacterPage = ({ discordId }: CharacterPageProperties): JSX.Element => {
return (
<div
className="character-page-equipment-item"
key={item.type}
key={item.name}
>
<div className="character-page-equipment-header">
<span className="character-page-equipment-slot">
@@ -269,6 +276,43 @@ const CharacterPage = ({ discordId }: CharacterPageProperties): JSX.Element => {
</div>
}
{profile.completedChapters.length === 0
? null
: <div className="character-page-section">
<h2 className="character-page-section-title">{"📖 Story"}</h2>
{profile.completedChapters.map((completion) => {
const chapter = STORY_CHAPTERS.find((candidate) => {
return candidate.id === completion.chapterId;
});
if (chapter === undefined) {
return null;
}
const choice = chapter.choices.find((candidate) => {
return candidate.id === completion.choiceId;
});
if (choice === undefined) {
return null;
}
return (
<div
className="character-sheet-story-entry"
key={completion.chapterId}
>
<span className="character-sheet-story-chapter">
{chapter.title}
</span>
<span className="character-sheet-story-choice">
{choice.label}
</span>
<p className="character-sheet-story-outcome">
{choice.description}
</p>
</div>
);
})}
</div>
}
<div className="character-page-divider" />
<p className="character-page-player-line">
@@ -19,6 +19,7 @@ import {
import { type ChangeEvent, type JSX, useEffect, useRef, useState } from "react";
import { updateProfile } from "../../api/client.js";
import { useGame } from "../../context/gameContext.js";
import { logError } from "../../utils/logError.js";
interface EquippedItem {
name: string;
@@ -205,11 +206,15 @@ const CharacterSheetPanel = (): JSX.Element => {
function handleShareClick(): void {
const discordId = player?.discordId ?? "";
const url = `${window.location.origin}/character/${discordId}`;
void navigator.clipboard.writeText(url).then(() => {
void navigator.clipboard.writeText(url).
then(() => {
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
}).
catch((error_: unknown) => {
logError("clipboard_copy", error_);
});
}
@@ -657,6 +662,15 @@ const CharacterSheetPanel = (): JSX.Element => {
if (choice === undefined) {
return null;
}
const characterName
= player?.characterName === ""
|| player?.characterName === undefined
? "the guild leader"
: player.characterName;
const outcome = choice.outcome.replaceAll(
"{characterName}",
characterName,
);
return (
<div
className="character-sheet-story-entry"
@@ -668,6 +682,7 @@ const CharacterSheetPanel = (): JSX.Element => {
<span className="character-sheet-story-choice">
{choice.label}
</span>
<p className="character-sheet-story-outcome">{outcome}</p>
</div>
);
})}
+24 -1
View File
@@ -8,6 +8,7 @@
import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js";
import { CODEX_ENTRIES, ZONE_LABELS } from "../../data/codex.js";
import { cdnImage } from "../../utils/cdn.js";
import type { CodexEntry } from "@elysium/types";
/**
@@ -36,6 +37,18 @@ const sourceBadge: Record<CodexEntry["sourceType"], string> = {
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.
* @returns The JSX element.
@@ -155,7 +168,17 @@ const CodexPanel = (): JSX.Element => {
</span>
</div>
{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}
</div>
);
+3 -3
View File
@@ -47,7 +47,7 @@ const CodexToastItem = ({
}
return (
<div className="codex-toast" onClick={handleClick}>
<div className="game-toast" onClick={handleClick}>
<span className="toast-icon">{"📖"}</span>
<div className="toast-content">
<span className="toast-label">{"✨ Lore Unlocked!"}</span>
@@ -70,13 +70,13 @@ const CodexToast = (): JSX.Element | null => {
}
return (
<div className="achievement-toast-container">
<>
{pendingEntryIds.map((id) => {
return (
<CodexToastItem entryId={id} key={id} onDismiss={dismissCodexEntry} />
);
})}
</div>
</>
);
};
@@ -8,6 +8,7 @@
/* eslint-disable max-lines-per-function -- Complex companion card with conditional renders */
import { COMPANIONS, type Companion } from "@elysium/types";
import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
import type { JSX } from "react";
const bonusLabels: Record<string, string> = {
@@ -96,6 +97,11 @@ const CompanionCard = ({
: ""}`}
>
<div className="companion-header">
<img
alt={companion.name}
className="card-thumbnail"
src={cdnImage("companions", companion.id)}
/>
<div className="companion-name-block">
<span className="companion-name">{companion.name}</span>
<span className="companion-title">{companion.title}</span>
+20 -2
View File
@@ -10,6 +10,7 @@ import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js";
import { MATERIALS } from "../../data/materials.js";
import { RECIPES } from "../../data/recipes.js";
import { cdnImage } from "../../utils/cdn.js";
import { ZoneSelector } from "./zoneSelector.js";
const bonusLabel: Record<string, string> = {
@@ -25,7 +26,9 @@ const bonusLabel: Record<string, string> = {
*/
const CraftingPanel = (): JSX.Element => {
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);
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> {
setPendingRecipeId(recipeId);
try {
@@ -84,7 +92,7 @@ const CraftingPanel = (): JSX.Element => {
<ZoneSelector
activeZoneId={activeZoneId}
onSelectZone={setActiveZoneId}
onSelectZone={handleZoneSelect}
zones={zones}
/>
@@ -105,6 +113,11 @@ const CraftingPanel = (): JSX.Element => {
}`}
key={material.id}
>
<img
alt={material.name}
className="card-thumbnail"
src={cdnImage("materials", material.id)}
/>
<div className="material-info">
<span className="material-name">{material.name}</span>
<span className="material-rarity">{material.rarity}</span>
@@ -144,6 +157,11 @@ const CraftingPanel = (): JSX.Element => {
: ""}`}
key={recipe.id}
>
<img
alt={recipe.name}
className="card-thumbnail"
src={cdnImage("recipes", recipe.id)}
/>
<div className="recipe-info">
<h4>{recipe.name}</h4>
<p className="recipe-description">{recipe.description}</p>
@@ -10,6 +10,7 @@
import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js";
import { EQUIPMENT_SETS } from "../../data/equipmentSets.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js";
import type { Equipment, EquipmentType } from "@elysium/types";
@@ -20,12 +21,6 @@ const rarityLabel: Record<string, string> = {
rare: "Rare",
};
const typeIcon: Record<EquipmentType, string> = {
armour: "🛡️",
trinket: "💍",
weapon: "⚔️",
};
/**
* Computes a human-readable bonus description for a piece of equipment.
* @param item - The equipment item.
@@ -128,7 +123,11 @@ const EquipmentCard = ({
<div
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-name-row">
<h3>{item.name}</h3>
@@ -9,6 +9,7 @@
import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js";
import { EXPLORATION_AREAS } from "../../data/explorations.js";
import { cdnImage } from "../../utils/cdn.js";
import { ZoneSelector } from "./zoneSelector.js";
import type { ExploreCollectResponse } from "@elysium/types";
@@ -66,7 +67,9 @@ interface CollectResult {
const ExplorationPanel = (): JSX.Element => {
const { state, startExploration, collectExploration, formatNumber }
= 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 [ lastResult, setLastResult ] = useState<CollectResult | null>(null);
@@ -115,6 +118,7 @@ const ExplorationPanel = (): JSX.Element => {
function handleZoneSelect(id: string): void {
setActiveZoneId(id);
setLastResult(null);
sessionStorage.setItem("elysium_explore_zone", id);
}
const goldChange = lastResult?.response.event?.goldChange ?? 0;
@@ -230,6 +234,11 @@ const ExplorationPanel = (): JSX.Element => {
className={`exploration-card exploration-${status}`}
key={area.id}
>
<img
alt={area.name}
className="card-thumbnail"
src={cdnImage("explorations", area.id)}
/>
<div className="exploration-info">
<h3>
{area.name}
@@ -27,10 +27,12 @@ import { EditProfileModal } from "./editProfileModal.js";
import { EquipmentPanel } from "./equipmentPanel.js";
import { ExplorationPanel } from "./explorationPanel.js";
import { LoginBonusModal } from "./loginBonusModal.js";
import { MilestoneToast } from "./milestoneToast.js";
import { OfflineModal } from "./offlineModal.js";
import { OutdatedSchemaModal } from "./outdatedSchemaModal.js";
import { PrestigePanel } from "./prestigePanel.js";
import { QuestPanel } from "./questPanel.js";
import { QuestCompleteToast, QuestFailedToast } from "./questToast.js";
import { StatisticsPanel } from "./statisticsPanel.js";
import { StoryPanel } from "./storyPanel.js";
import { StoryToast } from "./storyToast.js";
@@ -164,9 +166,14 @@ const GameLayout = (): JSX.Element => {
{schemaOutdated && !dismissedOutdatedWarning
? <OutdatedSchemaModal onDismiss={handleDismissOutdated} />
: null}
<div className="achievement-toast-container">
<AchievementToast />
<CodexToast />
<MilestoneToast />
<QuestCompleteToast />
<QuestFailedToast />
<StoryToast />
</div>
{loginBonus === null
? null
: <LoginBonusModal bonus={loginBonus} onClose={dismissLoginBonus} />
@@ -182,6 +189,7 @@ const GameLayout = (): JSX.Element => {
<div className="game-main">
<aside className="game-sidebar">
<ClickArea />
<div id="tree-nation-offset-website" />
<p className="game-copyright">{"© NHCarrigan"}</p>
</aside>
@@ -0,0 +1,96 @@
/**
* @file Milestone toast notification component for prestige, transcendence, and apotheosis.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable react/no-multi-comp -- Sub-components are tightly coupled to their containers */
import { type JSX, useEffect } from "react";
import { useGame } from "../../context/gameContext.js";
interface MilestoneToastItemProperties {
readonly icon: string;
readonly label: string;
readonly onDismiss: ()=> void;
}
/**
* Renders a single milestone toast notification.
* @param props - The toast item properties.
* @param props.icon - The emoji icon.
* @param props.label - The label text.
* @param props.onDismiss - Callback to dismiss the toast.
* @returns The JSX element.
*/
const MilestoneToastItem = ({
icon,
label,
onDismiss,
}: MilestoneToastItemProperties): JSX.Element => {
useEffect(() => {
const timer = setTimeout(() => {
onDismiss();
}, 4000);
return (): void => {
clearTimeout(timer);
};
}, [ onDismiss ]);
return (
<div className="game-toast" onClick={onDismiss}>
<span className="toast-icon">{icon}</span>
<div className="toast-content">
<span className="toast-label">{label}</span>
</div>
</div>
);
};
/**
* Renders all milestone toasts (prestige, transcendence, apotheosis).
* @returns The JSX element or null if no milestone toasts are pending.
*/
const MilestoneToast = (): JSX.Element | null => {
const {
showPrestigeToast,
showTranscendenceToast,
showApotheosisToast,
dismissPrestigeToast,
dismissTranscendenceToast,
dismissApotheosisToast,
} = useGame();
const hasAny
= showPrestigeToast || showTranscendenceToast || showApotheosisToast;
if (!hasAny) {
return null;
}
return (
<>
{showPrestigeToast
? <MilestoneToastItem
icon={"⭐"}
label={"⭐ Prestige!"}
onDismiss={dismissPrestigeToast}
/>
: null}
{showTranscendenceToast
? <MilestoneToastItem
icon={"🌌"}
label={"🌌 Transcendence!"}
onDismiss={dismissTranscendenceToast}
/>
: null}
{showApotheosisToast
? <MilestoneToastItem
icon={"✨"}
label={"✨ Apotheosis!"}
onDismiss={dismissApotheosisToast}
/>
: null}
</>
);
};
export { MilestoneToast };
@@ -15,6 +15,7 @@ import {
PRESTIGE_UPGRADES,
PRESTIGE_UPGRADE_CATEGORY_LABELS,
} from "../../data/prestigeUpgrades.js";
import { cdnImage } from "../../utils/cdn.js";
import { sendNotification } from "../../utils/notification.js";
import { playSound } from "../../utils/sound.js";
import type { PrestigeUpgradeCategory } from "@elysium/types";
@@ -89,6 +90,7 @@ const PrestigePanel = (): JSX.Element => {
enableNotifications,
enableSounds,
toggleAutoPrestige,
triggerPrestigeToast,
} = useGame();
const [ isPending, setIsPending ] = useState(false);
const [ result, setResult ] = useState<{
@@ -128,6 +130,7 @@ const PrestigePanel = (): JSX.Element => {
milestoneRunestones: data.milestoneRunestones,
runestones: data.runestones,
});
triggerPrestigeToast();
if (enableSounds) {
playSound("prestige");
}
@@ -364,6 +367,11 @@ const PrestigePanel = (): JSX.Element => {
: ""}`}
key={upgrade.id}
>
<img
alt={upgrade.name}
className="card-thumbnail"
src={cdnImage("prestige-upgrades", upgrade.id)}
/>
<div className="shop-upgrade-info">
<h4>{upgrade.name}</h4>
<p>{upgrade.description}</p>
+6 -1
View File
@@ -8,6 +8,7 @@
/* eslint-disable complexity -- Many conditional stat visibility checks */
import { useEffect, useState, type JSX } from "react";
import { formatNumber } from "../../utils/format.js";
import { logError } from "../../utils/logError.js";
import type { PublicProfileResponse } from "@elysium/types";
interface ProfilePageProperties {
@@ -52,11 +53,15 @@ const ProfilePage = ({ discordId }: ProfilePageProperties): JSX.Element => {
}, [ discordId ]);
function handleCopy(): void {
void navigator.clipboard.writeText(window.location.href).then(() => {
void navigator.clipboard.writeText(window.location.href).
then(() => {
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
}).
catch((error_: unknown) => {
logError("clipboard_copy", error_);
});
}
+22 -9
View File
@@ -10,6 +10,7 @@
/* eslint-disable max-statements -- Many local variables needed for quest state */
import { useState, type JSX } from "react";
import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js";
import { ZoneSelector } from "./zoneSelector.js";
import type { Quest } from "@elysium/types";
@@ -81,6 +82,11 @@ const QuestCard = ({
return (
<div className={`quest-card quest-${quest.status}`}>
<img
alt={quest.name}
className="card-thumbnail"
src={cdnImage("quests", quest.id)}
/>
<div className="quest-info">
<h3>{quest.name}</h3>
<p>{quest.description}</p>
@@ -102,9 +108,9 @@ const QuestCard = ({
</p>
}
<div className="quest-rewards">
{quest.rewards.map((reward) => {
{quest.rewards.map((reward, rewardIndex) => {
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"
&& `🪙 ${formatNumber(reward.amount ?? 0)}`}
{reward.type === "essence"
@@ -178,7 +184,9 @@ const QuestCard = ({
*/
const QuestPanel = (): JSX.Element => {
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);
if (state === null) {
@@ -190,11 +198,11 @@ const QuestPanel = (): JSX.Element => {
}
const { adventurers, autoQuest, quests, zones } = state;
// eslint-disable-next-line unicorn/no-array-reduce -- Need the total!
const partyCombatPower = adventurers.reduce((total, adventurer) => {
const power = total + adventurer.combatPower;
return power * adventurer.count;
}, 0);
let partyCombatPower = 0;
for (const adventurer of adventurers) {
const contribution = adventurer.combatPower * adventurer.count;
partyCombatPower = partyCombatPower + contribution;
}
const zoneQuests = quests.filter(({ zoneId }) => {
return zoneId === activeZoneId;
});
@@ -237,6 +245,11 @@ const QuestPanel = (): JSX.Element => {
}
}
function handleZoneSelect(zoneId: string): void {
setActiveZoneId(zoneId);
sessionStorage.setItem("elysium_quest_zone", zoneId);
}
function handleToggle(): void {
setShowLocked((current) => {
return !current;
@@ -279,7 +292,7 @@ const QuestPanel = (): JSX.Element => {
<ZoneSelector
activeZoneId={activeZoneId}
onSelectZone={setActiveZoneId}
onSelectZone={handleZoneSelect}
zones={zones}
/>
+113
View File
@@ -0,0 +1,113 @@
/**
* @file Quest toast notification component for completed and failed quests.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable react/no-multi-comp -- Sub-components are tightly coupled to their containers */
import { type JSX, useEffect } from "react";
import { useGame } from "../../context/gameContext.js";
import type { Quest } from "@elysium/types";
interface QuestToastItemProperties {
readonly quest: Quest;
readonly onDismiss: (id: string)=> void;
// eslint-disable-next-line react/require-default-props -- Default value set in destructuring
readonly isFailure?: boolean;
}
/**
* Renders a single quest toast notification.
* @param props - The toast item properties.
* @param props.quest - The quest to display.
* @param props.onDismiss - Callback to dismiss the toast.
* @param props.isFailure - Whether this is a failure toast.
* @returns The JSX element.
*/
const QuestToastItem = ({
quest,
onDismiss,
isFailure = false,
}: QuestToastItemProperties): JSX.Element => {
useEffect(() => {
const timer = setTimeout(() => {
onDismiss(quest.id);
}, 4000);
return (): void => {
clearTimeout(timer);
};
}, [ quest.id, onDismiss ]);
function handleClick(): void {
onDismiss(quest.id);
}
return (
<div className="game-toast" onClick={handleClick}>
<span className="toast-icon">{isFailure
? "💀"
: "📜"}</span>
<div className="toast-content">
<span className="toast-label">{isFailure
? "Quest Failed!"
: "✨ Quest Complete!"}</span>
<span className="toast-name">{quest.name}</span>
</div>
</div>
);
};
/**
* Renders the quest complete toast container.
* @returns The JSX element or null if there are no pending quest toasts.
*/
const QuestCompleteToast = (): JSX.Element | null => {
const { completedQuestToasts, dismissCompletedQuest } = useGame();
if (completedQuestToasts.length === 0) {
return null;
}
return (
<>
{completedQuestToasts.map((quest) => {
return (
<QuestToastItem
key={quest.id}
onDismiss={dismissCompletedQuest}
quest={quest}
/>
);
})}
</>
);
};
/**
* Renders the quest failed toast container.
* @returns The JSX element or null if there are no pending failure toasts.
*/
const QuestFailedToast = (): JSX.Element | null => {
const { failedQuestToasts, dismissFailedQuest } = useGame();
if (failedQuestToasts.length === 0) {
return null;
}
return (
<>
{failedQuestToasts.map((quest) => {
return (
<QuestToastItem
isFailure={true}
key={quest.id}
onDismiss={dismissFailedQuest}
quest={quest}
/>
);
})}
</>
);
};
export { QuestCompleteToast, QuestFailedToast };
@@ -9,6 +9,7 @@
import { STORY_CHAPTERS } from "@elysium/types";
import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
/**
* Substitutes the character name placeholder in story text.
@@ -102,6 +103,11 @@ const StoryPanel = (): JSX.Element => {
: <div className="story-chapter-view">
{isUnlocked
? <>
<img
alt={activeChapter.title}
className="story-chapter-banner"
src={cdnImage("story-chapters", activeChapter.id)}
/>
<h2 className="story-chapter-title">
{"Chapter "}
{activeChapterIndex + 1}
+8 -8
View File
@@ -45,13 +45,13 @@ const StoryToastItem = ({
}
return (
<button className="achievement-toast" onClick={handleClick} type="button">
<span className="achievement-toast-icon">{"📖"}</span>
<div className="achievement-toast-content">
<span className="achievement-toast-label">{"✨ New Chapter!"}</span>
<span className="achievement-toast-name">{chapter.title}</span>
<div className="game-toast" onClick={handleClick}>
<span className="toast-icon">{"📖"}</span>
<div className="toast-content">
<span className="toast-label">{"✨ New Chapter!"}</span>
<span className="toast-name">{chapter.title}</span>
</div>
</div>
</button>
);
};
@@ -65,11 +65,11 @@ const StoryToast = (): JSX.Element | null => {
return null;
}
return (
<div className="achievement-toast-container">
<>
{pendingChapterIds.map((id) => {
return <StoryToastItem chapterId={id} key={id} />;
})}
</div>
</>
);
};
@@ -7,12 +7,14 @@
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- Many conditional render paths */
/* 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 { useGame } from "../../context/gameContext.js";
import {
TRANSCENDENCE_UPGRADES,
TRANSCENDENCE_UPGRADE_CATEGORY_LABELS,
} from "../../data/transcendenceUpgrades.js";
import { cdnImage } from "../../utils/cdn.js";
import type { TranscendenceUpgradeCategory } from "@elysium/types";
const echoFormulaConstant = 853;
@@ -301,6 +303,11 @@ const TranscendencePanel = (): JSX.Element => {
: ""}`}
key={upgrade.id}
>
<img
alt={upgrade.name}
className="card-thumbnail"
src={cdnImage("transcendence-upgrades", upgrade.id)}
/>
<div className="shop-upgrade-info">
<h4>{upgrade.name}</h4>
<p>{upgrade.description}</p>
@@ -9,6 +9,7 @@
/* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */
import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js";
import type { Upgrade } from "@elysium/types";
@@ -53,6 +54,11 @@ const UpgradeCard = ({
if (upgrade.unlocked && upgrade.purchased) {
return (
<div className="upgrade-card purchased">
<img
alt={upgrade.name}
className="card-thumbnail"
src={cdnImage("upgrades", upgrade.id)}
/>
<span className="upgrade-name">
{"✅ "}
{upgrade.name}
@@ -65,6 +71,11 @@ const UpgradeCard = ({
if (upgrade.unlocked) {
return (
<div className="upgrade-card">
<img
alt={upgrade.name}
className="card-thumbnail"
src={cdnImage("upgrades", upgrade.id)}
/>
<div className="upgrade-info">
<h3>{upgrade.name}</h3>
<p>{upgrade.description}</p>
@@ -108,6 +119,11 @@ const UpgradeCard = ({
return (
<div className="upgrade-card locked">
<img
alt={upgrade.name}
className="card-thumbnail"
src={cdnImage("upgrades", upgrade.id)}
/>
<div className="upgrade-info">
<h3>
{"🔒 "}
@@ -4,6 +4,7 @@
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { cdnImage } from "../../utils/cdn.js";
import type { Zone } from "@elysium/types";
import type { JSX } from "react";
@@ -44,7 +45,11 @@ const ZoneSelector = ({
title={zone.description}
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>
</button>
);
+269 -41
View File
@@ -20,6 +20,7 @@ import {
type GameState,
type LoginBonusResult,
type NumberFormat,
type Quest,
type TranscendenceResponse,
isStoryChapterUnlocked,
} from "@elysium/types";
@@ -58,6 +59,7 @@ import {
} from "../engine/tick.js";
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
import { formatNumber as formatNumberUtil } from "../utils/format.js";
import { logError } from "../utils/logError.js";
import { sendNotification } from "../utils/notification.js";
import { playSound } from "../utils/sound.js";
@@ -334,6 +336,61 @@ interface GameContextValue {
*/
dismissAchievement: (id: string)=> void;
/**
* Queue of newly completed quests (for toast notifications).
*/
completedQuestToasts: Array<Quest>;
/**
* Remove a quest from the completed toast queue.
*/
dismissCompletedQuest: (id: string)=> void;
/**
* Queue of newly failed quests (for toast notifications).
*/
failedQuestToasts: Array<Quest>;
/**
* Remove a quest from the failed toast queue.
*/
dismissFailedQuest: (id: string)=> void;
/**
* Whether the prestige milestone toast is currently showing.
*/
showPrestigeToast: boolean;
/**
* Trigger the prestige milestone toast (called from prestigePanel on manual prestige).
*/
triggerPrestigeToast: ()=> void;
/**
* Dismiss the prestige milestone toast.
*/
dismissPrestigeToast: ()=> void;
/**
* Whether the transcendence milestone toast is currently showing.
*/
showTranscendenceToast: boolean;
/**
* Dismiss the transcendence milestone toast.
*/
dismissTranscendenceToast: ()=> void;
/**
* Whether the apotheosis milestone toast is currently showing.
*/
showApotheosisToast: boolean;
/**
* Dismiss the apotheosis milestone toast.
*/
dismissApotheosisToast: ()=> void;
/**
* The player's chosen number display format.
*/
@@ -399,6 +456,11 @@ interface GameContextValue {
*/
dismissCodexEntry: (id: string)=> void;
/**
* Flush pending boss lore codex toasts — call after the battle animation reveals the result.
*/
flushBossLoreToasts: ()=> void;
/**
* Perform a transcendence — nuclear reset, earning echoes.
*/
@@ -483,6 +545,18 @@ interface GameContextValue {
* Reset all progress to a fresh save state (resolves schema outdated).
*/
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 {
@@ -514,9 +588,24 @@ export const GameProvider = ({
const [ unlockedAchievements, setUnlockedAchievements ] = useState<
Array<Achievement>
>([]);
const [ completedQuestToasts, setCompletedQuestToasts ] = useState<
Array<Quest>
>([]);
const [ failedQuestToasts, setFailedQuestToasts ] = useState<Array<Quest>>(
[],
);
const [ showPrestigeToast, setShowPrestigeToast ] = useState(false);
const [ showTranscendenceToast, setShowTranscendenceToast ] = useState(false);
const [ showApotheosisToast, setShowApotheosisToast ] = useState(false);
const [ lastSavedAt, setLastSavedAt ] = useState<number | null>(null);
const [ isSyncing, setIsSyncing ] = useState(false);
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>(
null,
);
@@ -530,8 +619,8 @@ export const GameProvider = ({
const isSyncingReference = useRef(false);
const rafReference = useRef<number | null>(null);
const unlockedAchievementsReference = useRef<Array<Achievement>>([]);
const newlyCompletedQuestsCountReference = useRef(0);
const newlyFailedQuestsCountReference = useRef(0);
const newlyCompletedQuestsReference = useRef<Array<Quest>>([]);
const newlyFailedQuestsReference = useRef<Array<Quest>>([]);
const signatureReference = useRef<string | null>(
localStorage.getItem("elysium_save_signature"),
);
@@ -548,6 +637,7 @@ export const GameProvider = ({
Array<string>
>([]);
const codexProcessedReference = useRef<Set<string>>(new Set());
const pendingBossCodexIdsReference = useRef<Array<string>>([]);
const [ unlockedStoryChapterIds, setUnlockedStoryChapterIds ] = useState<
Array<string>
>([]);
@@ -815,12 +905,30 @@ export const GameProvider = ({
};
});
if (!isFirstRun) {
const bossIds = addedIds.filter((id) => {
return id.startsWith("boss_");
});
const otherIds = addedIds.filter((id) => {
return !id.startsWith("boss_");
});
if (bossIds.length > 0) {
if (battleResult === null) {
otherIds.push(...bossIds);
} else {
pendingBossCodexIdsReference.current = [
...pendingBossCodexIdsReference.current,
...bossIds,
];
}
}
if (otherIds.length > 0) {
setUnlockedCodexEntryIds((previous) => {
return [ ...previous, ...addedIds ];
return [ ...previous, ...otherIds ];
});
}
}
}, [ state ]);
}
}, [ battleResult, state ]);
// Detect newly unlocked story chapters
useEffect(() => {
@@ -949,17 +1057,17 @@ export const GameProvider = ({
);
// Detect newly completed quests
newlyCompletedQuestsCountReference.current = next.quests.filter(
newlyCompletedQuestsReference.current = next.quests.filter(
(q, index) => {
return (
previous.quests[index]?.status === "active"
&& q.status === "completed"
);
},
).length;
);
// Detect newly failed quests
newlyFailedQuestsCountReference.current = next.quests.filter(
newlyFailedQuestsReference.current = next.quests.filter(
(q, index) => {
const previousFailedAt = previous.quests[index]?.lastFailedAt;
return (
@@ -967,7 +1075,15 @@ export const GameProvider = ({
&& q.lastFailedAt !== previousFailedAt
);
},
).length;
);
// 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;
});
@@ -987,24 +1103,30 @@ export const GameProvider = ({
unlockedAchievementsReference.current = [];
}
if (newlyCompletedQuestsCountReference.current > 0) {
if (newlyCompletedQuestsReference.current.length > 0) {
setCompletedQuestToasts((previous) => {
return [ ...previous, ...newlyCompletedQuestsReference.current ];
});
if (enableSoundsReference.current) {
playSound("questCompleted");
}
if (enableNotificationsReference.current) {
sendNotification("📜 Quest Complete!", "A quest has been completed.");
}
newlyCompletedQuestsCountReference.current = 0;
newlyCompletedQuestsReference.current = [];
}
if (newlyFailedQuestsCountReference.current > 0) {
if (newlyFailedQuestsReference.current.length > 0) {
setFailedQuestToasts((previous) => {
return [ ...previous, ...newlyFailedQuestsReference.current ];
});
if (enableSoundsReference.current) {
playSound("questFailed");
}
if (enableNotificationsReference.current) {
sendNotification("💀 Quest Failed!", "A quest has failed.");
}
newlyFailedQuestsCountReference.current = 0;
newlyFailedQuestsReference.current = [];
}
// Auto-save every 30 seconds (skip if a force sync is in-flight to avoid signature collisions)
@@ -1035,6 +1157,8 @@ export const GameProvider = ({
) {
signatureReference.current = null;
localStorage.removeItem("elysium_save_signature");
} else {
logError("auto_save", error_);
}
});
}
@@ -1054,6 +1178,7 @@ export const GameProvider = ({
isAutoPrestigingReference.current = true;
void prestigeApi({}).
then(async() => {
setShowPrestigeToast(true);
if (enableSoundsReference.current) {
playSound("prestige");
}
@@ -1062,7 +1187,8 @@ export const GameProvider = ({
}
await reloadReference.current();
}).
catch(() => {
catch((error_: unknown) => {
logError("auto_prestige", error_);
/* Silently ignore — will retry next tick */
}).
@@ -1100,24 +1226,32 @@ export const GameProvider = ({
if (previous === null) {
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 });
if (result.won) {
if (enableSoundsReference.current) {
playSound("bossVictory");
}
if (enableNotificationsReference.current) {
sendNotification(
"⚔️ Boss Defeated!",
`You defeated ${bossName}!`,
);
}
}
}).
catch(() => {
/* Silently ignore — will retry next tick */
catch((error_: unknown) => {
logError("auto_boss", error_);
const message
= error_ instanceof Error
? error_.message
: String(error_);
setAutoBossError(message);
setState((previous) => {
if (previous === null) {
return previous;
}
return { ...previous, autoBoss: false };
});
}).
finally(() => {
isAutoBossingReference.current = false;
@@ -1436,13 +1570,16 @@ export const GameProvider = ({
},
};
});
} catch {
} catch (error_: unknown) {
logError("buy_prestige_upgrade", error_);
// Silently ignore — server errors shouldn't crash the UI
}
}, []);
const transcend = useCallback(async() => {
try {
const result = await transcendApi({});
setShowTranscendenceToast(true);
if (enableSoundsReference.current) {
playSound("transcendence");
}
@@ -1451,10 +1588,16 @@ export const GameProvider = ({
}
await reload();
return result;
} catch (error_: unknown) {
logError("transcend", error_);
throw error_;
}
}, [ reload ]);
const apotheosis = useCallback(async() => {
try {
const result = await achieveApotheosisApi({});
setShowApotheosisToast(true);
if (enableSoundsReference.current) {
playSound("apotheosis");
}
@@ -1463,6 +1606,10 @@ export const GameProvider = ({
}
await reload();
return result;
} catch (error_: unknown) {
logError("apotheosis", error_);
throw error_;
}
}, [ reload ]);
const buyEchoUpgrade = useCallback(async(upgradeId: string) => {
@@ -1488,12 +1635,14 @@ export const GameProvider = ({
},
};
});
} catch {
// Silently ignore server errors
} catch (error_: unknown) {
logError("buy_echo_upgrade", error_);
// Silently ignore — server errors shouldn't crash the UI
}
}, []);
const startExploration = useCallback(async(areaId: string) => {
try {
const response = await startExplorationApi({ areaId });
const areaData = EXPLORATION_AREAS.find((a) => {
return a.id === areaId;
@@ -1519,10 +1668,15 @@ export const GameProvider = ({
},
};
});
} catch (error_: unknown) {
logError("start_exploration", error_);
throw error_;
}
}, []);
const collectExploration = useCallback(
async(areaId: string): Promise<ExploreCollectResponse> => {
try {
const result = await collectExplorationApi({ areaId });
setState((previous) => {
if (previous?.exploration === undefined) {
@@ -1596,6 +1750,10 @@ export const GameProvider = ({
};
});
return result;
} catch (error_: unknown) {
logError("collect_exploration", error_);
throw error_;
}
},
[],
);
@@ -1607,6 +1765,7 @@ export const GameProvider = ({
if (recipe === undefined) {
return;
}
try {
const result = await craftRecipeApi({ recipeId });
setState((previous) => {
if (previous?.exploration === undefined) {
@@ -1636,6 +1795,10 @@ export const GameProvider = ({
},
};
});
} catch (error_: unknown) {
logError("craft_recipe", error_);
throw error_;
}
}, []);
const toggleAutoPrestige = useCallback(() => {
@@ -1663,6 +1826,8 @@ export const GameProvider = ({
}, []);
const toggleAutoBoss = useCallback(() => {
setAutoBossError(null);
setAutoBossLastResult(null);
setState((previous) => {
if (previous === null) {
return previous;
@@ -1711,15 +1876,8 @@ export const GameProvider = ({
return applyBossResult(previous, bossId, result);
});
setBattleResult({ bossName: boss.name, result: result });
if (result.won) {
if (enableSoundsReference.current) {
playSound("bossVictory");
}
if (enableNotificationsReference.current) {
sendNotification("⚔️ Boss Defeated!", `You defeated ${boss.name}!`);
}
}
} catch {
} catch (error_: unknown) {
logError("challenge_boss", error_);
// Silently ignore — server errors shouldn't crash the UI
}
}, []);
@@ -1733,6 +1891,38 @@ export const GameProvider = ({
setBattleResult(null);
}, []);
const dismissCompletedQuest = useCallback((id: string) => {
setCompletedQuestToasts((previous) => {
return previous.filter((q) => {
return q.id !== id;
});
});
}, []);
const dismissFailedQuest = useCallback((id: string) => {
setFailedQuestToasts((previous) => {
return previous.filter((q) => {
return q.id !== id;
});
});
}, []);
const triggerPrestigeToast = useCallback(() => {
setShowPrestigeToast(true);
}, []);
const dismissPrestigeToast = useCallback(() => {
setShowPrestigeToast(false);
}, []);
const dismissTranscendenceToast = useCallback(() => {
setShowTranscendenceToast(false);
}, []);
const dismissApotheosisToast = useCallback(() => {
setShowApotheosisToast(false);
}, []);
const dismissAchievement = useCallback((id: string) => {
setUnlockedAchievements((previous) => {
return previous.filter((a) => {
@@ -1749,6 +1939,16 @@ export const GameProvider = ({
});
}, []);
const flushBossLoreToasts = useCallback(() => {
const pending = pendingBossCodexIdsReference.current;
if (pending.length > 0) {
pendingBossCodexIdsReference.current = [];
setUnlockedCodexEntryIds((previous) => {
return [ ...previous, ...pending ];
});
}
}, []);
const dismissStoryChapter = useCallback((id: string) => {
setUnlockedStoryChapterIds((previous) => {
return previous.filter((chapter) => {
@@ -1820,6 +2020,8 @@ export const GameProvider = ({
const contextValue = useMemo<GameContextValue>(() => {
return {
apotheosis,
autoBossError,
autoBossLastResult,
battleResult,
buyAdventurer,
buyEchoUpgrade,
@@ -1829,18 +2031,26 @@ export const GameProvider = ({
challengeBoss,
collectExploration,
completeChapter,
completedQuestToasts,
craftRecipe,
currentSchemaVersion,
dismissAchievement,
dismissApotheosisToast,
dismissBattle,
dismissCodexEntry,
dismissCompletedQuest,
dismissFailedQuest,
dismissLoginBonus,
dismissOfflineGold,
dismissPrestigeToast,
dismissStoryChapter,
dismissTranscendenceToast,
enableNotifications,
enableSounds,
equipItem,
error,
failedQuestToasts,
flushBossLoreToasts,
forceSync,
formatNumber,
handleClick,
@@ -1860,6 +2070,9 @@ export const GameProvider = ({
setEnableNotifications,
setEnableSounds,
setNumberFormat,
showApotheosisToast,
showPrestigeToast,
showTranscendenceToast,
startExploration,
startQuest,
state,
@@ -1868,13 +2081,18 @@ export const GameProvider = ({
toggleAutoPrestige,
toggleAutoQuest,
transcend,
triggerPrestigeToast,
unlockedAchievements,
unlockedCodexEntryIds,
unlockedStoryChapterIds,
};
}, [
apotheosis,
autoBossError,
autoBossLastResult,
battleResult,
completedQuestToasts,
failedQuestToasts,
formatNumber,
buyAdventurer,
buyEchoUpgrade,
@@ -1887,15 +2105,21 @@ export const GameProvider = ({
craftRecipe,
currentSchemaVersion,
dismissAchievement,
dismissApotheosisToast,
dismissBattle,
dismissCodexEntry,
dismissCompletedQuest,
dismissFailedQuest,
dismissLoginBonus,
dismissOfflineGold,
dismissPrestigeToast,
dismissStoryChapter,
dismissTranscendenceToast,
enableNotifications,
enableSounds,
equipItem,
error,
flushBossLoreToasts,
forceSync,
handleClick,
isLoading,
@@ -1914,6 +2138,9 @@ export const GameProvider = ({
setEnableNotifications,
setEnableSounds,
setNumberFormat,
showApotheosisToast,
showPrestigeToast,
showTranscendenceToast,
startExploration,
startQuest,
state,
@@ -1922,6 +2149,7 @@ export const GameProvider = ({
toggleAutoPrestige,
toggleAutoQuest,
transcend,
triggerPrestigeToast,
unlockedAchievements,
unlockedCodexEntryIds,
unlockedStoryChapterIds,
+6
View File
@@ -8,8 +8,12 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./app.js";
import { ErrorBoundary } from "./components/errorBoundary.js";
import { initialiseFrontendLogger } from "./utils/logger.js";
import "./styles.css";
initialiseFrontendLogger();
const rootElement = document.getElementById("root");
if (!rootElement) {
@@ -18,6 +22,8 @@ if (!rootElement) {
createRoot(rootElement).render(
<StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>,
);
+136 -21
View File
@@ -26,6 +26,7 @@
--radius: 8px;
--radius-lg: 12px;
--font: "Segoe UI", system-ui, sans-serif;
--resource-bar-height: 3.5rem;
}
body {
@@ -33,6 +34,20 @@ body {
color: var(--colour-text);
font-family: var(--font);
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 ===================== */
@@ -122,6 +137,10 @@ body {
flex-direction: column;
align-items: center;
gap: 1rem;
position: sticky;
top: var(--resource-bar-height);
height: calc(100vh - var(--resource-bar-height));
overflow-y: auto;
}
.game-content {
@@ -1432,20 +1451,6 @@ body {
z-index: 200;
}
.achievement-toast {
align-items: center;
animation: slide-in-right 0.35s ease-out;
background: var(--colour-surface);
border: 1px solid var(--colour-gold);
border-radius: var(--radius);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
cursor: pointer;
display: flex;
gap: 0.75rem;
max-width: 280px;
padding: 0.75rem 1rem;
}
.toast-icon {
font-size: 1.5rem;
flex-shrink: 0;
@@ -2070,8 +2075,11 @@ body {
opacity: 0.45;
}
.zone-emoji {
font-size: 1.4rem;
.zone-tab-image {
aspect-ratio: 16 / 9;
border-radius: 0.35rem;
object-fit: cover;
width: 96px;
}
.zone-name {
@@ -2299,9 +2307,6 @@ body {
}
.about-release-body {
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
font-size: 0.85rem;
color: var(--colour-text-secondary, #b0b0b0);
padding: 0 1rem 0.75rem;
@@ -2309,6 +2314,81 @@ body {
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 {
list-style: none;
padding: 0;
@@ -2481,8 +2561,8 @@ body {
padding: 0.6rem 0.75rem;
}
/* Codex toast — uses a different accent from achievement toast */
.codex-toast {
/* Unified game toast — essence-coloured border used by all in-game notifications */
.game-toast {
align-items: center;
animation: slide-in-right 0.35s ease-out;
background: var(--colour-surface);
@@ -3106,8 +3186,11 @@ body {
border-right: none;
flex-direction: row;
gap: 0.75rem;
height: auto;
justify-content: center;
padding: 0.5rem 0.75rem;
position: static;
top: auto;
width: 100%;
}
@@ -4400,3 +4483,35 @@ body {
font-size: 0.8rem;
font-style: italic;
}
.character-sheet-story-outcome {
margin: 0;
color: var(--colour-muted);
font-size: 0.8rem;
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;
}
+20
View File
@@ -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 };
+19
View File
@@ -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 };
+68
View File
@@ -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 };
+3 -1
View File
@@ -4,6 +4,7 @@
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { logError } from "./logError.js";
/**
* Requests browser notification permission from the user.
@@ -38,7 +39,8 @@ const sendNotification = (title: string, body: string): void => {
try {
// eslint-disable-next-line no-new -- Notification constructor has side effects
new Notification(title, { body: body, icon: "/favicon.ico" });
} catch {
} catch (error_: unknown) {
logError("send_notification", error_);
// Silently ignore — notifications may fail silently
}
};
+3 -1
View File
@@ -4,6 +4,7 @@
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { logError } from "./logError.js";
type SoundEvent =
| "achievement"
@@ -101,7 +102,8 @@ const playSound = (event: SoundEvent): void => {
oscillator.start(startTime);
oscillator.stop(endTime);
}
} catch {
} catch (error_: unknown) {
logError("play_sound", error_);
// Silently ignore — audio may not be available in all environments
}
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "elysium",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@elysium/types",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"type": "module",
"main": "./prod/src/index.js",
+6
View File
@@ -12,6 +12,7 @@ import type {
import type { GameState } from "./gameState.js";
import type { Player } from "./player.js";
import type { ProfileSettings } from "./profileSettings.js";
import type { CompletedChapter } from "./story.js";
interface AuthResponse {
token: string;
@@ -247,6 +248,11 @@ interface PublicProfileResponse {
rarity: EquipmentRarity;
bonus: EquipmentBonus;
}>;
/**
* Story chapters the player has completed and their chosen outcomes.
*/
completedChapters: Array<CompletedChapter>;
}
interface UpdateProfileRequest {
+7
View File
@@ -59,6 +59,13 @@ interface Boss {
* One-time runestone bounty awarded on first-ever defeat.
*/
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 };
+68
View File
@@ -1,4 +1,5 @@
/* eslint-disable max-lines -- story data file necessarily exceeds line limit */
/* eslint-disable stylistic/max-len -- story descriptions are naturally long */
/**
* @file Story chapter types and data for the Elysium game.
* @copyright nhcarrigan
@@ -9,6 +10,7 @@ import type { Boss } from "./boss.js";
import type { GameState } from "./gameState.js";
interface StoryChoice {
description: string;
id: string;
label: string;
outcome: string;
@@ -88,6 +90,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{
choices: [
{
description: "Accepted the map with quiet resolve, already looking east.",
id: "resolve",
label: "Accept the map with quiet resolve",
outcome: `You folded the map carefully and tucked it away. Resolve was the only`
@@ -95,6 +98,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` this one has the look of someone who finishes things.`,
},
{
description: "Turned back to their people first — some leaders are built for their guild.",
id: "people",
label: "Return immediately to your people",
outcome: `Your first thought was of your guild — of wounds to tend and rest`
@@ -102,6 +106,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` glory; some are built for their people. You were becoming the latter.`,
},
{
description: "Studied the map in silence, already charting the next move.",
id: "plan",
label: "Study it in silence, already planning",
outcome: `Your eyes moved across the map before she'd even finished speaking. The`
@@ -129,6 +134,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{
choices: [
{
description: "Stayed to hear the scholar's findings, filing every warning about what had ended the city.",
id: "listen",
label: "Ask the scholar what she has learned",
outcome: `You stayed long enough to listen. The scholar was cautious with her theories`
@@ -137,6 +143,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` knowledge away like a sharp blade.`,
},
{
description: "Claimed the ancient hall as a waystation — filling old bones with new purpose.",
id: "claim",
label: "Claim the hall as a guild waystation",
outcome: `The ruins needed purpose more than they needed silence. Your guild cleared`
@@ -144,6 +151,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` age. Whatever had ended the people here, it would not end you.`,
},
{
description: "Marked the ruin on the chart and pressed on. History could wait.",
id: "press",
label: "Mark it on your chart and press on",
outcome: `There would be time for history later. You marked the ruin on your chart`
@@ -171,6 +179,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{
choices: [
{
description: "Asked what darker things lay deeper in the marsh, and listened carefully.",
id: "ask",
label: "Ask what lies deeper in the marshes",
outcome: `He told you what the marsh-folk knew: that the darkness didn't end at the`
@@ -178,6 +187,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` You thanked him and kept that information close.`,
},
{
description: "Accepted the lantern and moved on, carrying light into whatever came next.",
id: "lantern",
label: "Accept the lantern and move on",
outcome: `You took the lantern. Light against darkness — it was a simple philosophy,`
@@ -185,6 +195,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` disappear into the mist and smiled, alone.`,
},
{
description: "Chose to rest with the marsh villages first, giving the guild time to heal.",
id: "rest",
label: "Rest with the marsh villages first",
outcome: `Three days of sleeping on dry ground and eating hot food did more for your`
@@ -213,6 +224,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{
choices: [
{
description: "Took the monk's journal and studied it carefully, preparing for what was coming.",
id: "study",
label: "Take the journal and study it carefully",
outcome: `The journal became essential reading for your strongest strategists. The`
@@ -220,6 +232,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` comforting. You began preparing for something larger than any single battle.`,
},
{
description: "Promised to return with answers, carrying the old monk's question as a compass.",
id: "promise",
label: "Promise to return with answers",
outcome: `You couldn't take the old man down the mountain, but you could carry his`
@@ -227,6 +240,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` often, in the quiet hours — a compass of its own.`,
},
{
description: "Asked the monk what he believed was causing it, and descended with new understanding.",
id: "inquire",
label: "Ask the monk what he believes is causing it",
outcome: `He didn't answer immediately. When he did, the words were careful: 'I think`
@@ -255,6 +269,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{
choices: [
{
description: "Kept the phoenix feather — not a trophy, but a question not yet answered.",
id: "feather",
label: "Keep the feather as a reminder",
outcome: `You carried the feather in a sealed case from that day forward — not as a`
@@ -262,12 +277,14 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` question sharpened you.`,
},
{
description: "Answered plainly: the guild protects its people. A truth held without wavering.",
id: "people",
label: "Tell her: you protect your people",
outcome: `'Then don't lose them,' she said simply. It wasn't a warning. It was the`
+ ` closest thing to a blessing the volcanic depths had to offer.`,
},
{
description: "Asked what lay beyond the fire, and carried the uncertainty forward like a live coal.",
id: "beyond",
label: "Ask what she thinks lies beyond the fire",
outcome: `'Something that cannot burn,' she said, after a long pause. 'Something that`
@@ -297,6 +314,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{
choices: [
{
description: "Said it plainly: small, and yet fighting anyway. A philosophy that spread far.",
id: "fight",
label: "Yes — and we fight anyway",
outcome: `The philosopher wrote that down. She published it later, in an obscure`
@@ -304,6 +322,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` yet. And yet.`,
},
{
description: "Asked what lay further out — and made sure that when noticed, it would be their mistake.",
id: "further",
label: "Ask what she thinks is further out",
outcome: `She smiled, the way people smile when they've been waiting for the question.`
@@ -312,6 +331,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` be a mistake.`,
},
{
description: "Admitted the silence of the Void still echoed inside, and let time fill it back in.",
id: "honest",
label: "Admit the silence still echoes in you",
outcome: `She nodded, unsurprised. 'It does that. To everyone who goes there and comes`
@@ -342,12 +362,14 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{
choices: [
{
description: "Chose to carry the names of those who hadn't made it — weight and compass both.",
id: "memory",
label: "Carry forward the memory of those lost",
outcome: `The names. The faces. The ones who hadn't made it as far as this height. You`
+ ` held them as a weight and a compass both, and continued with your eyes open.`,
},
{
description: "Chose to carry the will to finish it: one step, then another, without stopping.",
id: "will",
label: "Carry forward the will to finish it",
outcome: `The work was not done. The scale of it had grown, but the work remained:`
@@ -355,6 +377,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` settled. You were not built to leave things undone.`,
},
{
description: "Chose to carry wonder deliberately, refusing to become something cold and certain.",
id: "wonder",
label: "Carry forward wonder, against hardness",
outcome: `It would have been easy, up here, to become something cold and certain. You`
@@ -384,6 +407,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{
choices: [
{
description: "Asked what the naturalist thought was falling, and received an unsettling answer.",
id: "ask",
label: "Ask what he thinks is falling",
outcome: `'Pressure,' he said. 'The kind that builds when too many powers concentrate`
@@ -392,6 +416,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` that he did not look away.`,
},
{
description: "Accepted that some things couldn't be predicted, holding the uncertainty like ballast.",
id: "accept",
label: "Accept that some things can't be predicted",
outcome: `Not everything could be prepared for. This was a truth you had learned the`
@@ -399,6 +424,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` surface settle and held the uncertainty like ballast.`,
},
{
description: "Spent the return voyage writing — a record of pattern for whoever came after.",
id: "document",
label: "Document everything for whoever comes next",
outcome: `If something woke what slept below, there would be others who needed to`
@@ -427,6 +453,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{
choices: [
{
description: "Asked the spirit what they had been warned about, and filed the answer carefully.",
id: "learn",
label: "Ask what they were warned about",
outcome: `The spirit answered slowly, in the manner of things that have had too much`
@@ -435,6 +462,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` a lesson.`,
},
{
description: "Acknowledged the warning and left without a word, carrying a weight not unearned.",
id: "silence",
label: "Acknowledge the warning and leave in silence",
outcome: `Some moments asked for silence. You gave it. The spirit seemed grateful, in`
@@ -442,6 +470,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` you that was not unearned.`,
},
{
description: "Vowed the guild would not make the same mistake, and was watched all the way to the door.",
id: "vow",
label: "Vow your guild won't make the same mistake",
outcome: `The spirit looked at you for a long time. 'That is what they said too,' it`
@@ -471,6 +500,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{
choices: [
{
description: "Told the crystallographer the balance was not as bad as feared, and meant it.",
id: "better",
label: "Not as bad as I feared",
outcome: `The crystallographer looked relieved in a way that surprised you — as though`
@@ -478,6 +508,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` its people, more than its victories. You had not forgotten that. Not yet.`,
},
{
description: "Said the ledger showed exactly what was expected. Honest accounting, nothing more.",
id: "expected",
label: "Exactly what I expected",
outcome: `'Then you have been paying attention,' she said, quietly approving. 'That is`
@@ -485,6 +516,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` discipline.`,
},
{
description: "Said nothing of the balance. The ones who stay quiet are usually telling the truth.",
id: "quiet",
label: "I don't think I'm the one who should say",
outcome: `She nodded slowly. 'The ones who say nothing are usually telling the truth,'`
@@ -512,6 +544,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{
choices: [
{
description: "Sat in the silence before leaving, letting the emptiness speak what it could.",
id: "sit",
label: "Let the silence sit before leaving",
outcome: `Wisdom, sometimes, is the willingness to remain still in an uncomfortable`
@@ -519,6 +552,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` When you left, you took that understanding with you.`,
},
{
description: "Filled pages on the return, documenting the Void Emperor's nature for what lay ahead.",
id: "record",
label: "Record the Void Emperor's nature carefully",
outcome: `If the Void had sent its best, it would send something different next time.`
@@ -526,6 +560,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` pages on the return.`,
},
{
description: "Rallied the guild before relief could settle. The Void had pulled back, not retreated.",
id: "rally",
label: "Rally the guild — the work isn't done",
outcome: `There was no room for relief yet. The Void had pulled back, but pulling back`
@@ -553,6 +588,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{
choices: [
{
description: "Turned their back on the throne and led the guild out. Not every power needs claiming.",
id: "walk",
label: "Walk away from the throne",
outcome: `You turned your back on it and led your guild out. Not every power needs to`
@@ -560,6 +596,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` left. You thought it might have been grateful.`,
},
{
description: "Stood at the throne's foot, acknowledged its weight, then turned toward the door.",
id: "stand",
label: "Stand at its foot and make a decision",
outcome: `You did not sit. But you acknowledged it — the gravity of everything it`
@@ -567,6 +604,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` away from it and toward the door, and that was its own kind of answer.`,
},
{
description: "Declared aloud that power is held in trust — and the guild held that for a long time.",
id: "declare",
label: "Declare that power is held in trust",
outcome: `The throne hummed louder, then quieter. You weren't sure if that was`
@@ -594,12 +632,14 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{
choices: [
{
description: "Asked what came before the before — accepted it had no shape yet, and moved on.",
id: "before",
label: "Ask what came before the before",
outcome: `Silence. Then: That is not a question with a shape yet. You decided to`
+ ` accept that as an answer and move forward.`,
},
{
description: "Affirmed that what was built is worth defending — the chaos agreed.",
id: "worth",
label: "Affirm that what was built is worth defending",
outcome: `Yes, said the voice. That is why it has lasted. You were not sure what to`
@@ -607,6 +647,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` sincerity it was offered.`,
},
{
description: "Stood in the chaos and felt their own solidity — specific, named, and decided.",
id: "fixed",
label: "Stand in the chaos and feel your own solidity",
outcome: `Whatever you were — guild leader, fighter, something increasingly harder to`
@@ -634,6 +675,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{
choices: [
{
description: "Stayed with a weeping scout without a word, offering presence. It was what was needed.",
id: "stay",
label: "Sit with your scout until the feeling passed",
outcome: `You stayed. There was no trick to it, no words that helped more than the`
@@ -641,6 +683,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` expression that was mostly gratitude.`,
},
{
description: "Acknowledged the scale — and found the audacity in their smallness to persist.",
id: "small",
label: "Acknowledge the scale — and your smallness",
outcome: `Big was not the same as better. The Expanse was infinite. Your guild was`
@@ -648,6 +691,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` say: we are still here. You could live with that audacity.`,
},
{
description: "Began planning immediately — and their scout looked on with fond exasperation.",
id: "plan",
label: "Begin immediately planning the next move",
outcome: `Movement was your steadiest anchor. Your scout caught you making notes and`
@@ -676,6 +720,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{
choices: [
{
description: "Left the Forge as found — wisdom in knowing what not to change.",
id: "intact",
label: "Accept the invitation; leave the Forge intact",
outcome: `The Forge continued its quiet work. You left it as you found it, not because`
@@ -683,6 +728,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` by wiser hands than yours, and wisdom lay in knowing the difference.`,
},
{
description: "Added a small notation to the blueprints, on the principle of memory.",
id: "add",
label: "Add a small note to the blueprints",
outcome: `Your addition was modest — almost invisible. A small notation in the margin`
@@ -690,6 +736,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` remember. Whether it had any effect, you never knew. You left it there anyway.`,
},
{
description: "Documented what the Forge was — strange notes, accurate ones, for whoever needed them.",
id: "write",
label: "Write down what you observed, for others",
outcome: `Documentation felt inadequate for what the Forge was. You did it anyway. The`
@@ -718,6 +765,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{
choices: [
{
description: "Found it comforting. The stars persisted; so did what had been done in the time between.",
id: "comfort",
label: "Find it comforting — the universe persists",
outcome: `The permanence of the stars was a kind of promise. What existed before you`
@@ -725,6 +773,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` scale. You held onto this.`,
},
{
description: "Found it terrible — and turned back to their people, where the grief was real and theirs.",
id: "grief",
label: "Find it terrible — your losses are not small",
outcome: `Your guild had bled for this. The grief of it was real and specific and`
@@ -732,6 +781,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` from the stars and toward your people.`,
},
{
description: "Found it neither — stood in the moment, let it be what it was, and called that enough.",
id: "present",
label: "Find it neither — just be present",
outcome: `Sometimes a moment did not need interpretation. You stood in it. It was what`
@@ -758,6 +808,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{
choices: [
{
description: "Chose to carry the weight of all that came before — none of it unacknowledged.",
id: "weight",
label: "Carry the weight of all that came before",
outcome: `The generations that had built the world — the forgotten, the unnamed, the`
@@ -766,6 +817,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` enough.`,
},
{
description: "Chose only what could be carried: the things that were truly theirs.",
id: "chosen",
label: "Carry only what you chose",
outcome: `You could not carry everything. The weight would have stopped you where you`
@@ -773,6 +825,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` the things that would survive the carrying.`,
},
{
description: "Chose the intention not to waste what they had reached, and made it real.",
id: "waste",
label: "Carry the intention not to waste this",
outcome: `You had arrived somewhere very few had. What you did next would define what`
@@ -801,6 +854,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{
choices: [
{
description: "Said yes without hesitation. Would have done it all again. The certainty was complete.",
id: "yes",
label: "Yes — without hesitation",
outcome: `There was nothing complicated in it. The weight, the cost, the long road —`
@@ -808,6 +862,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` complete, and that was the most honest thing you had ever known.`,
},
{
description: "Said yes, though the cost was real — holding both the loss and the worth without flinching.",
id: "cost",
label: "Yes — though the cost was real",
outcome: `The acknowledgement of loss did not diminish the worth of it. Things had`
@@ -816,6 +871,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` managed.`,
},
{
description: "Said the answer was still being written, and walked forward — as they always had.",
id: "becoming",
label: "I am still becoming the answer",
outcome: `The journey had not ended. The Absolute was a chapter, not a conclusion. You`
@@ -845,6 +901,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{
choices: [
{
description: "Told the guild: we know the way. The lessons passed forward to those who came next.",
id: "know",
label: "Tell the guild: we know the way",
outcome: `The veterans who had made this choice with you nodded. The newer members`
@@ -853,6 +910,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` them. That was the real economy of prestige.`,
},
{
description: "Began again without ceremony — the work was what mattered.",
id: "work",
label: "Begin immediately, without ceremony",
outcome: `There was a kind of respect in not making a production of it. The work was`
@@ -860,6 +918,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` set to work, and your guild followed, and that was the whole of the ritual.`,
},
{
description: "Took one day. The guild rested, healed, and said things urgency hadn't left room for.",
id: "rest",
label: "Take a single day to rest before restarting",
outcome: `One day. You had earned it, and so had they. The guild rested, and healed,`
@@ -891,6 +950,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{
choices: [
{
description: "Spoke honestly without preparation — the guild believed it, and that was the whole of it.",
id: "speak",
label: "Speak to the guild about why you keep going",
outcome: `You hadn't planned to say anything, and what you said wasn't polished. But`
@@ -898,6 +958,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` good way — the way of people deciding to believe in something together.`,
},
{
description: "Let the gathering speak for itself, and was grateful.",
id: "listen",
label: "Let the gathering speak for itself",
outcome: `Sometimes leadership was knowing when not to speak. The guild had found its`
@@ -905,6 +966,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` grateful.`,
},
{
description: "Committed the warmth and laughter to memory carefully, for the difficult nights ahead.",
id: "store",
label: "Commit the moment to memory, for hard times",
outcome: `There would be difficult nights later. There always were. You stored this one`
@@ -935,12 +997,14 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{
choices: [
{
description: "Accepted the strangeness and began. The discomfort was proof of somewhere genuinely new.",
id: "begin",
label: "Accept the strangeness and begin",
outcome: `The unfamiliarity was not your enemy. It was proof that you were somewhere`
+ ` genuinely new. You held that discomfort lightly and took the first step.`,
},
{
description: "Sat with what was released before turning forward — loss and choice are not incompatible.",
id: "grieve",
label: "Sit with what was released before moving on",
outcome: `Loss and choice were not incompatible. You had chosen to release, and what`
@@ -948,6 +1012,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` turning forward was not weakness. It was honesty.`,
},
{
description: "Found the shape of the new pattern immediately. The guild felt steadier for it.",
id: "pattern",
label: "Find the shape of the new pattern immediately",
outcome: `Your mind moved the way it always had, already mapping the new terrain. The`
@@ -977,6 +1042,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{
choices: [
{
description: "Acknowledged what was given as much as what was earned. No path here was walked alone.",
id: "given",
label: "Acknowledge what was given as much as earned",
outcome: `You had not walked this road alone. Every person who had followed you, every`
@@ -985,6 +1051,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` mattered.`,
},
{
description: "Looked forward to what this made possible, and felt excitement returning.",
id: "forward",
label: "Look forward to what this makes possible",
outcome: `The horizon had not disappeared. It had moved — further, broader, stranger.`
@@ -992,6 +1059,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
+ ` looked at the new horizon and felt something you had almost forgotten: excitement.`,
},
{
description: "Let the weight of what they had become settle before the next step. Presence as power.",
id: "be",
label: "Simply be what you have become, for now",
outcome: `Not every threshold needed to be rushed past. You were here. You were this.`
+699 -17
View File
File diff suppressed because it is too large Load Diff