diff --git a/apps/api/src/data/quests.ts b/apps/api/src/data/quests.ts index 5058d60..fac9c44 100644 --- a/apps/api/src/data/quests.ts +++ b/apps/api/src/data/quests.ts @@ -77,7 +77,7 @@ export const defaultQuests: Array = [ 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 = [ 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 = [ 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 = [ 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 = [ 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 = [ 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 = [ 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 = [ 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 = [ 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", diff --git a/apps/api/src/routes/explore.ts b/apps/api/src/routes/explore.ts index a7b80f1..6ac4c4a 100644 --- a/apps/api/src/routes/explore.ts +++ b/apps/api/src/routes/explore.ts @@ -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, diff --git a/apps/api/test/routes/explore.spec.ts b/apps/api/test/routes/explore.spec.ts index 848be98..dfe5885 100644 --- a/apps/api/test/routes/explore.spec.ts +++ b/apps/api/test/routes/explore.spec.ts @@ -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); diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index 6ceb7b1..cf16a9d 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -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({}).