feat: another balance and bug fix pass #238

Merged
naomi merged 9 commits from feat/another-pass into main 2026-04-06 18:17:01 -07:00
4 changed files with 82 additions and 14 deletions
Showing only changes of commit 99ca3083a1 - Show all commits
+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({}).