generated from nhcarrigan/template
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
621f594018
|
|||
| 1e845b14ce | |||
| 81ae1f18e1 | |||
| 0057cfeaaa | |||
| 161127dc21 | |||
| a8a465f293 | |||
| 79c4b99e8a | |||
| 3d114f63d7 | |||
| 911e089a9e | |||
| 14de87d765 | |||
| c4b4fba4c9 | |||
| d723656743 | |||
| 7e10757e68 | |||
| ca2edb090e | |||
| cfcf763ce3 | |||
| aede55a13d | |||
| 744cbf121f | |||
| 03b6c847b3 | |||
| 219d299e9f | |||
| 9e5b8ed972 | |||
|
a20cf3ef87
|
|||
| 9860a2cb1f | |||
| 404b31bd13 | |||
| d0790890ee | |||
| 4d7e624358 | |||
| ac94f67797 | |||
| a36c8e72a5 | |||
| 11e97325cb | |||
| 7a1c57be9a | |||
|
b604a4aa5c
|
|||
|
e10eabc8b5
|
|||
|
c3d79e0c11
|
|||
|
6e2cb45553
|
|||
|
5a065998b6
|
|||
|
f9c925b9fc
|
|||
|
290c06de83
|
@@ -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 the AWS CLI — credentials are in the global `~/.claude/CLAUDE.md` (never commit them here)
|
||||||
|
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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@elysium/api",
|
"name": "@elysium/api",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"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"
|
||||||
|
|||||||
+2
-1
@@ -9,4 +9,5 @@ CORS_ORIGIN="op://Environment Variables - Naomi/Elysium/origin"
|
|||||||
DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook"
|
DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook"
|
||||||
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token"
|
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token"
|
||||||
DISCORD_GUILD_ID="op://Environment Variables - Naomi/Elysium/discord guild id"
|
DISCORD_GUILD_ID="op://Environment Variables - Naomi/Elysium/discord guild id"
|
||||||
DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id"
|
DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id"
|
||||||
|
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
|
||||||
@@ -101,7 +101,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "weapon",
|
type: "weapon",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { combatMultiplier: 2.75 },
|
bonus: { combatMultiplier: 3.25 },
|
||||||
cost: { crystals: 500, essence: 2000, gold: 0 },
|
cost: { crystals: 500, essence: 2000, gold: 0 },
|
||||||
description:
|
description:
|
||||||
"A blade made of compressed nothingness. It does not cut — it simply unmakes.",
|
"A blade made of compressed nothingness. It does not cut — it simply unmakes.",
|
||||||
@@ -204,7 +204,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "armour",
|
type: "armour",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { goldMultiplier: 2.25 },
|
bonus: { goldMultiplier: 2.75 },
|
||||||
description:
|
description:
|
||||||
"Woven from threads of pure starlight harvested by the Astral Wraith. Income flows like cosmic energy.",
|
"Woven from threads of pure starlight harvested by the Astral Wraith. Income flows like cosmic energy.",
|
||||||
equipped: false,
|
equipped: false,
|
||||||
@@ -305,7 +305,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "trinket",
|
type: "trinket",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { clickMultiplier: 2, goldMultiplier: 1.25 },
|
bonus: { clickMultiplier: 2.25, goldMultiplier: 1.25 },
|
||||||
description:
|
description:
|
||||||
"The legendary stone that grants mastery over gold and combat alike.",
|
"The legendary stone that grants mastery over gold and combat alike.",
|
||||||
equipped: false,
|
equipped: false,
|
||||||
@@ -316,7 +316,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "trinket",
|
type: "trinket",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { clickMultiplier: 2.25, goldMultiplier: 1.15 },
|
bonus: { clickMultiplier: 2.25, goldMultiplier: 1.25 },
|
||||||
description:
|
description:
|
||||||
"A flame that has never been extinguished, sealed in crystal by the Phoenix Lord. It burns with the power of rebirth.",
|
"A flame that has never been extinguished, sealed in crystal by the Phoenix Lord. It burns with the power of rebirth.",
|
||||||
equipped: false,
|
equipped: false,
|
||||||
@@ -697,7 +697,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
},
|
},
|
||||||
// ── Purchasable endgame sinks ─────────────────────────────────────────────
|
// ── Purchasable endgame sinks ─────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
bonus: { clickMultiplier: 2.5 },
|
bonus: { clickMultiplier: 3 },
|
||||||
cost: { crystals: 0, essence: 20_000_000, gold: 0 },
|
cost: { crystals: 0, essence: 20_000_000, gold: 0 },
|
||||||
description:
|
description:
|
||||||
"A lens of compressed celestial light that sharpens every strike with divine precision.",
|
"A lens of compressed celestial light that sharpens every strike with divine precision.",
|
||||||
@@ -709,7 +709,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "trinket",
|
type: "trinket",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { goldMultiplier: 3 },
|
bonus: { goldMultiplier: 3.75 },
|
||||||
cost: { crystals: 0, essence: 50_000_000, gold: 0 },
|
cost: { crystals: 0, essence: 50_000_000, gold: 0 },
|
||||||
description:
|
description:
|
||||||
"A book written in the language of the deep — reading it aligns your guild's operations with abyssal efficiency.",
|
"A book written in the language of the deep — reading it aligns your guild's operations with abyssal efficiency.",
|
||||||
@@ -721,7 +721,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "armour",
|
type: "armour",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { combatMultiplier: 4 },
|
bonus: { combatMultiplier: 7 },
|
||||||
cost: { crystals: 0, essence: 100_000_000, gold: 0 },
|
cost: { crystals: 0, essence: 100_000_000, gold: 0 },
|
||||||
description:
|
description:
|
||||||
"A weapon that channels void energy — the absence of resistance makes every strike devastating.",
|
"A weapon that channels void energy — the absence of resistance makes every strike devastating.",
|
||||||
@@ -733,7 +733,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "weapon",
|
type: "weapon",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { clickMultiplier: 3.5, goldMultiplier: 1.5 },
|
bonus: { clickMultiplier: 4, goldMultiplier: 1.5 },
|
||||||
cost: { crystals: 5_000_000, essence: 0, gold: 0 },
|
cost: { crystals: 5_000_000, essence: 0, gold: 0 },
|
||||||
description:
|
description:
|
||||||
"A gem forged in the heart of the Infernal Court — it burns with productivity and righteous fury in equal measure.",
|
"A gem forged in the heart of the Infernal Court — it burns with productivity and righteous fury in equal measure.",
|
||||||
@@ -745,7 +745,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "trinket",
|
type: "trinket",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { goldMultiplier: 4 },
|
bonus: { goldMultiplier: 4.75 },
|
||||||
cost: { crystals: 20_000_000, essence: 0, gold: 0 },
|
cost: { crystals: 20_000_000, essence: 0, gold: 0 },
|
||||||
description:
|
description:
|
||||||
"Armour structured around a crystalline lattice of optimal income calculations. Every gold piece finds you faster.",
|
"Armour structured around a crystalline lattice of optimal income calculations. Every gold piece finds you faster.",
|
||||||
|
|||||||
@@ -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: [] },
|
||||||
|
|||||||
@@ -210,6 +210,15 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
|
|||||||
runestonesCost: 1200,
|
runestonesCost: 1200,
|
||||||
},
|
},
|
||||||
// ── Utility Unlocks ───────────────────────────────────────────────────────
|
// ── Utility Unlocks ───────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
category: "utility",
|
||||||
|
description:
|
||||||
|
"Unlock the Auto-Adventurer toggle. When enabled, the tick engine will automatically purchase the highest-tier adventurer you can currently afford.",
|
||||||
|
id: "auto_adventurer",
|
||||||
|
multiplier: 1,
|
||||||
|
name: "Autonomous Recruitment",
|
||||||
|
runestonesCost: 50,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
category: "utility",
|
category: "utility",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -767,4 +767,70 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
target: "adventurer",
|
target: "adventurer",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
|
// ── Essence Sinks ─────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
costCrystals: 0,
|
||||||
|
costEssence: 1e12,
|
||||||
|
costGold: 0,
|
||||||
|
description:
|
||||||
|
"Channel a vast reservoir of essence into the guild's core — all production ×2.",
|
||||||
|
id: "essence_sink_1",
|
||||||
|
multiplier: 2,
|
||||||
|
name: "Essence Infusion I",
|
||||||
|
purchased: false,
|
||||||
|
target: "global",
|
||||||
|
unlocked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
costCrystals: 0,
|
||||||
|
costEssence: 5e12,
|
||||||
|
costGold: 0,
|
||||||
|
description:
|
||||||
|
"A deeper infusion saturates every operation with raw essence — all production ×2.",
|
||||||
|
id: "essence_sink_2",
|
||||||
|
multiplier: 2,
|
||||||
|
name: "Essence Infusion II",
|
||||||
|
purchased: false,
|
||||||
|
target: "global",
|
||||||
|
unlocked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
costCrystals: 0,
|
||||||
|
costEssence: 2.5e13,
|
||||||
|
costGold: 0,
|
||||||
|
description:
|
||||||
|
"Essence floods the ley-lines binding your guild — all production ×2.",
|
||||||
|
id: "essence_sink_3",
|
||||||
|
multiplier: 2,
|
||||||
|
name: "Essence Infusion III",
|
||||||
|
purchased: false,
|
||||||
|
target: "global",
|
||||||
|
unlocked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
costCrystals: 0,
|
||||||
|
costEssence: 1e14,
|
||||||
|
costGold: 0,
|
||||||
|
description:
|
||||||
|
"The guild breathes essence as its very lifeblood — all production ×3.",
|
||||||
|
id: "essence_sink_4",
|
||||||
|
multiplier: 3,
|
||||||
|
name: "Essence Infusion IV",
|
||||||
|
purchased: false,
|
||||||
|
target: "global",
|
||||||
|
unlocked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
costCrystals: 0,
|
||||||
|
costEssence: 5e14,
|
||||||
|
costGold: 0,
|
||||||
|
description:
|
||||||
|
"Essence transcends material form and reshapes reality itself — all production ×5.",
|
||||||
|
id: "essence_sink_5",
|
||||||
|
multiplier: 5,
|
||||||
|
name: "Essence Infusion V",
|
||||||
|
purchased: false,
|
||||||
|
target: "global",
|
||||||
|
unlocked: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+29
-5
@@ -7,22 +7,25 @@
|
|||||||
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 { debugRouter } from "./routes/debug.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 +36,8 @@ app.use(
|
|||||||
);
|
);
|
||||||
|
|
||||||
app.route("/about", aboutRouter);
|
app.route("/about", aboutRouter);
|
||||||
|
app.route("/debug", debugRouter);
|
||||||
|
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 +53,27 @@ app.get("/health", (context) => {
|
|||||||
return context.json({ status: "ok" });
|
return context.json({ status: "ok" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.onError((error, context) => {
|
||||||
|
void logger.error(
|
||||||
|
"hono_unhandled_error",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
});
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 3001);
|
const port = Number(process.env.PORT ?? 3001);
|
||||||
|
|
||||||
serve({ fetch: app.fetch, port: port }, () => {
|
try {
|
||||||
process.stdout.write(`Elysium API running on port ${String(port)}\n`);
|
serve({ fetch: app.fetch, port: port }, () => {
|
||||||
});
|
process.stdout.write(`Elysium API running on port ${String(port)}\n`);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"server_startup",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { verifyToken } from "../services/jwt.js";
|
import { verifyToken } from "../services/jwt.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
import type { MiddlewareHandler } from "hono";
|
import type { MiddlewareHandler } from "hono";
|
||||||
|
|
||||||
@@ -33,7 +34,13 @@ export const authMiddleware: MiddlewareHandler<HonoEnvironment> = async(
|
|||||||
try {
|
try {
|
||||||
const payload = verifyToken(token);
|
const payload = verifyToken(token);
|
||||||
context.set("discordId", payload.discordId);
|
context.set("discordId", payload.discordId);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"auth_middleware",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
return context.json({ error: "Invalid or expired token" }, 401);
|
return context.json({ error: "Invalid or expired token" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
/* eslint-disable stylistic/max-len -- URL cannot be shortened */
|
/* eslint-disable stylistic/max-len -- URL cannot be shortened */
|
||||||
/* eslint-disable require-atomic-updates -- Simple cache; race condition is acceptable */
|
/* eslint-disable require-atomic-updates -- Simple cache; race condition is acceptable */
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { AboutResponse, GiteaRelease } from "@elysium/types";
|
import type { AboutResponse, GiteaRelease } from "@elysium/types";
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
@@ -46,12 +47,24 @@ const fetchReleases = async(): Promise<Array<GiteaRelease>> => {
|
|||||||
const aboutRouter = new Hono();
|
const aboutRouter = new Hono();
|
||||||
|
|
||||||
aboutRouter.get("/", async(context) => {
|
aboutRouter.get("/", async(context) => {
|
||||||
const releases = await fetchReleases();
|
try {
|
||||||
const body: AboutResponse = {
|
const releases = await fetchReleases();
|
||||||
apiVersion,
|
const body: AboutResponse = {
|
||||||
releases,
|
apiVersion,
|
||||||
};
|
releases,
|
||||||
return context.json(body);
|
};
|
||||||
|
return context.json(body);
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 9 -- @preserve */
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"about",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { aboutRouter };
|
export { aboutRouter };
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
/* eslint-disable max-lines-per-function -- Route handler requires many steps */
|
/* eslint-disable max-lines-per-function -- Route handler requires many steps */
|
||||||
|
/* eslint-disable max-statements -- Route handler requires many statements */
|
||||||
|
|
||||||
/* eslint-disable stylistic/max-len -- Description string cannot be shortened */
|
/* eslint-disable stylistic/max-len -- Description string cannot be shortened */
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
@@ -13,6 +15,7 @@ import {
|
|||||||
buildPostApotheosisState,
|
buildPostApotheosisState,
|
||||||
isEligibleForApotheosis,
|
isEligibleForApotheosis,
|
||||||
} from "../services/apotheosis.js";
|
} from "../services/apotheosis.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import {
|
import {
|
||||||
grantApotheosisRole,
|
grantApotheosisRole,
|
||||||
postMilestoneWebhook,
|
postMilestoneWebhook,
|
||||||
@@ -25,94 +28,106 @@ const apotheosisRouter = new Hono<HonoEnvironment>();
|
|||||||
apotheosisRouter.use("*", authMiddleware);
|
apotheosisRouter.use("*", authMiddleware);
|
||||||
|
|
||||||
apotheosisRouter.post("/", async(context) => {
|
apotheosisRouter.post("/", async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return context.json({ error: "No save found" }, 404);
|
return context.json({ error: "No save found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawState: unknown = record.state;
|
const rawState: unknown = record.state;
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||||
const state = rawState as GameState;
|
const state = rawState as GameState;
|
||||||
|
|
||||||
if (!isEligibleForApotheosis(state)) {
|
if (!isEligibleForApotheosis(state)) {
|
||||||
return context.json(
|
return context.json(
|
||||||
{
|
{
|
||||||
error:
|
error:
|
||||||
"Not eligible for Apotheosis — purchase all Transcendence upgrades first",
|
"Not eligible for Apotheosis — purchase all Transcendence upgrades first",
|
||||||
},
|
},
|
||||||
400,
|
400,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture current-run stats before the nuclear reset
|
// Capture current-run stats before the nuclear reset
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 9 -- @preserve */
|
|
||||||
const runBossesDefeated = state.bosses.filter((b) => {
|
|
||||||
return b.status === "defeated";
|
|
||||||
}).length;
|
|
||||||
const runQuestsCompleted = state.quests.filter((q) => {
|
|
||||||
return q.status === "completed";
|
|
||||||
}).length;
|
|
||||||
const runAdventurersRecruited = state.adventurers.reduce((sum, a) => {
|
|
||||||
return sum + a.count;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 3 -- @preserve */
|
|
||||||
const runAchievementsUnlocked = state.achievements.filter((a) => {
|
|
||||||
return a.unlockedAt !== null;
|
|
||||||
}).length;
|
|
||||||
|
|
||||||
const { updatedState, updatedApotheosisData } = buildPostApotheosisState(
|
|
||||||
state,
|
|
||||||
state.player.characterName,
|
|
||||||
);
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
await prisma.gameState.update({
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
|
||||||
data: { state: updatedState as object, updatedAt: now },
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.player.update({
|
|
||||||
data: {
|
|
||||||
characterName: state.player.characterName,
|
|
||||||
|
|
||||||
lastSavedAt: now,
|
|
||||||
|
|
||||||
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
|
|
||||||
|
|
||||||
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
|
|
||||||
|
|
||||||
lifetimeBossesDefeated: { increment: runBossesDefeated },
|
|
||||||
|
|
||||||
lifetimeClicks: { increment: state.player.totalClicks },
|
|
||||||
|
|
||||||
// Accumulate into lifetime totals
|
|
||||||
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
|
|
||||||
|
|
||||||
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
|
|
||||||
|
|
||||||
totalClicks: 0,
|
|
||||||
// Reset current-run counters
|
|
||||||
totalGoldEarned: 0,
|
|
||||||
},
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
void grantApotheosisRole(discordId);
|
|
||||||
void postMilestoneWebhook(discordId, "apotheosis", {
|
|
||||||
apotheosis: updatedApotheosisData.count,
|
|
||||||
prestige: updatedState.prestige.count,
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next 9 -- @preserve */
|
||||||
transcendence: updatedState.transcendence?.count ?? 0,
|
const runBossesDefeated = state.bosses.filter((b) => {
|
||||||
});
|
return b.status === "defeated";
|
||||||
|
}).length;
|
||||||
|
const runQuestsCompleted = state.quests.filter((q) => {
|
||||||
|
return q.status === "completed";
|
||||||
|
}).length;
|
||||||
|
const runAdventurersRecruited = state.adventurers.reduce((sum, a) => {
|
||||||
|
return sum + a.count;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
return context.json({ apotheosisCount: updatedApotheosisData.count });
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 3 -- @preserve */
|
||||||
|
const runAchievementsUnlocked = state.achievements.filter((a) => {
|
||||||
|
return a.unlockedAt !== null;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
const { updatedState, updatedApotheosisData } = buildPostApotheosisState(
|
||||||
|
state,
|
||||||
|
state.player.characterName,
|
||||||
|
);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: updatedState as object, updatedAt: now },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.player.update({
|
||||||
|
data: {
|
||||||
|
characterName: state.player.characterName,
|
||||||
|
|
||||||
|
lastSavedAt: now,
|
||||||
|
|
||||||
|
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
|
||||||
|
|
||||||
|
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
|
||||||
|
|
||||||
|
lifetimeBossesDefeated: { increment: runBossesDefeated },
|
||||||
|
|
||||||
|
lifetimeClicks: { increment: state.player.totalClicks },
|
||||||
|
|
||||||
|
// Accumulate into lifetime totals
|
||||||
|
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
|
||||||
|
|
||||||
|
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
|
||||||
|
|
||||||
|
totalClicks: 0,
|
||||||
|
// Reset current-run counters
|
||||||
|
totalGoldEarned: 0,
|
||||||
|
},
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const apotheosisCount = updatedApotheosisData.count;
|
||||||
|
void logger.metric("apotheosis", 1, { apotheosisCount, discordId });
|
||||||
|
void grantApotheosisRole(discordId);
|
||||||
|
void postMilestoneWebhook(discordId, "apotheosis", {
|
||||||
|
apotheosis: updatedApotheosisData.count,
|
||||||
|
prestige: updatedState.prestige.count,
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
transcendence: updatedState.transcendence?.count ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return context.json({ apotheosisCount: updatedApotheosisData.count });
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"apotheosis",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { apotheosisRouter };
|
export { apotheosisRouter };
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
fetchDiscordUser,
|
fetchDiscordUser,
|
||||||
} from "../services/discord.js";
|
} from "../services/discord.js";
|
||||||
import { signToken } from "../services/jwt.js";
|
import { signToken } from "../services/jwt.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { Player } from "@elysium/types";
|
import type { Player } from "@elysium/types";
|
||||||
|
|
||||||
const authRouter = new Hono();
|
const authRouter = new Hono();
|
||||||
@@ -92,6 +93,8 @@ authRouter.get("/callback", async(context) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const jwtToken = signToken(player.discordId);
|
const jwtToken = signToken(player.discordId);
|
||||||
|
void logger.log("info", `New player registered: ${player.discordId}`);
|
||||||
|
void logger.metric("user_registered", 1, { discordId: player.discordId });
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next -- @preserve */
|
||||||
@@ -111,6 +114,8 @@ authRouter.get("/callback", async(context) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const jwtToken = signToken(updated.discordId);
|
const jwtToken = signToken(updated.discordId);
|
||||||
|
void logger.log("info", `Player logged in: ${updated.discordId}`);
|
||||||
|
void logger.metric("user_login", 1, { discordId: updated.discordId });
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next -- @preserve */
|
||||||
@@ -118,7 +123,13 @@ authRouter.get("/callback", async(context) => {
|
|||||||
return context.redirect(
|
return context.redirect(
|
||||||
`${clientUrl}/auth/callback?token=${jwtToken}&isNew=false`,
|
`${clientUrl}/auth/callback?token=${jwtToken}&isNew=false`,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"auth_callback",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next -- @preserve */
|
||||||
const clientUrl = process.env.CORS_ORIGIN ?? "http://localhost:5173";
|
const clientUrl = process.env.CORS_ORIGIN ?? "http://localhost:5173";
|
||||||
|
|||||||
+253
-232
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -0,0 +1,441 @@
|
|||||||
|
/**
|
||||||
|
* @file Debug routes for administrative player state corrections.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||||
|
/* eslint-disable max-lines -- Multiple route handlers and helper functions in one file */
|
||||||
|
import { createHmac } from "node:crypto";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { defaultBosses } from "../data/bosses.js";
|
||||||
|
import { defaultExplorations } from "../data/explorations.js";
|
||||||
|
import { initialGameState } from "../data/initialState.js";
|
||||||
|
import { defaultQuests } from "../data/quests.js";
|
||||||
|
import { currentSchemaVersion } from "../data/schemaVersion.js";
|
||||||
|
import { defaultZones } from "../data/zones.js";
|
||||||
|
import { prisma } from "../db/client.js";
|
||||||
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
|
import type { GameState } from "@elysium/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the HMAC-SHA256 of data using the given secret.
|
||||||
|
* @param data - The data string to sign.
|
||||||
|
* @param secret - The HMAC secret key.
|
||||||
|
* @returns The hex-encoded HMAC digest.
|
||||||
|
*/
|
||||||
|
const computeHmac = (data: string, secret: string): string => {
|
||||||
|
return createHmac("sha256", secret).update(data).
|
||||||
|
digest("hex");
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlocks any zones whose required boss and quest conditions are satisfied.
|
||||||
|
* @param state - The player's current game state (mutated directly).
|
||||||
|
* @returns The number of zones that were unlocked.
|
||||||
|
*/
|
||||||
|
const applyZoneUnlocks = (state: GameState): number => {
|
||||||
|
let count = 0;
|
||||||
|
for (const zoneDefinition of defaultZones) {
|
||||||
|
const zoneInState = state.zones.find((z) => {
|
||||||
|
return z.id === zoneDefinition.id;
|
||||||
|
});
|
||||||
|
if (!zoneInState || zoneInState.status !== "locked") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredBossDefeated
|
||||||
|
= zoneDefinition.unlockBossId === null
|
||||||
|
|| state.bosses.some((b) => {
|
||||||
|
return b.id === zoneDefinition.unlockBossId && b.status === "defeated";
|
||||||
|
});
|
||||||
|
|
||||||
|
const requiredQuestCompleted
|
||||||
|
= zoneDefinition.unlockQuestId === null
|
||||||
|
|| state.quests.some((q) => {
|
||||||
|
return (
|
||||||
|
q.id === zoneDefinition.unlockQuestId && q.status === "completed"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requiredBossDefeated && requiredQuestCompleted) {
|
||||||
|
zoneInState.status = "unlocked";
|
||||||
|
count = count + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface QuestUnlockCheck {
|
||||||
|
questId: string;
|
||||||
|
zoneId: string;
|
||||||
|
prerequisiteIds: Array<string>;
|
||||||
|
state: GameState;
|
||||||
|
completedQuestIds: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether a quest should be made available given the current state.
|
||||||
|
* @param options - The options for the quest unlock check.
|
||||||
|
* @param options.questId - The ID of the quest to check.
|
||||||
|
* @param options.zoneId - The zone the quest belongs to.
|
||||||
|
* @param options.prerequisiteIds - The quest IDs that must be completed first.
|
||||||
|
* @param options.state - The current game state.
|
||||||
|
* @param options.completedQuestIds - Set of already-completed quest IDs.
|
||||||
|
* @returns True when the quest should be unlocked.
|
||||||
|
*/
|
||||||
|
const shouldUnlockQuest = ({
|
||||||
|
questId,
|
||||||
|
zoneId,
|
||||||
|
prerequisiteIds,
|
||||||
|
state,
|
||||||
|
completedQuestIds,
|
||||||
|
}: QuestUnlockCheck): boolean => {
|
||||||
|
const questInState = state.quests.find((q) => {
|
||||||
|
return q.id === questId;
|
||||||
|
});
|
||||||
|
if (!questInState || questInState.status !== "locked") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const zoneInState = state.zones.find((z) => {
|
||||||
|
return z.id === zoneId;
|
||||||
|
});
|
||||||
|
if (!zoneInState || zoneInState.status === "locked") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return prerequisiteIds.every((id) => {
|
||||||
|
return completedQuestIds.has(id);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes available any quests whose zone is unlocked and prerequisites are met.
|
||||||
|
* @param state - The player's current game state (mutated directly).
|
||||||
|
* @returns The number of quests that were made available.
|
||||||
|
*/
|
||||||
|
const applyQuestUnlocks = (state: GameState): number => {
|
||||||
|
let count = 0;
|
||||||
|
const completedQuestIds = new Set(
|
||||||
|
state.quests.
|
||||||
|
filter((q) => {
|
||||||
|
return q.status === "completed";
|
||||||
|
}).
|
||||||
|
map((q) => {
|
||||||
|
return q.id;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const questDefinition of defaultQuests) {
|
||||||
|
if (
|
||||||
|
!shouldUnlockQuest({
|
||||||
|
completedQuestIds: completedQuestIds,
|
||||||
|
prerequisiteIds: questDefinition.prerequisiteIds,
|
||||||
|
questId: questDefinition.id,
|
||||||
|
state: state,
|
||||||
|
zoneId: questDefinition.zoneId,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const questInState = state.quests.find((q) => {
|
||||||
|
return q.id === questDefinition.id;
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 4 -- @preserve */
|
||||||
|
if (questInState) {
|
||||||
|
questInState.status = "available";
|
||||||
|
count = count + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BossUnlockCheck {
|
||||||
|
bossId: string;
|
||||||
|
previousBossId: string | undefined;
|
||||||
|
isFirstInZone: boolean;
|
||||||
|
prestigeRequirement: number;
|
||||||
|
state: GameState;
|
||||||
|
prestigeCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether a boss should be made available given the current state.
|
||||||
|
* @param options - The options for the boss unlock check.
|
||||||
|
* @param options.bossId - The ID of the boss to check.
|
||||||
|
* @param options.previousBossId - The ID of the previous boss in the zone.
|
||||||
|
* @param options.isFirstInZone - Whether this boss is the first in its zone.
|
||||||
|
* @param options.prestigeRequirement - The prestige level required for this boss.
|
||||||
|
* @param options.state - The current game state.
|
||||||
|
* @param options.prestigeCount - The player's current prestige count.
|
||||||
|
* @returns True when the boss should be made available.
|
||||||
|
*/
|
||||||
|
const shouldUnlockBoss = ({
|
||||||
|
bossId,
|
||||||
|
previousBossId,
|
||||||
|
isFirstInZone,
|
||||||
|
prestigeRequirement,
|
||||||
|
state,
|
||||||
|
prestigeCount,
|
||||||
|
}: BossUnlockCheck): boolean => {
|
||||||
|
const bossInState = state.bosses.find((b) => {
|
||||||
|
return b.id === bossId;
|
||||||
|
});
|
||||||
|
if (!bossInState || bossInState.status !== "locked") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (prestigeRequirement > prestigeCount) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isFirstInZone) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 3 -- @preserve */
|
||||||
|
if (previousBossId === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const previousBossInState = state.bosses.find((b) => {
|
||||||
|
return b.id === previousBossId;
|
||||||
|
});
|
||||||
|
return previousBossInState?.status === "defeated";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes available any bosses that should be accessible based on zone status
|
||||||
|
* and sequential defeat order within each zone.
|
||||||
|
* @param state - The player's current game state (mutated directly).
|
||||||
|
* @returns The number of bosses that were made available.
|
||||||
|
*/
|
||||||
|
const applyBossUnlocks = (state: GameState): number => {
|
||||||
|
let count = 0;
|
||||||
|
const prestigeCount = state.prestige.count;
|
||||||
|
|
||||||
|
for (const zoneDefinition of defaultZones) {
|
||||||
|
const zoneInState = state.zones.find((z) => {
|
||||||
|
return z.id === zoneDefinition.id;
|
||||||
|
});
|
||||||
|
if (!zoneInState || zoneInState.status === "locked") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bossesInZone = defaultBosses.filter((b) => {
|
||||||
|
return b.zoneId === zoneDefinition.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let index = 0; index < bossesInZone.length; index = index + 1) {
|
||||||
|
const bossDefinition = bossesInZone[index];
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 3 -- @preserve */
|
||||||
|
if (!bossDefinition) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const previousBossDefinition = bossesInZone[index - 1];
|
||||||
|
const unlock = shouldUnlockBoss({
|
||||||
|
bossId: bossDefinition.id,
|
||||||
|
isFirstInZone: index === 0,
|
||||||
|
prestigeCount: prestigeCount,
|
||||||
|
prestigeRequirement: bossDefinition.prestigeRequirement,
|
||||||
|
previousBossId: previousBossDefinition?.id,
|
||||||
|
state: state,
|
||||||
|
});
|
||||||
|
if (unlock) {
|
||||||
|
const bossInState = state.bosses.find((b) => {
|
||||||
|
return b.id === bossDefinition.id;
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 4 -- @preserve */
|
||||||
|
if (bossInState) {
|
||||||
|
bossInState.status = "available";
|
||||||
|
count = count + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes available any exploration areas whose parent zone is now unlocked.
|
||||||
|
* @param state - The player's current game state (mutated directly).
|
||||||
|
* @returns The number of exploration areas that were made available.
|
||||||
|
*/
|
||||||
|
const applyExplorationUnlocks = (state: GameState): number => {
|
||||||
|
if (state.exploration === undefined) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let count = 0;
|
||||||
|
const unlockedZoneIds = new Set(
|
||||||
|
state.zones.
|
||||||
|
filter((z) => {
|
||||||
|
return z.status === "unlocked";
|
||||||
|
}).
|
||||||
|
map((z) => {
|
||||||
|
return z.id;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const areaDefinition of defaultExplorations) {
|
||||||
|
if (!unlockedZoneIds.has(areaDefinition.zoneId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const areaInState = state.exploration.areas.find((a) => {
|
||||||
|
return a.id === areaDefinition.id;
|
||||||
|
});
|
||||||
|
if (areaInState && areaInState.status === "locked") {
|
||||||
|
areaInState.status = "available";
|
||||||
|
count = count + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies all missing unlock corrections to a game state in-place.
|
||||||
|
* Delegates to per-category helpers and aggregates the results.
|
||||||
|
* @param state - The player's current game state (mutated directly).
|
||||||
|
* @returns Counts of each entity type that was corrected.
|
||||||
|
*/
|
||||||
|
const applyForceUnlocks = (
|
||||||
|
state: GameState,
|
||||||
|
): {
|
||||||
|
bossesUnlocked: number;
|
||||||
|
explorationUnlocked: number;
|
||||||
|
questsUnlocked: number;
|
||||||
|
zonesUnlocked: number;
|
||||||
|
} => {
|
||||||
|
const zonesUnlocked = applyZoneUnlocks(state);
|
||||||
|
const questsUnlocked = applyQuestUnlocks(state);
|
||||||
|
const bossesUnlocked = applyBossUnlocks(state);
|
||||||
|
const explorationUnlocked = applyExplorationUnlocks(state);
|
||||||
|
return { bossesUnlocked, explorationUnlocked, questsUnlocked, zonesUnlocked };
|
||||||
|
};
|
||||||
|
|
||||||
|
const debugRouter = new Hono<HonoEnvironment>();
|
||||||
|
debugRouter.use(authMiddleware);
|
||||||
|
|
||||||
|
debugRouter.post("/force-unlocks", async(context) => {
|
||||||
|
try {
|
||||||
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
|
const gameStateRecord = await prisma.gameState.findUnique({
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
if (!gameStateRecord) {
|
||||||
|
return context.json({ error: "No game state found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma stores state as JSON object */
|
||||||
|
const state = gameStateRecord.state as unknown as GameState;
|
||||||
|
|
||||||
|
const { bossesUnlocked, explorationUnlocked, questsUnlocked, zonesUnlocked }
|
||||||
|
= applyForceUnlocks(state);
|
||||||
|
|
||||||
|
const updatedAt = Date.now();
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: state as object, updatedAt: updatedAt },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||||
|
const signature
|
||||||
|
= secret === undefined
|
||||||
|
? undefined
|
||||||
|
: computeHmac(JSON.stringify(state), secret);
|
||||||
|
|
||||||
|
return context.json({
|
||||||
|
bossesUnlocked,
|
||||||
|
explorationUnlocked,
|
||||||
|
questsUnlocked,
|
||||||
|
signature,
|
||||||
|
state,
|
||||||
|
zonesUnlocked,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"debug_force_unlocks",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
debugRouter.post("/hard-reset", async(context) => {
|
||||||
|
try {
|
||||||
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
|
const playerRecord = await prisma.player.findUnique({
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
if (!playerRecord) {
|
||||||
|
return context.json({ error: "No player found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const freshState = initialGameState(
|
||||||
|
{
|
||||||
|
avatar: playerRecord.avatar,
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"debug_hard_reset",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { debugRouter };
|
||||||
+280
-254
@@ -12,6 +12,7 @@ import { defaultExplorations } from "../data/explorations.js";
|
|||||||
import { initialExploration } from "../data/initialState.js";
|
import { initialExploration } from "../data/initialState.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
import type {
|
import type {
|
||||||
ExploreCollectEventResult,
|
ExploreCollectEventResult,
|
||||||
@@ -49,280 +50,233 @@ const pickNothingMessage = (): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
exploreRouter.post("/start", async(context) => {
|
exploreRouter.post("/start", async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
const body = await context.req.json<ExploreStartRequest>();
|
const discordId = context.get("discordId");
|
||||||
|
const body = await context.req.json<ExploreStartRequest>();
|
||||||
|
|
||||||
const { areaId } = body;
|
const { areaId } = body;
|
||||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
if (!areaId) {
|
if (!areaId) {
|
||||||
return context.json({ error: "areaId is required" }, 400);
|
return context.json({ error: "areaId is required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const explorationArea = defaultExplorations.find((a) => {
|
const explorationArea = defaultExplorations.find((a) => {
|
||||||
return a.id === areaId;
|
return a.id === areaId;
|
||||||
});
|
});
|
||||||
if (!explorationArea) {
|
if (!explorationArea) {
|
||||||
return context.json({ error: "Unknown exploration area" }, 404);
|
return context.json({ error: "Unknown exploration area" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return context.json({ error: "No save found" }, 404);
|
return context.json({ error: "No save found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawState: unknown = record.state;
|
const rawState: unknown = record.state;
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||||
const state = rawState as GameState;
|
const state = rawState as GameState;
|
||||||
|
|
||||||
// Backfill exploration state for old saves that predate this feature
|
// Backfill exploration state for old saves that predate this feature
|
||||||
if (!state.exploration) {
|
if (!state.exploration) {
|
||||||
state.exploration = structuredClone(initialExploration);
|
state.exploration = structuredClone(initialExploration);
|
||||||
// Unlock areas for zones already unlocked in this save
|
// Unlock areas for zones already unlocked in this save
|
||||||
for (const area of state.exploration.areas) {
|
for (const area of state.exploration.areas) {
|
||||||
const areaData = defaultExplorations.find((areaItem) => {
|
const areaData = defaultExplorations.find((areaItem) => {
|
||||||
return areaItem.id === area.id;
|
return areaItem.id === area.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next 3 -- @preserve */
|
/* v8 ignore next 3 -- @preserve */
|
||||||
if (!areaData) {
|
if (!areaData) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const zone = state.zones.find((z) => {
|
const zone = state.zones.find((z) => {
|
||||||
return z.id === areaData.zoneId;
|
return z.id === areaData.zoneId;
|
||||||
});
|
});
|
||||||
if (zone?.status === "unlocked") {
|
if (zone?.status === "unlocked") {
|
||||||
area.status = "available";
|
area.status = "available";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const zone = state.zones.find((z) => {
|
const zone = state.zones.find((z) => {
|
||||||
return z.id === explorationArea.zoneId;
|
return z.id === explorationArea.zoneId;
|
||||||
});
|
});
|
||||||
if (!zone || zone.status !== "unlocked") {
|
if (!zone || zone.status !== "unlocked") {
|
||||||
return context.json({ error: "Zone is not unlocked" }, 400);
|
return context.json({ error: "Zone is not unlocked" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const area = state.exploration.areas.find((a) => {
|
const area = state.exploration.areas.find((a) => {
|
||||||
return a.id === areaId;
|
return a.id === areaId;
|
||||||
});
|
});
|
||||||
if (!area) {
|
if (!area) {
|
||||||
return context.json({ error: "Exploration area not found in state" }, 404);
|
return context.json(
|
||||||
}
|
{ error: "Exploration area not found in state" },
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const anyInProgress = state.exploration.areas.some((a) => {
|
const anyInProgress = state.exploration.areas.some((a) => {
|
||||||
return a.status === "in_progress";
|
return a.status === "in_progress";
|
||||||
});
|
});
|
||||||
if (anyInProgress) {
|
if (anyInProgress) {
|
||||||
return context.json(
|
return context.json(
|
||||||
{ error: "An exploration is already in progress" },
|
{ error: "An exploration is already in progress" },
|
||||||
400,
|
400,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (area.status === "locked") {
|
if (area.status === "locked") {
|
||||||
return context.json({ error: "Exploration area is locked" }, 400);
|
return context.json({ error: "Exploration area is locked" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
area.status = "in_progress";
|
area.status = "in_progress";
|
||||||
area.startedAt = now;
|
area.startedAt = now;
|
||||||
|
|
||||||
await prisma.gameState.update({
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
|
||||||
data: { state: state as object, updatedAt: now },
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
|
|
||||||
const endsAt = now + explorationArea.durationSeconds * 1000;
|
|
||||||
const response: ExploreStartResponse = {
|
|
||||||
areaId,
|
|
||||||
endsAt,
|
|
||||||
};
|
|
||||||
return context.json(response);
|
|
||||||
});
|
|
||||||
|
|
||||||
exploreRouter.post("/collect", async(context) => {
|
|
||||||
const discordId = context.get("discordId");
|
|
||||||
const body = await context.req.json<ExploreCollectRequest>();
|
|
||||||
|
|
||||||
const { areaId } = body;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
|
||||||
if (!areaId) {
|
|
||||||
return context.json({ error: "areaId is required" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const explorationArea = defaultExplorations.find((a) => {
|
|
||||||
return a.id === areaId;
|
|
||||||
});
|
|
||||||
if (!explorationArea) {
|
|
||||||
return context.json({ error: "Unknown exploration area" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
|
||||||
if (!record) {
|
|
||||||
return context.json({ error: "No save found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawState: unknown = record.state;
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
|
||||||
const state = rawState as GameState;
|
|
||||||
|
|
||||||
if (!state.exploration) {
|
|
||||||
return context.json({ error: "No exploration state found" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const area = state.exploration.areas.find((a) => {
|
|
||||||
return a.id === areaId;
|
|
||||||
});
|
|
||||||
if (!area) {
|
|
||||||
return context.json({ error: "Exploration area not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (area.status !== "in_progress") {
|
|
||||||
return context.json({ error: "Exploration is not in progress" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next -- @preserve */
|
|
||||||
const startedAt = area.startedAt ?? 0;
|
|
||||||
const durationMs = explorationArea.durationSeconds * 1000;
|
|
||||||
const expiresAt = startedAt + durationMs;
|
|
||||||
|
|
||||||
if (now < expiresAt) {
|
|
||||||
return context.json({ error: "Exploration is not yet complete" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
area.status = "available";
|
|
||||||
area.completedOnce = true;
|
|
||||||
|
|
||||||
// 20% chance of finding nothing
|
|
||||||
if (Math.random() < nothingProbability) {
|
|
||||||
await prisma.gameState.update({
|
await prisma.gameState.update({
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
data: { state: state as object, updatedAt: now },
|
data: { state: state as object, updatedAt: now },
|
||||||
where: { discordId },
|
where: { discordId },
|
||||||
});
|
});
|
||||||
|
|
||||||
const response: ExploreCollectResponse = {
|
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
|
||||||
event: null,
|
const endsAt = now + explorationArea.durationSeconds * 1000;
|
||||||
foundNothing: true,
|
const response: ExploreStartResponse = {
|
||||||
materialsFound: [],
|
areaId,
|
||||||
nothingMessage: pickNothingMessage(),
|
endsAt,
|
||||||
};
|
};
|
||||||
return context.json(response);
|
return context.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"explore_start",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Pick a random event
|
exploreRouter.post("/collect", async(context) => {
|
||||||
const eventIndex = Math.floor(Math.random() * explorationArea.events.length);
|
try {
|
||||||
const event = explorationArea.events[eventIndex];
|
const discordId = context.get("discordId");
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
const body = await context.req.json<ExploreCollectRequest>();
|
||||||
/* v8 ignore next 3 -- @preserve */
|
|
||||||
if (!event) {
|
|
||||||
return context.json({ error: "No events available" }, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply event effects and build the result summary
|
const { areaId } = body;
|
||||||
let goldChange = 0;
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
let essenceChange = 0;
|
if (!areaId) {
|
||||||
let materialGained: { materialId: string; quantity: number } | null = null;
|
return context.json({ error: "areaId is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
if (event.effect.type === "gold_gain") {
|
const explorationArea = defaultExplorations.find((a) => {
|
||||||
// Gold gain — amount may be undefined in edge cases
|
return a.id === areaId;
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
});
|
||||||
/* v8 ignore next -- @preserve */
|
if (!explorationArea) {
|
||||||
const amount = event.effect.amount ?? 0;
|
return context.json({ error: "Unknown exploration area" }, 404);
|
||||||
state.resources.gold = state.resources.gold + amount;
|
}
|
||||||
state.player.totalGoldEarned = state.player.totalGoldEarned + amount;
|
|
||||||
goldChange = amount;
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
} else if (event.effect.type === "gold_loss") {
|
if (!record) {
|
||||||
// Gold loss — amount may be undefined in edge cases
|
return context.json({ error: "No save found" }, 404);
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
}
|
||||||
/* v8 ignore next -- @preserve */
|
|
||||||
const amount = Math.min(state.resources.gold, event.effect.amount ?? 0);
|
const rawState: unknown = record.state;
|
||||||
state.resources.gold = state.resources.gold - amount;
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||||
goldChange = -amount;
|
const state = rawState as GameState;
|
||||||
} else if (event.effect.type === "essence_gain") {
|
|
||||||
// Essence gain — amount may be undefined in edge cases
|
if (!state.exploration) {
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
return context.json({ error: "No exploration state found" }, 400);
|
||||||
/* v8 ignore next -- @preserve */
|
}
|
||||||
const amount = event.effect.amount ?? 0;
|
|
||||||
state.resources.essence = state.resources.essence + amount;
|
const area = state.exploration.areas.find((a) => {
|
||||||
essenceChange = amount;
|
return a.id === areaId;
|
||||||
} else if (event.effect.type === "material_gain") {
|
});
|
||||||
const { materialId } = event.effect;
|
if (!area) {
|
||||||
|
return context.json({ error: "Exploration area not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (area.status !== "in_progress") {
|
||||||
|
return context.json({ error: "Exploration is not in progress" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next -- @preserve */
|
||||||
const quantity = event.effect.quantity ?? 1;
|
const startedAt = area.startedAt ?? 0;
|
||||||
if (materialId !== undefined && materialId !== "") {
|
const durationMs = explorationArea.durationSeconds * 1000;
|
||||||
const existing = state.exploration.materials.find((m) => {
|
const expiresAt = startedAt + durationMs;
|
||||||
return m.materialId === materialId;
|
|
||||||
|
if (now < expiresAt) {
|
||||||
|
return context.json({ error: "Exploration is not yet complete" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
area.status = "available";
|
||||||
|
area.completedOnce = true;
|
||||||
|
|
||||||
|
// 20% chance of finding nothing
|
||||||
|
if (Math.random() < nothingProbability) {
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: state as object, updatedAt: now },
|
||||||
|
where: { discordId },
|
||||||
});
|
});
|
||||||
if (existing) {
|
|
||||||
existing.quantity = existing.quantity + quantity;
|
const response: ExploreCollectResponse = {
|
||||||
} else {
|
event: null,
|
||||||
state.exploration.materials.push({ materialId, quantity });
|
foundNothing: true,
|
||||||
}
|
materialsFound: [],
|
||||||
materialGained = { materialId, quantity };
|
nothingMessage: pickNothingMessage(),
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
};
|
||||||
/* v8 ignore next 13 -- @preserve */
|
return context.json(response);
|
||||||
}
|
}
|
||||||
} else if (event.effect.type === "adventurer_loss") { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- exhausted all other union members above
|
|
||||||
// Adventurer loss — fraction and loop are defensive
|
// Pick a random event
|
||||||
|
const eventIndex = Math.floor(
|
||||||
|
Math.random() * explorationArea.events.length,
|
||||||
|
);
|
||||||
|
const event = explorationArea.events[eventIndex];
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next 8 -- @preserve */
|
/* v8 ignore next 3 -- @preserve */
|
||||||
const fraction = event.effect.fraction ?? 0.05;
|
if (!event) {
|
||||||
for (const adventurer of state.adventurers) {
|
return context.json({ error: "No events available" }, 500);
|
||||||
const lost = Math.floor(adventurer.count * fraction);
|
|
||||||
if (lost > 0) {
|
|
||||||
adventurer.count = Math.max(0, adventurer.count - lost);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// Apply event effects and build the result summary
|
||||||
/* v8 ignore next 8 -- @preserve */
|
let goldChange = 0;
|
||||||
let adventurerLostCount = 0;
|
let essenceChange = 0;
|
||||||
if (event.effect.type === "adventurer_loss") {
|
let materialGained: { materialId: string; quantity: number } | null = null;
|
||||||
const fraction = event.effect.fraction ?? 0.05;
|
|
||||||
for (const adv of state.adventurers) {
|
|
||||||
const lost = Math.floor(adv.count * fraction);
|
|
||||||
adventurerLostCount = adventurerLostCount + lost;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventResult: ExploreCollectEventResult = {
|
if (event.effect.type === "gold_gain") {
|
||||||
adventurerLostCount: adventurerLostCount,
|
// Gold gain — amount may be undefined in edge cases
|
||||||
essenceChange: essenceChange,
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
goldChange: goldChange,
|
/* v8 ignore next -- @preserve */
|
||||||
materialGained: materialGained,
|
const amount = event.effect.amount ?? 0;
|
||||||
text: event.text,
|
state.resources.gold = state.resources.gold + amount;
|
||||||
};
|
state.player.totalGoldEarned = state.player.totalGoldEarned + amount;
|
||||||
|
goldChange = amount;
|
||||||
// Roll for material drops from possibleMaterials (weighted random selection)
|
} else if (event.effect.type === "gold_loss") {
|
||||||
const materialsFound: Array<{ materialId: string; quantity: number }> = [];
|
// Gold loss — amount may be undefined in edge cases
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
if (explorationArea.possibleMaterials.length > 0) {
|
/* v8 ignore next -- @preserve */
|
||||||
let totalWeight = 0;
|
const amount = Math.min(state.resources.gold, event.effect.amount ?? 0);
|
||||||
for (const materialDrop of explorationArea.possibleMaterials) {
|
state.resources.gold = state.resources.gold - amount;
|
||||||
totalWeight = totalWeight + materialDrop.weight;
|
goldChange = -amount;
|
||||||
}
|
} else if (event.effect.type === "essence_gain") {
|
||||||
let roll = Math.random() * totalWeight;
|
// Essence gain — amount may be undefined in edge cases
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
for (const possible of explorationArea.possibleMaterials) {
|
/* v8 ignore next -- @preserve */
|
||||||
roll = roll - possible.weight;
|
const amount = event.effect.amount ?? 0;
|
||||||
if (roll <= 0) {
|
state.resources.essence = state.resources.essence + amount;
|
||||||
const maxMinDiff = possible.maxQuantity - possible.minQuantity;
|
essenceChange = amount;
|
||||||
const range = maxMinDiff + 1;
|
} else if (event.effect.type === "material_gain") {
|
||||||
const randomOffset = Math.floor(Math.random() * range);
|
const { materialId } = event.effect;
|
||||||
const quantity = randomOffset + possible.minQuantity;
|
|
||||||
const { materialId } = possible;
|
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
const quantity = event.effect.quantity ?? 1;
|
||||||
|
if (materialId !== undefined && materialId !== "") {
|
||||||
const existing = state.exploration.materials.find((m) => {
|
const existing = state.exploration.materials.find((m) => {
|
||||||
return m.materialId === materialId;
|
return m.materialId === materialId;
|
||||||
});
|
});
|
||||||
@@ -331,25 +285,97 @@ exploreRouter.post("/collect", async(context) => {
|
|||||||
} else {
|
} else {
|
||||||
state.exploration.materials.push({ materialId, quantity });
|
state.exploration.materials.push({ materialId, quantity });
|
||||||
}
|
}
|
||||||
|
materialGained = { materialId, quantity };
|
||||||
materialsFound.push({ materialId, quantity });
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
break;
|
/* v8 ignore next 13 -- @preserve */
|
||||||
|
}
|
||||||
|
} else if (event.effect.type === "adventurer_loss") { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- exhausted all other union members above
|
||||||
|
// Adventurer loss — fraction and loop are defensive
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 8 -- @preserve */
|
||||||
|
const fraction = event.effect.fraction ?? 0.05;
|
||||||
|
for (const adventurer of state.adventurers) {
|
||||||
|
const lost = Math.floor(adventurer.count * fraction);
|
||||||
|
if (lost > 0) {
|
||||||
|
adventurer.count = Math.max(0, adventurer.count - lost);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 8 -- @preserve */
|
||||||
|
let adventurerLostCount = 0;
|
||||||
|
if (event.effect.type === "adventurer_loss") {
|
||||||
|
const fraction = event.effect.fraction ?? 0.05;
|
||||||
|
for (const adv of state.adventurers) {
|
||||||
|
const lost = Math.floor(adv.count * fraction);
|
||||||
|
adventurerLostCount = adventurerLostCount + lost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventResult: ExploreCollectEventResult = {
|
||||||
|
adventurerLostCount: adventurerLostCount,
|
||||||
|
essenceChange: essenceChange,
|
||||||
|
goldChange: goldChange,
|
||||||
|
materialGained: materialGained,
|
||||||
|
text: event.text,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Roll for material drops from possibleMaterials (weighted random selection)
|
||||||
|
const materialsFound: Array<{ materialId: string; quantity: number }> = [];
|
||||||
|
|
||||||
|
if (explorationArea.possibleMaterials.length > 0) {
|
||||||
|
let totalWeight = 0;
|
||||||
|
for (const materialDrop of explorationArea.possibleMaterials) {
|
||||||
|
totalWeight = totalWeight + materialDrop.weight;
|
||||||
|
}
|
||||||
|
let roll = Math.random() * totalWeight;
|
||||||
|
|
||||||
|
for (const possible of explorationArea.possibleMaterials) {
|
||||||
|
roll = roll - possible.weight;
|
||||||
|
if (roll <= 0) {
|
||||||
|
const maxMinDiff = possible.maxQuantity - possible.minQuantity;
|
||||||
|
const range = maxMinDiff + 1;
|
||||||
|
const randomOffset = Math.floor(Math.random() * range);
|
||||||
|
const quantity = randomOffset + possible.minQuantity;
|
||||||
|
const { materialId } = possible;
|
||||||
|
|
||||||
|
const existing = state.exploration.materials.find((m) => {
|
||||||
|
return m.materialId === materialId;
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
existing.quantity = existing.quantity + quantity;
|
||||||
|
} else {
|
||||||
|
state.exploration.materials.push({ materialId, quantity });
|
||||||
|
}
|
||||||
|
|
||||||
|
materialsFound.push({ materialId, quantity });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: state as object, updatedAt: now },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: ExploreCollectResponse = {
|
||||||
|
event: eventResult,
|
||||||
|
foundNothing: false,
|
||||||
|
materialsFound: materialsFound,
|
||||||
|
};
|
||||||
|
return context.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"explore_collect",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.gameState.update({
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
|
||||||
data: { state: state as object, updatedAt: now },
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
const response: ExploreCollectResponse = {
|
|
||||||
event: eventResult,
|
|
||||||
foundNothing: false,
|
|
||||||
materialsFound: materialsFound,
|
|
||||||
};
|
|
||||||
return context.json(response);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export { exploreRouter };
|
export { exploreRouter };
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* @file Frontend logging routes that pipe client-side logs to the telemetry service.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
|
|
||||||
|
const validLevels = new Set([ "debug", "info", "warn" ]);
|
||||||
|
|
||||||
|
const frontendRouter = new Hono();
|
||||||
|
|
||||||
|
frontendRouter.post("/log", async(context) => {
|
||||||
|
try {
|
||||||
|
const body = await context.req.json<{ level: string; message: string }>();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
|
if (!body.level || !body.message || !validLevels.has(body.level)) {
|
||||||
|
return context.json({ error: "level and message are required" }, 400);
|
||||||
|
}
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- validated above */
|
||||||
|
void logger.log(body.level as "debug" | "info" | "warn", `[FE] ${body.message}`);
|
||||||
|
return context.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"frontend_log",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
frontendRouter.post("/error", async(context) => {
|
||||||
|
try {
|
||||||
|
const body = await context.req.json<{ context: string; message: string }>();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
|
if (!body.context || !body.message) {
|
||||||
|
return context.json({ error: "context and message are required" }, 400);
|
||||||
|
}
|
||||||
|
void logger.error(`[FE] ${body.context}`, new Error(body.message));
|
||||||
|
return context.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"frontend_error",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { frontendRouter };
|
||||||
+392
-335
@@ -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 };
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { gameTitles } from "../data/titles.js";
|
import { gameTitles } from "../data/titles.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
import type { GameState } from "@elysium/types";
|
import type { GameState } from "@elysium/types";
|
||||||
|
|
||||||
@@ -58,70 +59,80 @@ const resolveTitleName = (titleId: string | null): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
leaderboardRouter.get("/", async(context) => {
|
leaderboardRouter.get("/", async(context) => {
|
||||||
const category = context.req.query("category") ?? "totalGold";
|
try {
|
||||||
const limitRaw = Number(context.req.query("limit") ?? "100");
|
const category = context.req.query("category") ?? "totalGold";
|
||||||
const limit = Math.min(Math.max(1, limitRaw), 100);
|
const limitRaw = Number(context.req.query("limit") ?? "100");
|
||||||
|
const limit = Math.min(Math.max(1, limitRaw), 100);
|
||||||
|
|
||||||
if (!validCategories.has(category)) {
|
if (!validCategories.has(category)) {
|
||||||
return context.json({ error: "Invalid category" }, 400);
|
return context.json({ error: "Invalid category" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [ players, gameStates ] = await Promise.all([
|
const [ players, gameStates ] = await Promise.all([
|
||||||
prisma.player.findMany(),
|
prisma.player.findMany(),
|
||||||
gameStateCategories.has(category)
|
gameStateCategories.has(category)
|
||||||
? prisma.gameState.findMany()
|
? prisma.gameState.findMany()
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const stateMap = new Map(
|
const stateMap = new Map(
|
||||||
gameStates.map((gs) => {
|
gameStates.map((gs) => {
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||||
return [ gs.discordId, gs.state as unknown as GameState ];
|
return [ gs.discordId, gs.state as unknown as GameState ];
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const entries = players.
|
const entries = players.
|
||||||
filter((player) => {
|
filter((player) => {
|
||||||
return parseShowOnLeaderboards(player.profileSettings);
|
return parseShowOnLeaderboards(player.profileSettings);
|
||||||
}).
|
}).
|
||||||
map((player) => {
|
map((player) => {
|
||||||
let value = 0;
|
let value = 0;
|
||||||
if (category === "totalGold") {
|
if (category === "totalGold") {
|
||||||
value = player.lifetimeGoldEarned;
|
value = player.lifetimeGoldEarned;
|
||||||
} else if (category === "bossesDefeated") {
|
} else if (category === "bossesDefeated") {
|
||||||
value = player.lifetimeBossesDefeated;
|
value = player.lifetimeBossesDefeated;
|
||||||
} else if (category === "questsCompleted") {
|
} else if (category === "questsCompleted") {
|
||||||
value = player.lifetimeQuestsCompleted;
|
value = player.lifetimeQuestsCompleted;
|
||||||
} else if (category === "achievementsUnlocked") {
|
} else if (category === "achievementsUnlocked") {
|
||||||
value = player.lifetimeAchievementsUnlocked;
|
value = player.lifetimeAchievementsUnlocked;
|
||||||
} else {
|
} else {
|
||||||
const state = stateMap.get(player.discordId);
|
const state = stateMap.get(player.discordId);
|
||||||
if (category === "prestigeCount") {
|
if (category === "prestigeCount") {
|
||||||
value = state?.prestige.count ?? 0;
|
value = state?.prestige.count ?? 0;
|
||||||
} else if (category === "transcendenceCount") {
|
} else if (category === "transcendenceCount") {
|
||||||
value = state?.transcendence?.count ?? 0;
|
value = state?.transcendence?.count ?? 0;
|
||||||
} else if (category === "apotheosisCount") {
|
} else if (category === "apotheosisCount") {
|
||||||
value = state?.apotheosis?.count ?? 0;
|
value = state?.apotheosis?.count ?? 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return {
|
||||||
return {
|
activeTitle: resolveTitleName(player.activeTitle),
|
||||||
activeTitle: resolveTitleName(player.activeTitle),
|
avatar: player.avatar ?? null,
|
||||||
avatar: player.avatar ?? null,
|
characterName: player.characterName,
|
||||||
characterName: player.characterName,
|
discordId: player.discordId,
|
||||||
discordId: player.discordId,
|
username: player.username,
|
||||||
username: player.username,
|
value: value,
|
||||||
value: value,
|
};
|
||||||
};
|
}).
|
||||||
}).
|
sort((a, b) => {
|
||||||
sort((a, b) => {
|
return b.value - a.value;
|
||||||
return b.value - a.value;
|
}).
|
||||||
}).
|
slice(0, limit).
|
||||||
slice(0, limit).
|
map((entry, index) => {
|
||||||
map((entry, index) => {
|
return { ...entry, rank: index + 1 };
|
||||||
return { ...entry, rank: index + 1 };
|
});
|
||||||
});
|
|
||||||
|
|
||||||
return context.json({ category, entries });
|
return context.json({ category, entries });
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"leaderboards",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { leaderboardRouter };
|
export { leaderboardRouter };
|
||||||
|
|||||||
+192
-163
@@ -6,11 +6,13 @@
|
|||||||
*/
|
*/
|
||||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||||
|
/* eslint-disable complexity -- Route handlers have inherent complexity */
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
|
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import {
|
import {
|
||||||
buildPostPrestigeState,
|
buildPostPrestigeState,
|
||||||
computeRunestoneMultipliers,
|
computeRunestoneMultipliers,
|
||||||
@@ -25,190 +27,217 @@ const prestigeRouter = new Hono<HonoEnvironment>();
|
|||||||
prestigeRouter.use("*", authMiddleware);
|
prestigeRouter.use("*", authMiddleware);
|
||||||
|
|
||||||
prestigeRouter.post("/", async(context) => {
|
prestigeRouter.post("/", async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return context.json({ error: "No save found" }, 404);
|
return context.json({ error: "No save found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||||
const state = record.state as unknown as GameState;
|
const state = record.state as unknown as GameState;
|
||||||
|
|
||||||
if (!isEligibleForPrestige(state)) {
|
if (!isEligibleForPrestige(state)) {
|
||||||
return context.json(
|
return context.json(
|
||||||
{
|
{
|
||||||
error: "Not eligible for prestige — collect 1,000,000 total gold first",
|
// eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened
|
||||||
|
error: "Not eligible for prestige — collect 1,000,000 total gold first",
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update daily prestige challenge progress before resetting the run
|
||||||
|
let updatedDailyChallenges = state.dailyChallenges;
|
||||||
|
let challengeCrystals = 0;
|
||||||
|
if (updatedDailyChallenges) {
|
||||||
|
const result = updateChallengeProgress(
|
||||||
|
updatedDailyChallenges,
|
||||||
|
"prestige",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
updatedDailyChallenges = result.updatedChallenges;
|
||||||
|
challengeCrystals = result.crystalsAwarded;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
milestoneRunestones,
|
||||||
|
prestigeData,
|
||||||
|
prestigeState,
|
||||||
|
runestonesEarned,
|
||||||
|
} = buildPostPrestigeState(state, state.player.characterName);
|
||||||
|
|
||||||
|
// Preserve daily challenges across the prestige reset and apply any crystal rewards
|
||||||
|
const finalState: GameState = {
|
||||||
|
...prestigeState,
|
||||||
|
...updatedDailyChallenges === undefined
|
||||||
|
? {}
|
||||||
|
: { dailyChallenges: updatedDailyChallenges },
|
||||||
|
resources: {
|
||||||
|
...prestigeState.resources,
|
||||||
|
crystals: prestigeState.resources.crystals + challengeCrystals,
|
||||||
},
|
},
|
||||||
400,
|
};
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update daily prestige challenge progress before resetting the run
|
// Capture current-run stats to accumulate into lifetime totals before resetting
|
||||||
let updatedDailyChallenges = state.dailyChallenges;
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
let challengeCrystals = 0;
|
/* v8 ignore next 10 -- @preserve */
|
||||||
if (updatedDailyChallenges) {
|
const runBossesDefeated = state.bosses.filter((boss) => {
|
||||||
const result = updateChallengeProgress(
|
return boss.status === "defeated";
|
||||||
updatedDailyChallenges,
|
}).length;
|
||||||
|
const runQuestsCompleted = state.quests.filter((quest) => {
|
||||||
|
return quest.status === "completed";
|
||||||
|
}).length;
|
||||||
|
let runAdventurersRecruited = 0;
|
||||||
|
for (const adventurer of state.adventurers) {
|
||||||
|
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 3 -- @preserve */
|
||||||
|
const runAchievementsUnlocked = state.achievements.filter((achievement) => {
|
||||||
|
return achievement.unlockedAt !== null;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: finalState as object, updatedAt: now },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.player.update({
|
||||||
|
data: {
|
||||||
|
characterName: state.player.characterName,
|
||||||
|
|
||||||
|
lastSavedAt: now,
|
||||||
|
|
||||||
|
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
|
||||||
|
|
||||||
|
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
|
||||||
|
|
||||||
|
lifetimeBossesDefeated: { increment: runBossesDefeated },
|
||||||
|
|
||||||
|
lifetimeClicks: { increment: state.player.totalClicks },
|
||||||
|
|
||||||
|
// Accumulate into lifetime totals — never reset
|
||||||
|
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
|
||||||
|
|
||||||
|
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
|
||||||
|
|
||||||
|
totalClicks: 0,
|
||||||
|
// Reset current-run counters
|
||||||
|
totalGoldEarned: 0,
|
||||||
|
},
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const prestigeCount = prestigeData.count;
|
||||||
|
void logger.metric("prestige", 1, { discordId, prestigeCount });
|
||||||
|
void postMilestoneWebhook(discordId, "prestige", {
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
apotheosis: prestigeState.apotheosis?.count ?? 0,
|
||||||
|
|
||||||
|
prestige: prestigeData.count,
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 2 -- @preserve */
|
||||||
|
transcendence: prestigeState.transcendence?.count ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return context.json({
|
||||||
|
milestoneRunestones: milestoneRunestones,
|
||||||
|
newPrestigeCount: prestigeData.count, // eslint-disable-line unicorn/no-keyword-prefix -- API response field name required by client
|
||||||
|
runestones: runestonesEarned,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
"prestige",
|
"prestige",
|
||||||
1,
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
);
|
);
|
||||||
updatedDailyChallenges = result.updatedChallenges;
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
challengeCrystals = result.crystalsAwarded;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
|
||||||
milestoneRunestones,
|
|
||||||
prestigeData,
|
|
||||||
prestigeState,
|
|
||||||
runestonesEarned,
|
|
||||||
} = buildPostPrestigeState(state, state.player.characterName);
|
|
||||||
|
|
||||||
// Preserve daily challenges across the prestige reset and apply any crystal rewards
|
|
||||||
const finalState: GameState = {
|
|
||||||
...prestigeState,
|
|
||||||
...updatedDailyChallenges === undefined
|
|
||||||
? {}
|
|
||||||
: { dailyChallenges: updatedDailyChallenges },
|
|
||||||
resources: {
|
|
||||||
...prestigeState.resources,
|
|
||||||
crystals: prestigeState.resources.crystals + challengeCrystals,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Capture current-run stats to accumulate into lifetime totals before resetting
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 10 -- @preserve */
|
|
||||||
const runBossesDefeated = state.bosses.filter((boss) => {
|
|
||||||
return boss.status === "defeated";
|
|
||||||
}).length;
|
|
||||||
const runQuestsCompleted = state.quests.filter((quest) => {
|
|
||||||
return quest.status === "completed";
|
|
||||||
}).length;
|
|
||||||
let runAdventurersRecruited = 0;
|
|
||||||
for (const adventurer of state.adventurers) {
|
|
||||||
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 3 -- @preserve */
|
|
||||||
const runAchievementsUnlocked = state.achievements.filter((achievement) => {
|
|
||||||
return achievement.unlockedAt !== null;
|
|
||||||
}).length;
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
await prisma.gameState.update({
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
|
||||||
data: { state: finalState as object, updatedAt: now },
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.player.update({
|
|
||||||
data: {
|
|
||||||
characterName: state.player.characterName,
|
|
||||||
|
|
||||||
lastSavedAt: now,
|
|
||||||
|
|
||||||
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
|
|
||||||
|
|
||||||
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
|
|
||||||
|
|
||||||
lifetimeBossesDefeated: { increment: runBossesDefeated },
|
|
||||||
|
|
||||||
lifetimeClicks: { increment: state.player.totalClicks },
|
|
||||||
|
|
||||||
// Accumulate into lifetime totals — never reset
|
|
||||||
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
|
|
||||||
|
|
||||||
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
|
|
||||||
|
|
||||||
totalClicks: 0,
|
|
||||||
// Reset current-run counters
|
|
||||||
totalGoldEarned: 0,
|
|
||||||
},
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
void postMilestoneWebhook(discordId, "prestige", {
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next -- @preserve */
|
|
||||||
apotheosis: prestigeState.apotheosis?.count ?? 0,
|
|
||||||
|
|
||||||
prestige: prestigeData.count,
|
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 2 -- @preserve */
|
|
||||||
transcendence: prestigeState.transcendence?.count ?? 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
return context.json({
|
|
||||||
milestoneRunestones: milestoneRunestones,
|
|
||||||
newPrestigeCount: prestigeData.count, // eslint-disable-line unicorn/no-keyword-prefix -- API response field name required by client
|
|
||||||
runestones: runestonesEarned,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
prestigeRouter.post("/buy-upgrade", async(context) => {
|
prestigeRouter.post("/buy-upgrade", async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
const body = await context.req.json<BuyPrestigeUpgradeRequest>();
|
const discordId = context.get("discordId");
|
||||||
|
const body = await context.req.json<BuyPrestigeUpgradeRequest>();
|
||||||
|
|
||||||
const { upgradeId } = body;
|
const { upgradeId } = body;
|
||||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
if (!upgradeId) {
|
if (!upgradeId) {
|
||||||
return context.json({ error: "upgradeId is required" }, 400);
|
return context.json({ error: "upgradeId is required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const upgrade = defaultPrestigeUpgrades.find((prestigeUpgrade) => {
|
const upgrade = defaultPrestigeUpgrades.find((prestigeUpgrade) => {
|
||||||
return prestigeUpgrade.id === upgradeId;
|
return prestigeUpgrade.id === upgradeId;
|
||||||
});
|
});
|
||||||
if (!upgrade) {
|
if (!upgrade) {
|
||||||
return context.json({ error: "Unknown prestige upgrade" }, 404);
|
return context.json({ error: "Unknown prestige upgrade" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return context.json({ error: "No save found" }, 404);
|
return context.json({ error: "No save found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||||
const state = record.state as unknown as GameState;
|
const state = record.state as unknown as GameState;
|
||||||
const { purchasedUpgradeIds, runestones } = state.prestige;
|
const { purchasedUpgradeIds, runestones } = state.prestige;
|
||||||
|
|
||||||
if (purchasedUpgradeIds.includes(upgradeId)) {
|
if (purchasedUpgradeIds.includes(upgradeId)) {
|
||||||
return context.json({ error: "Upgrade already purchased" }, 400);
|
return context.json({ error: "Upgrade already purchased" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runestones < upgrade.runestonesCost) {
|
if (runestones < upgrade.runestonesCost) {
|
||||||
return context.json({ error: "Not enough runestones" }, 400);
|
return context.json({ error: "Not enough runestones" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedRunestones = runestones - upgrade.runestonesCost;
|
const updatedRunestones = runestones - upgrade.runestonesCost;
|
||||||
const updatedPurchasedUpgradeIds = [ ...purchasedUpgradeIds, upgradeId ];
|
const updatedPurchasedUpgradeIds = [ ...purchasedUpgradeIds, upgradeId ];
|
||||||
|
|
||||||
const updatedState: GameState = {
|
const updatedState: GameState = {
|
||||||
...state,
|
...state,
|
||||||
prestige: {
|
prestige: {
|
||||||
...state.prestige,
|
...state.prestige,
|
||||||
|
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
|
||||||
|
runestones: updatedRunestones,
|
||||||
|
...computeRunestoneMultipliers(updatedPurchasedUpgradeIds),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: updatedState as object, updatedAt: Date.now() },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const multipliers = computeRunestoneMultipliers(updatedPurchasedUpgradeIds);
|
||||||
|
|
||||||
|
void logger.metric("prestige_upgrade_purchased", 1, {
|
||||||
|
discordId,
|
||||||
|
upgradeId,
|
||||||
|
});
|
||||||
|
return context.json({
|
||||||
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
|
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
|
||||||
runestones: updatedRunestones,
|
runestonesRemaining: updatedRunestones,
|
||||||
...computeRunestoneMultipliers(updatedPurchasedUpgradeIds),
|
...multipliers,
|
||||||
},
|
});
|
||||||
};
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
await prisma.gameState.update({
|
"prestige_buy_upgrade",
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
error instanceof Error
|
||||||
data: { state: updatedState as object, updatedAt: Date.now() },
|
? error
|
||||||
where: { discordId },
|
: new Error(String(error)),
|
||||||
});
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
const multipliers = computeRunestoneMultipliers(updatedPurchasedUpgradeIds);
|
}
|
||||||
|
|
||||||
return context.json({
|
|
||||||
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
|
|
||||||
runestonesRemaining: updatedRunestones,
|
|
||||||
...multipliers,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export { prestigeRouter };
|
export { prestigeRouter };
|
||||||
|
|||||||
+185
-160
@@ -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 };
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
*/
|
*/
|
||||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||||
|
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js";
|
import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import {
|
import {
|
||||||
buildPostTranscendenceState,
|
buildPostTranscendenceState,
|
||||||
computeTranscendenceMultipliers,
|
computeTranscendenceMultipliers,
|
||||||
@@ -24,168 +26,196 @@ const transcendenceRouter = new Hono<HonoEnvironment>();
|
|||||||
transcendenceRouter.use("*", authMiddleware);
|
transcendenceRouter.use("*", authMiddleware);
|
||||||
|
|
||||||
transcendenceRouter.post("/", async(context) => {
|
transcendenceRouter.post("/", async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return context.json({ error: "No save found" }, 404);
|
return context.json({ error: "No save found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||||
const state = record.state as unknown as GameState;
|
const state = record.state as unknown as GameState;
|
||||||
|
|
||||||
if (!isEligibleForTranscendence(state)) {
|
if (!isEligibleForTranscendence(state)) {
|
||||||
return context.json(
|
return context.json(
|
||||||
{
|
{
|
||||||
error: "Not eligible for transcendence — defeat The Absolute One first",
|
// eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened
|
||||||
|
error: "Not eligible for transcendence — defeat The Absolute One first",
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
echoesEarned,
|
||||||
|
transcendenceData,
|
||||||
|
transcendenceState,
|
||||||
|
} = buildPostTranscendenceState(state, state.player.characterName);
|
||||||
|
|
||||||
|
// Capture current-run stats before the nuclear reset
|
||||||
|
const runBossesDefeated = state.bosses.filter((boss) => {
|
||||||
|
return boss.status === "defeated";
|
||||||
|
}).length;
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 7 -- @preserve */
|
||||||
|
const runQuestsCompleted = state.quests.filter((quest) => {
|
||||||
|
return quest.status === "completed";
|
||||||
|
}).length;
|
||||||
|
let runAdventurersRecruited = 0;
|
||||||
|
for (const adventurer of state.adventurers) {
|
||||||
|
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 3 -- @preserve */
|
||||||
|
const runAchievementsUnlocked = state.achievements.filter((achievement) => {
|
||||||
|
return achievement.unlockedAt !== null;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: transcendenceState as object, updatedAt: now },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.player.update({
|
||||||
|
data: {
|
||||||
|
characterName: state.player.characterName,
|
||||||
|
|
||||||
|
lastSavedAt: now,
|
||||||
|
|
||||||
|
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
|
||||||
|
|
||||||
|
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
|
||||||
|
|
||||||
|
lifetimeBossesDefeated: { increment: runBossesDefeated },
|
||||||
|
|
||||||
|
lifetimeClicks: { increment: state.player.totalClicks },
|
||||||
|
|
||||||
|
// Accumulate into lifetime totals
|
||||||
|
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
|
||||||
|
|
||||||
|
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
|
||||||
|
|
||||||
|
totalClicks: 0,
|
||||||
|
// Reset current-run counters (same as prestige)
|
||||||
|
totalGoldEarned: 0,
|
||||||
},
|
},
|
||||||
400,
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const transcendenceCount = transcendenceData.count;
|
||||||
|
void logger.metric("transcendence", 1, { discordId, transcendenceCount });
|
||||||
|
void postMilestoneWebhook(discordId, "transcendence", {
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
apotheosis: transcendenceState.apotheosis?.count ?? 0,
|
||||||
|
|
||||||
|
prestige: transcendenceState.prestige.count,
|
||||||
|
|
||||||
|
transcendence: transcendenceData.count,
|
||||||
|
});
|
||||||
|
|
||||||
|
return context.json({
|
||||||
|
echoes: echoesEarned,
|
||||||
|
// eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client
|
||||||
|
newTranscendenceCount: transcendenceData.count,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"transcendence",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
);
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
|
||||||
echoesEarned,
|
|
||||||
transcendenceData,
|
|
||||||
transcendenceState,
|
|
||||||
} = buildPostTranscendenceState(state, state.player.characterName);
|
|
||||||
|
|
||||||
// Capture current-run stats before the nuclear reset
|
|
||||||
const runBossesDefeated = state.bosses.filter((boss) => {
|
|
||||||
return boss.status === "defeated";
|
|
||||||
}).length;
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 7 -- @preserve */
|
|
||||||
const runQuestsCompleted = state.quests.filter((quest) => {
|
|
||||||
return quest.status === "completed";
|
|
||||||
}).length;
|
|
||||||
let runAdventurersRecruited = 0;
|
|
||||||
for (const adventurer of state.adventurers) {
|
|
||||||
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 3 -- @preserve */
|
|
||||||
const runAchievementsUnlocked = state.achievements.filter((achievement) => {
|
|
||||||
return achievement.unlockedAt !== null;
|
|
||||||
}).length;
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
await prisma.gameState.update({
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
|
||||||
data: { state: transcendenceState as object, updatedAt: now },
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.player.update({
|
|
||||||
data: {
|
|
||||||
characterName: state.player.characterName,
|
|
||||||
|
|
||||||
lastSavedAt: now,
|
|
||||||
|
|
||||||
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
|
|
||||||
|
|
||||||
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
|
|
||||||
|
|
||||||
lifetimeBossesDefeated: { increment: runBossesDefeated },
|
|
||||||
|
|
||||||
lifetimeClicks: { increment: state.player.totalClicks },
|
|
||||||
|
|
||||||
// Accumulate into lifetime totals
|
|
||||||
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
|
|
||||||
|
|
||||||
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
|
|
||||||
|
|
||||||
totalClicks: 0,
|
|
||||||
// Reset current-run counters (same as prestige)
|
|
||||||
totalGoldEarned: 0,
|
|
||||||
},
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
void postMilestoneWebhook(discordId, "transcendence", {
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next -- @preserve */
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next -- @preserve */
|
|
||||||
apotheosis: transcendenceState.apotheosis?.count ?? 0,
|
|
||||||
|
|
||||||
prestige: transcendenceState.prestige.count,
|
|
||||||
|
|
||||||
transcendence: transcendenceData.count,
|
|
||||||
});
|
|
||||||
|
|
||||||
return context.json({
|
|
||||||
echoes: echoesEarned,
|
|
||||||
// eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client
|
|
||||||
newTranscendenceCount: transcendenceData.count,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
transcendenceRouter.post("/buy-upgrade", async(context) => {
|
transcendenceRouter.post("/buy-upgrade", async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
const body = await context.req.json<BuyEchoUpgradeRequest>();
|
const discordId = context.get("discordId");
|
||||||
|
const body = await context.req.json<BuyEchoUpgradeRequest>();
|
||||||
|
|
||||||
const { upgradeId } = body;
|
const { upgradeId } = body;
|
||||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
if (!upgradeId) {
|
if (!upgradeId) {
|
||||||
return context.json({ error: "upgradeId is required" }, 400);
|
return context.json({ error: "upgradeId is required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const upgrade = defaultTranscendenceUpgrades.find((transcendenceUpgrade) => {
|
// eslint-disable-next-line stylistic/max-len -- Variable name mirrors the data source for clarity
|
||||||
return transcendenceUpgrade.id === upgradeId;
|
const upgrade = defaultTranscendenceUpgrades.find((transcendenceUpgrade) => {
|
||||||
});
|
return transcendenceUpgrade.id === upgradeId;
|
||||||
if (!upgrade) {
|
});
|
||||||
return context.json({ error: "Unknown echo upgrade" }, 404);
|
if (!upgrade) {
|
||||||
}
|
return context.json({ error: "Unknown echo upgrade" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return context.json({ error: "No save found" }, 404);
|
return context.json({ error: "No save found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||||
const state = record.state as unknown as GameState;
|
const state = record.state as unknown as GameState;
|
||||||
|
|
||||||
if (!state.transcendence) {
|
if (!state.transcendence) {
|
||||||
return context.json({ error: "No transcendence data found" }, 400);
|
return context.json({ error: "No transcendence data found" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { purchasedUpgradeIds, echoes } = state.transcendence;
|
const { purchasedUpgradeIds, echoes } = state.transcendence;
|
||||||
|
|
||||||
if (purchasedUpgradeIds.includes(upgradeId)) {
|
if (purchasedUpgradeIds.includes(upgradeId)) {
|
||||||
return context.json({ error: "Upgrade already purchased" }, 400);
|
return context.json({ error: "Upgrade already purchased" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (echoes < upgrade.cost) {
|
if (echoes < upgrade.cost) {
|
||||||
return context.json({ error: "Not enough echoes" }, 400);
|
return context.json({ error: "Not enough echoes" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedEchoes = echoes - upgrade.cost;
|
const updatedEchoes = echoes - upgrade.cost;
|
||||||
const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ];
|
const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ];
|
||||||
const updatedMultipliers
|
const updatedMultipliers
|
||||||
= computeTranscendenceMultipliers(updatedPurchasedIds);
|
= computeTranscendenceMultipliers(updatedPurchasedIds);
|
||||||
|
|
||||||
const updatedState: GameState = {
|
const updatedState: GameState = {
|
||||||
...state,
|
...state,
|
||||||
transcendence: {
|
transcendence: {
|
||||||
...state.transcendence,
|
...state.transcendence,
|
||||||
echoes: updatedEchoes,
|
echoes: updatedEchoes,
|
||||||
|
purchasedUpgradeIds: updatedPurchasedIds,
|
||||||
|
...updatedMultipliers,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: updatedState as object, updatedAt: Date.now() },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
void logger.metric("transcendence_upgrade_purchased", 1, {
|
||||||
|
discordId,
|
||||||
|
upgradeId,
|
||||||
|
});
|
||||||
|
return context.json({
|
||||||
|
echoesRemaining: updatedEchoes,
|
||||||
purchasedUpgradeIds: updatedPurchasedIds,
|
purchasedUpgradeIds: updatedPurchasedIds,
|
||||||
...updatedMultipliers,
|
...updatedMultipliers,
|
||||||
},
|
});
|
||||||
};
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
await prisma.gameState.update({
|
"transcendence_buy_upgrade",
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
error instanceof Error
|
||||||
data: { state: updatedState as object, updatedAt: Date.now() },
|
? error
|
||||||
where: { discordId },
|
: new Error(String(error)),
|
||||||
});
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
return context.json({
|
}
|
||||||
echoesRemaining: updatedEchoes,
|
|
||||||
purchasedUpgradeIds: updatedPurchasedIds,
|
|
||||||
...updatedMultipliers,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export { transcendenceRouter };
|
export { transcendenceRouter };
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */
|
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */
|
||||||
|
import { logger } from "./logger.js";
|
||||||
|
|
||||||
interface DiscordTokenResponse {
|
interface DiscordTokenResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
@@ -50,18 +51,28 @@ const exchangeCode = async(
|
|||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch("https://discord.com/api/v10/oauth2/token", {
|
try {
|
||||||
body: parameters.toString(),
|
const response = await fetch("https://discord.com/api/v10/oauth2/token", {
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
body: parameters.toString(),
|
||||||
method: "POST",
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
});
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Discord token exchange failed: ${response.statusText}`);
|
throw new Error(`Discord token exchange failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordTokenResponse shape */
|
||||||
|
return await (response.json() as Promise<DiscordTokenResponse>);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"discord_exchange_code",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordTokenResponse shape */
|
|
||||||
return await (response.json() as Promise<DiscordTokenResponse>);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,16 +84,26 @@ const exchangeCode = async(
|
|||||||
const fetchDiscordUser = async(
|
const fetchDiscordUser = async(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
): Promise<DiscordUser> => {
|
): Promise<DiscordUser> => {
|
||||||
const response = await fetch("https://discord.com/api/v10/users/@me", {
|
try {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
const response = await fetch("https://discord.com/api/v10/users/@me", {
|
||||||
});
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Discord user fetch failed: ${response.statusText}`);
|
throw new Error(`Discord user fetch failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */
|
||||||
|
return await (response.json() as Promise<DiscordUser>);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"discord_fetch_user",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */
|
|
||||||
return await (response.json() as Promise<DiscordUser>);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* @file Logger service for handling logging.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Logger } from "@nhcarrigan/logger";
|
||||||
|
|
||||||
|
const logger = new Logger("Elysium", process.env.LOG_TOKEN ?? "");
|
||||||
|
|
||||||
|
export { logger };
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
/* eslint-disable max-lines-per-function -- buildPostPrestigeState requires constructing a large composite state object */
|
/* eslint-disable max-lines-per-function -- buildPostPrestigeState requires constructing a large composite state object */
|
||||||
|
/* eslint-disable complexity -- buildPostPrestigeState has many optional fields that each add a branch point */
|
||||||
import { initialGameState } from "../data/initialState.js";
|
import { initialGameState } from "../data/initialState.js";
|
||||||
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
|
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -205,10 +206,81 @@ 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
|
||||||
|
|| currentBoss?.status === "defeated"
|
||||||
|
) {
|
||||||
|
return { ...freshBoss, bountyRunestonesClaimed: true };
|
||||||
|
}
|
||||||
|
return freshBoss;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compute current-run contributions to accumulate into lifetime totals
|
||||||
|
const runBossesDefeated = currentState.bosses.filter((boss) => {
|
||||||
|
return boss.status === "defeated";
|
||||||
|
}).length;
|
||||||
|
const runQuestsCompleted = currentState.quests.filter((quest) => {
|
||||||
|
return quest.status === "completed";
|
||||||
|
}).length;
|
||||||
|
let runAdventurersRecruited = 0;
|
||||||
|
for (const adventurer of currentState.adventurers) {
|
||||||
|
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
|
||||||
|
}
|
||||||
|
const runAchievementsUnlocked = currentState.achievements.filter(
|
||||||
|
(achievement) => {
|
||||||
|
return achievement.unlockedAt !== null;
|
||||||
|
},
|
||||||
|
).length;
|
||||||
|
|
||||||
const prestigeState: GameState = {
|
const prestigeState: GameState = {
|
||||||
...freshState,
|
...freshState,
|
||||||
|
|
||||||
|
// Achievements are permanent — earned achievements survive all prestiges
|
||||||
|
achievements: currentState.achievements,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Preserve automation preferences across prestige — the player explicitly
|
||||||
|
* opted into these settings and would not expect them to silently reset.
|
||||||
|
*/
|
||||||
|
autoBoss: currentState.autoBoss ?? false,
|
||||||
|
|
||||||
|
autoQuest: currentState.autoQuest ?? false,
|
||||||
|
// Boss statuses reset for gameplay, but first-kill claimed flag is preserved
|
||||||
|
bosses: bossesWithBountyClaimed,
|
||||||
lastTickAt: Date.now(),
|
lastTickAt: Date.now(),
|
||||||
prestige: prestigeData,
|
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
? {}
|
? {}
|
||||||
|
|||||||
@@ -5,8 +5,16 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case and Pascal-case header names */
|
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case and Pascal-case header names */
|
||||||
|
import { logger } from "./logger.js";
|
||||||
|
|
||||||
const discordApi = "https://discord.com/api/v10";
|
const discordApi = "https://discord.com/api/v10";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discord MessageFlags.SUPPRESS_NOTIFICATIONS — messages are delivered without
|
||||||
|
* triggering desktop or mobile push notifications.
|
||||||
|
*/
|
||||||
|
const suppressNotifications = 4096;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Grants the apotheosis Discord role to the given player if configured.
|
* Grants the apotheosis Discord role to the given player if configured.
|
||||||
* Fails silently so role grant errors do not affect the game action.
|
* Fails silently so role grant errors do not affect the game action.
|
||||||
@@ -34,7 +42,13 @@ const grantApotheosisRole = async(discordId: string): Promise<void> => {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"webhook_apotheosis_role",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
// Graceful degradation — role grant failure must not affect the apotheosis
|
// Graceful degradation — role grant failure must not affect the apotheosis
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -77,11 +91,20 @@ const postMilestoneWebhook = async(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(webhookUrl, {
|
await fetch(webhookUrl, {
|
||||||
body: JSON.stringify({ content }),
|
body: JSON.stringify({
|
||||||
|
content: content,
|
||||||
|
flags: suppressNotifications,
|
||||||
|
}),
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"webhook_milestone",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
// Graceful degradation — webhook failure must not affect the game action
|
// Graceful degradation — webhook failure must not affect the game action
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -55,4 +55,15 @@ describe("authMiddleware", () => {
|
|||||||
}));
|
}));
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 401 when verifyToken throws a non-Error value", async () => {
|
||||||
|
const { app, verifyToken } = await makeApp();
|
||||||
|
vi.mocked(verifyToken).mockImplementationOnce(() => {
|
||||||
|
throw "raw string error";
|
||||||
|
});
|
||||||
|
const res = await app.fetch(new Request("http://localhost/test", {
|
||||||
|
headers: { Authorization: "Bearer bad_token" },
|
||||||
|
}));
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,6 +80,18 @@ describe("apotheosis route", () => {
|
|||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await post();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns apotheosis count on success", async () => {
|
it("returns apotheosis count on success", async () => {
|
||||||
// Need all 15 transcendence upgrades purchased for eligibility
|
// Need all 15 transcendence upgrades purchased for eligibility
|
||||||
const allUpgradeIds = [
|
const allUpgradeIds = [
|
||||||
|
|||||||
@@ -113,5 +113,14 @@ describe("auth route", () => {
|
|||||||
const location = res.headers.get("Location") ?? "";
|
const location = res.headers.get("Location") ?? "";
|
||||||
expect(location).toContain("error=auth_failed");
|
expect(location).toContain("error=auth_failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("redirects with error when callback throws a non-Error value", async () => {
|
||||||
|
const { app, exchangeCode } = await makeApp();
|
||||||
|
exchangeCode.mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await app.fetch(new Request("http://localhost/auth/callback?code=bad_code"));
|
||||||
|
expect(res.status).toBe(302);
|
||||||
|
const location = res.headers.get("Location") ?? "";
|
||||||
|
expect(location).toContain("error=auth_failed");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -293,4 +293,37 @@ describe("boss route", () => {
|
|||||||
const body = await res.json() as { won: boolean };
|
const body = await res.json() as { won: boolean };
|
||||||
expect(body.won).toBe(true);
|
expect(body.won).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await challenge({ bossId: "test_boss" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await challenge({ bossId: "test_boss" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-award bounty runestones when bountyRunestonesClaimed is true", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [makeBoss({
|
||||||
|
bountyRunestonesClaimed: true,
|
||||||
|
currentHp: 100,
|
||||||
|
damagePerSecond: 1,
|
||||||
|
maxHp: 100,
|
||||||
|
})] as GameState["bosses"],
|
||||||
|
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
||||||
|
prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 5 },
|
||||||
|
zones: [],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await challenge({ bossId: "test_boss" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { won: boolean; rewards: { bountyRunestones: number } };
|
||||||
|
expect(body.won).toBe(true);
|
||||||
|
expect(body.rewards.bountyRunestones).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -143,4 +143,16 @@ describe("craft route", () => {
|
|||||||
expect(body.recipeId).toBe(TEST_RECIPE_ID);
|
expect(body.recipeId).toBe(TEST_RECIPE_ID);
|
||||||
expect(body.bonusType).toBe("gold_income");
|
expect(body.bonusType).toBe("gold_income");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,450 @@
|
|||||||
|
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||||
|
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
||||||
|
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import type { GameState } from "@elysium/types";
|
||||||
|
|
||||||
|
vi.mock("../../src/db/client.js", () => ({
|
||||||
|
prisma: {
|
||||||
|
gameState: { findUnique: vi.fn(), update: vi.fn(), upsert: vi.fn() },
|
||||||
|
player: { findUnique: vi.fn() },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../src/middleware/auth.js", () => ({
|
||||||
|
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
|
||||||
|
c.set("discordId", "test_discord_id");
|
||||||
|
await next();
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../src/services/logger.js", () => ({
|
||||||
|
logger: {
|
||||||
|
error: vi.fn().mockResolvedValue(undefined),
|
||||||
|
log: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const DISCORD_ID = "test_discord_id";
|
||||||
|
|
||||||
|
const makeExploration = (areas: GameState["exploration"]["areas"] = []): GameState["exploration"] => ({
|
||||||
|
areas: areas,
|
||||||
|
craftedCombatMultiplier: 1,
|
||||||
|
craftedClickMultiplier: 1,
|
||||||
|
craftedEssenceMultiplier: 1,
|
||||||
|
craftedGoldMultiplier: 1,
|
||||||
|
craftedRecipeIds: [],
|
||||||
|
materials: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||||
|
achievements: [],
|
||||||
|
adventurers: [],
|
||||||
|
baseClickPower: 1,
|
||||||
|
bosses: [],
|
||||||
|
companions: { activeCompanionId: null, unlockedCompanionIds: [] },
|
||||||
|
equipment: [],
|
||||||
|
exploration: makeExploration(),
|
||||||
|
lastTickAt: 0,
|
||||||
|
player: { avatar: null, characterName: "T", discordId: DISCORD_ID, discriminator: "0", totalClicks: 0, totalGoldEarned: 0, username: "u" },
|
||||||
|
prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
|
||||||
|
quests: [],
|
||||||
|
resources: { crystals: 0, essence: 0, gold: 0, runestones: 0 },
|
||||||
|
schemaVersion: 1,
|
||||||
|
upgrades: [],
|
||||||
|
zones: [],
|
||||||
|
...overrides,
|
||||||
|
} as GameState);
|
||||||
|
|
||||||
|
const makePlayer = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
avatar: null,
|
||||||
|
characterName: "TestChar",
|
||||||
|
createdAt: 0,
|
||||||
|
discordId: DISCORD_ID,
|
||||||
|
discriminator: "0",
|
||||||
|
lifetimeAchievementsUnlocked: 0,
|
||||||
|
lifetimeAdventurersRecruited: 0,
|
||||||
|
lifetimeBossesDefeated: 0,
|
||||||
|
lifetimeClicks: 0,
|
||||||
|
lifetimeGoldEarned: 0,
|
||||||
|
lifetimeQuestsCompleted: 0,
|
||||||
|
loginStreak: 1,
|
||||||
|
username: "test_user",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("debug route", () => {
|
||||||
|
let app: Hono;
|
||||||
|
let prisma: {
|
||||||
|
gameState: {
|
||||||
|
findUnique: ReturnType<typeof vi.fn>;
|
||||||
|
update: ReturnType<typeof vi.fn>;
|
||||||
|
upsert: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
player: { findUnique: ReturnType<typeof vi.fn> };
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
const { debugRouter } = await import("../../src/routes/debug.js");
|
||||||
|
const { prisma: p } = await import("../../src/db/client.js");
|
||||||
|
prisma = p as typeof prisma;
|
||||||
|
app = new Hono();
|
||||||
|
app.route("/debug", debugRouter);
|
||||||
|
});
|
||||||
|
|
||||||
|
const forceUnlocks = () =>
|
||||||
|
app.fetch(new Request("http://localhost/debug/force-unlocks", { method: "POST" }));
|
||||||
|
|
||||||
|
const hardReset = () =>
|
||||||
|
app.fetch(new Request("http://localhost/debug/hard-reset", { method: "POST" }));
|
||||||
|
|
||||||
|
describe("POST /force-unlocks", () => {
|
||||||
|
it("returns 404 when no game state found", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 with all zeros when no stale locks exist", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as {
|
||||||
|
bossesUnlocked: number;
|
||||||
|
explorationUnlocked: number;
|
||||||
|
questsUnlocked: number;
|
||||||
|
zonesUnlocked: number;
|
||||||
|
};
|
||||||
|
expect(body.zonesUnlocked).toBe(0);
|
||||||
|
expect(body.explorationUnlocked).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unlocks verdant_vale when it is locked and has no requirements", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
zones: [{ id: "verdant_vale", status: "locked" }] as GameState["zones"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { zonesUnlocked: number };
|
||||||
|
expect(body.zonesUnlocked).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not unlock zone when boss condition is not met", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [{ id: "forest_giant", status: "available" }] as GameState["bosses"],
|
||||||
|
quests: [{ id: "ancient_ruins", status: "completed" }] as GameState["quests"],
|
||||||
|
zones: [{ id: "shattered_ruins", status: "locked" }] as GameState["zones"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { zonesUnlocked: number };
|
||||||
|
expect(body.zonesUnlocked).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not unlock zone when quest condition is not met", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [{ id: "forest_giant", status: "defeated" }] as GameState["bosses"],
|
||||||
|
quests: [{ id: "ancient_ruins", status: "active" }] as GameState["quests"],
|
||||||
|
zones: [{ id: "shattered_ruins", status: "locked" }] as GameState["zones"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { zonesUnlocked: number };
|
||||||
|
expect(body.zonesUnlocked).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unlocks zone when both boss and quest conditions are met", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [{ id: "forest_giant", status: "defeated" }] as GameState["bosses"],
|
||||||
|
quests: [{ id: "ancient_ruins", status: "completed" }] as GameState["quests"],
|
||||||
|
zones: [{ id: "shattered_ruins", status: "locked" }] as GameState["zones"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { zonesUnlocked: number };
|
||||||
|
expect(body.zonesUnlocked).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unlocks a quest when zone is unlocked and prerequisites are met", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
quests: [{ id: "first_steps", status: "locked" }] as GameState["quests"],
|
||||||
|
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { questsUnlocked: number };
|
||||||
|
expect(body.questsUnlocked).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not unlock quest when zone is locked", async () => {
|
||||||
|
/*
|
||||||
|
* Use shattered_ruins (requires forest_giant defeated) so applyZoneUnlocks
|
||||||
|
* cannot auto-unlock it, keeping it locked when applyQuestUnlocks runs.
|
||||||
|
*/
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [{ id: "forest_giant", status: "available" }] as GameState["bosses"],
|
||||||
|
quests: [{ id: "necromancer_tower", status: "locked" }] as GameState["quests"],
|
||||||
|
zones: [{ id: "shattered_ruins", status: "locked" }] as GameState["zones"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { questsUnlocked: number };
|
||||||
|
expect(body.questsUnlocked).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not unlock quest when zone is not in state", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
quests: [{ id: "first_steps", status: "locked" }] as GameState["quests"],
|
||||||
|
zones: [] as GameState["zones"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { questsUnlocked: number };
|
||||||
|
expect(body.questsUnlocked).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not unlock quest when it is already available", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
quests: [{ id: "first_steps", status: "available" }] as GameState["quests"],
|
||||||
|
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { questsUnlocked: number };
|
||||||
|
expect(body.questsUnlocked).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not unlock quest when prerequisites are not completed", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
quests: [{ id: "goblin_camp", status: "locked" }] as GameState["quests"],
|
||||||
|
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { questsUnlocked: number };
|
||||||
|
expect(body.questsUnlocked).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unlocks the first boss in a zone when the zone is unlocked", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [{ id: "troll_king", status: "locked" }] as GameState["bosses"],
|
||||||
|
prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
|
||||||
|
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { bossesUnlocked: number };
|
||||||
|
expect(body.bossesUnlocked).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not unlock boss when prestige requirement is not met", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [{ id: "the_first_light", status: "locked" }] as GameState["bosses"],
|
||||||
|
prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
|
||||||
|
zones: [{ id: "celestial_reaches", status: "unlocked" }] as GameState["zones"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { bossesUnlocked: number };
|
||||||
|
expect(body.bossesUnlocked).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not unlock boss when previous boss is not defeated", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [
|
||||||
|
{ id: "troll_king", status: "available" },
|
||||||
|
{ id: "lich_queen", status: "locked" },
|
||||||
|
] as GameState["bosses"],
|
||||||
|
prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
|
||||||
|
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { bossesUnlocked: number };
|
||||||
|
expect(body.bossesUnlocked).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not unlock boss when previous boss is not in state", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [{ id: "lich_queen", status: "locked" }] as GameState["bosses"],
|
||||||
|
prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
|
||||||
|
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { bossesUnlocked: number };
|
||||||
|
expect(body.bossesUnlocked).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unlocks next boss when previous boss is defeated", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [
|
||||||
|
{ id: "troll_king", status: "defeated" },
|
||||||
|
{ id: "lich_queen", status: "locked" },
|
||||||
|
] as GameState["bosses"],
|
||||||
|
prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
|
||||||
|
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { bossesUnlocked: number };
|
||||||
|
expect(body.bossesUnlocked).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns explorationUnlocked=0 when exploration is undefined", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
exploration: undefined as unknown as GameState["exploration"],
|
||||||
|
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { explorationUnlocked: number };
|
||||||
|
expect(body.explorationUnlocked).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unlocks exploration area when its zone is unlocked", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
exploration: makeExploration([
|
||||||
|
{ id: "verdant_meadow", status: "locked" } as GameState["exploration"]["areas"][0],
|
||||||
|
]),
|
||||||
|
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { explorationUnlocked: number };
|
||||||
|
expect(body.explorationUnlocked).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not unlock exploration area when zone is not unlocked", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
exploration: makeExploration([
|
||||||
|
{ id: "vm_e1", status: "locked" } as GameState["exploration"]["areas"][0],
|
||||||
|
]),
|
||||||
|
zones: [] as GameState["zones"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { explorationUnlocked: number };
|
||||||
|
expect(body.explorationUnlocked).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not unlock exploration area when it is already available", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
exploration: makeExploration([
|
||||||
|
{ id: "verdant_meadow", status: "available" } as GameState["exploration"]["areas"][0],
|
||||||
|
]),
|
||||||
|
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { explorationUnlocked: number };
|
||||||
|
expect(body.explorationUnlocked).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
|
||||||
|
process.env.ANTI_CHEAT_SECRET = "test_secret";
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { signature: string | undefined };
|
||||||
|
expect(body.signature).toBeDefined();
|
||||||
|
delete process.env.ANTI_CHEAT_SECRET;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits signature when ANTI_CHEAT_SECRET is not set", async () => {
|
||||||
|
delete process.env.ANTI_CHEAT_SECRET;
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { signature: string | undefined };
|
||||||
|
expect(body.signature).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when DB throws an Error", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when DB throws a non-Error value", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw error");
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /hard-reset", () => {
|
||||||
|
it("returns 404 when no player found", async () => {
|
||||||
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
|
||||||
|
const res = await hardReset();
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 with a fresh state on success", async () => {
|
||||||
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
||||||
|
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await hardReset();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as {
|
||||||
|
loginBonus: null;
|
||||||
|
loginStreak: number;
|
||||||
|
schemaOutdated: boolean;
|
||||||
|
};
|
||||||
|
expect(body.loginBonus).toBeNull();
|
||||||
|
expect(body.schemaOutdated).toBe(false);
|
||||||
|
expect(body.loginStreak).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
|
||||||
|
process.env.ANTI_CHEAT_SECRET = "test_secret";
|
||||||
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
||||||
|
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await hardReset();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { signature: string | undefined };
|
||||||
|
expect(body.signature).toBeDefined();
|
||||||
|
delete process.env.ANTI_CHEAT_SECRET;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when DB throws an Error", async () => {
|
||||||
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await hardReset();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when DB throws a non-Error value", async () => {
|
||||||
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw error");
|
||||||
|
const res = await hardReset();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -406,5 +406,31 @@ describe("explore route", () => {
|
|||||||
expect(body.materialsFound.some((m) => m.materialId === "verdant_sap")).toBe(true);
|
expect(body.materialsFound.some((m) => m.materialId === "verdant_sap")).toBe(true);
|
||||||
mockRandom.mockRestore();
|
mockRandom.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws on collect", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value on collect", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /start error path", () => {
|
||||||
|
it("returns 500 when the database throws on start", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value on start", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
vi.mock("../../src/services/logger.js", () => ({
|
||||||
|
logger: {
|
||||||
|
log: vi.fn().mockResolvedValue(undefined),
|
||||||
|
error: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("frontend route", () => {
|
||||||
|
let loggerMock: { log: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
const { logger } = await import("../../src/services/logger.js");
|
||||||
|
loggerMock = logger as typeof loggerMock;
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeApp = async () => {
|
||||||
|
const { frontendRouter } = await import("../../src/routes/frontend.js");
|
||||||
|
const app = new Hono();
|
||||||
|
app.route("/frontend", frontendRouter);
|
||||||
|
return app;
|
||||||
|
};
|
||||||
|
|
||||||
|
const postLog = async (body: unknown, contentType = "application/json") => {
|
||||||
|
const app = await makeApp();
|
||||||
|
return app.fetch(new Request("http://localhost/frontend/log", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": contentType },
|
||||||
|
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const postError = async (body: unknown, contentType = "application/json") => {
|
||||||
|
const app = await makeApp();
|
||||||
|
return app.fetch(new Request("http://localhost/frontend/error", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": contentType },
|
||||||
|
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("POST /log", () => {
|
||||||
|
it("returns 200 when level is debug and message is present", async () => {
|
||||||
|
const res = await postLog({ level: "debug", message: "test debug" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 when level is info and message is present", async () => {
|
||||||
|
const res = await postLog({ level: "info", message: "test info" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 when level is warn and message is present", async () => {
|
||||||
|
const res = await postLog({ level: "warn", message: "test warn" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when level is invalid", async () => {
|
||||||
|
const res = await postLog({ level: "error", message: "test" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("level and message are required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when level is missing", async () => {
|
||||||
|
const res = await postLog({ message: "test" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when message is missing", async () => {
|
||||||
|
const res = await postLog({ level: "info" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when request body is invalid JSON", async () => {
|
||||||
|
const res = await postLog("not valid json at all", "application/json");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("Internal server error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 and covers non-Error branch when logger throws a raw value", async () => {
|
||||||
|
loggerMock.log.mockImplementationOnce(() => { throw "raw string error"; });
|
||||||
|
const res = await postLog({ level: "info", message: "test" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("Internal server error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /error", () => {
|
||||||
|
it("returns 200 with valid context and message", async () => {
|
||||||
|
const res = await postError({ context: "SomeComponent", message: "Something went wrong" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when context field is missing", async () => {
|
||||||
|
const res = await postError({ message: "Something went wrong" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("context and message are required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when message field is missing", async () => {
|
||||||
|
const res = await postError({ context: "SomeComponent" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when request body is invalid JSON", async () => {
|
||||||
|
const res = await postError("not valid json at all", "application/json");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("Internal server error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 and covers non-Error branch when logger throws a raw value", async () => {
|
||||||
|
loggerMock.error.mockImplementationOnce(() => { throw "raw string error"; });
|
||||||
|
const res = await postError({ context: "SomeComponent", message: "Something went wrong" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("Internal server error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -152,6 +152,18 @@ describe("leaderboards route", () => {
|
|||||||
expect(typeof body.entries[0]?.activeTitle).toBe("string");
|
expect(typeof body.entries[0]?.activeTitle).toBe("string");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws", async () => {
|
||||||
|
vi.mocked(prisma.player.findMany).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await get();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value", async () => {
|
||||||
|
vi.mocked(prisma.player.findMany).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await get();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults to 0 for game-state categories when state is missing", async () => {
|
it("defaults to 0 for game-state categories when state is missing", async () => {
|
||||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
|
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
|
||||||
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([] as never);
|
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([] as never);
|
||||||
|
|||||||
@@ -93,6 +93,18 @@ describe("prestige route", () => {
|
|||||||
expect(body.runestones).toBeGreaterThanOrEqual(0);
|
expect(body.runestones).toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during prestige", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post("");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during prestige", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await post("");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
it("updates daily challenge progress when dailyChallenges are set", async () => {
|
it("updates daily challenge progress when dailyChallenges are set", async () => {
|
||||||
const state = makeState({
|
const state = makeState({
|
||||||
dailyChallenges: {
|
dailyChallenges: {
|
||||||
@@ -152,5 +164,17 @@ describe("prestige route", () => {
|
|||||||
expect(body.runestonesRemaining).toBe(90); // 100 - 10
|
expect(body.runestonesRemaining).toBe(90); // 100 - 10
|
||||||
expect(body.purchasedUpgradeIds).toContain("income_1");
|
expect(body.purchasedUpgradeIds).toContain("income_1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during buy-upgrade", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post("/buy-upgrade", { upgradeId: "income_1" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during buy-upgrade", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await post("/buy-upgrade", { upgradeId: "income_1" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -86,5 +86,22 @@ describe("discord service", () => {
|
|||||||
expect(result.id).toBe("123");
|
expect(result.id).toBe("123");
|
||||||
expect(result.username).toBe("testuser");
|
expect(result.username).toBe("testuser");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("re-throws when fetch rejects with a non-Error value", async () => {
|
||||||
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
|
const { fetchDiscordUser } = await import("../../src/services/discord.js");
|
||||||
|
await expect(fetchDiscordUser("some_token")).rejects.toBe("raw string error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exchangeCode non-Error throw", () => {
|
||||||
|
it("re-throws when fetch rejects with a non-Error value", async () => {
|
||||||
|
process.env["DISCORD_CLIENT_ID"] = "cid";
|
||||||
|
process.env["DISCORD_CLIENT_SECRET"] = "secret";
|
||||||
|
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
|
||||||
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
|
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||||
|
await expect(exchangeCode("some_code")).rejects.toBe("raw string error");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,14 +13,24 @@ import {
|
|||||||
} from "../../src/services/prestige.js";
|
} from "../../src/services/prestige.js";
|
||||||
import type { GameState } from "@elysium/types";
|
import type { GameState } from "@elysium/types";
|
||||||
|
|
||||||
const makePlayer = (totalGoldEarned: number) => ({
|
const makePlayer = (
|
||||||
discordId: "test_id",
|
totalGoldEarned: number,
|
||||||
username: "testuser",
|
lifetimeGoldEarned = 0,
|
||||||
discriminator: "0",
|
totalClicks = 0,
|
||||||
avatar: null,
|
) => ({
|
||||||
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,152 @@ 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("sets bountyRunestonesClaimed on bosses defeated before the flag was introduced", () => {
|
||||||
|
const legacyDefeatedBoss = {
|
||||||
|
bountyRunestones: 5,
|
||||||
|
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: [ legacyDefeatedBoss ] as GameState["bosses"] });
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
const matchingBoss = prestigeState.bosses.find((boss) => {
|
||||||
|
return boss.id === "troll_king";
|
||||||
|
});
|
||||||
|
expect(matchingBoss?.bountyRunestonesClaimed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates completed quests into lifetime total", () => {
|
||||||
|
const quest = {
|
||||||
|
id: "q_1",
|
||||||
|
name: "A Quest",
|
||||||
|
description: "Do the thing",
|
||||||
|
status: "completed" as const,
|
||||||
|
zoneId: "zone_1",
|
||||||
|
};
|
||||||
|
const state = makeMinimalState({ quests: [ quest ] as GameState["quests"] });
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeState.player.lifetimeQuestsCompleted).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates recruited adventurers into lifetime total", () => {
|
||||||
|
const adventurer = {
|
||||||
|
combatPower: 10,
|
||||||
|
count: 5,
|
||||||
|
essencePerSecond: 0,
|
||||||
|
goldPerSecond: 1,
|
||||||
|
id: "adv_1",
|
||||||
|
level: 1,
|
||||||
|
unlocked: true,
|
||||||
|
};
|
||||||
|
const state = makeMinimalState({ adventurers: [ adventurer ] as GameState["adventurers"] });
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeState.player.lifetimeAdventurersRecruited).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves achievements from current state across prestige", () => {
|
||||||
|
const achievement = {
|
||||||
|
description: "Did a thing",
|
||||||
|
id: "ach_persisted",
|
||||||
|
name: "Achiever",
|
||||||
|
requirement: 1,
|
||||||
|
type: "totalClicks" as const,
|
||||||
|
unlockedAt: Date.now(),
|
||||||
|
};
|
||||||
|
const state = makeMinimalState({ achievements: [ achievement ] as GameState["achievements"] });
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeState.achievements).toEqual([ achievement ]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates unlocked achievements into lifetime total", () => {
|
||||||
|
const achievement = {
|
||||||
|
description: "Did a thing",
|
||||||
|
id: "ach_1",
|
||||||
|
name: "Achiever",
|
||||||
|
requirement: 1,
|
||||||
|
type: "totalClicks" as const,
|
||||||
|
unlockedAt: Date.now(),
|
||||||
|
};
|
||||||
|
const state = makeMinimalState({ achievements: [ achievement ] as GameState["achievements"] });
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeState.player.lifetimeAchievementsUnlocked).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -69,6 +69,15 @@ describe("webhook service", () => {
|
|||||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||||
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
|
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("swallows non-Error fetch rejections gracefully", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||||
|
process.env["DISCORD_GUILD_ID"] = "g";
|
||||||
|
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r";
|
||||||
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
|
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||||
|
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("postMilestoneWebhook", () => {
|
describe("postMilestoneWebhook", () => {
|
||||||
@@ -88,9 +97,10 @@ describe("webhook service", () => {
|
|||||||
await postMilestoneWebhook("user123", "prestige", counts);
|
await postMilestoneWebhook("user123", "prestige", counts);
|
||||||
const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit];
|
const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
expect(url).toBe("https://discord.com/webhook/abc");
|
expect(url).toBe("https://discord.com/webhook/abc");
|
||||||
const body = JSON.parse(options.body as string) as { content: string };
|
const body = JSON.parse(options.body as string) as { content: string; flags: number };
|
||||||
expect(body.content).toContain("<@user123>");
|
expect(body.content).toContain("<@user123>");
|
||||||
expect(body.content).toContain("prestiged");
|
expect(body.content).toContain("prestiged");
|
||||||
|
expect(body.flags).toBe(4096);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("posts transcendence message correctly", async () => {
|
it("posts transcendence message correctly", async () => {
|
||||||
@@ -119,5 +129,12 @@ describe("webhook service", () => {
|
|||||||
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
||||||
await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined();
|
await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("swallows non-Error fetch rejections gracefully", async () => {
|
||||||
|
process.env["DISCORD_MILESTONE_WEBHOOK"] = "https://discord.com/webhook/abc";
|
||||||
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
|
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
||||||
|
await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,39 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Elysium — Idle RPG</title>
|
<title>Elysium — Idle RPG</title>
|
||||||
<meta name="description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
|
<meta name="description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
|
||||||
|
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:title" content="Elysium — Idle RPG" />
|
||||||
|
<meta property="og:description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://elysium.nhcarrigan.com" />
|
||||||
|
<meta property="og:image" content="https://cdn.nhcarrigan.com/elysium/background.jpg" />
|
||||||
|
<meta property="og:site_name" content="Elysium" />
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Elysium — Idle RPG" />
|
||||||
|
<meta name="twitter:description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
|
||||||
|
<meta name="twitter:image" content="https://cdn.nhcarrigan.com/elysium/background.jpg" />
|
||||||
|
|
||||||
|
<!-- Plausible Analytics -->
|
||||||
|
<script defer data-domain="elysium.nhcarrigan.com" src="https://plausible.io/js/script.js"></script>
|
||||||
|
|
||||||
|
<!-- Tree-Nation -->
|
||||||
|
<script defer src="https://widgets.tree-nation.com/js/widgets/v1/widgets.min.js?v=1.0"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var interval = setInterval(function () {
|
||||||
|
if (typeof TreeNation !== "undefined") {
|
||||||
|
clearInterval(interval);
|
||||||
|
TreeNation.renderAll();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Google Ads -->
|
||||||
|
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-3569924701890974" crossorigin="anonymous"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@elysium/web",
|
"name": "@elysium/web",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"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",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
ExploreCollectResponse,
|
ExploreCollectResponse,
|
||||||
ExploreStartRequest,
|
ExploreStartRequest,
|
||||||
ExploreStartResponse,
|
ExploreStartResponse,
|
||||||
|
ForceUnlocksResponse,
|
||||||
LoadResponse,
|
LoadResponse,
|
||||||
PrestigeRequest,
|
PrestigeRequest,
|
||||||
PrestigeResponse,
|
PrestigeResponse,
|
||||||
@@ -256,6 +257,24 @@ const craftRecipe = async(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a request to fix any missing unlocks in the player's game state.
|
||||||
|
* @returns The corrected game state and counts of what was unlocked.
|
||||||
|
*/
|
||||||
|
const forceUnlocks = async(): Promise<ForceUnlocksResponse> => {
|
||||||
|
return await fetchJson<ForceUnlocksResponse>("/debug/force-unlocks", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs a complete hard reset of the player's game state via the debug endpoint.
|
||||||
|
* @returns The fresh game state as a LoadResponse.
|
||||||
|
*/
|
||||||
|
const debugHardReset = async(): Promise<LoadResponse> => {
|
||||||
|
return await fetchJson<LoadResponse>("/debug/hard-reset", { method: "POST" });
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches a public player profile by Discord ID.
|
* Fetches a public player profile by Discord ID.
|
||||||
* @param discordId - The Discord ID of the player to look up.
|
* @param discordId - The Discord ID of the player to look up.
|
||||||
@@ -288,6 +307,8 @@ export {
|
|||||||
challengeBoss,
|
challengeBoss,
|
||||||
collectExploration,
|
collectExploration,
|
||||||
craftRecipe,
|
craftRecipe,
|
||||||
|
debugHardReset,
|
||||||
|
forceUnlocks,
|
||||||
getAbout,
|
getAbout,
|
||||||
getAuthUrl,
|
getAuthUrl,
|
||||||
getPublicProfile,
|
getPublicProfile,
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* @file React Error Boundary for catching unhandled render-time errors.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||||
|
import { logError } from "../utils/logError.js";
|
||||||
|
|
||||||
|
interface ErrorBoundaryProperties {
|
||||||
|
readonly children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catches unhandled render-time errors in the React tree, logs them to the
|
||||||
|
* backend telemetry service, and renders a fallback UI.
|
||||||
|
*/
|
||||||
|
class ErrorBoundary extends Component<
|
||||||
|
ErrorBoundaryProperties,
|
||||||
|
ErrorBoundaryState
|
||||||
|
> {
|
||||||
|
// eslint-disable-next-line jsdoc/require-jsdoc -- React Error Boundary constructor is standard boilerplate
|
||||||
|
public constructor(properties: ErrorBoundaryProperties) {
|
||||||
|
super(properties);
|
||||||
|
this.state = { hasError: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates state so the next render shows the fallback UI.
|
||||||
|
* @returns The updated error boundary state.
|
||||||
|
*/
|
||||||
|
public static getDerivedStateFromError(): ErrorBoundaryState {
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs the error to the backend telemetry service.
|
||||||
|
* @param error - The error that was thrown during render.
|
||||||
|
* @param info - React error info containing the component stack trace.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/class-methods-use-this -- React lifecycle method cannot be static
|
||||||
|
public override componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||||
|
logError("react_error_boundary", error, info.componentStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the fallback UI when an error is caught, otherwise renders children.
|
||||||
|
* @returns The JSX element.
|
||||||
|
*/
|
||||||
|
public override render(): ReactNode {
|
||||||
|
const { hasError } = this.state;
|
||||||
|
const { children } = this.props;
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
return (
|
||||||
|
<div className="error-screen">
|
||||||
|
<p>{"Something went wrong. Please refresh the page."}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ErrorBoundary };
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
/* eslint-disable max-lines-per-function -- HOW_TO_PLAY data and render logic */
|
/* eslint-disable max-lines-per-function -- HOW_TO_PLAY data and render logic */
|
||||||
/* eslint-disable max-lines -- HOW_TO_PLAY data makes this file long */
|
/* eslint-disable max-lines -- HOW_TO_PLAY data makes this file long */
|
||||||
import { type JSX, useEffect, useState } from "react";
|
import { type JSX, useEffect, useState } from "react";
|
||||||
|
import Markdown from "react-markdown";
|
||||||
import { getAbout } from "../../api/client.js";
|
import { getAbout } from "../../api/client.js";
|
||||||
import type { AboutResponse } from "@elysium/types";
|
import type { AboutResponse } from "@elysium/types";
|
||||||
|
|
||||||
@@ -30,14 +31,24 @@ const howToPlay = [
|
|||||||
body:
|
body:
|
||||||
"Purchase upgrades to multiply the gold and essence output of specific"
|
"Purchase upgrades to multiply the gold and essence output of specific"
|
||||||
+ " adventurer tiers, or boost your whole guild. Upgrades are permanent"
|
+ " adventurer tiers, or boost your whole guild. Upgrades are permanent"
|
||||||
+ " for the current run and compound with each other.",
|
+ " for the current run and stack multiplicatively — two ×2 upgrades"
|
||||||
|
+ " targeting the same adventurer combine to give ×4, not ×3. Global"
|
||||||
|
+ " upgrades multiply on top of adventurer-specific ones, so stacking"
|
||||||
|
+ " both types compounds the effect significantly. Late in a run, look"
|
||||||
|
+ " for the Essence Infusion upgrades — five powerful global multipliers"
|
||||||
|
+ " purchasable purely with essence, giving that resource an ongoing"
|
||||||
|
+ " use when gold upgrades are all bought.",
|
||||||
title: "🔧 Upgrades",
|
title: "🔧 Upgrades",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
body:
|
body:
|
||||||
"Send your guild on quests that complete over time and reward gold,"
|
"Send your guild on quests that complete over time and reward gold,"
|
||||||
+ " essence, crystals, equipment, and upgrades. Multiple quests can run"
|
+ " essence, crystals, equipment, and upgrades. Multiple quests can run"
|
||||||
+ " simultaneously. Completing quests also unlocks new zones.",
|
+ " simultaneously. Completing quests also unlocks new zones."
|
||||||
|
+ " Each quest has a failure chance that increases in later zones"
|
||||||
|
+ " (from 10% in the starting zone up to 40% in the hardest zones)."
|
||||||
|
+ " If a quest fails, no rewards are granted and the quest resets —"
|
||||||
|
+ " your party must be sent again to retry it.",
|
||||||
title: "📜 Quests",
|
title: "📜 Quests",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -58,10 +69,12 @@ const howToPlay = [
|
|||||||
{
|
{
|
||||||
body:
|
body:
|
||||||
"Earn equipment from boss drops and quest rewards. Each piece provides"
|
"Earn equipment from boss drops and quest rewards. Each piece provides"
|
||||||
+ " bonuses to gold income, click power, or combat. Rarer equipment"
|
+ " bonuses to gold income, click power, or boss combat DPS. Rarer"
|
||||||
+ " provides stronger bonuses. Equip matching set pieces (2 or 3 of a"
|
+ " equipment provides stronger bonuses. Note: combat bonuses only"
|
||||||
+ " named set) to unlock escalating set bonuses shown at the top of the"
|
+ " affect boss fights — quest combat power is determined solely by"
|
||||||
+ " Equipment panel.",
|
+ " your adventurers. Equip matching set pieces (2 or 3 of a named set)"
|
||||||
|
+ " to unlock escalating set bonuses shown at the top of the Equipment"
|
||||||
|
+ " panel.",
|
||||||
title: "🗡️ Equipment & Sets",
|
title: "🗡️ Equipment & Sets",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -110,7 +123,11 @@ const howToPlay = [
|
|||||||
+ " real-time and reward gold, essence, and crafting materials when"
|
+ " real-time and reward gold, essence, and crafting materials when"
|
||||||
+ " collected. Each area has a set duration — short explorations are"
|
+ " collected. Each area has a set duration — short explorations are"
|
||||||
+ " faster but longer ones offer rarer finds. A 📖 icon marks areas"
|
+ " faster but longer ones offer rarer finds. A 📖 icon marks areas"
|
||||||
+ " you've collected from at least once, unlocking a Codex entry.",
|
+ " you've collected from at least once, unlocking a Codex entry."
|
||||||
|
+ " Exploration zones are locked until the corresponding main-game"
|
||||||
|
+ " zone is unlocked — which requires defeating that zone's final boss"
|
||||||
|
+ " and completing its final quest. The Exploration tab shows the"
|
||||||
|
+ " specific boss and quest required for each locked zone.",
|
||||||
title: "🗺️ Exploration",
|
title: "🗺️ Exploration",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -153,10 +170,12 @@ const howToPlay = [
|
|||||||
{
|
{
|
||||||
body:
|
body:
|
||||||
"Defeat bosses to earn equipment drops: weapons, armour, and trinkets."
|
"Defeat bosses to earn equipment drops: weapons, armour, and trinkets."
|
||||||
+ " Each item provides bonuses to gold income, combat power, or click"
|
+ " Each item provides bonuses to gold income, boss combat DPS, or click"
|
||||||
+ " power. Only one item per slot can be equipped at a time — visit the"
|
+ " power. Combat bonuses only affect boss fights — quest combat power"
|
||||||
+ " Equipment panel to manage your loadout. Your currently equipped"
|
+ " is determined solely by your adventurers. Only one item per slot"
|
||||||
+ " items are displayed on your character sheet and public profile.",
|
+ " can be equipped at a time — visit the Equipment panel to manage"
|
||||||
|
+ " your loadout. Your currently equipped items are displayed on your"
|
||||||
|
+ " character sheet and public profile.",
|
||||||
title: "🗡️ Equipment",
|
title: "🗡️ Equipment",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -180,14 +199,16 @@ const howToPlay = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
body:
|
body:
|
||||||
"Toggle automation in the Quests and Boss Encounters panels! Auto-Quest"
|
"Toggle automation in the Quests, Boss Encounters, and Prestige Shop"
|
||||||
+ " automatically sends your party on the highest-zone available quest"
|
+ " panels! Auto-Quest automatically sends your party on the"
|
||||||
+ " as soon as one completes, skipping quests whose combat power"
|
+ " highest-zone available quest as soon as one completes, skipping"
|
||||||
+ " requirement isn't met. Auto-Boss automatically challenges the"
|
+ " quests whose combat power requirement isn't met. Auto-Boss"
|
||||||
+ " highest available boss as soon as one is ready. Both can be toggled"
|
+ " automatically challenges the highest available boss as soon as one"
|
||||||
+ " on or off at any time using the 🤖 Auto button in each panel"
|
+ " is ready. Auto-Adventurer (unlocked via the Prestige Shop for 50"
|
||||||
+ " header.",
|
+ " runestones) automatically purchases the highest-tier adventurer you"
|
||||||
title: "🤖 Auto-Quest & Auto-Boss",
|
+ " can currently afford each tick, keeping your income growing after a"
|
||||||
|
+ " prestige without any manual clicks.",
|
||||||
|
title: "🤖 Auto-Quest, Auto-Boss & Auto-Adventurer",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
body:
|
body:
|
||||||
@@ -331,7 +352,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,8 +7,9 @@
|
|||||||
/* 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, GameState } from "@elysium/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the plural form of a word based on a count.
|
* Returns the plural form of a word based on a count.
|
||||||
@@ -53,9 +54,50 @@ const conditionDescription = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the player's current progress value toward an achievement's unlock condition,
|
||||||
|
* mirroring the logic used by the tick engine's checkAchievements function.
|
||||||
|
* @param achievement - The achievement to evaluate progress for.
|
||||||
|
* @param state - The current game state.
|
||||||
|
* @returns The current numeric progress toward the achievement condition.
|
||||||
|
*/
|
||||||
|
const getCurrentProgress = (
|
||||||
|
achievement: Achievement,
|
||||||
|
state: GameState,
|
||||||
|
): number => {
|
||||||
|
const { condition } = achievement;
|
||||||
|
switch (condition.type) {
|
||||||
|
case "totalGoldEarned":
|
||||||
|
return state.player.totalGoldEarned;
|
||||||
|
case "totalClicks":
|
||||||
|
return state.player.totalClicks;
|
||||||
|
case "bossesDefeated":
|
||||||
|
return state.bosses.filter((boss) => {
|
||||||
|
return boss.status === "defeated";
|
||||||
|
}).length;
|
||||||
|
case "questsCompleted":
|
||||||
|
return state.quests.filter((quest) => {
|
||||||
|
return quest.status === "completed";
|
||||||
|
}).length;
|
||||||
|
case "adventurerTotal":
|
||||||
|
return state.adventurers.reduce((sum, adventurer) => {
|
||||||
|
return sum + adventurer.count;
|
||||||
|
}, 0);
|
||||||
|
case "prestigeCount":
|
||||||
|
return state.prestige.count;
|
||||||
|
case "equipmentOwned":
|
||||||
|
return state.equipment.filter((item) => {
|
||||||
|
return item.owned;
|
||||||
|
}).length;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
interface AchievementCardProperties {
|
interface AchievementCardProperties {
|
||||||
readonly achievement: Achievement;
|
readonly achievement: Achievement;
|
||||||
readonly formatNumber: (n: number)=> string;
|
readonly formatNumber: (n: number)=> string;
|
||||||
|
readonly progressValue: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,26 +105,47 @@ interface AchievementCardProperties {
|
|||||||
* @param props - The achievement card properties.
|
* @param props - The achievement card properties.
|
||||||
* @param props.achievement - The achievement to display.
|
* @param props.achievement - The achievement to display.
|
||||||
* @param props.formatNumber - The number formatting utility function.
|
* @param props.formatNumber - The number formatting utility function.
|
||||||
|
* @param props.progressValue - The player's current progress toward the unlock condition.
|
||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
*/
|
*/
|
||||||
|
// eslint-disable-next-line max-lines-per-function -- Progress bar adds necessary lines for locked state
|
||||||
const AchievementCard = ({
|
const AchievementCard = ({
|
||||||
achievement,
|
achievement,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
|
progressValue,
|
||||||
}: AchievementCardProperties): JSX.Element => {
|
}: AchievementCardProperties): JSX.Element => {
|
||||||
const isUnlocked = achievement.unlockedAt !== null;
|
const isUnlocked = achievement.unlockedAt !== null;
|
||||||
const crystals = achievement.reward?.crystals;
|
const crystals = achievement.reward?.crystals;
|
||||||
|
const cappedProgress = Math.min(progressValue, achievement.condition.amount);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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>
|
||||||
<p className="achievement-condition">
|
<p className="achievement-condition">
|
||||||
{conditionDescription(achievement, formatNumber)}
|
{conditionDescription(achievement, formatNumber)}
|
||||||
</p>
|
</p>
|
||||||
|
{!isUnlocked
|
||||||
|
&& <div className="achievement-progress">
|
||||||
|
<progress
|
||||||
|
max={achievement.condition.amount}
|
||||||
|
value={cappedProgress}
|
||||||
|
/>
|
||||||
|
<span className="achievement-progress-label">
|
||||||
|
{formatNumber(progressValue)}
|
||||||
|
{" / "}
|
||||||
|
{formatNumber(achievement.condition.amount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
{crystals !== undefined
|
{crystals !== undefined
|
||||||
&& <p className="achievement-reward">
|
&& <p className="achievement-reward">
|
||||||
{"💎 +"}
|
{"💎 +"}
|
||||||
@@ -158,6 +221,7 @@ const AchievementPanel = (): JSX.Element => {
|
|||||||
achievement={achievement}
|
achievement={achievement}
|
||||||
formatNumber={formatNumber}
|
formatNumber={formatNumber}
|
||||||
key={achievement.id}
|
key={achievement.id}
|
||||||
|
progressValue={getCurrentProgress(achievement, state)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -125,6 +143,10 @@ const AdventurerCard = ({
|
|||||||
{" essence/s each"}
|
{" essence/s each"}
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
<p>
|
||||||
|
{formatNumber(adventurer.combatPower)}
|
||||||
|
{" combat power each"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="adventurer-count">
|
<div className="adventurer-count">
|
||||||
{"×"}
|
{"×"}
|
||||||
@@ -155,7 +177,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 +227,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
|
||||||
|
|||||||
@@ -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 25–50%, red below 25%.
|
||||||
|
* @param percent - Current HP as a percentage (0–100).
|
||||||
|
* @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)}%`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,21 @@ 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,
|
||||||
|
bossError,
|
||||||
|
} = 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) {
|
||||||
@@ -249,6 +267,23 @@ const BossPanel = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state;
|
const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state;
|
||||||
|
|
||||||
|
const activeZone = zones.find((zone) => {
|
||||||
|
return zone.id === activeZoneId;
|
||||||
|
});
|
||||||
|
const zoneIsLocked = activeZone?.status === "locked";
|
||||||
|
const unlockBoss = activeZone?.unlockBossId === null
|
||||||
|
|| activeZone?.unlockBossId === undefined
|
||||||
|
? undefined
|
||||||
|
: bosses.find((boss) => {
|
||||||
|
return boss.id === activeZone.unlockBossId;
|
||||||
|
});
|
||||||
|
const unlockQuest = activeZone?.unlockQuestId === null
|
||||||
|
|| activeZone?.unlockQuestId === undefined
|
||||||
|
? undefined
|
||||||
|
: quests.find((quest) => {
|
||||||
|
return quest.id === activeZone.unlockQuestId;
|
||||||
|
});
|
||||||
const zoneBosses = bosses.filter((boss) => {
|
const zoneBosses = bosses.filter((boss) => {
|
||||||
return boss.zoneId === activeZoneId;
|
return boss.zoneId === activeZoneId;
|
||||||
});
|
});
|
||||||
@@ -302,6 +337,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,12 +380,57 @@ const BossPanel = (): JSX.Element => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{bossError === null
|
||||||
|
? null
|
||||||
|
: <p className="auto-boss-error">
|
||||||
|
{"⚠️ "}
|
||||||
|
{bossError}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
{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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
|
||||||
|
? <div className="exploration-zone-locked-hint">
|
||||||
|
<p>{"🔒 This zone is locked. Unlock bosses by:"}</p>
|
||||||
|
{unlockBoss === undefined
|
||||||
|
? null
|
||||||
|
: <p>
|
||||||
|
{"⚔️ Defeat: "}
|
||||||
|
{unlockBoss.name}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
{unlockQuest === undefined
|
||||||
|
? null
|
||||||
|
: <p>
|
||||||
|
{"📜 Complete: "}
|
||||||
|
{unlockQuest.name}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
<div className="party-combat-stats">
|
<div className="party-combat-stats">
|
||||||
<div className="combat-stat">
|
<div className="combat-stat">
|
||||||
<span className="stat-label">{"⚔️ Party DPS"}</span>
|
<span className="stat-label">{"⚔️ Party DPS"}</span>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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> = {
|
||||||
@@ -25,7 +26,9 @@ const bonusLabel: Record<string, string> = {
|
|||||||
*/
|
*/
|
||||||
const CraftingPanel = (): JSX.Element => {
|
const CraftingPanel = (): JSX.Element => {
|
||||||
const { state, craftRecipe, formatNumber } = useGame();
|
const { state, craftRecipe, formatNumber } = useGame();
|
||||||
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
||||||
|
return sessionStorage.getItem("elysium_craft_zone") ?? "verdant_vale";
|
||||||
|
});
|
||||||
const [ pendingRecipeId, setPendingRecipeId ] = useState<string | null>(null);
|
const [ pendingRecipeId, setPendingRecipeId ] = useState<string | null>(null);
|
||||||
|
|
||||||
if (state === null) {
|
if (state === null) {
|
||||||
@@ -67,6 +70,11 @@ const CraftingPanel = (): JSX.Element => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleZoneSelect(zoneId: string): void {
|
||||||
|
setActiveZoneId(zoneId);
|
||||||
|
sessionStorage.setItem("elysium_craft_zone", zoneId);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCraft(recipeId: string): Promise<void> {
|
async function handleCraft(recipeId: string): Promise<void> {
|
||||||
setPendingRecipeId(recipeId);
|
setPendingRecipeId(recipeId);
|
||||||
try {
|
try {
|
||||||
@@ -84,7 +92,7 @@ const CraftingPanel = (): JSX.Element => {
|
|||||||
|
|
||||||
<ZoneSelector
|
<ZoneSelector
|
||||||
activeZoneId={activeZoneId}
|
activeZoneId={activeZoneId}
|
||||||
onSelectZone={setActiveZoneId}
|
onSelectZone={handleZoneSelect}
|
||||||
zones={zones}
|
zones={zones}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -105,6 +113,11 @@ const CraftingPanel = (): JSX.Element => {
|
|||||||
}`}
|
}`}
|
||||||
key={material.id}
|
key={material.id}
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
alt={material.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("materials", material.id)}
|
||||||
|
/>
|
||||||
<div className="material-info">
|
<div className="material-info">
|
||||||
<span className="material-name">{material.name}</span>
|
<span className="material-name">{material.name}</span>
|
||||||
<span className="material-rarity">{material.rarity}</span>
|
<span className="material-rarity">{material.rarity}</span>
|
||||||
@@ -144,6 +157,11 @@ const CraftingPanel = (): JSX.Element => {
|
|||||||
: ""}`}
|
: ""}`}
|
||||||
key={recipe.id}
|
key={recipe.id}
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
alt={recipe.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("recipes", recipe.id)}
|
||||||
|
/>
|
||||||
<div className="recipe-info">
|
<div className="recipe-info">
|
||||||
<h4>{recipe.name}</h4>
|
<h4>{recipe.name}</h4>
|
||||||
<p className="recipe-description">{recipe.description}</p>
|
<p className="recipe-description">{recipe.description}</p>
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* @file Debug panel component with administrative tools for correcting player state.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
/* eslint-disable max-lines-per-function -- Panel has multiple async handlers and conditional renders */
|
||||||
|
/* eslint-disable stylistic/max-len -- Debug descriptions require full explanatory text */
|
||||||
|
import { type JSX, useState } from "react";
|
||||||
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { ConfirmationModal } from "../ui/confirmationModal.js";
|
||||||
|
|
||||||
|
type ActiveModal = "force-unlocks" | "hard-reset" | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the debug panel with tools for fixing stuck game state.
|
||||||
|
* @returns The JSX element.
|
||||||
|
*/
|
||||||
|
const DebugPanel = (): JSX.Element => {
|
||||||
|
const { forceUnlocks, debugHardReset, isLoading } = useGame();
|
||||||
|
const [ activeModal, setActiveModal ] = useState<ActiveModal>(null);
|
||||||
|
const [ forceUnlocksResult, setForceUnlocksResult ] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function handleOpenForceUnlocks(): void {
|
||||||
|
setForceUnlocksResult(null);
|
||||||
|
setActiveModal("force-unlocks");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenHardReset(): void {
|
||||||
|
setActiveModal("hard-reset");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel(): void {
|
||||||
|
setActiveModal(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmForceUnlocks(): void {
|
||||||
|
setActiveModal(null);
|
||||||
|
void (async(): Promise<void> => {
|
||||||
|
const result = await forceUnlocks();
|
||||||
|
const parts: Array<string> = [];
|
||||||
|
if (result.zonesUnlocked > 0) {
|
||||||
|
parts.push(`${String(result.zonesUnlocked)} zone(s)`);
|
||||||
|
}
|
||||||
|
if (result.questsUnlocked > 0) {
|
||||||
|
parts.push(`${String(result.questsUnlocked)} quest(s)`);
|
||||||
|
}
|
||||||
|
if (result.bossesUnlocked > 0) {
|
||||||
|
parts.push(`${String(result.bossesUnlocked)} boss(es)`);
|
||||||
|
}
|
||||||
|
if (result.explorationUnlocked > 0) {
|
||||||
|
parts.push(`${String(result.explorationUnlocked)} exploration area(s)`);
|
||||||
|
}
|
||||||
|
const total
|
||||||
|
= result.zonesUnlocked
|
||||||
|
+ result.questsUnlocked
|
||||||
|
+ result.bossesUnlocked
|
||||||
|
+ result.explorationUnlocked;
|
||||||
|
const message
|
||||||
|
= parts.length === 0
|
||||||
|
? "Everything looks correct — no missing unlocks were found."
|
||||||
|
: `Fixed ${String(total)} unlock(s): ${parts.join(", ")}.`;
|
||||||
|
setForceUnlocksResult(message);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmHardReset(): void {
|
||||||
|
setActiveModal(null);
|
||||||
|
void debugHardReset();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel">
|
||||||
|
<h2>{"🔧 Debug Tools"}</h2>
|
||||||
|
<p className="panel-description">
|
||||||
|
{
|
||||||
|
"These tools are intended to fix broken game state. Use them with care — some operations are irreversible."
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="debug-actions">
|
||||||
|
<div className="debug-action-card">
|
||||||
|
<h3>{"🔓 Force Unlocks"}</h3>
|
||||||
|
<p>
|
||||||
|
{
|
||||||
|
"Scans your game state and unlocks any zones, quests, and bosses that you have earned but that are still incorrectly locked."
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="action-button"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={handleOpenForceUnlocks}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{"Force Unlocks"}
|
||||||
|
</button>
|
||||||
|
{forceUnlocksResult !== null
|
||||||
|
&& <p className="debug-result-message">{forceUnlocksResult}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="debug-action-card">
|
||||||
|
<h3>{"💀 Hard Reset"}</h3>
|
||||||
|
<p>
|
||||||
|
{
|
||||||
|
"Completely wipes all progress and resets your account to a brand-new state. This cannot be undone."
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="action-button action-button-danger"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={handleOpenHardReset}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{"Hard Reset"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeModal === "force-unlocks"
|
||||||
|
&& <ConfirmationModal
|
||||||
|
confirmLabel="Yes, Force Unlocks"
|
||||||
|
description="This will scan your save data and grant access to any zones, quests, and bosses that you have already earned but are incorrectly locked. This operation is safe and non-destructive."
|
||||||
|
isLoading={isLoading}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onConfirm={handleConfirmForceUnlocks}
|
||||||
|
title="Force Unlocks"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
{activeModal === "hard-reset"
|
||||||
|
&& <ConfirmationModal
|
||||||
|
confirmLabel="Yes, Wipe Everything"
|
||||||
|
description="This will permanently delete all of your current progress — gold, adventurers, upgrades, bosses, quests, and zones — and reset your account to a brand-new state. Lifetime stats are preserved, but everything else will be gone forever."
|
||||||
|
isLoading={isLoading}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onConfirm={handleConfirmHardReset}
|
||||||
|
title="⚠️ Hard Reset — This Cannot Be Undone"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { DebugPanel };
|
||||||
@@ -7,9 +7,11 @@
|
|||||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||||
/* 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 -- Complex component with many conditional render paths */
|
/* eslint-disable complexity -- Complex component with many conditional render paths */
|
||||||
|
/* eslint-disable max-lines -- Equipment panel with set bonus display and sort logic */
|
||||||
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 +22,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.
|
||||||
@@ -35,7 +31,7 @@ const bonusDescription = (item: Equipment): string => {
|
|||||||
const parts: Array<string> = [];
|
const parts: Array<string> = [];
|
||||||
if (item.bonus.combatMultiplier !== undefined) {
|
if (item.bonus.combatMultiplier !== undefined) {
|
||||||
const pct = Math.round((item.bonus.combatMultiplier - 1) * 100);
|
const pct = Math.round((item.bonus.combatMultiplier - 1) * 100);
|
||||||
parts.push(`+${String(pct)}% Combat`);
|
parts.push(`+${String(pct)}% Boss Combat`);
|
||||||
}
|
}
|
||||||
if (item.bonus.goldMultiplier !== undefined) {
|
if (item.bonus.goldMultiplier !== undefined) {
|
||||||
const pct = Math.round((item.bonus.goldMultiplier - 1) * 100);
|
const pct = Math.round((item.bonus.goldMultiplier - 1) * 100);
|
||||||
@@ -128,7 +124,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>
|
||||||
@@ -189,6 +189,20 @@ const EquipmentCard = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes a combined power score for sorting — sum of all bonus multipliers.
|
||||||
|
* Using the sum (rather than a single stat) keeps hybrid items in sensible order.
|
||||||
|
* @param item - The equipment piece whose bonus multipliers are summed.
|
||||||
|
* @returns The combined bonus value.
|
||||||
|
*/
|
||||||
|
const equipmentPower = (item: Equipment): number => {
|
||||||
|
return (
|
||||||
|
(item.bonus.combatMultiplier ?? 1)
|
||||||
|
+ (item.bonus.goldMultiplier ?? 1)
|
||||||
|
+ (item.bonus.clickMultiplier ?? 1)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const slotOrder: Array<EquipmentType> = [ "weapon", "armour", "trinket" ];
|
const slotOrder: Array<EquipmentType> = [ "weapon", "armour", "trinket" ];
|
||||||
const slotLabel: Record<EquipmentType, string> = {
|
const slotLabel: Record<EquipmentType, string> = {
|
||||||
armour: "🛡️ Armour",
|
armour: "🛡️ Armour",
|
||||||
@@ -262,7 +276,7 @@ const EquipmentPanel = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
if (bonus.combatMultiplier !== undefined) {
|
if (bonus.combatMultiplier !== undefined) {
|
||||||
const pct = Math.round((bonus.combatMultiplier - 1) * 100);
|
const pct = Math.round((bonus.combatMultiplier - 1) * 100);
|
||||||
parts.push(`+${String(pct)}% Combat (${String(threshold)}pc)`);
|
parts.push(`+${String(pct)}% Boss Combat (${String(threshold)}pc)`);
|
||||||
}
|
}
|
||||||
if (bonus.clickMultiplier !== undefined) {
|
if (bonus.clickMultiplier !== undefined) {
|
||||||
const pct = Math.round((bonus.clickMultiplier - 1) * 100);
|
const pct = Math.round((bonus.clickMultiplier - 1) * 100);
|
||||||
@@ -321,6 +335,8 @@ const EquipmentPanel = (): JSX.Element => {
|
|||||||
{slotOrder.map((slotType) => {
|
{slotOrder.map((slotType) => {
|
||||||
const items = equipment.filter((item) => {
|
const items = equipment.filter((item) => {
|
||||||
return item.type === slotType && (showLocked || item.owned);
|
return item.type === slotType && (showLocked || item.owned);
|
||||||
|
}).sort((a, b) => {
|
||||||
|
return equipmentPower(a) - equipmentPower(b);
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<div className="equipment-slot-section" key={slotType}>
|
<div className="equipment-slot-section" key={slotType}>
|
||||||
|
|||||||
@@ -6,9 +6,11 @@
|
|||||||
*/
|
*/
|
||||||
/* 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 -- Complex component with many conditional render paths */
|
/* eslint-disable complexity -- Complex component with many conditional render paths */
|
||||||
|
/* eslint-disable max-lines -- Exploration panel requires many render paths and result display */
|
||||||
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";
|
||||||
|
|
||||||
@@ -45,11 +47,21 @@ const formatDuration = (seconds: number): string => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes the time remaining for an exploration in progress.
|
* Computes the time remaining for an exploration in progress.
|
||||||
|
* Uses endsAt (server-computed) when available to avoid client/server clock drift.
|
||||||
|
* Falls back to startedAt + durationSeconds for saves predating the endsAt field.
|
||||||
|
* @param endsAt - The server-computed completion timestamp, if available.
|
||||||
* @param startedAt - The timestamp when exploration started.
|
* @param startedAt - The timestamp when exploration started.
|
||||||
* @param durationSeconds - The total duration in seconds.
|
* @param durationSeconds - The total duration in seconds.
|
||||||
* @returns The remaining seconds.
|
* @returns The remaining seconds.
|
||||||
*/
|
*/
|
||||||
const timeRemaining = (startedAt: number, durationSeconds: number): number => {
|
const timeRemaining = (
|
||||||
|
endsAt: number | undefined,
|
||||||
|
startedAt: number,
|
||||||
|
durationSeconds: number,
|
||||||
|
): number => {
|
||||||
|
if (endsAt !== undefined) {
|
||||||
|
return Math.max(0, (endsAt - Date.now()) / 1000);
|
||||||
|
}
|
||||||
const elapsed = (Date.now() - startedAt) / 1000;
|
const elapsed = (Date.now() - startedAt) / 1000;
|
||||||
return Math.max(0, durationSeconds - elapsed);
|
return Math.max(0, durationSeconds - elapsed);
|
||||||
};
|
};
|
||||||
@@ -66,7 +78,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);
|
||||||
|
|
||||||
@@ -78,7 +92,24 @@ const ExplorationPanel = (): JSX.Element => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { zones, exploration: explorationState } = state;
|
const { zones, exploration: explorationState, bosses, quests } = state;
|
||||||
|
|
||||||
|
const activeZone = zones.find((zone) => {
|
||||||
|
return zone.id === activeZoneId;
|
||||||
|
});
|
||||||
|
const zoneIsLocked = activeZone?.status === "locked";
|
||||||
|
const unlockBoss = activeZone?.unlockBossId === null
|
||||||
|
|| activeZone?.unlockBossId === undefined
|
||||||
|
? undefined
|
||||||
|
: bosses.find((boss) => {
|
||||||
|
return boss.id === activeZone.unlockBossId;
|
||||||
|
});
|
||||||
|
const unlockQuest = activeZone?.unlockQuestId === null
|
||||||
|
|| activeZone?.unlockQuestId === undefined
|
||||||
|
? undefined
|
||||||
|
: quests.find((quest) => {
|
||||||
|
return quest.id === activeZone.unlockQuestId;
|
||||||
|
});
|
||||||
|
|
||||||
const zoneAreas = EXPLORATION_AREAS.filter((area) => {
|
const zoneAreas = EXPLORATION_AREAS.filter((area) => {
|
||||||
return area.zoneId === activeZoneId;
|
return area.zoneId === activeZoneId;
|
||||||
@@ -115,6 +146,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;
|
||||||
@@ -206,6 +238,27 @@ const ExplorationPanel = (): JSX.Element => {
|
|||||||
zones={zones}
|
zones={zones}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
|
||||||
|
? <div className="exploration-zone-locked-hint">
|
||||||
|
<p>{"🔒 This zone is locked. Unlock exploration by:"}</p>
|
||||||
|
{unlockBoss === undefined
|
||||||
|
? null
|
||||||
|
: <p>
|
||||||
|
{"⚔️ Defeat: "}
|
||||||
|
{unlockBoss.name}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
{unlockQuest === undefined
|
||||||
|
? null
|
||||||
|
: <p>
|
||||||
|
{"📜 Complete: "}
|
||||||
|
{unlockQuest.name}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
<div className="exploration-list">
|
<div className="exploration-list">
|
||||||
{zoneAreas.map((area) => {
|
{zoneAreas.map((area) => {
|
||||||
const areaState = explorationState?.areas.find((explorationArea) => {
|
const areaState = explorationState?.areas.find((explorationArea) => {
|
||||||
@@ -213,9 +266,10 @@ const ExplorationPanel = (): JSX.Element => {
|
|||||||
});
|
});
|
||||||
const status = areaState?.status ?? "locked";
|
const status = areaState?.status ?? "locked";
|
||||||
const startedAt = areaState?.startedAt ?? 0;
|
const startedAt = areaState?.startedAt ?? 0;
|
||||||
|
const endsAt = areaState?.endsAt;
|
||||||
const isReady
|
const isReady
|
||||||
= status === "in_progress"
|
= status === "in_progress"
|
||||||
&& timeRemaining(startedAt, area.durationSeconds) <= 0;
|
&& timeRemaining(endsAt, startedAt, area.durationSeconds) <= 0;
|
||||||
const isPending = pendingAreaId === area.id;
|
const isPending = pendingAreaId === area.id;
|
||||||
|
|
||||||
function handleStartClick(): void {
|
function handleStartClick(): void {
|
||||||
@@ -230,6 +284,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}
|
||||||
@@ -267,9 +326,8 @@ const ExplorationPanel = (): JSX.Element => {
|
|||||||
{status === "in_progress" && !isReady
|
{status === "in_progress" && !isReady
|
||||||
&& <span className="quest-badge active">
|
&& <span className="quest-badge active">
|
||||||
{"⏳ "}
|
{"⏳ "}
|
||||||
{formatDuration(
|
{/* eslint-disable-next-line stylistic/max-len -- long call cannot be shortened */}
|
||||||
Math.ceil(timeRemaining(startedAt, area.durationSeconds)),
|
{formatDuration(Math.ceil(timeRemaining(endsAt, startedAt, area.durationSeconds)))}
|
||||||
)}
|
|
||||||
{" remaining"}
|
{" remaining"}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,14 +23,17 @@ import { CodexToast } from "./codexToast.js";
|
|||||||
import { CompanionPanel } from "./companionPanel.js";
|
import { CompanionPanel } from "./companionPanel.js";
|
||||||
import { CraftingPanel } from "./craftingPanel.js";
|
import { CraftingPanel } from "./craftingPanel.js";
|
||||||
import { DailyChallengePanel } from "./dailyChallengePanel.js";
|
import { DailyChallengePanel } from "./dailyChallengePanel.js";
|
||||||
|
import { DebugPanel } from "./debugPanel.js";
|
||||||
import { EditProfileModal } from "./editProfileModal.js";
|
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";
|
||||||
@@ -55,7 +58,8 @@ type Tab =
|
|||||||
| "crafting"
|
| "crafting"
|
||||||
| "character"
|
| "character"
|
||||||
| "companions"
|
| "companions"
|
||||||
| "story";
|
| "story"
|
||||||
|
| "debug";
|
||||||
|
|
||||||
const baseTabs: Array<{ id: Tab; label: string }> = [
|
const baseTabs: Array<{ id: Tab; label: string }> = [
|
||||||
{ id: "adventurers", label: "⚔️ Adventurers" },
|
{ id: "adventurers", label: "⚔️ Adventurers" },
|
||||||
@@ -76,6 +80,7 @@ const baseTabs: Array<{ id: Tab; label: string }> = [
|
|||||||
{ id: "story", label: "📖 Story" },
|
{ id: "story", label: "📖 Story" },
|
||||||
{ id: "codex", label: "🗺️ Codex" },
|
{ id: "codex", label: "🗺️ Codex" },
|
||||||
{ id: "about", label: "ℹ️ About" },
|
{ id: "about", label: "ℹ️ About" },
|
||||||
|
{ id: "debug", label: "🔧 Debug" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -164,9 +169,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 +192,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>
|
||||||
|
|
||||||
@@ -234,6 +245,7 @@ const GameLayout = (): JSX.Element => {
|
|||||||
{activeTab === "story" && <StoryPanel />}
|
{activeTab === "story" && <StoryPanel />}
|
||||||
{activeTab === "codex" && <CodexPanel />}
|
{activeTab === "codex" && <CodexPanel />}
|
||||||
{activeTab === "about" && <AboutPanel />}
|
{activeTab === "about" && <AboutPanel />}
|
||||||
|
{activeTab === "debug" && <DebugPanel />}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -156,6 +156,9 @@ const LeaderboardPage = (): JSX.Element => {
|
|||||||
<p className="leaderboard-subtitle">
|
<p className="leaderboard-subtitle">
|
||||||
{"The mightiest adventurers in Elysium"}
|
{"The mightiest adventurers in Elysium"}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="leaderboard-update-note">
|
||||||
|
{"🔄 Rankings update when you prestige."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="leaderboard-tabs">
|
<div className="leaderboard-tabs">
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -88,7 +89,9 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
buyPrestigeUpgrade,
|
buyPrestigeUpgrade,
|
||||||
enableNotifications,
|
enableNotifications,
|
||||||
enableSounds,
|
enableSounds,
|
||||||
|
toggleAutoAdventurer,
|
||||||
toggleAutoPrestige,
|
toggleAutoPrestige,
|
||||||
|
triggerPrestigeToast,
|
||||||
} = useGame();
|
} = useGame();
|
||||||
const [ isPending, setIsPending ] = useState(false);
|
const [ isPending, setIsPending ] = useState(false);
|
||||||
const [ result, setResult ] = useState<{
|
const [ result, setResult ] = useState<{
|
||||||
@@ -108,7 +111,7 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { prestige: prestigeData, player } = state;
|
const { autoAdventurer, prestige: prestigeData, player } = state;
|
||||||
const threshold = calculateThreshold(prestigeData.count);
|
const threshold = calculateThreshold(prestigeData.count);
|
||||||
const isEligible = player.totalGoldEarned >= threshold;
|
const isEligible = player.totalGoldEarned >= threshold;
|
||||||
const runestonePreview = calculateRunestonePreview(
|
const runestonePreview = calculateRunestonePreview(
|
||||||
@@ -128,6 +131,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");
|
||||||
}
|
}
|
||||||
@@ -170,6 +174,10 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
void handlePrestige();
|
void handlePrestige();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAutoAdventurerToggle(): void {
|
||||||
|
toggleAutoAdventurer();
|
||||||
|
}
|
||||||
|
|
||||||
function handleAutoPrestigeToggle(): void {
|
function handleAutoPrestigeToggle(): void {
|
||||||
toggleAutoPrestige();
|
toggleAutoPrestige();
|
||||||
}
|
}
|
||||||
@@ -344,6 +352,9 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
= prestigeData.runestones >= upgrade.runestonesCost;
|
= prestigeData.runestones >= upgrade.runestonesCost;
|
||||||
const isLoading = buyingId === upgrade.id;
|
const isLoading = buyingId === upgrade.id;
|
||||||
|
|
||||||
|
const isAutoAdventurerToggle
|
||||||
|
= upgrade.id === "auto_adventurer" && purchased;
|
||||||
|
const autoAdventurerEnabled = autoAdventurer ?? false;
|
||||||
const isAutoPrestigeToggle
|
const isAutoPrestigeToggle
|
||||||
= upgrade.id === "auto_prestige" && purchased;
|
= upgrade.id === "auto_prestige" && purchased;
|
||||||
const autoPrestigeEnabled
|
const autoPrestigeEnabled
|
||||||
@@ -364,6 +375,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>
|
||||||
@@ -373,6 +389,21 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
: `🔮 ${formatNumber(upgrade.runestonesCost)} Runestones`}
|
: `🔮 ${formatNumber(upgrade.runestonesCost)} Runestones`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{isAutoAdventurerToggle
|
||||||
|
? <button
|
||||||
|
className={`auto-prestige-toggle ${
|
||||||
|
autoAdventurerEnabled
|
||||||
|
? "enabled"
|
||||||
|
: "disabled"
|
||||||
|
}`}
|
||||||
|
onClick={handleAutoAdventurerToggle}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{autoAdventurerEnabled
|
||||||
|
? "⚡ Auto ON"
|
||||||
|
: "⏸ Auto OFF"}
|
||||||
|
</button>
|
||||||
|
: null}
|
||||||
{isAutoPrestigeToggle
|
{isAutoPrestigeToggle
|
||||||
? <button
|
? <button
|
||||||
className={`auto-prestige-toggle ${
|
className={`auto-prestige-toggle ${
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -4,12 +4,15 @@
|
|||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
/* eslint-disable max-lines -- QuestPanel with sub-component and helper functions */
|
||||||
/* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */
|
/* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */
|
||||||
/* 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 -- 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 { zoneFailureChance } from "../../engine/tick.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 +84,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 +110,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"
|
||||||
@@ -137,8 +145,17 @@ const QuestCard = ({
|
|||||||
: null}
|
: null}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
{quest.status === "available"
|
||||||
|
&& <p className="quest-failure-chance">
|
||||||
|
{"🎲 "}
|
||||||
|
{String(Math.round((zoneFailureChance[quest.zoneId] ?? 0) * 100))}
|
||||||
|
{"% failure chance"}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
{quest.status === "available" && quest.lastFailedAt !== undefined
|
{quest.status === "available" && quest.lastFailedAt !== undefined
|
||||||
&& <p className="quest-failed-hint">{"⚠️ Last attempt failed"}</p>
|
&& <p className="quest-failed-hint">
|
||||||
|
{"⚠️ Last attempt failed — no rewards were granted."}
|
||||||
|
</p>
|
||||||
}
|
}
|
||||||
{quest.status === "available"
|
{quest.status === "available"
|
||||||
&& <button
|
&& <button
|
||||||
@@ -178,7 +195,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) {
|
||||||
@@ -189,12 +208,29 @@ const QuestPanel = (): JSX.Element => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { adventurers, autoQuest, quests, zones } = state;
|
const { adventurers, autoQuest, bosses, quests, zones } = state;
|
||||||
// eslint-disable-next-line unicorn/no-array-reduce -- Need the total!
|
|
||||||
const partyCombatPower = adventurers.reduce((total, adventurer) => {
|
const activeZone = zones.find((zone) => {
|
||||||
const power = total + adventurer.combatPower;
|
return zone.id === activeZoneId;
|
||||||
return power * adventurer.count;
|
});
|
||||||
}, 0);
|
const zoneIsLocked = activeZone?.status === "locked";
|
||||||
|
const unlockBoss = activeZone?.unlockBossId === null
|
||||||
|
|| activeZone?.unlockBossId === undefined
|
||||||
|
? undefined
|
||||||
|
: bosses.find((boss) => {
|
||||||
|
return boss.id === activeZone.unlockBossId;
|
||||||
|
});
|
||||||
|
const unlockQuest = activeZone?.unlockQuestId === null
|
||||||
|
|| activeZone?.unlockQuestId === undefined
|
||||||
|
? undefined
|
||||||
|
: quests.find((quest) => {
|
||||||
|
return quest.id === activeZone.unlockQuestId;
|
||||||
|
});
|
||||||
|
let partyCombatPower = 0;
|
||||||
|
for (const adventurer of adventurers) {
|
||||||
|
const contribution = adventurer.combatPower * adventurer.count;
|
||||||
|
partyCombatPower = partyCombatPower + contribution;
|
||||||
|
}
|
||||||
const zoneQuests = quests.filter(({ zoneId }) => {
|
const zoneQuests = quests.filter(({ zoneId }) => {
|
||||||
return zoneId === activeZoneId;
|
return zoneId === activeZoneId;
|
||||||
});
|
});
|
||||||
@@ -237,6 +273,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,10 +320,35 @@ const QuestPanel = (): JSX.Element => {
|
|||||||
|
|
||||||
<ZoneSelector
|
<ZoneSelector
|
||||||
activeZoneId={activeZoneId}
|
activeZoneId={activeZoneId}
|
||||||
onSelectZone={setActiveZoneId}
|
onSelectZone={handleZoneSelect}
|
||||||
zones={zones}
|
zones={zones}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
|
||||||
|
? <div className="exploration-zone-locked-hint">
|
||||||
|
<p>{"🔒 This zone is locked. Unlock quests by:"}</p>
|
||||||
|
{unlockBoss === undefined
|
||||||
|
? null
|
||||||
|
: <p>
|
||||||
|
{"⚔️ Defeat: "}
|
||||||
|
{unlockBoss.name}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
{unlockQuest === undefined
|
||||||
|
? null
|
||||||
|
: <p>
|
||||||
|
{"📜 Complete: "}
|
||||||
|
{unlockQuest.name}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
<p className="quest-failure-note">
|
||||||
|
{"⚠️ If a quest fails, it resets with no rewards — you must retry."}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="quest-list">
|
<div className="quest-list">
|
||||||
{visibleQuests.map((quest) => {
|
{visibleQuests.map((quest) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,8 +9,9 @@
|
|||||||
/* 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 { Adventurer, Upgrade } from "@elysium/types";
|
||||||
|
|
||||||
interface UpgradeCardProperties {
|
interface UpgradeCardProperties {
|
||||||
readonly upgrade: Upgrade;
|
readonly upgrade: Upgrade;
|
||||||
@@ -19,6 +20,7 @@ interface UpgradeCardProperties {
|
|||||||
readonly currentCrystals: number;
|
readonly currentCrystals: number;
|
||||||
readonly unlockHint: string | undefined;
|
readonly unlockHint: string | undefined;
|
||||||
readonly formatNumber: (n: number)=> string;
|
readonly formatNumber: (n: number)=> string;
|
||||||
|
readonly adventurers: ReadonlyArray<Adventurer>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,6 +32,7 @@ interface UpgradeCardProperties {
|
|||||||
* @param props.currentCrystals - The current crystals amount.
|
* @param props.currentCrystals - The current crystals amount.
|
||||||
* @param props.unlockHint - Optional hint for how to unlock this upgrade.
|
* @param props.unlockHint - Optional hint for how to unlock this upgrade.
|
||||||
* @param props.formatNumber - The number formatting utility function.
|
* @param props.formatNumber - The number formatting utility function.
|
||||||
|
* @param props.adventurers - The list of adventurers, used to resolve the affected adventurer name.
|
||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
*/
|
*/
|
||||||
const UpgradeCard = ({
|
const UpgradeCard = ({
|
||||||
@@ -39,8 +42,14 @@ const UpgradeCard = ({
|
|||||||
currentCrystals,
|
currentCrystals,
|
||||||
unlockHint,
|
unlockHint,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
|
adventurers,
|
||||||
}: UpgradeCardProperties): JSX.Element => {
|
}: UpgradeCardProperties): JSX.Element => {
|
||||||
const { buyUpgrade } = useGame();
|
const { buyUpgrade } = useGame();
|
||||||
|
const adventurerName = upgrade.adventurerId === undefined
|
||||||
|
? undefined
|
||||||
|
: adventurers.find((adventurer) => {
|
||||||
|
return adventurer.id === upgrade.adventurerId;
|
||||||
|
})?.name;
|
||||||
const canAfford
|
const canAfford
|
||||||
= currentGold >= upgrade.costGold
|
= currentGold >= upgrade.costGold
|
||||||
&& currentEssence >= upgrade.costEssence
|
&& currentEssence >= upgrade.costEssence
|
||||||
@@ -53,11 +62,23 @@ 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}
|
||||||
</span>
|
</span>
|
||||||
<span className="upgrade-desc">{upgrade.description}</span>
|
<span className="upgrade-desc">{upgrade.description}</span>
|
||||||
|
{adventurerName === undefined
|
||||||
|
? null
|
||||||
|
: <span className="upgrade-target">
|
||||||
|
{"🗡️ Affects: "}
|
||||||
|
{adventurerName}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -65,9 +86,21 @@ 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>
|
||||||
|
{adventurerName === undefined
|
||||||
|
? null
|
||||||
|
: <p className="upgrade-target">
|
||||||
|
{"🗡️ Affects: "}
|
||||||
|
{adventurerName}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
<p className="upgrade-multiplier">
|
<p className="upgrade-multiplier">
|
||||||
{"×"}
|
{"×"}
|
||||||
{upgrade.multiplier}
|
{upgrade.multiplier}
|
||||||
@@ -108,12 +141,24 @@ 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>
|
||||||
{"🔒 "}
|
{"🔒 "}
|
||||||
{upgrade.name}
|
{upgrade.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p>{upgrade.description}</p>
|
<p>{upgrade.description}</p>
|
||||||
|
{adventurerName === undefined
|
||||||
|
? null
|
||||||
|
: <p className="upgrade-target">
|
||||||
|
{"🗡️ Affects: "}
|
||||||
|
{adventurerName}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
<p className="upgrade-multiplier">
|
<p className="upgrade-multiplier">
|
||||||
{"×"}
|
{"×"}
|
||||||
{upgrade.multiplier}
|
{upgrade.multiplier}
|
||||||
@@ -165,7 +210,7 @@ const UpgradePanel = (): JSX.Element => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { bosses, quests, upgrades, resources } = state;
|
const { adventurers, bosses, quests, upgrades, resources } = state;
|
||||||
const purchased = upgrades.filter((upgrade) => {
|
const purchased = upgrades.filter((upgrade) => {
|
||||||
return upgrade.purchased;
|
return upgrade.purchased;
|
||||||
});
|
});
|
||||||
@@ -216,6 +261,10 @@ const UpgradePanel = (): JSX.Element => {
|
|||||||
{upgrades.length}
|
{upgrades.length}
|
||||||
{" purchased"}
|
{" purchased"}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="upgrade-stacking-note">
|
||||||
|
{"💡 Upgrade multipliers stack multiplicatively — two ×2 upgrades"
|
||||||
|
+ " combine to give ×4, not ×3."}
|
||||||
|
</p>
|
||||||
{upgrades.length === 0
|
{upgrades.length === 0
|
||||||
? <p className="empty-state">
|
? <p className="empty-state">
|
||||||
{"No upgrades available yet — keep adventuring!"}
|
{"No upgrades available yet — keep adventuring!"}
|
||||||
@@ -224,6 +273,7 @@ const UpgradePanel = (): JSX.Element => {
|
|||||||
{available.map((upgrade) => {
|
{available.map((upgrade) => {
|
||||||
return (
|
return (
|
||||||
<UpgradeCard
|
<UpgradeCard
|
||||||
|
adventurers={adventurers}
|
||||||
currentCrystals={resources.crystals}
|
currentCrystals={resources.crystals}
|
||||||
currentEssence={resources.essence}
|
currentEssence={resources.essence}
|
||||||
currentGold={resources.gold}
|
currentGold={resources.gold}
|
||||||
@@ -237,6 +287,7 @@ const UpgradePanel = (): JSX.Element => {
|
|||||||
{purchased.map((upgrade) => {
|
{purchased.map((upgrade) => {
|
||||||
return (
|
return (
|
||||||
<UpgradeCard
|
<UpgradeCard
|
||||||
|
adventurers={adventurers}
|
||||||
currentCrystals={resources.crystals}
|
currentCrystals={resources.crystals}
|
||||||
currentEssence={resources.essence}
|
currentEssence={resources.essence}
|
||||||
currentGold={resources.gold}
|
currentGold={resources.gold}
|
||||||
@@ -251,6 +302,7 @@ const UpgradePanel = (): JSX.Element => {
|
|||||||
? locked.map((upgrade) => {
|
? locked.map((upgrade) => {
|
||||||
return (
|
return (
|
||||||
<UpgradeCard
|
<UpgradeCard
|
||||||
|
adventurers={adventurers}
|
||||||
currentCrystals={resources.crystals}
|
currentCrystals={resources.crystals}
|
||||||
currentEssence={resources.essence}
|
currentEssence={resources.essence}
|
||||||
currentGold={resources.gold}
|
currentGold={resources.gold}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* @file Reusable confirmation modal component for destructive operations.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import type { JSX } from "react";
|
||||||
|
|
||||||
|
interface ConfirmationModalProperties {
|
||||||
|
readonly title: string;
|
||||||
|
readonly description: string;
|
||||||
|
readonly confirmLabel: string;
|
||||||
|
readonly onConfirm: ()=> void;
|
||||||
|
readonly onCancel: ()=> void;
|
||||||
|
readonly isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a confirmation modal for destructive operations.
|
||||||
|
* @param props - The modal properties.
|
||||||
|
* @param props.title - The modal heading.
|
||||||
|
* @param props.description - Warning text explaining what the operation does.
|
||||||
|
* @param props.confirmLabel - Label for the confirm button.
|
||||||
|
* @param props.onConfirm - Callback fired when the player confirms.
|
||||||
|
* @param props.onCancel - Callback fired when the player cancels.
|
||||||
|
* @param props.isLoading - Whether the operation is currently in progress.
|
||||||
|
* @returns The JSX element.
|
||||||
|
*/
|
||||||
|
const ConfirmationModal = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
isLoading,
|
||||||
|
}: ConfirmationModalProperties): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="modal">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<p>{description}</p>
|
||||||
|
<p className="modal-note">{"Are you sure you want to do this?"}</p>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button
|
||||||
|
className="modal-close-button modal-button-danger"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={onConfirm}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isLoading
|
||||||
|
? "Working..."
|
||||||
|
: confirmLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="modal-close-button"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={onCancel}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ConfirmationModal };
|
||||||
@@ -77,8 +77,15 @@ const ResourceBar = ({
|
|||||||
isSyncing,
|
isSyncing,
|
||||||
onForceSync,
|
onForceSync,
|
||||||
}: ResourceBarProperties): JSX.Element => {
|
}: ResourceBarProperties): JSX.Element => {
|
||||||
const { formatNumber, syncError } = useGame();
|
const { formatNumber, syncError, state } = useGame();
|
||||||
const { gold, essence, crystals } = resources;
|
const { gold, essence, crystals } = resources;
|
||||||
|
let partyCombatPower = 0;
|
||||||
|
if (state !== null) {
|
||||||
|
for (const adventurer of state.adventurers) {
|
||||||
|
const contribution = adventurer.combatPower * adventurer.count;
|
||||||
|
partyCombatPower = partyCombatPower + contribution;
|
||||||
|
}
|
||||||
|
}
|
||||||
const resourceValues = [ gold, essence, crystals ];
|
const resourceValues = [ gold, essence, crystals ];
|
||||||
const anyFull = resourceValues.some((v) => {
|
const anyFull = resourceValues.some((v) => {
|
||||||
return v >= RESOURCE_CAP;
|
return v >= RESOURCE_CAP;
|
||||||
@@ -135,6 +142,13 @@ const ResourceBar = ({
|
|||||||
<span className="resource-value">{formatNumber(runestones)}</span>
|
<span className="resource-value">{formatNumber(runestones)}</span>
|
||||||
<span className="resource-label">{"Runestones"}</span>
|
<span className="resource-label">{"Runestones"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="resource">
|
||||||
|
<span className="resource-icon">{"⚔️"}</span>
|
||||||
|
<span className="resource-value">
|
||||||
|
{formatNumber(partyCombatPower)}
|
||||||
|
</span>
|
||||||
|
<span className="resource-label">{"Combat Power"}</span>
|
||||||
|
</div>
|
||||||
{apotheosisCount > 0
|
{apotheosisCount > 0
|
||||||
&& <div className="apotheosis-badge">
|
&& <div className="apotheosis-badge">
|
||||||
{"✨ Apotheosis "}
|
{"✨ Apotheosis "}
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -41,6 +42,8 @@ import {
|
|||||||
challengeBoss as challengeBossApi,
|
challengeBoss as challengeBossApi,
|
||||||
collectExploration as collectExplorationApi,
|
collectExploration as collectExplorationApi,
|
||||||
craftRecipe as craftRecipeApi,
|
craftRecipe as craftRecipeApi,
|
||||||
|
debugHardReset as debugHardResetApi,
|
||||||
|
forceUnlocks as forceUnlocksApi,
|
||||||
loadGame,
|
loadGame,
|
||||||
prestige as prestigeApi,
|
prestige as prestigeApi,
|
||||||
resetProgress as resetProgressApi,
|
resetProgress as resetProgressApi,
|
||||||
@@ -49,7 +52,6 @@ import {
|
|||||||
transcend as transcendApi,
|
transcend as transcendApi,
|
||||||
} from "../api/client.js";
|
} from "../api/client.js";
|
||||||
import { CODEX_ENTRIES } from "../data/codex.js";
|
import { CODEX_ENTRIES } from "../data/codex.js";
|
||||||
import { EXPLORATION_AREAS } from "../data/explorations.js";
|
|
||||||
import { RECIPES } from "../data/recipes.js";
|
import { RECIPES } from "../data/recipes.js";
|
||||||
import {
|
import {
|
||||||
RESOURCE_CAP,
|
RESOURCE_CAP,
|
||||||
@@ -58,6 +60,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 +337,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.
|
||||||
*/
|
*/
|
||||||
@@ -389,6 +447,11 @@ interface GameContextValue {
|
|||||||
*/
|
*/
|
||||||
toggleAutoBoss: ()=> void;
|
toggleAutoBoss: ()=> void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the auto-adventurer setting on/off (requires auto_adventurer prestige upgrade).
|
||||||
|
*/
|
||||||
|
toggleAutoAdventurer: ()=> void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue of newly unlocked codex entry IDs (for toast notifications).
|
* Queue of newly unlocked codex entry IDs (for toast notifications).
|
||||||
*/
|
*/
|
||||||
@@ -399,6 +462,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 +551,42 @@ 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>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force-unlock any zones, quests, and bosses the player has earned but that
|
||||||
|
* are still incorrectly locked due to a state bug.
|
||||||
|
* @returns Counts of what was corrected.
|
||||||
|
*/
|
||||||
|
forceUnlocks: ()=> Promise<{
|
||||||
|
bossesUnlocked: number;
|
||||||
|
explorationUnlocked: number;
|
||||||
|
questsUnlocked: number;
|
||||||
|
zonesUnlocked: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completely wipe the player's progress back to a brand-new save via the
|
||||||
|
* debug endpoint.
|
||||||
|
*/
|
||||||
|
debugHardReset: ()=> 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error message from the most recent manual boss challenge (null when no
|
||||||
|
* error). Cleared automatically when a new challenge is initiated.
|
||||||
|
*/
|
||||||
|
bossError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BattleResult {
|
export interface BattleResult {
|
||||||
@@ -514,9 +618,25 @@ 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 [ bossError, setBossError ] = useState<string | null>(null);
|
||||||
const syncErrorTimerReference = useRef<ReturnType<typeof setTimeout> | null>(
|
const syncErrorTimerReference = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -530,8 +650,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 +668,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 +936,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(() => {
|
||||||
@@ -939,6 +1078,42 @@ export const GameProvider = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-adventurer: buy one of the highest-tier affordable unlocked adventurer per tick
|
||||||
|
if (
|
||||||
|
next.autoAdventurer === true
|
||||||
|
&& next.prestige.purchasedUpgradeIds.includes("auto_adventurer")
|
||||||
|
) {
|
||||||
|
const [ bestAdventurer ] = next.adventurers.
|
||||||
|
filter((adventurer) => {
|
||||||
|
const cost
|
||||||
|
= adventurer.baseCost * Math.pow(1.15, adventurer.count);
|
||||||
|
return adventurer.unlocked && next.resources.gold >= cost;
|
||||||
|
}).
|
||||||
|
sort((adventurerA, adventurerB) => {
|
||||||
|
const costA
|
||||||
|
= adventurerA.baseCost * Math.pow(1.15, adventurerA.count);
|
||||||
|
const costB
|
||||||
|
= adventurerB.baseCost * Math.pow(1.15, adventurerB.count);
|
||||||
|
return costB - costA;
|
||||||
|
});
|
||||||
|
if (bestAdventurer !== undefined) {
|
||||||
|
const purchaseCost
|
||||||
|
= bestAdventurer.baseCost * Math.pow(1.15, bestAdventurer.count);
|
||||||
|
next = {
|
||||||
|
...next,
|
||||||
|
adventurers: next.adventurers.map((adventurer) => {
|
||||||
|
return adventurer.id === bestAdventurer.id
|
||||||
|
? { ...adventurer, count: adventurer.count + 1 }
|
||||||
|
: adventurer;
|
||||||
|
}),
|
||||||
|
resources: {
|
||||||
|
...next.resources,
|
||||||
|
gold: next.resources.gold - purchaseCost,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Detect newly unlocked achievements
|
// Detect newly unlocked achievements
|
||||||
unlockedAchievementsReference.current = next.achievements.filter(
|
unlockedAchievementsReference.current = next.achievements.filter(
|
||||||
(a, index) => {
|
(a, index) => {
|
||||||
@@ -949,17 +1124,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 +1142,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 +1170,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)
|
||||||
@@ -1036,6 +1225,11 @@ export const GameProvider = ({
|
|||||||
signatureReference.current = null;
|
signatureReference.current = null;
|
||||||
localStorage.removeItem("elysium_save_signature");
|
localStorage.removeItem("elysium_save_signature");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Network failures during background auto-save are expected on
|
||||||
|
* flaky connections — the next tick will retry, so no telemetry needed
|
||||||
|
*/
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1054,6 +1248,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");
|
||||||
}
|
}
|
||||||
@@ -1064,7 +1259,7 @@ export const GameProvider = ({
|
|||||||
}).
|
}).
|
||||||
catch(() => {
|
catch(() => {
|
||||||
|
|
||||||
/* Silently ignore — will retry next tick */
|
/* Silently ignore — eligibility is re-checked every tick */
|
||||||
}).
|
}).
|
||||||
finally(() => {
|
finally(() => {
|
||||||
isAutoPrestigingReference.current = false;
|
isAutoPrestigingReference.current = false;
|
||||||
@@ -1100,24 +1295,39 @@ 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) => {
|
||||||
|
const message
|
||||||
|
= error_ instanceof Error
|
||||||
|
? error_.message
|
||||||
|
: String(error_);
|
||||||
|
|
||||||
/* Silently ignore — will retry next tick */
|
/*
|
||||||
|
* "Boss is not currently available" is an expected race condition
|
||||||
|
* in the tick loop — suppress telemetry for this case only
|
||||||
|
*/
|
||||||
|
if (message !== "Boss is not currently available") {
|
||||||
|
logError("auto_boss", error_);
|
||||||
|
}
|
||||||
|
setAutoBossError(message);
|
||||||
|
setState((previous) => {
|
||||||
|
if (previous === null) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
return { ...previous, autoBoss: false };
|
||||||
|
});
|
||||||
}).
|
}).
|
||||||
finally(() => {
|
finally(() => {
|
||||||
isAutoBossingReference.current = false;
|
isAutoBossingReference.current = false;
|
||||||
@@ -1436,33 +1646,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,21 +1711,14 @@ export const GameProvider = ({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (error_: unknown) {
|
||||||
// Silently ignore server errors
|
logError("buy_echo_upgrade", error_);
|
||||||
|
// Silently ignore — server errors shouldn't crash the UI
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startExploration = useCallback(async(areaId: string) => {
|
const startExploration = useCallback(async(areaId: string) => {
|
||||||
const response = await startExplorationApi({ areaId });
|
const response = await startExplorationApi({ areaId });
|
||||||
const areaData = EXPLORATION_AREAS.find((a) => {
|
|
||||||
return a.id === areaId;
|
|
||||||
});
|
|
||||||
if (areaData === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
|
|
||||||
const startedAt = response.endsAt - areaData.durationSeconds * 1000;
|
|
||||||
setState((previous) => {
|
setState((previous) => {
|
||||||
if (previous?.exploration === undefined) {
|
if (previous?.exploration === undefined) {
|
||||||
return previous;
|
return previous;
|
||||||
@@ -1513,7 +1729,11 @@ export const GameProvider = ({
|
|||||||
...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, startedAt: startedAt, status: "in_progress" as const }
|
? {
|
||||||
|
...a,
|
||||||
|
endsAt: response.endsAt,
|
||||||
|
status: "in_progress" as const,
|
||||||
|
}
|
||||||
: a;
|
: a;
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -1581,13 +1801,13 @@ export const GameProvider = ({
|
|||||||
player: {
|
player: {
|
||||||
...previous.player,
|
...previous.player,
|
||||||
totalGoldEarned:
|
totalGoldEarned:
|
||||||
previous.player.totalGoldEarned
|
previous.player.totalGoldEarned
|
||||||
+ Math.max(0, result.event?.goldChange ?? 0),
|
+ Math.max(0, result.event?.goldChange ?? 0),
|
||||||
},
|
},
|
||||||
resources: {
|
resources: {
|
||||||
...previous.resources,
|
...previous.resources,
|
||||||
essence:
|
essence:
|
||||||
previous.resources.essence + (result.event?.essenceChange ?? 0),
|
previous.resources.essence + (result.event?.essenceChange ?? 0),
|
||||||
gold: Math.max(
|
gold: Math.max(
|
||||||
0,
|
0,
|
||||||
previous.resources.gold + (result.event?.goldChange ?? 0),
|
previous.resources.gold + (result.event?.goldChange ?? 0),
|
||||||
@@ -1607,35 +1827,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 +1888,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;
|
||||||
@@ -1671,6 +1898,18 @@ export const GameProvider = ({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const toggleAutoAdventurer = useCallback(() => {
|
||||||
|
setState((previous) => {
|
||||||
|
if (previous === null) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...previous,
|
||||||
|
autoAdventurer: previous.autoAdventurer !== true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const setActiveCompanion = useCallback((companionId: string | null) => {
|
const setActiveCompanion = useCallback((companionId: string | null) => {
|
||||||
setState((previous) => {
|
setState((previous) => {
|
||||||
if (previous === null) {
|
if (previous === null) {
|
||||||
@@ -1702,6 +1941,14 @@ export const GameProvider = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setBossError(null);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Flush any pending state (e.g. newly equipped items) to the server before
|
||||||
|
* the fight so the server-side calculation uses the player's live stats.
|
||||||
|
*/
|
||||||
|
await forceSync();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await challengeBossApi({ bossId });
|
const result = await challengeBossApi({ bossId });
|
||||||
setState((previous) => {
|
setState((previous) => {
|
||||||
@@ -1711,18 +1958,24 @@ 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) {
|
const bossErrorMessage
|
||||||
playSound("bossVictory");
|
= error_ instanceof Error
|
||||||
}
|
? error_.message
|
||||||
if (enableNotificationsReference.current) {
|
: "Failed to challenge boss";
|
||||||
sendNotification("⚔️ Boss Defeated!", `You defeated ${boss.name}!`);
|
|
||||||
}
|
/*
|
||||||
|
* "Boss is not currently available" is an expected server rejection
|
||||||
|
* (race condition between UI state and server state) — suppress telemetry
|
||||||
|
*/
|
||||||
|
if (bossErrorMessage !== "Boss is not currently available") {
|
||||||
|
logError("challenge_boss", error_);
|
||||||
}
|
}
|
||||||
} catch {
|
setBossError(
|
||||||
// Silently ignore — server errors shouldn't crash the UI
|
bossErrorMessage,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [ forceSync ]);
|
||||||
|
|
||||||
const dismissOfflineGold = useCallback(() => {
|
const dismissOfflineGold = useCallback(() => {
|
||||||
setOfflineGold(0);
|
setOfflineGold(0);
|
||||||
@@ -1733,6 +1986,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 +2034,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) => {
|
||||||
@@ -1806,6 +2101,61 @@ export const GameProvider = ({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const forceUnlocks = useCallback(async() => {
|
||||||
|
try {
|
||||||
|
const data = await forceUnlocksApi();
|
||||||
|
setState(data.state);
|
||||||
|
if (data.signature !== undefined) {
|
||||||
|
signatureReference.current = data.signature;
|
||||||
|
localStorage.setItem("elysium_save_signature", data.signature);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
bossesUnlocked: data.bossesUnlocked,
|
||||||
|
explorationUnlocked: data.explorationUnlocked,
|
||||||
|
questsUnlocked: data.questsUnlocked,
|
||||||
|
zonesUnlocked: data.zonesUnlocked,
|
||||||
|
};
|
||||||
|
} catch (error_: unknown) {
|
||||||
|
setError(
|
||||||
|
error_ instanceof Error
|
||||||
|
? error_.message
|
||||||
|
: "Failed to force unlocks",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
bossesUnlocked: 0,
|
||||||
|
explorationUnlocked: 0,
|
||||||
|
questsUnlocked: 0,
|
||||||
|
zonesUnlocked: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const debugHardReset = useCallback(async() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await debugHardResetApi();
|
||||||
|
setState(data.state);
|
||||||
|
setLastSavedAt(data.state.player.lastSavedAt);
|
||||||
|
setSchemaOutdated(false);
|
||||||
|
setOfflineGold(0);
|
||||||
|
setOfflineEssence(0);
|
||||||
|
setLoginBonus(null);
|
||||||
|
if (data.signature !== undefined) {
|
||||||
|
signatureReference.current = data.signature;
|
||||||
|
localStorage.setItem("elysium_save_signature", data.signature);
|
||||||
|
}
|
||||||
|
} catch (error_: unknown) {
|
||||||
|
setError(
|
||||||
|
error_ instanceof Error
|
||||||
|
? error_.message
|
||||||
|
: "Failed to reset progress",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const dismissLoginBonus = useCallback(() => {
|
const dismissLoginBonus = useCallback(() => {
|
||||||
setLoginBonus(null);
|
setLoginBonus(null);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -1820,7 +2170,10 @@ export const GameProvider = ({
|
|||||||
const contextValue = useMemo<GameContextValue>(() => {
|
const contextValue = useMemo<GameContextValue>(() => {
|
||||||
return {
|
return {
|
||||||
apotheosis,
|
apotheosis,
|
||||||
|
autoBossError,
|
||||||
|
autoBossLastResult,
|
||||||
battleResult,
|
battleResult,
|
||||||
|
bossError,
|
||||||
buyAdventurer,
|
buyAdventurer,
|
||||||
buyEchoUpgrade,
|
buyEchoUpgrade,
|
||||||
buyEquipment,
|
buyEquipment,
|
||||||
@@ -1829,19 +2182,29 @@ export const GameProvider = ({
|
|||||||
challengeBoss,
|
challengeBoss,
|
||||||
collectExploration,
|
collectExploration,
|
||||||
completeChapter,
|
completeChapter,
|
||||||
|
completedQuestToasts,
|
||||||
craftRecipe,
|
craftRecipe,
|
||||||
currentSchemaVersion,
|
currentSchemaVersion,
|
||||||
|
debugHardReset,
|
||||||
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,
|
||||||
|
forceUnlocks,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
handleClick,
|
handleClick,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -1860,21 +2223,31 @@ export const GameProvider = ({
|
|||||||
setEnableNotifications,
|
setEnableNotifications,
|
||||||
setEnableSounds,
|
setEnableSounds,
|
||||||
setNumberFormat,
|
setNumberFormat,
|
||||||
|
showApotheosisToast,
|
||||||
|
showPrestigeToast,
|
||||||
|
showTranscendenceToast,
|
||||||
startExploration,
|
startExploration,
|
||||||
startQuest,
|
startQuest,
|
||||||
state,
|
state,
|
||||||
syncError,
|
syncError,
|
||||||
|
toggleAutoAdventurer,
|
||||||
toggleAutoBoss,
|
toggleAutoBoss,
|
||||||
toggleAutoPrestige,
|
toggleAutoPrestige,
|
||||||
toggleAutoQuest,
|
toggleAutoQuest,
|
||||||
transcend,
|
transcend,
|
||||||
|
triggerPrestigeToast,
|
||||||
unlockedAchievements,
|
unlockedAchievements,
|
||||||
unlockedCodexEntryIds,
|
unlockedCodexEntryIds,
|
||||||
unlockedStoryChapterIds,
|
unlockedStoryChapterIds,
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
apotheosis,
|
apotheosis,
|
||||||
|
autoBossError,
|
||||||
|
autoBossLastResult,
|
||||||
battleResult,
|
battleResult,
|
||||||
|
bossError,
|
||||||
|
completedQuestToasts,
|
||||||
|
failedQuestToasts,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
buyAdventurer,
|
buyAdventurer,
|
||||||
buyEchoUpgrade,
|
buyEchoUpgrade,
|
||||||
@@ -1886,17 +2259,25 @@ export const GameProvider = ({
|
|||||||
completeChapter,
|
completeChapter,
|
||||||
craftRecipe,
|
craftRecipe,
|
||||||
currentSchemaVersion,
|
currentSchemaVersion,
|
||||||
|
debugHardReset,
|
||||||
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,
|
||||||
|
forceUnlocks,
|
||||||
handleClick,
|
handleClick,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSyncing,
|
isSyncing,
|
||||||
@@ -1914,14 +2295,19 @@ export const GameProvider = ({
|
|||||||
setEnableNotifications,
|
setEnableNotifications,
|
||||||
setEnableSounds,
|
setEnableSounds,
|
||||||
setNumberFormat,
|
setNumberFormat,
|
||||||
|
showApotheosisToast,
|
||||||
|
showPrestigeToast,
|
||||||
|
showTranscendenceToast,
|
||||||
startExploration,
|
startExploration,
|
||||||
startQuest,
|
startQuest,
|
||||||
state,
|
state,
|
||||||
syncError,
|
syncError,
|
||||||
|
toggleAutoAdventurer,
|
||||||
toggleAutoBoss,
|
toggleAutoBoss,
|
||||||
toggleAutoPrestige,
|
toggleAutoPrestige,
|
||||||
toggleAutoQuest,
|
toggleAutoQuest,
|
||||||
transcend,
|
transcend,
|
||||||
|
triggerPrestigeToast,
|
||||||
unlockedAchievements,
|
unlockedAchievements,
|
||||||
unlockedCodexEntryIds,
|
unlockedCodexEntryIds,
|
||||||
unlockedStoryChapterIds,
|
unlockedStoryChapterIds,
|
||||||
|
|||||||
@@ -212,6 +212,15 @@ export const PRESTIGE_UPGRADES: Array<PrestigeUpgrade> = [
|
|||||||
runestonesCost: 1200,
|
runestonesCost: 1200,
|
||||||
},
|
},
|
||||||
// ── Utility Unlocks ───────────────────────────────────────────────────────
|
// ── Utility Unlocks ───────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
category: "utility",
|
||||||
|
description:
|
||||||
|
"Unlock the Auto-Adventurer toggle. When enabled, the tick engine will automatically purchase the highest-tier adventurer you can currently afford.",
|
||||||
|
id: "auto_adventurer",
|
||||||
|
multiplier: 1,
|
||||||
|
name: "Autonomous Recruitment",
|
||||||
|
runestonesCost: 50,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
category: "utility",
|
category: "utility",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export const RESOURCE_CAP = 1e300;
|
|||||||
* On failure the quest resets to "available" with no rewards; the player must wait the
|
* On failure the quest resets to "available" with no rewards; the player must wait the
|
||||||
* full duration again on their next attempt.
|
* full duration again on their next attempt.
|
||||||
*/
|
*/
|
||||||
const zoneFailureChance: Record<string, number> = {
|
export const zoneFailureChance: Record<string, number> = {
|
||||||
abyssal_trench: 0.24,
|
abyssal_trench: 0.24,
|
||||||
astral_void: 0.2,
|
astral_void: 0.2,
|
||||||
celestial_reaches: 0.22,
|
celestial_reaches: 0.22,
|
||||||
|
|||||||
@@ -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>,
|
||||||
);
|
);
|
||||||
|
|||||||
+217
-21
@@ -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,116 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== ACTION BUTTONS ===================== */
|
||||||
|
.action-button {
|
||||||
|
background: var(--colour-accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.55rem 1.25rem;
|
||||||
|
transition: background 0.15s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:hover:not(:disabled) {
|
||||||
|
background: var(--colour-accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button-danger {
|
||||||
|
background: var(--colour-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button-danger:hover:not(:disabled) {
|
||||||
|
background: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== MODAL VARIANTS ===================== */
|
||||||
|
.modal-button-danger {
|
||||||
|
background: var(--colour-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-button-danger:hover:not(:disabled) {
|
||||||
|
background: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== DEBUG PANEL ===================== */
|
||||||
|
.debug-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-action-card {
|
||||||
|
background: var(--colour-surface);
|
||||||
|
border: 1px solid var(--colour-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-action-card h3 {
|
||||||
|
color: var(--colour-accent-light);
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-action-card > p {
|
||||||
|
color: var(--colour-text-muted);
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-result-message {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
border: 1px solid var(--colour-success);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--colour-success);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* @file CDN URL utility for Elysium game assets.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
const cdnBase = "https://cdn.nhcarrigan.com/elysium";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the CDN URL for a game asset image.
|
||||||
|
* @param folder - The asset category folder (e.g. "bosses", "companions").
|
||||||
|
* @param id - The asset identifier (file name without extension).
|
||||||
|
* @returns The full CDN URL for the asset.
|
||||||
|
*/
|
||||||
|
const cdnImage = (folder: string, id: string): string => {
|
||||||
|
return `${cdnBase}/${folder}/${id}.jpg`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { cdnImage };
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* @file Frontend error logging utility that forwards errors to the backend telemetry service.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
/* eslint-disable no-console -- Errors are forwarded to backend via the overridden console.error */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs an error to the backend telemetry service.
|
||||||
|
* Accepts the same arguments as console.error — conventionally a context string
|
||||||
|
* followed by the error value.
|
||||||
|
* @param logArguments - The values to log, forwarded directly to console.error.
|
||||||
|
*/
|
||||||
|
const logError = (...logArguments: Array<unknown>): void => {
|
||||||
|
console.error(...logArguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { logError };
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* @file Frontend logger that forwards console output to the backend telemetry service.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
/* eslint-disable no-console -- This file intentionally overrides console methods */
|
||||||
|
|
||||||
|
type Level = "debug" | "info" | "warn";
|
||||||
|
|
||||||
|
const post = (path: string, body: object): void => {
|
||||||
|
void fetch(path, {
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header names use kebab-case
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
method: "POST",
|
||||||
|
}).catch(() => {
|
||||||
|
// Intentionally swallowed — we cannot log logger failures without infinite recursion.
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overrides the global console.log and console.error methods so that all
|
||||||
|
* frontend log output is forwarded to the backend telemetry endpoints.
|
||||||
|
* Must be called once at application startup before any other code runs.
|
||||||
|
*/
|
||||||
|
const initialiseFrontendLogger = (): void => {
|
||||||
|
const originalLog = console.log.bind(console);
|
||||||
|
const originalError = console.error.bind(console);
|
||||||
|
|
||||||
|
console.log = (...consoleArguments: Array<unknown>): void => {
|
||||||
|
originalLog(...consoleArguments);
|
||||||
|
const level: Level = "info";
|
||||||
|
const message = consoleArguments.map((argument) => {
|
||||||
|
return typeof argument === "string"
|
||||||
|
? argument
|
||||||
|
: JSON.stringify(argument);
|
||||||
|
}).join(" ");
|
||||||
|
post("/api/fe/log", { level, message });
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error = (...consoleArguments: Array<unknown>): void => {
|
||||||
|
originalError(...consoleArguments);
|
||||||
|
const message = consoleArguments.map((argument) => {
|
||||||
|
if (argument instanceof Error) {
|
||||||
|
return `${argument.message}\n${argument.stack ?? ""}`;
|
||||||
|
}
|
||||||
|
return typeof argument === "string"
|
||||||
|
? argument
|
||||||
|
: JSON.stringify(argument);
|
||||||
|
}).join(" ");
|
||||||
|
const context = "console.error";
|
||||||
|
post("/api/fe/error", { context, message });
|
||||||
|
};
|
||||||
|
|
||||||
|
console.warn = (...consoleArguments: Array<unknown>): void => {
|
||||||
|
originalLog(...consoleArguments);
|
||||||
|
const level: Level = "warn";
|
||||||
|
const message = consoleArguments.map((argument) => {
|
||||||
|
return typeof argument === "string"
|
||||||
|
? argument
|
||||||
|
: JSON.stringify(argument);
|
||||||
|
}).join(" ");
|
||||||
|
post("/api/fe/log", { level, message });
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export { initialiseFrontendLogger };
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
import { logError } from "./logError.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Requests browser notification permission from the user.
|
* Requests browser notification permission from the user.
|
||||||
@@ -38,7 +39,8 @@ const sendNotification = (title: string, body: string): void => {
|
|||||||
try {
|
try {
|
||||||
// eslint-disable-next-line no-new -- Notification constructor has side effects
|
// eslint-disable-next-line no-new -- Notification constructor has side effects
|
||||||
new Notification(title, { body: body, icon: "/favicon.ico" });
|
new Notification(title, { body: body, icon: "/favicon.ico" });
|
||||||
} catch {
|
} catch (error_: unknown) {
|
||||||
|
logError("send_notification", error_);
|
||||||
// Silently ignore — notifications may fail silently
|
// Silently ignore — notifications may fail silently
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
import { logError } from "./logError.js";
|
||||||
|
|
||||||
type SoundEvent =
|
type SoundEvent =
|
||||||
| "achievement"
|
| "achievement"
|
||||||
@@ -101,7 +102,8 @@ const playSound = (event: SoundEvent): void => {
|
|||||||
oscillator.start(startTime);
|
oscillator.start(startTime);
|
||||||
oscillator.stop(endTime);
|
oscillator.stop(endTime);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error_: unknown) {
|
||||||
|
logError("play_sound", error_);
|
||||||
// Silently ignore — audio may not be available in all environments
|
// Silently ignore — audio may not be available in all environments
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "elysium",
|
"name": "elysium",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -11,6 +11,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nhcarrigan/typescript-config": "4.0.0",
|
"@nhcarrigan/typescript-config": "4.0.0",
|
||||||
"typescript": "5.8.2"
|
"typescript": "5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@elysium/types",
|
"name": "@elysium/types",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./prod/src/index.js",
|
"main": "./prod/src/index.js",
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export type {
|
|||||||
ExploreCollectResponse,
|
ExploreCollectResponse,
|
||||||
ExploreStartRequest,
|
ExploreStartRequest,
|
||||||
ExploreStartResponse,
|
ExploreStartResponse,
|
||||||
|
ForceUnlocksResponse,
|
||||||
GiteaRelease,
|
GiteaRelease,
|
||||||
LeaderboardCategory,
|
LeaderboardCategory,
|
||||||
LeaderboardEntry,
|
LeaderboardEntry,
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -392,6 +398,39 @@ interface CraftRecipeResponse {
|
|||||||
craftedCombatMultiplier: number;
|
craftedCombatMultiplier: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ForceUnlocksResponse {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The corrected game state after applying all missing unlocks.
|
||||||
|
*/
|
||||||
|
state: GameState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of zones that were unlocked by this operation.
|
||||||
|
*/
|
||||||
|
zonesUnlocked: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of quests that were made available by this operation.
|
||||||
|
*/
|
||||||
|
questsUnlocked: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of bosses that were made available by this operation.
|
||||||
|
*/
|
||||||
|
bossesUnlocked: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of exploration areas that were made available by this operation.
|
||||||
|
*/
|
||||||
|
explorationUnlocked: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HMAC-SHA256 signature of the corrected state for anti-cheat chain continuity.
|
||||||
|
*/
|
||||||
|
signature?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
AboutResponse,
|
AboutResponse,
|
||||||
ApiError,
|
ApiError,
|
||||||
@@ -411,6 +450,7 @@ export type {
|
|||||||
ExploreCollectResponse,
|
ExploreCollectResponse,
|
||||||
ExploreStartRequest,
|
ExploreStartRequest,
|
||||||
ExploreStartResponse,
|
ExploreStartResponse,
|
||||||
|
ForceUnlocksResponse,
|
||||||
GiteaRelease,
|
GiteaRelease,
|
||||||
LeaderboardCategory,
|
LeaderboardCategory,
|
||||||
LeaderboardEntry,
|
LeaderboardEntry,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ interface ExplorationAreaState {
|
|||||||
*/
|
*/
|
||||||
startedAt?: number;
|
startedAt?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unix timestamp when the exploration will complete (server-computed, used for
|
||||||
|
* accurate client-side countdown that is immune to client/server clock drift).
|
||||||
|
*/
|
||||||
|
endsAt?: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True after the first successful collect — used for codex unlock detection.
|
* True after the first successful collect — used for codex unlock detection.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ interface GameState {
|
|||||||
*/
|
*/
|
||||||
autoBoss?: boolean;
|
autoBoss?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When true, the tick engine automatically purchases the highest-tier affordable adventurer.
|
||||||
|
*/
|
||||||
|
autoAdventurer?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Companion unlock and active selection state — optional for backwards compatibility.
|
* Companion unlock and active selection state — optional for backwards compatibility.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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.`,
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+713
-20
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user