generated from nhcarrigan/template
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
66c2f7e8e9
|
|||
| 81ae1f18e1 | |||
| 0057cfeaaa | |||
| 161127dc21 | |||
| a8a465f293 | |||
| 79c4b99e8a | |||
| 3d114f63d7 | |||
| 911e089a9e | |||
| 14de87d765 | |||
| c4b4fba4c9 | |||
| d723656743 | |||
| 7e10757e68 | |||
| ca2edb090e | |||
| cfcf763ce3 | |||
| aede55a13d | |||
| 744cbf121f | |||
| 03b6c847b3 | |||
| 219d299e9f | |||
| 9e5b8ed972 | |||
|
a20cf3ef87
|
@@ -14,7 +14,7 @@ Game art is generated via the Gemini API (`gemini-3-pro-image-preview`, ~$0.134/
|
|||||||
### Process
|
### 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
|
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`
|
2. Save responses to `/home/naomi/code/naomi/elysium/img/<category>/<id>.jpg`
|
||||||
3. Upload to R2 with: `AWS_ACCESS_KEY_ID=dd0a3d73969143ada84d50f8940cc5e2 AWS_SECRET_ACCESS_KEY=f73e9907da1b2297e93e17f786d6446d33d4ac60e185879578a0d5020899b18e aws s3 sync img/ s3://nhcarrigan-cdn/elysium/ --endpoint-url https://751c386661d378cc032093493cfb0869.r2.cloudflarestorage.com`
|
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)
|
4. Delete the local `img/` directory before committing (images live on CDN only)
|
||||||
|
|
||||||
### CDN URL Helper
|
### CDN URL Helper
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@elysium/api",
|
"name": "@elysium/api",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./prod/src/index.js",
|
"main": "./prod/src/index.js",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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 { frontendRouter } from "./routes/frontend.js";
|
||||||
import { gameRouter } from "./routes/game.js";
|
import { gameRouter } from "./routes/game.js";
|
||||||
@@ -35,6 +36,7 @@ app.use(
|
|||||||
);
|
);
|
||||||
|
|
||||||
app.route("/about", aboutRouter);
|
app.route("/about", aboutRouter);
|
||||||
|
app.route("/debug", debugRouter);
|
||||||
app.route("/fe", frontendRouter);
|
app.route("/fe", frontendRouter);
|
||||||
app.route("/auth", authRouter);
|
app.route("/auth", authRouter);
|
||||||
app.route("/game", gameRouter);
|
app.route("/game", gameRouter);
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 {
|
||||||
@@ -214,7 +215,10 @@ const buildPostPrestigeState = (
|
|||||||
const currentBoss = currentState.bosses.find((candidate) => {
|
const currentBoss = currentState.bosses.find((candidate) => {
|
||||||
return candidate.id === freshBoss.id;
|
return candidate.id === freshBoss.id;
|
||||||
});
|
});
|
||||||
if (currentBoss?.bountyRunestonesClaimed === true) {
|
if (
|
||||||
|
currentBoss?.bountyRunestonesClaimed === true
|
||||||
|
|| currentBoss?.status === "defeated"
|
||||||
|
) {
|
||||||
return { ...freshBoss, bountyRunestonesClaimed: true };
|
return { ...freshBoss, bountyRunestonesClaimed: true };
|
||||||
}
|
}
|
||||||
return freshBoss;
|
return freshBoss;
|
||||||
@@ -239,11 +243,20 @@ const buildPostPrestigeState = (
|
|||||||
|
|
||||||
const prestigeState: GameState = {
|
const prestigeState: GameState = {
|
||||||
...freshState,
|
...freshState,
|
||||||
|
|
||||||
// Achievements are permanent — earned achievements survive all prestiges
|
// Achievements are permanent — earned achievements survive all prestiges
|
||||||
achievements: currentState.achievements,
|
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
|
// Boss statuses reset for gameplay, but first-kill claimed flag is preserved
|
||||||
bosses: bossesWithBountyClaimed,
|
bosses: bossesWithBountyClaimed,
|
||||||
lastTickAt: Date.now(),
|
lastTickAt: Date.now(),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Fold current-run totals into lifetime stats so the GameState reflects
|
* Fold current-run totals into lifetime stats so the GameState reflects
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -319,6 +319,32 @@ describe("buildPostPrestigeState", () => {
|
|||||||
expect(matchingBoss?.bountyRunestonesClaimed).toBe(true);
|
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", () => {
|
it("accumulates completed quests into lifetime total", () => {
|
||||||
const quest = {
|
const quest = {
|
||||||
id: "q_1",
|
id: "q_1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@elysium/web",
|
"name": "@elysium/web",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -31,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",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -59,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",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -111,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",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -142,6 +158,15 @@ const howToPlay = [
|
|||||||
+ " is visible on your public profile page.",
|
+ " is visible on your public profile page.",
|
||||||
title: "📋 Character Sheet",
|
title: "📋 Character Sheet",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
body:
|
||||||
|
"Customise your adventurer's appearance from the Character tab. Choose"
|
||||||
|
+ " your skin tone, hair style, hair colour, outfit, and accessory."
|
||||||
|
+ " Your paper doll is displayed in the sidebar for you to see at all"
|
||||||
|
+ " times. Appearance settings are purely cosmetic and persist through"
|
||||||
|
+ " prestige and transcendence resets.",
|
||||||
|
title: "🎨 Paper Doll",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
body:
|
body:
|
||||||
"Earn Titles by reaching milestones — defeating bosses, completing"
|
"Earn Titles by reaching milestones — defeating bosses, completing"
|
||||||
@@ -154,10 +179,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",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -181,14 +208,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:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ 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 { 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.
|
||||||
@@ -54,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,14 +105,18 @@ 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
|
||||||
@@ -88,6 +133,19 @@ const AchievementCard = ({
|
|||||||
<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">
|
||||||
{"💎 +"}
|
{"💎 +"}
|
||||||
@@ -163,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)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ const BossPanel = (): JSX.Element => {
|
|||||||
toggleAutoBoss,
|
toggleAutoBoss,
|
||||||
autoBossLastResult,
|
autoBossLastResult,
|
||||||
autoBossError,
|
autoBossError,
|
||||||
|
bossError,
|
||||||
} = useGame();
|
} = useGame();
|
||||||
const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
|
const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
@@ -362,6 +363,13 @@ const BossPanel = (): JSX.Element => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{bossError === null
|
||||||
|
? null
|
||||||
|
: <p className="auto-boss-error">
|
||||||
|
{"⚠️ "}
|
||||||
|
{bossError}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
{autoBossError === null
|
{autoBossError === null
|
||||||
? null
|
? null
|
||||||
: <p className="auto-boss-error">
|
: <p className="auto-boss-error">
|
||||||
|
|||||||
@@ -9,8 +9,10 @@
|
|||||||
/* eslint-disable max-statements -- Component requires many state declarations */
|
/* eslint-disable max-statements -- Component requires many state declarations */
|
||||||
/* eslint-disable max-lines -- Large component with editing and view modes */
|
/* eslint-disable max-lines -- Large component with editing and view modes */
|
||||||
import {
|
import {
|
||||||
|
defaultAppearance,
|
||||||
DEFAULT_PROFILE_SETTINGS,
|
DEFAULT_PROFILE_SETTINGS,
|
||||||
STORY_CHAPTERS,
|
STORY_CHAPTERS,
|
||||||
|
type Appearance,
|
||||||
type EquipmentBonus,
|
type EquipmentBonus,
|
||||||
type EquipmentRarity,
|
type EquipmentRarity,
|
||||||
type EquipmentType,
|
type EquipmentType,
|
||||||
@@ -87,7 +89,7 @@ const formatBonus = (bonus: EquipmentBonus): string => {
|
|||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
*/
|
*/
|
||||||
const CharacterSheetPanel = (): JSX.Element => {
|
const CharacterSheetPanel = (): JSX.Element => {
|
||||||
const { state, loginStreak } = useGame();
|
const { state, loginStreak, updateAppearance } = useGame();
|
||||||
const player = state?.player;
|
const player = state?.player;
|
||||||
|
|
||||||
const [ sheet, setSheet ] = useState<CharacterSheetData>(emptySheet);
|
const [ sheet, setSheet ] = useState<CharacterSheetData>(emptySheet);
|
||||||
@@ -276,6 +278,35 @@ const CharacterSheetPanel = (): JSX.Element => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentAppearance = state?.appearance ?? defaultAppearance;
|
||||||
|
|
||||||
|
function handleAppearanceChange(
|
||||||
|
field: keyof Appearance,
|
||||||
|
value: string,
|
||||||
|
): void {
|
||||||
|
updateAppearance({ ...currentAppearance, [field]: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSkinToneChange(event: ChangeEvent<HTMLSelectElement>): void {
|
||||||
|
handleAppearanceChange("skinTone", event.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHairStyleChange(event: ChangeEvent<HTMLSelectElement>): void {
|
||||||
|
handleAppearanceChange("hairStyle", event.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHairColourChange(event: ChangeEvent<HTMLSelectElement>): void {
|
||||||
|
handleAppearanceChange("hairColour", event.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOutfitChange(event: ChangeEvent<HTMLSelectElement>): void {
|
||||||
|
handleAppearanceChange("outfit", event.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAccessoryChange(event: ChangeEvent<HTMLSelectElement>): void {
|
||||||
|
handleAppearanceChange("accessory", event.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
@@ -573,6 +604,116 @@ const CharacterSheetPanel = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="character-sheet-section">
|
||||||
|
<h3 className="character-sheet-section-title">
|
||||||
|
{"🎨 Appearance"}
|
||||||
|
</h3>
|
||||||
|
<p className="character-sheet-hint">
|
||||||
|
{"Customise your adventurer's look. Changes save automatically."}
|
||||||
|
</p>
|
||||||
|
<div className="appearance-editor">
|
||||||
|
<label
|
||||||
|
className="character-sheet-label"
|
||||||
|
htmlFor="appearance-skin-tone"
|
||||||
|
>
|
||||||
|
{"Skin Tone"}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="character-sheet-select"
|
||||||
|
id="appearance-skin-tone"
|
||||||
|
onChange={handleSkinToneChange}
|
||||||
|
value={currentAppearance.skinTone}
|
||||||
|
>
|
||||||
|
<option value="pale">{"Pale"}</option>
|
||||||
|
<option value="light">{"Light"}</option>
|
||||||
|
<option value="tan">{"Tan"}</option>
|
||||||
|
<option value="medium">{"Medium"}</option>
|
||||||
|
<option value="dark">{"Dark"}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label
|
||||||
|
className="character-sheet-label"
|
||||||
|
htmlFor="appearance-hair-style"
|
||||||
|
>
|
||||||
|
{"Hair Style"}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="character-sheet-select"
|
||||||
|
id="appearance-hair-style"
|
||||||
|
onChange={handleHairStyleChange}
|
||||||
|
value={currentAppearance.hairStyle}
|
||||||
|
>
|
||||||
|
<option value="short">{"Short"}</option>
|
||||||
|
<option value="shoulder">{"Shoulder-length"}</option>
|
||||||
|
<option value="long">{"Long"}</option>
|
||||||
|
<option value="ponytail">{"Ponytail"}</option>
|
||||||
|
<option value="twintails">{"Twin Tails"}</option>
|
||||||
|
<option value="bun">{"Bun"}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label
|
||||||
|
className="character-sheet-label"
|
||||||
|
htmlFor="appearance-hair-colour"
|
||||||
|
>
|
||||||
|
{"Hair Colour"}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="character-sheet-select"
|
||||||
|
id="appearance-hair-colour"
|
||||||
|
onChange={handleHairColourChange}
|
||||||
|
value={currentAppearance.hairColour}
|
||||||
|
>
|
||||||
|
<option value="brown">{"Brown"}</option>
|
||||||
|
<option value="black">{"Black"}</option>
|
||||||
|
<option value="blonde">{"Blonde"}</option>
|
||||||
|
<option value="red">{"Red"}</option>
|
||||||
|
<option value="auburn">{"Auburn"}</option>
|
||||||
|
<option value="silver">{"Silver"}</option>
|
||||||
|
<option value="blue">{"Blue"}</option>
|
||||||
|
<option value="purple">{"Purple"}</option>
|
||||||
|
<option value="pink">{"Pink"}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label
|
||||||
|
className="character-sheet-label"
|
||||||
|
htmlFor="appearance-outfit"
|
||||||
|
>
|
||||||
|
{"Outfit"}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="character-sheet-select"
|
||||||
|
id="appearance-outfit"
|
||||||
|
onChange={handleOutfitChange}
|
||||||
|
value={currentAppearance.outfit}
|
||||||
|
>
|
||||||
|
<option value="warrior">{"Warrior"}</option>
|
||||||
|
<option value="mage">{"Mage"}</option>
|
||||||
|
<option value="rogue">{"Rogue"}</option>
|
||||||
|
<option value="archer">{"Archer"}</option>
|
||||||
|
<option value="bard">{"Bard"}</option>
|
||||||
|
<option value="ranger">{"Ranger"}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label
|
||||||
|
className="character-sheet-label"
|
||||||
|
htmlFor="appearance-accessory"
|
||||||
|
>
|
||||||
|
{"Accessory"}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="character-sheet-select"
|
||||||
|
id="appearance-accessory"
|
||||||
|
onChange={handleAccessoryChange}
|
||||||
|
value={currentAppearance.accessory}
|
||||||
|
>
|
||||||
|
<option value="none">{"None"}</option>
|
||||||
|
<option value="glasses">{"Glasses"}</option>
|
||||||
|
<option value="hat">{"Hat"}</option>
|
||||||
|
<option value="cape">{"Cape"}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="character-sheet-section">
|
<div className="character-sheet-section">
|
||||||
<h3 className="character-sheet-section-title">{"🗡️ Equipment"}</h3>
|
<h3 className="character-sheet-section-title">{"🗡️ Equipment"}</h3>
|
||||||
{sheet.equippedItems.length > 0
|
{sheet.equippedItems.length > 0
|
||||||
|
|||||||
@@ -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,6 +7,7 @@
|
|||||||
/* 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";
|
||||||
@@ -30,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);
|
||||||
@@ -188,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",
|
||||||
@@ -261,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);
|
||||||
@@ -320,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,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
/* 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";
|
||||||
@@ -46,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);
|
||||||
};
|
};
|
||||||
@@ -81,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;
|
||||||
@@ -210,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) => {
|
||||||
@@ -217,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 {
|
||||||
@@ -276,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,6 +23,7 @@ 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";
|
||||||
@@ -30,6 +31,7 @@ import { LoginBonusModal } from "./loginBonusModal.js";
|
|||||||
import { MilestoneToast } from "./milestoneToast.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 { PaperDoll } from "./paperDoll.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 { QuestCompleteToast, QuestFailedToast } from "./questToast.js";
|
||||||
@@ -57,7 +59,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" },
|
||||||
@@ -78,6 +81,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" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -190,6 +194,7 @@ const GameLayout = (): JSX.Element => {
|
|||||||
<aside className="game-sidebar">
|
<aside className="game-sidebar">
|
||||||
<ClickArea />
|
<ClickArea />
|
||||||
<div id="tree-nation-offset-website" />
|
<div id="tree-nation-offset-website" />
|
||||||
|
<PaperDoll />
|
||||||
<p className="game-copyright">{"© NHCarrigan"}</p>
|
<p className="game-copyright">{"© NHCarrigan"}</p>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -242,6 +247,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,89 @@
|
|||||||
|
/**
|
||||||
|
* @file Paper doll component for displaying layered adventurer appearance.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Hikari
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
defaultAppearance,
|
||||||
|
type HairColour,
|
||||||
|
type SkinTone,
|
||||||
|
} from "@elysium/types";
|
||||||
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
|
import type { JSX } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS filter strings for each skin tone, applied to the base body layer.
|
||||||
|
* Uses brightness + sepia + saturation to shift the neutral base skin.
|
||||||
|
*/
|
||||||
|
const skinToneFilters: Record<SkinTone, string> = {
|
||||||
|
dark: "brightness(0.55) saturate(0.55) sepia(0.5) contrast(1.1)",
|
||||||
|
light: "brightness(0.98) saturate(0.4) sepia(0.12)",
|
||||||
|
medium: "brightness(0.74) saturate(0.75) sepia(0.42)",
|
||||||
|
pale: "brightness(1.05) saturate(0.2) sepia(0.08)",
|
||||||
|
tan: "brightness(0.88) saturate(0.65) sepia(0.28)",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS filter strings for each hair colour.
|
||||||
|
* Applied to the greyscale hair layer via sepia + hue-rotate tinting.
|
||||||
|
*/
|
||||||
|
const hairColourFilters: Record<HairColour, string> = {
|
||||||
|
auburn: "sepia(1) saturate(3) hue-rotate(350deg)",
|
||||||
|
black: "brightness(0.15)",
|
||||||
|
blonde: "sepia(1) saturate(3) hue-rotate(5deg) brightness(1.6)",
|
||||||
|
blue: "sepia(1) saturate(5) hue-rotate(190deg)",
|
||||||
|
brown: "sepia(1) saturate(2) hue-rotate(0deg)",
|
||||||
|
pink: "sepia(1) saturate(5) hue-rotate(305deg)",
|
||||||
|
purple: "sepia(1) saturate(5) hue-rotate(245deg)",
|
||||||
|
red: "sepia(1) saturate(4) hue-rotate(345deg)",
|
||||||
|
silver: "grayscale(1) brightness(1.9) contrast(0.8)",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the paper doll — a layered composite of body, outfit, hair, and
|
||||||
|
* accessory images that together represent the player's adventurer appearance.
|
||||||
|
* All layers use mix-blend-mode: multiply so white backgrounds become
|
||||||
|
* transparent, allowing the layers to composite cleanly.
|
||||||
|
* @returns The JSX element.
|
||||||
|
*/
|
||||||
|
const PaperDoll = (): JSX.Element => {
|
||||||
|
const { state } = useGame();
|
||||||
|
const appearance = state?.appearance ?? defaultAppearance;
|
||||||
|
const { skinTone, hairStyle, hairColour, outfit, accessory } = appearance;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="paper-doll">
|
||||||
|
{/* Base body — skin-toneable */}
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
className="paper-doll-layer paper-doll-body"
|
||||||
|
src={cdnImage("paper-doll", "body")}
|
||||||
|
style={{ filter: skinToneFilters[skinTone] }}
|
||||||
|
/>
|
||||||
|
{/* Outfit layer */}
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
className="paper-doll-layer paper-doll-outfit"
|
||||||
|
src={cdnImage("paper-doll", `outfit-${outfit}`)}
|
||||||
|
/>
|
||||||
|
{/* Hair layer — colour-tintable greyscale */}
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
className="paper-doll-layer paper-doll-hair"
|
||||||
|
src={cdnImage("paper-doll", `hair-${hairStyle}`)}
|
||||||
|
style={{ filter: hairColourFilters[hairColour] }}
|
||||||
|
/>
|
||||||
|
{accessory === "none"
|
||||||
|
? null
|
||||||
|
: <img
|
||||||
|
alt=""
|
||||||
|
className="paper-doll-layer paper-doll-accessory"
|
||||||
|
src={cdnImage("paper-doll", `accessory-${accessory}`)}
|
||||||
|
/>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { PaperDoll };
|
||||||
@@ -89,6 +89,7 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
buyPrestigeUpgrade,
|
buyPrestigeUpgrade,
|
||||||
enableNotifications,
|
enableNotifications,
|
||||||
enableSounds,
|
enableSounds,
|
||||||
|
toggleAutoAdventurer,
|
||||||
toggleAutoPrestige,
|
toggleAutoPrestige,
|
||||||
triggerPrestigeToast,
|
triggerPrestigeToast,
|
||||||
} = useGame();
|
} = useGame();
|
||||||
@@ -110,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(
|
||||||
@@ -173,6 +174,10 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
void handlePrestige();
|
void handlePrestige();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAutoAdventurerToggle(): void {
|
||||||
|
toggleAutoAdventurer();
|
||||||
|
}
|
||||||
|
|
||||||
function handleAutoPrestigeToggle(): void {
|
function handleAutoPrestigeToggle(): void {
|
||||||
toggleAutoPrestige();
|
toggleAutoPrestige();
|
||||||
}
|
}
|
||||||
@@ -347,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
|
||||||
@@ -381,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 ${
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
/* eslint-disable max-statements -- Many local variables needed for quest state */
|
/* eslint-disable max-statements -- Many local variables needed for quest state */
|
||||||
import { useState, type JSX } from "react";
|
import { useState, type JSX } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { zoneFailureChance } from "../../engine/tick.js";
|
||||||
import { cdnImage } from "../../utils/cdn.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";
|
||||||
@@ -143,8 +144,18 @@ const QuestCard = ({
|
|||||||
: null}
|
: null}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
{quest.status === "available"
|
||||||
|
&& <p className="quest-failure-chance">
|
||||||
|
{"🎲 "}
|
||||||
|
{String(Math.round((zoneFailureChance[quest.zoneId] ?? 0) * 100))}
|
||||||
|
{"% failure chance — if failed, the quest resets"}
|
||||||
|
{" and must be retried."}
|
||||||
|
</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
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ 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 { 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;
|
||||||
@@ -20,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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,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 = ({
|
||||||
@@ -40,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
|
||||||
@@ -64,6 +72,13 @@ const UpgradeCard = ({
|
|||||||
{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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -79,6 +94,13 @@ const UpgradeCard = ({
|
|||||||
<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}
|
||||||
@@ -130,6 +152,13 @@ const UpgradeCard = ({
|
|||||||
{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}
|
||||||
@@ -181,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;
|
||||||
});
|
});
|
||||||
@@ -232,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!"}
|
||||||
@@ -240,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}
|
||||||
@@ -253,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}
|
||||||
@@ -267,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}
|
||||||
|
|||||||
@@ -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 "}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import {
|
import {
|
||||||
STORY_CHAPTERS,
|
STORY_CHAPTERS,
|
||||||
type Achievement,
|
type Achievement,
|
||||||
|
type Appearance,
|
||||||
type ApotheosisResponse,
|
type ApotheosisResponse,
|
||||||
type BossChallengeResponse,
|
type BossChallengeResponse,
|
||||||
type ExploreCollectResponse,
|
type ExploreCollectResponse,
|
||||||
@@ -42,6 +43,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,
|
||||||
@@ -50,7 +53,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,
|
||||||
@@ -446,6 +448,17 @@ interface GameContextValue {
|
|||||||
*/
|
*/
|
||||||
toggleAutoBoss: ()=> void;
|
toggleAutoBoss: ()=> void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the auto-adventurer setting on/off (requires auto_adventurer prestige upgrade).
|
||||||
|
*/
|
||||||
|
toggleAutoAdventurer: ()=> void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the player's paper doll appearance customisation.
|
||||||
|
* @param appearance - The new appearance settings.
|
||||||
|
*/
|
||||||
|
updateAppearance: (appearance: Appearance)=> void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue of newly unlocked codex entry IDs (for toast notifications).
|
* Queue of newly unlocked codex entry IDs (for toast notifications).
|
||||||
*/
|
*/
|
||||||
@@ -546,6 +559,24 @@ interface GameContextValue {
|
|||||||
*/
|
*/
|
||||||
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
|
* Last auto-boss fight result — null until the first auto fight completes or
|
||||||
* when auto-boss is toggled off.
|
* when auto-boss is toggled off.
|
||||||
@@ -557,6 +588,12 @@ interface GameContextValue {
|
|||||||
* when no error). Cleared automatically when the player re-enables auto-boss.
|
* when no error). Cleared automatically when the player re-enables auto-boss.
|
||||||
*/
|
*/
|
||||||
autoBossError: string | null;
|
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 {
|
||||||
@@ -606,6 +643,7 @@ export const GameProvider = ({
|
|||||||
at: number;
|
at: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [ autoBossError, setAutoBossError ] = useState<string | 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,
|
||||||
);
|
);
|
||||||
@@ -1047,6 +1085,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) => {
|
||||||
@@ -1157,9 +1231,12 @@ export const GameProvider = ({
|
|||||||
) {
|
) {
|
||||||
signatureReference.current = null;
|
signatureReference.current = null;
|
||||||
localStorage.removeItem("elysium_save_signature");
|
localStorage.removeItem("elysium_save_signature");
|
||||||
} else {
|
|
||||||
logError("auto_save", error_);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Network failures during background auto-save are expected on
|
||||||
|
* flaky connections — the next tick will retry, so no telemetry needed
|
||||||
|
*/
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1187,10 +1264,9 @@ export const GameProvider = ({
|
|||||||
}
|
}
|
||||||
await reloadReference.current();
|
await reloadReference.current();
|
||||||
}).
|
}).
|
||||||
catch((error_: unknown) => {
|
catch(() => {
|
||||||
logError("auto_prestige", error_);
|
|
||||||
|
|
||||||
/* Silently ignore — will retry next tick */
|
/* Silently ignore — eligibility is re-checked every tick */
|
||||||
}).
|
}).
|
||||||
finally(() => {
|
finally(() => {
|
||||||
isAutoPrestigingReference.current = false;
|
isAutoPrestigingReference.current = false;
|
||||||
@@ -1240,11 +1316,18 @@ export const GameProvider = ({
|
|||||||
});
|
});
|
||||||
}).
|
}).
|
||||||
catch((error_: unknown) => {
|
catch((error_: unknown) => {
|
||||||
logError("auto_boss", error_);
|
|
||||||
const message
|
const message
|
||||||
= error_ instanceof Error
|
= error_ instanceof Error
|
||||||
? error_.message
|
? error_.message
|
||||||
: String(error_);
|
: String(error_);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* "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);
|
setAutoBossError(message);
|
||||||
setState((previous) => {
|
setState((previous) => {
|
||||||
if (previous === null) {
|
if (previous === null) {
|
||||||
@@ -1642,118 +1725,104 @@ export const GameProvider = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startExploration = useCallback(async(areaId: string) => {
|
const startExploration = useCallback(async(areaId: string) => {
|
||||||
try {
|
const response = await startExplorationApi({ areaId });
|
||||||
const response = await startExplorationApi({ areaId });
|
setState((previous) => {
|
||||||
const areaData = EXPLORATION_AREAS.find((a) => {
|
if (previous?.exploration === undefined) {
|
||||||
return a.id === areaId;
|
return previous;
|
||||||
});
|
|
||||||
if (areaData === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
|
return {
|
||||||
const startedAt = response.endsAt - areaData.durationSeconds * 1000;
|
...previous,
|
||||||
|
exploration: {
|
||||||
|
...previous.exploration,
|
||||||
|
areas: previous.exploration.areas.map((a) => {
|
||||||
|
return a.id === areaId
|
||||||
|
? {
|
||||||
|
...a,
|
||||||
|
endsAt: response.endsAt,
|
||||||
|
status: "in_progress" as const,
|
||||||
|
}
|
||||||
|
: a;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const collectExploration = useCallback(
|
||||||
|
async(areaId: string): Promise<ExploreCollectResponse> => {
|
||||||
|
const result = await collectExplorationApi({ areaId });
|
||||||
setState((previous) => {
|
setState((previous) => {
|
||||||
if (previous?.exploration === undefined) {
|
if (previous?.exploration === undefined) {
|
||||||
return previous;
|
return previous;
|
||||||
}
|
}
|
||||||
|
let materials = [ ...previous.exploration.materials ];
|
||||||
|
|
||||||
|
// Apply material drops from the random loot roll
|
||||||
|
for (const drop of result.materialsFound) {
|
||||||
|
const existing = materials.find((mat) => {
|
||||||
|
return mat.materialId === drop.materialId;
|
||||||
|
});
|
||||||
|
if (existing === undefined) {
|
||||||
|
materials = [
|
||||||
|
...materials,
|
||||||
|
{ materialId: drop.materialId, quantity: drop.quantity },
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
materials = materials.map((mat) => {
|
||||||
|
return mat.materialId === drop.materialId
|
||||||
|
? { ...mat, quantity: mat.quantity + drop.quantity }
|
||||||
|
: mat;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply material from event (if any)
|
||||||
|
const materialGained = result.event?.materialGained;
|
||||||
|
if (materialGained !== null && materialGained !== undefined) {
|
||||||
|
const { materialId, quantity } = materialGained;
|
||||||
|
const existing = materials.find((mat) => {
|
||||||
|
return mat.materialId === materialId;
|
||||||
|
});
|
||||||
|
if (existing === undefined) {
|
||||||
|
materials = [ ...materials, { materialId, quantity } ];
|
||||||
|
} else {
|
||||||
|
materials = materials.map((mat) => {
|
||||||
|
return mat.materialId === materialId
|
||||||
|
? { ...mat, quantity: mat.quantity + quantity }
|
||||||
|
: mat;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...previous,
|
...previous,
|
||||||
exploration: {
|
exploration: {
|
||||||
...previous.exploration,
|
...previous.exploration,
|
||||||
areas: previous.exploration.areas.map((a) => {
|
areas: previous.exploration.areas.map((a) => {
|
||||||
return a.id === areaId
|
return a.id === areaId
|
||||||
? { ...a, startedAt: startedAt, status: "in_progress" as const }
|
? { ...a, completedOnce: true, status: "available" as const }
|
||||||
: a;
|
: a;
|
||||||
}),
|
}),
|
||||||
|
materials: materials,
|
||||||
|
},
|
||||||
|
player: {
|
||||||
|
...previous.player,
|
||||||
|
totalGoldEarned:
|
||||||
|
previous.player.totalGoldEarned
|
||||||
|
+ Math.max(0, result.event?.goldChange ?? 0),
|
||||||
|
},
|
||||||
|
resources: {
|
||||||
|
...previous.resources,
|
||||||
|
essence:
|
||||||
|
previous.resources.essence + (result.event?.essenceChange ?? 0),
|
||||||
|
gold: Math.max(
|
||||||
|
0,
|
||||||
|
previous.resources.gold + (result.event?.goldChange ?? 0),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch (error_: unknown) {
|
return result;
|
||||||
logError("start_exploration", error_);
|
|
||||||
throw error_;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const collectExploration = useCallback(
|
|
||||||
async(areaId: string): Promise<ExploreCollectResponse> => {
|
|
||||||
try {
|
|
||||||
const result = await collectExplorationApi({ areaId });
|
|
||||||
setState((previous) => {
|
|
||||||
if (previous?.exploration === undefined) {
|
|
||||||
return previous;
|
|
||||||
}
|
|
||||||
let materials = [ ...previous.exploration.materials ];
|
|
||||||
|
|
||||||
// Apply material drops from the random loot roll
|
|
||||||
for (const drop of result.materialsFound) {
|
|
||||||
const existing = materials.find((mat) => {
|
|
||||||
return mat.materialId === drop.materialId;
|
|
||||||
});
|
|
||||||
if (existing === undefined) {
|
|
||||||
materials = [
|
|
||||||
...materials,
|
|
||||||
{ materialId: drop.materialId, quantity: drop.quantity },
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
materials = materials.map((mat) => {
|
|
||||||
return mat.materialId === drop.materialId
|
|
||||||
? { ...mat, quantity: mat.quantity + drop.quantity }
|
|
||||||
: mat;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply material from event (if any)
|
|
||||||
const materialGained = result.event?.materialGained;
|
|
||||||
if (materialGained !== null && materialGained !== undefined) {
|
|
||||||
const { materialId, quantity } = materialGained;
|
|
||||||
const existing = materials.find((mat) => {
|
|
||||||
return mat.materialId === materialId;
|
|
||||||
});
|
|
||||||
if (existing === undefined) {
|
|
||||||
materials = [ ...materials, { materialId, quantity } ];
|
|
||||||
} else {
|
|
||||||
materials = materials.map((mat) => {
|
|
||||||
return mat.materialId === materialId
|
|
||||||
? { ...mat, quantity: mat.quantity + quantity }
|
|
||||||
: mat;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...previous,
|
|
||||||
exploration: {
|
|
||||||
...previous.exploration,
|
|
||||||
areas: previous.exploration.areas.map((a) => {
|
|
||||||
return a.id === areaId
|
|
||||||
? { ...a, completedOnce: true, status: "available" as const }
|
|
||||||
: a;
|
|
||||||
}),
|
|
||||||
materials: materials,
|
|
||||||
},
|
|
||||||
player: {
|
|
||||||
...previous.player,
|
|
||||||
totalGoldEarned:
|
|
||||||
previous.player.totalGoldEarned
|
|
||||||
+ Math.max(0, result.event?.goldChange ?? 0),
|
|
||||||
},
|
|
||||||
resources: {
|
|
||||||
...previous.resources,
|
|
||||||
essence:
|
|
||||||
previous.resources.essence + (result.event?.essenceChange ?? 0),
|
|
||||||
gold: Math.max(
|
|
||||||
0,
|
|
||||||
previous.resources.gold + (result.event?.goldChange ?? 0),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
} catch (error_: unknown) {
|
|
||||||
logError("collect_exploration", error_);
|
|
||||||
throw error_;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -1836,6 +1905,27 @@ export const GameProvider = ({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const toggleAutoAdventurer = useCallback(() => {
|
||||||
|
setState((previous) => {
|
||||||
|
if (previous === null) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...previous,
|
||||||
|
autoAdventurer: previous.autoAdventurer !== true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateAppearance = useCallback((appearance: Appearance) => {
|
||||||
|
setState((previous) => {
|
||||||
|
if (previous === null) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
return { ...previous, appearance };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const setActiveCompanion = useCallback((companionId: string | null) => {
|
const setActiveCompanion = useCallback((companionId: string | null) => {
|
||||||
setState((previous) => {
|
setState((previous) => {
|
||||||
if (previous === null) {
|
if (previous === null) {
|
||||||
@@ -1867,6 +1957,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) => {
|
||||||
@@ -1877,10 +1975,23 @@ export const GameProvider = ({
|
|||||||
});
|
});
|
||||||
setBattleResult({ bossName: boss.name, result: result });
|
setBattleResult({ bossName: boss.name, result: result });
|
||||||
} catch (error_: unknown) {
|
} catch (error_: unknown) {
|
||||||
logError("challenge_boss", error_);
|
const bossErrorMessage
|
||||||
// Silently ignore — server errors shouldn't crash the UI
|
= error_ instanceof Error
|
||||||
|
? error_.message
|
||||||
|
: "Failed to challenge boss";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* "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_);
|
||||||
|
}
|
||||||
|
setBossError(
|
||||||
|
bossErrorMessage,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [ forceSync ]);
|
||||||
|
|
||||||
const dismissOfflineGold = useCallback(() => {
|
const dismissOfflineGold = useCallback(() => {
|
||||||
setOfflineGold(0);
|
setOfflineGold(0);
|
||||||
@@ -2006,6 +2117,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);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -2023,6 +2189,7 @@ export const GameProvider = ({
|
|||||||
autoBossError,
|
autoBossError,
|
||||||
autoBossLastResult,
|
autoBossLastResult,
|
||||||
battleResult,
|
battleResult,
|
||||||
|
bossError,
|
||||||
buyAdventurer,
|
buyAdventurer,
|
||||||
buyEchoUpgrade,
|
buyEchoUpgrade,
|
||||||
buyEquipment,
|
buyEquipment,
|
||||||
@@ -2034,6 +2201,7 @@ export const GameProvider = ({
|
|||||||
completedQuestToasts,
|
completedQuestToasts,
|
||||||
craftRecipe,
|
craftRecipe,
|
||||||
currentSchemaVersion,
|
currentSchemaVersion,
|
||||||
|
debugHardReset,
|
||||||
dismissAchievement,
|
dismissAchievement,
|
||||||
dismissApotheosisToast,
|
dismissApotheosisToast,
|
||||||
dismissBattle,
|
dismissBattle,
|
||||||
@@ -2052,6 +2220,7 @@ export const GameProvider = ({
|
|||||||
failedQuestToasts,
|
failedQuestToasts,
|
||||||
flushBossLoreToasts,
|
flushBossLoreToasts,
|
||||||
forceSync,
|
forceSync,
|
||||||
|
forceUnlocks,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
handleClick,
|
handleClick,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -2077,6 +2246,7 @@ export const GameProvider = ({
|
|||||||
startQuest,
|
startQuest,
|
||||||
state,
|
state,
|
||||||
syncError,
|
syncError,
|
||||||
|
toggleAutoAdventurer,
|
||||||
toggleAutoBoss,
|
toggleAutoBoss,
|
||||||
toggleAutoPrestige,
|
toggleAutoPrestige,
|
||||||
toggleAutoQuest,
|
toggleAutoQuest,
|
||||||
@@ -2085,12 +2255,14 @@ export const GameProvider = ({
|
|||||||
unlockedAchievements,
|
unlockedAchievements,
|
||||||
unlockedCodexEntryIds,
|
unlockedCodexEntryIds,
|
||||||
unlockedStoryChapterIds,
|
unlockedStoryChapterIds,
|
||||||
|
updateAppearance,
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
apotheosis,
|
apotheosis,
|
||||||
autoBossError,
|
autoBossError,
|
||||||
autoBossLastResult,
|
autoBossLastResult,
|
||||||
battleResult,
|
battleResult,
|
||||||
|
bossError,
|
||||||
completedQuestToasts,
|
completedQuestToasts,
|
||||||
failedQuestToasts,
|
failedQuestToasts,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
@@ -2104,6 +2276,7 @@ export const GameProvider = ({
|
|||||||
completeChapter,
|
completeChapter,
|
||||||
craftRecipe,
|
craftRecipe,
|
||||||
currentSchemaVersion,
|
currentSchemaVersion,
|
||||||
|
debugHardReset,
|
||||||
dismissAchievement,
|
dismissAchievement,
|
||||||
dismissApotheosisToast,
|
dismissApotheosisToast,
|
||||||
dismissBattle,
|
dismissBattle,
|
||||||
@@ -2121,6 +2294,7 @@ export const GameProvider = ({
|
|||||||
error,
|
error,
|
||||||
flushBossLoreToasts,
|
flushBossLoreToasts,
|
||||||
forceSync,
|
forceSync,
|
||||||
|
forceUnlocks,
|
||||||
handleClick,
|
handleClick,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSyncing,
|
isSyncing,
|
||||||
@@ -2145,6 +2319,7 @@ export const GameProvider = ({
|
|||||||
startQuest,
|
startQuest,
|
||||||
state,
|
state,
|
||||||
syncError,
|
syncError,
|
||||||
|
toggleAutoAdventurer,
|
||||||
toggleAutoBoss,
|
toggleAutoBoss,
|
||||||
toggleAutoPrestige,
|
toggleAutoPrestige,
|
||||||
toggleAutoQuest,
|
toggleAutoQuest,
|
||||||
@@ -2153,6 +2328,7 @@ export const GameProvider = ({
|
|||||||
unlockedAchievements,
|
unlockedAchievements,
|
||||||
unlockedCodexEntryIds,
|
unlockedCodexEntryIds,
|
||||||
unlockedStoryChapterIds,
|
unlockedStoryChapterIds,
|
||||||
|
updateAppearance,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -4515,3 +4515,84 @@ body::before {
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
width: 80px;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "elysium",
|
"name": "elysium",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"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.1",
|
"version": "0.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./prod/src/index.js",
|
"main": "./prod/src/index.js",
|
||||||
|
|||||||
@@ -5,6 +5,15 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
export type { ApotheosisData } from "./interfaces/apotheosis.js";
|
export type { ApotheosisData } from "./interfaces/apotheosis.js";
|
||||||
|
export type {
|
||||||
|
Accessory,
|
||||||
|
Appearance,
|
||||||
|
HairColour,
|
||||||
|
HairStyle,
|
||||||
|
Outfit,
|
||||||
|
SkinTone,
|
||||||
|
} from "./interfaces/appearance.js";
|
||||||
|
export { defaultAppearance } from "./interfaces/appearance.js";
|
||||||
export type {
|
export type {
|
||||||
Companion,
|
Companion,
|
||||||
CompanionBonus,
|
CompanionBonus,
|
||||||
@@ -60,6 +69,7 @@ export type {
|
|||||||
ExploreCollectResponse,
|
ExploreCollectResponse,
|
||||||
ExploreStartRequest,
|
ExploreStartRequest,
|
||||||
ExploreStartResponse,
|
ExploreStartResponse,
|
||||||
|
ForceUnlocksResponse,
|
||||||
GiteaRelease,
|
GiteaRelease,
|
||||||
LeaderboardCategory,
|
LeaderboardCategory,
|
||||||
LeaderboardEntry,
|
LeaderboardEntry,
|
||||||
|
|||||||
@@ -398,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,
|
||||||
@@ -417,6 +450,7 @@ export type {
|
|||||||
ExploreCollectResponse,
|
ExploreCollectResponse,
|
||||||
ExploreStartRequest,
|
ExploreStartRequest,
|
||||||
ExploreStartResponse,
|
ExploreStartResponse,
|
||||||
|
ForceUnlocksResponse,
|
||||||
GiteaRelease,
|
GiteaRelease,
|
||||||
LeaderboardCategory,
|
LeaderboardCategory,
|
||||||
LeaderboardEntry,
|
LeaderboardEntry,
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* @file Appearance type for the paper doll customisation system.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Hikari
|
||||||
|
*/
|
||||||
|
|
||||||
|
type SkinTone = "pale" | "light" | "tan" | "medium" | "dark";
|
||||||
|
|
||||||
|
type HairStyle =
|
||||||
|
| "short"
|
||||||
|
| "shoulder"
|
||||||
|
| "long"
|
||||||
|
| "ponytail"
|
||||||
|
| "twintails"
|
||||||
|
| "bun";
|
||||||
|
|
||||||
|
type HairColour =
|
||||||
|
| "brown"
|
||||||
|
| "black"
|
||||||
|
| "blonde"
|
||||||
|
| "red"
|
||||||
|
| "auburn"
|
||||||
|
| "silver"
|
||||||
|
| "blue"
|
||||||
|
| "purple"
|
||||||
|
| "pink";
|
||||||
|
|
||||||
|
type Outfit = "warrior" | "mage" | "rogue" | "archer" | "bard" | "ranger";
|
||||||
|
|
||||||
|
type Accessory = "none" | "glasses" | "hat" | "cape";
|
||||||
|
|
||||||
|
interface Appearance {
|
||||||
|
skinTone: SkinTone;
|
||||||
|
hairStyle: HairStyle;
|
||||||
|
hairColour: HairColour;
|
||||||
|
outfit: Outfit;
|
||||||
|
accessory: Accessory;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultAppearance: Appearance = {
|
||||||
|
accessory: "none",
|
||||||
|
hairColour: "brown",
|
||||||
|
hairStyle: "short",
|
||||||
|
outfit: "warrior",
|
||||||
|
skinTone: "pale",
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { Accessory, Appearance, HairColour, HairStyle, Outfit, SkinTone };
|
||||||
|
export { defaultAppearance };
|
||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import type { Achievement } from "./achievement.js";
|
import type { Achievement } from "./achievement.js";
|
||||||
import type { Adventurer } from "./adventurer.js";
|
import type { Adventurer } from "./adventurer.js";
|
||||||
import type { ApotheosisData } from "./apotheosis.js";
|
import type { ApotheosisData } from "./apotheosis.js";
|
||||||
|
import type { Appearance } from "./appearance.js";
|
||||||
import type { Boss } from "./boss.js";
|
import type { Boss } from "./boss.js";
|
||||||
import type { CodexState } from "./codex.js";
|
import type { CodexState } from "./codex.js";
|
||||||
import type { CompanionState } from "./companion.js";
|
import type { CompanionState } from "./companion.js";
|
||||||
@@ -79,6 +80,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.
|
||||||
*/
|
*/
|
||||||
@@ -93,6 +99,12 @@ interface GameState {
|
|||||||
* Schema version — used to detect saves from older game versions.
|
* Schema version — used to detect saves from older game versions.
|
||||||
*/
|
*/
|
||||||
schemaVersion?: number;
|
schemaVersion?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paper doll appearance customisation — optional for backwards compatibility.
|
||||||
|
* Persists across prestige and transcendence resets.
|
||||||
|
*/
|
||||||
|
appearance?: Appearance;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { GameState };
|
export type { GameState };
|
||||||
|
|||||||
Generated
+14
-3
@@ -10,10 +10,10 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
'@nhcarrigan/typescript-config':
|
'@nhcarrigan/typescript-config':
|
||||||
specifier: 4.0.0
|
specifier: 4.0.0
|
||||||
version: 4.0.0(typescript@5.8.2)
|
version: 4.0.0(typescript@5.9.3)
|
||||||
typescript:
|
typescript:
|
||||||
specifier: 5.8.2
|
specifier: 5.9.3
|
||||||
version: 5.8.2
|
version: 5.9.3
|
||||||
|
|
||||||
apps/api:
|
apps/api:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2833,6 +2833,11 @@ packages:
|
|||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
typescript@5.9.3:
|
||||||
|
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||||
|
engines: {node: '>=14.17'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
unbox-primitive@1.1.0:
|
unbox-primitive@1.1.0:
|
||||||
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3502,6 +3507,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
typescript: 5.8.2
|
typescript: 5.8.2
|
||||||
|
|
||||||
|
'@nhcarrigan/typescript-config@4.0.0(typescript@5.9.3)':
|
||||||
|
dependencies:
|
||||||
|
typescript: 5.9.3
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nodelib/fs.stat': 2.0.5
|
'@nodelib/fs.stat': 2.0.5
|
||||||
@@ -6138,6 +6147,8 @@ snapshots:
|
|||||||
|
|
||||||
typescript@5.8.2: {}
|
typescript@5.8.2: {}
|
||||||
|
|
||||||
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
unbox-primitive@1.1.0:
|
unbox-primitive@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bound: 1.0.4
|
call-bound: 1.0.4
|
||||||
|
|||||||
Reference in New Issue
Block a user