generated from nhcarrigan/template
feat: another balance and bug fix pass (#238)
Working through open issues — fixes, balance changes, and features. ## Closed - Closes #161 - Closes #181 - Closes #191 - Closes #199 - Closes #201 - Closes #202 - Closes #203 - Closes #204 - Closes #205 - Closes #206 - Closes #208 - Closes #211 - Closes #212 - Closes #213 - Closes #214 - Closes #216 - Closes #219 - Closes #220 - Closes #221 - Closes #222 - Closes #224 - Closes #225 - Closes #226 - Closes #228 - Closes #229 - Closes #230 - Closes #231 - Closes #232 - Closes #233 - Closes #234 - Closes #235 - Closes #236 ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #238 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #238.
This commit is contained in:
@@ -334,8 +334,8 @@ export const defaultAchievements: Array<Achievement> = [
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 112, type: "questsCompleted" },
|
||||
description: "Complete all 112 quests across the known multiverse.",
|
||||
condition: { amount: 122, type: "questsCompleted" },
|
||||
description: "Complete all 122 quests across the known multiverse.",
|
||||
icon: "🌌",
|
||||
id: "quest_eternal",
|
||||
name: "Quest Eternal",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -20,6 +20,7 @@ import { gameRouter } from "./routes/game.js";
|
||||
import { leaderboardRouter } from "./routes/leaderboards.js";
|
||||
import { prestigeRouter } from "./routes/prestige.js";
|
||||
import { profileRouter } from "./routes/profile.js";
|
||||
import { timersRouter } from "./routes/timers.js";
|
||||
import { transcendenceRouter } from "./routes/transcendence.js";
|
||||
import { connectGateway } from "./services/gateway.js";
|
||||
import { logger } from "./services/logger.js";
|
||||
@@ -49,6 +50,7 @@ app.route("/transcendence", transcendenceRouter);
|
||||
app.route("/apotheosis", apotheosisRouter);
|
||||
app.route("/leaderboards", leaderboardRouter);
|
||||
app.route("/profile", profileRouter);
|
||||
app.route("/timers", timersRouter);
|
||||
|
||||
app.get("/health", (context) => {
|
||||
return context.json({ status: "ok" });
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { Hono } from "hono";
|
||||
import { defaultBosses } from "../data/bosses.js";
|
||||
import { defaultEquipmentSets } from "../data/equipmentSets.js";
|
||||
import { defaultExplorations } from "../data/explorations.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
||||
@@ -205,9 +206,11 @@ bossRouter.post("/challenge", async(context) => {
|
||||
boss.status = "defeated";
|
||||
boss.currentHp = 0;
|
||||
|
||||
const crystalMult = state.prestige.runestonesCrystalMultiplier ?? 1;
|
||||
state.resources.gold = state.resources.gold + boss.goldReward;
|
||||
state.resources.essence = state.resources.essence + boss.essenceReward;
|
||||
state.resources.crystals = state.resources.crystals + boss.crystalReward;
|
||||
const crystalAward = boss.crystalReward * crystalMult;
|
||||
state.resources.crystals = state.resources.crystals + crystalAward;
|
||||
state.player.totalGoldEarned = state.player.totalGoldEarned + boss.goldReward;
|
||||
|
||||
for (const upgradeId of boss.upgradeRewards) {
|
||||
@@ -282,6 +285,19 @@ bossRouter.post("/challenge", async(context) => {
|
||||
continue;
|
||||
}
|
||||
zone.status = "unlocked";
|
||||
|
||||
// Unlock exploration areas for the newly unlocked zone
|
||||
for (const area of state.exploration?.areas ?? []) {
|
||||
const areaDefinition = defaultExplorations.find((explorationArea) => {
|
||||
return explorationArea.id === area.id;
|
||||
});
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
if (areaDefinition?.zoneId === zone.id && area.status === "locked") {
|
||||
area.status = "available";
|
||||
}
|
||||
}
|
||||
|
||||
const updatedZoneBosses = state.bosses.filter((b) => {
|
||||
return b.zoneId === zone.id;
|
||||
});
|
||||
@@ -323,7 +339,7 @@ bossRouter.post("/challenge", async(context) => {
|
||||
|
||||
rewards = {
|
||||
bountyRunestones: bountyRunestones,
|
||||
crystals: boss.crystalReward,
|
||||
crystals: crystalAward,
|
||||
equipmentIds: boss.equipmentRewards,
|
||||
essence: boss.essenceReward,
|
||||
gold: boss.goldReward,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -546,6 +546,17 @@ const validateAndSanitize = (
|
||||
? previous.prestige
|
||||
: incoming.prestige;
|
||||
|
||||
/*
|
||||
* If the DB prestige count is higher than the client's, the client is sending a
|
||||
* stale pre-prestige save. Discard its upgrades (which have purchased: true) in
|
||||
* favour of the DB's post-prestige upgrades (purchased: false) so that upgrade
|
||||
* multipliers cannot persist across prestige via a race-condition auto-save.
|
||||
*/
|
||||
const upgrades
|
||||
= incoming.prestige.count < previous.prestige.count
|
||||
? previous.upgrades
|
||||
: incoming.upgrades;
|
||||
|
||||
/*
|
||||
* Echoes are only granted server-side via transcendence and can only decrease between
|
||||
* saves (spent on echo upgrades). Cap at the previous value to block inflation.
|
||||
@@ -611,11 +622,17 @@ const validateAndSanitize = (
|
||||
= Math.min(material.quantity, previousQuantity);
|
||||
return { ...material, quantity: cappedQuantity };
|
||||
});
|
||||
const craftedRecipeIds = incoming.exploration.craftedRecipeIds.filter(
|
||||
(recipeId) => {
|
||||
return previousExploration.craftedRecipeIds.includes(recipeId);
|
||||
},
|
||||
);
|
||||
|
||||
/*
|
||||
* Merge crafted recipe IDs from both states so the list can only ever grow.
|
||||
* A stale auto-save arriving after a craft must not silently un-craft items.
|
||||
*/
|
||||
const craftedRecipeIds = [
|
||||
...new Set([
|
||||
...previousExploration.craftedRecipeIds,
|
||||
...incoming.exploration.craftedRecipeIds,
|
||||
]),
|
||||
];
|
||||
explorationSpread = {
|
||||
exploration: {
|
||||
...incoming.exploration,
|
||||
@@ -671,6 +688,7 @@ const validateAndSanitize = (
|
||||
prestige,
|
||||
quests,
|
||||
resources,
|
||||
upgrades,
|
||||
...transcendenceSpread,
|
||||
...apotheosisSpread,
|
||||
...explorationSpread,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import {
|
||||
buildPostPrestigeState,
|
||||
calculatePrestigeThreshold,
|
||||
computeRunestoneMultipliers,
|
||||
isEligibleForPrestige,
|
||||
} from "../services/prestige.js";
|
||||
@@ -40,10 +41,15 @@ prestigeRouter.post("/", async(context) => {
|
||||
const state = record.state as unknown as GameState;
|
||||
|
||||
if (!isEligibleForPrestige(state)) {
|
||||
const thresholdMultiplier
|
||||
= state.transcendence?.echoPrestigeThresholdMultiplier ?? 1;
|
||||
const required = calculatePrestigeThreshold(
|
||||
state.prestige.count,
|
||||
thresholdMultiplier,
|
||||
);
|
||||
return context.json(
|
||||
{
|
||||
// eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened
|
||||
error: "Not eligible for prestige — collect 1,000,000 total gold first",
|
||||
error: `Not eligible for prestige — collect ${required.toLocaleString()} total gold first`,
|
||||
},
|
||||
400,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @file Public read-only timer API for external tooling (bots, automations, etc.).
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { Hono } from "hono";
|
||||
import { defaultExplorations } from "../data/explorations.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
const timersRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
const explorationNameMap = new Map(
|
||||
defaultExplorations.map((area) => {
|
||||
return [ area.id, area.name ];
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Extracts active quest timers from a game state.
|
||||
* @param state - The player's game state.
|
||||
* @param now - The current timestamp in milliseconds.
|
||||
* @returns An array of active quest timer objects.
|
||||
*/
|
||||
const getQuestTimers = (
|
||||
state: GameState,
|
||||
now: number,
|
||||
): Array<{
|
||||
endsAt: number;
|
||||
name: string;
|
||||
questId: string;
|
||||
timeLeft: number;
|
||||
}> => {
|
||||
return state.quests.
|
||||
filter((quest) => {
|
||||
return quest.status === "active" && quest.startedAt !== undefined;
|
||||
}).
|
||||
map((quest) => {
|
||||
const durationMs = quest.durationSeconds * 1000;
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const endsAt = (quest.startedAt ?? 0) + durationMs;
|
||||
return {
|
||||
endsAt: endsAt,
|
||||
name: quest.name,
|
||||
questId: quest.id,
|
||||
timeLeft: Math.max(0, endsAt - now),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts active exploration timers from a game state.
|
||||
* @param state - The player's game state.
|
||||
* @param now - The current timestamp in milliseconds.
|
||||
* @returns An array of active exploration timer objects.
|
||||
*/
|
||||
const getExplorationTimers = (
|
||||
state: GameState,
|
||||
now: number,
|
||||
): Array<{
|
||||
areaId: string;
|
||||
endsAt: number;
|
||||
name: string;
|
||||
timeLeft: number;
|
||||
}> => {
|
||||
return (state.exploration?.areas ?? []).
|
||||
filter((area) => {
|
||||
return area.status === "in_progress" && area.endsAt !== undefined;
|
||||
}).
|
||||
map((area) => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const endsAt = area.endsAt ?? 0;
|
||||
return {
|
||||
areaId: area.id,
|
||||
endsAt: endsAt,
|
||||
name: explorationNameMap.get(area.id) ?? area.id,
|
||||
timeLeft: Math.max(0, endsAt - now),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns active quest and exploration timers for a given player.
|
||||
* This endpoint is public and read-only — no authentication required.
|
||||
* Rate limiting is enforced at the infrastructure level.
|
||||
*/
|
||||
timersRouter.get("/:userId", async(context) => {
|
||||
try {
|
||||
const { userId } = context.req.param();
|
||||
|
||||
if (userId.length === 0 || !/^\d+$/u.test(userId)) {
|
||||
return context.json({ error: "Invalid user ID" }, 400);
|
||||
}
|
||||
|
||||
const record = await prisma.gameState.findUnique({
|
||||
where: { discordId: userId },
|
||||
});
|
||||
|
||||
if (record === null) {
|
||||
return context.json({ error: "Player not found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
const now = Date.now();
|
||||
|
||||
return context.json({
|
||||
explorations: getExplorationTimers(state, now),
|
||||
quests: getQuestTimers(state, now),
|
||||
});
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"timers",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { timersRouter };
|
||||
@@ -28,8 +28,8 @@ const maxBaseRunestones = 200;
|
||||
|
||||
/**
|
||||
* Calculates the gold threshold required for the next prestige.
|
||||
* Formula: BASE * (count + 1)^2 — polynomial growth that peaks around prestige 8–10
|
||||
* then gets easier as the production multiplier overtakes it.
|
||||
* Formula: BASE * (count + 1)^2.5 — steeper growth to keep late prestiges
|
||||
* meaningful even as the production multiplier scales.
|
||||
* @param prestigeCount - The current number of prestiges completed.
|
||||
* @param thresholdMultiplier - An optional echo-upgrade multiplier applied to the threshold.
|
||||
* @returns The gold amount required to prestige.
|
||||
@@ -40,7 +40,7 @@ const calculatePrestigeThreshold = (
|
||||
): number => {
|
||||
return (
|
||||
basePrestigeGoldThreshold
|
||||
* Math.pow(prestigeCount + 1, 2)
|
||||
* Math.pow(prestigeCount + 1, 2.5)
|
||||
* thresholdMultiplier
|
||||
);
|
||||
};
|
||||
@@ -189,6 +189,7 @@ const buildPostPrestigeState = (
|
||||
} => {
|
||||
const {
|
||||
autoPrestigeEnabled,
|
||||
autoPrestigeMaxRunestonesOnly,
|
||||
count: currentPrestigeCount,
|
||||
purchasedUpgradeIds,
|
||||
runestones: currentRunestones,
|
||||
@@ -215,6 +216,9 @@ const buildPostPrestigeState = (
|
||||
...autoPrestigeEnabled === undefined
|
||||
? {}
|
||||
: { autoPrestigeEnabled },
|
||||
...autoPrestigeMaxRunestonesOnly === undefined
|
||||
? {}
|
||||
: { autoPrestigeMaxRunestonesOnly },
|
||||
};
|
||||
|
||||
const freshState = initialGameState(currentState.player, characterName);
|
||||
|
||||
@@ -294,6 +294,52 @@ describe("boss route", () => {
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("handles zone unlock gracefully when exploration state is undefined", async () => {
|
||||
const state = makeState({
|
||||
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
|
||||
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
||||
zones: [{ id: "test_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: null }] as GameState["zones"],
|
||||
quests: [],
|
||||
exploration: undefined,
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("unlocks exploration areas when a zone is unlocked on boss defeat", async () => {
|
||||
const state = makeState({
|
||||
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
|
||||
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
||||
zones: [{ id: "test_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: null }] as GameState["zones"],
|
||||
quests: [],
|
||||
exploration: {
|
||||
areas: [{ id: "test_area", status: "locked" as const }],
|
||||
materials: [],
|
||||
craftedRecipeIds: [],
|
||||
craftedGoldMultiplier: 1,
|
||||
craftedEssenceMultiplier: 1,
|
||||
craftedClickMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
},
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
let savedState: GameState | undefined;
|
||||
vi.mocked(prisma.gameState.update).mockImplementationOnce(async (args) => {
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Test assertion */
|
||||
savedState = (args as { data: { state: GameState } }).data.state;
|
||||
return {} as never;
|
||||
});
|
||||
const res = await challenge({ bossId: "test_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
// Exploration area should remain locked — no matching defaultExploration for "test_area"
|
||||
const area = savedState?.exploration?.areas.find((a) => a.id === "test_area");
|
||||
expect(area?.status).toBe("locked");
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await challenge({ bossId: "test_boss" });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -477,6 +477,28 @@ describe("game route", () => {
|
||||
expect(body.savedAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("restores previous upgrades when incoming prestige count is lower (stale post-prestige save)", async () => {
|
||||
const prevUpgrades = [
|
||||
{ id: "click_1", purchased: false, unlocked: true, target: "click", multiplier: 2 },
|
||||
] as GameState["upgrades"];
|
||||
const prevState = makeState({
|
||||
prestige: { count: 1, runestones: 10, productionMultiplier: 1.3, purchasedUpgradeIds: [] },
|
||||
upgrades: prevUpgrades,
|
||||
});
|
||||
const incomingState = makeState({
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
upgrades: [
|
||||
{ id: "click_1", purchased: true, unlocked: true, target: "click", multiplier: 2 },
|
||||
] as GameState["upgrades"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer({ createdAt: Date.now() }) as never);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
||||
const res = await save({ state: incomingState });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("validates companion when active companion is legitimately unlocked", async () => {
|
||||
const prevState = makeState();
|
||||
const stateWithCompanion = makeState({
|
||||
|
||||
@@ -81,6 +81,16 @@ describe("prestige route", () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 with echoPrestigeThresholdMultiplier applied when transcendence is present", async () => {
|
||||
const state = makeState({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 500_000, totalClicks: 0, characterName: "T" },
|
||||
transcendence: { count: 1, echoes: 0, purchasedUpgradeIds: [], echoIncomeMultiplier: 1, echoCombatMultiplier: 1, echoPrestigeThresholdMultiplier: 2, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1 },
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns runestones on successful prestige", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
gameState: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/services/logger.js", () => ({
|
||||
logger: {
|
||||
error: vi.fn().mockResolvedValue(undefined),
|
||||
log: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const makeState = (overrides: Record<string, unknown> = {}) => ({
|
||||
quests: [],
|
||||
exploration: { areas: [] },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("timers route", () => {
|
||||
let app: Hono;
|
||||
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn> } };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { timersRouter } = await import("../../src/routes/timers.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/timers", timersRouter);
|
||||
});
|
||||
|
||||
const get = (userId: string) =>
|
||||
app.fetch(new Request(`http://localhost/timers/${userId}`));
|
||||
|
||||
it("returns 400 for a non-numeric user ID", async () => {
|
||||
const res = await get("not-a-number");
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Invalid user ID");
|
||||
});
|
||||
|
||||
it("returns 404 when player is not found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await get("123456789");
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Player not found");
|
||||
});
|
||||
|
||||
it("returns empty arrays when no active quests or explorations", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({
|
||||
state: makeState(),
|
||||
});
|
||||
const res = await get("123456789");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { quests: unknown[]; explorations: unknown[] };
|
||||
expect(body.quests).toEqual([]);
|
||||
expect(body.explorations).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns active quest timers with endsAt computed from startedAt + duration", async () => {
|
||||
const startedAt = Date.now() - 30_000;
|
||||
const state = makeState({
|
||||
quests: [
|
||||
{
|
||||
id: "q1",
|
||||
name: "Forest Patrol",
|
||||
status: "active",
|
||||
startedAt: startedAt,
|
||||
durationSeconds: 600,
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
|
||||
const res = await get("123456789");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as {
|
||||
quests: Array<{ questId: string; name: string; endsAt: number; timeLeft: number }>;
|
||||
};
|
||||
expect(body.quests).toHaveLength(1);
|
||||
expect(body.quests[0]?.questId).toBe("q1");
|
||||
expect(body.quests[0]?.name).toBe("Forest Patrol");
|
||||
expect(body.quests[0]?.endsAt).toBe(startedAt + 600_000);
|
||||
expect(body.quests[0]?.timeLeft).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("filters out quests that are not in_progress", async () => {
|
||||
const state = makeState({
|
||||
quests: [
|
||||
{ id: "q1", name: "Done Quest", status: "completed", startedAt: 0, durationSeconds: 60 },
|
||||
{ id: "q2", name: "Idle Quest", status: "available", durationSeconds: 60 },
|
||||
],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
|
||||
const res = await get("123456789");
|
||||
const body = await res.json() as { quests: unknown[] };
|
||||
expect(body.quests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns timeLeft of 0 for already-completed quests still marked in_progress", async () => {
|
||||
const startedAt = Date.now() - 700_000;
|
||||
const state = makeState({
|
||||
quests: [
|
||||
{
|
||||
id: "q1",
|
||||
name: "Old Quest",
|
||||
status: "active",
|
||||
startedAt: startedAt,
|
||||
durationSeconds: 600,
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
|
||||
const res = await get("123456789");
|
||||
const body = await res.json() as {
|
||||
quests: Array<{ timeLeft: number }>;
|
||||
};
|
||||
expect(body.quests[0]?.timeLeft).toBe(0);
|
||||
});
|
||||
|
||||
it("returns active exploration timers", async () => {
|
||||
const endsAt = Date.now() + 120_000;
|
||||
const state = makeState({
|
||||
exploration: {
|
||||
areas: [
|
||||
{ id: "verdant_meadows", status: "in_progress", endsAt },
|
||||
{ id: "unknown_area_xyz", status: "in_progress", endsAt },
|
||||
],
|
||||
},
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
|
||||
const res = await get("123456789");
|
||||
const body = await res.json() as {
|
||||
explorations: Array<{ areaId: string; name: string; endsAt: number; timeLeft: number }>;
|
||||
};
|
||||
expect(body.explorations).toHaveLength(2);
|
||||
expect(body.explorations[0]?.areaId).toBe("verdant_meadows");
|
||||
expect(body.explorations[0]?.endsAt).toBe(endsAt);
|
||||
expect(body.explorations[0]?.timeLeft).toBeGreaterThan(0);
|
||||
// Unknown area falls back to ID as name
|
||||
expect(body.explorations[1]?.name).toBe("unknown_area_xyz");
|
||||
});
|
||||
|
||||
it("filters out explorations not in_progress", async () => {
|
||||
const state = makeState({
|
||||
exploration: {
|
||||
areas: [
|
||||
{ id: "area1", status: "available" },
|
||||
{ id: "area2", status: "completed" },
|
||||
],
|
||||
},
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
|
||||
const res = await get("123456789");
|
||||
const body = await res.json() as { explorations: unknown[] };
|
||||
expect(body.explorations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles missing exploration state gracefully", async () => {
|
||||
const state = { quests: [] };
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
|
||||
const res = await get("123456789");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { explorations: unknown[] };
|
||||
expect(body.explorations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns 500 on database error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(
|
||||
new Error("DB failure"),
|
||||
);
|
||||
const res = await get("123456789");
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Internal server error");
|
||||
});
|
||||
|
||||
it("returns 500 and logs non-Error throws", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("string error");
|
||||
const res = await get("123456789");
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -55,18 +55,18 @@ const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
|
||||
|
||||
describe("calculatePrestigeThreshold", () => {
|
||||
it("returns base threshold at count 0", () => {
|
||||
// base × (0+1)^2 = 1_000_000 × 1 = 1_000_000
|
||||
// base × (0+1)^2.5 = 1_000_000 × 1 = 1_000_000
|
||||
expect(calculatePrestigeThreshold(0)).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it("returns 4× base at count 1", () => {
|
||||
// base × (1+1)^2 = 1_000_000 × 4 = 4_000_000
|
||||
expect(calculatePrestigeThreshold(1)).toBe(4_000_000);
|
||||
it("returns base × 2^2.5 at count 1", () => {
|
||||
// base × (1+1)^2.5 = 1_000_000 × 2^2.5
|
||||
expect(calculatePrestigeThreshold(1)).toBeCloseTo(1_000_000 * Math.pow(2, 2.5));
|
||||
});
|
||||
|
||||
it("returns 9× base at count 2", () => {
|
||||
// base × (2+1)^2 = 1_000_000 × 9 = 9_000_000
|
||||
expect(calculatePrestigeThreshold(2)).toBe(9_000_000);
|
||||
it("returns base × 3^2.5 at count 2", () => {
|
||||
// base × (2+1)^2.5 = 1_000_000 × 3^2.5
|
||||
expect(calculatePrestigeThreshold(2)).toBeCloseTo(1_000_000 * Math.pow(3, 2.5));
|
||||
});
|
||||
|
||||
it("applies threshold multiplier correctly", () => {
|
||||
@@ -255,6 +255,20 @@ describe("buildPostPrestigeState", () => {
|
||||
expect(prestigeData.autoPrestigeEnabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves autoPrestigeMaxRunestonesOnly when set", () => {
|
||||
const state = makeMinimalState({
|
||||
prestige: { autoPrestigeMaxRunestonesOnly: true, count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
|
||||
});
|
||||
const { prestigeData } = buildPostPrestigeState(state, "Tester");
|
||||
expect(prestigeData.autoPrestigeMaxRunestonesOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("omits autoPrestigeMaxRunestonesOnly when not set", () => {
|
||||
const state = makeMinimalState();
|
||||
const { prestigeData } = buildPostPrestigeState(state, "Tester");
|
||||
expect(prestigeData.autoPrestigeMaxRunestonesOnly).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves apotheosis data across prestige", () => {
|
||||
const apotheosis = { count: 2 };
|
||||
const state = makeMinimalState({ apotheosis });
|
||||
|
||||
@@ -38,6 +38,26 @@ import type {
|
||||
|
||||
const baseUrl = "/api";
|
||||
|
||||
/**
|
||||
* Represents a 4xx API error so callers can distinguish expected server
|
||||
* rejections from unexpected failures. ValidationErrors are downgraded to
|
||||
* console.warn and are not forwarded to the error-email pipeline.
|
||||
*/
|
||||
class ValidationError extends Error {
|
||||
public readonly statusCode: number;
|
||||
|
||||
/**
|
||||
* Creates a new ValidationError.
|
||||
* @param message - The error message from the server response.
|
||||
* @param statusCode - The HTTP status code (4xx) returned by the server.
|
||||
*/
|
||||
public constructor(message: string, statusCode: number) {
|
||||
super(message);
|
||||
this.name = "ValidationError";
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
const getToken = (): string | null => {
|
||||
return globalThis.localStorage.getItem("elysium_token");
|
||||
};
|
||||
@@ -72,6 +92,9 @@ const fetchJson = async <T>(
|
||||
= typeof errorBody.error === "string"
|
||||
? errorBody.error
|
||||
: "Unknown error";
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
throw new ValidationError(message, response.status);
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
@@ -326,6 +349,7 @@ const updateProfile = async(
|
||||
};
|
||||
|
||||
export {
|
||||
ValidationError,
|
||||
achieveApotheosis,
|
||||
buyEchoUpgrade,
|
||||
buyPrestigeUpgrade,
|
||||
|
||||
@@ -232,7 +232,7 @@ const howToPlay = [
|
||||
{
|
||||
body:
|
||||
"Transcendence is the ultimate prestige layer, unlocked by defeating"
|
||||
+ " The Absolute One (requires Prestige 90). Transcending performs a"
|
||||
+ " The Absolute One (requires Prestige 20). Transcending performs a"
|
||||
+ " nuclear reset — wiping resources, prestige, runestones, upgrades,"
|
||||
+ " and equipment — but grants Echoes based on your prestige count"
|
||||
+ " (fewer prestiges = more Echoes). Echoes are permanent and survive"
|
||||
@@ -277,6 +277,15 @@ const howToPlay = [
|
||||
+ " when you first enable them.",
|
||||
title: "🔔 Sounds & Notifications",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Have a question, found a bug, or want to suggest a feature? Join the"
|
||||
+ " NHCarrigan community Discord at https://chat.nhcarrigan.com or open"
|
||||
+ " a support ticket at https://support.nhcarrigan.com. You can also"
|
||||
+ " report issues directly on the project repository. We'd love to hear"
|
||||
+ " from you!",
|
||||
title: "💬 Community & Support",
|
||||
},
|
||||
];
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
|
||||
@@ -156,7 +156,18 @@ const AchievementCard = ({
|
||||
</div>
|
||||
<div className="achievement-status">
|
||||
{isUnlocked
|
||||
? <span className="achievement-unlocked-badge">{"✓ Unlocked"}</span>
|
||||
? <>
|
||||
<span className="achievement-unlocked-badge">{"✓ Unlocked"}</span>
|
||||
{achievement.unlockedAt !== null
|
||||
&& <span className="achievement-unlocked-at">
|
||||
{new Date(achievement.unlockedAt).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
}
|
||||
</>
|
||||
: <span className="achievement-locked-badge">{"🔒"}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -62,6 +62,7 @@ const BattleModal = ({
|
||||
enableNotifications,
|
||||
enableSounds,
|
||||
flushBossLoreToasts,
|
||||
formatInteger,
|
||||
formatNumber,
|
||||
} = useGame();
|
||||
|
||||
@@ -241,14 +242,14 @@ const BattleModal = ({
|
||||
{result.rewards.crystals > 0
|
||||
&& <span>
|
||||
{"💎 "}
|
||||
{formatNumber(result.rewards.crystals)}
|
||||
{formatInteger(result.rewards.crystals)}
|
||||
{" crystals"}
|
||||
</span>
|
||||
}
|
||||
{result.rewards.bountyRunestones > 0
|
||||
&& <span className="battle-bounty">
|
||||
{"🔮 "}
|
||||
{formatNumber(result.rewards.bountyRunestones)}
|
||||
{formatInteger(result.rewards.bountyRunestones)}
|
||||
{" runestones (first kill!)"}
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ interface BossCardProperties {
|
||||
readonly onChallenge: (bossId: string)=> void;
|
||||
readonly isChallenging: boolean;
|
||||
readonly unlockHint: string | undefined;
|
||||
readonly formatInteger: (n: number)=> string;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
}
|
||||
|
||||
@@ -34,6 +35,7 @@ interface BossCardProperties {
|
||||
* @param props.onChallenge - Callback to challenge this boss.
|
||||
* @param props.isChallenging - Whether this boss is currently being challenged.
|
||||
* @param props.unlockHint - Optional hint for how to unlock this boss.
|
||||
* @param props.formatInteger - The integer formatting utility function.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
@@ -43,6 +45,7 @@ const BossCard = ({
|
||||
onChallenge,
|
||||
isChallenging,
|
||||
unlockHint,
|
||||
formatInteger,
|
||||
formatNumber,
|
||||
}: BossCardProperties): JSX.Element => {
|
||||
const scaled = boss.currentHp * 100;
|
||||
@@ -117,7 +120,7 @@ const BossCard = ({
|
||||
{boss.crystalReward > 0
|
||||
&& <span>
|
||||
{"💎 "}
|
||||
{formatNumber(boss.crystalReward)}
|
||||
{formatInteger(boss.crystalReward)}
|
||||
</span>
|
||||
}
|
||||
{boss.equipmentRewards.length > 0
|
||||
@@ -166,6 +169,7 @@ const BossPanel = (): JSX.Element => {
|
||||
const {
|
||||
state,
|
||||
challengeBoss,
|
||||
formatInteger,
|
||||
formatNumber,
|
||||
toggleAutoBoss,
|
||||
autoBossLastResult,
|
||||
@@ -395,6 +399,7 @@ const BossPanel = (): JSX.Element => {
|
||||
return (
|
||||
<BossCard
|
||||
boss={boss}
|
||||
formatInteger={formatInteger}
|
||||
formatNumber={formatNumber}
|
||||
isChallenging={challengingBossId === bossId}
|
||||
key={bossId}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||
/* eslint-disable max-lines-per-function -- Complex companion card with conditional renders */
|
||||
/* eslint-disable complexity -- Companion card has many conditional render paths */
|
||||
import { COMPANIONS, type Companion } from "@elysium/types";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
@@ -28,41 +29,13 @@ const unlockLabels: Record<string, string> = {
|
||||
transcendence: "transcendence(s)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a companion unlock threshold for display.
|
||||
* @param type - The unlock condition type.
|
||||
* @param threshold - The threshold value.
|
||||
* @returns The formatted threshold string.
|
||||
*/
|
||||
const formatThreshold = (type: string, threshold: number): string => {
|
||||
if (type === "lifetimeGold") {
|
||||
if (threshold >= 1e18) {
|
||||
return `${(threshold / 1e18).toFixed(0)}Qt`;
|
||||
}
|
||||
if (threshold >= 1e15) {
|
||||
return `${(threshold / 1e15).toFixed(0)}Q`;
|
||||
}
|
||||
if (threshold >= 1e12) {
|
||||
return `${(threshold / 1e12).toFixed(0)}T`;
|
||||
}
|
||||
if (threshold >= 1e9) {
|
||||
return `${(threshold / 1e9).toFixed(0)}B`;
|
||||
}
|
||||
if (threshold >= 1e6) {
|
||||
return `${(threshold / 1e6).toFixed(0)}M`;
|
||||
}
|
||||
if (threshold >= 1e3) {
|
||||
return `${(threshold / 1e3).toFixed(0)}K`;
|
||||
}
|
||||
}
|
||||
return threshold.toString();
|
||||
};
|
||||
|
||||
interface CompanionCardProperties {
|
||||
readonly companion: Companion;
|
||||
readonly isUnlocked: boolean;
|
||||
readonly isActive: boolean;
|
||||
readonly onSelect: ()=> void;
|
||||
readonly companion: Companion;
|
||||
readonly isUnlocked: boolean;
|
||||
readonly isActive: boolean;
|
||||
readonly onSelect: ()=> void;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
readonly currentProgress: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,6 +45,8 @@ interface CompanionCardProperties {
|
||||
* @param props.isUnlocked - Whether this companion is unlocked.
|
||||
* @param props.isActive - Whether this companion is currently active.
|
||||
* @param props.onSelect - Callback when the companion is selected/deselected.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @param props.currentProgress - The player's current progress toward the unlock threshold.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const CompanionCard = ({
|
||||
@@ -79,6 +54,8 @@ const CompanionCard = ({
|
||||
isUnlocked,
|
||||
isActive,
|
||||
onSelect,
|
||||
formatNumber,
|
||||
currentProgress,
|
||||
}: CompanionCardProperties): JSX.Element => {
|
||||
const bonusSign = companion.bonus.type === "questTime"
|
||||
? "-"
|
||||
@@ -137,12 +114,28 @@ const CompanionCard = ({
|
||||
: "Activate"}
|
||||
</button>
|
||||
: <div className="companion-unlock-requirement">
|
||||
{"🔒 Unlock: "}
|
||||
{formatThreshold(
|
||||
companion.unlock.type,
|
||||
companion.unlock.threshold,
|
||||
)}{" "}
|
||||
{unlockLabels[companion.unlock.type] ?? companion.unlock.type}
|
||||
<p>
|
||||
{"🔒 Unlock: "}
|
||||
{companion.unlock.type === "lifetimeGold"
|
||||
? formatNumber(companion.unlock.threshold)
|
||||
: String(companion.unlock.threshold)}{" "}
|
||||
{unlockLabels[companion.unlock.type] ?? companion.unlock.type}
|
||||
</p>
|
||||
<div className="companion-progress">
|
||||
<progress
|
||||
max={companion.unlock.threshold}
|
||||
value={Math.min(currentProgress, companion.unlock.threshold)}
|
||||
/>
|
||||
<span className="companion-progress-label">
|
||||
{companion.unlock.type === "lifetimeGold"
|
||||
? formatNumber(currentProgress)
|
||||
: String(currentProgress)}
|
||||
{" / "}
|
||||
{companion.unlock.type === "lifetimeGold"
|
||||
? formatNumber(companion.unlock.threshold)
|
||||
: String(companion.unlock.threshold)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -154,7 +147,7 @@ const CompanionCard = ({
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const CompanionPanel = (): JSX.Element => {
|
||||
const { state, setActiveCompanion } = useGame();
|
||||
const { formatNumber, setActiveCompanion, state } = useGame();
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
@@ -167,6 +160,15 @@ const CompanionPanel = (): JSX.Element => {
|
||||
const unlockedIds = state.companions?.unlockedCompanionIds ?? [];
|
||||
const activeId = state.companions?.activeCompanionId ?? null;
|
||||
|
||||
const progressByUnlockType: Record<string, number> = {
|
||||
apotheosis: state.apotheosis?.count ?? 0,
|
||||
lifetimeBosses: state.player.lifetimeBossesDefeated,
|
||||
lifetimeGold: state.player.lifetimeGoldEarned,
|
||||
lifetimeQuests: state.player.lifetimeQuestsCompleted,
|
||||
prestige: state.prestige.count,
|
||||
transcendence: state.transcendence?.count ?? 0,
|
||||
};
|
||||
|
||||
function handleSelect(companionId: string): void {
|
||||
setActiveCompanion(activeId === companionId
|
||||
? null
|
||||
@@ -204,6 +206,10 @@ const CompanionPanel = (): JSX.Element => {
|
||||
return (
|
||||
<CompanionCard
|
||||
companion={companion}
|
||||
currentProgress={
|
||||
progressByUnlockType[companion.unlock.type] ?? 0
|
||||
}
|
||||
formatNumber={formatNumber}
|
||||
isActive={activeId === companion.id}
|
||||
isUnlocked={unlockedIds.includes(companion.id)}
|
||||
key={companion.id}
|
||||
|
||||
@@ -27,12 +27,12 @@ const baseThreshold = 1_000_000;
|
||||
|
||||
/**
|
||||
* Calculates the prestige threshold for a given prestige count.
|
||||
* Mirrors the server formula: BASE * (count + 1)^2.
|
||||
* Mirrors the server formula: BASE * (count + 1)^2.5.
|
||||
* @param prestigeCount - The current prestige count.
|
||||
* @returns The required gold to prestige.
|
||||
*/
|
||||
const calculateThreshold = (prestigeCount: number): number => {
|
||||
return baseThreshold * Math.pow(prestigeCount + 1, 2);
|
||||
return baseThreshold * Math.pow(prestigeCount + 1, 2.5);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -41,7 +41,7 @@ const calculateThreshold = (prestigeCount: number): number => {
|
||||
* @returns The compounding multiplier applied to all income sources.
|
||||
*/
|
||||
const calculateProductionMultiplier = (prestigeCount: number): number => {
|
||||
return Math.pow(1.15, prestigeCount);
|
||||
return Math.pow(1.3, prestigeCount);
|
||||
};
|
||||
|
||||
const categoryOrder: Array<PrestigeUpgradeCategory> = [
|
||||
@@ -61,12 +61,14 @@ const PrestigePanel = (): JSX.Element => {
|
||||
const {
|
||||
state,
|
||||
reloadSilent,
|
||||
formatInteger,
|
||||
formatNumber,
|
||||
buyPrestigeUpgrade,
|
||||
enableNotifications,
|
||||
enableSounds,
|
||||
toggleAutoAdventurer,
|
||||
toggleAutoPrestige,
|
||||
toggleAutoPrestigeMaxRunestones,
|
||||
triggerPrestigeToast,
|
||||
} = useGame();
|
||||
const [ isPending, setIsPending ] = useState(false);
|
||||
@@ -92,6 +94,11 @@ const PrestigePanel = (): JSX.Element => {
|
||||
const isEligible = player.totalGoldEarned >= threshold;
|
||||
const runestonePreview = computeProjectedRunestones(state);
|
||||
const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1);
|
||||
const baseRunestones = Math.min(
|
||||
Math.floor(Math.cbrt(player.totalGoldEarned / threshold)) * 15,
|
||||
200,
|
||||
);
|
||||
const isAtMaxRunestones = baseRunestones >= 200;
|
||||
|
||||
async function handlePrestige(): Promise<void> {
|
||||
setIsPending(true);
|
||||
@@ -154,6 +161,10 @@ const PrestigePanel = (): JSX.Element => {
|
||||
toggleAutoPrestige();
|
||||
}
|
||||
|
||||
function handleAutoPrestigeMaxRunestonesToggle(): void {
|
||||
toggleAutoPrestigeMaxRunestones();
|
||||
}
|
||||
|
||||
function handlePrestigeTabClick(): void {
|
||||
setActiveTab("prestige");
|
||||
}
|
||||
@@ -187,7 +198,7 @@ const PrestigePanel = (): JSX.Element => {
|
||||
type="button"
|
||||
>
|
||||
{"🔮 Runestone Shop ("}
|
||||
{formatNumber(prestigeData.runestones)}
|
||||
{formatInteger(prestigeData.runestones)}
|
||||
{" stones)"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -198,7 +209,7 @@ const PrestigePanel = (): JSX.Element => {
|
||||
{"Prestige resets your progress but grants "}
|
||||
<strong>{"Runestones"}</strong>
|
||||
{"— permanent currency used for powerful upgrades."}
|
||||
{" Each prestige multiplies your global production by ×1.15"}
|
||||
{" Each prestige multiplies your global production by ×1.3"}
|
||||
{" (compounding each run)."}
|
||||
</p>
|
||||
|
||||
@@ -231,15 +242,25 @@ const PrestigePanel = (): JSX.Element => {
|
||||
</p>
|
||||
<p>
|
||||
{"Runestones: "}
|
||||
<strong>{formatNumber(prestigeData.runestones)}</strong>
|
||||
<strong>{formatInteger(prestigeData.runestones)}</strong>
|
||||
</p>
|
||||
{isEligible
|
||||
? <p className="runestone-preview">
|
||||
{"Runestones on prestige: "}
|
||||
<strong>
|
||||
{"+"}
|
||||
{formatNumber(runestonePreview)}
|
||||
{formatInteger(runestonePreview)}
|
||||
</strong>
|
||||
{isAtMaxRunestones
|
||||
? <span className="runestone-max-badge">{" ⚡ MAX"}</span>
|
||||
: null
|
||||
}
|
||||
</p>
|
||||
: null}
|
||||
{isEligible && !isAtMaxRunestones
|
||||
? <p className="runestone-progress-hint">
|
||||
{"Earn more gold to increase your runestone yield "
|
||||
+ "(capped at ×14³ the prestige threshold)."}
|
||||
</p>
|
||||
: null}
|
||||
{isEligible
|
||||
@@ -268,7 +289,7 @@ const PrestigePanel = (): JSX.Element => {
|
||||
>
|
||||
{isPending
|
||||
? "Ascending..."
|
||||
: `✨ Ascend (+${formatNumber(runestonePreview)} Runestones)`}
|
||||
: `✨ Ascend (+${formatInteger(runestonePreview)} Runestones)`}
|
||||
</button>
|
||||
{prestigeError === null
|
||||
? null
|
||||
@@ -280,12 +301,12 @@ const PrestigePanel = (): JSX.Element => {
|
||||
{"Ascended to Prestige "}
|
||||
{result.count}
|
||||
{"! Earned "}
|
||||
{formatNumber(result.runestones)}
|
||||
{formatInteger(result.runestones)}
|
||||
{" Runestones."}
|
||||
{result.milestoneRunestones > 0
|
||||
&& <>
|
||||
{" 🎉 Milestone bonus: +"}
|
||||
{formatNumber(result.milestoneRunestones)}
|
||||
{formatInteger(result.milestoneRunestones)}
|
||||
{" Runestones!"}
|
||||
</>
|
||||
}
|
||||
@@ -306,7 +327,7 @@ const PrestigePanel = (): JSX.Element => {
|
||||
<p className="shop-balance">
|
||||
{"Balance: "}
|
||||
<strong>
|
||||
{formatNumber(prestigeData.runestones)}
|
||||
{formatInteger(prestigeData.runestones)}
|
||||
{" Runestones"}
|
||||
</strong>
|
||||
</p>
|
||||
@@ -331,6 +352,8 @@ const PrestigePanel = (): JSX.Element => {
|
||||
= upgrade.id === "auto_prestige" && purchased;
|
||||
const autoPrestigeEnabled
|
||||
= prestigeData.autoPrestigeEnabled ?? false;
|
||||
const autoPrestigeMaxRunestonesOnly
|
||||
= prestigeData.autoPrestigeMaxRunestonesOnly ?? false;
|
||||
|
||||
function handleBuyClick(): void {
|
||||
void handleBuyUpgrade(upgrade.id);
|
||||
@@ -358,7 +381,7 @@ const PrestigePanel = (): JSX.Element => {
|
||||
<p className="upgrade-cost">
|
||||
{purchased
|
||||
? "✅ Purchased"
|
||||
: `🔮 ${formatNumber(upgrade.runestonesCost)} Runestones`}
|
||||
: `🔮 ${formatInteger(upgrade.runestonesCost)} Runestones`}
|
||||
</p>
|
||||
</div>
|
||||
{isAutoAdventurerToggle
|
||||
@@ -377,19 +400,37 @@ const PrestigePanel = (): JSX.Element => {
|
||||
</button>
|
||||
: null}
|
||||
{isAutoPrestigeToggle
|
||||
? <button
|
||||
className={`auto-prestige-toggle ${
|
||||
autoPrestigeEnabled
|
||||
? "enabled"
|
||||
: "disabled"
|
||||
}`}
|
||||
onClick={handleAutoPrestigeToggle}
|
||||
type="button"
|
||||
>
|
||||
? <>
|
||||
<button
|
||||
className={`auto-prestige-toggle ${
|
||||
autoPrestigeEnabled
|
||||
? "enabled"
|
||||
: "disabled"
|
||||
}`}
|
||||
onClick={handleAutoPrestigeToggle}
|
||||
type="button"
|
||||
>
|
||||
{autoPrestigeEnabled
|
||||
? "⚡ Auto ON"
|
||||
: "⏸ Auto OFF"}
|
||||
</button>
|
||||
{autoPrestigeEnabled
|
||||
? "⚡ Auto ON"
|
||||
: "⏸ Auto OFF"}
|
||||
</button>
|
||||
? <button
|
||||
className={`auto-prestige-toggle ${
|
||||
autoPrestigeMaxRunestonesOnly
|
||||
? "enabled"
|
||||
: "disabled"
|
||||
}`}
|
||||
onClick={handleAutoPrestigeMaxRunestonesToggle}
|
||||
title="Only fire at max runestone yield"
|
||||
type="button"
|
||||
>
|
||||
{autoPrestigeMaxRunestonesOnly
|
||||
? "⚡ Max Runes Only"
|
||||
: "⏸ Max Runes OFF"}
|
||||
</button>
|
||||
: null}
|
||||
</>
|
||||
: null}
|
||||
{purchased
|
||||
? null
|
||||
|
||||
@@ -121,6 +121,7 @@ const QuestCard = ({
|
||||
{reward.type === "essence"
|
||||
&& `✨ ${formatNumber(reward.amount ?? 0)}`}
|
||||
{reward.type === "crystals"
|
||||
&& (reward.amount ?? 0) > 0
|
||||
&& `💎 ${formatNumber(reward.amount ?? 0)}`}
|
||||
{reward.type === "upgrade" && "🔓 Upgrade"}
|
||||
{reward.type === "adventurer" && "👥 New Adventurer"}
|
||||
|
||||
@@ -59,7 +59,7 @@ const StatCard = ({
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const StatisticsPanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const { state, formatInteger, formatNumber } = useGame();
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
@@ -152,13 +152,13 @@ const StatisticsPanel = (): JSX.Element => {
|
||||
<StatCard
|
||||
icon="💎"
|
||||
label="Crystals"
|
||||
value={formatNumber(resources.crystals)}
|
||||
value={formatInteger(resources.crystals)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🔮"
|
||||
label="Runestones"
|
||||
sub="permanent currency"
|
||||
value={formatNumber(prestige.runestones)}
|
||||
value={formatInteger(prestige.runestones)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ const categoryOrder: Array<TranscendenceUpgradeCategory> = [
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const TranscendencePanel = (): JSX.Element => {
|
||||
const { state, formatNumber, transcend, buyEchoUpgrade } = useGame();
|
||||
const { state, formatInteger, transcend, buyEchoUpgrade } = useGame();
|
||||
const [ isPending, setIsPending ] = useState(false);
|
||||
const [ result, setResult ] = useState<{
|
||||
echoes: number;
|
||||
@@ -152,7 +152,7 @@ const TranscendencePanel = (): JSX.Element => {
|
||||
type="button"
|
||||
>
|
||||
{"✨ Echo Shop ("}
|
||||
{formatNumber(currentEchoes)}
|
||||
{formatInteger(currentEchoes)}
|
||||
{" echoes)"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -184,7 +184,7 @@ const TranscendencePanel = (): JSX.Element => {
|
||||
}
|
||||
<p>
|
||||
{"Current Echoes: "}
|
||||
<strong>{formatNumber(currentEchoes)}</strong>
|
||||
<strong>{formatInteger(currentEchoes)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"Current prestige count: "}
|
||||
@@ -195,7 +195,7 @@ const TranscendencePanel = (): JSX.Element => {
|
||||
{"Echoes on transcendence: "}
|
||||
<strong>
|
||||
{"+"}
|
||||
{formatNumber(echoPreview)}
|
||||
{formatInteger(echoPreview)}
|
||||
</strong>
|
||||
{echoMetaMultiplier > 1
|
||||
&& <span className="echo-meta-bonus">
|
||||
@@ -238,7 +238,7 @@ const TranscendencePanel = (): JSX.Element => {
|
||||
>
|
||||
{isPending
|
||||
? "Transcending..."
|
||||
: `🌌 Transcend (+${formatNumber(echoPreview)} Echoes)`}
|
||||
: `🌌 Transcend (+${formatInteger(echoPreview)} Echoes)`}
|
||||
</button>
|
||||
{error === null
|
||||
? null
|
||||
@@ -248,7 +248,7 @@ const TranscendencePanel = (): JSX.Element => {
|
||||
: <p className="success">
|
||||
{"Transcended! Earned "}
|
||||
<strong>
|
||||
{formatNumber(result.echoes)}
|
||||
{formatInteger(result.echoes)}
|
||||
{" Echoes"}
|
||||
</strong>
|
||||
{". This is Transcendence "}
|
||||
@@ -266,7 +266,7 @@ const TranscendencePanel = (): JSX.Element => {
|
||||
<p className="shop-balance">
|
||||
{"Balance: "}
|
||||
<strong>
|
||||
{formatNumber(currentEchoes)}
|
||||
{formatInteger(currentEchoes)}
|
||||
{" Echoes"}
|
||||
</strong>
|
||||
</p>
|
||||
@@ -314,7 +314,7 @@ const TranscendencePanel = (): JSX.Element => {
|
||||
<p className="upgrade-cost">
|
||||
{purchased
|
||||
? "✅ Purchased"
|
||||
: `✨ ${formatNumber(upgrade.cost)} Echoes`}
|
||||
: `✨ ${formatInteger(upgrade.cost)} Echoes`}
|
||||
</p>
|
||||
</div>
|
||||
{purchased
|
||||
|
||||
@@ -82,7 +82,7 @@ const ResourceBar = ({
|
||||
isSyncing,
|
||||
onForceSync,
|
||||
}: ResourceBarProperties): JSX.Element => {
|
||||
const { formatNumber, syncError, state } = useGame();
|
||||
const { formatInteger, formatNumber, syncError, state } = useGame();
|
||||
const [ isProfileOpen, setIsProfileOpen ] = useState(false);
|
||||
const [ isResourcesOpen, setIsResourcesOpen ] = useState(false);
|
||||
|
||||
@@ -162,7 +162,7 @@ const ResourceBar = ({
|
||||
title="Click to see all resources"
|
||||
type="button"
|
||||
>
|
||||
<span className="resource-icon">{"🪙"}</span>
|
||||
<span className="resource-icon">{"💰"}</span>
|
||||
<span className="resource-value">{formatNumber(gold)}</span>
|
||||
<span className="resource-label">{"Gold"}</span>
|
||||
{goldFull
|
||||
@@ -218,7 +218,7 @@ const ResourceBar = ({
|
||||
: ""}`}>
|
||||
<span className="resource-icon">{"💎"}</span>
|
||||
<span className="resource-value">
|
||||
{formatNumber(crystals)}
|
||||
{formatInteger(crystals)}
|
||||
</span>
|
||||
<span className="resource-label">{"Crystals"}</span>
|
||||
{crystalsFull
|
||||
@@ -233,14 +233,14 @@ const ResourceBar = ({
|
||||
<div className="resource">
|
||||
<span className="resource-icon">{"🔮"}</span>
|
||||
<span className="resource-value">
|
||||
{formatNumber(runestones)}
|
||||
{formatInteger(runestones)}
|
||||
</span>
|
||||
<span className="resource-label">{"Runestones"}</span>
|
||||
</div>
|
||||
<div className="resource">
|
||||
<span className="resource-icon">{"⭐"}</span>
|
||||
<span className="resource-value">
|
||||
{`+${formatNumber(projectedRunestones)}`}
|
||||
{`+${formatInteger(projectedRunestones)}`}
|
||||
</span>
|
||||
<span className="resource-label">{"On Prestige"}</span>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type NumberFormat,
|
||||
type Quest,
|
||||
type TranscendenceResponse,
|
||||
computeUnlockedCompanionIds,
|
||||
isStoryChapterUnlocked,
|
||||
} from "@elysium/types";
|
||||
import {
|
||||
@@ -62,14 +63,16 @@ import {
|
||||
computePartyCombatPower,
|
||||
} from "../engine/tick.js";
|
||||
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
||||
import { formatNumber as formatNumberUtil } from "../utils/format.js";
|
||||
import {
|
||||
formatInteger as formatIntegerUtil,
|
||||
formatNumber as formatNumberUtil,
|
||||
} from "../utils/format.js";
|
||||
import { logError } from "../utils/logError.js";
|
||||
import { sendNotification } from "../utils/notification.js";
|
||||
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.
|
||||
@@ -461,6 +464,11 @@ interface GameContextValue {
|
||||
*/
|
||||
formatNumber: (value: number)=> string;
|
||||
|
||||
/**
|
||||
* Format a whole-number value without decimal places.
|
||||
*/
|
||||
formatInteger: (value: number)=> string;
|
||||
|
||||
/**
|
||||
* Buy a prestige upgrade from the runestone shop.
|
||||
*/
|
||||
@@ -471,6 +479,11 @@ interface GameContextValue {
|
||||
*/
|
||||
toggleAutoPrestige: ()=> void;
|
||||
|
||||
/**
|
||||
* Toggle whether auto-prestige waits for maximum runestone yield before firing.
|
||||
*/
|
||||
toggleAutoPrestigeMaxRunestones: ()=> void;
|
||||
|
||||
/**
|
||||
* Toggle the auto-quest setting on/off.
|
||||
*/
|
||||
@@ -1111,6 +1124,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(() => {
|
||||
@@ -1332,14 +1396,27 @@ export const GameProvider = ({
|
||||
|
||||
// Auto-prestige: fire when unlocked, enabled, and threshold is met
|
||||
const autoState = stateReference.current;
|
||||
const autoPrestigeThreshold = autoPrestigeThresholdBase
|
||||
* Math.pow((autoState?.prestige.count ?? 0) + 1, 2.5)
|
||||
* (autoState?.transcendence?.echoPrestigeThresholdMultiplier ?? 1);
|
||||
const autoBaseRunestones = Math.min(
|
||||
Math.floor(
|
||||
Math.cbrt(
|
||||
(autoState?.player.totalGoldEarned ?? 0) / autoPrestigeThreshold,
|
||||
),
|
||||
) * 15,
|
||||
200,
|
||||
);
|
||||
const autoMaxRunestonesMet
|
||||
= autoState?.prestige.autoPrestigeMaxRunestonesOnly !== true
|
||||
|| autoBaseRunestones >= 200;
|
||||
if (
|
||||
!isAutoPrestigingReference.current
|
||||
&& autoState?.prestige.purchasedUpgradeIds.includes("auto_prestige")
|
||||
=== true
|
||||
&& autoState.prestige.autoPrestigeEnabled === true
|
||||
&& autoState.player.totalGoldEarned
|
||||
>= autoPrestigeThresholdBase
|
||||
* Math.pow(autoPrestigeThresholdScale, autoState.prestige.count)
|
||||
&& autoState.player.totalGoldEarned >= autoPrestigeThreshold
|
||||
&& autoMaxRunestonesMet
|
||||
) {
|
||||
isAutoPrestigingReference.current = true;
|
||||
void prestigeApi({}).
|
||||
@@ -2005,6 +2082,22 @@ export const GameProvider = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleAutoPrestigeMaxRunestones = useCallback(() => {
|
||||
setState((previous) => {
|
||||
if (previous === null) {
|
||||
return previous;
|
||||
}
|
||||
return {
|
||||
...previous,
|
||||
prestige: {
|
||||
...previous.prestige,
|
||||
autoPrestigeMaxRunestonesOnly:
|
||||
previous.prestige.autoPrestigeMaxRunestonesOnly !== true,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleAutoQuest = useCallback(() => {
|
||||
setState((previous) => {
|
||||
if (previous === null) {
|
||||
@@ -2359,6 +2452,13 @@ export const GameProvider = ({
|
||||
[ numberFormat ],
|
||||
);
|
||||
|
||||
const formatInteger = useCallback(
|
||||
(value: number) => {
|
||||
return formatIntegerUtil(value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const contextValue = useMemo<GameContextValue>(() => {
|
||||
return {
|
||||
apotheosis,
|
||||
@@ -2397,6 +2497,7 @@ export const GameProvider = ({
|
||||
flushBossLoreToasts,
|
||||
forceSync,
|
||||
forceUnlocks,
|
||||
formatInteger,
|
||||
formatNumber,
|
||||
handleClick,
|
||||
inGuild,
|
||||
@@ -2428,6 +2529,7 @@ export const GameProvider = ({
|
||||
toggleAutoAdventurer,
|
||||
toggleAutoBoss,
|
||||
toggleAutoPrestige,
|
||||
toggleAutoPrestigeMaxRunestones,
|
||||
toggleAutoQuest,
|
||||
transcend,
|
||||
triggerPrestigeToast,
|
||||
@@ -2443,6 +2545,7 @@ export const GameProvider = ({
|
||||
bossError,
|
||||
completedQuestToasts,
|
||||
failedQuestToasts,
|
||||
formatInteger,
|
||||
formatNumber,
|
||||
buyAdventurer,
|
||||
buyEchoUpgrade,
|
||||
@@ -2502,6 +2605,7 @@ export const GameProvider = ({
|
||||
toggleAutoAdventurer,
|
||||
toggleAutoBoss,
|
||||
toggleAutoPrestige,
|
||||
toggleAutoPrestigeMaxRunestones,
|
||||
toggleAutoQuest,
|
||||
transcend,
|
||||
triggerPrestigeToast,
|
||||
|
||||
@@ -20,7 +20,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"Rolling fields of wildflowers at the edge of the guild's territory. Travellers pass through often, and occasionally leave things behind.",
|
||||
durationSeconds: 3600,
|
||||
durationSeconds: 300,
|
||||
id: "verdant_meadow",
|
||||
name: "The Verdant Meadow",
|
||||
zoneId: "verdant_vale",
|
||||
@@ -28,7 +28,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"Ancient trees whose canopy blocks out most of the light. The forest whispers things your scouts swear they understand, just not when they try to remember later.",
|
||||
durationSeconds: 7200,
|
||||
durationSeconds: 600,
|
||||
id: "whispering_forest",
|
||||
name: "The Whispering Forest",
|
||||
zoneId: "verdant_vale",
|
||||
@@ -36,7 +36,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A circle of trees so old they predate the kingdom. Druids once held ceremonies here. The trees remember, and their bark holds echoes of old power.",
|
||||
durationSeconds: 10_800,
|
||||
durationSeconds: 900,
|
||||
id: "ancient_grove",
|
||||
name: "The Ancient Grove",
|
||||
zoneId: "verdant_vale",
|
||||
@@ -44,7 +44,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A clearing the locals will not enter after dark. Something about the bark of the trees here is different. Your scouts feel watched the entire time.",
|
||||
durationSeconds: 14_400,
|
||||
durationSeconds: 1200,
|
||||
id: "forbidden_glen",
|
||||
name: "The Forbidden Glen",
|
||||
zoneId: "verdant_vale",
|
||||
@@ -54,7 +54,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"What was once a military garrison, now half-buried in rubble and wild growth. The previous occupants left in a hurry and did not take everything.",
|
||||
durationSeconds: 7200,
|
||||
durationSeconds: 600,
|
||||
id: "collapsed_outpost",
|
||||
name: "The Collapsed Outpost",
|
||||
zoneId: "shattered_ruins",
|
||||
@@ -62,7 +62,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The water here reflects things that aren't there. Something is at the bottom that doesn't want to be found, which means your scouts want very much to find it.",
|
||||
durationSeconds: 14_400,
|
||||
durationSeconds: 1200,
|
||||
id: "cursed_lake",
|
||||
name: "The Cursed Lake",
|
||||
zoneId: "shattered_ruins",
|
||||
@@ -70,7 +70,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"Buried walls covered in script no living scholar can read. The knowledge is lost but the enchantments remain, faded but still murmuring in the stone.",
|
||||
durationSeconds: 21_600,
|
||||
durationSeconds: 1800,
|
||||
id: "runic_archive",
|
||||
name: "The Runic Archive",
|
||||
zoneId: "shattered_ruins",
|
||||
@@ -78,7 +78,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The chamber the elder dragon called his own before your guild deposed him. He won't be back soon. Probably. The heat of his presence lingers in the stone.",
|
||||
durationSeconds: 28_800,
|
||||
durationSeconds: 2400,
|
||||
id: "dragon_throne",
|
||||
name: "The Dragon's Throne",
|
||||
zoneId: "shattered_ruins",
|
||||
@@ -88,7 +88,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A cave carved by a glacier over thousands of years. The ice walls are so clear you can see things preserved within them from before the kingdom existed.",
|
||||
durationSeconds: 10_800,
|
||||
durationSeconds: 900,
|
||||
id: "glacial_cave",
|
||||
name: "The Glacial Cave",
|
||||
zoneId: "frozen_peaks",
|
||||
@@ -96,7 +96,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"Flat, white, and vast. The tundra looks featureless until you know what to look for. Under the ice, there are things that were buried with intent.",
|
||||
durationSeconds: 21_600,
|
||||
durationSeconds: 1800,
|
||||
id: "frozen_tundra",
|
||||
name: "The Frozen Tundra",
|
||||
zoneId: "frozen_peaks",
|
||||
@@ -104,7 +104,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A tear in reality that appeared after the Void Titan's defeat, miles above the world. Something leaks through it constantly. Mostly harmless. Mostly.",
|
||||
durationSeconds: 32_400,
|
||||
durationSeconds: 2700,
|
||||
id: "void_rift",
|
||||
name: "The Void Rift",
|
||||
zoneId: "frozen_peaks",
|
||||
@@ -112,7 +112,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"At the absolute peak, a shrine nobody remembers building. The prayers still tied to its poles are in a language no scholar has identified. Offerings remain.",
|
||||
durationSeconds: 43_200,
|
||||
durationSeconds: 3600,
|
||||
id: "summit_shrine",
|
||||
name: "The Summit Shrine",
|
||||
zoneId: "frozen_peaks",
|
||||
@@ -122,7 +122,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A depression in the marsh where the fog never fully lifts. Sound behaves differently here. Your scouts can hear things they probably should not.",
|
||||
durationSeconds: 18_000,
|
||||
durationSeconds: 1500,
|
||||
id: "fog_hollow",
|
||||
name: "The Fog Hollow",
|
||||
zoneId: "shadow_marshes",
|
||||
@@ -130,7 +130,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A cave system beneath the marsh floor. The water drips through the ceiling in patterns that look deliberate. Nothing down here needs eyes to find you.",
|
||||
durationSeconds: 36_000,
|
||||
durationSeconds: 3000,
|
||||
id: "dark_grotto",
|
||||
name: "The Dark Grotto",
|
||||
zoneId: "shadow_marshes",
|
||||
@@ -138,7 +138,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A burial mound. Something was interred here that should not have been — or perhaps something interred itself, which is a different and more troubling problem.",
|
||||
durationSeconds: 54_000,
|
||||
durationSeconds: 5400,
|
||||
id: "cursed_barrow",
|
||||
name: "The Cursed Barrow",
|
||||
zoneId: "shadow_marshes",
|
||||
@@ -146,7 +146,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The bottommost point of the Shadow Marshes, where the water is perfectly still and perfectly black. Your scouts can see the bottom. The bottom is very far down.",
|
||||
durationSeconds: 72_000,
|
||||
durationSeconds: 5400,
|
||||
id: "marsh_depths",
|
||||
name: "The Marsh Depths",
|
||||
zoneId: "shadow_marshes",
|
||||
@@ -156,7 +156,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A natural tunnel cut by ancient lava flows. Still warm. The walls glow faintly orange in some sections, which is either residual heat or something else.",
|
||||
durationSeconds: 25_200,
|
||||
durationSeconds: 2100,
|
||||
id: "magma_tunnel",
|
||||
name: "The Magma Tunnel",
|
||||
zoneId: "volcanic_depths",
|
||||
@@ -164,7 +164,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"An ancient workshop space, built into the volcano by whoever the fire elementals served before they served no one. The fires here never went out.",
|
||||
durationSeconds: 50_400,
|
||||
durationSeconds: 3600,
|
||||
id: "forge_chamber",
|
||||
name: "The Forge Chamber",
|
||||
zoneId: "volcanic_depths",
|
||||
@@ -172,7 +172,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A place of worship for entities that have never met a god but found the general idea appealing and decided to be worshipped instead. The fire elementals receive visitors here.",
|
||||
durationSeconds: 75_600,
|
||||
durationSeconds: 7200,
|
||||
id: "fire_temple",
|
||||
name: "The Fire Temple",
|
||||
zoneId: "volcanic_depths",
|
||||
@@ -180,7 +180,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The lowest point your guild can reach — close enough to the planet's core that the rocks bleed metal and the air shimmers with heat haze that never quite resolves into anything.",
|
||||
durationSeconds: 100_800,
|
||||
durationSeconds: 9000,
|
||||
id: "core_descent",
|
||||
name: "The Core Descent",
|
||||
zoneId: "volcanic_depths",
|
||||
@@ -190,7 +190,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"Open void between reality and whatever lies beyond it. Stars in various states of life and death drift past. Your scouts learn very quickly not to touch them.",
|
||||
durationSeconds: 36_000,
|
||||
durationSeconds: 3000,
|
||||
id: "star_field",
|
||||
name: "The Star Field",
|
||||
zoneId: "astral_void",
|
||||
@@ -198,7 +198,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A region where every possible outcome is equally real and they jostle each other for space. Your scouts exist in several states simultaneously here and find it disorienting.",
|
||||
durationSeconds: 72_000,
|
||||
durationSeconds: 5400,
|
||||
id: "probability_sea",
|
||||
name: "The Probability Sea",
|
||||
zoneId: "astral_void",
|
||||
@@ -206,7 +206,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A river of nothing flowing through the void. It carries things from everywhere to nowhere. Some of those things are valuable, if you know how to fish from a river of nothing.",
|
||||
durationSeconds: 108_000,
|
||||
durationSeconds: 9000,
|
||||
id: "void_current",
|
||||
name: "The Void Current",
|
||||
zoneId: "astral_void",
|
||||
@@ -214,7 +214,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The highest point of the astral void, where nothing exists so thoroughly that it becomes a kind of substance. Your scouts feel, for a moment, what it is like to be absolutely alone in all of existence.",
|
||||
durationSeconds: 144_000,
|
||||
durationSeconds: 12_600,
|
||||
id: "null_zenith",
|
||||
name: "The Null Zenith",
|
||||
zoneId: "astral_void",
|
||||
@@ -224,7 +224,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A tower of compressed light older than the concept of architecture. The celestial host uses it as a marker. Your guild uses it as a starting point.",
|
||||
durationSeconds: 43_200,
|
||||
durationSeconds: 3600,
|
||||
id: "light_spire",
|
||||
name: "The Light Spire",
|
||||
zoneId: "celestial_reaches",
|
||||
@@ -232,7 +232,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"Where the celestial choir rehearses, continuously, for a performance that has been ongoing since before your world had an audience. The harmonics do things to objects in the vicinity.",
|
||||
durationSeconds: 86_400,
|
||||
durationSeconds: 7200,
|
||||
id: "choir_hall",
|
||||
name: "The Choir Hall",
|
||||
zoneId: "celestial_reaches",
|
||||
@@ -240,7 +240,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"Where the celestial host adjudicates disputes that have been ongoing since before your sun was lit. The proceedings are extremely formal. Interrupting them is inadvisable.",
|
||||
durationSeconds: 129_600,
|
||||
durationSeconds: 10_800,
|
||||
id: "divine_court",
|
||||
name: "The Divine Court",
|
||||
zoneId: "celestial_reaches",
|
||||
@@ -248,7 +248,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"Where the celestial host stores things they consider too valuable to use and too important to discard. Your guild has different ideas about what 'valuable' means.",
|
||||
durationSeconds: 172_800,
|
||||
durationSeconds: 14_400,
|
||||
id: "celestial_vault",
|
||||
name: "The Celestial Vault",
|
||||
zoneId: "celestial_reaches",
|
||||
@@ -258,7 +258,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The lip of the trench, where the shelf drops away into depths that swallow light entirely. Your scouts can hear something breathing, very slowly, from far below.",
|
||||
durationSeconds: 50_400,
|
||||
durationSeconds: 3600,
|
||||
id: "trench_entrance",
|
||||
name: "The Trench Entrance",
|
||||
zoneId: "abyssal_trench",
|
||||
@@ -266,7 +266,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"An underwater river at a depth that should be impossible to survive. Your scouts have learned, by necessity, to survive it anyway.",
|
||||
durationSeconds: 100_800,
|
||||
durationSeconds: 9000,
|
||||
id: "deep_current",
|
||||
name: "The Deep Current",
|
||||
zoneId: "abyssal_trench",
|
||||
@@ -274,7 +274,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A space at the bottom of the trench so far from light that light has no meaning here. Something has been in this chamber for so long it no longer needs to breathe.",
|
||||
durationSeconds: 151_200,
|
||||
durationSeconds: 12_600,
|
||||
id: "sunless_chamber",
|
||||
name: "The Sunless Chamber",
|
||||
zoneId: "abyssal_trench",
|
||||
@@ -282,7 +282,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The absolute bottom of the trench. Something is here. It has been here since before your world was made. It is, today, patient. Your scouts are not sure this is always the case.",
|
||||
durationSeconds: 201_600,
|
||||
durationSeconds: 16_200,
|
||||
id: "the_waiting_place",
|
||||
name: "The Waiting Place",
|
||||
zoneId: "abyssal_trench",
|
||||
@@ -292,7 +292,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"An open-air market in the court's outer districts. The vendors sell things that were not legally obtained, in exchange for things that should not legally exist.",
|
||||
durationSeconds: 57_600,
|
||||
durationSeconds: 5400,
|
||||
id: "demon_market",
|
||||
name: "The Demon Market",
|
||||
zoneId: "infernal_court",
|
||||
@@ -300,7 +300,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"Where the court processes those who lost their cases. Your scouts move through it quickly and look at nothing. They still hear everything.",
|
||||
durationSeconds: 115_200,
|
||||
durationSeconds: 9000,
|
||||
id: "torment_hall",
|
||||
name: "The Torment Hall",
|
||||
zoneId: "infernal_court",
|
||||
@@ -308,7 +308,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The court's industrial district, where deals are processed and the residue of completed contracts is extracted. The machinery runs on something the court considers renewable.",
|
||||
durationSeconds: 172_800,
|
||||
durationSeconds: 14_400,
|
||||
id: "soul_forge",
|
||||
name: "The Soul Forge",
|
||||
zoneId: "infernal_court",
|
||||
@@ -316,7 +316,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The inner sanctum of the infernal court, where the demon lords make decisions that echo across aeons. Your guild should not be here. Your guild is here anyway.",
|
||||
durationSeconds: 230_400,
|
||||
durationSeconds: 19_800,
|
||||
id: "lords_chamber",
|
||||
name: "The Lords' Chamber",
|
||||
zoneId: "infernal_court",
|
||||
@@ -326,7 +326,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The outer surface of the spire, where thousands of crystal facets reflect realities that are not the one you arrived in. Your scouts learn to focus on the ground in front of them.",
|
||||
durationSeconds: 64_800,
|
||||
durationSeconds: 5400,
|
||||
id: "facet_approach",
|
||||
name: "The Facet Approach",
|
||||
zoneId: "crystalline_spire",
|
||||
@@ -334,7 +334,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A room inside the spire where the intelligence runs its oldest and most complex calculations. The numbers on the walls change too fast to read. The calculations are always correct.",
|
||||
durationSeconds: 129_600,
|
||||
durationSeconds: 10_800,
|
||||
id: "calculation_chamber",
|
||||
name: "The Calculation Chamber",
|
||||
zoneId: "crystalline_spire",
|
||||
@@ -342,7 +342,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A corridor of perfect mirrors that show not reflections but what might have been. Your scouts avoid eye contact with their alternates. The alternates do not always extend the same courtesy.",
|
||||
durationSeconds: 194_400,
|
||||
durationSeconds: 16_200,
|
||||
id: "mirror_hall",
|
||||
name: "The Mirror Hall",
|
||||
zoneId: "crystalline_spire",
|
||||
@@ -350,7 +350,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The deepest point of the spire, where the intelligence's primary substrate runs continuously. The hum of calculation is felt in the bones. Numbers that have never been numbers drift past.",
|
||||
durationSeconds: 259_200,
|
||||
durationSeconds: 21_600,
|
||||
id: "core_access",
|
||||
name: "The Core Access",
|
||||
zoneId: "crystalline_spire",
|
||||
@@ -360,7 +360,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The entrance to the void sanctum, where the rules of existence become suggestions. Your scouts describe the crossing as like stepping sideways and arriving somewhere that was always there.",
|
||||
durationSeconds: 72_000,
|
||||
durationSeconds: 5400,
|
||||
id: "threshold",
|
||||
name: "The Threshold",
|
||||
zoneId: "void_sanctum",
|
||||
@@ -368,7 +368,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A place inside the sanctum where everything is perfectly quiet because nothing exists to make noise. Your scouts can hear their own thoughts very clearly here. Some of them find this unsettling.",
|
||||
durationSeconds: 144_000,
|
||||
durationSeconds: 12_600,
|
||||
id: "inner_silence",
|
||||
name: "The Inner Silence",
|
||||
zoneId: "void_sanctum",
|
||||
@@ -376,7 +376,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A space inside the sanctum where something is calling out, continuously, to something that has not yet answered. The call is beautiful and deeply wrong.",
|
||||
durationSeconds: 216_000,
|
||||
durationSeconds: 18_000,
|
||||
id: "resonance_chamber",
|
||||
name: "The Resonance Chamber",
|
||||
zoneId: "void_sanctum",
|
||||
@@ -384,7 +384,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The source of the call. Something here has been reaching out for so long it no longer remembers what it is reaching toward. Your guild's arrival is, perhaps, an answer.",
|
||||
durationSeconds: 288_000,
|
||||
durationSeconds: 25_200,
|
||||
id: "sanctum_heart",
|
||||
name: "The Sanctum Heart",
|
||||
zoneId: "void_sanctum",
|
||||
@@ -394,7 +394,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The long road to the eternal throne. Countless beings have walked it, seeking audience, seeking power, seeking something the throne has always already decided about them.",
|
||||
durationSeconds: 79_200,
|
||||
durationSeconds: 7200,
|
||||
id: "throne_approach",
|
||||
name: "The Throne Approach",
|
||||
zoneId: "eternal_throne",
|
||||
@@ -402,7 +402,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The ante-chamber of absolute power. Records are kept here of everything that has ever been ruled and everything that has ever been lost. The records go back further than memory.",
|
||||
durationSeconds: 158_400,
|
||||
durationSeconds: 12_600,
|
||||
id: "dominion_hall",
|
||||
name: "The Dominion Hall",
|
||||
zoneId: "eternal_throne",
|
||||
@@ -410,7 +410,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"Where things are stored that have nowhere else to go. Objects of power that cannot be used, secrets that cannot be shared, and wealth that belongs to entities that stopped existing before your world was born.",
|
||||
durationSeconds: 237_600,
|
||||
durationSeconds: 19_800,
|
||||
id: "eternity_vault",
|
||||
name: "The Eternity Vault",
|
||||
zoneId: "eternal_throne",
|
||||
@@ -418,7 +418,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The eternal throne itself. Whoever sits here has sat here since the beginning. They observe your guild's presence with neither surprise nor emotion. They have been expecting you. They have been expecting everyone.",
|
||||
durationSeconds: 316_800,
|
||||
durationSeconds: 25_200,
|
||||
id: "the_seat",
|
||||
name: "The Seat",
|
||||
zoneId: "eternal_throne",
|
||||
@@ -428,7 +428,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A permanent storm at the edge of the chaos zone where things are constantly being made and unmade simultaneously. Your scouts move through it quickly and try not to look at what they might become.",
|
||||
durationSeconds: 86_400,
|
||||
durationSeconds: 7200,
|
||||
id: "creation_storm",
|
||||
name: "The Creation Storm",
|
||||
zoneId: "primordial_chaos",
|
||||
@@ -436,7 +436,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A vast ocean of something that is exactly the opposite of matter. Your scouts cross it by not thinking too hard about what they are standing on.",
|
||||
durationSeconds: 172_800,
|
||||
durationSeconds: 14_400,
|
||||
id: "unmaking_sea",
|
||||
name: "The Unmaking Sea",
|
||||
zoneId: "primordial_chaos",
|
||||
@@ -444,7 +444,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A space where all possible outcomes already happened and none of them mattered. Your scouts find this philosophically challenging and practically navigable.",
|
||||
durationSeconds: 259_200,
|
||||
durationSeconds: 21_600,
|
||||
id: "probability_void",
|
||||
name: "The Probability Void",
|
||||
zoneId: "primordial_chaos",
|
||||
@@ -452,7 +452,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The centre of all primordial chaos. Everything is here and nothing is here and both statements are entirely accurate. Your scouts report the experience as indescribable, then describe it for three hours.",
|
||||
durationSeconds: 345_600,
|
||||
durationSeconds: 28_800,
|
||||
id: "chaos_core",
|
||||
name: "The Chaos Core",
|
||||
zoneId: "primordial_chaos",
|
||||
@@ -462,7 +462,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The first horizon you reach in the infinite expanse, which looks exactly like the starting point from behind but is provably, mathematically, somewhere else. Your scouts are sceptical but cannot argue with the math.",
|
||||
durationSeconds: 93_600,
|
||||
durationSeconds: 7200,
|
||||
id: "first_horizon",
|
||||
name: "The First Horizon",
|
||||
zoneId: "infinite_expanse",
|
||||
@@ -470,7 +470,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"There is no centre of the infinite expanse. This is the centre of the infinite expanse. Both things are true. Your scouts have stopped asking questions and started collecting samples.",
|
||||
durationSeconds: 187_200,
|
||||
durationSeconds: 16_200,
|
||||
id: "middle_nowhere",
|
||||
name: "The Middle of Nowhere",
|
||||
zoneId: "infinite_expanse",
|
||||
@@ -478,7 +478,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The road toward the edge that the expanse does not have. Your scouts know it does not exist. They are getting closer to it anyway.",
|
||||
durationSeconds: 280_800,
|
||||
durationSeconds: 25_200,
|
||||
id: "edge_approach",
|
||||
name: "The Edge Approach",
|
||||
zoneId: "infinite_expanse",
|
||||
@@ -486,7 +486,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"As far as any being has ever gone in the infinite expanse. Your scouts hold this record now. They are not entirely sure whether to be proud or frightened.",
|
||||
durationSeconds: 374_400,
|
||||
durationSeconds: 32_400,
|
||||
id: "the_furthest",
|
||||
name: "The Furthest",
|
||||
zoneId: "infinite_expanse",
|
||||
@@ -496,7 +496,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The outer area of the reality forge, where the overflow of unrealised realities pools and cools. Things that never quite existed are everywhere here, and some of them are extremely useful.",
|
||||
durationSeconds: 100_800,
|
||||
durationSeconds: 9000,
|
||||
id: "workshop_entrance",
|
||||
name: "The Workshop Entrance",
|
||||
zoneId: "reality_forge",
|
||||
@@ -504,7 +504,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"Where realities are assembled from the raw components of existence. The work here is continuous and has been going on since before your universe was queued.",
|
||||
durationSeconds: 201_600,
|
||||
durationSeconds: 16_200,
|
||||
id: "creation_floor",
|
||||
name: "The Creation Floor",
|
||||
zoneId: "reality_forge",
|
||||
@@ -512,7 +512,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The primary forging station, where major realities are hammered into their final shape. The hammers are larger than planets. The anvil has never been named because no one has ever successfully described it.",
|
||||
durationSeconds: 302_400,
|
||||
durationSeconds: 25_200,
|
||||
id: "master_forge",
|
||||
name: "The Master Forge",
|
||||
zoneId: "reality_forge",
|
||||
@@ -520,7 +520,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The energy source that powers the entire reality forge. It has been running since before time was a meaningful concept. What powers it is not a question that has been answered by anyone who came here to ask it.",
|
||||
durationSeconds: 403_200,
|
||||
durationSeconds: 32_400,
|
||||
id: "forge_core",
|
||||
name: "The Forge Core",
|
||||
zoneId: "reality_forge",
|
||||
@@ -530,7 +530,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The outermost spiral of the cosmic maelstrom, where the forces are at their most navigable — which still means they routinely shatter planets that wander too close.",
|
||||
durationSeconds: 108_000,
|
||||
durationSeconds: 9000,
|
||||
id: "outer_current",
|
||||
name: "The Outer Current",
|
||||
zoneId: "cosmic_maelstrom",
|
||||
@@ -538,7 +538,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The accumulated wreckage of everything the maelstrom has consumed, compressed into a navigable (mostly) field. Your scouts move through it quickly. Things in debris fields become part of the debris field.",
|
||||
durationSeconds: 216_000,
|
||||
durationSeconds: 18_000,
|
||||
id: "debris_field",
|
||||
name: "The Debris Field",
|
||||
zoneId: "cosmic_maelstrom",
|
||||
@@ -546,7 +546,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"Where the fundamental forces of the cosmos intersect inside the maelstrom. Gravity and electromagnetism and things that do not have names yet jostle each other here with consequences that exceed polite description.",
|
||||
durationSeconds: 324_000,
|
||||
durationSeconds: 28_800,
|
||||
id: "force_confluence",
|
||||
name: "The Force Confluence",
|
||||
zoneId: "cosmic_maelstrom",
|
||||
@@ -554,7 +554,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The path to the maelstrom's impossible centre — the one point of absolute calm surrounded by forces that make galaxies look fragile. Your scouts have never been so far in. They are doing this anyway.",
|
||||
durationSeconds: 432_000,
|
||||
durationSeconds: 36_000,
|
||||
id: "eye_approach",
|
||||
name: "The Eye Approach",
|
||||
zoneId: "cosmic_maelstrom",
|
||||
@@ -564,7 +564,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The entrance to the oldest place. The floor here was walked before walking was invented, which is philosophically impossible and physically evident.",
|
||||
durationSeconds: 115_200,
|
||||
durationSeconds: 9000,
|
||||
id: "first_steps",
|
||||
name: "The First Steps",
|
||||
zoneId: "primeval_sanctum",
|
||||
@@ -572,7 +572,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A collection of records that predate the concept of records. The information stored here concerns things that no longer exist, but the records persist because the sanctum will not let them stop.",
|
||||
durationSeconds: 230_400,
|
||||
durationSeconds: 19_800,
|
||||
id: "ancient_archive",
|
||||
name: "The Ancient Archive",
|
||||
zoneId: "primeval_sanctum",
|
||||
@@ -580,7 +580,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"Where the sanctum stores the memory of the first moment of existence. The memory is perfect, complete, and overwhelming. Your scouts spend the minimum time here and speak little for some time after.",
|
||||
durationSeconds: 345_600,
|
||||
durationSeconds: 28_800,
|
||||
id: "memory_chamber",
|
||||
name: "The Memory Chamber",
|
||||
zoneId: "primeval_sanctum",
|
||||
@@ -588,7 +588,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"There is nothing older than this. The sanctum's deepest point, where the very first thing that ever was still is, unchanged, because nothing in the universe has had long enough to change it.",
|
||||
durationSeconds: 460_800,
|
||||
durationSeconds: 39_600,
|
||||
id: "the_oldest_place",
|
||||
name: "The Oldest Place",
|
||||
zoneId: "primeval_sanctum",
|
||||
@@ -598,7 +598,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The boundary between existence and non-existence. On one side: everything there is. On the other: everything there isn't. The view from here is indescribable and has been described by your scouts at length.",
|
||||
durationSeconds: 129_600,
|
||||
durationSeconds: 10_800,
|
||||
id: "edge_of_everything",
|
||||
name: "The Edge of Everything",
|
||||
zoneId: "the_absolute",
|
||||
@@ -606,7 +606,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The road to the final truth, which your guild has been walking toward since the first step in the Verdant Vale. It looks like every other road your guild has walked. It feels different.",
|
||||
durationSeconds: 259_200,
|
||||
durationSeconds: 21_600,
|
||||
id: "truth_approach",
|
||||
name: "The Truth Approach",
|
||||
zoneId: "the_absolute",
|
||||
@@ -614,7 +614,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"One step from the absolute. The door ahead is the last door. Your guild has opened every other door. This one opens when you are ready, which is something only the absolute can determine.",
|
||||
durationSeconds: 388_800,
|
||||
durationSeconds: 32_400,
|
||||
id: "final_antechamber",
|
||||
name: "The Final Antechamber",
|
||||
zoneId: "the_absolute",
|
||||
@@ -622,7 +622,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The final truth, at the end of all things. There is nothing beyond this. Your guild stands here, at the end, and finds that the end is not empty. It has been waiting for you specifically.",
|
||||
durationSeconds: 518_400,
|
||||
durationSeconds: 43_200,
|
||||
id: "the_absolute_heart",
|
||||
name: "The Absolute Heart",
|
||||
zoneId: "the_absolute",
|
||||
|
||||
@@ -41,7 +41,7 @@ const checkAchievements = (state: GameState): Array<Achievement> => {
|
||||
|
||||
switch (condition.type) {
|
||||
case "totalGoldEarned":
|
||||
met = state.player.totalGoldEarned >= condition.amount;
|
||||
met = state.player.lifetimeGoldEarned >= condition.amount;
|
||||
break;
|
||||
case "totalClicks":
|
||||
met = state.player.totalClicks >= condition.amount;
|
||||
@@ -462,7 +462,10 @@ const maxBaseRunestones = 200;
|
||||
*/
|
||||
export const computeProjectedRunestones = (state: GameState): number => {
|
||||
const { count, purchasedUpgradeIds } = state.prestige;
|
||||
const threshold = basePrestigeThreshold * Math.pow(count + 1, 2);
|
||||
const thresholdMult: number
|
||||
= state.transcendence?.echoPrestigeThresholdMultiplier ?? 1;
|
||||
const threshold
|
||||
= basePrestigeThreshold * Math.pow(count + 1, 2.5) * thresholdMult;
|
||||
const base = Math.min(
|
||||
Math.floor(Math.cbrt(state.player.totalGoldEarned / threshold))
|
||||
* runestonesPerPrestigeLevelClient,
|
||||
|
||||
@@ -4586,6 +4586,7 @@ body::before {
|
||||
height: 220px;
|
||||
margin-bottom: 1rem;
|
||||
object-fit: cover;
|
||||
object-position: top;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,35 @@ const formatEngineering = (value: number): string => {
|
||||
return `${mantissa.toFixed(2)}E${String(engExp)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a whole-number value for display without decimal places.
|
||||
* Uses the same suffix/letter logic as formatNumber but rounds to integers.
|
||||
* @param value - The integer value to format.
|
||||
* @returns The formatted string with no decimal places.
|
||||
*/
|
||||
const formatInteger = (value: number): string => {
|
||||
if (!Number.isFinite(value) || Number.isNaN(value)) {
|
||||
return "0";
|
||||
}
|
||||
if (value < 0) {
|
||||
return `-${formatInteger(-value)}`;
|
||||
}
|
||||
if (value >= Math.pow(10, letterBaseExp)) {
|
||||
const exp = Math.floor(Math.log10(value));
|
||||
const stepsAboveBase = Math.floor((exp - letterBaseExp) / 3);
|
||||
const steps = stepsAboveBase * 3;
|
||||
const divisorExp = letterBaseExp + steps;
|
||||
const divisor = Math.pow(10, divisorExp);
|
||||
return `${String(Math.round(value / divisor))}${getLetterSuffix(stepsAboveBase)}`;
|
||||
}
|
||||
for (const { threshold, suffix } of namedSuffixes) {
|
||||
if (value >= threshold) {
|
||||
return `${String(Math.floor(value / threshold))}${suffix}`;
|
||||
}
|
||||
}
|
||||
return String(Math.floor(value));
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a number for display using the player's chosen notation style.
|
||||
* Negative values are formatted with a leading minus sign.
|
||||
@@ -115,7 +144,7 @@ const formatEngineering = (value: number): string => {
|
||||
* @param format - The notation style to use.
|
||||
* @returns The formatted number string.
|
||||
*/
|
||||
export const formatNumber = (
|
||||
const formatNumber = (
|
||||
value: number,
|
||||
format: NumberFormat = "suffix",
|
||||
): string => {
|
||||
@@ -139,3 +168,5 @@ export const formatNumber = (
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export { formatInteger, formatNumber };
|
||||
|
||||
@@ -5,14 +5,22 @@
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable no-console -- Errors are forwarded to backend via the overridden console.error */
|
||||
import { ValidationError } from "../api/client.js";
|
||||
|
||||
/**
|
||||
* Logs an error to the backend telemetry service.
|
||||
* Accepts the same arguments as console.error — conventionally a context string
|
||||
* followed by the error value.
|
||||
* @param logArguments - The values to log, forwarded directly to console.error.
|
||||
* ValidationErrors (4xx API rejections) are downgraded to console.warn so they
|
||||
* are not forwarded to the error-email pipeline — they are expected server responses.
|
||||
* @param logArguments - The values to log, forwarded directly to console.error or console.warn.
|
||||
*/
|
||||
const logError = (...logArguments: Array<unknown>): void => {
|
||||
const isValidation = logArguments.some((argument) => {
|
||||
return argument instanceof ValidationError;
|
||||
});
|
||||
if (isValidation) {
|
||||
console.warn(...logArguments);
|
||||
return;
|
||||
}
|
||||
console.error(...logArguments);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatNumber } from "../src/utils/format.js";
|
||||
import { formatInteger, formatNumber } from "../src/utils/format.js";
|
||||
|
||||
describe("formatNumber", () => {
|
||||
describe("edge cases", () => {
|
||||
@@ -142,3 +142,59 @@ describe("formatNumber", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatInteger", () => {
|
||||
describe("edge cases", () => {
|
||||
it("should return '0' for NaN", () => {
|
||||
expect(formatInteger(Number.NaN)).toBe("0");
|
||||
});
|
||||
|
||||
it("should return '0' for Infinity", () => {
|
||||
expect(formatInteger(Infinity)).toBe("0");
|
||||
});
|
||||
|
||||
it("should format negative integers with a leading minus sign", () => {
|
||||
expect(formatInteger(-1500)).toBe("-1K");
|
||||
});
|
||||
|
||||
it("should format zero as '0'", () => {
|
||||
expect(formatInteger(0)).toBe("0");
|
||||
});
|
||||
|
||||
it("should format small integers without decimals", () => {
|
||||
expect(formatInteger(42)).toBe("42");
|
||||
});
|
||||
});
|
||||
|
||||
describe("named suffixes", () => {
|
||||
it("should format thousands with K suffix and no decimals", () => {
|
||||
expect(formatInteger(1500)).toBe("1K");
|
||||
});
|
||||
|
||||
it("should format millions with M suffix and no decimals", () => {
|
||||
expect(formatInteger(2_500_000)).toBe("2M");
|
||||
});
|
||||
|
||||
it("should format billions with B suffix", () => {
|
||||
expect(formatInteger(3_000_000_000)).toBe("3B");
|
||||
});
|
||||
|
||||
it("should format trillions with T suffix", () => {
|
||||
expect(formatInteger(1e12)).toBe("1T");
|
||||
});
|
||||
|
||||
it("should format quintillions with Qi suffix", () => {
|
||||
expect(formatInteger(1e18)).toBe("1Qi");
|
||||
});
|
||||
});
|
||||
|
||||
describe("letter suffixes", () => {
|
||||
it("should format values >= 1e36 with letter suffix 'a'", () => {
|
||||
expect(formatInteger(1e36)).toBe("1a");
|
||||
});
|
||||
|
||||
it("should format values >= 1e39 with letter suffix 'b'", () => {
|
||||
expect(formatInteger(1e39)).toBe("1b");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +56,12 @@ interface PrestigeData {
|
||||
* Whether the auto-prestige feature is currently enabled (requires auto_prestige upgrade).
|
||||
*/
|
||||
autoPrestigeEnabled?: boolean;
|
||||
|
||||
/**
|
||||
* When true, auto-prestige only fires when the runestone yield is at its maximum base cap.
|
||||
* Requires auto_prestige upgrade and autoPrestigeEnabled to be true.
|
||||
*/
|
||||
autoPrestigeMaxRunestonesOnly?: boolean;
|
||||
}
|
||||
|
||||
export type { PrestigeData };
|
||||
|
||||
Reference in New Issue
Block a user