18 Commits

Author SHA1 Message Date
hikari c3e85c3768 feat: persist exploration zone selection in sessionStorage
CI / Lint, Build & Test (pull_request) Successful in 1m16s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m17s
Applies the same sticky-zone pattern as the boss and quest panels.
The handleZoneSelect wrapper already existed — it just needed to write
to sessionStorage alongside updating state, and the useState initialiser
needed to read from sessionStorage on mount.
2026-03-09 22:15:38 -07:00
hikari 2c16736bdf fix: use item name as key for equipped items list
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m4s
CI / Lint, Build & Test (pull_request) Successful in 1m8s
item.type only has three possible values (weapon/armour/trinket).
Using it as a React key is safe in practice because the equipment system
enforces one item per slot, but item.name is a stable, semantically
correct unique identifier that does not rely on that invariant.
2026-03-09 22:12:42 -07:00
hikari 0dc572c6fa fix: use stable unique keys for quest reward list
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m3s
CI / Lint, Build & Test (pull_request) Successful in 1m8s
The previous key `${reward.type}-${amount ?? ""}` collapsed to
"adventurer-" for every adventurer-unlock reward (which carries no
amount), producing duplicate-key React warnings on every render tick.
Because console.error is forwarded to the backend telemetry service,
this caused continuous email alerts.

The key now uses targetId (present on adventurer and upgrade rewards)
first, falls back to amount (present on gold/essence/crystal rewards),
and uses the map index only as a last resort.
2026-03-09 22:07:36 -07:00
hikari 9fcc5bb836 feat: persist zone selection across panel navigation in sessionStorage
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m2s
CI / Lint, Build & Test (pull_request) Successful in 1m7s
Both the boss panel and the quest panel now read their active zone from
sessionStorage on mount and write back to it whenever the user selects
a new zone. The stored selections are cleared automatically when the
session ends, and fall back to verdant_vale when no stored value exists.

Closes #36
2026-03-09 22:02:56 -07:00
hikari fc93efd245 feat: persist adventure multiplier selection in localStorage
Reads the saved batch-size preference on mount and writes it back to
localStorage on every selection change, so the chosen multiplier
survives page refreshes. Falls back to 1 when no stored value is found
or the value is unrecognisable.

Closes #35
2026-03-09 22:02:52 -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 4975 additions and 2078 deletions
+35
View File
@@ -7,6 +7,41 @@
2. `pnpm build` — all packages build cleanly 2. `pnpm build` — all packages build cleanly
3. `pnpm test` — all tests pass with 100% coverage on `apps/api` and `packages/types` 3. `pnpm test` — all tests pass with 100% coverage on `apps/api` and `packages/types`
## Art Assets
Game art is generated via the Gemini API (`gemini-3-pro-image-preview`, ~$0.134/image at 1K resolution) and hosted on the CDN at `https://cdn.nhcarrigan.com/elysium/`.
### Process
1. Generate images with `curl` to `https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent?key=<API_KEY>`, requesting soft-shaded anime style
2. Save responses to `/home/naomi/code/naomi/elysium/img/<category>/<id>.jpg`
3. Upload to R2 with: `AWS_ACCESS_KEY_ID=dd0a3d73969143ada84d50f8940cc5e2 AWS_SECRET_ACCESS_KEY=f73e9907da1b2297e93e17f786d6446d33d4ac60e185879578a0d5020899b18e aws s3 sync img/ s3://nhcarrigan-cdn/elysium/ --endpoint-url https://751c386661d378cc032093493cfb0869.r2.cloudflarestorage.com`
4. Delete the local `img/` directory before committing (images live on CDN only)
### CDN URL Helper
`apps/web/src/utils/cdn.ts` exports `cdnImage(folder, id)``https://cdn.nhcarrigan.com/elysium/<folder>/<id>.jpg`
### Directory → Category Mapping
| Game entity | CDN folder |
|---|---|
| Zones | `zones` |
| Bosses | `bosses` |
| Quests | `quests` |
| Adventurers | `adventurers` |
| Companions | `companions` |
| Equipment | `equipment` |
| Upgrades | `upgrades` |
| Prestige upgrades | `prestige-upgrades` |
| Transcendence upgrades | `transcendence-upgrades` |
| Achievements | `achievements` |
| Explorations | `explorations` |
| Materials | `materials` |
| Recipes | `recipes` |
| Story chapter banners | `story-chapters` |
### API Rate Limits
- 250 images/day per API key — use a second key if quota is hit
- Free-tier keys cannot use `gemini-3-pro-image-preview`; key must be on a billing-linked project
## About Page ## About Page
The About page (`apps/web/src/components/game/aboutPanel.tsx`) contains a **How to Play** guide that should be kept up to date as new features are added to the game. When implementing new game systems, zones, mechanics, or significant UI features, update the `HOW_TO_PLAY` array in `aboutPanel.tsx` to include a description of the new feature. The About page (`apps/web/src/components/game/aboutPanel.tsx`) contains a **How to Play** guide that should be kept up to date as new features are added to the game. When implementing new game systems, zones, mechanics, or significant UI features, update the `HOW_TO_PLAY` array in `aboutPanel.tsx` to include a description of the new feature.
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/api", "name": "@elysium/api",
"version": "0.1.0", "version": "0.1.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
@@ -15,6 +15,7 @@
"dependencies": { "dependencies": {
"@elysium/types": "workspace:*", "@elysium/types": "workspace:*",
"@hono/node-server": "1.13.7", "@hono/node-server": "1.13.7",
"@nhcarrigan/logger": "1.1.1",
"@prisma/client": "6.5.0", "@prisma/client": "6.5.0",
"hono": "4.7.4", "hono": "4.7.4",
"prisma": "6.5.0" "prisma": "6.5.0"
+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_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token"
DISCORD_GUILD_ID="op://Environment Variables - Naomi/Elysium/discord guild id" DISCORD_GUILD_ID="op://Environment Variables - Naomi/Elysium/discord guild id"
DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id" DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id"
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
+2
View File
@@ -76,6 +76,8 @@ const initialGameState = (
achievements: structuredClone(defaultAchievements), achievements: structuredClone(defaultAchievements),
adventurers: structuredClone(defaultAdventurers), adventurers: structuredClone(defaultAdventurers),
apotheosis: { ...initialApotheosis }, apotheosis: { ...initialApotheosis },
autoBoss: false,
autoQuest: false,
baseClickPower: 1, baseClickPower: 1,
bosses: structuredClone(defaultBosses), bosses: structuredClone(defaultBosses),
companions: { activeCompanionId: null, unlockedCompanionIds: [] }, companions: { activeCompanionId: null, unlockedCompanionIds: [] },
+27 -5
View File
@@ -7,22 +7,24 @@
import { serve } from "@hono/node-server"; import { serve } from "@hono/node-server";
import { Hono } from "hono"; import { Hono } from "hono";
import { cors } from "hono/cors"; import { cors } from "hono/cors";
import { logger } from "hono/logger"; import { logger as honoLogger } from "hono/logger";
import { aboutRouter } from "./routes/about.js"; import { aboutRouter } from "./routes/about.js";
import { apotheosisRouter } from "./routes/apotheosis.js"; import { apotheosisRouter } from "./routes/apotheosis.js";
import { authRouter } from "./routes/auth.js"; import { authRouter } from "./routes/auth.js";
import { bossRouter } from "./routes/boss.js"; import { bossRouter } from "./routes/boss.js";
import { craftRouter } from "./routes/craft.js"; import { craftRouter } from "./routes/craft.js";
import { exploreRouter } from "./routes/explore.js"; import { exploreRouter } from "./routes/explore.js";
import { frontendRouter } from "./routes/frontend.js";
import { gameRouter } from "./routes/game.js"; import { gameRouter } from "./routes/game.js";
import { leaderboardRouter } from "./routes/leaderboards.js"; import { leaderboardRouter } from "./routes/leaderboards.js";
import { prestigeRouter } from "./routes/prestige.js"; import { prestigeRouter } from "./routes/prestige.js";
import { profileRouter } from "./routes/profile.js"; import { profileRouter } from "./routes/profile.js";
import { transcendenceRouter } from "./routes/transcendence.js"; import { transcendenceRouter } from "./routes/transcendence.js";
import { logger } from "./services/logger.js";
const app = new Hono(); const app = new Hono();
app.use("*", logger()); app.use("*", honoLogger());
app.use( app.use(
"*", "*",
cors({ cors({
@@ -33,6 +35,7 @@ app.use(
); );
app.route("/about", aboutRouter); app.route("/about", aboutRouter);
app.route("/fe", frontendRouter);
app.route("/auth", authRouter); app.route("/auth", authRouter);
app.route("/game", gameRouter); app.route("/game", gameRouter);
app.route("/boss", bossRouter); app.route("/boss", bossRouter);
@@ -48,8 +51,27 @@ app.get("/health", (context) => {
return context.json({ status: "ok" }); return context.json({ status: "ok" });
}); });
app.onError((error, context) => {
void logger.error(
"hono_unhandled_error",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
});
const port = Number(process.env.PORT ?? 3001); const port = Number(process.env.PORT ?? 3001);
serve({ fetch: app.fetch, port: port }, () => { try {
process.stdout.write(`Elysium API running on port ${String(port)}\n`); serve({ fetch: app.fetch, port: port }, () => {
}); process.stdout.write(`Elysium API running on port ${String(port)}\n`);
});
} catch (error) {
void logger.error(
"server_startup",
error instanceof Error
? error
: new Error(String(error)),
);
}
+8 -1
View File
@@ -6,6 +6,7 @@
*/ */
import { verifyToken } from "../services/jwt.js"; import { verifyToken } from "../services/jwt.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js"; import type { HonoEnvironment } from "../types/hono.js";
import type { MiddlewareHandler } from "hono"; import type { MiddlewareHandler } from "hono";
@@ -33,7 +34,13 @@ export const authMiddleware: MiddlewareHandler<HonoEnvironment> = async(
try { try {
const payload = verifyToken(token); const payload = verifyToken(token);
context.set("discordId", payload.discordId); context.set("discordId", payload.discordId);
} catch { } catch (error) {
void logger.error(
"auth_middleware",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Invalid or expired token" }, 401); return context.json({ error: "Invalid or expired token" }, 401);
} }
+19 -6
View File
@@ -7,6 +7,7 @@
/* eslint-disable stylistic/max-len -- URL cannot be shortened */ /* eslint-disable stylistic/max-len -- URL cannot be shortened */
/* eslint-disable require-atomic-updates -- Simple cache; race condition is acceptable */ /* eslint-disable require-atomic-updates -- Simple cache; race condition is acceptable */
import { Hono } from "hono"; import { Hono } from "hono";
import { logger } from "../services/logger.js";
import type { AboutResponse, GiteaRelease } from "@elysium/types"; import type { AboutResponse, GiteaRelease } from "@elysium/types";
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
@@ -46,12 +47,24 @@ const fetchReleases = async(): Promise<Array<GiteaRelease>> => {
const aboutRouter = new Hono(); const aboutRouter = new Hono();
aboutRouter.get("/", async(context) => { aboutRouter.get("/", async(context) => {
const releases = await fetchReleases(); try {
const body: AboutResponse = { const releases = await fetchReleases();
apiVersion, const body: AboutResponse = {
releases, apiVersion,
}; releases,
return context.json(body); };
return context.json(body);
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 9 -- @preserve */
} catch (error) {
void logger.error(
"about",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
}); });
export { aboutRouter }; export { aboutRouter };
+97 -82
View File
@@ -5,6 +5,8 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable max-lines-per-function -- Route handler requires many steps */ /* eslint-disable max-lines-per-function -- Route handler requires many steps */
/* eslint-disable max-statements -- Route handler requires many statements */
/* eslint-disable stylistic/max-len -- Description string cannot be shortened */ /* eslint-disable stylistic/max-len -- Description string cannot be shortened */
import { Hono } from "hono"; import { Hono } from "hono";
import { prisma } from "../db/client.js"; import { prisma } from "../db/client.js";
@@ -13,6 +15,7 @@ import {
buildPostApotheosisState, buildPostApotheosisState,
isEligibleForApotheosis, isEligibleForApotheosis,
} from "../services/apotheosis.js"; } from "../services/apotheosis.js";
import { logger } from "../services/logger.js";
import { import {
grantApotheosisRole, grantApotheosisRole,
postMilestoneWebhook, postMilestoneWebhook,
@@ -25,94 +28,106 @@ const apotheosisRouter = new Hono<HonoEnvironment>();
apotheosisRouter.use("*", authMiddleware); apotheosisRouter.use("*", authMiddleware);
apotheosisRouter.post("/", async(context) => { apotheosisRouter.post("/", async(context) => {
const discordId = context.get("discordId"); try {
const discordId = context.get("discordId");
const record = await prisma.gameState.findUnique({ where: { discordId } }); const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) { if (!record) {
return context.json({ error: "No save found" }, 404); return context.json({ error: "No save found" }, 404);
} }
const rawState: unknown = record.state; const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState; const state = rawState as GameState;
if (!isEligibleForApotheosis(state)) { if (!isEligibleForApotheosis(state)) {
return context.json( return context.json(
{ {
error: error:
"Not eligible for Apotheosis — purchase all Transcendence upgrades first", "Not eligible for Apotheosis — purchase all Transcendence upgrades first",
}, },
400, 400,
); );
} }
// Capture current-run stats before the nuclear reset // Capture current-run stats before the nuclear reset
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 9 -- @preserve */
const runBossesDefeated = state.bosses.filter((b) => {
return b.status === "defeated";
}).length;
const runQuestsCompleted = state.quests.filter((q) => {
return q.status === "completed";
}).length;
const runAdventurersRecruited = state.adventurers.reduce((sum, a) => {
return sum + a.count;
}, 0);
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
const runAchievementsUnlocked = state.achievements.filter((a) => {
return a.unlockedAt !== null;
}).length;
const { updatedState, updatedApotheosisData } = buildPostApotheosisState(
state,
state.player.characterName,
);
const now = Date.now();
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: updatedState as object, updatedAt: now },
where: { discordId },
});
await prisma.player.update({
data: {
characterName: state.player.characterName,
lastSavedAt: now,
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
lifetimeBossesDefeated: { increment: runBossesDefeated },
lifetimeClicks: { increment: state.player.totalClicks },
// Accumulate into lifetime totals
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
totalClicks: 0,
// Reset current-run counters
totalGoldEarned: 0,
},
where: { discordId },
});
void grantApotheosisRole(discordId);
void postMilestoneWebhook(discordId, "apotheosis", {
apotheosis: updatedApotheosisData.count,
prestige: updatedState.prestige.count,
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */ /* v8 ignore next 9 -- @preserve */
transcendence: updatedState.transcendence?.count ?? 0, const runBossesDefeated = state.bosses.filter((b) => {
}); return b.status === "defeated";
}).length;
const runQuestsCompleted = state.quests.filter((q) => {
return q.status === "completed";
}).length;
const runAdventurersRecruited = state.adventurers.reduce((sum, a) => {
return sum + a.count;
}, 0);
return context.json({ apotheosisCount: updatedApotheosisData.count }); // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
const runAchievementsUnlocked = state.achievements.filter((a) => {
return a.unlockedAt !== null;
}).length;
const { updatedState, updatedApotheosisData } = buildPostApotheosisState(
state,
state.player.characterName,
);
const now = Date.now();
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: updatedState as object, updatedAt: now },
where: { discordId },
});
await prisma.player.update({
data: {
characterName: state.player.characterName,
lastSavedAt: now,
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
lifetimeBossesDefeated: { increment: runBossesDefeated },
lifetimeClicks: { increment: state.player.totalClicks },
// Accumulate into lifetime totals
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
totalClicks: 0,
// Reset current-run counters
totalGoldEarned: 0,
},
where: { discordId },
});
const apotheosisCount = updatedApotheosisData.count;
void logger.metric("apotheosis", 1, { apotheosisCount, discordId });
void grantApotheosisRole(discordId);
void postMilestoneWebhook(discordId, "apotheosis", {
apotheosis: updatedApotheosisData.count,
prestige: updatedState.prestige.count,
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
transcendence: updatedState.transcendence?.count ?? 0,
});
return context.json({ apotheosisCount: updatedApotheosisData.count });
} catch (error) {
void logger.error(
"apotheosis",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
}); });
export { apotheosisRouter }; export { apotheosisRouter };
+12 -1
View File
@@ -15,6 +15,7 @@ import {
fetchDiscordUser, fetchDiscordUser,
} from "../services/discord.js"; } from "../services/discord.js";
import { signToken } from "../services/jwt.js"; import { signToken } from "../services/jwt.js";
import { logger } from "../services/logger.js";
import type { Player } from "@elysium/types"; import type { Player } from "@elysium/types";
const authRouter = new Hono(); const authRouter = new Hono();
@@ -92,6 +93,8 @@ authRouter.get("/callback", async(context) => {
}); });
const jwtToken = signToken(player.discordId); const jwtToken = signToken(player.discordId);
void logger.log("info", `New player registered: ${player.discordId}`);
void logger.metric("user_registered", 1, { discordId: player.discordId });
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */ /* v8 ignore next -- @preserve */
@@ -111,6 +114,8 @@ authRouter.get("/callback", async(context) => {
}); });
const jwtToken = signToken(updated.discordId); const jwtToken = signToken(updated.discordId);
void logger.log("info", `Player logged in: ${updated.discordId}`);
void logger.metric("user_login", 1, { discordId: updated.discordId });
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */ /* v8 ignore next -- @preserve */
@@ -118,7 +123,13 @@ authRouter.get("/callback", async(context) => {
return context.redirect( return context.redirect(
`${clientUrl}/auth/callback?token=${jwtToken}&isNew=false`, `${clientUrl}/auth/callback?token=${jwtToken}&isNew=false`,
); );
} catch { } catch (error) {
void logger.error(
"auth_callback",
error instanceof Error
? error
: new Error(String(error)),
);
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */ /* v8 ignore next -- @preserve */
const clientUrl = process.env.CORS_ORIGIN ?? "http://localhost:5173"; const clientUrl = process.env.CORS_ORIGIN ?? "http://localhost:5173";
+253 -232
View File
@@ -8,6 +8,7 @@
/* eslint-disable max-statements -- Boss handler requires many statements */ /* eslint-disable max-statements -- Boss handler requires many statements */
/* eslint-disable complexity -- Boss handler has inherent complexity */ /* eslint-disable complexity -- Boss handler has inherent complexity */
/* eslint-disable stylistic/max-len -- Long lines in combat logic */ /* eslint-disable stylistic/max-len -- Long lines in combat logic */
/* eslint-disable max-lines -- Boss route with full combat logic and helpers exceeds line limit */
import { import {
computeSetBonuses, computeSetBonuses,
getActiveCompanionBonus, getActiveCompanionBonus,
@@ -20,6 +21,7 @@ import { defaultEquipmentSets } from "../data/equipmentSets.js";
import { prisma } from "../db/client.js"; import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js"; import { authMiddleware } from "../middleware/auth.js";
import { updateChallengeProgress } from "../services/dailyChallenges.js"; import { updateChallengeProgress } from "../services/dailyChallenges.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js"; import type { HonoEnvironment } from "../types/hono.js";
const bossRouter = new Hono<HonoEnvironment>(); const bossRouter = new Hono<HonoEnvironment>();
@@ -121,254 +123,273 @@ const calculatePartyStats = (
}; };
bossRouter.post("/challenge", async(context) => { bossRouter.post("/challenge", async(context) => {
const discordId = context.get("discordId"); try {
const body = await context.req.json<{ bossId: string }>(); const discordId = context.get("discordId");
const body = await context.req.json<{ bossId: string }>();
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!body.bossId) { if (!body.bossId) {
return context.json({ error: "Invalid request body" }, 400); return context.json({ error: "Invalid request body" }, 400);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState;
const boss = state.bosses.find((b) => {
return b.id === body.bossId;
});
if (!boss) {
return context.json({ error: "Boss not found" }, 404);
}
if (boss.status !== "available" && boss.status !== "in_progress") {
return context.json({ error: "Boss is not currently available" }, 400);
}
if (boss.prestigeRequirement > state.prestige.count) {
return context.json({ error: "Prestige requirement not met" }, 403);
}
const { partyDPS, partyMaxHp } = calculatePartyStats(state);
if (
partyDPS === 0
|| partyMaxHp === 0
|| !Number.isFinite(partyDPS)
|| !Number.isFinite(partyMaxHp)
) {
return context.json(
{ error: "Your party has no adventurers ready to fight" },
400,
);
}
const bossHpBefore = boss.currentHp;
const bossDPS = boss.damagePerSecond;
const timeToKillBoss = bossHpBefore / partyDPS;
const timeToKillParty = partyMaxHp / bossDPS;
const won = timeToKillBoss <= timeToKillParty;
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
let partyHpRemaining: number;
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
let bossHpAtBattleEnd: number;
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
let bossUpdatedHp: number;
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
let rewards: BossChallengeResponse["rewards"];
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
let casualties: BossChallengeResponse["casualties"];
if (won) {
bossHpAtBattleEnd = 0;
bossUpdatedHp = 0;
const bossDamageDealt = bossDPS * timeToKillBoss;
partyHpRemaining = Math.max(0, partyMaxHp - bossDamageDealt);
boss.status = "defeated";
boss.currentHp = 0;
state.resources.gold = state.resources.gold + boss.goldReward;
state.resources.essence = state.resources.essence + boss.essenceReward;
state.resources.crystals = state.resources.crystals + boss.crystalReward;
state.player.totalGoldEarned = state.player.totalGoldEarned + boss.goldReward;
for (const upgradeId of boss.upgradeRewards) {
const upgrade = state.upgrades.find((u) => {
return u.id === upgradeId;
});
if (upgrade) {
upgrade.unlocked = true;
}
} }
// Grant equipment rewards — auto-equip if the slot is currently empty const record = await prisma.gameState.findUnique({ where: { discordId } });
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 14 -- @preserve */
for (const equipmentId of boss.equipmentRewards) {
const equipment = state.equipment.find((item) => {
return item.id === equipmentId;
});
if (equipment) {
equipment.owned = true;
const slotAlreadyEquipped = state.equipment.some((item) => { if (!record) {
return item.type === equipment.type && item.equipped; return context.json({ error: "No save found" }, 404);
}
const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState;
const boss = state.bosses.find((b) => {
return b.id === body.bossId;
});
if (!boss) {
return context.json({ error: "Boss not found" }, 404);
}
if (boss.status !== "available" && boss.status !== "in_progress") {
return context.json({ error: "Boss is not currently available" }, 400);
}
if (boss.prestigeRequirement > state.prestige.count) {
return context.json({ error: "Prestige requirement not met" }, 403);
}
const { partyDPS, partyMaxHp } = calculatePartyStats(state);
if (
partyDPS === 0
|| partyMaxHp === 0
|| !Number.isFinite(partyDPS)
|| !Number.isFinite(partyMaxHp)
) {
return context.json(
{ error: "Your party has no adventurers ready to fight" },
400,
);
}
const bossHpBefore = boss.currentHp;
const bossDPS = boss.damagePerSecond;
const timeToKillBoss = bossHpBefore / partyDPS;
const timeToKillParty = partyMaxHp / bossDPS;
const won = timeToKillBoss <= timeToKillParty;
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
let partyHpRemaining: number;
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
let bossHpAtBattleEnd: number;
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
let bossUpdatedHp: number;
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
let rewards: BossChallengeResponse["rewards"];
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
let casualties: BossChallengeResponse["casualties"];
if (won) {
bossHpAtBattleEnd = 0;
bossUpdatedHp = 0;
const bossDamageDealt = bossDPS * timeToKillBoss;
partyHpRemaining = Math.max(0, partyMaxHp - bossDamageDealt);
boss.status = "defeated";
boss.currentHp = 0;
state.resources.gold = state.resources.gold + boss.goldReward;
state.resources.essence = state.resources.essence + boss.essenceReward;
state.resources.crystals = state.resources.crystals + boss.crystalReward;
state.player.totalGoldEarned = state.player.totalGoldEarned + boss.goldReward;
for (const upgradeId of boss.upgradeRewards) {
const upgrade = state.upgrades.find((u) => {
return u.id === upgradeId;
}); });
if (!slotAlreadyEquipped) { if (upgrade) {
equipment.equipped = true; upgrade.unlocked = true;
}
}
// Grant equipment rewards — auto-equip if the slot is currently empty
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 14 -- @preserve */
for (const equipmentId of boss.equipmentRewards) {
const equipment = state.equipment.find((item) => {
return item.id === equipmentId;
});
if (equipment) {
equipment.owned = true;
const slotAlreadyEquipped = state.equipment.some((item) => {
return item.type === equipment.type && item.equipped;
});
if (!slotAlreadyEquipped) {
equipment.equipped = true;
}
}
}
// Unlock next boss in the same zone (zone-based sequential progression)
const zoneBosses = state.bosses.filter((b) => {
return b.zoneId === boss.zoneId;
});
const zoneIndex = zoneBosses.findIndex((b) => {
return b.id === body.bossId;
});
const [ nextZoneBoss ] = zoneBosses.slice(zoneIndex + 1);
if (
nextZoneBoss
&& nextZoneBoss.prestigeRequirement <= state.prestige.count
) {
const nextBossInState = state.bosses.find((b) => {
return b.id === nextZoneBoss.id;
});
if (nextBossInState) {
nextBossInState.status = "available";
}
}
/*
* Unlock any zone whose unlock conditions are now both satisfied
* (final boss defeated AND final quest completed)
*/
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
for (const zone of state.zones) {
if (zone.status === "unlocked") {
continue;
}
if (zone.unlockBossId !== body.bossId) {
continue;
}
// Boss condition just became satisfied — check the quest condition too
const questSatisfied
= zone.unlockQuestId === null
|| state.quests.some((q) => {
return q.id === zone.unlockQuestId && q.status === "completed";
});
if (!questSatisfied) {
continue;
}
zone.status = "unlocked";
const updatedZoneBosses = state.bosses.filter((b) => {
return b.zoneId === zone.id;
});
const [ firstUpdatedBoss ] = updatedZoneBosses;
if (
firstUpdatedBoss
&& firstUpdatedBoss.prestigeRequirement <= state.prestige.count
) {
firstUpdatedBoss.status = "available";
}
}
// Update daily boss challenge progress
if (state.dailyChallenges) {
const { crystalsAwarded, updatedChallenges } = updateChallengeProgress(
state.dailyChallenges,
"bossesDefeated",
1,
);
state.dailyChallenges = updatedChallenges;
state.resources.crystals = state.resources.crystals + crystalsAwarded;
}
// First-kill bounty — 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 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 = {
bountyRunestones: bountyRunestones,
crystals: boss.crystalReward,
equipmentIds: boss.equipmentRewards,
essence: boss.essenceReward,
gold: boss.goldReward,
upgradeIds: boss.upgradeRewards,
};
} else {
const partyDamageDealt = partyDPS * timeToKillParty;
bossHpAtBattleEnd = Math.max(0, bossHpBefore - partyDamageDealt);
bossUpdatedHp = boss.maxHp;
partyHpRemaining = 0;
boss.status = "available";
boss.currentHp = boss.maxHp;
// How close was the party to winning? (0 = hopeless, 1 = nearly won)
const victoryProgress = Math.min(1, timeToKillParty / timeToKillBoss);
// Casualty rate scales from ~0% (nearly won) to 60% (completely outmatched)
const casualtyFraction = (1 - victoryProgress) * 0.6;
casualties = [];
for (const adventurer of state.adventurers) {
if (adventurer.count === 0) {
continue;
}
const killed = Math.floor(adventurer.count * casualtyFraction);
if (killed > 0) {
adventurer.count = Math.max(1, adventurer.count - killed);
casualties.push({ adventurerId: adventurer.id, killed: killed });
} }
} }
} }
// Unlock next boss in the same zone (zone-based sequential progression) const now = Date.now();
const zoneBosses = state.bosses.filter((b) => { await prisma.gameState.update({
return b.zoneId === boss.zoneId; /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
}); data: { state: state as object, updatedAt: now },
const zoneIndex = zoneBosses.findIndex((b) => { where: { discordId },
return b.id === body.bossId;
});
const [ nextZoneBoss ] = zoneBosses.slice(zoneIndex + 1);
if (
nextZoneBoss
&& nextZoneBoss.prestigeRequirement <= state.prestige.count
) {
const nextBossInState = state.bosses.find((b) => {
return b.id === nextZoneBoss.id;
});
if (nextBossInState) {
nextBossInState.status = "available";
}
}
/*
* Unlock any zone whose unlock conditions are now both satisfied
* (final boss defeated AND final quest completed)
*/
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
for (const zone of state.zones) {
if (zone.status === "unlocked") {
continue;
}
if (zone.unlockBossId !== body.bossId) {
continue;
}
// Boss condition just became satisfied — check the quest condition too
const questSatisfied
= zone.unlockQuestId === null
|| state.quests.some((q) => {
return q.id === zone.unlockQuestId && q.status === "completed";
});
if (!questSatisfied) {
continue;
}
zone.status = "unlocked";
const updatedZoneBosses = state.bosses.filter((b) => {
return b.zoneId === zone.id;
});
const [ firstUpdatedBoss ] = updatedZoneBosses;
if (
firstUpdatedBoss
&& firstUpdatedBoss.prestigeRequirement <= state.prestige.count
) {
firstUpdatedBoss.status = "available";
}
}
// Update daily boss challenge progress
if (state.dailyChallenges) {
const { crystalsAwarded, updatedChallenges } = updateChallengeProgress(
state.dailyChallenges,
"bossesDefeated",
1,
);
state.dailyChallenges = updatedChallenges;
state.resources.crystals = state.resources.crystals + crystalsAwarded;
}
// First-kill bounty — look up authoritative bounty from static data
const staticBoss = defaultBosses.find((b) => {
return b.id === body.bossId;
}); });
// eslint-disable-next-line capitalized-comments -- v8 ignore const { bossId } = body;
/* v8 ignore next -- @preserve */ void logger.metric("boss_challenge", 1, { bossId, discordId, won });
const bountyRunestones = staticBoss?.bountyRunestones ?? 0;
state.prestige.runestones = state.prestige.runestones + bountyRunestones;
rewards = { const bossMaxHp = boss.maxHp;
bountyRunestones: bountyRunestones, const bossNewHp = bossUpdatedHp;
crystals: boss.crystalReward, const response: BossChallengeResponse = {
equipmentIds: boss.equipmentRewards, bossDPS,
essence: boss.essenceReward, bossHpAtBattleEnd,
gold: boss.goldReward, bossHpBefore,
upgradeIds: boss.upgradeRewards, bossMaxHp,
bossNewHp,
partyDPS,
partyHpRemaining,
partyMaxHp,
won,
}; };
} else { if (rewards !== undefined) {
const partyDamageDealt = partyDPS * timeToKillParty; response.rewards = rewards;
bossHpAtBattleEnd = Math.max(0, bossHpBefore - partyDamageDealt); }
bossUpdatedHp = boss.maxHp; if (casualties !== undefined) {
partyHpRemaining = 0; response.casualties = casualties;
boss.status = "available";
boss.currentHp = boss.maxHp;
// How close was the party to winning? (0 = hopeless, 1 = nearly won)
const victoryProgress = Math.min(1, timeToKillParty / timeToKillBoss);
// Casualty rate scales from ~0% (nearly won) to 60% (completely outmatched)
const casualtyFraction = (1 - victoryProgress) * 0.6;
casualties = [];
for (const adventurer of state.adventurers) {
if (adventurer.count === 0) {
continue;
}
const killed = Math.floor(adventurer.count * casualtyFraction);
if (killed > 0) {
adventurer.count = Math.max(1, adventurer.count - killed);
casualties.push({ adventurerId: adventurer.id, killed: killed });
}
} }
}
const now = Date.now(); return context.json(response);
await prisma.gameState.update({ } catch (error) {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ void logger.error(
data: { state: state as object, updatedAt: now }, "boss_challenge",
where: { discordId }, error instanceof Error
}); ? error
: new Error(String(error)),
const bossMaxHp = boss.maxHp; );
const bossNewHp = bossUpdatedHp; return context.json({ error: "Internal server error" }, 500);
const response: BossChallengeResponse = {
bossDPS,
bossHpAtBattleEnd,
bossHpBefore,
bossMaxHp,
bossNewHp,
partyDPS,
partyHpRemaining,
partyMaxHp,
won,
};
if (rewards !== undefined) {
response.rewards = rewards;
} }
if (casualties !== undefined) {
response.casualties = casualties;
}
return context.json(response);
}); });
export { bossRouter }; export { bossRouter };
+95 -82
View File
@@ -11,6 +11,7 @@ import { Hono } from "hono";
import { defaultRecipes } from "../data/recipes.js"; import { defaultRecipes } from "../data/recipes.js";
import { prisma } from "../db/client.js"; import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js"; import { authMiddleware } from "../middleware/auth.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js"; import type { HonoEnvironment } from "../types/hono.js";
import type { import type {
CraftRecipeRequest, CraftRecipeRequest,
@@ -63,94 +64,106 @@ const recomputeCraftedMultipliers = (
}; };
craftRouter.post("/", async(context) => { craftRouter.post("/", async(context) => {
const discordId = context.get("discordId"); try {
const body = await context.req.json<CraftRecipeRequest>(); const discordId = context.get("discordId");
const body = await context.req.json<CraftRecipeRequest>();
const { recipeId } = body; const { recipeId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!recipeId) { if (!recipeId) {
return context.json({ error: "recipeId is required" }, 400); return context.json({ error: "recipeId is required" }, 400);
}
const recipe = defaultRecipes.find((r) => {
return r.id === recipeId;
});
if (!recipe) {
return context.json({ error: "Unknown recipe" }, 404);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState;
if (!state.exploration) {
return context.json({ error: "No exploration state found" }, 400);
}
if (state.exploration.craftedRecipeIds.includes(recipeId)) {
return context.json({ error: "Recipe already crafted" }, 400);
}
// Verify the player has all required materials
for (const requirement of recipe.requiredMaterials) {
const material = state.exploration.materials.find((m) => {
return m.materialId === requirement.materialId;
});
const quantity = material?.quantity ?? 0;
if (quantity < requirement.quantity) {
return context.json(
{
error: `Not enough ${requirement.materialId} (need ${String(requirement.quantity)}, have ${String(quantity)})`,
},
400,
);
} }
}
// Deduct materials const recipe = defaultRecipes.find((r) => {
for (const requirement of recipe.requiredMaterials) { return r.id === recipeId;
const material = state.exploration.materials.find((m) => {
return m.materialId === requirement.materialId;
}); });
if (material) { if (!recipe) {
material.quantity = material.quantity - requirement.quantity; return context.json({ error: "Unknown recipe" }, 404);
} }
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState;
if (!state.exploration) {
return context.json({ error: "No exploration state found" }, 400);
}
if (state.exploration.craftedRecipeIds.includes(recipeId)) {
return context.json({ error: "Recipe already crafted" }, 400);
}
// Verify the player has all required materials
for (const requirement of recipe.requiredMaterials) {
const material = state.exploration.materials.find((m) => {
return m.materialId === requirement.materialId;
});
const quantity = material?.quantity ?? 0;
if (quantity < requirement.quantity) {
return context.json(
{
error: `Not enough ${requirement.materialId} (need ${String(requirement.quantity)}, have ${String(quantity)})`,
},
400,
);
}
}
// Deduct materials
for (const requirement of recipe.requiredMaterials) {
const material = state.exploration.materials.find((m) => {
return m.materialId === requirement.materialId;
});
if (material) {
material.quantity = material.quantity - requirement.quantity;
}
}
// Add recipe and recompute all multipliers from scratch
state.exploration.craftedRecipeIds.push(recipeId);
const updatedMultipliers = recomputeCraftedMultipliers(
state.exploration.craftedRecipeIds,
);
state.exploration.craftedGoldMultiplier
= updatedMultipliers.craftedGoldMultiplier;
state.exploration.craftedEssenceMultiplier
= updatedMultipliers.craftedEssenceMultiplier;
state.exploration.craftedClickMultiplier
= updatedMultipliers.craftedClickMultiplier;
state.exploration.craftedCombatMultiplier
= updatedMultipliers.craftedCombatMultiplier;
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: Date.now() },
where: { discordId },
});
void logger.metric("recipe_crafted", 1, { discordId, recipeId });
const bonusType = recipe.bonus.type;
const bonusValue = recipe.bonus.value;
const response: CraftRecipeResponse = {
bonusType,
bonusValue,
recipeId,
...updatedMultipliers,
};
return context.json(response);
} catch (error) {
void logger.error(
"craft",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
} }
// Add recipe and recompute all multipliers from scratch
state.exploration.craftedRecipeIds.push(recipeId);
const updatedMultipliers = recomputeCraftedMultipliers(
state.exploration.craftedRecipeIds,
);
state.exploration.craftedGoldMultiplier
= updatedMultipliers.craftedGoldMultiplier;
state.exploration.craftedEssenceMultiplier
= updatedMultipliers.craftedEssenceMultiplier;
state.exploration.craftedClickMultiplier
= updatedMultipliers.craftedClickMultiplier;
state.exploration.craftedCombatMultiplier
= updatedMultipliers.craftedCombatMultiplier;
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: Date.now() },
where: { discordId },
});
const bonusType = recipe.bonus.type;
const bonusValue = recipe.bonus.value;
const response: CraftRecipeResponse = {
bonusType,
bonusValue,
recipeId,
...updatedMultipliers,
};
return context.json(response);
}); });
export { craftRouter }; export { craftRouter };
+280 -254
View File
@@ -12,6 +12,7 @@ import { defaultExplorations } from "../data/explorations.js";
import { initialExploration } from "../data/initialState.js"; import { initialExploration } from "../data/initialState.js";
import { prisma } from "../db/client.js"; import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js"; import { authMiddleware } from "../middleware/auth.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js"; import type { HonoEnvironment } from "../types/hono.js";
import type { import type {
ExploreCollectEventResult, ExploreCollectEventResult,
@@ -49,280 +50,233 @@ const pickNothingMessage = (): string => {
}; };
exploreRouter.post("/start", async(context) => { exploreRouter.post("/start", async(context) => {
const discordId = context.get("discordId"); try {
const body = await context.req.json<ExploreStartRequest>(); const discordId = context.get("discordId");
const body = await context.req.json<ExploreStartRequest>();
const { areaId } = body; const { areaId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!areaId) { if (!areaId) {
return context.json({ error: "areaId is required" }, 400); return context.json({ error: "areaId is required" }, 400);
} }
const explorationArea = defaultExplorations.find((a) => { const explorationArea = defaultExplorations.find((a) => {
return a.id === areaId; return a.id === areaId;
}); });
if (!explorationArea) { if (!explorationArea) {
return context.json({ error: "Unknown exploration area" }, 404); return context.json({ error: "Unknown exploration area" }, 404);
} }
const record = await prisma.gameState.findUnique({ where: { discordId } }); const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) { if (!record) {
return context.json({ error: "No save found" }, 404); return context.json({ error: "No save found" }, 404);
} }
const rawState: unknown = record.state; const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState; const state = rawState as GameState;
// Backfill exploration state for old saves that predate this feature // Backfill exploration state for old saves that predate this feature
if (!state.exploration) { if (!state.exploration) {
state.exploration = structuredClone(initialExploration); state.exploration = structuredClone(initialExploration);
// Unlock areas for zones already unlocked in this save // Unlock areas for zones already unlocked in this save
for (const area of state.exploration.areas) { for (const area of state.exploration.areas) {
const areaData = defaultExplorations.find((areaItem) => { const areaData = defaultExplorations.find((areaItem) => {
return areaItem.id === area.id; return areaItem.id === area.id;
}); });
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */ /* v8 ignore next 3 -- @preserve */
if (!areaData) { if (!areaData) {
continue; continue;
} }
const zone = state.zones.find((z) => { const zone = state.zones.find((z) => {
return z.id === areaData.zoneId; return z.id === areaData.zoneId;
}); });
if (zone?.status === "unlocked") { if (zone?.status === "unlocked") {
area.status = "available"; area.status = "available";
}
} }
} }
}
const zone = state.zones.find((z) => { const zone = state.zones.find((z) => {
return z.id === explorationArea.zoneId; return z.id === explorationArea.zoneId;
}); });
if (!zone || zone.status !== "unlocked") { if (!zone || zone.status !== "unlocked") {
return context.json({ error: "Zone is not unlocked" }, 400); return context.json({ error: "Zone is not unlocked" }, 400);
} }
const area = state.exploration.areas.find((a) => { const area = state.exploration.areas.find((a) => {
return a.id === areaId; return a.id === areaId;
}); });
if (!area) { if (!area) {
return context.json({ error: "Exploration area not found in state" }, 404); return context.json(
} { error: "Exploration area not found in state" },
404,
);
}
const anyInProgress = state.exploration.areas.some((a) => { const anyInProgress = state.exploration.areas.some((a) => {
return a.status === "in_progress"; return a.status === "in_progress";
}); });
if (anyInProgress) { if (anyInProgress) {
return context.json( return context.json(
{ error: "An exploration is already in progress" }, { error: "An exploration is already in progress" },
400, 400,
); );
} }
if (area.status === "locked") { if (area.status === "locked") {
return context.json({ error: "Exploration area is locked" }, 400); return context.json({ error: "Exploration area is locked" }, 400);
} }
const now = Date.now(); const now = Date.now();
area.status = "in_progress"; area.status = "in_progress";
area.startedAt = now; area.startedAt = now;
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: now },
where: { discordId },
});
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
const endsAt = now + explorationArea.durationSeconds * 1000;
const response: ExploreStartResponse = {
areaId,
endsAt,
};
return context.json(response);
});
exploreRouter.post("/collect", async(context) => {
const discordId = context.get("discordId");
const body = await context.req.json<ExploreCollectRequest>();
const { areaId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!areaId) {
return context.json({ error: "areaId is required" }, 400);
}
const explorationArea = defaultExplorations.find((a) => {
return a.id === areaId;
});
if (!explorationArea) {
return context.json({ error: "Unknown exploration area" }, 404);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState;
if (!state.exploration) {
return context.json({ error: "No exploration state found" }, 400);
}
const area = state.exploration.areas.find((a) => {
return a.id === areaId;
});
if (!area) {
return context.json({ error: "Exploration area not found" }, 404);
}
if (area.status !== "in_progress") {
return context.json({ error: "Exploration is not in progress" }, 400);
}
const now = Date.now();
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const startedAt = area.startedAt ?? 0;
const durationMs = explorationArea.durationSeconds * 1000;
const expiresAt = startedAt + durationMs;
if (now < expiresAt) {
return context.json({ error: "Exploration is not yet complete" }, 400);
}
area.status = "available";
area.completedOnce = true;
// 20% chance of finding nothing
if (Math.random() < nothingProbability) {
await prisma.gameState.update({ await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: now }, data: { state: state as object, updatedAt: now },
where: { discordId }, where: { discordId },
}); });
const response: ExploreCollectResponse = { // eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
event: null, const endsAt = now + explorationArea.durationSeconds * 1000;
foundNothing: true, const response: ExploreStartResponse = {
materialsFound: [], areaId,
nothingMessage: pickNothingMessage(), endsAt,
}; };
return context.json(response); return context.json(response);
} catch (error) {
void logger.error(
"explore_start",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
} }
});
// Pick a random event exploreRouter.post("/collect", async(context) => {
const eventIndex = Math.floor(Math.random() * explorationArea.events.length); try {
const event = explorationArea.events[eventIndex]; const discordId = context.get("discordId");
// eslint-disable-next-line capitalized-comments -- v8 ignore const body = await context.req.json<ExploreCollectRequest>();
/* v8 ignore next 3 -- @preserve */
if (!event) {
return context.json({ error: "No events available" }, 500);
}
// Apply event effects and build the result summary const { areaId } = body;
let goldChange = 0; // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
let essenceChange = 0; if (!areaId) {
let materialGained: { materialId: string; quantity: number } | null = null; return context.json({ error: "areaId is required" }, 400);
}
if (event.effect.type === "gold_gain") { const explorationArea = defaultExplorations.find((a) => {
// Gold gain — amount may be undefined in edge cases return a.id === areaId;
// eslint-disable-next-line capitalized-comments -- v8 ignore });
/* v8 ignore next -- @preserve */ if (!explorationArea) {
const amount = event.effect.amount ?? 0; return context.json({ error: "Unknown exploration area" }, 404);
state.resources.gold = state.resources.gold + amount; }
state.player.totalGoldEarned = state.player.totalGoldEarned + amount;
goldChange = amount; const record = await prisma.gameState.findUnique({ where: { discordId } });
} else if (event.effect.type === "gold_loss") { if (!record) {
// Gold loss — amount may be undefined in edge cases return context.json({ error: "No save found" }, 404);
// eslint-disable-next-line capitalized-comments -- v8 ignore }
/* v8 ignore next -- @preserve */
const amount = Math.min(state.resources.gold, event.effect.amount ?? 0); const rawState: unknown = record.state;
state.resources.gold = state.resources.gold - amount; /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
goldChange = -amount; const state = rawState as GameState;
} else if (event.effect.type === "essence_gain") {
// Essence gain — amount may be undefined in edge cases if (!state.exploration) {
// eslint-disable-next-line capitalized-comments -- v8 ignore return context.json({ error: "No exploration state found" }, 400);
/* v8 ignore next -- @preserve */ }
const amount = event.effect.amount ?? 0;
state.resources.essence = state.resources.essence + amount; const area = state.exploration.areas.find((a) => {
essenceChange = amount; return a.id === areaId;
} else if (event.effect.type === "material_gain") { });
const { materialId } = event.effect; if (!area) {
return context.json({ error: "Exploration area not found" }, 404);
}
if (area.status !== "in_progress") {
return context.json({ error: "Exploration is not in progress" }, 400);
}
const now = Date.now();
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */ /* v8 ignore next -- @preserve */
const quantity = event.effect.quantity ?? 1; const startedAt = area.startedAt ?? 0;
if (materialId !== undefined && materialId !== "") { const durationMs = explorationArea.durationSeconds * 1000;
const existing = state.exploration.materials.find((m) => { const expiresAt = startedAt + durationMs;
return m.materialId === materialId;
if (now < expiresAt) {
return context.json({ error: "Exploration is not yet complete" }, 400);
}
area.status = "available";
area.completedOnce = true;
// 20% chance of finding nothing
if (Math.random() < nothingProbability) {
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: now },
where: { discordId },
}); });
if (existing) {
existing.quantity = existing.quantity + quantity; const response: ExploreCollectResponse = {
} else { event: null,
state.exploration.materials.push({ materialId, quantity }); foundNothing: true,
} materialsFound: [],
materialGained = { materialId, quantity }; nothingMessage: pickNothingMessage(),
// eslint-disable-next-line capitalized-comments -- v8 ignore };
/* v8 ignore next 13 -- @preserve */ return context.json(response);
} }
} else if (event.effect.type === "adventurer_loss") { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- exhausted all other union members above
// Adventurer loss — fraction and loop are defensive // Pick a random event
const eventIndex = Math.floor(
Math.random() * explorationArea.events.length,
);
const event = explorationArea.events[eventIndex];
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 8 -- @preserve */ /* v8 ignore next 3 -- @preserve */
const fraction = event.effect.fraction ?? 0.05; if (!event) {
for (const adventurer of state.adventurers) { return context.json({ error: "No events available" }, 500);
const lost = Math.floor(adventurer.count * fraction);
if (lost > 0) {
adventurer.count = Math.max(0, adventurer.count - lost);
}
} }
}
// eslint-disable-next-line capitalized-comments -- v8 ignore // Apply event effects and build the result summary
/* v8 ignore next 8 -- @preserve */ let goldChange = 0;
let adventurerLostCount = 0; let essenceChange = 0;
if (event.effect.type === "adventurer_loss") { let materialGained: { materialId: string; quantity: number } | null = null;
const fraction = event.effect.fraction ?? 0.05;
for (const adv of state.adventurers) {
const lost = Math.floor(adv.count * fraction);
adventurerLostCount = adventurerLostCount + lost;
}
}
const eventResult: ExploreCollectEventResult = { if (event.effect.type === "gold_gain") {
adventurerLostCount: adventurerLostCount, // Gold gain — amount may be undefined in edge cases
essenceChange: essenceChange, // eslint-disable-next-line capitalized-comments -- v8 ignore
goldChange: goldChange, /* v8 ignore next -- @preserve */
materialGained: materialGained, const amount = event.effect.amount ?? 0;
text: event.text, state.resources.gold = state.resources.gold + amount;
}; state.player.totalGoldEarned = state.player.totalGoldEarned + amount;
goldChange = amount;
// Roll for material drops from possibleMaterials (weighted random selection) } else if (event.effect.type === "gold_loss") {
const materialsFound: Array<{ materialId: string; quantity: number }> = []; // Gold loss — amount may be undefined in edge cases
// eslint-disable-next-line capitalized-comments -- v8 ignore
if (explorationArea.possibleMaterials.length > 0) { /* v8 ignore next -- @preserve */
let totalWeight = 0; const amount = Math.min(state.resources.gold, event.effect.amount ?? 0);
for (const materialDrop of explorationArea.possibleMaterials) { state.resources.gold = state.resources.gold - amount;
totalWeight = totalWeight + materialDrop.weight; goldChange = -amount;
} } else if (event.effect.type === "essence_gain") {
let roll = Math.random() * totalWeight; // Essence gain — amount may be undefined in edge cases
// eslint-disable-next-line capitalized-comments -- v8 ignore
for (const possible of explorationArea.possibleMaterials) { /* v8 ignore next -- @preserve */
roll = roll - possible.weight; const amount = event.effect.amount ?? 0;
if (roll <= 0) { state.resources.essence = state.resources.essence + amount;
const maxMinDiff = possible.maxQuantity - possible.minQuantity; essenceChange = amount;
const range = maxMinDiff + 1; } else if (event.effect.type === "material_gain") {
const randomOffset = Math.floor(Math.random() * range); const { materialId } = event.effect;
const quantity = randomOffset + possible.minQuantity;
const { materialId } = possible;
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const quantity = event.effect.quantity ?? 1;
if (materialId !== undefined && materialId !== "") {
const existing = state.exploration.materials.find((m) => { const existing = state.exploration.materials.find((m) => {
return m.materialId === materialId; return m.materialId === materialId;
}); });
@@ -331,25 +285,97 @@ exploreRouter.post("/collect", async(context) => {
} else { } else {
state.exploration.materials.push({ materialId, quantity }); state.exploration.materials.push({ materialId, quantity });
} }
materialGained = { materialId, quantity };
materialsFound.push({ materialId, quantity }); // eslint-disable-next-line capitalized-comments -- v8 ignore
break; /* v8 ignore next 13 -- @preserve */
}
} else if (event.effect.type === "adventurer_loss") { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- exhausted all other union members above
// Adventurer loss — fraction and loop are defensive
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 8 -- @preserve */
const fraction = event.effect.fraction ?? 0.05;
for (const adventurer of state.adventurers) {
const lost = Math.floor(adventurer.count * fraction);
if (lost > 0) {
adventurer.count = Math.max(0, adventurer.count - lost);
}
} }
} }
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 8 -- @preserve */
let adventurerLostCount = 0;
if (event.effect.type === "adventurer_loss") {
const fraction = event.effect.fraction ?? 0.05;
for (const adv of state.adventurers) {
const lost = Math.floor(adv.count * fraction);
adventurerLostCount = adventurerLostCount + lost;
}
}
const eventResult: ExploreCollectEventResult = {
adventurerLostCount: adventurerLostCount,
essenceChange: essenceChange,
goldChange: goldChange,
materialGained: materialGained,
text: event.text,
};
// Roll for material drops from possibleMaterials (weighted random selection)
const materialsFound: Array<{ materialId: string; quantity: number }> = [];
if (explorationArea.possibleMaterials.length > 0) {
let totalWeight = 0;
for (const materialDrop of explorationArea.possibleMaterials) {
totalWeight = totalWeight + materialDrop.weight;
}
let roll = Math.random() * totalWeight;
for (const possible of explorationArea.possibleMaterials) {
roll = roll - possible.weight;
if (roll <= 0) {
const maxMinDiff = possible.maxQuantity - possible.minQuantity;
const range = maxMinDiff + 1;
const randomOffset = Math.floor(Math.random() * range);
const quantity = randomOffset + possible.minQuantity;
const { materialId } = possible;
const existing = state.exploration.materials.find((m) => {
return m.materialId === materialId;
});
if (existing) {
existing.quantity = existing.quantity + quantity;
} else {
state.exploration.materials.push({ materialId, quantity });
}
materialsFound.push({ materialId, quantity });
break;
}
}
}
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: now },
where: { discordId },
});
const response: ExploreCollectResponse = {
event: eventResult,
foundNothing: false,
materialsFound: materialsFound,
};
return context.json(response);
} catch (error) {
void logger.error(
"explore_collect",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
} }
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: now },
where: { discordId },
});
const response: ExploreCollectResponse = {
event: eventResult,
foundNothing: false,
materialsFound: materialsFound,
};
return context.json(response);
}); });
export { exploreRouter }; export { exploreRouter };
+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 };
+392 -335
View File
@@ -27,6 +27,7 @@ import { currentSchemaVersion } from "../data/schemaVersion.js";
import { prisma } from "../db/client.js"; import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js"; import { authMiddleware } from "../middleware/auth.js";
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js"; import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
import { logger } from "../services/logger.js";
import { calculateOfflineEarnings } from "../services/offlineProgress.js"; import { calculateOfflineEarnings } from "../services/offlineProgress.js";
import { import {
checkAndUnlockTitles, checkAndUnlockTitles,
@@ -681,18 +682,387 @@ const gameRouter = new Hono<HonoEnvironment>();
gameRouter.use("*", authMiddleware); gameRouter.use("*", authMiddleware);
gameRouter.get("/load", async(context) => { gameRouter.get("/load", async(context) => {
const discordId = context.get("discordId"); try {
const discordId = context.get("discordId");
const [ record, playerRecord ] = await Promise.all([ const [ record, playerRecord ] = await Promise.all([
prisma.gameState.findUnique({ where: { discordId } }), prisma.gameState.findUnique({ where: { discordId } }),
prisma.player.findUnique({ where: { discordId } }), prisma.player.findUnique({ where: { discordId } }),
]); ]);
if (!record) { if (!record) {
// No save found — create a fresh state (handles nuked DB or first-time load race) // No save found — create a fresh state (handles nuked DB or first-time load race)
if (!playerRecord) {
return context.json({ error: "No player found" }, 404);
}
const freshState = initialGameState(
{
avatar: playerRecord.avatar,
characterName: playerRecord.characterName,
createdAt: playerRecord.createdAt,
discordId: playerRecord.discordId,
discriminator: playerRecord.discriminator,
lastSavedAt: Date.now(),
// eslint-disable-next-line stylistic/max-len -- Long property names exceed limit after try-block indent
lifetimeAchievementsUnlocked: playerRecord.lifetimeAchievementsUnlocked,
// eslint-disable-next-line stylistic/max-len -- Long property names exceed limit after try-block indent
lifetimeAdventurersRecruited: playerRecord.lifetimeAdventurersRecruited,
lifetimeBossesDefeated: playerRecord.lifetimeBossesDefeated,
lifetimeClicks: playerRecord.lifetimeClicks,
lifetimeGoldEarned: playerRecord.lifetimeGoldEarned,
lifetimeQuestsCompleted: playerRecord.lifetimeQuestsCompleted,
totalClicks: 0,
totalGoldEarned: 0,
username: playerRecord.username,
},
playerRecord.characterName,
);
const createdAt = Date.now();
await prisma.gameState.create({
data: {
discordId: discordId,
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
state: freshState as object,
updatedAt: createdAt,
},
});
const secret = process.env.ANTI_CHEAT_SECRET;
// Sign the state for anti-cheat verification
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
const signature = secret === undefined
? undefined
: computeHmac(JSON.stringify(freshState), secret);
return context.json({
currentSchemaVersion: currentSchemaVersion,
loginBonus: null,
loginStreak: playerRecord.loginStreak,
offlineEssence: 0,
offlineGold: 0,
offlineSeconds: 0,
schemaOutdated: false,
signature: signature,
state: freshState,
});
}
const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState;
/*
* Always sync character name from the Player record — the profile update route
* writes to Player.characterName directly, bypassing the game state blob.
*/
if (playerRecord !== null) {
state.player.characterName = playerRecord.characterName;
}
const now = Date.now();
const { offlineGold, offlineEssence, offlineSeconds }
= calculateOfflineEarnings(state, now);
if (offlineGold > 0) {
state.resources.gold = state.resources.gold + offlineGold;
state.player.totalGoldEarned = state.player.totalGoldEarned + offlineGold;
}
if (offlineEssence > 0) {
state.resources.essence = state.resources.essence + offlineEssence;
}
// Generate or reset daily challenges if a new day has begun
state.dailyChallenges = getOrResetDailyChallenges(state);
// Daily login bonus — award once per calendar day (UTC)
const todayUTC = new Date().toISOString().
slice(0, 10);
const yesterdayUTC = new Date(now - 86_400_000).toISOString().
slice(0, 10);
let loginBonus: LoginBonusResult | null = null;
// Default loginStreak to 1 for brand-new accounts
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
let loginStreak = playerRecord?.loginStreak ?? 1;
if (playerRecord && playerRecord.lastLoginDate !== todayUTC) {
const previousStreak = playerRecord.loginStreak;
const updatedStreak
= playerRecord.lastLoginDate === yesterdayUTC
? previousStreak + 1
: 1;
const dayIndex = (updatedStreak - 1) % 7;
const weekMultiplier = Math.floor((updatedStreak - 1) / 7) + 1;
const reward = dailyRewards[dayIndex];
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 2 -- @preserve */
const goldEarned = (reward?.goldBase ?? 500) * weekMultiplier;
const crystalsEarned = (reward?.crystals ?? 0) * weekMultiplier;
state.resources.gold = Math.min(
state.resources.gold + goldEarned,
resourceCap,
);
state.player.totalGoldEarned = state.player.totalGoldEarned + goldEarned;
state.resources.crystals = Math.min(
state.resources.crystals + crystalsEarned,
resourceCap,
);
loginStreak = updatedStreak;
loginBonus = {
crystalsEarned: crystalsEarned,
day: dayIndex + 1,
goldEarned: goldEarned,
streak: updatedStreak,
weekMultiplier: weekMultiplier,
};
await prisma.player.
update({
data: { lastLoginDate: todayUTC, loginStreak: updatedStreak },
where: { discordId },
}).
catch((error: unknown) => {
// Ignore write-conflict errors (P2034) — rethrow anything else
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 5 -- @preserve */
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */
const { code } = error as { code?: string };
if (code !== "P2034") {
throw error;
}
});
}
state.lastTickAt = now;
if (offlineGold > 0 || offlineEssence > 0 || loginBonus !== null) {
// Persist updated state immediately so offline/login rewards aren't double-counted.
/*
* Swallow write conflicts (P2034): offline earnings and login bonus are applied
* server-side and must be persisted immediately so they aren't double-counted.
*/
await prisma.gameState.
update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: now },
where: { discordId },
}).
catch((error: unknown) => {
// Ignore write-conflict errors (P2034) — rethrow anything else
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 5 -- @preserve */
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */
const { code } = error as { code?: string };
if (code !== "P2034") {
throw error;
}
});
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const schemaOutdated = (state.schemaVersion ?? 0) < currentSchemaVersion;
const secret = process.env.ANTI_CHEAT_SECRET;
const signature = secret === undefined
? undefined
: computeHmac(JSON.stringify(state), secret);
return context.json({
currentSchemaVersion,
loginBonus,
loginStreak,
offlineEssence,
offlineGold,
offlineSeconds,
schemaOutdated,
signature,
state,
});
} catch (error) {
void logger.error(
"game_load",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
gameRouter.post("/save", async(context) => {
try {
const discordId = context.get("discordId");
const body = await context.req.json<SaveRequest>();
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Defensive check for malformed requests
if (body.state === null || body.state === undefined) {
return context.json({ error: "Missing state in request body" }, 400);
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
if ((body.state.schemaVersion ?? 0) < currentSchemaVersion) {
return context.json(
{
// eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened
error: "Save rejected: outdated save. Reset your progress to continue.",
},
409,
);
}
const secret = process.env.ANTI_CHEAT_SECRET;
const [ record, playerRecord ] = await Promise.all([
prisma.gameState.findUnique({ where: { discordId } }),
prisma.player.findUnique({ where: { discordId } }),
]);
let stateToSave = body.state;
if (record) {
const rawPreviousState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const previousState = rawPreviousState as GameState;
// Option D: verify HMAC signature if the secret is configured and client sent one
if (secret !== undefined && body.signature !== undefined) {
const expectedSig = computeHmac(JSON.stringify(previousState), secret);
if (body.signature !== expectedSig) {
return context.json(
{ error: "Save rejected: signature mismatch" },
400,
);
}
}
// Option A: sanitise the incoming state against the previous to block rollbacks and cap cheats
stateToSave = validateAndSanitize(body.state, previousState);
}
const now = Date.now();
/*
* Stamp the authoritative save timestamp into the state blob so that on the
* next load the client reads the correct value from state.player.lastSavedAt.
*/
stateToSave = {
...stateToSave,
player: { ...stateToSave.player, lastSavedAt: now },
};
/*
* Preserve the Player record's character name so that profile updates are not
* overwritten by the next auto-save (profile PUT writes to Player, not the blob).
*/
stateToSave = {
...stateToSave,
player: {
...stateToSave.player,
characterName:
playerRecord?.characterName ?? stateToSave.player.characterName,
},
};
/*
* Recompute companion unlocks server-side using DB-authoritative player lifetime stats.
* This prevents clients from claiming companions they haven't legitimately unlocked.
*/
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 8 -- @preserve */
const companionUnlocks = computeUnlockedCompanionIds({
apotheosisCount: stateToSave.apotheosis?.count ?? 0,
lifetimeBossesDefeated: playerRecord?.lifetimeBossesDefeated ?? 0,
lifetimeGoldEarned: playerRecord?.lifetimeGoldEarned ?? 0,
lifetimeQuestsCompleted: playerRecord?.lifetimeQuestsCompleted ?? 0,
prestigeCount: stateToSave.prestige.count,
transcendenceCount: stateToSave.transcendence?.count ?? 0,
});
const clientActiveCompanionId
= stateToSave.companions?.activeCompanionId ?? null;
const validatedActiveCompanionId
= clientActiveCompanionId !== null
&& companionUnlocks.includes(clientActiveCompanionId)
? clientActiveCompanionId
: null;
stateToSave = {
...stateToSave,
companions: {
activeCompanionId: validatedActiveCompanionId,
unlockedCompanionIds: companionUnlocks,
},
};
const currentUnlocked = parseUnlockedTitles(playerRecord?.unlockedTitles);
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 6 -- @preserve */
const updatedTitles = checkAndUnlockTitles({
createdAt: playerRecord?.createdAt ?? Date.now(),
currentUnlocked: currentUnlocked,
guildName: playerRecord?.guildName ?? "",
state: stateToSave,
});
const updatedUnlocked
= updatedTitles.length > 0
? [ ...currentUnlocked, ...updatedTitles ]
: undefined;
await prisma.player.update({
data: {
characterName: stateToSave.player.characterName,
lastSavedAt: now,
totalClicks: stateToSave.player.totalClicks,
totalGoldEarned: stateToSave.player.totalGoldEarned,
...updatedUnlocked
? { unlockedTitles: updatedUnlocked }
: {},
},
where: { discordId },
});
await prisma.gameState.upsert({
create: {
discordId: discordId,
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */
state: stateToSave as unknown as never,
updatedAt: now,
},
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */
update: { state: stateToSave as unknown as never, updatedAt: now },
where: { discordId },
});
const signature = secret === undefined
? undefined
: computeHmac(JSON.stringify(stateToSave), secret);
return context.json({ savedAt: now, signature: signature });
} catch (error) {
void logger.error(
"game_save",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
gameRouter.post("/reset", async(context) => {
try {
const discordId = context.get("discordId");
const playerRecord = await prisma.player.findUnique({
where: { discordId },
});
if (!playerRecord) { if (!playerRecord) {
return context.json({ error: "No player found" }, 404); return context.json({ error: "No player found" }, 404);
} }
const freshState = initialGameState( const freshState = initialGameState(
{ {
avatar: playerRecord.avatar, avatar: playerRecord.avatar,
@@ -713,23 +1083,25 @@ gameRouter.get("/load", async(context) => {
}, },
playerRecord.characterName, playerRecord.characterName,
); );
const createdAt = Date.now(); const createdAt = Date.now();
await prisma.gameState.create({ await prisma.gameState.upsert({
data: { create: {
discordId: discordId, discordId: discordId,
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
state: freshState as object, state: freshState as object,
updatedAt: createdAt, updatedAt: createdAt,
}, },
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
update: { state: freshState as object, updatedAt: createdAt },
where: { discordId },
}); });
const secret = process.env.ANTI_CHEAT_SECRET;
// Sign the state for anti-cheat verification const secret = process.env.ANTI_CHEAT_SECRET;
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
const signature = secret === undefined const signature = secret === undefined
? undefined ? undefined
: computeHmac(JSON.stringify(freshState), secret); : computeHmac(JSON.stringify(freshState), secret);
return context.json({ return context.json({
currentSchemaVersion: currentSchemaVersion, currentSchemaVersion: currentSchemaVersion,
loginBonus: null, loginBonus: null,
@@ -741,330 +1113,15 @@ gameRouter.get("/load", async(context) => {
signature: signature, signature: signature,
state: freshState, state: freshState,
}); });
} } catch (error) {
void logger.error(
const rawState: unknown = record.state; "game_reset",
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ error instanceof Error
const state = rawState as GameState; ? error
: new Error(String(error)),
const now = Date.now();
const { offlineGold, offlineEssence, offlineSeconds }
= calculateOfflineEarnings(state, now);
if (offlineGold > 0) {
state.resources.gold = state.resources.gold + offlineGold;
state.player.totalGoldEarned = state.player.totalGoldEarned + offlineGold;
}
if (offlineEssence > 0) {
state.resources.essence = state.resources.essence + offlineEssence;
}
// Generate or reset daily challenges if a new day has begun
state.dailyChallenges = getOrResetDailyChallenges(state);
// Daily login bonus — award once per calendar day (UTC)
const todayUTC = new Date().toISOString().
slice(0, 10);
const yesterdayUTC = new Date(now - 86_400_000).toISOString().
slice(0, 10);
let loginBonus: LoginBonusResult | null = null;
// Default loginStreak to 1 for brand-new accounts
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
let loginStreak = playerRecord?.loginStreak ?? 1;
if (playerRecord && playerRecord.lastLoginDate !== todayUTC) {
const previousStreak = playerRecord.loginStreak;
const updatedStreak
= playerRecord.lastLoginDate === yesterdayUTC
? previousStreak + 1
: 1;
const dayIndex = (updatedStreak - 1) % 7;
const weekMultiplier = Math.floor((updatedStreak - 1) / 7) + 1;
const reward = dailyRewards[dayIndex];
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 2 -- @preserve */
const goldEarned = (reward?.goldBase ?? 500) * weekMultiplier;
const crystalsEarned = (reward?.crystals ?? 0) * weekMultiplier;
state.resources.gold = Math.min(
state.resources.gold + goldEarned,
resourceCap,
); );
state.player.totalGoldEarned = state.player.totalGoldEarned + goldEarned; return context.json({ error: "Internal server error" }, 500);
state.resources.crystals = Math.min(
state.resources.crystals + crystalsEarned,
resourceCap,
);
loginStreak = updatedStreak;
loginBonus = {
crystalsEarned: crystalsEarned,
day: dayIndex + 1,
goldEarned: goldEarned,
streak: updatedStreak,
weekMultiplier: weekMultiplier,
};
await prisma.player.
update({
data: { lastLoginDate: todayUTC, loginStreak: updatedStreak },
where: { discordId },
}).
catch((error: unknown) => {
// Ignore write-conflict errors (P2034) — rethrow anything else
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 5 -- @preserve */
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */
const { code } = error as { code?: string };
if (code !== "P2034") {
throw error;
}
});
} }
state.lastTickAt = now;
if (offlineGold > 0 || offlineEssence > 0 || loginBonus !== null) {
// Persist updated state immediately so offline/login rewards aren't double-counted.
/*
* Swallow write conflicts (P2034): offline earnings and login bonus are applied
* server-side and must be persisted immediately so they aren't double-counted.
*/
await prisma.gameState.
update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: now },
where: { discordId },
}).
catch((error: unknown) => {
// Ignore write-conflict errors (P2034) — rethrow anything else
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 5 -- @preserve */
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */
const { code } = error as { code?: string };
if (code !== "P2034") {
throw error;
}
});
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const schemaOutdated = (state.schemaVersion ?? 0) < currentSchemaVersion;
const secret = process.env.ANTI_CHEAT_SECRET;
const signature = secret === undefined
? undefined
: computeHmac(JSON.stringify(state), secret);
return context.json({
currentSchemaVersion,
loginBonus,
loginStreak,
offlineEssence,
offlineGold,
offlineSeconds,
schemaOutdated,
signature,
state,
});
});
gameRouter.post("/save", async(context) => {
const discordId = context.get("discordId");
const body = await context.req.json<SaveRequest>();
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Defensive check for malformed requests
if (body.state === null || body.state === undefined) {
return context.json({ error: "Missing state in request body" }, 400);
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
if ((body.state.schemaVersion ?? 0) < currentSchemaVersion) {
return context.json(
{
error: "Save rejected: outdated save. Reset your progress to continue.",
},
409,
);
}
const secret = process.env.ANTI_CHEAT_SECRET;
const [ record, playerRecord ] = await Promise.all([
prisma.gameState.findUnique({ where: { discordId } }),
prisma.player.findUnique({ where: { discordId } }),
]);
let stateToSave = body.state;
if (record) {
const rawPreviousState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const previousState = rawPreviousState as GameState;
// Option D: verify HMAC signature if the secret is configured and client sent one
if (secret !== undefined && body.signature !== undefined) {
const expectedSig = computeHmac(JSON.stringify(previousState), secret);
if (body.signature !== expectedSig) {
return context.json(
{ error: "Save rejected: signature mismatch" },
400,
);
}
}
// Option A: sanitise the incoming state against the previous to block rollbacks and cap cheats
stateToSave = validateAndSanitize(body.state, previousState);
}
const now = Date.now();
/*
* Stamp the authoritative save timestamp into the state blob so that on the
* next load the client reads the correct value from state.player.lastSavedAt.
*/
stateToSave = {
...stateToSave,
player: { ...stateToSave.player, lastSavedAt: now },
};
/*
* Recompute companion unlocks server-side using DB-authoritative player lifetime stats.
* This prevents clients from claiming companions they haven't legitimately unlocked.
*/
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 8 -- @preserve */
const companionUnlocks = computeUnlockedCompanionIds({
apotheosisCount: stateToSave.apotheosis?.count ?? 0,
lifetimeBossesDefeated: playerRecord?.lifetimeBossesDefeated ?? 0,
lifetimeGoldEarned: playerRecord?.lifetimeGoldEarned ?? 0,
lifetimeQuestsCompleted: playerRecord?.lifetimeQuestsCompleted ?? 0,
prestigeCount: stateToSave.prestige.count,
transcendenceCount: stateToSave.transcendence?.count ?? 0,
});
const clientActiveCompanionId
= stateToSave.companions?.activeCompanionId ?? null;
const validatedActiveCompanionId
= clientActiveCompanionId !== null
&& companionUnlocks.includes(clientActiveCompanionId)
? clientActiveCompanionId
: null;
stateToSave = {
...stateToSave,
companions: {
activeCompanionId: validatedActiveCompanionId,
unlockedCompanionIds: companionUnlocks,
},
};
const currentUnlocked = parseUnlockedTitles(playerRecord?.unlockedTitles);
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 6 -- @preserve */
const updatedTitles = checkAndUnlockTitles({
createdAt: playerRecord?.createdAt ?? Date.now(),
currentUnlocked: currentUnlocked,
guildName: playerRecord?.guildName ?? "",
state: stateToSave,
});
const updatedUnlocked
= updatedTitles.length > 0
? [ ...currentUnlocked, ...updatedTitles ]
: undefined;
await prisma.player.update({
data: {
characterName: stateToSave.player.characterName,
lastSavedAt: now,
totalClicks: stateToSave.player.totalClicks,
totalGoldEarned: stateToSave.player.totalGoldEarned,
...updatedUnlocked
? { unlockedTitles: updatedUnlocked }
: {},
},
where: { discordId },
});
await prisma.gameState.upsert({
create: {
discordId: discordId,
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */
state: stateToSave as unknown as never,
updatedAt: now,
},
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */
update: { state: stateToSave as unknown as never, updatedAt: now },
where: { discordId },
});
const signature = secret === undefined
? undefined
: computeHmac(JSON.stringify(stateToSave), secret);
return context.json({ savedAt: now, signature: signature });
});
gameRouter.post("/reset", async(context) => {
const discordId = context.get("discordId");
const playerRecord = await prisma.player.findUnique({ where: { discordId } });
if (!playerRecord) {
return context.json({ error: "No player found" }, 404);
}
const freshState = initialGameState(
{
avatar: playerRecord.avatar,
characterName: playerRecord.characterName,
createdAt: playerRecord.createdAt,
discordId: playerRecord.discordId,
discriminator: playerRecord.discriminator,
lastSavedAt: Date.now(),
lifetimeAchievementsUnlocked: playerRecord.lifetimeAchievementsUnlocked,
lifetimeAdventurersRecruited: playerRecord.lifetimeAdventurersRecruited,
lifetimeBossesDefeated: playerRecord.lifetimeBossesDefeated,
lifetimeClicks: playerRecord.lifetimeClicks,
lifetimeGoldEarned: playerRecord.lifetimeGoldEarned,
lifetimeQuestsCompleted: playerRecord.lifetimeQuestsCompleted,
totalClicks: 0,
totalGoldEarned: 0,
username: playerRecord.username,
},
playerRecord.characterName,
);
const createdAt = Date.now();
await prisma.gameState.upsert({
create: {
discordId: discordId,
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
state: freshState as object,
updatedAt: createdAt,
},
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
update: { state: freshState as object, updatedAt: createdAt },
where: { discordId },
});
const secret = process.env.ANTI_CHEAT_SECRET;
const signature = secret === undefined
? undefined
: computeHmac(JSON.stringify(freshState), secret);
return context.json({
currentSchemaVersion: currentSchemaVersion,
loginBonus: null,
loginStreak: playerRecord.loginStreak,
offlineEssence: 0,
offlineGold: 0,
offlineSeconds: 0,
schemaOutdated: false,
signature: signature,
state: freshState,
});
}); });
export { gameRouter }; export { gameRouter };
+69 -58
View File
@@ -9,6 +9,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { gameTitles } from "../data/titles.js"; import { gameTitles } from "../data/titles.js";
import { prisma } from "../db/client.js"; import { prisma } from "../db/client.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js"; import type { HonoEnvironment } from "../types/hono.js";
import type { GameState } from "@elysium/types"; import type { GameState } from "@elysium/types";
@@ -58,70 +59,80 @@ const resolveTitleName = (titleId: string | null): string => {
}; };
leaderboardRouter.get("/", async(context) => { leaderboardRouter.get("/", async(context) => {
const category = context.req.query("category") ?? "totalGold"; try {
const limitRaw = Number(context.req.query("limit") ?? "100"); const category = context.req.query("category") ?? "totalGold";
const limit = Math.min(Math.max(1, limitRaw), 100); const limitRaw = Number(context.req.query("limit") ?? "100");
const limit = Math.min(Math.max(1, limitRaw), 100);
if (!validCategories.has(category)) { if (!validCategories.has(category)) {
return context.json({ error: "Invalid category" }, 400); return context.json({ error: "Invalid category" }, 400);
} }
const [ players, gameStates ] = await Promise.all([ const [ players, gameStates ] = await Promise.all([
prisma.player.findMany(), prisma.player.findMany(),
gameStateCategories.has(category) gameStateCategories.has(category)
? prisma.gameState.findMany() ? prisma.gameState.findMany()
: Promise.resolve([]), : Promise.resolve([]),
]); ]);
const stateMap = new Map( const stateMap = new Map(
gameStates.map((gs) => { gameStates.map((gs) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
return [ gs.discordId, gs.state as unknown as GameState ]; return [ gs.discordId, gs.state as unknown as GameState ];
}), }),
); );
const entries = players. const entries = players.
filter((player) => { filter((player) => {
return parseShowOnLeaderboards(player.profileSettings); return parseShowOnLeaderboards(player.profileSettings);
}). }).
map((player) => { map((player) => {
let value = 0; let value = 0;
if (category === "totalGold") { if (category === "totalGold") {
value = player.lifetimeGoldEarned; value = player.lifetimeGoldEarned;
} else if (category === "bossesDefeated") { } else if (category === "bossesDefeated") {
value = player.lifetimeBossesDefeated; value = player.lifetimeBossesDefeated;
} else if (category === "questsCompleted") { } else if (category === "questsCompleted") {
value = player.lifetimeQuestsCompleted; value = player.lifetimeQuestsCompleted;
} else if (category === "achievementsUnlocked") { } else if (category === "achievementsUnlocked") {
value = player.lifetimeAchievementsUnlocked; value = player.lifetimeAchievementsUnlocked;
} else { } else {
const state = stateMap.get(player.discordId); const state = stateMap.get(player.discordId);
if (category === "prestigeCount") { if (category === "prestigeCount") {
value = state?.prestige.count ?? 0; value = state?.prestige.count ?? 0;
} else if (category === "transcendenceCount") { } else if (category === "transcendenceCount") {
value = state?.transcendence?.count ?? 0; value = state?.transcendence?.count ?? 0;
} else if (category === "apotheosisCount") { } else if (category === "apotheosisCount") {
value = state?.apotheosis?.count ?? 0; value = state?.apotheosis?.count ?? 0;
}
} }
} return {
return { activeTitle: resolveTitleName(player.activeTitle),
activeTitle: resolveTitleName(player.activeTitle), avatar: player.avatar ?? null,
avatar: player.avatar ?? null, characterName: player.characterName,
characterName: player.characterName, discordId: player.discordId,
discordId: player.discordId, username: player.username,
username: player.username, value: value,
value: value, };
}; }).
}). sort((a, b) => {
sort((a, b) => { return b.value - a.value;
return b.value - a.value; }).
}). slice(0, limit).
slice(0, limit). map((entry, index) => {
map((entry, index) => { return { ...entry, rank: index + 1 };
return { ...entry, rank: index + 1 }; });
});
return context.json({ category, entries }); return context.json({ category, entries });
} catch (error) {
void logger.error(
"leaderboards",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
}); });
export { leaderboardRouter }; export { leaderboardRouter };
+192 -163
View File
@@ -6,11 +6,13 @@
*/ */
/* eslint-disable max-lines-per-function -- Route handlers require many steps */ /* eslint-disable max-lines-per-function -- Route handlers require many steps */
/* eslint-disable max-statements -- Route handlers require many statements */ /* eslint-disable max-statements -- Route handlers require many statements */
/* eslint-disable complexity -- Route handlers have inherent complexity */
import { Hono } from "hono"; import { Hono } from "hono";
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js"; import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
import { prisma } from "../db/client.js"; import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js"; import { authMiddleware } from "../middleware/auth.js";
import { updateChallengeProgress } from "../services/dailyChallenges.js"; import { updateChallengeProgress } from "../services/dailyChallenges.js";
import { logger } from "../services/logger.js";
import { import {
buildPostPrestigeState, buildPostPrestigeState,
computeRunestoneMultipliers, computeRunestoneMultipliers,
@@ -25,190 +27,217 @@ const prestigeRouter = new Hono<HonoEnvironment>();
prestigeRouter.use("*", authMiddleware); prestigeRouter.use("*", authMiddleware);
prestigeRouter.post("/", async(context) => { prestigeRouter.post("/", async(context) => {
const discordId = context.get("discordId"); try {
const discordId = context.get("discordId");
const record = await prisma.gameState.findUnique({ where: { discordId } }); const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) { if (!record) {
return context.json({ error: "No save found" }, 404); return context.json({ error: "No save found" }, 404);
} }
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = record.state as unknown as GameState; const state = record.state as unknown as GameState;
if (!isEligibleForPrestige(state)) { if (!isEligibleForPrestige(state)) {
return context.json( return context.json(
{ {
error: "Not eligible for prestige — collect 1,000,000 total gold first", // eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened
error: "Not eligible for prestige — collect 1,000,000 total gold first",
},
400,
);
}
// Update daily prestige challenge progress before resetting the run
let updatedDailyChallenges = state.dailyChallenges;
let challengeCrystals = 0;
if (updatedDailyChallenges) {
const result = updateChallengeProgress(
updatedDailyChallenges,
"prestige",
1,
);
updatedDailyChallenges = result.updatedChallenges;
challengeCrystals = result.crystalsAwarded;
}
const {
milestoneRunestones,
prestigeData,
prestigeState,
runestonesEarned,
} = buildPostPrestigeState(state, state.player.characterName);
// Preserve daily challenges across the prestige reset and apply any crystal rewards
const finalState: GameState = {
...prestigeState,
...updatedDailyChallenges === undefined
? {}
: { dailyChallenges: updatedDailyChallenges },
resources: {
...prestigeState.resources,
crystals: prestigeState.resources.crystals + challengeCrystals,
}, },
400, };
);
}
// Update daily prestige challenge progress before resetting the run // Capture current-run stats to accumulate into lifetime totals before resetting
let updatedDailyChallenges = state.dailyChallenges; // eslint-disable-next-line capitalized-comments -- v8 ignore
let challengeCrystals = 0; /* v8 ignore next 10 -- @preserve */
if (updatedDailyChallenges) { const runBossesDefeated = state.bosses.filter((boss) => {
const result = updateChallengeProgress( return boss.status === "defeated";
updatedDailyChallenges, }).length;
const runQuestsCompleted = state.quests.filter((quest) => {
return quest.status === "completed";
}).length;
let runAdventurersRecruited = 0;
for (const adventurer of state.adventurers) {
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
const runAchievementsUnlocked = state.achievements.filter((achievement) => {
return achievement.unlockedAt !== null;
}).length;
const now = Date.now();
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: finalState as object, updatedAt: now },
where: { discordId },
});
await prisma.player.update({
data: {
characterName: state.player.characterName,
lastSavedAt: now,
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
lifetimeBossesDefeated: { increment: runBossesDefeated },
lifetimeClicks: { increment: state.player.totalClicks },
// Accumulate into lifetime totals — never reset
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
totalClicks: 0,
// Reset current-run counters
totalGoldEarned: 0,
},
where: { discordId },
});
const prestigeCount = prestigeData.count;
void logger.metric("prestige", 1, { discordId, prestigeCount });
void postMilestoneWebhook(discordId, "prestige", {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
apotheosis: prestigeState.apotheosis?.count ?? 0,
prestige: prestigeData.count,
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 2 -- @preserve */
transcendence: prestigeState.transcendence?.count ?? 0,
});
return context.json({
milestoneRunestones: milestoneRunestones,
newPrestigeCount: prestigeData.count, // eslint-disable-line unicorn/no-keyword-prefix -- API response field name required by client
runestones: runestonesEarned,
});
} catch (error) {
void logger.error(
"prestige", "prestige",
1, error instanceof Error
? error
: new Error(String(error)),
); );
updatedDailyChallenges = result.updatedChallenges; return context.json({ error: "Internal server error" }, 500);
challengeCrystals = result.crystalsAwarded;
} }
const {
milestoneRunestones,
prestigeData,
prestigeState,
runestonesEarned,
} = buildPostPrestigeState(state, state.player.characterName);
// Preserve daily challenges across the prestige reset and apply any crystal rewards
const finalState: GameState = {
...prestigeState,
...updatedDailyChallenges === undefined
? {}
: { dailyChallenges: updatedDailyChallenges },
resources: {
...prestigeState.resources,
crystals: prestigeState.resources.crystals + challengeCrystals,
},
};
// Capture current-run stats to accumulate into lifetime totals before resetting
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 10 -- @preserve */
const runBossesDefeated = state.bosses.filter((boss) => {
return boss.status === "defeated";
}).length;
const runQuestsCompleted = state.quests.filter((quest) => {
return quest.status === "completed";
}).length;
let runAdventurersRecruited = 0;
for (const adventurer of state.adventurers) {
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
const runAchievementsUnlocked = state.achievements.filter((achievement) => {
return achievement.unlockedAt !== null;
}).length;
const now = Date.now();
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: finalState as object, updatedAt: now },
where: { discordId },
});
await prisma.player.update({
data: {
characterName: state.player.characterName,
lastSavedAt: now,
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
lifetimeBossesDefeated: { increment: runBossesDefeated },
lifetimeClicks: { increment: state.player.totalClicks },
// Accumulate into lifetime totals — never reset
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
totalClicks: 0,
// Reset current-run counters
totalGoldEarned: 0,
},
where: { discordId },
});
void postMilestoneWebhook(discordId, "prestige", {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
apotheosis: prestigeState.apotheosis?.count ?? 0,
prestige: prestigeData.count,
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 2 -- @preserve */
transcendence: prestigeState.transcendence?.count ?? 0,
});
return context.json({
milestoneRunestones: milestoneRunestones,
newPrestigeCount: prestigeData.count, // eslint-disable-line unicorn/no-keyword-prefix -- API response field name required by client
runestones: runestonesEarned,
});
}); });
prestigeRouter.post("/buy-upgrade", async(context) => { prestigeRouter.post("/buy-upgrade", async(context) => {
const discordId = context.get("discordId"); try {
const body = await context.req.json<BuyPrestigeUpgradeRequest>(); const discordId = context.get("discordId");
const body = await context.req.json<BuyPrestigeUpgradeRequest>();
const { upgradeId } = body; const { upgradeId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!upgradeId) { if (!upgradeId) {
return context.json({ error: "upgradeId is required" }, 400); return context.json({ error: "upgradeId is required" }, 400);
} }
const upgrade = defaultPrestigeUpgrades.find((prestigeUpgrade) => { const upgrade = defaultPrestigeUpgrades.find((prestigeUpgrade) => {
return prestigeUpgrade.id === upgradeId; return prestigeUpgrade.id === upgradeId;
}); });
if (!upgrade) { if (!upgrade) {
return context.json({ error: "Unknown prestige upgrade" }, 404); return context.json({ error: "Unknown prestige upgrade" }, 404);
} }
const record = await prisma.gameState.findUnique({ where: { discordId } }); const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) { if (!record) {
return context.json({ error: "No save found" }, 404); return context.json({ error: "No save found" }, 404);
} }
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = record.state as unknown as GameState; const state = record.state as unknown as GameState;
const { purchasedUpgradeIds, runestones } = state.prestige; const { purchasedUpgradeIds, runestones } = state.prestige;
if (purchasedUpgradeIds.includes(upgradeId)) { if (purchasedUpgradeIds.includes(upgradeId)) {
return context.json({ error: "Upgrade already purchased" }, 400); return context.json({ error: "Upgrade already purchased" }, 400);
} }
if (runestones < upgrade.runestonesCost) { if (runestones < upgrade.runestonesCost) {
return context.json({ error: "Not enough runestones" }, 400); return context.json({ error: "Not enough runestones" }, 400);
} }
const updatedRunestones = runestones - upgrade.runestonesCost; const updatedRunestones = runestones - upgrade.runestonesCost;
const updatedPurchasedUpgradeIds = [ ...purchasedUpgradeIds, upgradeId ]; const updatedPurchasedUpgradeIds = [ ...purchasedUpgradeIds, upgradeId ];
const updatedState: GameState = { const updatedState: GameState = {
...state, ...state,
prestige: { prestige: {
...state.prestige, ...state.prestige,
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
runestones: updatedRunestones,
...computeRunestoneMultipliers(updatedPurchasedUpgradeIds),
},
};
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: updatedState as object, updatedAt: Date.now() },
where: { discordId },
});
const multipliers = computeRunestoneMultipliers(updatedPurchasedUpgradeIds);
void logger.metric("prestige_upgrade_purchased", 1, {
discordId,
upgradeId,
});
return context.json({
purchasedUpgradeIds: updatedPurchasedUpgradeIds, purchasedUpgradeIds: updatedPurchasedUpgradeIds,
runestones: updatedRunestones, runestonesRemaining: updatedRunestones,
...computeRunestoneMultipliers(updatedPurchasedUpgradeIds), ...multipliers,
}, });
}; } catch (error) {
void logger.error(
await prisma.gameState.update({ "prestige_buy_upgrade",
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ error instanceof Error
data: { state: updatedState as object, updatedAt: Date.now() }, ? error
where: { discordId }, : new Error(String(error)),
}); );
return context.json({ error: "Internal server error" }, 500);
const multipliers = computeRunestoneMultipliers(updatedPurchasedUpgradeIds); }
return context.json({
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
runestonesRemaining: updatedRunestones,
...multipliers,
});
}); });
export { prestigeRouter }; export { prestigeRouter };
+185 -160
View File
@@ -5,6 +5,7 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable max-lines-per-function -- Route handlers require many steps */ /* eslint-disable max-lines-per-function -- Route handlers require many steps */
/* eslint-disable max-statements -- Route handlers require many steps */
/* eslint-disable complexity -- Route handlers have inherent complexity */ /* eslint-disable complexity -- Route handlers have inherent complexity */
/* eslint-disable stylistic/key-spacing -- ProfileSettings keys exceed max-len when aligned */ /* eslint-disable stylistic/key-spacing -- ProfileSettings keys exceed max-len when aligned */
/* eslint-disable stylistic/max-len -- ProfileSettings key names exceed line length limit */ /* 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 { gameTitles } from "../data/titles.js";
import { prisma } from "../db/client.js"; import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js"; import { authMiddleware } from "../middleware/auth.js";
import { logger } from "../services/logger.js";
import { parseUnlockedTitles } from "../services/titles.js"; import { parseUnlockedTitles } from "../services/titles.js";
import type { HonoEnvironment } from "../types/hono.js"; import type { HonoEnvironment } from "../types/hono.js";
@@ -80,187 +82,210 @@ const resolveTitle = (id: string): { id: string; name: string } => {
}; };
profileRouter.get("/:discordId", async(context) => { profileRouter.get("/:discordId", async(context) => {
const { discordId } = context.req.param(); try {
const { discordId } = context.req.param();
const [ player, gameStateRecord ] = await Promise.all([ const [ player, gameStateRecord ] = await Promise.all([
prisma.player.findUnique({ where: { discordId } }), prisma.player.findUnique({ where: { discordId } }),
prisma.gameState.findUnique({ where: { discordId } }), prisma.gameState.findUnique({ where: { discordId } }),
]); ]);
if (!player) { if (!player) {
return context.json({ error: "Player not found" }, 404); return context.json({ error: "Player not found" }, 404);
} }
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = gameStateRecord?.state as unknown as GameState | undefined; const state = gameStateRecord?.state as unknown as GameState | undefined;
const prestigeCount = state?.prestige.count ?? 0; const prestigeCount = state?.prestige.count ?? 0;
const transcendenceCount = state?.transcendence?.count ?? 0; const transcendenceCount = state?.transcendence?.count ?? 0;
const apotheosisCount = state?.apotheosis?.count ?? 0; const apotheosisCount = state?.apotheosis?.count ?? 0;
const profileSettings = parseProfileSettings(player.profileSettings); const profileSettings = parseProfileSettings(player.profileSettings);
const bossesDefeated const bossesDefeated
= state?.bosses.filter((boss) => { = state?.bosses.filter((boss) => {
return boss.status === "defeated"; return boss.status === "defeated";
}).length ?? 0; }).length ?? 0;
const questsCompleted const questsCompleted
= state?.quests.filter((quest) => { = state?.quests.filter((quest) => {
return quest.status === "completed"; return quest.status === "completed";
}).length ?? 0; }).length ?? 0;
let adventurersRecruited = 0;
if (state) {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
for (const adventurer of state.adventurers) {
adventurersRecruited = adventurersRecruited + adventurer.count;
}
}
let adventurersRecruited = 0;
if (state) {
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */ /* v8 ignore next 3 -- @preserve */
for (const adventurer of state.adventurers) { const achievementsUnlocked = (state?.achievements ?? []).filter((achievement) => {
adventurersRecruited = adventurersRecruited + adventurer.count; return achievement.unlockedAt !== null;
} }).length;
}
// eslint-disable-next-line capitalized-comments -- v8 ignore const unlockedTitleIds = parseUnlockedTitles(player.unlockedTitles);
/* v8 ignore next 3 -- @preserve */ const unlockedTitles = unlockedTitleIds.map((id) => {
const achievementsUnlocked = (state?.achievements ?? []).filter((achievement) => { return resolveTitle(id);
return achievement.unlockedAt !== null;
}).length;
const unlockedTitleIds = parseUnlockedTitles(player.unlockedTitles);
const unlockedTitles = unlockedTitleIds.map((id) => {
return resolveTitle(id);
});
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 12 -- @preserve */
const equippedItems = (state?.equipment ?? []).
filter((item) => {
return item.owned && item.equipped;
}).
map((item) => {
return {
bonus: item.bonus,
name: item.name,
rarity: item.rarity,
type: item.type,
};
}); });
return context.json({ // eslint-disable-next-line capitalized-comments -- v8 ignore
achievementsUnlocked: achievementsUnlocked, /* v8 ignore next 12 -- @preserve */
activeTitle: player.activeTitle, const equippedItems = (state?.equipment ?? []).
adventurersRecruited: adventurersRecruited, filter((item) => {
apotheosisCount: apotheosisCount, return item.owned && item.equipped;
avatar: player.avatar, }).
bio: player.bio ?? "", map((item) => {
bossesDefeated: bossesDefeated, return {
characterClass: player.characterClass, bonus: item.bonus,
characterName: player.characterName, name: item.name,
characterRace: player.characterRace ?? "", rarity: item.rarity,
createdAt: player.createdAt, type: item.type,
currentRunClicks: state?.player.totalClicks ?? 0, };
currentRunGold: state?.player.totalGoldEarned ?? 0, });
equippedItems: equippedItems,
guildDescription: player.guildDescription, const completedChapters = state?.story?.completedChapters ?? [];
guildName: player.guildName,
lifetimeAchievementsUnlocked: player.lifetimeAchievementsUnlocked, return context.json({
lifetimeAdventurersRecruited: player.lifetimeAdventurersRecruited, achievementsUnlocked: achievementsUnlocked,
lifetimeBossesDefeated: player.lifetimeBossesDefeated, activeTitle: player.activeTitle,
lifetimeQuestsCompleted: player.lifetimeQuestsCompleted, adventurersRecruited: adventurersRecruited,
prestigeCount: prestigeCount, apotheosisCount: apotheosisCount,
profileSettings: profileSettings, avatar: player.avatar,
pronouns: player.pronouns ?? "", bio: player.bio ?? "",
questsCompleted: questsCompleted, bossesDefeated: bossesDefeated,
totalClicks: player.lifetimeClicks, characterClass: player.characterClass,
totalGoldEarned: player.lifetimeGoldEarned, characterName: player.characterName,
transcendenceCount: transcendenceCount, characterRace: player.characterRace ?? "",
unlockedTitles: unlockedTitles, completedChapters: completedChapters,
username: player.username, createdAt: player.createdAt,
}); currentRunClicks: state?.player.totalClicks ?? 0,
currentRunGold: state?.player.totalGoldEarned ?? 0,
equippedItems: equippedItems,
guildDescription: player.guildDescription,
guildName: player.guildName,
lifetimeAchievementsUnlocked: player.lifetimeAchievementsUnlocked,
lifetimeAdventurersRecruited: player.lifetimeAdventurersRecruited,
lifetimeBossesDefeated: player.lifetimeBossesDefeated,
lifetimeQuestsCompleted: player.lifetimeQuestsCompleted,
prestigeCount: prestigeCount,
profileSettings: profileSettings,
pronouns: player.pronouns ?? "",
questsCompleted: questsCompleted,
totalClicks: player.lifetimeClicks,
totalGoldEarned: player.lifetimeGoldEarned,
transcendenceCount: transcendenceCount,
unlockedTitles: unlockedTitles,
username: player.username,
});
} catch (error) {
void logger.error(
"profile_get",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
}); });
profileRouter.put("/", authMiddleware, async(context) => { profileRouter.put("/", authMiddleware, async(context) => {
const discordId = context.get("discordId"); try {
const body = await context.req.json<UpdateProfileRequest>(); const discordId = context.get("discordId");
const body = await context.req.json<UpdateProfileRequest>();
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!body.characterName) { if (!body.characterName) {
return context.json({ error: "Character name cannot be empty" }, 400); return context.json({ error: "Character name cannot be empty" }, 400);
} }
const characterName = body.characterName.trim().slice(0, 32); const characterName = body.characterName.trim().slice(0, 32);
if (characterName === "") { if (characterName === "") {
return context.json({ error: "Character name cannot be empty" }, 400); return context.json({ error: "Character name cannot be empty" }, 400);
} }
const pronouns = (body.pronouns ?? "").trim().slice(0, 20); const pronouns = (body.pronouns ?? "").trim().slice(0, 20);
const characterRace = (body.characterRace ?? "").trim().slice(0, 32); const characterRace = (body.characterRace ?? "").trim().slice(0, 32);
const characterClass = (body.characterClass ?? "").trim().slice(0, 32); const characterClass = (body.characterClass ?? "").trim().slice(0, 32);
const bio = (body.bio ?? "").trim().slice(0, 200); const bio = (body.bio ?? "").trim().slice(0, 200);
const guildName = (body.guildName ?? "").trim().slice(0, 64); const guildName = (body.guildName ?? "").trim().slice(0, 64);
const guildDescription = (body.guildDescription ?? "").trim().slice(0, 500); const guildDescription = (body.guildDescription ?? "").trim().slice(0, 500);
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 2 -- @preserve */ /* v8 ignore next 2 -- @preserve */
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
const parsedNumberFormat = (body.profileSettings.numberFormat ?? "") as string;
const numberFormat = validNumberFormats.has(parsedNumberFormat)
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
? (parsedNumberFormat as ProfileSettings["numberFormat"]) const parsedNumberFormat = (body.profileSettings.numberFormat ?? "") as string;
: "suffix"; const numberFormat = validNumberFormats.has(parsedNumberFormat)
const profileSettings: ProfileSettings = { /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
enableNotifications: body.profileSettings.enableNotifications ?? false, ? (parsedNumberFormat as ProfileSettings["numberFormat"])
enableSounds: body.profileSettings.enableSounds ?? false, : "suffix";
numberFormat: numberFormat, const profileSettings: ProfileSettings = {
showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true, enableNotifications: body.profileSettings.enableNotifications ?? false,
showAdventurersRecruited: body.profileSettings.showAdventurersRecruited ?? true, enableSounds: body.profileSettings.enableSounds ?? false,
showApotheosis: body.profileSettings.showApotheosis ?? true, numberFormat: numberFormat,
showBossesDefeated: body.profileSettings.showBossesDefeated ?? true, showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true,
showCurrentClicks: body.profileSettings.showCurrentClicks ?? true, showAdventurersRecruited: body.profileSettings.showAdventurersRecruited ?? true,
showCurrentGold: body.profileSettings.showCurrentGold ?? true, showApotheosis: body.profileSettings.showApotheosis ?? true,
showGuildFounded: body.profileSettings.showGuildFounded ?? true, showBossesDefeated: body.profileSettings.showBossesDefeated ?? true,
showLifetimeAchievementsUnlocked: body.profileSettings.showLifetimeAchievementsUnlocked ?? true, showCurrentClicks: body.profileSettings.showCurrentClicks ?? true,
showLifetimeAdventurersRecruited: body.profileSettings.showLifetimeAdventurersRecruited ?? true, showCurrentGold: body.profileSettings.showCurrentGold ?? true,
showLifetimeBossesDefeated: body.profileSettings.showLifetimeBossesDefeated ?? true, showGuildFounded: body.profileSettings.showGuildFounded ?? true,
showLifetimeQuestsCompleted: body.profileSettings.showLifetimeQuestsCompleted ?? true, showLifetimeAchievementsUnlocked: body.profileSettings.showLifetimeAchievementsUnlocked ?? true,
showOnLeaderboards: body.profileSettings.showOnLeaderboards ?? true, showLifetimeAdventurersRecruited: body.profileSettings.showLifetimeAdventurersRecruited ?? true,
showPrestige: body.profileSettings.showPrestige ?? true, showLifetimeBossesDefeated: body.profileSettings.showLifetimeBossesDefeated ?? true,
showQuestsCompleted: body.profileSettings.showQuestsCompleted ?? true, showLifetimeQuestsCompleted: body.profileSettings.showLifetimeQuestsCompleted ?? true,
showTotalClicks: body.profileSettings.showTotalClicks ?? true, showOnLeaderboards: body.profileSettings.showOnLeaderboards ?? true,
showTotalGold: body.profileSettings.showTotalGold ?? true, showPrestige: body.profileSettings.showPrestige ?? true,
showTranscendence: body.profileSettings.showTranscendence ?? true, showQuestsCompleted: body.profileSettings.showQuestsCompleted ?? true,
}; showTotalClicks: body.profileSettings.showTotalClicks ?? true,
showTotalGold: body.profileSettings.showTotalGold ?? true,
showTranscendence: body.profileSettings.showTranscendence ?? true,
};
const activeTitle const activeTitle
= typeof body.activeTitle === "string" = typeof body.activeTitle === "string"
? body.activeTitle.slice(0, 64) ? body.activeTitle.slice(0, 64)
: undefined; : undefined;
const updated = await prisma.player.update({ const updated = await prisma.player.update({
data: { data: {
bio: bio, bio: bio,
characterClass: characterClass, characterClass: characterClass,
characterName: characterName, characterName: characterName,
characterRace: characterRace, characterRace: characterRace,
guildDescription: guildDescription, guildDescription: guildDescription,
guildName: guildName, guildName: guildName,
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
profileSettings: profileSettings as object, profileSettings: profileSettings as object,
pronouns: pronouns, pronouns: pronouns,
...activeTitle === undefined ...activeTitle === undefined
? {} ? {}
: { activeTitle }, : { activeTitle },
}, },
where: { discordId }, where: { discordId },
}); });
return context.json({ return context.json({
activeTitle: updated.activeTitle, activeTitle: updated.activeTitle,
bio: updated.bio, bio: updated.bio,
characterClass: updated.characterClass, characterClass: updated.characterClass,
characterName: updated.characterName, characterName: updated.characterName,
characterRace: updated.characterRace, characterRace: updated.characterRace,
guildDescription: updated.guildDescription, guildDescription: updated.guildDescription,
guildName: updated.guildName, guildName: updated.guildName,
profileSettings: profileSettings, profileSettings: profileSettings,
pronouns: updated.pronouns, pronouns: updated.pronouns,
}); });
} catch (error) {
void logger.error(
"profile_update",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
}); });
export { profileRouter }; export { profileRouter };
+171 -141
View File
@@ -6,10 +6,12 @@
*/ */
/* eslint-disable max-lines-per-function -- Route handlers require many steps */ /* eslint-disable max-lines-per-function -- Route handlers require many steps */
/* eslint-disable max-statements -- Route handlers require many statements */ /* eslint-disable max-statements -- Route handlers require many statements */
import { Hono } from "hono"; import { Hono } from "hono";
import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js"; import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js";
import { prisma } from "../db/client.js"; import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js"; import { authMiddleware } from "../middleware/auth.js";
import { logger } from "../services/logger.js";
import { import {
buildPostTranscendenceState, buildPostTranscendenceState,
computeTranscendenceMultipliers, computeTranscendenceMultipliers,
@@ -24,168 +26,196 @@ const transcendenceRouter = new Hono<HonoEnvironment>();
transcendenceRouter.use("*", authMiddleware); transcendenceRouter.use("*", authMiddleware);
transcendenceRouter.post("/", async(context) => { transcendenceRouter.post("/", async(context) => {
const discordId = context.get("discordId"); try {
const discordId = context.get("discordId");
const record = await prisma.gameState.findUnique({ where: { discordId } }); const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) { if (!record) {
return context.json({ error: "No save found" }, 404); return context.json({ error: "No save found" }, 404);
} }
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = record.state as unknown as GameState; const state = record.state as unknown as GameState;
if (!isEligibleForTranscendence(state)) { if (!isEligibleForTranscendence(state)) {
return context.json( return context.json(
{ {
error: "Not eligible for transcendence — defeat The Absolute One first", // eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened
error: "Not eligible for transcendence — defeat The Absolute One first",
},
400,
);
}
const {
echoesEarned,
transcendenceData,
transcendenceState,
} = buildPostTranscendenceState(state, state.player.characterName);
// Capture current-run stats before the nuclear reset
const runBossesDefeated = state.bosses.filter((boss) => {
return boss.status === "defeated";
}).length;
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 7 -- @preserve */
const runQuestsCompleted = state.quests.filter((quest) => {
return quest.status === "completed";
}).length;
let runAdventurersRecruited = 0;
for (const adventurer of state.adventurers) {
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
const runAchievementsUnlocked = state.achievements.filter((achievement) => {
return achievement.unlockedAt !== null;
}).length;
const now = Date.now();
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: transcendenceState as object, updatedAt: now },
where: { discordId },
});
await prisma.player.update({
data: {
characterName: state.player.characterName,
lastSavedAt: now,
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
lifetimeBossesDefeated: { increment: runBossesDefeated },
lifetimeClicks: { increment: state.player.totalClicks },
// Accumulate into lifetime totals
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
totalClicks: 0,
// Reset current-run counters (same as prestige)
totalGoldEarned: 0,
}, },
400, where: { discordId },
});
const transcendenceCount = transcendenceData.count;
void logger.metric("transcendence", 1, { discordId, transcendenceCount });
void postMilestoneWebhook(discordId, "transcendence", {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
apotheosis: transcendenceState.apotheosis?.count ?? 0,
prestige: transcendenceState.prestige.count,
transcendence: transcendenceData.count,
});
return context.json({
echoes: echoesEarned,
// eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client
newTranscendenceCount: transcendenceData.count,
});
} catch (error) {
void logger.error(
"transcendence",
error instanceof Error
? error
: new Error(String(error)),
); );
return context.json({ error: "Internal server error" }, 500);
} }
const {
echoesEarned,
transcendenceData,
transcendenceState,
} = buildPostTranscendenceState(state, state.player.characterName);
// Capture current-run stats before the nuclear reset
const runBossesDefeated = state.bosses.filter((boss) => {
return boss.status === "defeated";
}).length;
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 7 -- @preserve */
const runQuestsCompleted = state.quests.filter((quest) => {
return quest.status === "completed";
}).length;
let runAdventurersRecruited = 0;
for (const adventurer of state.adventurers) {
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
const runAchievementsUnlocked = state.achievements.filter((achievement) => {
return achievement.unlockedAt !== null;
}).length;
const now = Date.now();
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: transcendenceState as object, updatedAt: now },
where: { discordId },
});
await prisma.player.update({
data: {
characterName: state.player.characterName,
lastSavedAt: now,
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
lifetimeBossesDefeated: { increment: runBossesDefeated },
lifetimeClicks: { increment: state.player.totalClicks },
// Accumulate into lifetime totals
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
totalClicks: 0,
// Reset current-run counters (same as prestige)
totalGoldEarned: 0,
},
where: { discordId },
});
void postMilestoneWebhook(discordId, "transcendence", {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
apotheosis: transcendenceState.apotheosis?.count ?? 0,
prestige: transcendenceState.prestige.count,
transcendence: transcendenceData.count,
});
return context.json({
echoes: echoesEarned,
// eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client
newTranscendenceCount: transcendenceData.count,
});
}); });
transcendenceRouter.post("/buy-upgrade", async(context) => { transcendenceRouter.post("/buy-upgrade", async(context) => {
const discordId = context.get("discordId"); try {
const body = await context.req.json<BuyEchoUpgradeRequest>(); const discordId = context.get("discordId");
const body = await context.req.json<BuyEchoUpgradeRequest>();
const { upgradeId } = body; const { upgradeId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!upgradeId) { if (!upgradeId) {
return context.json({ error: "upgradeId is required" }, 400); return context.json({ error: "upgradeId is required" }, 400);
} }
const upgrade = defaultTranscendenceUpgrades.find((transcendenceUpgrade) => { // eslint-disable-next-line stylistic/max-len -- Variable name mirrors the data source for clarity
return transcendenceUpgrade.id === upgradeId; const upgrade = defaultTranscendenceUpgrades.find((transcendenceUpgrade) => {
}); return transcendenceUpgrade.id === upgradeId;
if (!upgrade) { });
return context.json({ error: "Unknown echo upgrade" }, 404); if (!upgrade) {
} return context.json({ error: "Unknown echo upgrade" }, 404);
}
const record = await prisma.gameState.findUnique({ where: { discordId } }); const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) { if (!record) {
return context.json({ error: "No save found" }, 404); return context.json({ error: "No save found" }, 404);
} }
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = record.state as unknown as GameState; const state = record.state as unknown as GameState;
if (!state.transcendence) { if (!state.transcendence) {
return context.json({ error: "No transcendence data found" }, 400); return context.json({ error: "No transcendence data found" }, 400);
} }
const { purchasedUpgradeIds, echoes } = state.transcendence; const { purchasedUpgradeIds, echoes } = state.transcendence;
if (purchasedUpgradeIds.includes(upgradeId)) { if (purchasedUpgradeIds.includes(upgradeId)) {
return context.json({ error: "Upgrade already purchased" }, 400); return context.json({ error: "Upgrade already purchased" }, 400);
} }
if (echoes < upgrade.cost) { if (echoes < upgrade.cost) {
return context.json({ error: "Not enough echoes" }, 400); return context.json({ error: "Not enough echoes" }, 400);
} }
const updatedEchoes = echoes - upgrade.cost; const updatedEchoes = echoes - upgrade.cost;
const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ]; const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ];
const updatedMultipliers const updatedMultipliers
= computeTranscendenceMultipliers(updatedPurchasedIds); = computeTranscendenceMultipliers(updatedPurchasedIds);
const updatedState: GameState = { const updatedState: GameState = {
...state, ...state,
transcendence: { transcendence: {
...state.transcendence, ...state.transcendence,
echoes: updatedEchoes, echoes: updatedEchoes,
purchasedUpgradeIds: updatedPurchasedIds,
...updatedMultipliers,
},
};
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: updatedState as object, updatedAt: Date.now() },
where: { discordId },
});
void logger.metric("transcendence_upgrade_purchased", 1, {
discordId,
upgradeId,
});
return context.json({
echoesRemaining: updatedEchoes,
purchasedUpgradeIds: updatedPurchasedIds, purchasedUpgradeIds: updatedPurchasedIds,
...updatedMultipliers, ...updatedMultipliers,
}, });
}; } catch (error) {
void logger.error(
await prisma.gameState.update({ "transcendence_buy_upgrade",
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ error instanceof Error
data: { state: updatedState as object, updatedAt: Date.now() }, ? error
where: { discordId }, : new Error(String(error)),
}); );
return context.json({ error: "Internal server error" }, 500);
return context.json({ }
echoesRemaining: updatedEchoes,
purchasedUpgradeIds: updatedPurchasedIds,
...updatedMultipliers,
});
}); });
export { transcendenceRouter }; export { transcendenceRouter };
+39 -18
View File
@@ -5,6 +5,7 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */ /* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */
import { logger } from "./logger.js";
interface DiscordTokenResponse { interface DiscordTokenResponse {
access_token: string; access_token: string;
@@ -50,18 +51,28 @@ const exchangeCode = async(
redirect_uri: redirectUri, redirect_uri: redirectUri,
}); });
const response = await fetch("https://discord.com/api/v10/oauth2/token", { try {
body: parameters.toString(), const response = await fetch("https://discord.com/api/v10/oauth2/token", {
headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: parameters.toString(),
method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" },
}); method: "POST",
});
if (!response.ok) { if (!response.ok) {
throw new Error(`Discord token exchange failed: ${response.statusText}`); throw new Error(`Discord token exchange failed: ${response.statusText}`);
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordTokenResponse shape */
return await (response.json() as Promise<DiscordTokenResponse>);
} catch (error) {
void logger.error(
"discord_exchange_code",
error instanceof Error
? error
: new Error(String(error)),
);
throw error;
} }
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordTokenResponse shape */
return await (response.json() as Promise<DiscordTokenResponse>);
}; };
/** /**
@@ -73,16 +84,26 @@ const exchangeCode = async(
const fetchDiscordUser = async( const fetchDiscordUser = async(
accessToken: string, accessToken: string,
): Promise<DiscordUser> => { ): Promise<DiscordUser> => {
const response = await fetch("https://discord.com/api/v10/users/@me", { try {
headers: { Authorization: `Bearer ${accessToken}` }, const response = await fetch("https://discord.com/api/v10/users/@me", {
}); headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) { if (!response.ok) {
throw new Error(`Discord user fetch failed: ${response.statusText}`); throw new Error(`Discord user fetch failed: ${response.statusText}`);
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */
return await (response.json() as Promise<DiscordUser>);
} catch (error) {
void logger.error(
"discord_fetch_user",
error instanceof Error
? error
: new Error(String(error)),
);
throw error;
} }
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */
return await (response.json() as Promise<DiscordUser>);
}; };
/** /**
+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 };
+61 -2
View File
@@ -205,10 +205,69 @@ const buildPostPrestigeState = (
}; };
const freshState = initialGameState(currentState.player, characterName); const freshState = initialGameState(currentState.player, characterName);
/*
* Preserve first-kill (bounty claimed) status across the prestige reset so
* the one-time bounty is never re-awarded in subsequent runs.
*/
const bossesWithBountyClaimed = freshState.bosses.map((freshBoss) => {
const currentBoss = currentState.bosses.find((candidate) => {
return candidate.id === freshBoss.id;
});
if (currentBoss?.bountyRunestonesClaimed === true) {
return { ...freshBoss, bountyRunestonesClaimed: true };
}
return freshBoss;
});
// Compute current-run contributions to accumulate into lifetime totals
const runBossesDefeated = currentState.bosses.filter((boss) => {
return boss.status === "defeated";
}).length;
const runQuestsCompleted = currentState.quests.filter((quest) => {
return quest.status === "completed";
}).length;
let runAdventurersRecruited = 0;
for (const adventurer of currentState.adventurers) {
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
}
const runAchievementsUnlocked = currentState.achievements.filter(
(achievement) => {
return achievement.unlockedAt !== null;
},
).length;
const prestigeState: GameState = { const prestigeState: GameState = {
...freshState, ...freshState,
lastTickAt: Date.now(), // Achievements are permanent — earned achievements survive all prestiges
prestige: prestigeData, 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 // Codex lore persists across prestiges — players keep their discovered entries
...currentState.codex === undefined ...currentState.codex === undefined
? {} ? {}
+26 -3
View File
@@ -5,8 +5,16 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case and Pascal-case header names */ /* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case and Pascal-case header names */
import { logger } from "./logger.js";
const discordApi = "https://discord.com/api/v10"; const discordApi = "https://discord.com/api/v10";
/**
* Discord MessageFlags.SUPPRESS_NOTIFICATIONS — messages are delivered without
* triggering desktop or mobile push notifications.
*/
const suppressNotifications = 4096;
/** /**
* Grants the apotheosis Discord role to the given player if configured. * Grants the apotheosis Discord role to the given player if configured.
* Fails silently so role grant errors do not affect the game action. * Fails silently so role grant errors do not affect the game action.
@@ -34,7 +42,13 @@ const grantApotheosisRole = async(discordId: string): Promise<void> => {
method: "PUT", method: "PUT",
}, },
); );
} catch { } catch (error) {
void logger.error(
"webhook_apotheosis_role",
error instanceof Error
? error
: new Error(String(error)),
);
// Graceful degradation — role grant failure must not affect the apotheosis // Graceful degradation — role grant failure must not affect the apotheosis
} }
}; };
@@ -77,11 +91,20 @@ const postMilestoneWebhook = async(
try { try {
await fetch(webhookUrl, { await fetch(webhookUrl, {
body: JSON.stringify({ content }), body: JSON.stringify({
content: content,
flags: suppressNotifications,
}),
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
method: "POST", method: "POST",
}); });
} catch { } catch (error) {
void logger.error(
"webhook_milestone",
error instanceof Error
? error
: new Error(String(error)),
);
// Graceful degradation — webhook failure must not affect the game action // Graceful degradation — webhook failure must not affect the game action
} }
}; };
+11
View File
@@ -55,4 +55,15 @@ describe("authMiddleware", () => {
})); }));
expect(res.status).toBe(401); expect(res.status).toBe(401);
}); });
it("returns 401 when verifyToken throws a non-Error value", async () => {
const { app, verifyToken } = await makeApp();
vi.mocked(verifyToken).mockImplementationOnce(() => {
throw "raw string error";
});
const res = await app.fetch(new Request("http://localhost/test", {
headers: { Authorization: "Bearer bad_token" },
}));
expect(res.status).toBe(401);
});
}); });
+12
View File
@@ -80,6 +80,18 @@ describe("apotheosis route", () => {
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
it("returns 500 when the database throws", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post();
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post();
expect(res.status).toBe(500);
});
it("returns apotheosis count on success", async () => { it("returns apotheosis count on success", async () => {
// Need all 15 transcendence upgrades purchased for eligibility // Need all 15 transcendence upgrades purchased for eligibility
const allUpgradeIds = [ const allUpgradeIds = [
+9
View File
@@ -113,5 +113,14 @@ describe("auth route", () => {
const location = res.headers.get("Location") ?? ""; const location = res.headers.get("Location") ?? "";
expect(location).toContain("error=auth_failed"); expect(location).toContain("error=auth_failed");
}); });
it("redirects with error when callback throws a non-Error value", async () => {
const { app, exchangeCode } = await makeApp();
exchangeCode.mockRejectedValueOnce("raw string error");
const res = await app.fetch(new Request("http://localhost/auth/callback?code=bad_code"));
expect(res.status).toBe(302);
const location = res.headers.get("Location") ?? "";
expect(location).toContain("error=auth_failed");
});
}); });
}); });
+33
View File
@@ -293,4 +293,37 @@ describe("boss route", () => {
const body = await res.json() as { won: boolean }; const body = await res.json() as { won: boolean };
expect(body.won).toBe(true); expect(body.won).toBe(true);
}); });
it("returns 500 when the database throws", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(500);
});
it("does not re-award bounty runestones when bountyRunestonesClaimed is true", async () => {
const state = makeState({
bosses: [makeBoss({
bountyRunestonesClaimed: true,
currentHp: 100,
damagePerSecond: 1,
maxHp: 100,
})] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 5 },
zones: [],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean; rewards: { bountyRunestones: number } };
expect(body.won).toBe(true);
expect(body.rewards.bountyRunestones).toBe(0);
});
}); });
+12
View File
@@ -143,4 +143,16 @@ describe("craft route", () => {
expect(body.recipeId).toBe(TEST_RECIPE_ID); expect(body.recipeId).toBe(TEST_RECIPE_ID);
expect(body.bonusType).toBe("gold_income"); expect(body.bonusType).toBe("gold_income");
}); });
it("returns 500 when the database throws", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(500);
});
}); });
+26
View File
@@ -406,5 +406,31 @@ describe("explore route", () => {
expect(body.materialsFound.some((m) => m.materialId === "verdant_sap")).toBe(true); expect(body.materialsFound.some((m) => m.materialId === "verdant_sap")).toBe(true);
mockRandom.mockRestore(); mockRandom.mockRestore();
}); });
it("returns 500 when the database throws on collect", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value on collect", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(500);
});
});
describe("POST /start error path", () => {
it("returns 500 when the database throws on start", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value on start", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(500);
});
}); });
}); });
+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); 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 () => { it("validates and sanitizes state when previous record exists", async () => {
const prevState = makeState({ resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 } }); const prevState = makeState({ resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 } });
const incomingState = makeState({ resources: { gold: 1e400, essence: 0, crystals: 0, runestones: 9999 } }); 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", () => { describe("POST /reset", () => {
const reset = () => const reset = () =>
app.fetch(new Request("http://localhost/game/reset", { method: "POST" })); app.fetch(new Request("http://localhost/game/reset", { method: "POST" }));
@@ -440,5 +489,17 @@ describe("game route", () => {
const body = await res.json() as { signature: string | undefined }; const body = await res.json() as { signature: string | undefined };
expect(typeof body.signature).toBe("string"); expect(typeof body.signature).toBe("string");
}); });
it("returns 500 when the database throws during reset", async () => {
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await reset();
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during reset", async () => {
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error");
const res = await reset();
expect(res.status).toBe(500);
});
}); });
}); });
+12
View File
@@ -152,6 +152,18 @@ describe("leaderboards route", () => {
expect(typeof body.entries[0]?.activeTitle).toBe("string"); expect(typeof body.entries[0]?.activeTitle).toBe("string");
}); });
it("returns 500 when the database throws", async () => {
vi.mocked(prisma.player.findMany).mockRejectedValueOnce(new Error("DB error"));
const res = await get();
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.player.findMany).mockRejectedValueOnce("raw string error");
const res = await get();
expect(res.status).toBe(500);
});
it("defaults to 0 for game-state categories when state is missing", async () => { it("defaults to 0 for game-state categories when state is missing", async () => {
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never); vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([] as never); vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([] as never);
+24
View File
@@ -93,6 +93,18 @@ describe("prestige route", () => {
expect(body.runestones).toBeGreaterThanOrEqual(0); expect(body.runestones).toBeGreaterThanOrEqual(0);
}); });
it("returns 500 when the database throws during prestige", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post("");
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during prestige", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post("");
expect(res.status).toBe(500);
});
it("updates daily challenge progress when dailyChallenges are set", async () => { it("updates daily challenge progress when dailyChallenges are set", async () => {
const state = makeState({ const state = makeState({
dailyChallenges: { dailyChallenges: {
@@ -152,5 +164,17 @@ describe("prestige route", () => {
expect(body.runestonesRemaining).toBe(90); // 100 - 10 expect(body.runestonesRemaining).toBe(90); // 100 - 10
expect(body.purchasedUpgradeIds).toContain("income_1"); expect(body.purchasedUpgradeIds).toContain("income_1");
}); });
it("returns 500 when the database throws during buy-upgrade", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post("/buy-upgrade", { upgradeId: "income_1" });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during buy-upgrade", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post("/buy-upgrade", { upgradeId: "income_1" });
expect(res.status).toBe(500);
});
}); });
}); });
+48
View File
@@ -181,6 +181,36 @@ describe("profile route", () => {
const unknown = body.unlockedTitles.find((t) => t.id === "unknown_title_id"); const unknown = body.unlockedTitles.find((t) => t.id === "unknown_title_id");
expect(unknown?.name).toBe("unknown_title_id"); expect(unknown?.name).toBe("unknown_title_id");
}); });
it("returns 500 when the database throws during profile get", async () => {
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during profile get", async () => {
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error");
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
expect(res.status).toBe(500);
});
it("includes completed story chapters in profile response", async () => {
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 /", () => { describe("PUT /", () => {
@@ -238,5 +268,23 @@ describe("profile route", () => {
const body = await res.json() as { profileSettings: { numberFormat: string } }; const body = await res.json() as { profileSettings: { numberFormat: string } };
expect(body.profileSettings.numberFormat).toBe("suffix"); expect(body.profileSettings.numberFormat).toBe("suffix");
}); });
it("returns 500 when the database throws during profile update", async () => {
vi.mocked(prisma.player.update).mockRejectedValueOnce(new Error("DB error"));
const res = await put({
characterName: "NewName",
profileSettings: { numberFormat: "suffix" },
});
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during profile update", async () => {
vi.mocked(prisma.player.update).mockRejectedValueOnce("raw string error");
const res = await put({
characterName: "NewName",
profileSettings: { numberFormat: "suffix" },
});
expect(res.status).toBe(500);
});
}); });
}); });
@@ -92,6 +92,18 @@ describe("transcendence route", () => {
expect(body.newTranscendenceCount).toBe(1); expect(body.newTranscendenceCount).toBe(1);
expect(body.echoes).toBeGreaterThanOrEqual(0); expect(body.echoes).toBeGreaterThanOrEqual(0);
}); });
it("returns 500 when the database throws during transcendence", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post("");
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during transcendence", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post("");
expect(res.status).toBe(500);
});
}); });
describe("POST /buy-upgrade", () => { describe("POST /buy-upgrade", () => {
@@ -149,5 +161,17 @@ describe("transcendence route", () => {
expect(body.echoesRemaining).toBe(95); // 100 - 5 expect(body.echoesRemaining).toBe(95); // 100 - 5
expect(body.purchasedUpgradeIds).toContain("echo_income_1"); expect(body.purchasedUpgradeIds).toContain("echo_income_1");
}); });
it("returns 500 when the database throws during buy-upgrade", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during buy-upgrade", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
expect(res.status).toBe(500);
});
}); });
}); });
+17
View File
@@ -86,5 +86,22 @@ describe("discord service", () => {
expect(result.id).toBe("123"); expect(result.id).toBe("123");
expect(result.username).toBe("testuser"); expect(result.username).toBe("testuser");
}); });
it("re-throws when fetch rejects with a non-Error value", async () => {
mockFetch.mockRejectedValueOnce("raw string error");
const { fetchDiscordUser } = await import("../../src/services/discord.js");
await expect(fetchDiscordUser("some_token")).rejects.toBe("raw string error");
});
});
describe("exchangeCode non-Error throw", () => {
it("re-throws when fetch rejects with a non-Error value", async () => {
process.env["DISCORD_CLIENT_ID"] = "cid";
process.env["DISCORD_CLIENT_SECRET"] = "secret";
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
mockFetch.mockRejectedValueOnce("raw string error");
const { exchangeCode } = await import("../../src/services/discord.js");
await expect(exchangeCode("some_code")).rejects.toBe("raw string error");
});
}); });
}); });
+140 -8
View File
@@ -13,14 +13,24 @@ import {
} from "../../src/services/prestige.js"; } from "../../src/services/prestige.js";
import type { GameState } from "@elysium/types"; import type { GameState } from "@elysium/types";
const makePlayer = (totalGoldEarned: number) => ({ const makePlayer = (
discordId: "test_id", totalGoldEarned: number,
username: "testuser", lifetimeGoldEarned = 0,
discriminator: "0", totalClicks = 0,
avatar: null, ) => ({
totalGoldEarned, avatar: null,
totalClicks: 0, characterName: "Tester",
characterName: "Tester", discordId: "test_id",
discriminator: "0",
lifetimeAchievementsUnlocked: 0,
lifetimeAdventurersRecruited: 0,
lifetimeBossesDefeated: 0,
lifetimeClicks: 0,
lifetimeGoldEarned: lifetimeGoldEarned,
lifetimeQuestsCompleted: 0,
totalClicks: totalClicks,
totalGoldEarned: totalGoldEarned,
username: "testuser",
}); });
const makeMinimalState = (overrides: Partial<GameState> = {}): GameState => const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
@@ -242,4 +252,126 @@ describe("buildPostPrestigeState", () => {
const { prestigeState } = buildPostPrestigeState(state, "Tester"); const { prestigeState } = buildPostPrestigeState(state, "Tester");
expect(prestigeState.apotheosis).toEqual(apotheosis); expect(prestigeState.apotheosis).toEqual(apotheosis);
}); });
it("accumulates current-run gold into lifetime total", () => {
const state = makeMinimalState({
player: makePlayer(4_000_000, 1_000_000),
});
const { prestigeState } = buildPostPrestigeState(state, "Tester");
expect(prestigeState.player.lifetimeGoldEarned).toBe(5_000_000);
});
it("accumulates current-run clicks into lifetime total", () => {
const state = makeMinimalState({
player: makePlayer(4_000_000, 0, 500),
});
const { prestigeState } = buildPostPrestigeState(state, "Tester");
expect(prestigeState.player.lifetimeClicks).toBe(500);
});
it("accumulates defeated bosses into lifetime total", () => {
const defeatedBoss = {
bountyRunestones: 0,
crystalReward: 0,
currentHp: 0,
damagePerSecond: 10,
description: "A boss",
equipmentRewards: [] as string[],
essenceReward: 0,
goldReward: 100,
id: "boss_1",
maxHp: 100,
name: "Boss One",
prestigeRequirement: 0,
status: "defeated" as const,
upgradeRewards: [] as string[],
zoneId: "zone_1",
};
const state = makeMinimalState({ bosses: [ defeatedBoss ] });
const { prestigeState } = buildPostPrestigeState(state, "Tester");
expect(prestigeState.player.lifetimeBossesDefeated).toBe(1);
});
it("preserves bountyRunestonesClaimed flag on bosses across prestige", () => {
const claimedBoss = {
bountyRunestones: 5,
bountyRunestonesClaimed: true,
crystalReward: 0,
currentHp: 0,
damagePerSecond: 10,
description: "A boss",
equipmentRewards: [] as string[],
essenceReward: 0,
goldReward: 100,
id: "troll_king",
maxHp: 100,
name: "Troll King",
prestigeRequirement: 0,
status: "defeated" as const,
upgradeRewards: [] as string[],
zoneId: "verdant_vale",
};
const state = makeMinimalState({ bosses: [ claimedBoss ] as GameState["bosses"] });
const { prestigeState } = buildPostPrestigeState(state, "Tester");
const matchingBoss = prestigeState.bosses.find((boss) => {
return boss.id === "troll_king";
});
expect(matchingBoss?.bountyRunestonesClaimed).toBe(true);
});
it("accumulates completed quests into lifetime total", () => {
const quest = {
id: "q_1",
name: "A Quest",
description: "Do the thing",
status: "completed" as const,
zoneId: "zone_1",
};
const state = makeMinimalState({ quests: [ quest ] as GameState["quests"] });
const { prestigeState } = buildPostPrestigeState(state, "Tester");
expect(prestigeState.player.lifetimeQuestsCompleted).toBe(1);
});
it("accumulates recruited adventurers into lifetime total", () => {
const adventurer = {
combatPower: 10,
count: 5,
essencePerSecond: 0,
goldPerSecond: 1,
id: "adv_1",
level: 1,
unlocked: true,
};
const state = makeMinimalState({ adventurers: [ adventurer ] as GameState["adventurers"] });
const { prestigeState } = buildPostPrestigeState(state, "Tester");
expect(prestigeState.player.lifetimeAdventurersRecruited).toBe(5);
});
it("preserves achievements from current state across prestige", () => {
const achievement = {
description: "Did a thing",
id: "ach_persisted",
name: "Achiever",
requirement: 1,
type: "totalClicks" as const,
unlockedAt: Date.now(),
};
const state = makeMinimalState({ achievements: [ achievement ] as GameState["achievements"] });
const { prestigeState } = buildPostPrestigeState(state, "Tester");
expect(prestigeState.achievements).toEqual([ achievement ]);
});
it("accumulates unlocked achievements into lifetime total", () => {
const achievement = {
description: "Did a thing",
id: "ach_1",
name: "Achiever",
requirement: 1,
type: "totalClicks" as const,
unlockedAt: Date.now(),
};
const state = makeMinimalState({ achievements: [ achievement ] as GameState["achievements"] });
const { prestigeState } = buildPostPrestigeState(state, "Tester");
expect(prestigeState.player.lifetimeAchievementsUnlocked).toBe(1);
});
}); });
+18 -1
View File
@@ -69,6 +69,15 @@ describe("webhook service", () => {
const { grantApotheosisRole } = await import("../../src/services/webhook.js"); const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await expect(grantApotheosisRole("user")).resolves.toBeUndefined(); await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
}); });
it("swallows non-Error fetch rejections gracefully", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok";
process.env["DISCORD_GUILD_ID"] = "g";
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r";
mockFetch.mockRejectedValueOnce("raw string error");
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
});
}); });
describe("postMilestoneWebhook", () => { describe("postMilestoneWebhook", () => {
@@ -88,9 +97,10 @@ describe("webhook service", () => {
await postMilestoneWebhook("user123", "prestige", counts); await postMilestoneWebhook("user123", "prestige", counts);
const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit]; const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toBe("https://discord.com/webhook/abc"); expect(url).toBe("https://discord.com/webhook/abc");
const body = JSON.parse(options.body as string) as { content: string }; const body = JSON.parse(options.body as string) as { content: string; flags: number };
expect(body.content).toContain("<@user123>"); expect(body.content).toContain("<@user123>");
expect(body.content).toContain("prestiged"); expect(body.content).toContain("prestiged");
expect(body.flags).toBe(4096);
}); });
it("posts transcendence message correctly", async () => { it("posts transcendence message correctly", async () => {
@@ -119,5 +129,12 @@ describe("webhook service", () => {
const { postMilestoneWebhook } = await import("../../src/services/webhook.js"); const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined(); await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined();
}); });
it("swallows non-Error fetch rejections gracefully", async () => {
process.env["DISCORD_MILESTONE_WEBHOOK"] = "https://discord.com/webhook/abc";
mockFetch.mockRejectedValueOnce("raw string error");
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined();
});
}); });
}); });
+33
View File
@@ -5,6 +5,39 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Elysium — Idle RPG</title> <title>Elysium — Idle RPG</title>
<meta name="description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." /> <meta name="description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
<!-- Open Graph -->
<meta property="og:title" content="Elysium — Idle RPG" />
<meta property="og:description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://elysium.nhcarrigan.com" />
<meta property="og:image" content="https://cdn.nhcarrigan.com/elysium/background.jpg" />
<meta property="og:site_name" content="Elysium" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Elysium — Idle RPG" />
<meta name="twitter:description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
<meta name="twitter:image" content="https://cdn.nhcarrigan.com/elysium/background.jpg" />
<!-- Plausible Analytics -->
<script defer data-domain="elysium.nhcarrigan.com" src="https://plausible.io/js/script.js"></script>
<!-- Tree-Nation -->
<script defer src="https://widgets.tree-nation.com/js/widgets/v1/widgets.min.js?v=1.0"></script>
<script>
(function () {
var interval = setInterval(function () {
if (typeof TreeNation !== "undefined") {
clearInterval(interval);
TreeNation.renderAll();
}
}, 100);
}());
</script>
<!-- Google Ads -->
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-3569924701890974" crossorigin="anonymous"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/web", "name": "@elysium/web",
"version": "0.1.0", "version": "0.1.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -13,7 +13,8 @@
"dependencies": { "dependencies": {
"@elysium/types": "workspace:*", "@elysium/types": "workspace:*",
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0" "react-dom": "19.0.0",
"react-markdown": "10.1.0"
}, },
"devDependencies": { "devDependencies": {
"@nhcarrigan/eslint-config": "5.2.0", "@nhcarrigan/eslint-config": "5.2.0",
+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-per-function -- HOW_TO_PLAY data and render logic */
/* eslint-disable max-lines -- HOW_TO_PLAY data makes this file long */ /* eslint-disable max-lines -- HOW_TO_PLAY data makes this file long */
import { type JSX, useEffect, useState } from "react"; import { type JSX, useEffect, useState } from "react";
import Markdown from "react-markdown";
import { getAbout } from "../../api/client.js"; import { getAbout } from "../../api/client.js";
import type { AboutResponse } from "@elysium/types"; import type { AboutResponse } from "@elysium/types";
@@ -331,7 +332,9 @@ const aboutPanel = (): JSX.Element => {
</span> </span>
</button> </button>
{expandedRelease === release.tag_name {expandedRelease === release.tag_name
&& <pre className="about-release-body">{release.body}</pre> && <div className="about-release-body">
<Markdown>{release.body}</Markdown>
</div>
} }
</li> </li>
); );
@@ -7,6 +7,7 @@
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */ /* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */
import { type JSX, useState } from "react"; import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js"; import { LockToggle } from "../ui/lockToggle.js";
import type { Achievement } from "@elysium/types"; import type { Achievement } from "@elysium/types";
@@ -76,7 +77,11 @@ const AchievementCard = ({
<div className={`achievement-card ${isUnlocked <div className={`achievement-card ${isUnlocked
? "unlocked" ? "unlocked"
: "locked"}`}> : "locked"}`}>
<div className="achievement-icon">{achievement.icon}</div> <img
alt={achievement.name}
className="card-thumbnail"
src={cdnImage("achievements", achievement.id)}
/>
<div className="achievement-info"> <div className="achievement-info">
<h3>{achievement.name}</h3> <h3>{achievement.name}</h3>
<p>{achievement.description}</p> <p>{achievement.description}</p>
@@ -41,7 +41,7 @@ const ToastItem = ({
const crystals = achievement.reward?.crystals; const crystals = achievement.reward?.crystals;
return ( return (
<div className="achievement-toast" onClick={handleClick}> <div className="game-toast" onClick={handleClick}>
<span className="toast-icon">{achievement.icon}</span> <span className="toast-icon">{achievement.icon}</span>
<div className="toast-content"> <div className="toast-content">
<span className="toast-label">{"Achievement Unlocked!"}</span> <span className="toast-label">{"Achievement Unlocked!"}</span>
@@ -70,7 +70,7 @@ const AchievementToast = (): JSX.Element | null => {
} }
return ( return (
<div className="achievement-toast-container"> <>
{pendingAchievements.map((achievement) => { {pendingAchievements.map((achievement) => {
return ( return (
<ToastItem <ToastItem
@@ -80,7 +80,7 @@ const AchievementToast = (): JSX.Element | null => {
/> />
); );
})} })}
</div> </>
); );
}; };
@@ -9,21 +9,38 @@
/* eslint-disable complexity -- Complex component with many render paths */ /* eslint-disable complexity -- Complex component with many render paths */
import { type JSX, useState } from "react"; import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js"; import { LockToggle } from "../ui/lockToggle.js";
import type { Adventurer } from "@elysium/types"; import type { Adventurer } from "@elysium/types";
const iconByClass: Record<string, string> = {
cleric: "✝️",
mage: "🔮",
paladin: "🛡️",
ranger: "🏹",
rogue: "🗝️",
warrior: "🗡️",
};
type BatchSize = 1 | 5 | 10 | 25 | 100 | "max"; type BatchSize = 1 | 5 | 10 | 25 | 100 | "max";
const batchOptions: Array<BatchSize> = [ 1, 5, 10, 25, 100, "max" ]; const batchOptions: Array<BatchSize> = [ 1, 5, 10, 25, 100, "max" ];
/**
* Parses a localStorage string back into a valid BatchSize, defaulting to 1.
* @param stored - The raw string from localStorage (or null if absent).
* @returns A valid BatchSize value.
*/
const parseBatchSize = (stored: string | null): BatchSize => {
if (stored === "max") {
return "max";
}
const numeric = Number(stored);
if (numeric === 5) {
return 5;
}
if (numeric === 10) {
return 10;
}
if (numeric === 25) {
return 25;
}
if (numeric === 100) {
return 100;
}
return 1;
};
/** /**
* Computes the total cost to buy a batch of adventurers. * Computes the total cost to buy a batch of adventurers.
* @param adventurer - The adventurer to buy. * @param adventurer - The adventurer to buy.
@@ -105,14 +122,15 @@ const AdventurerCard = ({
? `🪙 ${formatNumber(Math.ceil(cost))}${maxSuffix}` ? `🪙 ${formatNumber(Math.ceil(cost))}${maxSuffix}`
: "🔒 Locked"; : "🔒 Locked";
// eslint-disable-next-line @typescript-eslint/dot-notation -- "class" is a reserved word
const adventurerIcon = iconByClass[adventurer["class"]] ?? "⚔️";
return ( return (
<div className={`adventurer-card ${adventurer.unlocked <div className={`adventurer-card ${adventurer.unlocked
? "" ? ""
: "locked"}`}> : "locked"}`}>
<div className="adventurer-icon">{adventurerIcon}</div> <img
alt={adventurer.name}
className="card-thumbnail"
src={cdnImage("adventurers", adventurer.id)}
/>
<div className="adventurer-info"> <div className="adventurer-info">
<h3>{adventurer.name}</h3> <h3>{adventurer.name}</h3>
<p> <p>
@@ -155,7 +173,9 @@ const AdventurerCard = ({
const AdventurerPanel = (): JSX.Element => { const AdventurerPanel = (): JSX.Element => {
const { state, formatNumber } = useGame(); const { state, formatNumber } = useGame();
const [ showLocked, setShowLocked ] = useState(true); const [ showLocked, setShowLocked ] = useState(true);
const [ batchSize, setBatchSize ] = useState<BatchSize>(1); const [ batchSize, setBatchSize ] = useState<BatchSize>(() => {
return parseBatchSize(localStorage.getItem("elysium_batch_size"));
});
if (state === null) { if (state === null) {
return ( return (
@@ -203,6 +223,7 @@ const AdventurerPanel = (): JSX.Element => {
{batchOptions.map((option) => { {batchOptions.map((option) => {
function handleBatchSelect(): void { function handleBatchSelect(): void {
setBatchSize(option); setBatchSize(option);
localStorage.setItem("elysium_batch_size", String(option));
} }
return ( return (
<button <button
+80 -25
View File
@@ -8,6 +8,8 @@
/* eslint-disable complexity -- Battle result display requires many conditional paths */ /* eslint-disable complexity -- Battle result display requires many conditional paths */
import { type JSX, useEffect, useState } from "react"; import { type JSX, useEffect, useState } from "react";
import { type BattleResult, useGame } from "../../context/gameContext.js"; 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. * Converts HP values to a percentage for display.
@@ -23,6 +25,22 @@ const toHpPercent = (current: number, maximum: number): number => {
return scaled / maximum; 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 { interface BattleModalProperties {
readonly battle: BattleResult; readonly battle: BattleResult;
readonly onDismiss: ()=> void; readonly onDismiss: ()=> void;
@@ -40,12 +58,16 @@ const BattleModal = ({
onDismiss, onDismiss,
}: BattleModalProperties): JSX.Element => { }: BattleModalProperties): JSX.Element => {
const { result, bossName } = battle; const { result, bossName } = battle;
const { formatNumber } = useGame(); const {
enableNotifications,
enableSounds,
flushBossLoreToasts,
formatNumber,
} = useGame();
const [ phase, setPhase ] = useState<"animating" | "result">("animating"); const [ phase, setPhase ] = useState<"animating" | "result">("animating");
const bossStartPercent = toHpPercent(result.bossHpBefore, result.bossMaxHp); const bossStartPercent = toHpPercent(result.bossHpBefore, result.bossMaxHp);
const partyStartPercent = 100;
const bossEndPercent = toHpPercent( const bossEndPercent = toHpPercent(
result.bossHpAtBattleEnd, result.bossHpAtBattleEnd,
@@ -57,37 +79,72 @@ const BattleModal = ({
); );
const [ bossHpPercent, setBossHpPercent ] = useState(bossStartPercent); const [ bossHpPercent, setBossHpPercent ] = useState(bossStartPercent);
const [ partyHpPercent, setPartyHpPercent ] = useState(partyStartPercent); const [ partyHpPercent, setPartyHpPercent ] = useState(100);
useEffect(() => { useEffect(() => {
const startAnimation = setTimeout(() => { const animationDurationMs = 5000;
setBossHpPercent(bossEndPercent); const intervalMs = 50;
setPartyHpPercent(partyEndPercent); 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); }, 200);
const revealResult = setTimeout(() => { const revealTimeout = setTimeout(() => {
setPhase("result"); setPhase("result");
flushBossLoreToasts();
if (result.won) {
if (enableSounds) {
playSound("bossVictory");
}
if (enableNotifications) {
sendNotification("⚔️ Boss Defeated!", `You defeated ${bossName}!`);
}
}
}, 5200); }, 5200);
return (): void => { return (): void => {
clearTimeout(startAnimation); clearTimeout(startTimeout);
clearTimeout(revealResult); clearTimeout(revealTimeout);
clearInterval(intervalId);
}; };
}, [ bossEndPercent, partyEndPercent ]); }, [
bossEndPercent,
bossName,
bossStartPercent,
enableNotifications,
enableSounds,
flushBossLoreToasts,
partyEndPercent,
result.won,
]);
let bossHpBarColour = "#c0392b"; const bossHpBarColour = getHpColour(bossHpPercent);
if (bossHpPercent > 50) { const partyHpBarColour = getHpColour(partyHpPercent);
bossHpBarColour = "#e74c3c";
} else if (bossHpPercent > 25) {
bossHpBarColour = "#e67e22";
}
let partyHpBarColour = "#e74c3c";
if (partyHpPercent > 50) {
partyHpBarColour = "#27ae60";
} else if (partyHpPercent > 25) {
partyHpBarColour = "#f39c12";
}
return ( return (
<div className="modal-overlay"> <div className="modal-overlay">
@@ -120,7 +177,6 @@ const BattleModal = ({
className="hp-bar-fill" className="hp-bar-fill"
style={{ style={{
backgroundColor: bossHpBarColour, backgroundColor: bossHpBarColour,
transition: "width 5s ease-in-out",
width: `${bossHpPercent.toFixed(1)}%`, width: `${bossHpPercent.toFixed(1)}%`,
}} }}
/> />
@@ -141,7 +197,6 @@ const BattleModal = ({
className="hp-bar-fill party-hp" className="hp-bar-fill party-hp"
style={{ style={{
backgroundColor: partyHpBarColour, backgroundColor: partyHpBarColour,
transition: "width 5s ease-in-out",
width: `${partyHpPercent.toFixed(1)}%`, width: `${partyHpPercent.toFixed(1)}%`,
}} }}
/> />
+43 -4
View File
@@ -11,6 +11,7 @@
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */ /* eslint-disable max-lines -- Boss panel with sub-component and helper function */
import { type JSX, useState } from "react"; import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js"; import { LockToggle } from "../ui/lockToggle.js";
import { ZoneSelector } from "./zoneSelector.js"; import { ZoneSelector } from "./zoneSelector.js";
import type { Boss, GameState } from "@elysium/types"; import type { Boss, GameState } from "@elysium/types";
@@ -56,6 +57,11 @@ const BossCard = ({
return ( return (
<div className={`boss-card boss-${boss.status}`}> <div className={`boss-card boss-${boss.status}`}>
<img
alt={boss.name}
className="card-thumbnail"
src={cdnImage("bosses", boss.id)}
/>
<div className="boss-info"> <div className="boss-info">
<h3>{boss.name}</h3> <h3>{boss.name}</h3>
<p>{boss.description}</p> <p>{boss.description}</p>
@@ -120,7 +126,9 @@ const BossCard = ({
{" Equipment"} {" Equipment"}
</span> </span>
} }
{boss.status !== "defeated" && boss.bountyRunestones > 0 {boss.status !== "defeated"
&& boss.bountyRunestones > 0
&& boss.bountyRunestonesClaimed !== true
&& <span className="boss-bounty"> && <span className="boss-bounty">
{"🔮 "} {"🔮 "}
{boss.bountyRunestones} {boss.bountyRunestones}
@@ -220,11 +228,20 @@ const computePartyStats = (
* @returns The JSX element. * @returns The JSX element.
*/ */
const BossPanel = (): JSX.Element => { const BossPanel = (): JSX.Element => {
const { state, challengeBoss, formatNumber, toggleAutoBoss } = useGame(); const {
state,
challengeBoss,
formatNumber,
toggleAutoBoss,
autoBossLastResult,
autoBossError,
} = useGame();
const [ challengingBossId, setChallengingBossId ] = useState<string | null>( const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
null, null,
); );
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale"); const [ activeZoneId, setActiveZoneId ] = useState(() => {
return sessionStorage.getItem("elysium_boss_zone") ?? "verdant_vale";
});
const [ showLocked, setShowLocked ] = useState(true); const [ showLocked, setShowLocked ] = useState(true);
if (state === null) { if (state === null) {
@@ -302,6 +319,11 @@ const BossPanel = (): JSX.Element => {
} }
} }
function handleZoneSelect(zoneId: string): void {
setActiveZoneId(zoneId);
sessionStorage.setItem("elysium_boss_zone", zoneId);
}
function handleToggle(): void { function handleToggle(): void {
setShowLocked((current) => { setShowLocked((current) => {
return !current; return !current;
@@ -340,9 +362,26 @@ const BossPanel = (): JSX.Element => {
</div> </div>
</div> </div>
{autoBossError === null
? null
: <p className="auto-boss-error">
{"⚠️ Auto-boss stopped: "}
{autoBossError}
</p>
}
{autoBossLastResult !== null && autoBossError === null
? <p className="auto-boss-status">
{"🤖 Last fight: "}
{autoBossLastResult.bossName}
{autoBossLastResult.won
? " — ✅ Won"
: " — ❌ Lost"}
</p>
: null}
<ZoneSelector <ZoneSelector
activeZoneId={activeZoneId} activeZoneId={activeZoneId}
onSelectZone={setActiveZoneId} onSelectZone={handleZoneSelect}
zones={zones} zones={zones}
/> />
+56 -12
View File
@@ -5,13 +5,16 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable max-lines-per-function -- Complex component with many render paths */ /* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable max-lines -- Story section adds lines beyond the file limit */
/* eslint-disable complexity -- Many conditional render paths for optional fields */ /* eslint-disable complexity -- Many conditional render paths for optional fields */
import { type JSX, useEffect, useState } from "react"; import {
import type { STORY_CHAPTERS,
EquipmentBonus, type EquipmentBonus,
EquipmentType, type EquipmentType,
PublicProfileResponse, type PublicProfileResponse,
} from "@elysium/types"; } from "@elysium/types";
import { type JSX, useEffect, useState } from "react";
import { logError } from "../../utils/logError.js";
interface CharacterPageProperties { interface CharacterPageProperties {
readonly discordId: string; readonly discordId: string;
@@ -76,12 +79,16 @@ const CharacterPage = ({ discordId }: CharacterPageProperties): JSX.Element => {
}, [ discordId ]); }, [ discordId ]);
function handleCopy(): void { function handleCopy(): void {
void navigator.clipboard.writeText(window.location.href).then(() => { void navigator.clipboard.writeText(window.location.href).
setCopied(true); then(() => {
setTimeout(() => { setCopied(true);
setCopied(false); setTimeout(() => {
}, 2000); setCopied(false);
}); }, 2000);
}).
catch((error_: unknown) => {
logError("clipboard_copy", error_);
});
} }
if (error !== null) { if (error !== null) {
@@ -236,7 +243,7 @@ const CharacterPage = ({ discordId }: CharacterPageProperties): JSX.Element => {
return ( return (
<div <div
className="character-page-equipment-item" className="character-page-equipment-item"
key={item.type} key={item.name}
> >
<div className="character-page-equipment-header"> <div className="character-page-equipment-header">
<span className="character-page-equipment-slot"> <span className="character-page-equipment-slot">
@@ -269,6 +276,43 @@ const CharacterPage = ({ discordId }: CharacterPageProperties): JSX.Element => {
</div> </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" /> <div className="character-page-divider" />
<p className="character-page-player-line"> <p className="character-page-player-line">
@@ -19,6 +19,7 @@ import {
import { type ChangeEvent, type JSX, useEffect, useRef, useState } from "react"; import { type ChangeEvent, type JSX, useEffect, useRef, useState } from "react";
import { updateProfile } from "../../api/client.js"; import { updateProfile } from "../../api/client.js";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { logError } from "../../utils/logError.js";
interface EquippedItem { interface EquippedItem {
name: string; name: string;
@@ -205,12 +206,16 @@ const CharacterSheetPanel = (): JSX.Element => {
function handleShareClick(): void { function handleShareClick(): void {
const discordId = player?.discordId ?? ""; const discordId = player?.discordId ?? "";
const url = `${window.location.origin}/character/${discordId}`; const url = `${window.location.origin}/character/${discordId}`;
void navigator.clipboard.writeText(url).then(() => { void navigator.clipboard.writeText(url).
setCopied(true); then(() => {
setTimeout(() => { setCopied(true);
setCopied(false); setTimeout(() => {
}, 2000); setCopied(false);
}); }, 2000);
}).
catch((error_: unknown) => {
logError("clipboard_copy", error_);
});
} }
function handleNameChange(event: ChangeEvent<HTMLInputElement>): void { function handleNameChange(event: ChangeEvent<HTMLInputElement>): void {
@@ -657,6 +662,15 @@ const CharacterSheetPanel = (): JSX.Element => {
if (choice === undefined) { if (choice === undefined) {
return null; return null;
} }
const characterName
= player?.characterName === ""
|| player?.characterName === undefined
? "the guild leader"
: player.characterName;
const outcome = choice.outcome.replaceAll(
"{characterName}",
characterName,
);
return ( return (
<div <div
className="character-sheet-story-entry" className="character-sheet-story-entry"
@@ -668,6 +682,7 @@ const CharacterSheetPanel = (): JSX.Element => {
<span className="character-sheet-story-choice"> <span className="character-sheet-story-choice">
{choice.label} {choice.label}
</span> </span>
<p className="character-sheet-story-outcome">{outcome}</p>
</div> </div>
); );
})} })}
+24 -1
View File
@@ -8,6 +8,7 @@
import { type JSX, useState } from "react"; import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { CODEX_ENTRIES, ZONE_LABELS } from "../../data/codex.js"; import { CODEX_ENTRIES, ZONE_LABELS } from "../../data/codex.js";
import { cdnImage } from "../../utils/cdn.js";
import type { CodexEntry } from "@elysium/types"; import type { CodexEntry } from "@elysium/types";
/** /**
@@ -36,6 +37,18 @@ const sourceBadge: Record<CodexEntry["sourceType"], string> = {
zone: "🗺️", zone: "🗺️",
}; };
const sourceTypeFolder: Record<CodexEntry["sourceType"], string> = {
adventurer: "adventurers",
boss: "bosses",
equipment: "equipment",
exploration: "explorations",
prestige: "prestige-upgrades",
quest: "quests",
recipe: "recipes",
upgrade: "upgrades",
zone: "zones",
};
/** /**
* Renders the codex panel with lore entries grouped by zone. * Renders the codex panel with lore entries grouped by zone.
* @returns The JSX element. * @returns The JSX element.
@@ -155,7 +168,17 @@ const CodexPanel = (): JSX.Element => {
</span> </span>
</div> </div>
{isExpanded {isExpanded
? <p className="codex-entry-content">{entry.content}</p> ? <>
<img
alt={entry.title}
className="codex-entry-image"
src={cdnImage(
sourceTypeFolder[entry.sourceType],
entry.sourceId,
)}
/>
<p className="codex-entry-content">{entry.content}</p>
</>
: null} : null}
</div> </div>
); );
+3 -3
View File
@@ -47,7 +47,7 @@ const CodexToastItem = ({
} }
return ( return (
<div className="codex-toast" onClick={handleClick}> <div className="game-toast" onClick={handleClick}>
<span className="toast-icon">{"📖"}</span> <span className="toast-icon">{"📖"}</span>
<div className="toast-content"> <div className="toast-content">
<span className="toast-label">{"✨ Lore Unlocked!"}</span> <span className="toast-label">{"✨ Lore Unlocked!"}</span>
@@ -70,13 +70,13 @@ const CodexToast = (): JSX.Element | null => {
} }
return ( return (
<div className="achievement-toast-container"> <>
{pendingEntryIds.map((id) => { {pendingEntryIds.map((id) => {
return ( return (
<CodexToastItem entryId={id} key={id} onDismiss={dismissCodexEntry} /> <CodexToastItem entryId={id} key={id} onDismiss={dismissCodexEntry} />
); );
})} })}
</div> </>
); );
}; };
@@ -8,6 +8,7 @@
/* eslint-disable max-lines-per-function -- Complex companion card with conditional renders */ /* eslint-disable max-lines-per-function -- Complex companion card with conditional renders */
import { COMPANIONS, type Companion } from "@elysium/types"; import { COMPANIONS, type Companion } from "@elysium/types";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
import type { JSX } from "react"; import type { JSX } from "react";
const bonusLabels: Record<string, string> = { const bonusLabels: Record<string, string> = {
@@ -96,6 +97,11 @@ const CompanionCard = ({
: ""}`} : ""}`}
> >
<div className="companion-header"> <div className="companion-header">
<img
alt={companion.name}
className="card-thumbnail"
src={cdnImage("companions", companion.id)}
/>
<div className="companion-name-block"> <div className="companion-name-block">
<span className="companion-name">{companion.name}</span> <span className="companion-name">{companion.name}</span>
<span className="companion-title">{companion.title}</span> <span className="companion-title">{companion.title}</span>
@@ -10,6 +10,7 @@ import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { MATERIALS } from "../../data/materials.js"; import { MATERIALS } from "../../data/materials.js";
import { RECIPES } from "../../data/recipes.js"; import { RECIPES } from "../../data/recipes.js";
import { cdnImage } from "../../utils/cdn.js";
import { ZoneSelector } from "./zoneSelector.js"; import { ZoneSelector } from "./zoneSelector.js";
const bonusLabel: Record<string, string> = { const bonusLabel: Record<string, string> = {
@@ -105,6 +106,11 @@ const CraftingPanel = (): JSX.Element => {
}`} }`}
key={material.id} key={material.id}
> >
<img
alt={material.name}
className="card-thumbnail"
src={cdnImage("materials", material.id)}
/>
<div className="material-info"> <div className="material-info">
<span className="material-name">{material.name}</span> <span className="material-name">{material.name}</span>
<span className="material-rarity">{material.rarity}</span> <span className="material-rarity">{material.rarity}</span>
@@ -144,6 +150,11 @@ const CraftingPanel = (): JSX.Element => {
: ""}`} : ""}`}
key={recipe.id} key={recipe.id}
> >
<img
alt={recipe.name}
className="card-thumbnail"
src={cdnImage("recipes", recipe.id)}
/>
<div className="recipe-info"> <div className="recipe-info">
<h4>{recipe.name}</h4> <h4>{recipe.name}</h4>
<p className="recipe-description">{recipe.description}</p> <p className="recipe-description">{recipe.description}</p>
@@ -10,6 +10,7 @@
import { type JSX, useState } from "react"; import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { EQUIPMENT_SETS } from "../../data/equipmentSets.js"; import { EQUIPMENT_SETS } from "../../data/equipmentSets.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js"; import { LockToggle } from "../ui/lockToggle.js";
import type { Equipment, EquipmentType } from "@elysium/types"; import type { Equipment, EquipmentType } from "@elysium/types";
@@ -20,12 +21,6 @@ const rarityLabel: Record<string, string> = {
rare: "Rare", rare: "Rare",
}; };
const typeIcon: Record<EquipmentType, string> = {
armour: "🛡️",
trinket: "💍",
weapon: "⚔️",
};
/** /**
* Computes a human-readable bonus description for a piece of equipment. * Computes a human-readable bonus description for a piece of equipment.
* @param item - The equipment item. * @param item - The equipment item.
@@ -128,7 +123,11 @@ const EquipmentCard = ({
<div <div
className={`equipment-card rarity-${item.rarity} ${equippedClass} ${ownedClass}`} className={`equipment-card rarity-${item.rarity} ${equippedClass} ${ownedClass}`}
> >
<div className="equipment-icon">{typeIcon[item.type]}</div> <img
alt={item.name}
className="card-thumbnail"
src={cdnImage("equipment", item.id)}
/>
<div className="equipment-info"> <div className="equipment-info">
<div className="equipment-name-row"> <div className="equipment-name-row">
<h3>{item.name}</h3> <h3>{item.name}</h3>
@@ -9,6 +9,7 @@
import { type JSX, useState } from "react"; import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { EXPLORATION_AREAS } from "../../data/explorations.js"; import { EXPLORATION_AREAS } from "../../data/explorations.js";
import { cdnImage } from "../../utils/cdn.js";
import { ZoneSelector } from "./zoneSelector.js"; import { ZoneSelector } from "./zoneSelector.js";
import type { ExploreCollectResponse } from "@elysium/types"; import type { ExploreCollectResponse } from "@elysium/types";
@@ -66,7 +67,9 @@ interface CollectResult {
const ExplorationPanel = (): JSX.Element => { const ExplorationPanel = (): JSX.Element => {
const { state, startExploration, collectExploration, formatNumber } const { state, startExploration, collectExploration, formatNumber }
= useGame(); = useGame();
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale"); const [ activeZoneId, setActiveZoneId ] = useState(() => {
return sessionStorage.getItem("elysium_explore_zone") ?? "verdant_vale";
});
const [ pendingAreaId, setPendingAreaId ] = useState<string | null>(null); const [ pendingAreaId, setPendingAreaId ] = useState<string | null>(null);
const [ lastResult, setLastResult ] = useState<CollectResult | null>(null); const [ lastResult, setLastResult ] = useState<CollectResult | null>(null);
@@ -115,6 +118,7 @@ const ExplorationPanel = (): JSX.Element => {
function handleZoneSelect(id: string): void { function handleZoneSelect(id: string): void {
setActiveZoneId(id); setActiveZoneId(id);
setLastResult(null); setLastResult(null);
sessionStorage.setItem("elysium_explore_zone", id);
} }
const goldChange = lastResult?.response.event?.goldChange ?? 0; const goldChange = lastResult?.response.event?.goldChange ?? 0;
@@ -230,6 +234,11 @@ const ExplorationPanel = (): JSX.Element => {
className={`exploration-card exploration-${status}`} className={`exploration-card exploration-${status}`}
key={area.id} key={area.id}
> >
<img
alt={area.name}
className="card-thumbnail"
src={cdnImage("explorations", area.id)}
/>
<div className="exploration-info"> <div className="exploration-info">
<h3> <h3>
{area.name} {area.name}
+11 -3
View File
@@ -27,10 +27,12 @@ import { EditProfileModal } from "./editProfileModal.js";
import { EquipmentPanel } from "./equipmentPanel.js"; import { EquipmentPanel } from "./equipmentPanel.js";
import { ExplorationPanel } from "./explorationPanel.js"; import { ExplorationPanel } from "./explorationPanel.js";
import { LoginBonusModal } from "./loginBonusModal.js"; import { LoginBonusModal } from "./loginBonusModal.js";
import { MilestoneToast } from "./milestoneToast.js";
import { OfflineModal } from "./offlineModal.js"; import { OfflineModal } from "./offlineModal.js";
import { OutdatedSchemaModal } from "./outdatedSchemaModal.js"; import { OutdatedSchemaModal } from "./outdatedSchemaModal.js";
import { PrestigePanel } from "./prestigePanel.js"; import { PrestigePanel } from "./prestigePanel.js";
import { QuestPanel } from "./questPanel.js"; import { QuestPanel } from "./questPanel.js";
import { QuestCompleteToast, QuestFailedToast } from "./questToast.js";
import { StatisticsPanel } from "./statisticsPanel.js"; import { StatisticsPanel } from "./statisticsPanel.js";
import { StoryPanel } from "./storyPanel.js"; import { StoryPanel } from "./storyPanel.js";
import { StoryToast } from "./storyToast.js"; import { StoryToast } from "./storyToast.js";
@@ -164,9 +166,14 @@ const GameLayout = (): JSX.Element => {
{schemaOutdated && !dismissedOutdatedWarning {schemaOutdated && !dismissedOutdatedWarning
? <OutdatedSchemaModal onDismiss={handleDismissOutdated} /> ? <OutdatedSchemaModal onDismiss={handleDismissOutdated} />
: null} : null}
<AchievementToast /> <div className="achievement-toast-container">
<CodexToast /> <AchievementToast />
<StoryToast /> <CodexToast />
<MilestoneToast />
<QuestCompleteToast />
<QuestFailedToast />
<StoryToast />
</div>
{loginBonus === null {loginBonus === null
? null ? null
: <LoginBonusModal bonus={loginBonus} onClose={dismissLoginBonus} /> : <LoginBonusModal bonus={loginBonus} onClose={dismissLoginBonus} />
@@ -182,6 +189,7 @@ const GameLayout = (): JSX.Element => {
<div className="game-main"> <div className="game-main">
<aside className="game-sidebar"> <aside className="game-sidebar">
<ClickArea /> <ClickArea />
<div id="tree-nation-offset-website" />
<p className="game-copyright">{"© NHCarrigan"}</p> <p className="game-copyright">{"© NHCarrigan"}</p>
</aside> </aside>
@@ -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_UPGRADES,
PRESTIGE_UPGRADE_CATEGORY_LABELS, PRESTIGE_UPGRADE_CATEGORY_LABELS,
} from "../../data/prestigeUpgrades.js"; } from "../../data/prestigeUpgrades.js";
import { cdnImage } from "../../utils/cdn.js";
import { sendNotification } from "../../utils/notification.js"; import { sendNotification } from "../../utils/notification.js";
import { playSound } from "../../utils/sound.js"; import { playSound } from "../../utils/sound.js";
import type { PrestigeUpgradeCategory } from "@elysium/types"; import type { PrestigeUpgradeCategory } from "@elysium/types";
@@ -89,6 +90,7 @@ const PrestigePanel = (): JSX.Element => {
enableNotifications, enableNotifications,
enableSounds, enableSounds,
toggleAutoPrestige, toggleAutoPrestige,
triggerPrestigeToast,
} = useGame(); } = useGame();
const [ isPending, setIsPending ] = useState(false); const [ isPending, setIsPending ] = useState(false);
const [ result, setResult ] = useState<{ const [ result, setResult ] = useState<{
@@ -128,6 +130,7 @@ const PrestigePanel = (): JSX.Element => {
milestoneRunestones: data.milestoneRunestones, milestoneRunestones: data.milestoneRunestones,
runestones: data.runestones, runestones: data.runestones,
}); });
triggerPrestigeToast();
if (enableSounds) { if (enableSounds) {
playSound("prestige"); playSound("prestige");
} }
@@ -364,6 +367,11 @@ const PrestigePanel = (): JSX.Element => {
: ""}`} : ""}`}
key={upgrade.id} key={upgrade.id}
> >
<img
alt={upgrade.name}
className="card-thumbnail"
src={cdnImage("prestige-upgrades", upgrade.id)}
/>
<div className="shop-upgrade-info"> <div className="shop-upgrade-info">
<h4>{upgrade.name}</h4> <h4>{upgrade.name}</h4>
<p>{upgrade.description}</p> <p>{upgrade.description}</p>
+11 -6
View File
@@ -8,6 +8,7 @@
/* eslint-disable complexity -- Many conditional stat visibility checks */ /* eslint-disable complexity -- Many conditional stat visibility checks */
import { useEffect, useState, type JSX } from "react"; import { useEffect, useState, type JSX } from "react";
import { formatNumber } from "../../utils/format.js"; import { formatNumber } from "../../utils/format.js";
import { logError } from "../../utils/logError.js";
import type { PublicProfileResponse } from "@elysium/types"; import type { PublicProfileResponse } from "@elysium/types";
interface ProfilePageProperties { interface ProfilePageProperties {
@@ -52,12 +53,16 @@ const ProfilePage = ({ discordId }: ProfilePageProperties): JSX.Element => {
}, [ discordId ]); }, [ discordId ]);
function handleCopy(): void { function handleCopy(): void {
void navigator.clipboard.writeText(window.location.href).then(() => { void navigator.clipboard.writeText(window.location.href).
setCopied(true); then(() => {
setTimeout(() => { setCopied(true);
setCopied(false); setTimeout(() => {
}, 2000); setCopied(false);
}); }, 2000);
}).
catch((error_: unknown) => {
logError("clipboard_copy", error_);
});
} }
if (error !== null) { if (error !== null) {
+22 -9
View File
@@ -10,6 +10,7 @@
/* eslint-disable max-statements -- Many local variables needed for quest state */ /* eslint-disable max-statements -- Many local variables needed for quest state */
import { useState, type JSX } from "react"; import { useState, type JSX } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js"; import { LockToggle } from "../ui/lockToggle.js";
import { ZoneSelector } from "./zoneSelector.js"; import { ZoneSelector } from "./zoneSelector.js";
import type { Quest } from "@elysium/types"; import type { Quest } from "@elysium/types";
@@ -81,6 +82,11 @@ const QuestCard = ({
return ( return (
<div className={`quest-card quest-${quest.status}`}> <div className={`quest-card quest-${quest.status}`}>
<img
alt={quest.name}
className="card-thumbnail"
src={cdnImage("quests", quest.id)}
/>
<div className="quest-info"> <div className="quest-info">
<h3>{quest.name}</h3> <h3>{quest.name}</h3>
<p>{quest.description}</p> <p>{quest.description}</p>
@@ -102,9 +108,9 @@ const QuestCard = ({
</p> </p>
} }
<div className="quest-rewards"> <div className="quest-rewards">
{quest.rewards.map((reward) => { {quest.rewards.map((reward, rewardIndex) => {
return ( return (
<span className="reward-tag" key={`${reward.type}-${String(reward.amount ?? "")}`}> <span className="reward-tag" key={`${reward.type}-${reward.targetId ?? String(reward.amount ?? rewardIndex)}`}>
{reward.type === "gold" {reward.type === "gold"
&& `🪙 ${formatNumber(reward.amount ?? 0)}`} && `🪙 ${formatNumber(reward.amount ?? 0)}`}
{reward.type === "essence" {reward.type === "essence"
@@ -178,7 +184,9 @@ const QuestCard = ({
*/ */
const QuestPanel = (): JSX.Element => { const QuestPanel = (): JSX.Element => {
const { state, toggleAutoQuest } = useGame(); const { state, toggleAutoQuest } = useGame();
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale"); const [ activeZoneId, setActiveZoneId ] = useState(() => {
return sessionStorage.getItem("elysium_quest_zone") ?? "verdant_vale";
});
const [ showLocked, setShowLocked ] = useState(true); const [ showLocked, setShowLocked ] = useState(true);
if (state === null) { if (state === null) {
@@ -190,11 +198,11 @@ const QuestPanel = (): JSX.Element => {
} }
const { adventurers, autoQuest, quests, zones } = state; const { adventurers, autoQuest, quests, zones } = state;
// eslint-disable-next-line unicorn/no-array-reduce -- Need the total! let partyCombatPower = 0;
const partyCombatPower = adventurers.reduce((total, adventurer) => { for (const adventurer of adventurers) {
const power = total + adventurer.combatPower; const contribution = adventurer.combatPower * adventurer.count;
return power * adventurer.count; partyCombatPower = partyCombatPower + contribution;
}, 0); }
const zoneQuests = quests.filter(({ zoneId }) => { const zoneQuests = quests.filter(({ zoneId }) => {
return zoneId === activeZoneId; 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 { function handleToggle(): void {
setShowLocked((current) => { setShowLocked((current) => {
return !current; return !current;
@@ -279,7 +292,7 @@ const QuestPanel = (): JSX.Element => {
<ZoneSelector <ZoneSelector
activeZoneId={activeZoneId} activeZoneId={activeZoneId}
onSelectZone={setActiveZoneId} onSelectZone={handleZoneSelect}
zones={zones} zones={zones}
/> />
+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 { STORY_CHAPTERS } from "@elysium/types";
import { type JSX, useState } from "react"; import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
/** /**
* Substitutes the character name placeholder in story text. * Substitutes the character name placeholder in story text.
@@ -102,6 +103,11 @@ const StoryPanel = (): JSX.Element => {
: <div className="story-chapter-view"> : <div className="story-chapter-view">
{isUnlocked {isUnlocked
? <> ? <>
<img
alt={activeChapter.title}
className="story-chapter-banner"
src={cdnImage("story-chapters", activeChapter.id)}
/>
<h2 className="story-chapter-title"> <h2 className="story-chapter-title">
{"Chapter "} {"Chapter "}
{activeChapterIndex + 1} {activeChapterIndex + 1}
+8 -8
View File
@@ -45,13 +45,13 @@ const StoryToastItem = ({
} }
return ( return (
<button className="achievement-toast" onClick={handleClick} type="button"> <div className="game-toast" onClick={handleClick}>
<span className="achievement-toast-icon">{"📖"}</span> <span className="toast-icon">{"📖"}</span>
<div className="achievement-toast-content"> <div className="toast-content">
<span className="achievement-toast-label">{"✨ New Chapter!"}</span> <span className="toast-label">{"✨ New Chapter!"}</span>
<span className="achievement-toast-name">{chapter.title}</span> <span className="toast-name">{chapter.title}</span>
</div> </div>
</button> </div>
); );
}; };
@@ -65,11 +65,11 @@ const StoryToast = (): JSX.Element | null => {
return null; return null;
} }
return ( return (
<div className="achievement-toast-container"> <>
{pendingChapterIds.map((id) => { {pendingChapterIds.map((id) => {
return <StoryToastItem chapterId={id} key={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 max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- Many conditional render paths */ /* eslint-disable complexity -- Many conditional render paths */
/* eslint-disable max-statements -- Transcendence panel manages many local state variables */ /* eslint-disable max-statements -- Transcendence panel manages many local state variables */
/* eslint-disable max-lines -- Transcendence panel with CDN images exceeds line limit */
import { useState, type JSX } from "react"; import { useState, type JSX } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { import {
TRANSCENDENCE_UPGRADES, TRANSCENDENCE_UPGRADES,
TRANSCENDENCE_UPGRADE_CATEGORY_LABELS, TRANSCENDENCE_UPGRADE_CATEGORY_LABELS,
} from "../../data/transcendenceUpgrades.js"; } from "../../data/transcendenceUpgrades.js";
import { cdnImage } from "../../utils/cdn.js";
import type { TranscendenceUpgradeCategory } from "@elysium/types"; import type { TranscendenceUpgradeCategory } from "@elysium/types";
const echoFormulaConstant = 853; const echoFormulaConstant = 853;
@@ -301,6 +303,11 @@ const TranscendencePanel = (): JSX.Element => {
: ""}`} : ""}`}
key={upgrade.id} key={upgrade.id}
> >
<img
alt={upgrade.name}
className="card-thumbnail"
src={cdnImage("transcendence-upgrades", upgrade.id)}
/>
<div className="shop-upgrade-info"> <div className="shop-upgrade-info">
<h4>{upgrade.name}</h4> <h4>{upgrade.name}</h4>
<p>{upgrade.description}</p> <p>{upgrade.description}</p>
@@ -9,6 +9,7 @@
/* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */ /* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */
import { type JSX, useState } from "react"; import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js"; import { LockToggle } from "../ui/lockToggle.js";
import type { Upgrade } from "@elysium/types"; import type { Upgrade } from "@elysium/types";
@@ -53,6 +54,11 @@ const UpgradeCard = ({
if (upgrade.unlocked && upgrade.purchased) { if (upgrade.unlocked && upgrade.purchased) {
return ( return (
<div className="upgrade-card purchased"> <div className="upgrade-card purchased">
<img
alt={upgrade.name}
className="card-thumbnail"
src={cdnImage("upgrades", upgrade.id)}
/>
<span className="upgrade-name"> <span className="upgrade-name">
{"✅ "} {"✅ "}
{upgrade.name} {upgrade.name}
@@ -65,6 +71,11 @@ const UpgradeCard = ({
if (upgrade.unlocked) { if (upgrade.unlocked) {
return ( return (
<div className="upgrade-card"> <div className="upgrade-card">
<img
alt={upgrade.name}
className="card-thumbnail"
src={cdnImage("upgrades", upgrade.id)}
/>
<div className="upgrade-info"> <div className="upgrade-info">
<h3>{upgrade.name}</h3> <h3>{upgrade.name}</h3>
<p>{upgrade.description}</p> <p>{upgrade.description}</p>
@@ -108,6 +119,11 @@ const UpgradeCard = ({
return ( return (
<div className="upgrade-card locked"> <div className="upgrade-card locked">
<img
alt={upgrade.name}
className="card-thumbnail"
src={cdnImage("upgrades", upgrade.id)}
/>
<div className="upgrade-info"> <div className="upgrade-info">
<h3> <h3>
{"🔒 "} {"🔒 "}
@@ -4,6 +4,7 @@
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import { cdnImage } from "../../utils/cdn.js";
import type { Zone } from "@elysium/types"; import type { Zone } from "@elysium/types";
import type { JSX } from "react"; import type { JSX } from "react";
@@ -44,7 +45,11 @@ const ZoneSelector = ({
title={zone.description} title={zone.description}
type="button" type="button"
> >
<span className="zone-emoji">{zone.emoji}</span> <img
alt={zone.name}
className="zone-tab-image"
src={cdnImage("zones", zone.id)}
/>
<span className="zone-name">{zone.name}</span> <span className="zone-name">{zone.name}</span>
</button> </button>
); );
+401 -173
View File
@@ -20,6 +20,7 @@ import {
type GameState, type GameState,
type LoginBonusResult, type LoginBonusResult,
type NumberFormat, type NumberFormat,
type Quest,
type TranscendenceResponse, type TranscendenceResponse,
isStoryChapterUnlocked, isStoryChapterUnlocked,
} from "@elysium/types"; } from "@elysium/types";
@@ -58,6 +59,7 @@ import {
} from "../engine/tick.js"; } from "../engine/tick.js";
import { updateChallengeProgress } from "../utils/dailyChallenges.js"; import { updateChallengeProgress } from "../utils/dailyChallenges.js";
import { formatNumber as formatNumberUtil } from "../utils/format.js"; import { formatNumber as formatNumberUtil } from "../utils/format.js";
import { logError } from "../utils/logError.js";
import { sendNotification } from "../utils/notification.js"; import { sendNotification } from "../utils/notification.js";
import { playSound } from "../utils/sound.js"; import { playSound } from "../utils/sound.js";
@@ -334,6 +336,61 @@ interface GameContextValue {
*/ */
dismissAchievement: (id: string)=> void; 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. * The player's chosen number display format.
*/ */
@@ -399,6 +456,11 @@ interface GameContextValue {
*/ */
dismissCodexEntry: (id: string)=> void; 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. * Perform a transcendence nuclear reset, earning echoes.
*/ */
@@ -483,6 +545,18 @@ interface GameContextValue {
* Reset all progress to a fresh save state (resolves schema outdated). * Reset all progress to a fresh save state (resolves schema outdated).
*/ */
resetProgress: ()=> Promise<void>; resetProgress: ()=> Promise<void>;
/**
* Last auto-boss fight result null until the first auto fight completes or
* when auto-boss is toggled off.
*/
autoBossLastResult: { bossName: string; won: boolean; at: number } | null;
/**
* Error message set when auto-boss stopped due to a critical failure (null
* when no error). Cleared automatically when the player re-enables auto-boss.
*/
autoBossError: string | null;
} }
export interface BattleResult { export interface BattleResult {
@@ -514,9 +588,24 @@ export const GameProvider = ({
const [ unlockedAchievements, setUnlockedAchievements ] = useState< const [ unlockedAchievements, setUnlockedAchievements ] = useState<
Array<Achievement> 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 [ lastSavedAt, setLastSavedAt ] = useState<number | null>(null);
const [ isSyncing, setIsSyncing ] = useState(false); const [ isSyncing, setIsSyncing ] = useState(false);
const [ syncError, setSyncError ] = useState<string | null>(null); const [ syncError, setSyncError ] = useState<string | null>(null);
const [ autoBossLastResult, setAutoBossLastResult ] = useState<{
bossName: string;
won: boolean;
at: number;
} | null>(null);
const [ autoBossError, setAutoBossError ] = useState<string | null>(null);
const syncErrorTimerReference = useRef<ReturnType<typeof setTimeout> | null>( const syncErrorTimerReference = useRef<ReturnType<typeof setTimeout> | null>(
null, null,
); );
@@ -530,8 +619,8 @@ export const GameProvider = ({
const isSyncingReference = useRef(false); const isSyncingReference = useRef(false);
const rafReference = useRef<number | null>(null); const rafReference = useRef<number | null>(null);
const unlockedAchievementsReference = useRef<Array<Achievement>>([]); const unlockedAchievementsReference = useRef<Array<Achievement>>([]);
const newlyCompletedQuestsCountReference = useRef(0); const newlyCompletedQuestsReference = useRef<Array<Quest>>([]);
const newlyFailedQuestsCountReference = useRef(0); const newlyFailedQuestsReference = useRef<Array<Quest>>([]);
const signatureReference = useRef<string | null>( const signatureReference = useRef<string | null>(
localStorage.getItem("elysium_save_signature"), localStorage.getItem("elysium_save_signature"),
); );
@@ -548,6 +637,7 @@ export const GameProvider = ({
Array<string> Array<string>
>([]); >([]);
const codexProcessedReference = useRef<Set<string>>(new Set()); const codexProcessedReference = useRef<Set<string>>(new Set());
const pendingBossCodexIdsReference = useRef<Array<string>>([]);
const [ unlockedStoryChapterIds, setUnlockedStoryChapterIds ] = useState< const [ unlockedStoryChapterIds, setUnlockedStoryChapterIds ] = useState<
Array<string> Array<string>
>([]); >([]);
@@ -815,12 +905,30 @@ export const GameProvider = ({
}; };
}); });
if (!isFirstRun) { if (!isFirstRun) {
setUnlockedCodexEntryIds((previous) => { const bossIds = addedIds.filter((id) => {
return [ ...previous, ...addedIds ]; 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, ...otherIds ];
});
}
} }
} }
}, [ state ]); }, [ battleResult, state ]);
// Detect newly unlocked story chapters // Detect newly unlocked story chapters
useEffect(() => { useEffect(() => {
@@ -949,17 +1057,17 @@ export const GameProvider = ({
); );
// Detect newly completed quests // Detect newly completed quests
newlyCompletedQuestsCountReference.current = next.quests.filter( newlyCompletedQuestsReference.current = next.quests.filter(
(q, index) => { (q, index) => {
return ( return (
previous.quests[index]?.status === "active" previous.quests[index]?.status === "active"
&& q.status === "completed" && q.status === "completed"
); );
}, },
).length; );
// Detect newly failed quests // Detect newly failed quests
newlyFailedQuestsCountReference.current = next.quests.filter( newlyFailedQuestsReference.current = next.quests.filter(
(q, index) => { (q, index) => {
const previousFailedAt = previous.quests[index]?.lastFailedAt; const previousFailedAt = previous.quests[index]?.lastFailedAt;
return ( return (
@@ -967,7 +1075,15 @@ export const GameProvider = ({
&& q.lastFailedAt !== previousFailedAt && 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; return next;
}); });
@@ -987,24 +1103,30 @@ export const GameProvider = ({
unlockedAchievementsReference.current = []; unlockedAchievementsReference.current = [];
} }
if (newlyCompletedQuestsCountReference.current > 0) { if (newlyCompletedQuestsReference.current.length > 0) {
setCompletedQuestToasts((previous) => {
return [ ...previous, ...newlyCompletedQuestsReference.current ];
});
if (enableSoundsReference.current) { if (enableSoundsReference.current) {
playSound("questCompleted"); playSound("questCompleted");
} }
if (enableNotificationsReference.current) { if (enableNotificationsReference.current) {
sendNotification("📜 Quest Complete!", "A quest has been completed."); 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) { if (enableSoundsReference.current) {
playSound("questFailed"); playSound("questFailed");
} }
if (enableNotificationsReference.current) { if (enableNotificationsReference.current) {
sendNotification("💀 Quest Failed!", "A quest has failed."); 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) // 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; signatureReference.current = null;
localStorage.removeItem("elysium_save_signature"); localStorage.removeItem("elysium_save_signature");
} else {
logError("auto_save", error_);
} }
}); });
} }
@@ -1054,6 +1178,7 @@ export const GameProvider = ({
isAutoPrestigingReference.current = true; isAutoPrestigingReference.current = true;
void prestigeApi({}). void prestigeApi({}).
then(async() => { then(async() => {
setShowPrestigeToast(true);
if (enableSoundsReference.current) { if (enableSoundsReference.current) {
playSound("prestige"); playSound("prestige");
} }
@@ -1062,7 +1187,8 @@ export const GameProvider = ({
} }
await reloadReference.current(); await reloadReference.current();
}). }).
catch(() => { catch((error_: unknown) => {
logError("auto_prestige", error_);
/* Silently ignore — will retry next tick */ /* Silently ignore — will retry next tick */
}). }).
@@ -1100,24 +1226,32 @@ export const GameProvider = ({
if (previous === null) { if (previous === null) {
return previous; return previous;
} }
return applyBossResult(previous, bossId, result); const afterBoss = applyBossResult(previous, bossId, result);
// Defeat — turn off auto-boss so the player can reassess
if (!result.won) {
return { ...afterBoss, autoBoss: false };
}
return afterBoss;
});
setAutoBossLastResult({
at: Date.now(),
bossName: bossName,
won: result.won,
}); });
setBattleResult({ bossName, result });
if (result.won) {
if (enableSoundsReference.current) {
playSound("bossVictory");
}
if (enableNotificationsReference.current) {
sendNotification(
"⚔️ Boss Defeated!",
`You defeated ${bossName}!`,
);
}
}
}). }).
catch(() => { catch((error_: unknown) => {
logError("auto_boss", error_);
/* Silently ignore — will retry next tick */ const message
= error_ instanceof Error
? error_.message
: String(error_);
setAutoBossError(message);
setState((previous) => {
if (previous === null) {
return previous;
}
return { ...previous, autoBoss: false };
});
}). }).
finally(() => { finally(() => {
isAutoBossingReference.current = false; isAutoBossingReference.current = false;
@@ -1436,33 +1570,46 @@ export const GameProvider = ({
}, },
}; };
}); });
} catch { } catch (error_: unknown) {
logError("buy_prestige_upgrade", error_);
// Silently ignore — server errors shouldn't crash the UI // Silently ignore — server errors shouldn't crash the UI
} }
}, []); }, []);
const transcend = useCallback(async() => { const transcend = useCallback(async() => {
const result = await transcendApi({}); try {
if (enableSoundsReference.current) { const result = await transcendApi({});
playSound("transcendence"); setShowTranscendenceToast(true);
if (enableSoundsReference.current) {
playSound("transcendence");
}
if (enableNotificationsReference.current) {
sendNotification("🌌 Transcendence!", "You have transcended reality!");
}
await reload();
return result;
} catch (error_: unknown) {
logError("transcend", error_);
throw error_;
} }
if (enableNotificationsReference.current) {
sendNotification("🌌 Transcendence!", "You have transcended reality!");
}
await reload();
return result;
}, [ reload ]); }, [ reload ]);
const apotheosis = useCallback(async() => { const apotheosis = useCallback(async() => {
const result = await achieveApotheosisApi({}); try {
if (enableSoundsReference.current) { const result = await achieveApotheosisApi({});
playSound("apotheosis"); setShowApotheosisToast(true);
if (enableSoundsReference.current) {
playSound("apotheosis");
}
if (enableNotificationsReference.current) {
sendNotification("✨ Apotheosis!", "You have achieved godhood!");
}
await reload();
return result;
} catch (error_: unknown) {
logError("apotheosis", error_);
throw error_;
} }
if (enableNotificationsReference.current) {
sendNotification("✨ Apotheosis!", "You have achieved godhood!");
}
await reload();
return result;
}, [ reload ]); }, [ reload ]);
const buyEchoUpgrade = useCallback(async(upgradeId: string) => { const buyEchoUpgrade = useCallback(async(upgradeId: string) => {
@@ -1488,114 +1635,125 @@ export const GameProvider = ({
}, },
}; };
}); });
} catch { } catch (error_: unknown) {
// Silently ignore server errors logError("buy_echo_upgrade", error_);
// Silently ignore — server errors shouldn't crash the UI
} }
}, []); }, []);
const startExploration = useCallback(async(areaId: string) => { const startExploration = useCallback(async(areaId: string) => {
const response = await startExplorationApi({ areaId }); try {
const areaData = EXPLORATION_AREAS.find((a) => { const response = await startExplorationApi({ areaId });
return a.id === areaId; const areaData = EXPLORATION_AREAS.find((a) => {
}); return a.id === areaId;
if (areaData === undefined) { });
return; if (areaData === undefined) {
} return;
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
const startedAt = response.endsAt - areaData.durationSeconds * 1000;
setState((previous) => {
if (previous?.exploration === undefined) {
return previous;
} }
return { // eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
...previous, const startedAt = response.endsAt - areaData.durationSeconds * 1000;
exploration: {
...previous.exploration,
areas: previous.exploration.areas.map((a) => {
return a.id === areaId
? { ...a, startedAt: startedAt, status: "in_progress" as const }
: a;
}),
},
};
});
}, []);
const collectExploration = useCallback(
async(areaId: string): Promise<ExploreCollectResponse> => {
const result = await collectExplorationApi({ areaId });
setState((previous) => { setState((previous) => {
if (previous?.exploration === undefined) { if (previous?.exploration === undefined) {
return previous; return previous;
} }
let materials = [ ...previous.exploration.materials ];
// Apply material drops from the random loot roll
for (const drop of result.materialsFound) {
const existing = materials.find((mat) => {
return mat.materialId === drop.materialId;
});
if (existing === undefined) {
materials = [
...materials,
{ materialId: drop.materialId, quantity: drop.quantity },
];
} else {
materials = materials.map((mat) => {
return mat.materialId === drop.materialId
? { ...mat, quantity: mat.quantity + drop.quantity }
: mat;
});
}
}
// Apply material from event (if any)
const materialGained = result.event?.materialGained;
if (materialGained !== null && materialGained !== undefined) {
const { materialId, quantity } = materialGained;
const existing = materials.find((mat) => {
return mat.materialId === materialId;
});
if (existing === undefined) {
materials = [ ...materials, { materialId, quantity } ];
} else {
materials = materials.map((mat) => {
return mat.materialId === materialId
? { ...mat, quantity: mat.quantity + quantity }
: mat;
});
}
}
return { return {
...previous, ...previous,
exploration: { exploration: {
...previous.exploration, ...previous.exploration,
areas: previous.exploration.areas.map((a) => { areas: previous.exploration.areas.map((a) => {
return a.id === areaId return a.id === areaId
? { ...a, completedOnce: true, status: "available" as const } ? { ...a, startedAt: startedAt, status: "in_progress" as const }
: a; : a;
}), }),
materials: materials,
},
player: {
...previous.player,
totalGoldEarned:
previous.player.totalGoldEarned
+ Math.max(0, result.event?.goldChange ?? 0),
},
resources: {
...previous.resources,
essence:
previous.resources.essence + (result.event?.essenceChange ?? 0),
gold: Math.max(
0,
previous.resources.gold + (result.event?.goldChange ?? 0),
),
}, },
}; };
}); });
return result; } catch (error_: unknown) {
logError("start_exploration", error_);
throw error_;
}
}, []);
const collectExploration = useCallback(
async(areaId: string): Promise<ExploreCollectResponse> => {
try {
const result = await collectExplorationApi({ areaId });
setState((previous) => {
if (previous?.exploration === undefined) {
return previous;
}
let materials = [ ...previous.exploration.materials ];
// Apply material drops from the random loot roll
for (const drop of result.materialsFound) {
const existing = materials.find((mat) => {
return mat.materialId === drop.materialId;
});
if (existing === undefined) {
materials = [
...materials,
{ materialId: drop.materialId, quantity: drop.quantity },
];
} else {
materials = materials.map((mat) => {
return mat.materialId === drop.materialId
? { ...mat, quantity: mat.quantity + drop.quantity }
: mat;
});
}
}
// Apply material from event (if any)
const materialGained = result.event?.materialGained;
if (materialGained !== null && materialGained !== undefined) {
const { materialId, quantity } = materialGained;
const existing = materials.find((mat) => {
return mat.materialId === materialId;
});
if (existing === undefined) {
materials = [ ...materials, { materialId, quantity } ];
} else {
materials = materials.map((mat) => {
return mat.materialId === materialId
? { ...mat, quantity: mat.quantity + quantity }
: mat;
});
}
}
return {
...previous,
exploration: {
...previous.exploration,
areas: previous.exploration.areas.map((a) => {
return a.id === areaId
? { ...a, completedOnce: true, status: "available" as const }
: a;
}),
materials: materials,
},
player: {
...previous.player,
totalGoldEarned:
previous.player.totalGoldEarned
+ Math.max(0, result.event?.goldChange ?? 0),
},
resources: {
...previous.resources,
essence:
previous.resources.essence + (result.event?.essenceChange ?? 0),
gold: Math.max(
0,
previous.resources.gold + (result.event?.goldChange ?? 0),
),
},
};
});
return result;
} catch (error_: unknown) {
logError("collect_exploration", error_);
throw error_;
}
}, },
[], [],
); );
@@ -1607,35 +1765,40 @@ export const GameProvider = ({
if (recipe === undefined) { if (recipe === undefined) {
return; return;
} }
const result = await craftRecipeApi({ recipeId }); try {
setState((previous) => { const result = await craftRecipeApi({ recipeId });
if (previous?.exploration === undefined) { setState((previous) => {
return previous; if (previous?.exploration === undefined) {
} return previous;
let materials = [ ...previous.exploration.materials ]; }
for (const request of recipe.requiredMaterials) { let materials = [ ...previous.exploration.materials ];
materials = materials.map((mat) => { for (const request of recipe.requiredMaterials) {
return mat.materialId === request.materialId materials = materials.map((mat) => {
? { ...mat, quantity: mat.quantity - request.quantity } return mat.materialId === request.materialId
: mat; ? { ...mat, quantity: mat.quantity - request.quantity }
}); : mat;
} });
return { }
...previous, return {
exploration: { ...previous,
...previous.exploration, exploration: {
craftedClickMultiplier: result.craftedClickMultiplier, ...previous.exploration,
craftedCombatMultiplier: result.craftedCombatMultiplier, craftedClickMultiplier: result.craftedClickMultiplier,
craftedEssenceMultiplier: result.craftedEssenceMultiplier, craftedCombatMultiplier: result.craftedCombatMultiplier,
craftedGoldMultiplier: result.craftedGoldMultiplier, craftedEssenceMultiplier: result.craftedEssenceMultiplier,
craftedRecipeIds: [ craftedGoldMultiplier: result.craftedGoldMultiplier,
...previous.exploration.craftedRecipeIds, craftedRecipeIds: [
recipeId, ...previous.exploration.craftedRecipeIds,
], recipeId,
materials: materials, ],
}, materials: materials,
}; },
}); };
});
} catch (error_: unknown) {
logError("craft_recipe", error_);
throw error_;
}
}, []); }, []);
const toggleAutoPrestige = useCallback(() => { const toggleAutoPrestige = useCallback(() => {
@@ -1663,6 +1826,8 @@ export const GameProvider = ({
}, []); }, []);
const toggleAutoBoss = useCallback(() => { const toggleAutoBoss = useCallback(() => {
setAutoBossError(null);
setAutoBossLastResult(null);
setState((previous) => { setState((previous) => {
if (previous === null) { if (previous === null) {
return previous; return previous;
@@ -1711,15 +1876,8 @@ export const GameProvider = ({
return applyBossResult(previous, bossId, result); return applyBossResult(previous, bossId, result);
}); });
setBattleResult({ bossName: boss.name, result: result }); setBattleResult({ bossName: boss.name, result: result });
if (result.won) { } catch (error_: unknown) {
if (enableSoundsReference.current) { logError("challenge_boss", error_);
playSound("bossVictory");
}
if (enableNotificationsReference.current) {
sendNotification("⚔️ Boss Defeated!", `You defeated ${boss.name}!`);
}
}
} catch {
// Silently ignore — server errors shouldn't crash the UI // Silently ignore — server errors shouldn't crash the UI
} }
}, []); }, []);
@@ -1733,6 +1891,38 @@ export const GameProvider = ({
setBattleResult(null); 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) => { const dismissAchievement = useCallback((id: string) => {
setUnlockedAchievements((previous) => { setUnlockedAchievements((previous) => {
return previous.filter((a) => { 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) => { const dismissStoryChapter = useCallback((id: string) => {
setUnlockedStoryChapterIds((previous) => { setUnlockedStoryChapterIds((previous) => {
return previous.filter((chapter) => { return previous.filter((chapter) => {
@@ -1820,6 +2020,8 @@ export const GameProvider = ({
const contextValue = useMemo<GameContextValue>(() => { const contextValue = useMemo<GameContextValue>(() => {
return { return {
apotheosis, apotheosis,
autoBossError,
autoBossLastResult,
battleResult, battleResult,
buyAdventurer, buyAdventurer,
buyEchoUpgrade, buyEchoUpgrade,
@@ -1829,18 +2031,26 @@ export const GameProvider = ({
challengeBoss, challengeBoss,
collectExploration, collectExploration,
completeChapter, completeChapter,
completedQuestToasts,
craftRecipe, craftRecipe,
currentSchemaVersion, currentSchemaVersion,
dismissAchievement, dismissAchievement,
dismissApotheosisToast,
dismissBattle, dismissBattle,
dismissCodexEntry, dismissCodexEntry,
dismissCompletedQuest,
dismissFailedQuest,
dismissLoginBonus, dismissLoginBonus,
dismissOfflineGold, dismissOfflineGold,
dismissPrestigeToast,
dismissStoryChapter, dismissStoryChapter,
dismissTranscendenceToast,
enableNotifications, enableNotifications,
enableSounds, enableSounds,
equipItem, equipItem,
error, error,
failedQuestToasts,
flushBossLoreToasts,
forceSync, forceSync,
formatNumber, formatNumber,
handleClick, handleClick,
@@ -1860,6 +2070,9 @@ export const GameProvider = ({
setEnableNotifications, setEnableNotifications,
setEnableSounds, setEnableSounds,
setNumberFormat, setNumberFormat,
showApotheosisToast,
showPrestigeToast,
showTranscendenceToast,
startExploration, startExploration,
startQuest, startQuest,
state, state,
@@ -1868,13 +2081,18 @@ export const GameProvider = ({
toggleAutoPrestige, toggleAutoPrestige,
toggleAutoQuest, toggleAutoQuest,
transcend, transcend,
triggerPrestigeToast,
unlockedAchievements, unlockedAchievements,
unlockedCodexEntryIds, unlockedCodexEntryIds,
unlockedStoryChapterIds, unlockedStoryChapterIds,
}; };
}, [ }, [
apotheosis, apotheosis,
autoBossError,
autoBossLastResult,
battleResult, battleResult,
completedQuestToasts,
failedQuestToasts,
formatNumber, formatNumber,
buyAdventurer, buyAdventurer,
buyEchoUpgrade, buyEchoUpgrade,
@@ -1887,15 +2105,21 @@ export const GameProvider = ({
craftRecipe, craftRecipe,
currentSchemaVersion, currentSchemaVersion,
dismissAchievement, dismissAchievement,
dismissApotheosisToast,
dismissBattle, dismissBattle,
dismissCodexEntry, dismissCodexEntry,
dismissCompletedQuest,
dismissFailedQuest,
dismissLoginBonus, dismissLoginBonus,
dismissOfflineGold, dismissOfflineGold,
dismissPrestigeToast,
dismissStoryChapter, dismissStoryChapter,
dismissTranscendenceToast,
enableNotifications, enableNotifications,
enableSounds, enableSounds,
equipItem, equipItem,
error, error,
flushBossLoreToasts,
forceSync, forceSync,
handleClick, handleClick,
isLoading, isLoading,
@@ -1914,6 +2138,9 @@ export const GameProvider = ({
setEnableNotifications, setEnableNotifications,
setEnableSounds, setEnableSounds,
setNumberFormat, setNumberFormat,
showApotheosisToast,
showPrestigeToast,
showTranscendenceToast,
startExploration, startExploration,
startQuest, startQuest,
state, state,
@@ -1922,6 +2149,7 @@ export const GameProvider = ({
toggleAutoPrestige, toggleAutoPrestige,
toggleAutoQuest, toggleAutoQuest,
transcend, transcend,
triggerPrestigeToast,
unlockedAchievements, unlockedAchievements,
unlockedCodexEntryIds, unlockedCodexEntryIds,
unlockedStoryChapterIds, unlockedStoryChapterIds,
+7 -1
View File
@@ -8,8 +8,12 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { App } from "./app.js"; import { App } from "./app.js";
import { ErrorBoundary } from "./components/errorBoundary.js";
import { initialiseFrontendLogger } from "./utils/logger.js";
import "./styles.css"; import "./styles.css";
initialiseFrontendLogger();
const rootElement = document.getElementById("root"); const rootElement = document.getElementById("root");
if (!rootElement) { if (!rootElement) {
@@ -18,6 +22,8 @@ if (!rootElement) {
createRoot(rootElement).render( createRoot(rootElement).render(
<StrictMode> <StrictMode>
<App /> <ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>, </StrictMode>,
); );
+136 -21
View File
@@ -26,6 +26,7 @@
--radius: 8px; --radius: 8px;
--radius-lg: 12px; --radius-lg: 12px;
--font: "Segoe UI", system-ui, sans-serif; --font: "Segoe UI", system-ui, sans-serif;
--resource-bar-height: 3.5rem;
} }
body { body {
@@ -33,6 +34,20 @@ body {
color: var(--colour-text); color: var(--colour-text);
font-family: var(--font); font-family: var(--font);
min-height: 100vh; min-height: 100vh;
position: relative;
}
body::before {
background-attachment: fixed;
background-image: url("https://cdn.nhcarrigan.com/elysium/background.jpg");
background-position: center;
background-size: cover;
content: "";
inset: 0;
opacity: 0.15;
pointer-events: none;
position: fixed;
z-index: -1;
} }
/* ===================== RESOURCE BAR ===================== */ /* ===================== RESOURCE BAR ===================== */
@@ -122,6 +137,10 @@ body {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
position: sticky;
top: var(--resource-bar-height);
height: calc(100vh - var(--resource-bar-height));
overflow-y: auto;
} }
.game-content { .game-content {
@@ -1432,20 +1451,6 @@ body {
z-index: 200; 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 { .toast-icon {
font-size: 1.5rem; font-size: 1.5rem;
flex-shrink: 0; flex-shrink: 0;
@@ -2070,8 +2075,11 @@ body {
opacity: 0.45; opacity: 0.45;
} }
.zone-emoji { .zone-tab-image {
font-size: 1.4rem; aspect-ratio: 16 / 9;
border-radius: 0.35rem;
object-fit: cover;
width: 96px;
} }
.zone-name { .zone-name {
@@ -2299,9 +2307,6 @@ body {
} }
.about-release-body { .about-release-body {
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--colour-text-secondary, #b0b0b0); color: var(--colour-text-secondary, #b0b0b0);
padding: 0 1rem 0.75rem; padding: 0 1rem 0.75rem;
@@ -2309,6 +2314,81 @@ body {
border-top: 1px solid var(--colour-border, #0f3460); border-top: 1px solid var(--colour-border, #0f3460);
} }
.about-release-body p {
margin: 0.4rem 0;
}
.about-release-body p:first-child {
margin-top: 0.5rem;
}
.about-release-body p:last-child {
margin-bottom: 0;
}
.about-release-body ul,
.about-release-body ol {
padding-left: 1.5rem;
margin: 0.4rem 0;
}
.about-release-body li {
margin-bottom: 0.2rem;
line-height: 1.5;
}
.about-release-body h1,
.about-release-body h2,
.about-release-body h3,
.about-release-body h4 {
color: var(--colour-accent);
font-size: 0.9rem;
font-weight: bold;
margin: 0.75rem 0 0.25rem;
}
.about-release-body h1:first-child,
.about-release-body h2:first-child,
.about-release-body h3:first-child,
.about-release-body h4:first-child {
margin-top: 0.5rem;
}
.about-release-body code {
background: rgba(255, 255, 255, 0.08);
border-radius: 3px;
padding: 0.1em 0.3em;
font-family: monospace;
font-size: 0.8rem;
}
.about-release-body pre {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
padding: 0.75rem;
overflow-x: auto;
margin: 0.4rem 0;
}
.about-release-body pre code {
background: none;
padding: 0;
}
.about-release-body a {
color: var(--colour-accent-light);
text-decoration: none;
}
.about-release-body a:hover {
text-decoration: underline;
}
.about-release-body strong {
color: var(--colour-text);
font-weight: bold;
}
.about-how-to-play { .about-how-to-play {
list-style: none; list-style: none;
padding: 0; padding: 0;
@@ -2481,8 +2561,8 @@ body {
padding: 0.6rem 0.75rem; padding: 0.6rem 0.75rem;
} }
/* Codex toast — uses a different accent from achievement toast */ /* Unified game toast — essence-coloured border used by all in-game notifications */
.codex-toast { .game-toast {
align-items: center; align-items: center;
animation: slide-in-right 0.35s ease-out; animation: slide-in-right 0.35s ease-out;
background: var(--colour-surface); background: var(--colour-surface);
@@ -3106,8 +3186,11 @@ body {
border-right: none; border-right: none;
flex-direction: row; flex-direction: row;
gap: 0.75rem; gap: 0.75rem;
height: auto;
justify-content: center; justify-content: center;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
position: static;
top: auto;
width: 100%; width: 100%;
} }
@@ -4400,3 +4483,35 @@ body {
font-size: 0.8rem; font-size: 0.8rem;
font-style: italic; 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 * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import { logError } from "./logError.js";
/** /**
* Requests browser notification permission from the user. * Requests browser notification permission from the user.
@@ -38,7 +39,8 @@ const sendNotification = (title: string, body: string): void => {
try { try {
// eslint-disable-next-line no-new -- Notification constructor has side effects // eslint-disable-next-line no-new -- Notification constructor has side effects
new Notification(title, { body: body, icon: "/favicon.ico" }); new Notification(title, { body: body, icon: "/favicon.ico" });
} catch { } catch (error_: unknown) {
logError("send_notification", error_);
// Silently ignore — notifications may fail silently // Silently ignore — notifications may fail silently
} }
}; };
+3 -1
View File
@@ -4,6 +4,7 @@
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import { logError } from "./logError.js";
type SoundEvent = type SoundEvent =
| "achievement" | "achievement"
@@ -101,7 +102,8 @@ const playSound = (event: SoundEvent): void => {
oscillator.start(startTime); oscillator.start(startTime);
oscillator.stop(endTime); oscillator.stop(endTime);
} }
} catch { } catch (error_: unknown) {
logError("play_sound", error_);
// Silently ignore — audio may not be available in all environments // Silently ignore — audio may not be available in all environments
} }
}; };
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "elysium", "name": "elysium",
"version": "0.1.0", "version": "0.1.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/types", "name": "@elysium/types",
"version": "0.1.0", "version": "0.1.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
+6
View File
@@ -12,6 +12,7 @@ import type {
import type { GameState } from "./gameState.js"; import type { GameState } from "./gameState.js";
import type { Player } from "./player.js"; import type { Player } from "./player.js";
import type { ProfileSettings } from "./profileSettings.js"; import type { ProfileSettings } from "./profileSettings.js";
import type { CompletedChapter } from "./story.js";
interface AuthResponse { interface AuthResponse {
token: string; token: string;
@@ -247,6 +248,11 @@ interface PublicProfileResponse {
rarity: EquipmentRarity; rarity: EquipmentRarity;
bonus: EquipmentBonus; bonus: EquipmentBonus;
}>; }>;
/**
* Story chapters the player has completed and their chosen outcomes.
*/
completedChapters: Array<CompletedChapter>;
} }
interface UpdateProfileRequest { interface UpdateProfileRequest {
+7
View File
@@ -59,6 +59,13 @@ interface Boss {
* One-time runestone bounty awarded on first-ever defeat. * One-time runestone bounty awarded on first-ever defeat.
*/ */
bountyRunestones: number; bountyRunestones: number;
/**
* Whether the first-kill runestone bounty has already been claimed.
* Set to true on first defeat and preserved across all prestiges so the
* bounty is never re-awarded in subsequent runs.
*/
bountyRunestonesClaimed?: boolean;
} }
export type { Boss, BossStatus }; export type { Boss, BossStatus };
+269 -201
View File
@@ -1,4 +1,5 @@
/* eslint-disable max-lines -- story data file necessarily exceeds line limit */ /* 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. * @file Story chapter types and data for the Elysium game.
* @copyright nhcarrigan * @copyright nhcarrigan
@@ -9,9 +10,10 @@ import type { Boss } from "./boss.js";
import type { GameState } from "./gameState.js"; import type { GameState } from "./gameState.js";
interface StoryChoice { interface StoryChoice {
id: string; description: string;
label: string; id: string;
outcome: string; label: string;
outcome: string;
} }
interface StoryChapter { interface StoryChapter {
@@ -88,23 +90,26 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "resolve", description: "Accepted the map with quiet resolve, already looking east.",
label: "Accept the map with quiet resolve", id: "resolve",
outcome: `You folded the map carefully and tucked it away. Resolve was the only` label: "Accept the map with quiet resolve",
outcome: `You folded the map carefully and tucked it away. Resolve was the only`
+ ` currency you had in abundance. The cartographer watched you go and thought:` + ` currency you had in abundance. The cartographer watched you go and thought:`
+ ` this one has the look of someone who finishes things.`, + ` this one has the look of someone who finishes things.`,
}, },
{ {
id: "people", description: "Turned back to their people first — some leaders are built for their guild.",
label: "Return immediately to your people", id: "people",
outcome: `Your first thought was of your guild — of wounds to tend and rest` label: "Return immediately to your people",
outcome: `Your first thought was of your guild — of wounds to tend and rest`
+ ` hard-earned. The cartographer smiled at your back. Some leaders are built for` + ` hard-earned. The cartographer smiled at your back. Some leaders are built for`
+ ` glory; some are built for their people. You were becoming the latter.`, + ` glory; some are built for their people. You were becoming the latter.`,
}, },
{ {
id: "plan", description: "Studied the map in silence, already charting the next move.",
label: "Study it in silence, already planning", id: "plan",
outcome: `Your eyes moved across the map before she'd even finished speaking. The` label: "Study it in silence, already planning",
outcome: `Your eyes moved across the map before she'd even finished speaking. The`
+ ` forest had only been the first line of a much longer story. You were already` + ` forest had only been the first line of a much longer story. You were already`
+ ` writing the next.`, + ` writing the next.`,
}, },
@@ -129,24 +134,27 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "listen", description: "Stayed to hear the scholar's findings, filing every warning about what had ended the city.",
label: "Ask the scholar what she has learned", id: "listen",
outcome: `You stayed long enough to listen. The scholar was cautious with her theories` label: "Ask the scholar what she has learned",
outcome: `You stayed long enough to listen. The scholar was cautious with her theories`
+ ` but certain of one thing: the people who had built this place had been powerful,` + ` but certain of one thing: the people who had built this place had been powerful,`
+ ` and their end had come from somewhere far beyond the Vale. You filed that` + ` and their end had come from somewhere far beyond the Vale. You filed that`
+ ` knowledge away like a sharp blade.`, + ` knowledge away like a sharp blade.`,
}, },
{ {
id: "claim", description: "Claimed the ancient hall as a waystation — filling old bones with new purpose.",
label: "Claim the hall as a guild waystation", id: "claim",
outcome: `The ruins needed purpose more than they needed silence. Your guild cleared` label: "Claim the hall as a guild waystation",
outcome: `The ruins needed purpose more than they needed silence. Your guild cleared`
+ ` rubble, shored up walls, and lit fires in hearths that hadn't been warm in an` + ` rubble, shored up walls, and lit fires in hearths that hadn't been warm in an`
+ ` age. Whatever had ended the people here, it would not end you.`, + ` age. Whatever had ended the people here, it would not end you.`,
}, },
{ {
id: "press", description: "Marked the ruin on the chart and pressed on. History could wait.",
label: "Mark it on your chart and press on", id: "press",
outcome: `There would be time for history later. You marked the ruin on your chart` label: "Mark it on your chart and press on",
outcome: `There would be time for history later. You marked the ruin on your chart`
+ ` with a careful hand and turned your face toward the horizon. The past could` + ` with a careful hand and turned your face toward the horizon. The past could`
+ ` wait; the future wouldn't.`, + ` wait; the future wouldn't.`,
}, },
@@ -171,23 +179,26 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "ask", description: "Asked what darker things lay deeper in the marsh, and listened carefully.",
label: "Ask what lies deeper in the marshes", id: "ask",
outcome: `He told you what the marsh-folk knew: that the darkness didn't end at the` 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`
+ ` Kraken, that there were seams of shadow that ran all the way to the world's edge.` + ` Kraken, that there were seams of shadow that ran all the way to the world's edge.`
+ ` You thanked him and kept that information close.`, + ` You thanked him and kept that information close.`,
}, },
{ {
id: "lantern", description: "Accepted the lantern and moved on, carrying light into whatever came next.",
label: "Accept the lantern and move on", id: "lantern",
outcome: `You took the lantern. Light against darkness — it was a simple philosophy,` label: "Accept the lantern and move on",
outcome: `You took the lantern. Light against darkness — it was a simple philosophy,`
+ ` but it had served you well enough so far. The ferryman watched your guild` + ` but it had served you well enough so far. The ferryman watched your guild`
+ ` disappear into the mist and smiled, alone.`, + ` disappear into the mist and smiled, alone.`,
}, },
{ {
id: "rest", description: "Chose to rest with the marsh villages first, giving the guild time to heal.",
label: "Rest with the marsh villages first", id: "rest",
outcome: `Three days of sleeping on dry ground and eating hot food did more for your` label: "Rest with the marsh villages first",
outcome: `Three days of sleeping on dry ground and eating hot food did more for your`
+ ` guild than any potion. The marsh-folk gave generously and asked nothing. You left` + ` guild than any potion. The marsh-folk gave generously and asked nothing. You left`
+ ` them safer than you'd found them.`, + ` them safer than you'd found them.`,
}, },
@@ -213,23 +224,26 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "study", description: "Took the monk's journal and studied it carefully, preparing for what was coming.",
label: "Take the journal and study it carefully", id: "study",
outcome: `The journal became essential reading for your strongest strategists. The` label: "Take the journal and study it carefully",
outcome: `The journal became essential reading for your strongest strategists. The`
+ ` monk had been meticulous; his observations mapped a pattern that wasn't` + ` monk had been meticulous; his observations mapped a pattern that wasn't`
+ ` comforting. You began preparing for something larger than any single battle.`, + ` comforting. You began preparing for something larger than any single battle.`,
}, },
{ {
id: "promise", description: "Promised to return with answers, carrying the old monk's question as a compass.",
label: "Promise to return with answers", id: "promise",
outcome: `You couldn't take the old man down the mountain, but you could carry his` label: "Promise to return with answers",
outcome: `You couldn't take the old man down the mountain, but you could carry his`
+ ` question. The promise you made on that peak became something you returned to` + ` question. The promise you made on that peak became something you returned to`
+ ` often, in the quiet hours — a compass of its own.`, + ` often, in the quiet hours — a compass of its own.`,
}, },
{ {
id: "inquire", description: "Asked the monk what he believed was causing it, and descended with new understanding.",
label: "Ask the monk what he believes is causing it", id: "inquire",
outcome: `He didn't answer immediately. When he did, the words were careful: 'I think` 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`
+ ` something learned that it could come here. And now it knows the way.' You` + ` something learned that it could come here. And now it knows the way.' You`
+ ` descended the mountain knowing that the way in was also the way back.`, + ` descended the mountain knowing that the way in was also the way back.`,
}, },
@@ -255,22 +269,25 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "feather", description: "Kept the phoenix feather — not a trophy, but a question not yet answered.",
label: "Keep the feather as a reminder", id: "feather",
outcome: `You carried the feather in a sealed case from that day forward — not as a` label: "Keep the feather as a reminder",
outcome: `You carried the feather in a sealed case from that day forward — not as a`
+ ` trophy, but as a question you hadn't answered yet. What are you protecting? The` + ` trophy, but as a question you hadn't answered yet. What are you protecting? The`
+ ` question sharpened you.`, + ` question sharpened you.`,
}, },
{ {
id: "people", description: "Answered plainly: the guild protects its people. A truth held without wavering.",
label: "Tell her: you protect your people", id: "people",
outcome: `'Then don't lose them,' she said simply. It wasn't a warning. It was the` 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.`, + ` closest thing to a blessing the volcanic depths had to offer.`,
}, },
{ {
id: "beyond", description: "Asked what lay beyond the fire, and carried the uncertainty forward like a live coal.",
label: "Ask what she thinks lies beyond the fire", id: "beyond",
outcome: `'Something that cannot burn,' she said, after a long pause. 'Something that` label: "Ask what she thinks lies beyond the fire",
outcome: `'Something that cannot burn,' she said, after a long pause. 'Something that`
+ ` has never needed to.' You weren't sure if that was reassuring. You carried the` + ` has never needed to.' You weren't sure if that was reassuring. You carried the`
+ ` uncertainty with you like a coal.`, + ` uncertainty with you like a coal.`,
}, },
@@ -297,24 +314,27 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "fight", description: "Said it plainly: small, and yet fighting anyway. A philosophy that spread far.",
label: "Yes — and we fight anyway", id: "fight",
outcome: `The philosopher wrote that down. She published it later, in an obscure` label: "Yes — and we fight anyway",
outcome: `The philosopher wrote that down. She published it later, in an obscure`
+ ` academic tract that circulated far wider than she'd expected. Small, and yet. And` + ` academic tract that circulated far wider than she'd expected. Small, and yet. And`
+ ` yet. And yet.`, + ` yet. And yet.`,
}, },
{ {
id: "further", description: "Asked what lay further out — and made sure that when noticed, it would be their mistake.",
label: "Ask what she thinks is further out", id: "further",
outcome: `She smiled, the way people smile when they've been waiting for the question.` label: "Ask what she thinks is further out",
outcome: `She smiled, the way people smile when they've been waiting for the question.`
+ ` 'Minds,' she said. 'Ancient, patient, watching. The question is whether they've` + ` 'Minds,' she said. 'Ancient, patient, watching. The question is whether they've`
+ ` noticed us yet.' You decided to make sure, when they did, that noticing you would` + ` noticed us yet.' You decided to make sure, when they did, that noticing you would`
+ ` be a mistake.`, + ` be a mistake.`,
}, },
{ {
id: "honest", description: "Admitted the silence of the Void still echoed inside, and let time fill it back in.",
label: "Admit the silence still echoes in you", id: "honest",
outcome: `She nodded, unsurprised. 'It does that. To everyone who goes there and comes` label: "Admit the silence still echoes in you",
outcome: `She nodded, unsurprised. 'It does that. To everyone who goes there and comes`
+ ` back.' She poured two cups of something hot and handed you one. 'The trick is to` + ` back.' She poured two cups of something hot and handed you one. 'The trick is to`
+ ` let the sound fill back in. Give it time.'`, + ` let the sound fill back in. Give it time.'`,
}, },
@@ -342,22 +362,25 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "memory", description: "Chose to carry the names of those who hadn't made it — weight and compass both.",
label: "Carry forward the memory of those lost", id: "memory",
outcome: `The names. The faces. The ones who hadn't made it as far as this height. You` 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.`, + ` held them as a weight and a compass both, and continued with your eyes open.`,
}, },
{ {
id: "will", description: "Chose to carry the will to finish it: one step, then another, without stopping.",
label: "Carry forward the will to finish it", id: "will",
outcome: `The work was not done. The scale of it had grown, but the work remained:` label: "Carry forward the will to finish it",
outcome: `The work was not done. The scale of it had grown, but the work remained:`
+ ` take one more step, and then another, and do not stop until the last thing is` + ` take one more step, and then another, and do not stop until the last thing is`
+ ` settled. You were not built to leave things undone.`, + ` settled. You were not built to leave things undone.`,
}, },
{ {
id: "wonder", description: "Chose to carry wonder deliberately, refusing to become something cold and certain.",
label: "Carry forward wonder, against hardness", id: "wonder",
outcome: `It would have been easy, up here, to become something cold and certain. You` label: "Carry forward wonder, against hardness",
outcome: `It would have been easy, up here, to become something cold and certain. You`
+ ` chose differently. The capacity to be astonished — by starlight, by loyalty, by` + ` chose differently. The capacity to be astonished — by starlight, by loyalty, by`
+ ` the improbable fact of still being alive — you held on to that deliberately.`, + ` the improbable fact of still being alive — you held on to that deliberately.`,
}, },
@@ -384,24 +407,27 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "ask", description: "Asked what the naturalist thought was falling, and received an unsettling answer.",
label: "Ask what he thinks is falling", id: "ask",
outcome: `'Pressure,' he said. 'The kind that builds when too many powers concentrate` label: "Ask what he thinks is falling",
outcome: `'Pressure,' he said. 'The kind that builds when too many powers concentrate`
+ ` in one place. When too much of the world's weight tips in a single direction.' He` + ` in one place. When too much of the world's weight tips in a single direction.' He`
+ ` looked at you with an expression that was half-admiration, half-concern. You noted` + ` looked at you with an expression that was half-admiration, half-concern. You noted`
+ ` that he did not look away.`, + ` that he did not look away.`,
}, },
{ {
id: "accept", description: "Accepted that some things couldn't be predicted, holding the uncertainty like ballast.",
label: "Accept that some things can't be predicted", id: "accept",
outcome: `Not everything could be prepared for. This was a truth you had learned the` label: "Accept that some things can't be predicted",
outcome: `Not everything could be prepared for. This was a truth you had learned the`
+ ` hard way, and you'd learned it well enough to stop fighting it. You watched the` + ` hard way, and you'd learned it well enough to stop fighting it. You watched the`
+ ` surface settle and held the uncertainty like ballast.`, + ` surface settle and held the uncertainty like ballast.`,
}, },
{ {
id: "document", description: "Spent the return voyage writing — a record of pattern for whoever came after.",
label: "Document everything for whoever comes next", id: "document",
outcome: `If something woke what slept below, there would be others who needed to` label: "Document everything for whoever comes next",
outcome: `If something woke what slept below, there would be others who needed to`
+ ` know. You spent the return voyage writing — a record not of victory, but of` + ` know. You spent the return voyage writing — a record not of victory, but of`
+ ` pattern, for the eyes of whoever followed after.`, + ` pattern, for the eyes of whoever followed after.`,
}, },
@@ -427,24 +453,27 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "learn", description: "Asked the spirit what they had been warned about, and filed the answer carefully.",
label: "Ask what they were warned about", id: "learn",
outcome: `The spirit answered slowly, in the manner of things that have had too much` label: "Ask what they were warned about",
outcome: `The spirit answered slowly, in the manner of things that have had too much`
+ ` time to think. The warning had been about the Void — about the hunger at the edge` + ` time to think. The warning had been about the Void — about the hunger at the edge`
+ ` of everything. They had believed themselves beyond reach. You filed this away as` + ` of everything. They had believed themselves beyond reach. You filed this away as`
+ ` a lesson.`, + ` a lesson.`,
}, },
{ {
id: "silence", description: "Acknowledged the warning and left without a word, carrying a weight not unearned.",
label: "Acknowledge the warning and leave in silence", id: "silence",
outcome: `Some moments asked for silence. You gave it. The spirit seemed grateful, in` label: "Acknowledge the warning and leave in silence",
outcome: `Some moments asked for silence. You gave it. The spirit seemed grateful, in`
+ ` its way — acknowledged rather than dismissed. You left the court with a weight on` + ` its way — acknowledged rather than dismissed. You left the court with a weight on`
+ ` you that was not unearned.`, + ` you that was not unearned.`,
}, },
{ {
id: "vow", description: "Vowed the guild would not make the same mistake, and was watched all the way to the door.",
label: "Vow your guild won't make the same mistake", id: "vow",
outcome: `The spirit looked at you for a long time. 'That is what they said too,' it` 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`
+ ` finally replied. But it did not say it unkindly. And it watched you all the way` + ` finally replied. But it did not say it unkindly. And it watched you all the way`
+ ` to the door.`, + ` to the door.`,
}, },
@@ -471,23 +500,26 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "better", description: "Told the crystallographer the balance was not as bad as feared, and meant it.",
label: "Not as bad as I feared", id: "better",
outcome: `The crystallographer looked relieved in a way that surprised you — as though` label: "Not as bad as I feared",
outcome: `The crystallographer looked relieved in a way that surprised you — as though`
+ ` your answer was the one she'd needed to hear too. The balance of your guild was` + ` your answer was the one she'd needed to hear too. The balance of your guild was`
+ ` its people, more than its victories. You had not forgotten that. Not yet.`, + ` its people, more than its victories. You had not forgotten that. Not yet.`,
}, },
{ {
id: "expected", description: "Said the ledger showed exactly what was expected. Honest accounting, nothing more.",
label: "Exactly what I expected", id: "expected",
outcome: `'Then you have been paying attention,' she said, quietly approving. 'That is` label: "Exactly what I expected",
outcome: `'Then you have been paying attention,' she said, quietly approving. 'That is`
+ ` rarer than it should be.' Honesty about your own ledger was its own form of` + ` rarer than it should be.' Honesty about your own ledger was its own form of`
+ ` discipline.`, + ` discipline.`,
}, },
{ {
id: "quiet", description: "Said nothing of the balance. The ones who stay quiet are usually telling the truth.",
label: "I don't think I'm the one who should say", id: "quiet",
outcome: `She nodded slowly. 'The ones who say nothing are usually telling the truth,'` 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,'`
+ ` she said. There was no judgment in it. Only recognition.`, + ` she said. There was no judgment in it. Only recognition.`,
}, },
], ],
@@ -512,23 +544,26 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "sit", description: "Sat in the silence before leaving, letting the emptiness speak what it could.",
label: "Let the silence sit before leaving", id: "sit",
outcome: `Wisdom, sometimes, is the willingness to remain still in an uncomfortable` label: "Let the silence sit before leaving",
outcome: `Wisdom, sometimes, is the willingness to remain still in an uncomfortable`
+ ` place long enough to understand it. You sat. The silence told you what it could.` + ` place long enough to understand it. You sat. The silence told you what it could.`
+ ` When you left, you took that understanding with you.`, + ` When you left, you took that understanding with you.`,
}, },
{ {
id: "record", description: "Filled pages on the return, documenting the Void Emperor's nature for what lay ahead.",
label: "Record the Void Emperor's nature carefully", id: "record",
outcome: `If the Void had sent its best, it would send something different next time.` label: "Record the Void Emperor's nature carefully",
outcome: `If the Void had sent its best, it would send something different next time.`
+ ` Documentation was not heroism, but it was its own form of readiness. You filled` + ` Documentation was not heroism, but it was its own form of readiness. You filled`
+ ` pages on the return.`, + ` pages on the return.`,
}, },
{ {
id: "rally", description: "Rallied the guild before relief could settle. The Void had pulled back, not retreated.",
label: "Rally the guild — the work isn't done", id: "rally",
outcome: `There was no room for relief yet. The Void had pulled back, but pulling back` 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`
+ ` was not retreating. You said this to your guild and they already knew it. That` + ` was not retreating. You said this to your guild and they already knew it. That`
+ ` was the measure of how far you had all come.`, + ` was the measure of how far you had all come.`,
}, },
@@ -553,23 +588,26 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "walk", description: "Turned their back on the throne and led the guild out. Not every power needs claiming.",
label: "Walk away from the throne", id: "walk",
outcome: `You turned your back on it and led your guild out. Not every power needs to` label: "Walk away from the throne",
outcome: `You turned your back on it and led your guild out. Not every power needs to`
+ ` be claimed. Not every throne needs an occupant. The room was quieter when you` + ` be claimed. Not every throne needs an occupant. The room was quieter when you`
+ ` left. You thought it might have been grateful.`, + ` left. You thought it might have been grateful.`,
}, },
{ {
id: "stand", description: "Stood at the throne's foot, acknowledged its weight, then turned toward the door.",
label: "Stand at its foot and make a decision", id: "stand",
outcome: `You did not sit. But you acknowledged it — the gravity of everything it` label: "Stand at its foot and make a decision",
outcome: `You did not sit. But you acknowledged it — the gravity of everything it`
+ ` represented, the cost and the weight and the long history. And then you looked` + ` represented, the cost and the weight and the long history. And then you looked`
+ ` away from it and toward the door, and that was its own kind of answer.`, + ` away from it and toward the door, and that was its own kind of answer.`,
}, },
{ {
id: "declare", description: "Declared aloud that power is held in trust — and the guild held that for a long time.",
label: "Declare that power is held in trust", id: "declare",
outcome: `The throne hummed louder, then quieter. You weren't sure if that was` label: "Declare that power is held in trust",
outcome: `The throne hummed louder, then quieter. You weren't sure if that was`
+ ` agreement or only vibration. But your guild heard you, and they held onto those` + ` agreement or only vibration. But your guild heard you, and they held onto those`
+ ` words for a long time afterward.`, + ` words for a long time afterward.`,
}, },
@@ -594,22 +632,25 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "before", description: "Asked what came before the before — accepted it had no shape yet, and moved on.",
label: "Ask what came before the before", id: "before",
outcome: `Silence. Then: That is not a question with a shape yet. You decided to` 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.`, + ` accept that as an answer and move forward.`,
}, },
{ {
id: "worth", description: "Affirmed that what was built is worth defending — the chaos agreed.",
label: "Affirm that what was built is worth defending", id: "worth",
outcome: `Yes, said the voice. That is why it has lasted. You were not sure what to` 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`
+ ` do with a compliment from the primordial chaos, but you received it with the` + ` do with a compliment from the primordial chaos, but you received it with the`
+ ` sincerity it was offered.`, + ` sincerity it was offered.`,
}, },
{ {
id: "fixed", description: "Stood in the chaos and felt their own solidity — specific, named, and decided.",
label: "Stand in the chaos and feel your own solidity", id: "fixed",
outcome: `Whatever you were — guild leader, fighter, something increasingly harder to` label: "Stand in the chaos and feel your own solidity",
outcome: `Whatever you were — guild leader, fighter, something increasingly harder to`
+ ` categorise — you were specific. Named. Decided. In the midst of all this` + ` categorise — you were specific. Named. Decided. In the midst of all this`
+ ` undecidedness, you were a fixed point, and that was enough.`, + ` undecidedness, you were a fixed point, and that was enough.`,
}, },
@@ -634,23 +675,26 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "stay", description: "Stayed with a weeping scout without a word, offering presence. It was what was needed.",
label: "Sit with your scout until the feeling passed", id: "stay",
outcome: `You stayed. There was no trick to it, no words that helped more than the` 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`
+ ` simple fact of not being alone. The scout looked at you later with a complicated` + ` simple fact of not being alone. The scout looked at you later with a complicated`
+ ` expression that was mostly gratitude.`, + ` expression that was mostly gratitude.`,
}, },
{ {
id: "small", description: "Acknowledged the scale — and found the audacity in their smallness to persist.",
label: "Acknowledge the scale — and your smallness", id: "small",
outcome: `Big was not the same as better. The Expanse was infinite. Your guild was` label: "Acknowledge the scale — and your smallness",
outcome: `Big was not the same as better. The Expanse was infinite. Your guild was`
+ ` finite. And yet something in you had the audacity to persist in finite space and` + ` finite. And yet something in you had the audacity to persist in finite space and`
+ ` say: we are still here. You could live with that audacity.`, + ` say: we are still here. You could live with that audacity.`,
}, },
{ {
id: "plan", description: "Began planning immediately — and their scout looked on with fond exasperation.",
label: "Begin immediately planning the next move", id: "plan",
outcome: `Movement was your steadiest anchor. Your scout caught you making notes and` label: "Begin immediately planning the next move",
outcome: `Movement was your steadiest anchor. Your scout caught you making notes and`
+ ` shook their head, half exasperated and half relieved to see you so thoroughly` + ` shook their head, half exasperated and half relieved to see you so thoroughly`
+ ` yourself. You both knew it meant you were going to be all right.`, + ` yourself. You both knew it meant you were going to be all right.`,
}, },
@@ -676,23 +720,26 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "intact", description: "Left the Forge as found — wisdom in knowing what not to change.",
label: "Accept the invitation; leave the Forge intact", id: "intact",
outcome: `The Forge continued its quiet work. You left it as you found it, not because` label: "Accept the invitation; leave the Forge intact",
outcome: `The Forge continued its quiet work. You left it as you found it, not because`
+ ` you lacked the power to change it, but because some things had been put in place` + ` you lacked the power to change it, but because some things had been put in place`
+ ` by wiser hands than yours, and wisdom lay in knowing the difference.`, + ` by wiser hands than yours, and wisdom lay in knowing the difference.`,
}, },
{ {
id: "add", description: "Added a small notation to the blueprints, on the principle of memory.",
label: "Add a small note to the blueprints", id: "add",
outcome: `Your addition was modest — almost invisible. A small notation in the margin` label: "Add a small note to the blueprints",
outcome: `Your addition was modest — almost invisible. A small notation in the margin`
+ ` of the principle of memory: and what is remembered by those who choose to` + ` of the principle of memory: and what is remembered by those who choose to`
+ ` remember. Whether it had any effect, you never knew. You left it there anyway.`, + ` remember. Whether it had any effect, you never knew. You left it there anyway.`,
}, },
{ {
id: "write", description: "Documented what the Forge was — strange notes, accurate ones, for whoever needed them.",
label: "Write down what you observed, for others", id: "write",
outcome: `Documentation felt inadequate for what the Forge was. You did it anyway. The` label: "Write down what you observed, for others",
outcome: `Documentation felt inadequate for what the Forge was. You did it anyway. The`
+ ` notes would be strange, but they would be accurate, and accuracy was the only` + ` notes would be strange, but they would be accurate, and accuracy was the only`
+ ` thing the Forge itself seemed to care about.`, + ` thing the Forge itself seemed to care about.`,
}, },
@@ -718,23 +765,26 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "comfort", description: "Found it comforting. The stars persisted; so did what had been done in the time between.",
label: "Find it comforting — the universe persists", id: "comfort",
outcome: `The permanence of the stars was a kind of promise. What existed before you` label: "Find it comforting — the universe persists",
outcome: `The permanence of the stars was a kind of promise. What existed before you`
+ ` would exist after you, and what you did in the time between was not erased by` + ` would exist after you, and what you did in the time between was not erased by`
+ ` scale. You held onto this.`, + ` scale. You held onto this.`,
}, },
{ {
id: "grief", description: "Found it terrible — and turned back to their people, where the grief was real and theirs.",
label: "Find it terrible — your losses are not small", id: "grief",
outcome: `Your guild had bled for this. The grief of it was real and specific and` 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`
+ ` theirs, and the indifference of the cosmos did not diminish it. You turned away` + ` theirs, and the indifference of the cosmos did not diminish it. You turned away`
+ ` from the stars and toward your people.`, + ` from the stars and toward your people.`,
}, },
{ {
id: "present", description: "Found it neither — stood in the moment, let it be what it was, and called that enough.",
label: "Find it neither — just be present", id: "present",
outcome: `Sometimes a moment did not need interpretation. You stood in it. It was what` label: "Find it neither — just be present",
outcome: `Sometimes a moment did not need interpretation. You stood in it. It was what`
+ ` it was. The stars were what they were. That was enough, for now.`, + ` it was. The stars were what they were. That was enough, for now.`,
}, },
], ],
@@ -758,24 +808,27 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "weight", description: "Chose to carry the weight of all that came before — none of it unacknowledged.",
label: "Carry the weight of all that came before", id: "weight",
outcome: `The generations that had built the world — the forgotten, the unnamed, the` label: "Carry the weight of all that came before",
outcome: `The generations that had built the world — the forgotten, the unnamed, the`
+ ` ones whose courage made your existence possible — you acknowledged them. You were` + ` ones whose courage made your existence possible — you acknowledged them. You were`
+ ` not the beginning. You were what they had been working toward. That felt like` + ` not the beginning. You were what they had been working toward. That felt like`
+ ` enough.`, + ` enough.`,
}, },
{ {
id: "chosen", description: "Chose only what could be carried: the things that were truly theirs.",
label: "Carry only what you chose", id: "chosen",
outcome: `You could not carry everything. The weight would have stopped you where you` label: "Carry only what you chose",
outcome: `You could not carry everything. The weight would have stopped you where you`
+ ` stood. You chose carefully — the things that were yours, the things that mattered,` + ` stood. You chose carefully — the things that were yours, the things that mattered,`
+ ` the things that would survive the carrying.`, + ` the things that would survive the carrying.`,
}, },
{ {
id: "waste", description: "Chose the intention not to waste what they had reached, and made it real.",
label: "Carry the intention not to waste this", id: "waste",
outcome: `You had arrived somewhere very few had. What you did next would define what` label: "Carry the intention not to waste this",
outcome: `You had arrived somewhere very few had. What you did next would define what`
+ ` arriving here meant. You did not intend to waste it.`, + ` arriving here meant. You did not intend to waste it.`,
}, },
], ],
@@ -801,24 +854,27 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "yes", description: "Said yes without hesitation. Would have done it all again. The certainty was complete.",
label: "Yes — without hesitation", id: "yes",
outcome: `There was nothing complicated in it. The weight, the cost, the long road —` label: "Yes — without hesitation",
outcome: `There was nothing complicated in it. The weight, the cost, the long road —`
+ ` you would have done it again. Would do it again. The certainty was quiet and` + ` you would have done it again. Would do it again. The certainty was quiet and`
+ ` complete, and that was the most honest thing you had ever known.`, + ` complete, and that was the most honest thing you had ever known.`,
}, },
{ {
id: "cost", description: "Said yes, though the cost was real — holding both the loss and the worth without flinching.",
label: "Yes — though the cost was real", id: "cost",
outcome: `The acknowledgement of loss did not diminish the worth of it. Things had` label: "Yes — though the cost was real",
outcome: `The acknowledgement of loss did not diminish the worth of it. Things had`
+ ` been spent that could not be recovered. That was true. And the answer was still` + ` been spent that could not be recovered. That was true. And the answer was still`
+ ` yes. Holding both of those things at once was the truest thing you had ever` + ` yes. Holding both of those things at once was the truest thing you had ever`
+ ` managed.`, + ` managed.`,
}, },
{ {
id: "becoming", description: "Said the answer was still being written, and walked forward — as they always had.",
label: "I am still becoming the answer", id: "becoming",
outcome: `The journey had not ended. The Absolute was a chapter, not a conclusion. You` label: "I am still becoming the answer",
outcome: `The journey had not ended. The Absolute was a chapter, not a conclusion. You`
+ ` were still writing the rest of it. That was neither modesty nor avoidance — it` + ` were still writing the rest of it. That was neither modesty nor avoidance — it`
+ ` was honesty. You left the silence of the Absolute and walked forward, because` + ` was honesty. You left the silence of the Absolute and walked forward, because`
+ ` walking forward was what you did.`, + ` walking forward was what you did.`,
@@ -845,24 +901,27 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "know", description: "Told the guild: we know the way. The lessons passed forward to those who came next.",
label: "Tell the guild: we know the way", id: "know",
outcome: `The veterans who had made this choice with you nodded. The newer members` label: "Tell the guild: we know the way",
outcome: `The veterans who had made this choice with you nodded. The newer members`
+ ` looked uncertain. You had both in your guild, and that was the point — the` + ` looked uncertain. You had both in your guild, and that was the point — the`
+ ` knowledge passed forward, the lessons given to those who hadn't yet paid for` + ` knowledge passed forward, the lessons given to those who hadn't yet paid for`
+ ` them. That was the real economy of prestige.`, + ` them. That was the real economy of prestige.`,
}, },
{ {
id: "work", description: "Began again without ceremony — the work was what mattered.",
label: "Begin immediately, without ceremony", id: "work",
outcome: `There was a kind of respect in not making a production of it. The work was` label: "Begin immediately, without ceremony",
outcome: `There was a kind of respect in not making a production of it. The work was`
+ ` what mattered. The ceremony could wait for a summit that didn't keep moving. You` + ` what mattered. The ceremony could wait for a summit that didn't keep moving. You`
+ ` set to work, and your guild followed, and that was the whole of the ritual.`, + ` set to work, and your guild followed, and that was the whole of the ritual.`,
}, },
{ {
id: "rest", description: "Took one day. The guild rested, healed, and said things urgency hadn't left room for.",
label: "Take a single day to rest before restarting", id: "rest",
outcome: `One day. You had earned it, and so had they. The guild rested, and healed,` 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,`
+ ` and ate without rushing, and said things to each other that the urgency of the` + ` and ate without rushing, and said things to each other that the urgency of the`
+ ` climb hadn't left room for. On the second morning you began again, and you began` + ` climb hadn't left room for. On the second morning you began again, and you began`
+ ` stronger.`, + ` stronger.`,
@@ -891,23 +950,26 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "speak", description: "Spoke honestly without preparation — the guild believed it, and that was the whole of it.",
label: "Speak to the guild about why you keep going", id: "speak",
outcome: `You hadn't planned to say anything, and what you said wasn't polished. But` 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`
+ ` it was honest, and your guild heard it that way, and the room got quieter in the` + ` it was honest, and your guild heard it that way, and the room got quieter in the`
+ ` good way — the way of people deciding to believe in something together.`, + ` good way — the way of people deciding to believe in something together.`,
}, },
{ {
id: "listen", description: "Let the gathering speak for itself, and was grateful.",
label: "Let the gathering speak for itself", id: "listen",
outcome: `Sometimes leadership was knowing when not to speak. The guild had found its` label: "Let the gathering speak for itself",
outcome: `Sometimes leadership was knowing when not to speak. The guild had found its`
+ ` own reason to celebrate, its own meaning in the repetition. You listened and were` + ` own reason to celebrate, its own meaning in the repetition. You listened and were`
+ ` grateful.`, + ` grateful.`,
}, },
{ {
id: "store", description: "Committed the warmth and laughter to memory carefully, for the difficult nights ahead.",
label: "Commit the moment to memory, for hard times", id: "store",
outcome: `There would be difficult nights later. There always were. You stored this one` label: "Commit the moment to memory, for hard times",
outcome: `There would be difficult nights later. There always were. You stored this one`
+ ` carefully — the warmth of it, the sound of laughter, the proof that your people` + ` carefully — the warmth of it, the sound of laughter, the proof that your people`
+ ` were still whole — so that you could return to it when the cold came in.`, + ` were still whole — so that you could return to it when the cold came in.`,
}, },
@@ -935,22 +997,25 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "begin", description: "Accepted the strangeness and began. The discomfort was proof of somewhere genuinely new.",
label: "Accept the strangeness and begin", id: "begin",
outcome: `The unfamiliarity was not your enemy. It was proof that you were somewhere` 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.`, + ` genuinely new. You held that discomfort lightly and took the first step.`,
}, },
{ {
id: "grieve", description: "Sat with what was released before turning forward — loss and choice are not incompatible.",
label: "Sit with what was released before moving on", id: "grieve",
outcome: `Loss and choice were not incompatible. You had chosen to release, and what` label: "Sit with what was released before moving on",
outcome: `Loss and choice were not incompatible. You had chosen to release, and what`
+ ` you had released had been real and worth having. Acknowledging that before` + ` you had released had been real and worth having. Acknowledging that before`
+ ` turning forward was not weakness. It was honesty.`, + ` turning forward was not weakness. It was honesty.`,
}, },
{ {
id: "pattern", description: "Found the shape of the new pattern immediately. The guild felt steadier for it.",
label: "Find the shape of the new pattern immediately", id: "pattern",
outcome: `Your mind moved the way it always had, already mapping the new terrain. The` label: "Find the shape of the new pattern immediately",
outcome: `Your mind moved the way it always had, already mapping the new terrain. The`
+ ` guild watched you and felt steadier for it. Pattern-finding was its own form of` + ` guild watched you and felt steadier for it. Pattern-finding was its own form of`
+ ` courage — the refusal to be lost.`, + ` courage — the refusal to be lost.`,
}, },
@@ -977,24 +1042,27 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "given", description: "Acknowledged what was given as much as what was earned. No path here was walked alone.",
label: "Acknowledge what was given as much as earned", id: "given",
outcome: `You had not walked this road alone. Every person who had followed you, every` label: "Acknowledge what was given as much as earned",
outcome: `You had not walked this road alone. Every person who had followed you, every`
+ ` ally who had helped, every predecessor whose failures had mapped the path — their` + ` ally who had helped, every predecessor whose failures had mapped the path — their`
+ ` contribution was woven into what you were now. You remembered them, and it` + ` contribution was woven into what you were now. You remembered them, and it`
+ ` mattered.`, + ` mattered.`,
}, },
{ {
id: "forward", description: "Looked forward to what this made possible, and felt excitement returning.",
label: "Look forward to what this makes possible", id: "forward",
outcome: `The horizon had not disappeared. It had moved — further, broader, stranger.` label: "Look forward to what this makes possible",
outcome: `The horizon had not disappeared. It had moved — further, broader, stranger.`
+ ` What you were now could do things that what you had been could only approach. You` + ` What you were now could do things that what you had been could only approach. You`
+ ` looked at the new horizon and felt something you had almost forgotten: excitement.`, + ` looked at the new horizon and felt something you had almost forgotten: excitement.`,
}, },
{ {
id: "be", description: "Let the weight of what they had become settle before the next step. Presence as power.",
label: "Simply be what you have become, for now", id: "be",
outcome: `Not every threshold needed to be rushed past. You were here. You were this.` label: "Simply be what you have become, for now",
outcome: `Not every threshold needed to be rushed past. You were here. You were this.`
+ ` You let the weight of that settle before you took the next step. Presence was its` + ` You let the weight of that settle before you took the next step. Presence was its`
+ ` own kind of power.`, + ` own kind of power.`,
}, },
+699 -17
View File
File diff suppressed because it is too large Load Diff