fix: real-time companion unlocks, persist exploration endsAt, correct auto-prestige formula, and quest balance
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m3s
CI / Lint, Build & Test (pull_request) Failing after 1m13s

Closes #191
Closes #205
Closes #206
Closes #212
Closes #214
Closes #216
Closes #224
This commit is contained in:
2026-04-06 13:39:35 -07:00
committed by Naomi Carrigan
parent 69579e166a
commit 99ca3083a1
4 changed files with 82 additions and 14 deletions
+9 -9
View File
@@ -77,7 +77,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 500,
description:
"A rogue necromancer has raised an army of skeletons near the city. Silence him before the dead overrun us.",
durationSeconds: 5 * 60,
durationSeconds: 30 * 60,
id: "necromancer_tower",
name: "Necromancer's Tower",
prerequisiteIds: [],
@@ -94,7 +94,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 2000,
description:
"An ancient fortress still garrisoned by constructs who don't know the war ended. Clear it out and claim its vaults.",
durationSeconds: 5 * 60,
durationSeconds: 45 * 60,
id: "crumbling_fortress",
name: "The Crumbling Fortress",
prerequisiteIds: [ "necromancer_tower" ],
@@ -111,7 +111,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 8000,
description:
"A vast library sealed for centuries whose contents have warped and grown hostile. The knowledge within is priceless.",
durationSeconds: 10 * 60,
durationSeconds: 60 * 60,
id: "cursed_library",
name: "The Cursed Library",
prerequisiteIds: [ "crumbling_fortress" ],
@@ -127,7 +127,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 30_000,
description:
"The legendary lair of Pyraxis the Undying. Few who enter return — those who do are rich beyond imagining.",
durationSeconds: 15 * 60,
durationSeconds: 90 * 60,
id: "dragon_lair",
name: "Dragon's Lair",
prerequisiteIds: [ "cursed_library" ],
@@ -545,7 +545,7 @@ export const defaultQuests: Array<Quest> = [
rewards: [
{ amount: 3_000_000_000_000, type: "gold" },
{ amount: 1_500_000_000, type: "essence" },
{ amount: 12_000_000, type: "crystals" },
{ amount: 0, type: "crystals" },
],
status: "locked",
zoneId: "abyssal_trench",
@@ -561,7 +561,7 @@ export const defaultQuests: Array<Quest> = [
rewards: [
{ amount: 10_000_000_000_000, type: "gold" },
{ amount: 5_000_000_000, type: "essence" },
{ amount: 30_000_000, type: "crystals" },
{ amount: 0, type: "crystals" },
],
status: "locked",
zoneId: "abyssal_trench",
@@ -577,7 +577,7 @@ export const defaultQuests: Array<Quest> = [
rewards: [
{ amount: 30_000_000_000_000, type: "gold" },
{ amount: 15_000_000_000, type: "essence" },
{ amount: 60_000_000, type: "crystals" },
{ amount: 0, type: "crystals" },
],
status: "locked",
zoneId: "abyssal_trench",
@@ -593,7 +593,7 @@ export const defaultQuests: Array<Quest> = [
rewards: [
{ amount: 100_000_000_000_000, type: "gold" },
{ amount: 50_000_000_000, type: "essence" },
{ amount: 120_000_000, type: "crystals" },
{ amount: 0, type: "crystals" },
{ targetId: "abyss_diver_1", type: "upgrade" },
],
status: "locked",
@@ -610,7 +610,7 @@ export const defaultQuests: Array<Quest> = [
rewards: [
{ amount: 400_000_000_000_000, type: "gold" },
{ amount: 200_000_000_000, type: "essence" },
{ amount: 400_000_000, type: "crystals" },
{ amount: 0, type: "crystals" },
],
status: "locked",
zoneId: "abyssal_trench",
+3 -3
View File
@@ -191,17 +191,17 @@ exploreRouter.post("/start", async(context) => {
}
const now = Date.now();
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
const endsAt = now + explorationArea.durationSeconds * 1000;
area.status = "in_progress";
area.startedAt = now;
area.endsAt = endsAt;
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: now },
where: { discordId },
});
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
const endsAt = now + explorationArea.durationSeconds * 1000;
const response: ExploreStartResponse = {
areaId,
endsAt,
+16
View File
@@ -246,6 +246,22 @@ describe("explore route", () => {
expect(body.endsAt).toBeGreaterThan(Date.now());
});
it("persists endsAt to the DB state on exploration start", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(200);
const body = await res.json() as { areaId: string; endsAt: number };
const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0]?.[0];
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Test accesses nested mock data */
const savedState = (updateCall?.data as { state?: { exploration?: { areas?: Array<{ id: string; endsAt?: number }> } } }).state;
const savedArea = savedState?.exploration?.areas?.find((a) => {
return a.id === TEST_AREA_ID;
});
expect(savedArea?.endsAt).toBe(body.endsAt);
});
it("backfills exploration state for old saves without exploration", async () => {
const state = makeState({ exploration: undefined });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
+54 -2
View File
@@ -22,6 +22,7 @@ import {
type NumberFormat,
type Quest,
type TranscendenceResponse,
computeUnlockedCompanionIds,
isStoryChapterUnlocked,
} from "@elysium/types";
import {
@@ -72,7 +73,6 @@ import { playSound } from "../utils/sound.js";
const autoSaveIntervalMs = 30_000;
const autoPrestigeThresholdBase = 1_000_000;
const autoPrestigeThresholdScale = 5;
/**
* Pure function — applies a boss challenge result to the game state.
@@ -1119,6 +1119,57 @@ export const GameProvider = ({
});
}, [ state ]);
// Detect newly unlocked companions whenever relevant state changes
useEffect(() => {
if (state === null) {
return;
}
const computedUnlocks = computeUnlockedCompanionIds({
apotheosisCount: state.apotheosis?.count ?? 0,
lifetimeBossesDefeated: state.player.lifetimeBossesDefeated,
lifetimeGoldEarned: state.player.lifetimeGoldEarned,
lifetimeQuestsCompleted: state.player.lifetimeQuestsCompleted,
prestigeCount: state.prestige.count,
transcendenceCount: state.transcendence?.count ?? 0,
});
const currentUnlocks = state.companions?.unlockedCompanionIds ?? [];
const toAdd = computedUnlocks.filter((id) => {
return !currentUnlocks.includes(id);
});
if (toAdd.length === 0) {
return;
}
setState((previous) => {
if (previous === null) {
return previous;
}
const existingUnlocks = previous.companions?.unlockedCompanionIds ?? [];
const addedIds = computedUnlocks.filter((id) => {
return !existingUnlocks.includes(id);
});
if (addedIds.length === 0) {
return previous;
}
const updatedUnlocks = [ ...existingUnlocks, ...addedIds ];
const activeId = previous.companions?.activeCompanionId ?? null;
const validatedActiveId
= activeId !== null && updatedUnlocks.includes(activeId)
? activeId
: null;
return {
...previous,
companions: {
activeCompanionId: validatedActiveId,
unlockedCompanionIds: updatedUnlocks,
},
};
});
}, [ state ]);
// Game loop via requestAnimationFrame
useEffect(() => {
@@ -1347,7 +1398,8 @@ export const GameProvider = ({
&& autoState.prestige.autoPrestigeEnabled === true
&& autoState.player.totalGoldEarned
>= autoPrestigeThresholdBase
* Math.pow(autoPrestigeThresholdScale, autoState.prestige.count)
* Math.pow(autoState.prestige.count + 1, 2.5)
* (autoState.transcendence?.echoPrestigeThresholdMultiplier ?? 1)
) {
isAutoPrestigingReference.current = true;
void prestigeApi({}).