generated from nhcarrigan/template
fix: real-time companion unlocks, persist exploration endsAt, correct auto-prestige formula, and quest balance
Closes #191 Closes #205 Closes #206 Closes #212 Closes #214 Closes #216 Closes #224
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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({}).
|
||||||
|
|||||||
Reference in New Issue
Block a user