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, combatPowerRequired: 500,
description: description:
"A rogue necromancer has raised an army of skeletons near the city. Silence him before the dead overrun us.", "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", id: "necromancer_tower",
name: "Necromancer's Tower", name: "Necromancer's Tower",
prerequisiteIds: [], prerequisiteIds: [],
@@ -94,7 +94,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 2000, combatPowerRequired: 2000,
description: description:
"An ancient fortress still garrisoned by constructs who don't know the war ended. Clear it out and claim its vaults.", "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", id: "crumbling_fortress",
name: "The Crumbling Fortress", name: "The Crumbling Fortress",
prerequisiteIds: [ "necromancer_tower" ], prerequisiteIds: [ "necromancer_tower" ],
@@ -111,7 +111,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 8000, combatPowerRequired: 8000,
description: description:
"A vast library sealed for centuries whose contents have warped and grown hostile. The knowledge within is priceless.", "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", id: "cursed_library",
name: "The Cursed Library", name: "The Cursed Library",
prerequisiteIds: [ "crumbling_fortress" ], prerequisiteIds: [ "crumbling_fortress" ],
@@ -127,7 +127,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 30_000, combatPowerRequired: 30_000,
description: description:
"The legendary lair of Pyraxis the Undying. Few who enter return — those who do are rich beyond imagining.", "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", id: "dragon_lair",
name: "Dragon's Lair", name: "Dragon's Lair",
prerequisiteIds: [ "cursed_library" ], prerequisiteIds: [ "cursed_library" ],
@@ -545,7 +545,7 @@ export const defaultQuests: Array<Quest> = [
rewards: [ rewards: [
{ amount: 3_000_000_000_000, type: "gold" }, { amount: 3_000_000_000_000, type: "gold" },
{ amount: 1_500_000_000, type: "essence" }, { amount: 1_500_000_000, type: "essence" },
{ amount: 12_000_000, type: "crystals" }, { amount: 0, type: "crystals" },
], ],
status: "locked", status: "locked",
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
@@ -561,7 +561,7 @@ export const defaultQuests: Array<Quest> = [
rewards: [ rewards: [
{ amount: 10_000_000_000_000, type: "gold" }, { amount: 10_000_000_000_000, type: "gold" },
{ amount: 5_000_000_000, type: "essence" }, { amount: 5_000_000_000, type: "essence" },
{ amount: 30_000_000, type: "crystals" }, { amount: 0, type: "crystals" },
], ],
status: "locked", status: "locked",
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
@@ -577,7 +577,7 @@ export const defaultQuests: Array<Quest> = [
rewards: [ rewards: [
{ amount: 30_000_000_000_000, type: "gold" }, { amount: 30_000_000_000_000, type: "gold" },
{ amount: 15_000_000_000, type: "essence" }, { amount: 15_000_000_000, type: "essence" },
{ amount: 60_000_000, type: "crystals" }, { amount: 0, type: "crystals" },
], ],
status: "locked", status: "locked",
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
@@ -593,7 +593,7 @@ export const defaultQuests: Array<Quest> = [
rewards: [ rewards: [
{ amount: 100_000_000_000_000, type: "gold" }, { amount: 100_000_000_000_000, type: "gold" },
{ amount: 50_000_000_000, type: "essence" }, { amount: 50_000_000_000, type: "essence" },
{ amount: 120_000_000, type: "crystals" }, { amount: 0, type: "crystals" },
{ targetId: "abyss_diver_1", type: "upgrade" }, { targetId: "abyss_diver_1", type: "upgrade" },
], ],
status: "locked", status: "locked",
@@ -610,7 +610,7 @@ export const defaultQuests: Array<Quest> = [
rewards: [ rewards: [
{ amount: 400_000_000_000_000, type: "gold" }, { amount: 400_000_000_000_000, type: "gold" },
{ amount: 200_000_000_000, type: "essence" }, { amount: 200_000_000_000, type: "essence" },
{ amount: 400_000_000, type: "crystals" }, { amount: 0, type: "crystals" },
], ],
status: "locked", status: "locked",
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
+3 -3
View File
@@ -191,17 +191,17 @@ exploreRouter.post("/start", async(context) => {
} }
const now = Date.now(); 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.status = "in_progress";
area.startedAt = now; area.startedAt = now;
area.endsAt = endsAt;
await prisma.gameState.update({ await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: now }, data: { state: state as object, updatedAt: now },
where: { discordId }, where: { discordId },
}); });
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
const endsAt = now + explorationArea.durationSeconds * 1000;
const response: ExploreStartResponse = { const response: ExploreStartResponse = {
areaId, areaId,
endsAt, endsAt,
+16
View File
@@ -246,6 +246,22 @@ describe("explore route", () => {
expect(body.endsAt).toBeGreaterThan(Date.now()); 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 () => { it("backfills exploration state for old saves without exploration", async () => {
const state = makeState({ exploration: undefined }); const state = makeState({ exploration: undefined });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
+54 -2
View File
@@ -22,6 +22,7 @@ import {
type NumberFormat, type NumberFormat,
type Quest, type Quest,
type TranscendenceResponse, type TranscendenceResponse,
computeUnlockedCompanionIds,
isStoryChapterUnlocked, isStoryChapterUnlocked,
} from "@elysium/types"; } from "@elysium/types";
import { import {
@@ -72,7 +73,6 @@ import { playSound } from "../utils/sound.js";
const autoSaveIntervalMs = 30_000; const autoSaveIntervalMs = 30_000;
const autoPrestigeThresholdBase = 1_000_000; const autoPrestigeThresholdBase = 1_000_000;
const autoPrestigeThresholdScale = 5;
/** /**
* Pure function — applies a boss challenge result to the game state. * Pure function — applies a boss challenge result to the game state.
@@ -1119,6 +1119,57 @@ export const GameProvider = ({
}); });
}, [ state ]); }, [ 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 // Game loop via requestAnimationFrame
useEffect(() => { useEffect(() => {
@@ -1347,7 +1398,8 @@ export const GameProvider = ({
&& autoState.prestige.autoPrestigeEnabled === true && autoState.prestige.autoPrestigeEnabled === true
&& autoState.player.totalGoldEarned && autoState.player.totalGoldEarned
>= autoPrestigeThresholdBase >= autoPrestigeThresholdBase
* Math.pow(autoPrestigeThresholdScale, autoState.prestige.count) * Math.pow(autoState.prestige.count + 1, 2.5)
* (autoState.transcendence?.echoPrestigeThresholdMultiplier ?? 1)
) { ) {
isAutoPrestigingReference.current = true; isAutoPrestigingReference.current = true;
void prestigeApi({}). void prestigeApi({}).