2 Commits

Author SHA1 Message Date
hikari 9fcc5bb836 feat: persist zone selection across panel navigation in sessionStorage
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m2s
CI / Lint, Build & Test (pull_request) Successful in 1m7s
Both the boss panel and the quest panel now read their active zone from
sessionStorage on mount and write back to it whenever the user selects
a new zone. The stored selections are cleared automatically when the
session ends, and fall back to verdant_vale when no stored value exists.

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

Closes #35
2026-03-09 22:02:52 -07:00
40 changed files with 198 additions and 2521 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ Game art is generated via the Gemini API (`gemini-3-pro-image-preview`, ~$0.134/
### Process
1. Generate images with `curl` to `https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent?key=<API_KEY>`, requesting soft-shaded anime style
2. Save responses to `/home/naomi/code/naomi/elysium/img/<category>/<id>.jpg`
3. Upload to R2 with the AWS CLI — credentials are in the global `~/.claude/CLAUDE.md` (never commit them here)
3. Upload to R2 with: `AWS_ACCESS_KEY_ID=dd0a3d73969143ada84d50f8940cc5e2 AWS_SECRET_ACCESS_KEY=f73e9907da1b2297e93e17f786d6446d33d4ac60e185879578a0d5020899b18e aws s3 sync img/ s3://nhcarrigan-cdn/elysium/ --endpoint-url https://751c386661d378cc032093493cfb0869.r2.cloudflarestorage.com`
4. Delete the local `img/` directory before committing (images live on CDN only)
### CDN URL Helper
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@elysium/api",
"version": "0.2.1",
"version": "0.1.1",
"private": true,
"type": "module",
"main": "./prod/src/index.js",
+9 -9
View File
@@ -101,7 +101,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "weapon",
},
{
bonus: { combatMultiplier: 3.25 },
bonus: { combatMultiplier: 2.75 },
cost: { crystals: 500, essence: 2000, gold: 0 },
description:
"A blade made of compressed nothingness. It does not cut — it simply unmakes.",
@@ -204,7 +204,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "armour",
},
{
bonus: { goldMultiplier: 2.75 },
bonus: { goldMultiplier: 2.25 },
description:
"Woven from threads of pure starlight harvested by the Astral Wraith. Income flows like cosmic energy.",
equipped: false,
@@ -305,7 +305,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "trinket",
},
{
bonus: { clickMultiplier: 2.25, goldMultiplier: 1.25 },
bonus: { clickMultiplier: 2, goldMultiplier: 1.25 },
description:
"The legendary stone that grants mastery over gold and combat alike.",
equipped: false,
@@ -316,7 +316,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "trinket",
},
{
bonus: { clickMultiplier: 2.25, goldMultiplier: 1.25 },
bonus: { clickMultiplier: 2.25, goldMultiplier: 1.15 },
description:
"A flame that has never been extinguished, sealed in crystal by the Phoenix Lord. It burns with the power of rebirth.",
equipped: false,
@@ -697,7 +697,7 @@ export const defaultEquipment: Array<Equipment> = [
},
// ── Purchasable endgame sinks ─────────────────────────────────────────────
{
bonus: { clickMultiplier: 3 },
bonus: { clickMultiplier: 2.5 },
cost: { crystals: 0, essence: 20_000_000, gold: 0 },
description:
"A lens of compressed celestial light that sharpens every strike with divine precision.",
@@ -709,7 +709,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "trinket",
},
{
bonus: { goldMultiplier: 3.75 },
bonus: { goldMultiplier: 3 },
cost: { crystals: 0, essence: 50_000_000, gold: 0 },
description:
"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",
},
{
bonus: { combatMultiplier: 7 },
bonus: { combatMultiplier: 4 },
cost: { crystals: 0, essence: 100_000_000, gold: 0 },
description:
"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",
},
{
bonus: { clickMultiplier: 4, goldMultiplier: 1.5 },
bonus: { clickMultiplier: 3.5, goldMultiplier: 1.5 },
cost: { crystals: 5_000_000, essence: 0, gold: 0 },
description:
"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",
},
{
bonus: { goldMultiplier: 4.75 },
bonus: { goldMultiplier: 4 },
cost: { crystals: 20_000_000, essence: 0, gold: 0 },
description:
"Armour structured around a crystalline lattice of optimal income calculations. Every gold piece finds you faster.",
-9
View File
@@ -210,15 +210,6 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
runestonesCost: 1200,
},
// ── 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",
description:
+13 -15
View File
@@ -141,7 +141,7 @@ export const defaultQuests: Array<Quest> = [
},
// ── Shadow Marshes ────────────────────────────────────────────────────────
{
combatPowerRequired: 5_000_000,
combatPowerRequired: 5000,
description:
"A cursed lake shrouded in permanent twilight. Strange energies pulse beneath its surface.",
durationSeconds: 45 * 60,
@@ -156,7 +156,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "shadow_marshes",
},
{
combatPowerRequired: 20_000_000,
combatPowerRequired: 20_000,
description:
"Deep in the marshes, a coven of swamp witches performs rites that twist the very land. Their power must be broken.",
durationSeconds: 90 * 60,
@@ -171,7 +171,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "shadow_marshes",
},
{
combatPowerRequired: 80_000_000,
combatPowerRequired: 80_000,
description:
"An ancient temple half-submerged in black water, its altars still humming with the power of a god long since departed.",
durationSeconds: 2 * 60 * 60,
@@ -180,7 +180,6 @@ export const defaultQuests: Array<Quest> = [
prerequisiteIds: [ "witch_coven" ],
rewards: [
{ amount: 2_000_000, type: "gold" },
{ amount: 1500, type: "essence" },
{ amount: 75, type: "crystals" },
{ targetId: "knight_1", type: "upgrade" },
],
@@ -188,7 +187,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "shadow_marshes",
},
{
combatPowerRequired: 300_000_000,
combatPowerRequired: 300_000,
description:
"A city that died overnight, its streets still thick with something no healer can identify. Treasures lie unclaimed among the bones.",
durationSeconds: 3 * 60 * 60,
@@ -254,7 +253,7 @@ export const defaultQuests: Array<Quest> = [
},
// ── Volcanic Depths ───────────────────────────────────────────────────────
{
combatPowerRequired: 1_200_000_000,
combatPowerRequired: 2_000_000,
description:
"A river of molten rock that flows without end through the volcanic tunnels. Something valuable gleams in the depths.",
durationSeconds: 3 * 60 * 60,
@@ -264,13 +263,12 @@ export const defaultQuests: Array<Quest> = [
rewards: [
{ amount: 15_000_000, type: "gold" },
{ amount: 4000, type: "essence" },
{ targetId: "void_walker", type: "adventurer" },
],
status: "locked",
zoneId: "volcanic_depths",
},
{
combatPowerRequired: 4_800_000_000,
combatPowerRequired: 8_000_000,
description:
"A vast shrine where fire elementals perform rituals that shake the mountains. Whatever they worship, it has answered.",
durationSeconds: 5 * 60 * 60,
@@ -278,15 +276,15 @@ export const defaultQuests: Array<Quest> = [
name: "The Temple of the Flame",
prerequisiteIds: [ "lava_flows" ],
rewards: [
{ amount: 40_000_000, type: "gold" },
{ amount: 12_000, type: "essence" },
{ amount: 300, type: "crystals" },
{ targetId: "void_walker", type: "adventurer" },
],
status: "locked",
zoneId: "volcanic_depths",
},
{
combatPowerRequired: 18_000_000_000,
combatPowerRequired: 30_000_000,
description:
"Kilometres of tunnels filled with rivers of fire and creatures born from the earth's core. The heat alone should kill you. Somehow, it won't.",
durationSeconds: 7 * 60 * 60,
@@ -302,7 +300,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "volcanic_depths",
},
{
combatPowerRequired: 72_000_000_000,
combatPowerRequired: 120_000_000,
description:
"The oldest forge in existence, where the fire elementals crafted weapons for gods. Its secrets could revolutionise your guild's arsenal.",
durationSeconds: 10 * 60 * 60,
@@ -319,7 +317,7 @@ export const defaultQuests: Array<Quest> = [
},
// ── Astral Void ───────────────────────────────────────────────────────────
{
combatPowerRequired: 300_000_000_000,
combatPowerRequired: 50_000_000,
description:
"A tear in reality itself. What lies beyond defies description — but the power within is unlike anything of this world.",
durationSeconds: 4 * 60 * 60,
@@ -334,7 +332,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "astral_void",
},
{
combatPowerRequired: 1_200_000_000_000,
combatPowerRequired: 200_000_000,
description:
"A field of dead stars, each one larger than a planet, each one cold and silent where once they burned with the light of creation.",
durationSeconds: 8 * 60 * 60,
@@ -350,7 +348,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "astral_void",
},
{
combatPowerRequired: 4_800_000_000_000,
combatPowerRequired: 800_000_000,
description:
"The space between realities, where the rules that govern your world do not apply. Time is meaningless here. Power is everything.",
durationSeconds: 12 * 60 * 60,
@@ -366,7 +364,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "astral_void",
},
{
combatPowerRequired: 18_000_000_000_000,
combatPowerRequired: 3_000_000_000,
description:
"There is nothing beyond this point. Only the greatest guild in the history of all existence could reach here — and you have.",
durationSeconds: 24 * 60 * 60,
-66
View File
@@ -767,70 +767,4 @@ export const defaultUpgrades: Array<Upgrade> = [
target: "adventurer",
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,
},
];
-2
View File
@@ -13,7 +13,6 @@ import { apotheosisRouter } from "./routes/apotheosis.js";
import { authRouter } from "./routes/auth.js";
import { bossRouter } from "./routes/boss.js";
import { craftRouter } from "./routes/craft.js";
import { debugRouter } from "./routes/debug.js";
import { exploreRouter } from "./routes/explore.js";
import { frontendRouter } from "./routes/frontend.js";
import { gameRouter } from "./routes/game.js";
@@ -36,7 +35,6 @@ app.use(
);
app.route("/about", aboutRouter);
app.route("/debug", debugRouter);
app.route("/fe", frontendRouter);
app.route("/auth", authRouter);
app.route("/game", gameRouter);
-648
View File
@@ -1,648 +0,0 @@
/**
* @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 {
STORY_CHAPTERS,
isStoryChapterUnlocked,
type GameState,
} from "@elysium/types";
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";
/**
* 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;
};
/**
* Unlocks any adventurer tiers that were granted as rewards for completed quests
* but are still locked in the player's state.
* @param state - The player's current game state (mutated directly).
* @returns The number of adventurer tiers that were unlocked.
*/
const applyAdventurerUnlocks = (state: GameState): number => {
let count = 0;
const completedQuestIds = new Set(
state.quests.
filter((q) => {
return q.status === "completed";
}).
map((q) => {
return q.id;
}),
);
const earnedAdventurerIds = new Set<string>();
for (const questDefinition of defaultQuests) {
if (!completedQuestIds.has(questDefinition.id)) {
continue;
}
for (const reward of questDefinition.rewards) {
if (reward.type === "adventurer" && reward.targetId !== undefined) {
earnedAdventurerIds.add(reward.targetId);
}
}
}
for (const adventurer of state.adventurers) {
if (!adventurer.unlocked && earnedAdventurerIds.has(adventurer.id)) {
adventurer.unlocked = true;
count = count + 1;
}
}
return count;
};
/**
* Collects all upgrade IDs the player has legitimately earned via boss defeats
* and completed quest rewards, sourcing reward data from game definitions.
* @param state - The player's current game state.
* @returns A set of earned upgrade IDs.
*/
const collectEarnedUpgradeIds = (state: GameState): Set<string> => {
const earnedIds = new Set<string>();
const defeatedBossIds = new Set(
state.bosses.
filter((b) => {
return b.status === "defeated";
}).
map((b) => {
return b.id;
}),
);
const completedQuestIds = new Set(
state.quests.
filter((q) => {
return q.status === "completed";
}).
map((q) => {
return q.id;
}),
);
for (const bossDefinition of defaultBosses) {
if (!defeatedBossIds.has(bossDefinition.id)) {
continue;
}
for (const upgradeId of bossDefinition.upgradeRewards) {
earnedIds.add(upgradeId);
}
}
for (const questDefinition of defaultQuests) {
if (!completedQuestIds.has(questDefinition.id)) {
continue;
}
for (const reward of questDefinition.rewards) {
if (reward.type === "upgrade" && reward.targetId !== undefined) {
earnedIds.add(reward.targetId);
}
}
}
return earnedIds;
};
/**
* Unlocks any upgrades that were granted as rewards for defeated bosses or
* completed quests but are still locked in the player's state.
* @param state - The player's current game state (mutated directly).
* @returns The number of upgrades that were unlocked.
*/
const applyUpgradeUnlocks = (state: GameState): number => {
let count = 0;
const earnedUpgradeIds = collectEarnedUpgradeIds(state);
for (const upgrade of state.upgrades) {
if (!upgrade.unlocked && earnedUpgradeIds.has(upgrade.id)) {
upgrade.unlocked = true;
count = count + 1;
}
}
return count;
};
/**
* Marks as owned any equipment that was granted as a reward for defeated bosses
* but is still unowned in the player's state.
* @param state - The player's current game state (mutated directly).
* @returns The number of equipment items that were marked as owned.
*/
const applyEquipmentUnlocks = (state: GameState): number => {
let count = 0;
const defeatedBossIds = new Set(
state.bosses.
filter((b) => {
return b.status === "defeated";
}).
map((b) => {
return b.id;
}),
);
const earnedEquipmentIds = new Set<string>();
for (const bossDefinition of defaultBosses) {
if (!defeatedBossIds.has(bossDefinition.id)) {
continue;
}
for (const equipmentId of bossDefinition.equipmentRewards) {
earnedEquipmentIds.add(equipmentId);
}
}
for (const item of state.equipment) {
if (!item.owned && earnedEquipmentIds.has(item.id)) {
item.owned = true;
count = count + 1;
}
}
return count;
};
/**
* Unlocks any story chapters whose conditions are met by the current game state
* but are still absent from the player's unlockedChapterIds list.
* @param state - The player's current game state (mutated directly).
* @returns The number of story chapters that were unlocked.
*/
const applyStoryUnlocks = (state: GameState): number => {
if (state.story === undefined) {
return 0;
}
let count = 0;
const alreadyUnlocked = new Set(state.story.unlockedChapterIds);
for (const chapter of STORY_CHAPTERS) {
if (alreadyUnlocked.has(chapter.id)) {
continue;
}
if (isStoryChapterUnlocked(chapter, state)) {
state.story.unlockedChapterIds.push(chapter.id);
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,
): {
adventurersUnlocked: number;
bossesUnlocked: number;
equipmentUnlocked: number;
explorationUnlocked: number;
questsUnlocked: number;
storyUnlocked: number;
upgradesUnlocked: number;
zonesUnlocked: number;
} => {
const zonesUnlocked = applyZoneUnlocks(state);
const questsUnlocked = applyQuestUnlocks(state);
const bossesUnlocked = applyBossUnlocks(state);
const explorationUnlocked = applyExplorationUnlocks(state);
const adventurersUnlocked = applyAdventurerUnlocks(state);
const upgradesUnlocked = applyUpgradeUnlocks(state);
const equipmentUnlocked = applyEquipmentUnlocks(state);
const storyUnlocked = applyStoryUnlocks(state);
return {
adventurersUnlocked,
bossesUnlocked,
equipmentUnlocked,
explorationUnlocked,
questsUnlocked,
storyUnlocked,
upgradesUnlocked,
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 {
adventurersUnlocked,
bossesUnlocked,
equipmentUnlocked,
explorationUnlocked,
questsUnlocked,
storyUnlocked,
upgradesUnlocked,
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({
adventurersUnlocked,
bossesUnlocked,
equipmentUnlocked,
explorationUnlocked,
questsUnlocked,
signature,
state,
storyUnlocked,
upgradesUnlocked,
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 };
+3 -16
View File
@@ -5,7 +5,6 @@
* @author Naomi Carrigan
*/
/* 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 { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
import type {
@@ -215,10 +214,7 @@ const buildPostPrestigeState = (
const currentBoss = currentState.bosses.find((candidate) => {
return candidate.id === freshBoss.id;
});
if (
currentBoss?.bountyRunestonesClaimed === true
|| currentBoss?.status === "defeated"
) {
if (currentBoss?.bountyRunestonesClaimed === true) {
return { ...freshBoss, bountyRunestonesClaimed: true };
}
return freshBoss;
@@ -243,20 +239,11 @@ const buildPostPrestigeState = (
const prestigeState: GameState = {
...freshState,
// Achievements are permanent — earned achievements survive all prestiges
achievements: currentState.achievements,
/*
* Preserve automation preferences across prestige — the player explicitly
* opted into these settings and would not expect them to silently reset.
*/
autoBoss: currentState.autoBoss ?? false,
autoQuest: currentState.autoQuest ?? false,
// Boss statuses reset for gameplay, but first-kill claimed flag is preserved
bosses: bossesWithBountyClaimed,
lastTickAt: Date.now(),
bosses: bossesWithBountyClaimed,
lastTickAt: Date.now(),
/*
* Fold current-run totals into lifetime stats so the GameState reflects
-605
View File
@@ -1,605 +0,0 @@
/* 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("unlocks adventurer tier when its quest has been completed", async () => {
const state = makeState({
adventurers: [ { id: "scout", unlocked: false } ] as GameState["adventurers"],
quests: [ { id: "haunted_mine", status: "completed" } ] as GameState["quests"],
});
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 { adventurersUnlocked: number };
expect(body.adventurersUnlocked).toBe(1);
});
it("does not unlock adventurer tier when it is already unlocked", async () => {
const state = makeState({
adventurers: [ { id: "scout", unlocked: true } ] as GameState["adventurers"],
quests: [ { id: "haunted_mine", status: "completed" } ] as GameState["quests"],
});
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 { adventurersUnlocked: number };
expect(body.adventurersUnlocked).toBe(0);
});
it("unlocks upgrade when its boss has been defeated", async () => {
const state = makeState({
bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"],
upgrades: [ { id: "click_2", unlocked: false } ] as GameState["upgrades"],
});
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 { upgradesUnlocked: number };
expect(body.upgradesUnlocked).toBe(1);
});
it("does not unlock upgrade when boss is not defeated", async () => {
const state = makeState({
bosses: [ { id: "troll_king", status: "available" } ] as GameState["bosses"],
upgrades: [ { id: "click_2", unlocked: false } ] as GameState["upgrades"],
});
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 { upgradesUnlocked: number };
expect(body.upgradesUnlocked).toBe(0);
});
it("does not unlock upgrade when it is already unlocked", async () => {
const state = makeState({
bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"],
upgrades: [ { id: "click_2", unlocked: true } ] as GameState["upgrades"],
});
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 { upgradesUnlocked: number };
expect(body.upgradesUnlocked).toBe(0);
});
it("unlocks upgrade granted as a quest reward", async () => {
const state = makeState({
quests: [ { id: "haunted_mine", status: "completed" } ] as GameState["quests"],
upgrades: [ { id: "global_1", unlocked: false } ] as GameState["upgrades"],
});
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 { upgradesUnlocked: number };
expect(body.upgradesUnlocked).toBe(1);
});
it("marks equipment as owned when its boss has been defeated", async () => {
const state = makeState({
bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"],
equipment: [ { id: "iron_sword", owned: false } ] as GameState["equipment"],
});
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 { equipmentUnlocked: number };
expect(body.equipmentUnlocked).toBe(1);
});
it("does not mark equipment as owned when boss is not defeated", async () => {
const state = makeState({
bosses: [ { id: "troll_king", status: "available" } ] as GameState["bosses"],
equipment: [ { id: "iron_sword", owned: false } ] as GameState["equipment"],
});
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 { equipmentUnlocked: number };
expect(body.equipmentUnlocked).toBe(0);
});
it("does not mark equipment as owned when it is already owned", async () => {
const state = makeState({
bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"],
equipment: [ { id: "iron_sword", owned: true } ] as GameState["equipment"],
});
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 { equipmentUnlocked: number };
expect(body.equipmentUnlocked).toBe(0);
});
it("returns storyUnlocked=0 when story is undefined", async () => {
const state = makeState({
story: undefined as unknown as GameState["story"],
});
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 { storyUnlocked: number };
expect(body.storyUnlocked).toBe(0);
});
it("unlocks story chapter when its boss has been defeated", async () => {
const state = makeState({
bosses: [ { id: "forest_giant", status: "defeated" } ] as GameState["bosses"],
story: { completedChapters: [], unlockedChapterIds: [] },
});
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 { storyUnlocked: number };
expect(body.storyUnlocked).toBe(1);
});
it("does not unlock story chapter when boss is not defeated", async () => {
const state = makeState({
bosses: [ { id: "forest_giant", status: "available" } ] as GameState["bosses"],
story: { completedChapters: [], unlockedChapterIds: [] },
});
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 { storyUnlocked: number };
expect(body.storyUnlocked).toBe(0);
});
it("does not unlock story chapter when it is already unlocked", async () => {
const state = makeState({
bosses: [ { id: "forest_giant", status: "defeated" } ] as GameState["bosses"],
story: { completedChapters: [], unlockedChapterIds: [ "story_ch_01" ] },
});
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 { storyUnlocked: number };
expect(body.storyUnlocked).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);
});
});
});
-26
View File
@@ -319,32 +319,6 @@ describe("buildPostPrestigeState", () => {
expect(matchingBoss?.bountyRunestonesClaimed).toBe(true);
});
it("sets bountyRunestonesClaimed on bosses defeated before the flag was introduced", () => {
const legacyDefeatedBoss = {
bountyRunestones: 5,
crystalReward: 0,
currentHp: 0,
damagePerSecond: 10,
description: "A boss",
equipmentRewards: [] as string[],
essenceReward: 0,
goldReward: 100,
id: "troll_king",
maxHp: 100,
name: "Troll King",
prestigeRequirement: 0,
status: "defeated" as const,
upgradeRewards: [] as string[],
zoneId: "verdant_vale",
};
const state = makeMinimalState({ bosses: [ legacyDefeatedBoss ] as GameState["bosses"] });
const { prestigeState } = buildPostPrestigeState(state, "Tester");
const matchingBoss = prestigeState.bosses.find((boss) => {
return boss.id === "troll_king";
});
expect(matchingBoss?.bountyRunestonesClaimed).toBe(true);
});
it("accumulates completed quests into lifetime total", () => {
const quest = {
id: "q_1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@elysium/web",
"version": "0.2.1",
"version": "0.1.1",
"private": true,
"type": "module",
"scripts": {
-21
View File
@@ -21,7 +21,6 @@ import type {
ExploreCollectResponse,
ExploreStartRequest,
ExploreStartResponse,
ForceUnlocksResponse,
LoadResponse,
PrestigeRequest,
PrestigeResponse,
@@ -257,24 +256,6 @@ 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.
* @param discordId - The Discord ID of the player to look up.
@@ -307,8 +288,6 @@ export {
challengeBoss,
collectExploration,
craftRecipe,
debugHardReset,
forceUnlocks,
getAbout,
getAuthUrl,
getPublicProfile,
+19 -39
View File
@@ -31,24 +31,14 @@ const howToPlay = [
body:
"Purchase upgrades to multiply the gold and essence output of specific"
+ " adventurer tiers, or boost your whole guild. Upgrades are permanent"
+ " 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.",
+ " for the current run and compound with each other.",
title: "đź”§ Upgrades",
},
{
body:
"Send your guild on quests that complete over time and reward gold,"
+ " essence, crystals, equipment, and upgrades. Multiple quests can run"
+ " 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.",
+ " simultaneously. Completing quests also unlocks new zones.",
title: "📜 Quests",
},
{
@@ -69,12 +59,10 @@ const howToPlay = [
{
body:
"Earn equipment from boss drops and quest rewards. Each piece provides"
+ " bonuses to gold income, click power, or boss combat DPS. Rarer"
+ " equipment provides stronger bonuses. Note: combat bonuses only"
+ " affect boss fights — quest combat power is determined solely by"
+ " 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.",
+ " bonuses to gold income, click power, or combat. Rarer equipment"
+ " provides stronger bonuses. 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",
},
{
@@ -123,11 +111,7 @@ const howToPlay = [
+ " real-time and reward gold, essence, and crafting materials when"
+ " collected. Each area has a set duration — short explorations are"
+ " faster but longer ones offer rarer finds. A đź“– icon marks areas"
+ " 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.",
+ " you've collected from at least once, unlocking a Codex entry.",
title: "🗺️ Exploration",
},
{
@@ -170,12 +154,10 @@ const howToPlay = [
{
body:
"Defeat bosses to earn equipment drops: weapons, armour, and trinkets."
+ " Each item provides bonuses to gold income, boss combat DPS, or click"
+ " power. Combat bonuses only affect boss fights — quest combat power"
+ " is determined solely by your adventurers. Only one item per slot"
+ " 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.",
+ " Each item provides bonuses to gold income, combat power, or click"
+ " power. Only one item per slot 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",
},
{
@@ -199,16 +181,14 @@ const howToPlay = [
},
{
body:
"Toggle automation in the Quests, Boss Encounters, and Prestige Shop"
+ " panels! Auto-Quest automatically sends your party on the"
+ " highest-zone available quest as soon as one completes, skipping"
+ " quests whose combat power requirement isn't met. Auto-Boss"
+ " automatically challenges the highest available boss as soon as one"
+ " is ready. Auto-Adventurer (unlocked via the Prestige Shop for 50"
+ " runestones) automatically purchases the highest-tier adventurer you"
+ " can currently afford each tick, keeping your income growing after a"
+ " prestige without any manual clicks.",
title: "🤖 Auto-Quest, Auto-Boss & Auto-Adventurer",
"Toggle automation in the Quests and Boss Encounters panels! Auto-Quest"
+ " automatically sends your party on the highest-zone available quest"
+ " as soon as one completes, skipping quests whose combat power"
+ " requirement isn't met. Auto-Boss automatically challenges the"
+ " highest available boss as soon as one is ready. Both can be toggled"
+ " on or off at any time using the 🤖 Auto button in each panel"
+ " header.",
title: "🤖 Auto-Quest & Auto-Boss",
},
{
body:
@@ -9,7 +9,7 @@ import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js";
import type { Achievement, GameState } from "@elysium/types";
import type { Achievement } from "@elysium/types";
/**
* Returns the plural form of a word based on a count.
@@ -54,50 +54,9 @@ 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 {
readonly achievement: Achievement;
readonly formatNumber: (n: number)=> string;
readonly progressValue: number;
readonly achievement: Achievement;
readonly formatNumber: (n: number)=> string;
}
/**
@@ -105,18 +64,14 @@ interface AchievementCardProperties {
* @param props - The achievement card properties.
* @param props.achievement - The achievement to display.
* @param props.formatNumber - The number formatting utility function.
* @param props.progressValue - The player's current progress toward the unlock condition.
* @returns The JSX element.
*/
// eslint-disable-next-line max-lines-per-function -- Progress bar adds necessary lines for locked state
const AchievementCard = ({
achievement,
formatNumber,
progressValue,
}: AchievementCardProperties): JSX.Element => {
const isUnlocked = achievement.unlockedAt !== null;
const crystals = achievement.reward?.crystals;
const cappedProgress = Math.min(progressValue, achievement.condition.amount);
return (
<div className={`achievement-card ${isUnlocked
@@ -133,19 +88,6 @@ const AchievementCard = ({
<p className="achievement-condition">
{conditionDescription(achievement, formatNumber)}
</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
&& <p className="achievement-reward">
{"đź’Ž +"}
@@ -221,7 +163,6 @@ const AchievementPanel = (): JSX.Element => {
achievement={achievement}
formatNumber={formatNumber}
key={achievement.id}
progressValue={getCurrentProgress(achievement, state)}
/>
);
})}
@@ -143,10 +143,6 @@ const AdventurerCard = ({
{" essence/s each"}
</p>
}
<p>
{formatNumber(adventurer.combatPower)}
{" combat power each"}
</p>
</div>
<div className="adventurer-count">
{"Ă—"}
@@ -175,7 +171,7 @@ const AdventurerCard = ({
* @returns The JSX element.
*/
const AdventurerPanel = (): JSX.Element => {
const { state, formatNumber, toggleAutoAdventurer } = useGame();
const { state, formatNumber } = useGame();
const [ showLocked, setShowLocked ] = useState(true);
const [ batchSize, setBatchSize ] = useState<BatchSize>(() => {
return parseBatchSize(localStorage.getItem("elysium_batch_size"));
@@ -207,11 +203,6 @@ const AdventurerPanel = (): JSX.Element => {
}
}
const autoAdventurerUnlocked = state.prestige.purchasedUpgradeIds.includes(
"auto_adventurer",
);
const autoAdventurerOn = state.autoAdventurer === true;
function handleToggle(): void {
setShowLocked((current) => {
return !current;
@@ -222,34 +213,11 @@ const AdventurerPanel = (): JSX.Element => {
<section className="panel adventurer-panel">
<div className="panel-header">
<h2>{"Adventurers"}</h2>
<div className="panel-header-controls">
{autoAdventurerUnlocked
? <button
className={`auto-toggle-btn ${
autoAdventurerOn
? "auto-toggle-on"
: "auto-toggle-off"
}`}
onClick={toggleAutoAdventurer}
title={
"Automatically purchase the highest-tier"
+ " affordable adventurer"
}
type="button"
>
{"🤖 Auto: "}
{autoAdventurerOn
? "ON"
: "OFF"}
</button>
: null
}
<LockToggle
lockedCount={locked.length}
onToggle={handleToggle}
showLocked={showLocked}
/>
</div>
<LockToggle
lockedCount={locked.length}
onToggle={handleToggle}
showLocked={showLocked}
/>
</div>
<div className="batch-selector">
{batchOptions.map((option) => {
@@ -235,7 +235,6 @@ const BossPanel = (): JSX.Element => {
toggleAutoBoss,
autoBossLastResult,
autoBossError,
bossError,
} = useGame();
const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
null,
@@ -267,23 +266,6 @@ const BossPanel = (): JSX.Element => {
}
const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state;
const activeZone = zones.find((zone) => {
return zone.id === activeZoneId;
});
const zoneIsLocked = activeZone?.status === "locked";
const unlockBoss = activeZone?.unlockBossId === null
|| activeZone?.unlockBossId === undefined
? undefined
: bosses.find((boss) => {
return boss.id === activeZone.unlockBossId;
});
const unlockQuest = activeZone?.unlockQuestId === null
|| activeZone?.unlockQuestId === undefined
? undefined
: quests.find((quest) => {
return quest.id === activeZone.unlockQuestId;
});
const zoneBosses = bosses.filter((boss) => {
return boss.zoneId === activeZoneId;
});
@@ -380,13 +362,6 @@ const BossPanel = (): JSX.Element => {
</div>
</div>
{bossError === null
? null
: <p className="auto-boss-error">
{"⚠️ "}
{bossError}
</p>
}
{autoBossError === null
? null
: <p className="auto-boss-error">
@@ -410,27 +385,6 @@ const BossPanel = (): JSX.Element => {
zones={zones}
/>
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
? <div className="exploration-zone-locked-hint">
<p>{"đź”’ This zone is locked. Unlock bosses by:"}</p>
{unlockBoss === undefined
? null
: <p>
{"⚔️ Defeat: "}
{unlockBoss.name}
</p>
}
{unlockQuest === undefined
? null
: <p>
{"📜 Complete: "}
{unlockQuest.name}
</p>
}
</div>
: null
}
<div className="party-combat-stats">
<div className="combat-stat">
<span className="stat-label">{"⚔️ Party DPS"}</span>
@@ -243,7 +243,7 @@ const CharacterPage = ({ discordId }: CharacterPageProperties): JSX.Element => {
return (
<div
className="character-page-equipment-item"
key={item.name}
key={item.type}
>
<div className="character-page-equipment-header">
<span className="character-page-equipment-slot">
@@ -26,9 +26,7 @@ const bonusLabel: Record<string, string> = {
*/
const CraftingPanel = (): JSX.Element => {
const { state, craftRecipe, formatNumber } = useGame();
const [ activeZoneId, setActiveZoneId ] = useState(() => {
return sessionStorage.getItem("elysium_craft_zone") ?? "verdant_vale";
});
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
const [ pendingRecipeId, setPendingRecipeId ] = useState<string | null>(null);
if (state === null) {
@@ -70,11 +68,6 @@ const CraftingPanel = (): JSX.Element => {
});
}
function handleZoneSelect(zoneId: string): void {
setActiveZoneId(zoneId);
sessionStorage.setItem("elysium_craft_zone", zoneId);
}
async function handleCraft(recipeId: string): Promise<void> {
setPendingRecipeId(recipeId);
try {
@@ -92,7 +85,7 @@ const CraftingPanel = (): JSX.Element => {
<ZoneSelector
activeZoneId={activeZoneId}
onSelectZone={handleZoneSelect}
onSelectZone={setActiveZoneId}
zones={zones}
/>
-166
View File
@@ -1,166 +0,0 @@
/**
* @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;
interface ForceUnlocksResult {
adventurersUnlocked: number;
bossesUnlocked: number;
equipmentUnlocked: number;
explorationUnlocked: number;
questsUnlocked: number;
storyUnlocked: number;
upgradesUnlocked: number;
zonesUnlocked: number;
}
/**
* Builds a human-readable summary of what the force-unlock operation corrected.
* @param result - The counts returned by the force-unlock operation.
* @returns A message string describing what was fixed, or a confirmation that nothing needed fixing.
*/
const buildForceUnlocksMessage = (result: ForceUnlocksResult): string => {
const entries: Array<[ number, string ]> = [
[ result.zonesUnlocked, "zone(s)" ],
[ result.questsUnlocked, "quest(s)" ],
[ result.bossesUnlocked, "boss(es)" ],
[ result.explorationUnlocked, "exploration area(s)" ],
[ result.adventurersUnlocked, "adventurer tier(s)" ],
[ result.upgradesUnlocked, "upgrade(s)" ],
[ result.equipmentUnlocked, "equipment item(s)" ],
[ result.storyUnlocked, "story chapter(s)" ],
];
const parts = entries.
filter(([ count ]) => {
return count > 0;
}).
map(([ count, label ]) => {
return `${String(count)} ${label}`;
});
if (parts.length === 0) {
return "Everything looks correct — no missing unlocks were found.";
}
const total = entries.reduce((sum, [ count ]) => {
return sum + count;
}, 0);
return `Fixed ${String(total)} unlock(s): ${parts.join(", ")}.`;
};
/**
* 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();
setForceUnlocksResult(buildForceUnlocksMessage(result));
})();
}
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,7 +7,6 @@
/* 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 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 { useGame } from "../../context/gameContext.js";
import { EQUIPMENT_SETS } from "../../data/equipmentSets.js";
@@ -31,7 +30,7 @@ const bonusDescription = (item: Equipment): string => {
const parts: Array<string> = [];
if (item.bonus.combatMultiplier !== undefined) {
const pct = Math.round((item.bonus.combatMultiplier - 1) * 100);
parts.push(`+${String(pct)}% Boss Combat`);
parts.push(`+${String(pct)}% Combat`);
}
if (item.bonus.goldMultiplier !== undefined) {
const pct = Math.round((item.bonus.goldMultiplier - 1) * 100);
@@ -189,20 +188,6 @@ 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 slotLabel: Record<EquipmentType, string> = {
armour: "🛡️ Armour",
@@ -276,7 +261,7 @@ const EquipmentPanel = (): JSX.Element => {
}
if (bonus.combatMultiplier !== undefined) {
const pct = Math.round((bonus.combatMultiplier - 1) * 100);
parts.push(`+${String(pct)}% Boss Combat (${String(threshold)}pc)`);
parts.push(`+${String(pct)}% Combat (${String(threshold)}pc)`);
}
if (bonus.clickMultiplier !== undefined) {
const pct = Math.round((bonus.clickMultiplier - 1) * 100);
@@ -335,8 +320,6 @@ const EquipmentPanel = (): JSX.Element => {
{slotOrder.map((slotType) => {
const items = equipment.filter((item) => {
return item.type === slotType && (showLocked || item.owned);
}).sort((a, b) => {
return equipmentPower(a) - equipmentPower(b);
});
return (
<div className="equipment-slot-section" key={slotType}>
@@ -6,7 +6,6 @@
*/
/* eslint-disable max-lines-per-function -- Complex component with many 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 { useGame } from "../../context/gameContext.js";
import { EXPLORATION_AREAS } from "../../data/explorations.js";
@@ -47,21 +46,11 @@ const formatDuration = (seconds: number): string => {
/**
* 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 durationSeconds - The total duration in seconds.
* @returns The remaining seconds.
*/
const timeRemaining = (
endsAt: number | undefined,
startedAt: number,
durationSeconds: number,
): number => {
if (endsAt !== undefined) {
return Math.max(0, (endsAt - Date.now()) / 1000);
}
const timeRemaining = (startedAt: number, durationSeconds: number): number => {
const elapsed = (Date.now() - startedAt) / 1000;
return Math.max(0, durationSeconds - elapsed);
};
@@ -78,9 +67,7 @@ interface CollectResult {
const ExplorationPanel = (): JSX.Element => {
const { state, startExploration, collectExploration, formatNumber }
= useGame();
const [ activeZoneId, setActiveZoneId ] = useState(() => {
return sessionStorage.getItem("elysium_explore_zone") ?? "verdant_vale";
});
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
const [ pendingAreaId, setPendingAreaId ] = useState<string | null>(null);
const [ lastResult, setLastResult ] = useState<CollectResult | null>(null);
@@ -92,24 +79,7 @@ const ExplorationPanel = (): JSX.Element => {
);
}
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 { zones, exploration: explorationState } = state;
const zoneAreas = EXPLORATION_AREAS.filter((area) => {
return area.zoneId === activeZoneId;
@@ -146,7 +116,6 @@ const ExplorationPanel = (): JSX.Element => {
function handleZoneSelect(id: string): void {
setActiveZoneId(id);
setLastResult(null);
sessionStorage.setItem("elysium_explore_zone", id);
}
const goldChange = lastResult?.response.event?.goldChange ?? 0;
@@ -238,27 +207,6 @@ const ExplorationPanel = (): JSX.Element => {
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">
{zoneAreas.map((area) => {
const areaState = explorationState?.areas.find((explorationArea) => {
@@ -266,10 +214,9 @@ const ExplorationPanel = (): JSX.Element => {
});
const status = areaState?.status ?? "locked";
const startedAt = areaState?.startedAt ?? 0;
const endsAt = areaState?.endsAt;
const isReady
= status === "in_progress"
&& timeRemaining(endsAt, startedAt, area.durationSeconds) <= 0;
&& timeRemaining(startedAt, area.durationSeconds) <= 0;
const isPending = pendingAreaId === area.id;
function handleStartClick(): void {
@@ -326,8 +273,9 @@ const ExplorationPanel = (): JSX.Element => {
{status === "in_progress" && !isReady
&& <span className="quest-badge active">
{"⏳ "}
{/* eslint-disable-next-line stylistic/max-len -- long call cannot be shortened */}
{formatDuration(Math.ceil(timeRemaining(endsAt, startedAt, area.durationSeconds)))}
{formatDuration(
Math.ceil(timeRemaining(startedAt, area.durationSeconds)),
)}
{" remaining"}
</span>
}
+1 -5
View File
@@ -23,7 +23,6 @@ import { CodexToast } from "./codexToast.js";
import { CompanionPanel } from "./companionPanel.js";
import { CraftingPanel } from "./craftingPanel.js";
import { DailyChallengePanel } from "./dailyChallengePanel.js";
import { DebugPanel } from "./debugPanel.js";
import { EditProfileModal } from "./editProfileModal.js";
import { EquipmentPanel } from "./equipmentPanel.js";
import { ExplorationPanel } from "./explorationPanel.js";
@@ -58,8 +57,7 @@ type Tab =
| "crafting"
| "character"
| "companions"
| "story"
| "debug";
| "story";
const baseTabs: Array<{ id: Tab; label: string }> = [
{ id: "adventurers", label: "⚔️ Adventurers" },
@@ -80,7 +78,6 @@ const baseTabs: Array<{ id: Tab; label: string }> = [
{ id: "story", label: "đź“– Story" },
{ id: "codex", label: "🗺️ Codex" },
{ id: "about", label: "ℹ️ About" },
{ id: "debug", label: "đź”§ Debug" },
];
/**
@@ -245,7 +242,6 @@ const GameLayout = (): JSX.Element => {
{activeTab === "story" && <StoryPanel />}
{activeTab === "codex" && <CodexPanel />}
{activeTab === "about" && <AboutPanel />}
{activeTab === "debug" && <DebugPanel />}
</div>
</main>
</div>
@@ -156,9 +156,6 @@ const LeaderboardPage = (): JSX.Element => {
<p className="leaderboard-subtitle">
{"The mightiest adventurers in Elysium"}
</p>
<p className="leaderboard-update-note">
{"🔄 Rankings update when you prestige."}
</p>
</div>
<div className="leaderboard-tabs">
+1 -24
View File
@@ -89,7 +89,6 @@ const PrestigePanel = (): JSX.Element => {
buyPrestigeUpgrade,
enableNotifications,
enableSounds,
toggleAutoAdventurer,
toggleAutoPrestige,
triggerPrestigeToast,
} = useGame();
@@ -111,7 +110,7 @@ const PrestigePanel = (): JSX.Element => {
);
}
const { autoAdventurer, prestige: prestigeData, player } = state;
const { prestige: prestigeData, player } = state;
const threshold = calculateThreshold(prestigeData.count);
const isEligible = player.totalGoldEarned >= threshold;
const runestonePreview = calculateRunestonePreview(
@@ -174,10 +173,6 @@ const PrestigePanel = (): JSX.Element => {
void handlePrestige();
}
function handleAutoAdventurerToggle(): void {
toggleAutoAdventurer();
}
function handleAutoPrestigeToggle(): void {
toggleAutoPrestige();
}
@@ -352,9 +347,6 @@ const PrestigePanel = (): JSX.Element => {
= prestigeData.runestones >= upgrade.runestonesCost;
const isLoading = buyingId === upgrade.id;
const isAutoAdventurerToggle
= upgrade.id === "auto_adventurer" && purchased;
const autoAdventurerEnabled = autoAdventurer ?? false;
const isAutoPrestigeToggle
= upgrade.id === "auto_prestige" && purchased;
const autoPrestigeEnabled
@@ -389,21 +381,6 @@ const PrestigePanel = (): JSX.Element => {
: `đź”® ${formatNumber(upgrade.runestonesCost)} Runestones`}
</p>
</div>
{isAutoAdventurerToggle
? <button
className={`auto-prestige-toggle ${
autoAdventurerEnabled
? "enabled"
: "disabled"
}`}
onClick={handleAutoAdventurerToggle}
type="button"
>
{autoAdventurerEnabled
? "⚡ Auto ON"
: "⏸ Auto OFF"}
</button>
: null}
{isAutoPrestigeToggle
? <button
className={`auto-prestige-toggle ${
+4 -57
View File
@@ -4,14 +4,12 @@
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines -- QuestPanel with sub-component and helper functions */
/* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- Many conditional render paths */
/* eslint-disable max-statements -- Many local variables needed for quest state */
import { useState, type JSX } from "react";
import { useGame } from "../../context/gameContext.js";
import { zoneFailureChance } from "../../engine/tick.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js";
import { ZoneSelector } from "./zoneSelector.js";
@@ -110,9 +108,9 @@ const QuestCard = ({
</p>
}
<div className="quest-rewards">
{quest.rewards.map((reward, rewardIndex) => {
{quest.rewards.map((reward) => {
return (
<span className="reward-tag" key={`${reward.type}-${reward.targetId ?? String(reward.amount ?? rewardIndex)}`}>
<span className="reward-tag" key={`${reward.type}-${String(reward.amount ?? "")}`}>
{reward.type === "gold"
&& `🪙 ${formatNumber(reward.amount ?? 0)}`}
{reward.type === "essence"
@@ -145,17 +143,8 @@ const QuestCard = ({
: null}
</>
}
{quest.status === "available"
&& <p className="quest-failure-chance">
{"🎲 "}
{String(Math.round((zoneFailureChance[quest.zoneId] ?? 0) * 100))}
{"% failure chance"}
</p>
}
{quest.status === "available" && quest.lastFailedAt !== undefined
&& <p className="quest-failed-hint">
{"⚠️ Last attempt failed — no rewards were granted."}
</p>
&& <p className="quest-failed-hint">{"⚠️ Last attempt failed"}</p>
}
{quest.status === "available"
&& <button
@@ -208,24 +197,7 @@ const QuestPanel = (): JSX.Element => {
);
}
const { adventurers, autoQuest, bosses, quests, zones } = 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 { adventurers, autoQuest, quests, zones } = state;
let partyCombatPower = 0;
for (const adventurer of adventurers) {
const contribution = adventurer.combatPower * adventurer.count;
@@ -324,31 +296,6 @@ const QuestPanel = (): JSX.Element => {
zones={zones}
/>
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
? <div className="exploration-zone-locked-hint">
<p>{"đź”’ This zone is locked. Unlock quests by:"}</p>
{unlockBoss === undefined
? null
: <p>
{"⚔️ Defeat: "}
{unlockBoss.name}
</p>
}
{unlockQuest === undefined
? null
: <p>
{"📜 Complete: "}
{unlockQuest.name}
</p>
}
</div>
: null
}
<p className="quest-failure-note">
{"⚠️ If a quest fails, it resets with no rewards — you must retry."}
</p>
<div className="quest-list">
{visibleQuests.map((quest) => {
return (
+2 -38
View File
@@ -11,7 +11,7 @@ import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js";
import type { Adventurer, Upgrade } from "@elysium/types";
import type { Upgrade } from "@elysium/types";
interface UpgradeCardProperties {
readonly upgrade: Upgrade;
@@ -20,7 +20,6 @@ interface UpgradeCardProperties {
readonly currentCrystals: number;
readonly unlockHint: string | undefined;
readonly formatNumber: (n: number)=> string;
readonly adventurers: ReadonlyArray<Adventurer>;
}
/**
@@ -32,7 +31,6 @@ interface UpgradeCardProperties {
* @param props.currentCrystals - The current crystals amount.
* @param props.unlockHint - Optional hint for how to unlock this upgrade.
* @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.
*/
const UpgradeCard = ({
@@ -42,14 +40,8 @@ const UpgradeCard = ({
currentCrystals,
unlockHint,
formatNumber,
adventurers,
}: UpgradeCardProperties): JSX.Element => {
const { buyUpgrade } = useGame();
const adventurerName = upgrade.adventurerId === undefined
? undefined
: adventurers.find((adventurer) => {
return adventurer.id === upgrade.adventurerId;
})?.name;
const canAfford
= currentGold >= upgrade.costGold
&& currentEssence >= upgrade.costEssence
@@ -72,13 +64,6 @@ const UpgradeCard = ({
{upgrade.name}
</span>
<span className="upgrade-desc">{upgrade.description}</span>
{adventurerName === undefined
? null
: <span className="upgrade-target">
{"🗡️ Affects: "}
{adventurerName}
</span>
}
</div>
);
}
@@ -94,13 +79,6 @@ const UpgradeCard = ({
<div className="upgrade-info">
<h3>{upgrade.name}</h3>
<p>{upgrade.description}</p>
{adventurerName === undefined
? null
: <p className="upgrade-target">
{"🗡️ Affects: "}
{adventurerName}
</p>
}
<p className="upgrade-multiplier">
{"Ă—"}
{upgrade.multiplier}
@@ -152,13 +130,6 @@ const UpgradeCard = ({
{upgrade.name}
</h3>
<p>{upgrade.description}</p>
{adventurerName === undefined
? null
: <p className="upgrade-target">
{"🗡️ Affects: "}
{adventurerName}
</p>
}
<p className="upgrade-multiplier">
{"Ă—"}
{upgrade.multiplier}
@@ -210,7 +181,7 @@ const UpgradePanel = (): JSX.Element => {
);
}
const { adventurers, bosses, quests, upgrades, resources } = state;
const { bosses, quests, upgrades, resources } = state;
const purchased = upgrades.filter((upgrade) => {
return upgrade.purchased;
});
@@ -261,10 +232,6 @@ const UpgradePanel = (): JSX.Element => {
{upgrades.length}
{" purchased"}
</p>
<p className="upgrade-stacking-note">
{"💡 Upgrade multipliers stack multiplicatively — two ×2 upgrades"
+ " combine to give Ă—4, not Ă—3."}
</p>
{upgrades.length === 0
? <p className="empty-state">
{"No upgrades available yet — keep adventuring!"}
@@ -273,7 +240,6 @@ const UpgradePanel = (): JSX.Element => {
{available.map((upgrade) => {
return (
<UpgradeCard
adventurers={adventurers}
currentCrystals={resources.crystals}
currentEssence={resources.essence}
currentGold={resources.gold}
@@ -287,7 +253,6 @@ const UpgradePanel = (): JSX.Element => {
{purchased.map((upgrade) => {
return (
<UpgradeCard
adventurers={adventurers}
currentCrystals={resources.crystals}
currentEssence={resources.essence}
currentGold={resources.gold}
@@ -302,7 +267,6 @@ const UpgradePanel = (): JSX.Element => {
? locked.map((upgrade) => {
return (
<UpgradeCard
adventurers={adventurers}
currentCrystals={resources.crystals}
currentEssence={resources.essence}
currentGold={resources.gold}
@@ -1,68 +0,0 @@
/**
* @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 };
+1 -15
View File
@@ -77,15 +77,8 @@ const ResourceBar = ({
isSyncing,
onForceSync,
}: ResourceBarProperties): JSX.Element => {
const { formatNumber, syncError, state } = useGame();
const { formatNumber, syncError } = useGame();
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 anyFull = resourceValues.some((v) => {
return v >= RESOURCE_CAP;
@@ -142,13 +135,6 @@ const ResourceBar = ({
<span className="resource-value">{formatNumber(runestones)}</span>
<span className="resource-label">{"Runestones"}</span>
</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
&& <div className="apotheosis-badge">
{"✨ Apotheosis "}
+114 -278
View File
@@ -42,8 +42,6 @@ import {
challengeBoss as challengeBossApi,
collectExploration as collectExplorationApi,
craftRecipe as craftRecipeApi,
debugHardReset as debugHardResetApi,
forceUnlocks as forceUnlocksApi,
loadGame,
prestige as prestigeApi,
resetProgress as resetProgressApi,
@@ -52,6 +50,7 @@ import {
transcend as transcendApi,
} from "../api/client.js";
import { CODEX_ENTRIES } from "../data/codex.js";
import { EXPLORATION_AREAS } from "../data/explorations.js";
import { RECIPES } from "../data/recipes.js";
import {
RESOURCE_CAP,
@@ -447,11 +446,6 @@ interface GameContextValue {
*/
toggleAutoBoss: ()=> void;
/**
* Toggle the auto-adventurer setting on/off (requires auto_adventurer prestige upgrade).
*/
toggleAutoAdventurer: ()=> void;
/**
* Queue of newly unlocked codex entry IDs (for toast notifications).
*/
@@ -552,28 +546,6 @@ interface GameContextValue {
*/
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<{
adventurersUnlocked: number;
bossesUnlocked: number;
equipmentUnlocked: number;
explorationUnlocked: number;
questsUnlocked: number;
storyUnlocked: number;
upgradesUnlocked: number;
zonesUnlocked: number;
}>;
/**
* Completely wipe the player's progress back to a brand-new save via the
* debug endpoint.
*/
debugHardReset: ()=> Promise<void>;
/**
* Last auto-boss fight result — null until the first auto fight completes or
* when auto-boss is toggled off.
@@ -585,12 +557,6 @@ interface GameContextValue {
* when no error). Cleared automatically when the player re-enables auto-boss.
*/
autoBossError: string | null;
/**
* Error message from the most recent manual boss challenge (null when no
* error). Cleared automatically when a new challenge is initiated.
*/
bossError: string | null;
}
export interface BattleResult {
@@ -640,7 +606,6 @@ export const GameProvider = ({
at: number;
} | null>(null);
const [ autoBossError, setAutoBossError ] = useState<string | null>(null);
const [ bossError, setBossError ] = useState<string | null>(null);
const syncErrorTimerReference = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
@@ -1082,42 +1047,6 @@ 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
unlockedAchievementsReference.current = next.achievements.filter(
(a, index) => {
@@ -1148,6 +1077,14 @@ export const GameProvider = ({
},
);
// Quest failure — turn off auto-quest so the player can reassess
if (
newlyFailedQuestsReference.current.length > 0
&& next.autoQuest === true
) {
next = { ...next, autoQuest: false };
}
return next;
});
@@ -1220,12 +1157,9 @@ export const GameProvider = ({
) {
signatureReference.current = null;
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
*/
});
}
}
@@ -1253,9 +1187,10 @@ export const GameProvider = ({
}
await reloadReference.current();
}).
catch(() => {
catch((error_: unknown) => {
logError("auto_prestige", error_);
/* Silently ignore — eligibility is re-checked every tick */
/* Silently ignore — will retry next tick */
}).
finally(() => {
isAutoPrestigingReference.current = false;
@@ -1305,20 +1240,11 @@ export const GameProvider = ({
});
}).
catch((error_: unknown) => {
logError("auto_boss", error_);
const message
= error_ instanceof Error
? error_.message
: String(error_);
/*
* "Boss is not currently available" is an expected race condition
* when the client is ahead of the server save — silently skip and
* let the next tick retry rather than halting automation.
*/
if (message === "Boss is not currently available") {
return;
}
logError("auto_boss", error_);
setAutoBossError(message);
setState((previous) => {
if (previous === null) {
@@ -1716,104 +1642,118 @@ export const GameProvider = ({
}, []);
const startExploration = useCallback(async(areaId: string) => {
const response = await startExplorationApi({ areaId });
setState((previous) => {
if (previous?.exploration === undefined) {
return previous;
try {
const response = await startExplorationApi({ areaId });
const areaData = EXPLORATION_AREAS.find((a) => {
return a.id === areaId;
});
if (areaData === undefined) {
return;
}
return {
...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 });
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
const startedAt = response.endsAt - areaData.durationSeconds * 1000;
setState((previous) => {
if (previous?.exploration === undefined) {
return previous;
}
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, startedAt: startedAt, status: "in_progress" 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("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_;
}
},
[],
);
@@ -1896,18 +1836,6 @@ export const GameProvider = ({
});
}, []);
const toggleAutoAdventurer = useCallback(() => {
setState((previous) => {
if (previous === null) {
return previous;
}
return {
...previous,
autoAdventurer: previous.autoAdventurer !== true,
};
});
}, []);
const setActiveCompanion = useCallback((companionId: string | null) => {
setState((previous) => {
if (previous === null) {
@@ -1939,14 +1867,6 @@ export const GameProvider = ({
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 {
const result = await challengeBossApi({ bossId });
setState((previous) => {
@@ -1957,23 +1877,10 @@ export const GameProvider = ({
});
setBattleResult({ bossName: boss.name, result: result });
} catch (error_: unknown) {
const bossErrorMessage
= 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,
);
logError("challenge_boss", error_);
// Silently ignore — server errors shouldn't crash the UI
}
}, [ forceSync ]);
}, []);
const dismissOfflineGold = useCallback(() => {
setOfflineGold(0);
@@ -2099,69 +2006,6 @@ 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 {
adventurersUnlocked: data.adventurersUnlocked,
bossesUnlocked: data.bossesUnlocked,
equipmentUnlocked: data.equipmentUnlocked,
explorationUnlocked: data.explorationUnlocked,
questsUnlocked: data.questsUnlocked,
storyUnlocked: data.storyUnlocked,
upgradesUnlocked: data.upgradesUnlocked,
zonesUnlocked: data.zonesUnlocked,
};
} catch (error_: unknown) {
setError(
error_ instanceof Error
? error_.message
: "Failed to force unlocks",
);
return {
adventurersUnlocked: 0,
bossesUnlocked: 0,
equipmentUnlocked: 0,
explorationUnlocked: 0,
questsUnlocked: 0,
storyUnlocked: 0,
upgradesUnlocked: 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(() => {
setLoginBonus(null);
}, []);
@@ -2179,7 +2023,6 @@ export const GameProvider = ({
autoBossError,
autoBossLastResult,
battleResult,
bossError,
buyAdventurer,
buyEchoUpgrade,
buyEquipment,
@@ -2191,7 +2034,6 @@ export const GameProvider = ({
completedQuestToasts,
craftRecipe,
currentSchemaVersion,
debugHardReset,
dismissAchievement,
dismissApotheosisToast,
dismissBattle,
@@ -2210,7 +2052,6 @@ export const GameProvider = ({
failedQuestToasts,
flushBossLoreToasts,
forceSync,
forceUnlocks,
formatNumber,
handleClick,
isLoading,
@@ -2236,7 +2077,6 @@ export const GameProvider = ({
startQuest,
state,
syncError,
toggleAutoAdventurer,
toggleAutoBoss,
toggleAutoPrestige,
toggleAutoQuest,
@@ -2251,7 +2091,6 @@ export const GameProvider = ({
autoBossError,
autoBossLastResult,
battleResult,
bossError,
completedQuestToasts,
failedQuestToasts,
formatNumber,
@@ -2265,7 +2104,6 @@ export const GameProvider = ({
completeChapter,
craftRecipe,
currentSchemaVersion,
debugHardReset,
dismissAchievement,
dismissApotheosisToast,
dismissBattle,
@@ -2283,7 +2121,6 @@ export const GameProvider = ({
error,
flushBossLoreToasts,
forceSync,
forceUnlocks,
handleClick,
isLoading,
isSyncing,
@@ -2308,7 +2145,6 @@ export const GameProvider = ({
startQuest,
state,
syncError,
toggleAutoAdventurer,
toggleAutoBoss,
toggleAutoPrestige,
toggleAutoQuest,
-9
View File
@@ -212,15 +212,6 @@ export const PRESTIGE_UPGRADES: Array<PrestigeUpgrade> = [
runestonesCost: 1200,
},
// ── 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",
description:
+1 -1
View File
@@ -93,7 +93,7 @@ export const RESOURCE_CAP = 1e300;
* On failure the quest resets to "available" with no rewards; the player must wait the
* full duration again on their next attempt.
*/
export const zoneFailureChance: Record<string, number> = {
const zoneFailureChance: Record<string, number> = {
abyssal_trench: 0.24,
astral_void: 0.2,
celestial_reaches: 0.22,
-81
View File
@@ -4515,84 +4515,3 @@ body::before {
object-fit: cover;
width: 80px;
}
/* ===================== ACTION BUTTONS ===================== */
.action-button {
background: var(--colour-accent);
border: none;
border-radius: var(--radius);
color: #fff;
cursor: pointer;
font-size: 0.9rem;
font-weight: 700;
margin-top: 0.5rem;
padding: 0.55rem 1.25rem;
transition: background 0.15s;
width: 100%;
}
.action-button:hover:not(:disabled) {
background: var(--colour-accent-light);
}
.action-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.action-button-danger {
background: var(--colour-error);
}
.action-button-danger:hover:not(:disabled) {
background: #f87171;
}
/* ===================== MODAL VARIANTS ===================== */
.modal-button-danger {
background: var(--colour-error);
}
.modal-button-danger:hover:not(:disabled) {
background: #f87171;
}
/* ===================== DEBUG PANEL ===================== */
.debug-actions {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
margin-top: 1rem;
}
.debug-action-card {
background: var(--colour-surface);
border: 1px solid var(--colour-border);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
padding: 1.25rem;
}
.debug-action-card h3 {
color: var(--colour-accent-light);
font-size: 1rem;
margin: 0 0 0.5rem;
}
.debug-action-card > p {
color: var(--colour-text-muted);
flex: 1;
font-size: 0.875rem;
margin: 0;
}
.debug-result-message {
background: rgba(16, 185, 129, 0.1);
border: 1px solid var(--colour-success);
border-radius: var(--radius);
color: var(--colour-success);
font-size: 0.8rem;
margin-top: 0.75rem;
padding: 0.5rem 0.75rem;
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "elysium",
"version": "0.2.1",
"version": "0.1.1",
"private": true,
"type": "module",
"scripts": {
@@ -11,6 +11,6 @@
},
"devDependencies": {
"@nhcarrigan/typescript-config": "4.0.0",
"typescript": "5.9.3"
"typescript": "5.8.2"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@elysium/types",
"version": "0.2.1",
"version": "0.1.1",
"private": true,
"type": "module",
"main": "./prod/src/index.js",
-1
View File
@@ -60,7 +60,6 @@ export type {
ExploreCollectResponse,
ExploreStartRequest,
ExploreStartResponse,
ForceUnlocksResponse,
GiteaRelease,
LeaderboardCategory,
LeaderboardEntry,
-54
View File
@@ -398,59 +398,6 @@ interface CraftRecipeResponse {
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;
/**
* Number of adventurer tiers that were unlocked by this operation.
*/
adventurersUnlocked: number;
/**
* Number of upgrades that were unlocked by this operation.
*/
upgradesUnlocked: number;
/**
* Number of equipment items that were marked as owned by this operation.
*/
equipmentUnlocked: number;
/**
* Number of story chapters that were unlocked by this operation.
*/
storyUnlocked: number;
/**
* HMAC-SHA256 signature of the corrected state for anti-cheat chain continuity.
*/
signature?: string;
}
export type {
AboutResponse,
ApiError,
@@ -470,7 +417,6 @@ export type {
ExploreCollectResponse,
ExploreStartRequest,
ExploreStartResponse,
ForceUnlocksResponse,
GiteaRelease,
LeaderboardCategory,
LeaderboardEntry,
@@ -72,12 +72,6 @@ interface ExplorationAreaState {
*/
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.
*/
@@ -79,11 +79,6 @@ interface GameState {
*/
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.
*/
+3 -14
View File
@@ -10,10 +10,10 @@ importers:
devDependencies:
'@nhcarrigan/typescript-config':
specifier: 4.0.0
version: 4.0.0(typescript@5.9.3)
version: 4.0.0(typescript@5.8.2)
typescript:
specifier: 5.9.3
version: 5.9.3
specifier: 5.8.2
version: 5.8.2
apps/api:
dependencies:
@@ -2833,11 +2833,6 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
unbox-primitive@1.1.0:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
@@ -3507,10 +3502,6 @@ snapshots:
dependencies:
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':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -6147,8 +6138,6 @@ snapshots:
typescript@5.8.2: {}
typescript@5.9.3: {}
unbox-primitive@1.1.0:
dependencies:
call-bound: 1.0.4