7 Commits

Author SHA1 Message Date
naomi 7bd6b2d3e3 release: v0.2.1
CI / Lint, Build & Test (push) Successful in 1m9s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m6s
2026-03-20 15:23:13 -07:00
hikari 354b7e372e fix: break fire_temple combat power wall (#96)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m13s
CI / Lint, Build & Test (push) Successful in 1m17s
Closes #95

## Summary

`void_walker` adventurers (130K combat power each) were locked behind `fire_temple`, which requires 4.8B combat power. The best adventurer available before completing that quest was `arcane_scholar` at 45K CP each — meaning players needed ~107K arcane scholars to break through, versus ~37K if they had void walkers. Classic chicken-and-egg wall.

## Changes

- Moved `void_walker` adventurer reward from `fire_temple` to `lava_flows` (the entry quest to Volcanic Depths, no CP requirement)
- Added 40M gold reward to `fire_temple` to replace the removed adventurer unlock

Players now unlock `void_walker` as soon as they enter the zone, giving them the combat power boost before they need to grind toward the temple.

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #96
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-20 15:17:23 -07:00
hikari dc1782bec9 chore: add auto-adventurer toggle to adventurer panel header (#94)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint, Build & Test (push) Successful in 1m9s
The auto-adventurer toggle is now surfaced directly in the adventurer shop panel header, mirroring the auto-boss button. It only renders when the `auto_adventurer` prestige upgrade has been purchased, so players who have not reached prestige see no change.

Closes #89

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #94
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-20 14:35:04 -07:00
hikari 635c630e49 fix: adventurer unlocks not applied by force-unlock tool (#93)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint, Build & Test (push) Successful in 1m10s
The force-unlock debug route now scans completed quests for adventurer rewards and ensures those tiers are marked as unlocked in game state.

The UI and API response type both surface the new `adventurersUnlocked` count alongside the existing zone/quest/boss/exploration counts.

Closes #88

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #93
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-20 10:28:17 -07:00
hikari bb60ae3390 fix: auto-quest continues after quest failure (#92)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m1s
CI / Lint, Build & Test (push) Successful in 1m17s
## Summary

Fixes #87. When a quest failed, the tick loop detected the failure and turned auto-quest off so the "player could reassess". This meant every quest failure required the player to manually re-enable the toggle.

## Root Cause

The tick applies quest failure by resetting the quest to `status: "available"` with `lastFailedAt` set. Auto-quest picks up `available` quests automatically — so turning off auto-quest on failure was entirely unnecessary, it just broke the loop.

## Fix

Remove the auto-quest-off-on-failure block entirely. The quest returns to `available` immediately after failure, so auto-quest naturally retries on the next tick. Players can still disable it manually if they want to stop.

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #92
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-20 09:35:55 -07:00
hikari ee47c1e8c9 fix: auto-boss no longer halts on client/server save race condition (#91)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint, Build & Test (push) Successful in 1m10s
## Summary

Fixes #86. When the client state is ahead of the server save, the auto-boss tick would receive a "Boss is not currently available" error from the API. This error was already acknowledged as an expected race condition and suppressed from telemetry — but it was still setting the error state and turning auto-boss off.

## Root Cause

The `catch` handler treated all errors identically: set `autoBossError`, turn off `autoBoss`. The race-condition case should instead silently skip so the next tick can retry naturally.

## Fix

When the error is `"Boss is not currently available"`, return early from the `catch` handler. The `finally` block still runs, resetting `isAutoBossingReference.current = false`, so the next tick retries cleanly.

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #91
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-20 09:30:57 -07:00
hikari 2236d1dc9f fix: correct quest combat power requirements for SM, VD, and AV (#90)
CI / Lint, Build & Test (push) Successful in 1m10s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m11s
## Summary

Fixes #85. Quest combat power requirements for Shadow Marshes, Volcanic Depths, and Astral Void were all drastically too low, breaking the zone progression curve.

### Root Cause

All three zones appear to have had their `combatPowerRequired` values entered at the wrong magnitude. Shadow Marshes was using K values where M was intended; Volcanic Depths and Astral Void were similarly off, resulting in later zones being trivially easier than earlier ones.

### Changes

| Zone | Before | After |
|---|---|---|
| Shadow Marshes | 5K / 20K / 80K / 300K | 5M / 20M / 80M / 300M |
| Volcanic Depths | 2M / 8M / 30M / 120M | 1.2B / 4.8B / 18B / 72B |
| Astral Void | 50M / 200M / 800M / 3B | 300B / 1.2T / 4.8T / 18T |

### Progression

All values now maintain a consistent ~×4 multiplier within each zone and ~×4 jump between zones, matching the established pattern from Verdant Vale through Frozen Peaks.

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #90
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-20 09:19:16 -07:00
11 changed files with 500 additions and 61 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/api", "name": "@elysium/api",
"version": "0.2.0", "version": "0.2.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
+15 -13
View File
@@ -141,7 +141,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Shadow Marshes ──────────────────────────────────────────────────────── // ── Shadow Marshes ────────────────────────────────────────────────────────
{ {
combatPowerRequired: 5000, combatPowerRequired: 5_000_000,
description: description:
"A cursed lake shrouded in permanent twilight. Strange energies pulse beneath its surface.", "A cursed lake shrouded in permanent twilight. Strange energies pulse beneath its surface.",
durationSeconds: 45 * 60, durationSeconds: 45 * 60,
@@ -156,7 +156,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "shadow_marshes", zoneId: "shadow_marshes",
}, },
{ {
combatPowerRequired: 20_000, combatPowerRequired: 20_000_000,
description: description:
"Deep in the marshes, a coven of swamp witches performs rites that twist the very land. Their power must be broken.", "Deep in the marshes, a coven of swamp witches performs rites that twist the very land. Their power must be broken.",
durationSeconds: 90 * 60, durationSeconds: 90 * 60,
@@ -171,7 +171,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "shadow_marshes", zoneId: "shadow_marshes",
}, },
{ {
combatPowerRequired: 80_000, combatPowerRequired: 80_000_000,
description: description:
"An ancient temple half-submerged in black water, its altars still humming with the power of a god long since departed.", "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, durationSeconds: 2 * 60 * 60,
@@ -180,6 +180,7 @@ export const defaultQuests: Array<Quest> = [
prerequisiteIds: [ "witch_coven" ], prerequisiteIds: [ "witch_coven" ],
rewards: [ rewards: [
{ amount: 2_000_000, type: "gold" }, { amount: 2_000_000, type: "gold" },
{ amount: 1500, type: "essence" },
{ amount: 75, type: "crystals" }, { amount: 75, type: "crystals" },
{ targetId: "knight_1", type: "upgrade" }, { targetId: "knight_1", type: "upgrade" },
], ],
@@ -187,7 +188,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "shadow_marshes", zoneId: "shadow_marshes",
}, },
{ {
combatPowerRequired: 300_000, combatPowerRequired: 300_000_000,
description: description:
"A city that died overnight, its streets still thick with something no healer can identify. Treasures lie unclaimed among the bones.", "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, durationSeconds: 3 * 60 * 60,
@@ -253,7 +254,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Volcanic Depths ─────────────────────────────────────────────────────── // ── Volcanic Depths ───────────────────────────────────────────────────────
{ {
combatPowerRequired: 2_000_000, combatPowerRequired: 1_200_000_000,
description: description:
"A river of molten rock that flows without end through the volcanic tunnels. Something valuable gleams in the depths.", "A river of molten rock that flows without end through the volcanic tunnels. Something valuable gleams in the depths.",
durationSeconds: 3 * 60 * 60, durationSeconds: 3 * 60 * 60,
@@ -263,12 +264,13 @@ export const defaultQuests: Array<Quest> = [
rewards: [ rewards: [
{ amount: 15_000_000, type: "gold" }, { amount: 15_000_000, type: "gold" },
{ amount: 4000, type: "essence" }, { amount: 4000, type: "essence" },
{ targetId: "void_walker", type: "adventurer" },
], ],
status: "locked", status: "locked",
zoneId: "volcanic_depths", zoneId: "volcanic_depths",
}, },
{ {
combatPowerRequired: 8_000_000, combatPowerRequired: 4_800_000_000,
description: description:
"A vast shrine where fire elementals perform rituals that shake the mountains. Whatever they worship, it has answered.", "A vast shrine where fire elementals perform rituals that shake the mountains. Whatever they worship, it has answered.",
durationSeconds: 5 * 60 * 60, durationSeconds: 5 * 60 * 60,
@@ -276,15 +278,15 @@ export const defaultQuests: Array<Quest> = [
name: "The Temple of the Flame", name: "The Temple of the Flame",
prerequisiteIds: [ "lava_flows" ], prerequisiteIds: [ "lava_flows" ],
rewards: [ rewards: [
{ amount: 40_000_000, type: "gold" },
{ amount: 12_000, type: "essence" }, { amount: 12_000, type: "essence" },
{ amount: 300, type: "crystals" }, { amount: 300, type: "crystals" },
{ targetId: "void_walker", type: "adventurer" },
], ],
status: "locked", status: "locked",
zoneId: "volcanic_depths", zoneId: "volcanic_depths",
}, },
{ {
combatPowerRequired: 30_000_000, combatPowerRequired: 18_000_000_000,
description: 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.", "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, durationSeconds: 7 * 60 * 60,
@@ -300,7 +302,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "volcanic_depths", zoneId: "volcanic_depths",
}, },
{ {
combatPowerRequired: 120_000_000, combatPowerRequired: 72_000_000_000,
description: description:
"The oldest forge in existence, where the fire elementals crafted weapons for gods. Its secrets could revolutionise your guild's arsenal.", "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, durationSeconds: 10 * 60 * 60,
@@ -317,7 +319,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Astral Void ─────────────────────────────────────────────────────────── // ── Astral Void ───────────────────────────────────────────────────────────
{ {
combatPowerRequired: 50_000_000, combatPowerRequired: 300_000_000_000,
description: description:
"A tear in reality itself. What lies beyond defies description — but the power within is unlike anything of this world.", "A tear in reality itself. What lies beyond defies description — but the power within is unlike anything of this world.",
durationSeconds: 4 * 60 * 60, durationSeconds: 4 * 60 * 60,
@@ -332,7 +334,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "astral_void", zoneId: "astral_void",
}, },
{ {
combatPowerRequired: 200_000_000, combatPowerRequired: 1_200_000_000_000,
description: 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.", "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, durationSeconds: 8 * 60 * 60,
@@ -348,7 +350,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "astral_void", zoneId: "astral_void",
}, },
{ {
combatPowerRequired: 800_000_000, combatPowerRequired: 4_800_000_000_000,
description: description:
"The space between realities, where the rules that govern your world do not apply. Time is meaningless here. Power is everything.", "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, durationSeconds: 12 * 60 * 60,
@@ -364,7 +366,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "astral_void", zoneId: "astral_void",
}, },
{ {
combatPowerRequired: 3_000_000_000, combatPowerRequired: 18_000_000_000_000,
description: description:
"There is nothing beyond this point. Only the greatest guild in the history of all existence could reach here — and you have.", "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, durationSeconds: 24 * 60 * 60,
+211 -4
View File
@@ -7,6 +7,11 @@
/* eslint-disable max-lines-per-function -- Route handlers require many steps */ /* eslint-disable max-lines-per-function -- Route handlers require many steps */
/* eslint-disable max-lines -- Multiple route handlers and helper functions in one file */ /* eslint-disable max-lines -- Multiple route handlers and helper functions in one file */
import { createHmac } from "node:crypto"; import { createHmac } from "node:crypto";
import {
STORY_CHAPTERS,
isStoryChapterUnlocked,
type GameState,
} from "@elysium/types";
import { Hono } from "hono"; import { Hono } from "hono";
import { defaultBosses } from "../data/bosses.js"; import { defaultBosses } from "../data/bosses.js";
import { defaultExplorations } from "../data/explorations.js"; import { defaultExplorations } from "../data/explorations.js";
@@ -18,7 +23,6 @@ import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js"; import { authMiddleware } from "../middleware/auth.js";
import { logger } from "../services/logger.js"; import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js"; import type { HonoEnvironment } from "../types/hono.js";
import type { GameState } from "@elysium/types";
/** /**
* Computes the HMAC-SHA256 of data using the given secret. * Computes the HMAC-SHA256 of data using the given secret.
@@ -257,6 +261,180 @@ const applyBossUnlocks = (state: GameState): number => {
return count; 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. * Makes available any exploration areas whose parent zone is now unlocked.
* @param state - The player's current game state (mutated directly). * @param state - The player's current game state (mutated directly).
@@ -301,16 +479,33 @@ const applyExplorationUnlocks = (state: GameState): number => {
const applyForceUnlocks = ( const applyForceUnlocks = (
state: GameState, state: GameState,
): { ): {
adventurersUnlocked: number;
bossesUnlocked: number; bossesUnlocked: number;
equipmentUnlocked: number;
explorationUnlocked: number; explorationUnlocked: number;
questsUnlocked: number; questsUnlocked: number;
storyUnlocked: number;
upgradesUnlocked: number;
zonesUnlocked: number; zonesUnlocked: number;
} => { } => {
const zonesUnlocked = applyZoneUnlocks(state); const zonesUnlocked = applyZoneUnlocks(state);
const questsUnlocked = applyQuestUnlocks(state); const questsUnlocked = applyQuestUnlocks(state);
const bossesUnlocked = applyBossUnlocks(state); const bossesUnlocked = applyBossUnlocks(state);
const explorationUnlocked = applyExplorationUnlocks(state); const explorationUnlocked = applyExplorationUnlocks(state);
return { bossesUnlocked, explorationUnlocked, questsUnlocked, zonesUnlocked }; 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>(); const debugRouter = new Hono<HonoEnvironment>();
@@ -330,8 +525,16 @@ debugRouter.post("/force-unlocks", async(context) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma stores state as JSON object */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma stores state as JSON object */
const state = gameStateRecord.state as unknown as GameState; const state = gameStateRecord.state as unknown as GameState;
const { bossesUnlocked, explorationUnlocked, questsUnlocked, zonesUnlocked } const {
= applyForceUnlocks(state); adventurersUnlocked,
bossesUnlocked,
equipmentUnlocked,
explorationUnlocked,
questsUnlocked,
storyUnlocked,
upgradesUnlocked,
zonesUnlocked,
} = applyForceUnlocks(state);
const updatedAt = Date.now(); const updatedAt = Date.now();
await prisma.gameState.update({ await prisma.gameState.update({
@@ -347,11 +550,15 @@ debugRouter.post("/force-unlocks", async(context) => {
: computeHmac(JSON.stringify(state), secret); : computeHmac(JSON.stringify(state), secret);
return context.json({ return context.json({
adventurersUnlocked,
bossesUnlocked, bossesUnlocked,
equipmentUnlocked,
explorationUnlocked, explorationUnlocked,
questsUnlocked, questsUnlocked,
signature, signature,
state, state,
storyUnlocked,
upgradesUnlocked,
zonesUnlocked, zonesUnlocked,
}); });
} catch (error) { } catch (error) {
+155
View File
@@ -366,6 +366,161 @@ describe("debug route", () => {
expect(body.explorationUnlocked).toBe(0); 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 () => { it("computes HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
process.env.ANTI_CHEAT_SECRET = "test_secret"; process.env.ANTI_CHEAT_SECRET = "test_secret";
const state = makeState(); const state = makeState();
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/web", "name": "@elysium/web",
"version": "0.2.0", "version": "0.2.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -175,7 +175,7 @@ const AdventurerCard = ({
* @returns The JSX element. * @returns The JSX element.
*/ */
const AdventurerPanel = (): JSX.Element => { const AdventurerPanel = (): JSX.Element => {
const { state, formatNumber } = useGame(); const { state, formatNumber, toggleAutoAdventurer } = useGame();
const [ showLocked, setShowLocked ] = useState(true); const [ showLocked, setShowLocked ] = useState(true);
const [ batchSize, setBatchSize ] = useState<BatchSize>(() => { const [ batchSize, setBatchSize ] = useState<BatchSize>(() => {
return parseBatchSize(localStorage.getItem("elysium_batch_size")); return parseBatchSize(localStorage.getItem("elysium_batch_size"));
@@ -207,6 +207,11 @@ const AdventurerPanel = (): JSX.Element => {
} }
} }
const autoAdventurerUnlocked = state.prestige.purchasedUpgradeIds.includes(
"auto_adventurer",
);
const autoAdventurerOn = state.autoAdventurer === true;
function handleToggle(): void { function handleToggle(): void {
setShowLocked((current) => { setShowLocked((current) => {
return !current; return !current;
@@ -217,12 +222,35 @@ const AdventurerPanel = (): JSX.Element => {
<section className="panel adventurer-panel"> <section className="panel adventurer-panel">
<div className="panel-header"> <div className="panel-header">
<h2>{"Adventurers"}</h2> <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 <LockToggle
lockedCount={locked.length} lockedCount={locked.length}
onToggle={handleToggle} onToggle={handleToggle}
showLocked={showLocked} showLocked={showLocked}
/> />
</div> </div>
</div>
<div className="batch-selector"> <div className="batch-selector">
{batchOptions.map((option) => { {batchOptions.map((option) => {
function handleBatchSelect(): void { function handleBatchSelect(): void {
+44 -23
View File
@@ -12,6 +12,49 @@ import { ConfirmationModal } from "../ui/confirmationModal.js";
type ActiveModal = "force-unlocks" | "hard-reset" | null; 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. * Renders the debug panel with tools for fixing stuck game state.
* @returns The JSX element. * @returns The JSX element.
@@ -38,29 +81,7 @@ const DebugPanel = (): JSX.Element => {
setActiveModal(null); setActiveModal(null);
void (async(): Promise<void> => { void (async(): Promise<void> => {
const result = await forceUnlocks(); const result = await forceUnlocks();
const parts: Array<string> = []; setForceUnlocksResult(buildForceUnlocksMessage(result));
if (result.zonesUnlocked > 0) {
parts.push(`${String(result.zonesUnlocked)} zone(s)`);
}
if (result.questsUnlocked > 0) {
parts.push(`${String(result.questsUnlocked)} quest(s)`);
}
if (result.bossesUnlocked > 0) {
parts.push(`${String(result.bossesUnlocked)} boss(es)`);
}
if (result.explorationUnlocked > 0) {
parts.push(`${String(result.explorationUnlocked)} exploration area(s)`);
}
const total
= result.zonesUnlocked
+ result.questsUnlocked
+ result.bossesUnlocked
+ result.explorationUnlocked;
const message
= parts.length === 0
? "Everything looks correct — no missing unlocks were found."
: `Fixed ${String(total)} unlock(s): ${parts.join(", ")}.`;
setForceUnlocksResult(message);
})(); })();
} }
+17 -11
View File
@@ -558,9 +558,13 @@ interface GameContextValue {
* @returns Counts of what was corrected. * @returns Counts of what was corrected.
*/ */
forceUnlocks: ()=> Promise<{ forceUnlocks: ()=> Promise<{
adventurersUnlocked: number;
bossesUnlocked: number; bossesUnlocked: number;
equipmentUnlocked: number;
explorationUnlocked: number; explorationUnlocked: number;
questsUnlocked: number; questsUnlocked: number;
storyUnlocked: number;
upgradesUnlocked: number;
zonesUnlocked: number; zonesUnlocked: number;
}>; }>;
@@ -1144,14 +1148,6 @@ 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; return next;
}); });
@@ -1316,11 +1312,13 @@ export const GameProvider = ({
/* /*
* "Boss is not currently available" is an expected race condition * "Boss is not currently available" is an expected race condition
* in the tick loop — suppress telemetry for this case only * 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") { if (message === "Boss is not currently available") {
logError("auto_boss", error_); return;
} }
logError("auto_boss", error_);
setAutoBossError(message); setAutoBossError(message);
setState((previous) => { setState((previous) => {
if (previous === null) { if (previous === null) {
@@ -2110,9 +2108,13 @@ export const GameProvider = ({
localStorage.setItem("elysium_save_signature", data.signature); localStorage.setItem("elysium_save_signature", data.signature);
} }
return { return {
adventurersUnlocked: data.adventurersUnlocked,
bossesUnlocked: data.bossesUnlocked, bossesUnlocked: data.bossesUnlocked,
equipmentUnlocked: data.equipmentUnlocked,
explorationUnlocked: data.explorationUnlocked, explorationUnlocked: data.explorationUnlocked,
questsUnlocked: data.questsUnlocked, questsUnlocked: data.questsUnlocked,
storyUnlocked: data.storyUnlocked,
upgradesUnlocked: data.upgradesUnlocked,
zonesUnlocked: data.zonesUnlocked, zonesUnlocked: data.zonesUnlocked,
}; };
} catch (error_: unknown) { } catch (error_: unknown) {
@@ -2122,9 +2124,13 @@ export const GameProvider = ({
: "Failed to force unlocks", : "Failed to force unlocks",
); );
return { return {
adventurersUnlocked: 0,
bossesUnlocked: 0, bossesUnlocked: 0,
equipmentUnlocked: 0,
explorationUnlocked: 0, explorationUnlocked: 0,
questsUnlocked: 0, questsUnlocked: 0,
storyUnlocked: 0,
upgradesUnlocked: 0,
zonesUnlocked: 0, zonesUnlocked: 0,
}; };
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "elysium", "name": "elysium",
"version": "0.2.0", "version": "0.2.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/types", "name": "@elysium/types",
"version": "0.2.0", "version": "0.2.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
+20
View File
@@ -425,6 +425,26 @@ interface ForceUnlocksResponse {
*/ */
explorationUnlocked: number; 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. * HMAC-SHA256 signature of the corrected state for anti-cheat chain continuity.
*/ */