feat: v0.6.0 balance pass, quest-gated bosses, and achievement fixes

This commit is contained in:
2026-07-17 01:02:12 -05:00
parent 9bb1d01d2b
commit 5928eb73d8
49 changed files with 1836 additions and 528 deletions
+4 -1
View File
@@ -56,7 +56,10 @@ const howToPlay = [
"Challenge zone bosses to earn large one-time rewards and unlock new"
+ " zones. Your party's combat power is based on the number and tier of"
+ " adventurers you've recruited. Defeated bosses cannot be re-fought,"
+ " but undefeated bosses regenerate HP over time.",
+ " but undefeated bosses regenerate HP over time. Some late-game bosses"
+ " also require a specific quest to be completed before they become"
+ " available, in addition to defeating the previous boss in the zone —"
+ " the boss card shows exactly what's required while it's locked.",
title: "👹 Boss Fights",
},
{
@@ -7,9 +7,10 @@
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */
import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js";
import { getAchievementProgress } from "../../engine/achievementProgress.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js";
import type { Achievement, GameState } from "@elysium/types";
import type { Achievement } from "@elysium/types";
/**
* Returns the plural form of a word based on a count.
@@ -18,9 +19,12 @@ import type { Achievement, GameState } from "@elysium/types";
* @returns The pluralised word string.
*/
const pluralise = (count: number, word: string): string => {
return count > 1
? `${word}s`
: word;
if (count <= 1) {
return word;
}
return word.endsWith("s")
? `${word}es`
: `${word}s`;
};
/**
@@ -39,9 +43,9 @@ const conditionDescription = (
return `Earn ${formatNumber(condition.amount)} total gold`;
case "totalClicks":
return `Click ${formatNumber(condition.amount)} times`;
case "bossesDefeated":
case "uniqueBossesDefeated":
return `Defeat ${String(condition.amount)} ${pluralise(condition.amount, "boss")}`;
case "questsCompleted":
case "uniqueQuestsCompleted":
return `Complete ${String(condition.amount)} ${pluralise(condition.amount, "quest")}`;
case "adventurerTotal":
return `Recruit ${formatNumber(condition.amount)} total adventurers`;
@@ -54,46 +58,6 @@ const conditionDescription = (
}
};
/**
* Returns the player's current progress value toward an achievement's unlock condition,
* mirroring the logic used by the tick engine's checkAchievements function.
* @param achievement - The achievement to evaluate progress for.
* @param state - The current game state.
* @returns The current numeric progress toward the achievement condition.
*/
const getCurrentProgress = (
achievement: Achievement,
state: GameState,
): number => {
const { condition } = achievement;
switch (condition.type) {
case "totalGoldEarned":
return state.player.totalGoldEarned;
case "totalClicks":
return state.player.totalClicks;
case "bossesDefeated":
return state.bosses.filter((boss) => {
return boss.status === "defeated";
}).length;
case "questsCompleted":
return state.quests.filter((quest) => {
return quest.status === "completed";
}).length;
case "adventurerTotal":
return state.adventurers.reduce((sum, adventurer) => {
return sum + adventurer.count;
}, 0);
case "prestigeCount":
return state.prestige.count;
case "equipmentOwned":
return state.equipment.filter((item) => {
return item.owned;
}).length;
default:
return 0;
}
};
interface AchievementCardProperties {
readonly achievement: Achievement;
readonly formatNumber: (n: number)=> string;
@@ -232,7 +196,9 @@ const AchievementPanel = (): JSX.Element => {
achievement={achievement}
formatNumber={formatNumber}
key={achievement.id}
progressValue={getCurrentProgress(achievement, state)}
progressValue={
getAchievementProgress(achievement.condition, state)
}
/>
);
})}
+22 -5
View File
@@ -23,6 +23,7 @@ interface BossCardProperties {
readonly onChallenge: (bossId: string)=> void;
readonly isChallenging: boolean;
readonly unlockHint: string | undefined;
readonly questName: string | undefined;
readonly formatInteger: (n: number)=> string;
readonly formatNumber: (n: number)=> string;
}
@@ -35,6 +36,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.questName - Optional name of this boss's own gating quest.
* @param props.formatInteger - The integer formatting utility function.
* @param props.formatNumber - The number formatting utility function.
* @returns The JSX element.
@@ -45,6 +47,7 @@ const BossCard = ({
onChallenge,
isChallenging,
unlockHint,
questName,
formatInteger,
formatNumber,
}: BossCardProperties): JSX.Element => {
@@ -73,6 +76,9 @@ const BossCard = ({
? <p className="prestige-lock">
{"🔒 Requires Prestige "}
{boss.prestigeRequirement}
{questName === undefined
? null
: ` and Completing ${questName}`}
</p>
: null}
{!isPrestigeLocked
@@ -243,6 +249,7 @@ const BossPanel = (): JSX.Element => {
});
const bossUnlockHints = new Map<string, string>();
const bossQuestNames = new Map<string, string>();
for (const zone of zones) {
const { id: zoneId, unlockBossId, unlockQuestId } = zone;
const allZoneBosses = bosses.filter((boss) => {
@@ -253,8 +260,8 @@ const BossPanel = (): JSX.Element => {
if (boss === undefined || boss.status !== "locked") {
continue;
}
const parts: Array<string> = [];
if (index === 0) {
const parts: Array<string> = [];
if (unlockBossId !== null) {
const gateBoss = bosses.find((candidate) => {
return candidate.id === unlockBossId;
@@ -271,15 +278,24 @@ const BossPanel = (): JSX.Element => {
parts.push(`📜 Complete: ${gateQuest.name}`);
}
}
if (parts.length > 0) {
bossUnlockHints.set(boss.id, parts.join(" & "));
}
} else {
const previousBoss = allZoneBosses[index - 1];
if (previousBoss !== undefined) {
bossUnlockHints.set(boss.id, `⚔️ Defeat: ${previousBoss.name} first`);
parts.push(`⚔️ Defeat: ${previousBoss.name} first`);
}
}
if (boss.unlockQuestId !== null && boss.unlockQuestId !== undefined) {
const bossGateQuest = quests.find((candidate) => {
return candidate.id === boss.unlockQuestId;
});
if (bossGateQuest !== undefined) {
parts.push(`📜 Complete: ${bossGateQuest.name}`);
bossQuestNames.set(boss.id, bossGateQuest.name);
}
}
if (parts.length > 0) {
bossUnlockHints.set(boss.id, parts.join(" & "));
}
}
}
@@ -405,6 +421,7 @@ const BossPanel = (): JSX.Element => {
key={bossId}
onChallenge={handleChallengeClick}
prestigeCount={prestigeCount}
questName={bossQuestNames.get(bossId)}
unlockHint={bossUnlockHints.get(bossId)}
/>
);
+19 -5
View File
@@ -16,6 +16,7 @@ import {
PRESTIGE_UPGRADES,
} from "../../data/prestigeUpgrades.js";
import {
computeProjectedMilestoneBonus,
computeProjectedRunestones,
} from "../../engine/tick.js";
import { cdnImage } from "../../utils/cdn.js";
@@ -70,6 +71,7 @@ const PrestigePanel = (): JSX.Element => {
toggleAutoPrestige,
toggleAutoPrestigeMaxRunestones,
triggerPrestigeToast,
notifyPrestiged,
} = useGame();
const [ isPending, setIsPending ] = useState(false);
const [ result, setResult ] = useState<{
@@ -93,12 +95,13 @@ const PrestigePanel = (): JSX.Element => {
const threshold = calculateThreshold(prestigeData.count);
const isEligible = player.totalGoldEarned >= threshold;
const runestonePreview = computeProjectedRunestones(state);
const milestoneBonusPreview = computeProjectedMilestoneBonus(state);
const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1);
const baseRunestones = Math.min(
Math.floor(Math.cbrt(player.totalGoldEarned / threshold)) * 15,
200,
400,
);
const isAtMaxRunestones = baseRunestones >= 200;
const isAtMaxRunestones = baseRunestones >= 400;
async function handlePrestige(): Promise<void> {
setIsPending(true);
@@ -111,6 +114,7 @@ const PrestigePanel = (): JSX.Element => {
runestones: data.runestones,
});
triggerPrestigeToast();
notifyPrestiged();
if (enableSounds) {
playSound("prestige");
}
@@ -230,14 +234,14 @@ const PrestigePanel = (): JSX.Element => {
{"Current production multiplier: "}
<strong>
{"×"}
{prestigeData.productionMultiplier.toFixed(2)}
{formatNumber(prestigeData.productionMultiplier)}
</strong>
</p>
<p>
{"After next prestige: "}
<strong>
{"×"}
{nextMultiplier.toFixed(2)}
{formatNumber(nextMultiplier)}
</strong>
</p>
<p>
@@ -257,10 +261,20 @@ const PrestigePanel = (): JSX.Element => {
}
</p>
: null}
{isEligible && milestoneBonusPreview > 0
? <p className="runestone-milestone-preview">
{"🎉 Bonus: "}
<strong>
{"+"}
{formatInteger(milestoneBonusPreview)}
</strong>
{" Runestones (milestone prestige)"}
</p>
: null}
{isEligible && !isAtMaxRunestones
? <p className="runestone-progress-hint">
{"Earn more gold to increase your runestone yield "
+ "(capped at ×14³ the prestige threshold)."}
+ "(capped at ×27³ the prestige threshold)."}
</p>
: null}
{isEligible
+46 -10
View File
@@ -9,6 +9,7 @@
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- Many conditional render paths */
/* eslint-disable max-statements -- Many local variables needed for quest state */
import { getActiveCompanionBonus, type Quest } from "@elysium/types";
import { useState, type JSX } from "react";
import { useGame } from "../../context/gameContext.js";
import {
@@ -18,7 +19,6 @@ import {
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js";
import { ZoneSelector } from "./zoneSelector.js";
import type { Quest } from "@elysium/types";
/**
* Formats a duration in seconds to a human-readable string.
@@ -42,24 +42,44 @@ const formatDuration = (seconds: number): string => {
return `${String(seconds)}s`;
};
/**
* Computes the effective quest duration in seconds, after applying any active
* companion quest-time reduction (e.g. Wren, Zuri).
* @param quest - The quest to check.
* @param questTimeReduction - Fractional reduction (0-1) from an active companion.
* @returns The effective duration in seconds.
*/
const effectiveQuestDuration = (
quest: Quest,
questTimeReduction: number,
): number => {
return quest.durationSeconds * (1 - questTimeReduction);
};
/**
* Computes the time remaining for an active quest.
* @param quest - The quest to check.
* @param questTimeReduction - Fractional reduction (0-1) from an active companion.
* @returns The remaining seconds.
*/
const questTimeRemaining = (quest: Quest): number => {
const questTimeRemaining = (
quest: Quest,
questTimeReduction: number,
): number => {
if (quest.status !== "active" || quest.startedAt === undefined) {
return 0;
}
const elapsed = (Date.now() - quest.startedAt) / 1000;
return Math.max(0, quest.durationSeconds - elapsed);
const remaining = effectiveQuestDuration(quest, questTimeReduction) - elapsed;
return Math.max(0, remaining);
};
interface QuestCardProperties {
readonly quest: Quest;
readonly partyCombatPower: number;
readonly unlockHint: string | undefined;
readonly zoneHint: string | undefined;
readonly quest: Quest;
readonly partyCombatPower: number;
readonly unlockHint: string | undefined;
readonly zoneHint: string | undefined;
readonly questTimeReduction: number;
}
/**
@@ -69,6 +89,7 @@ interface QuestCardProperties {
* @param props.partyCombatPower - The current party's combat power.
* @param props.unlockHint - Optional hint for how to unlock this quest.
* @param props.zoneHint - Optional hint for which zone to unlock.
* @param props.questTimeReduction - Fractional reduction (0-1) from an active companion.
* @returns The JSX element.
*/
const QuestCard = ({
@@ -76,6 +97,7 @@ const QuestCard = ({
partyCombatPower,
unlockHint,
zoneHint,
questTimeReduction,
}: QuestCardProperties): JSX.Element => {
const { startQuest, formatNumber } = useGame();
const cpRequired = quest.combatPowerRequired ?? 0;
@@ -176,14 +198,18 @@ const QuestCard = ({
type="button"
>
{"Send Party ("}
{formatDuration(quest.durationSeconds)}
{formatDuration(
Math.round(effectiveQuestDuration(quest, questTimeReduction)),
)}
{")"}
</button>
}
{quest.status === "active"
&& <span className="quest-badge active">
{"⏳ "}
{formatDuration(Math.ceil(questTimeRemaining(quest)))}
{formatDuration(
Math.ceil(questTimeRemaining(quest, questTimeReduction)),
)}
{" remaining"}
</span>
}
@@ -214,7 +240,16 @@ const QuestPanel = (): JSX.Element => {
);
}
const { autoQuest, bosses, quests, zones } = state;
const { autoQuest, bosses, companions, quests, zones } = state;
const companionBonus = getActiveCompanionBonus(
companions?.activeCompanionId,
companions?.unlockedCompanionIds ?? [],
);
const questTimeReduction
= companionBonus?.type === "questTime"
? companionBonus.value
: 0;
const activeZone = zones.find((zone) => {
return zone.id === activeZoneId;
@@ -358,6 +393,7 @@ const QuestPanel = (): JSX.Element => {
key={quest.id}
partyCombatPower={partyCombatPower}
quest={quest}
questTimeReduction={questTimeReduction}
unlockHint={questUnlockHints.get(quest.id)}
zoneHint={questZoneHints.get(quest.id)}
/>
+64 -1
View File
@@ -7,7 +7,13 @@
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- Complex component with many conditional render paths */
import { STORY_CHAPTERS } from "@elysium/types";
import { type JSX, useState } from "react";
import {
type JSX,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
useEffect,
useState,
} from "react";
import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
@@ -27,6 +33,16 @@ const substituteCharacterName = (
return text.replaceAll("{characterName}", fallback);
};
/**
* Stops the enlarged image click from bubbling to the overlay's close handler.
* @param event - The click event on the enlarged image.
*/
const handleFullImageClick = (
event: ReactMouseEvent<HTMLImageElement>,
): void => {
event.stopPropagation();
};
/**
* Renders the story panel with chapter navigation and content.
* @returns The JSX element.
@@ -34,6 +50,22 @@ const substituteCharacterName = (
const StoryPanel = (): JSX.Element => {
const { state, completeChapter } = useGame();
const [ activeChapterIndex, setActiveChapterIndex ] = useState(0);
const [ isImageExpanded, setIsImageExpanded ] = useState(false);
useEffect(() => {
if (!isImageExpanded) {
return undefined;
}
function handleKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape") {
setIsImageExpanded(false);
}
}
globalThis.addEventListener("keydown", handleKeyDown);
return (): void => {
globalThis.removeEventListener("keydown", handleKeyDown);
};
}, [ isImageExpanded ]);
if (state === null) {
return (
@@ -57,6 +89,19 @@ const StoryPanel = (): JSX.Element => {
}) ?? null;
const isUnread = isUnlocked && completion === null;
function handleImageExpand(): void {
setIsImageExpanded(true);
}
function handleImageCollapse(): void {
setIsImageExpanded(false);
}
function handleImageKeyDown(
event: ReactKeyboardEvent<HTMLImageElement>,
): void {
if (event.key === "Enter" || event.key === " ") {
setIsImageExpanded(true);
}
}
return (
<div className="story-panel">
<div className="story-chapter-tabs">
@@ -106,7 +151,11 @@ const StoryPanel = (): JSX.Element => {
<img
alt={activeChapter.title}
className="story-chapter-banner"
onClick={handleImageExpand}
onKeyDown={handleImageKeyDown}
role="button"
src={cdnImage("story-chapters", activeChapter.id)}
tabIndex={0}
/>
<h2 className="story-chapter-title">
{"Chapter "}
@@ -178,6 +227,20 @@ const StoryPanel = (): JSX.Element => {
}
</div>
}
{isImageExpanded && activeChapter !== undefined
? <div
className="modal-overlay"
onClick={handleImageCollapse}
>
<img
alt={activeChapter.title}
className="story-chapter-banner-full"
onClick={handleFullImageClick}
src={cdnImage("story-chapters", activeChapter.id)}
/>
</div>
: null}
</div>
);
};
+60 -8
View File
@@ -74,6 +74,25 @@ import { playSound } from "../utils/sound.js";
const autoSaveIntervalMs = 30_000;
const autoPrestigeThresholdBase = 1_000_000;
/**
* Returns whether a boss's own unlock quest (if any) has been completed.
* @param questId - The quest ID gating the boss, or null/undefined if none.
* @param quests - The player's current quest states.
* @returns True when there's no quest gate, or the gate quest is completed.
*/
const isBossQuestSatisfied = (
questId: string | null | undefined,
quests: GameState["quests"],
): boolean => {
return (
questId === null
|| questId === undefined
|| quests.some((q) => {
return q.id === questId && q.status === "completed";
})
);
};
/**
* Pure function — applies a boss challenge result to the game state.
* Used by both the manual challengeBoss flow and the auto-boss tick logic.
@@ -146,12 +165,14 @@ const applyBossResult = (
if (
b.id === nextZoneBossId
&& b.prestigeRequirement <= previous.prestige.count
&& isBossQuestSatisfied(b.unlockQuestId, previous.quests)
) {
return { ...b, status: "available" as const };
}
if (
zoneFirstBossIds.has(b.id)
&& b.prestigeRequirement <= previous.prestige.count
&& isBossQuestSatisfied(b.unlockQuestId, previous.quests)
) {
return { ...b, status: "available" as const };
}
@@ -180,8 +201,9 @@ const applyBossResult = (
});
return {
...equip,
equipped: slotEmpty || equip.equipped,
owned: true,
equipped: slotEmpty || equip.equipped,
everOwned: true,
owned: true,
};
}
return equip;
@@ -404,6 +426,13 @@ interface GameContextValue {
*/
triggerPrestigeToast: ()=> void;
/**
* Schedule a lightweight background save ~10s after a prestige completes,
* folding back into the normal auto-save cadence (called from prestigePanel
* on manual prestige, and internally on auto-prestige).
*/
notifyPrestiged: ()=> void;
/**
* Dismiss the prestige milestone toast.
*/
@@ -716,13 +745,15 @@ export const GameProvider = ({
const syncErrorTimerReference = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const [ numberFormat, setNumberFormat ] = useState<NumberFormat>("suffix");
const [ numberFormat, setNumberFormat ]
= useState<NumberFormat>("scientific");
const [ enableSounds, setEnableSounds ] = useState(false);
const [ enableNotifications, setEnableNotifications ] = useState(false);
const enableSoundsReference = useRef(false);
const enableNotificationsReference = useRef(false);
const stateReference = useRef<GameState | null>(null);
const lastSaveReference = useRef<number>(Date.now());
const postPrestigeSaveDeadlineReference = useRef<number | null>(null);
const isSyncingReference = useRef(false);
const rafReference = useRef<number | null>(null);
const unlockedAchievementsReference = useRef<Array<Achievement>>([]);
@@ -741,6 +772,9 @@ export const GameProvider = ({
/* No-op placeholder */
});
const notifyPrestiged = useCallback(() => {
postPrestigeSaveDeadlineReference.current = Date.now() + 10_000;
}, []);
const [ schemaOutdated, setSchemaOutdated ] = useState(false);
const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0);
const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0);
@@ -1356,9 +1390,19 @@ export const GameProvider = ({
newlyFailedQuestsReference.current = [];
}
// Auto-save every 30 seconds (skip if a force sync or auto-prestige is in-flight to avoid signature collisions)
if (Date.now() - lastSaveReference.current >= autoSaveIntervalMs) {
/*
* Auto-save every 30 seconds, or ~10s after a prestige (whichever comes
* first) so recent progress lands without waiting on a heavy force-sync
* (skip if a force sync or auto-prestige is in-flight to avoid signature collisions)
*/
const postPrestigeDeadline = postPrestigeSaveDeadlineReference.current;
const shouldAutoSave
= Date.now() - lastSaveReference.current >= autoSaveIntervalMs;
const shouldPostPrestigeSave
= postPrestigeDeadline !== null && Date.now() >= postPrestigeDeadline;
if (shouldAutoSave || shouldPostPrestigeSave) {
lastSaveReference.current = Date.now();
postPrestigeSaveDeadlineReference.current = null;
// eslint-disable-next-line stylistic/max-len -- Long condition; splitting would reduce readability
if (stateReference.current !== null && !isSyncingReference.current && !isAutoPrestigingReference.current) {
void saveGame({
@@ -1406,11 +1450,11 @@ export const GameProvider = ({
(autoState?.player.totalGoldEarned ?? 0) / autoPrestigeThreshold,
),
) * 15,
200,
400,
);
const autoMaxRunestonesMet
= autoState?.prestige.autoPrestigeMaxRunestonesOnly !== true
|| autoBaseRunestones >= 200;
|| autoBaseRunestones >= 400;
if (
!isAutoPrestigingReference.current
&& autoState?.prestige.purchasedUpgradeIds.includes("auto_prestige")
@@ -1429,6 +1473,7 @@ export const GameProvider = ({
if (enableNotificationsReference.current) {
sendNotification("⭐ Prestige!", "You have ascended!");
}
notifyPrestiged();
await reloadSilentReference.current();
}).
catch(() => {
@@ -1823,7 +1868,12 @@ export const GameProvider = ({
...previous,
equipment: previous.equipment.map((equip) => {
if (equip.id === equipmentId) {
return { ...equip, equipped: !slotAlreadyEquipped, owned: true };
return {
...equip,
equipped: !slotAlreadyEquipped,
everOwned: true,
owned: true,
};
}
return equip;
}),
@@ -2531,6 +2581,7 @@ export const GameProvider = ({
lastSavedAt,
loginBonus,
loginStreak,
notifyPrestiged,
numberFormat,
offlineEssence,
offlineGold,
@@ -2608,6 +2659,7 @@ export const GameProvider = ({
lastSavedAt,
loginBonus,
loginStreak,
notifyPrestiged,
numberFormat,
offlineEssence,
offlineGold,
+67 -67
View File
@@ -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: 300,
durationSeconds: 60,
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: 600,
durationSeconds: 75,
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: 900,
durationSeconds: 90,
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: 1200,
durationSeconds: 105,
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: 600,
durationSeconds: 75,
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: 1200,
durationSeconds: 90,
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: 1800,
durationSeconds: 120,
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: 2400,
durationSeconds: 150,
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: 900,
durationSeconds: 105,
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: 1800,
durationSeconds: 135,
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: 2700,
durationSeconds: 180,
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: 3600,
durationSeconds: 240,
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: 1500,
durationSeconds: 105,
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: 3000,
durationSeconds: 150,
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: 5400,
durationSeconds: 195,
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: 5400,
durationSeconds: 255,
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: 2100,
durationSeconds: 120,
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: 3600,
durationSeconds: 165,
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: 7200,
durationSeconds: 225,
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: 9000,
durationSeconds: 300,
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: 3000,
durationSeconds: 150,
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: 5400,
durationSeconds: 210,
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: 9000,
durationSeconds: 270,
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: 12_600,
durationSeconds: 375,
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: 3600,
durationSeconds: 300,
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: 7200,
durationSeconds: 390,
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: 10_800,
durationSeconds: 540,
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: 14_400,
durationSeconds: 720,
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: 3600,
durationSeconds: 420,
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: 9000,
durationSeconds: 570,
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: 12_600,
durationSeconds: 750,
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: 16_200,
durationSeconds: 990,
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: 5400,
durationSeconds: 540,
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: 9000,
durationSeconds: 720,
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: 14_400,
durationSeconds: 960,
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: 19_800,
durationSeconds: 1320,
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: 5400,
durationSeconds: 750,
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: 10_800,
durationSeconds: 1005,
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: 16_200,
durationSeconds: 1320,
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: 21_600,
durationSeconds: 1800,
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: 5400,
durationSeconds: 990,
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: 12_600,
durationSeconds: 1320,
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: 18_000,
durationSeconds: 1800,
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: 25_200,
durationSeconds: 2400,
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: 7200,
durationSeconds: 1320,
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: 12_600,
durationSeconds: 1800,
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: 19_800,
durationSeconds: 2400,
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: 25_200,
durationSeconds: 3600,
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: 7200,
durationSeconds: 1800,
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: 14_400,
durationSeconds: 2700,
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: 21_600,
durationSeconds: 3600,
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: 28_800,
durationSeconds: 4500,
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: 7200,
durationSeconds: 2400,
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: 16_200,
durationSeconds: 3600,
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: 25_200,
durationSeconds: 4500,
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: 32_400,
durationSeconds: 5700,
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: 9000,
durationSeconds: 5400,
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: 16_200,
durationSeconds: 7200,
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: 25_200,
durationSeconds: 9000,
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: 32_400,
durationSeconds: 12_600,
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: 9000,
durationSeconds: 7200,
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: 18_000,
durationSeconds: 10_800,
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: 28_800,
durationSeconds: 14_400,
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: 36_000,
durationSeconds: 18_000,
id: "eye_approach",
name: "The Eye Approach",
zoneId: "cosmic_maelstrom",
@@ -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: 19_800,
durationSeconds: 16_200,
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: 28_800,
durationSeconds: 22_500,
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: 39_600,
durationSeconds: 29_700,
id: "the_oldest_place",
name: "The Oldest Place",
zoneId: "primeval_sanctum",
+6 -6
View File
@@ -94,20 +94,20 @@ export const PRESTIGE_UPGRADES: Array<PrestigeUpgrade> = [
{
category: "income",
description:
"The oldest runes, carved before memory began, yield their secrets at last. All production ×500.",
"The oldest runes, carved before memory began, yield their secrets at last. All production ×200.",
id: "income_10",
multiplier: 500,
multiplier: 200,
name: "Eternal Rune I",
runestonesCost: 30_000,
runestonesCost: 15_000,
},
{
category: "income",
description:
"Eternal runes resonate with the heartbeat of creation itself. All production ×1,000.",
"Eternal runes resonate with the heartbeat of creation itself. All production ×500.",
id: "income_11",
multiplier: 1000,
multiplier: 500,
name: "Eternal Rune II",
runestonesCost: 80_000,
runestonesCost: 35_000,
},
// ── Click Power ───────────────────────────────────────────────────────────
{
+15 -15
View File
@@ -13,7 +13,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
// ── Income multipliers ──────────────────────────────────────────────────────
{
category: "income",
cost: 5,
cost: 2,
description:
"The echoes of past runs linger, amplifying your guild's income by 25%.",
id: "echo_income_1",
@@ -22,7 +22,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
},
{
category: "income",
cost: 10,
cost: 4,
description:
"Your transcendent experience resonates through your guild, boosting income by 50%.",
id: "echo_income_2",
@@ -31,7 +31,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
},
{
category: "income",
cost: 20,
cost: 8,
description:
"The harmony of multiple timelines surges through your guild, doubling its income.",
id: "echo_income_3",
@@ -40,7 +40,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
},
{
category: "income",
cost: 40,
cost: 16,
description:
"Ethereal energy overflows from your transcendence, tripling your guild's income.",
id: "echo_income_4",
@@ -49,7 +49,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
},
{
category: "income",
cost: 80,
cost: 32,
description:
"The infinite chorus of every run you've ever played amplifies your guild fivefold.",
id: "echo_income_5",
@@ -60,7 +60,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
// ── Combat multipliers ──────────────────────────────────────────────────────
{
category: "combat",
cost: 5,
cost: 2,
description:
"Memories of countless battles harden your adventurers, increasing party DPS by 25%.",
id: "echo_combat_1",
@@ -69,7 +69,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
},
{
category: "combat",
cost: 15,
cost: 6,
description:
"Veterans of transcendence know how to fight smarter, boosting party DPS by 50%.",
id: "echo_combat_2",
@@ -78,7 +78,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
},
{
category: "combat",
cost: 35,
cost: 12,
description:
"Your warriors carry the strength of every fallen timeline, doubling party DPS.",
id: "echo_combat_3",
@@ -89,7 +89,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
// ── Prestige threshold reductions ──────────────────────────────────────────
{
category: "prestige_threshold",
cost: 8,
cost: 3,
description:
"Experience from past lives shortens the road to prestige — threshold reduced by 10%.",
id: "echo_prestige_threshold_1",
@@ -98,7 +98,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
},
{
category: "prestige_threshold",
cost: 20,
cost: 6,
description:
"You've walked this path so many times you know every shortcut — threshold reduced by 20%.",
id: "echo_prestige_threshold_2",
@@ -109,7 +109,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
// ── Prestige runestone multipliers ─────────────────────────────────────────
{
category: "prestige_runestones",
cost: 8,
cost: 3,
description:
"Transcendent insight attunes you to the runestones, earning 50% more per prestige.",
id: "echo_prestige_runestones_1",
@@ -118,7 +118,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
},
{
category: "prestige_runestones",
cost: 20,
cost: 6,
description:
"You have mastered the art of runestone crafting, doubling your prestige runestone yield.",
id: "echo_prestige_runestones_2",
@@ -129,7 +129,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
// ── Echo meta multipliers ───────────────────────────────────────────────────
{
category: "echo_meta",
cost: 10,
cost: 15,
description:
"Your transcendence resonates deeper, amplifying future echo yields by 25%.",
id: "echo_meta_1",
@@ -138,7 +138,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
},
{
category: "echo_meta",
cost: 25,
cost: 45,
description:
"Each loop of existence makes the next more powerful — future echo yields +50%.",
id: "echo_meta_2",
@@ -147,7 +147,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
},
{
category: "echo_meta",
cost: 50,
cost: 100,
description:
"You have mastered the infinite spiral of transcendence, doubling all future echo yields.",
id: "echo_meta_3",
@@ -0,0 +1,57 @@
/**
* @file Shared achievement progress calculation for Elysium.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { AchievementCondition, GameState } from "@elysium/types";
/**
* Computes the player's current progress toward an achievement condition.
* Stat-based conditions combine the frozen lifetime total from the last
* prestige/transcendence/apotheosis reset with this run's live contribution,
* so progress reflects the true all-time total rather than resetting to zero
* every reset. `prestigeCount`, `equipmentOwned`, `uniqueQuestsCompleted`,
* and `uniqueBossesDefeated` already read persistent state directly (via
* `everOwned`/`everCompleted`/`everDefeated` flags) and need no such
* combination.
* @param condition - The achievement's unlock condition.
* @param state - The current game state to evaluate progress against.
* @returns The current numeric progress toward the condition's target amount.
*/
const getAchievementProgress = (
condition: AchievementCondition,
state: GameState,
): number => {
switch (condition.type) {
case "totalGoldEarned":
return state.player.lifetimeGoldEarned + state.player.totalGoldEarned;
case "totalClicks":
return state.player.lifetimeClicks + state.player.totalClicks;
case "uniqueBossesDefeated":
return state.bosses.filter((boss) => {
return boss.everDefeated === true || boss.status === "defeated";
}).length;
case "uniqueQuestsCompleted":
return state.quests.filter((quest) => {
return quest.everCompleted === true || quest.status === "completed";
}).length;
case "adventurerTotal":
return (
state.player.lifetimeAdventurersRecruited
+ state.adventurers.reduce((sum, adventurer) => {
return sum + adventurer.count;
}, 0)
);
case "prestigeCount":
return state.prestige.count;
case "equipmentOwned":
return state.equipment.filter((item) => {
return item.everOwned === true || item.owned;
}).length;
default:
/* V8 ignore next -- @preserve */ return 0;
}
};
export { getAchievementProgress };
+77 -46
View File
@@ -22,6 +22,7 @@ import {
import { EQUIPMENT_SETS } from "../data/equipmentSets.js";
import { EXPLORATION_AREAS } from "../data/explorations.js";
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
import { getAchievementProgress } from "./achievementProgress.js";
/**
* Checks all achievements against the current game state and returns an updated
@@ -37,45 +38,7 @@ const checkAchievements = (state: GameState): Array<Achievement> => {
}
const { condition } = achievement;
let met = false;
switch (condition.type) {
case "totalGoldEarned":
met = state.player.lifetimeGoldEarned >= condition.amount;
break;
case "totalClicks":
met = state.player.totalClicks >= condition.amount;
break;
case "bossesDefeated":
met
= state.bosses.filter((boss) => {
return boss.status === "defeated";
}).length >= condition.amount;
break;
case "questsCompleted":
met
= state.quests.filter((quest) => {
return quest.status === "completed";
}).length >= condition.amount;
break;
case "adventurerTotal":
met
= state.adventurers.reduce((sum, adventurer) => {
return sum + adventurer.count;
}, 0) >= condition.amount;
break;
case "prestigeCount":
met = state.prestige.count >= condition.amount;
break;
case "equipmentOwned":
met
= state.equipment.filter((item) => {
return item.owned;
}).length >= condition.amount;
break;
default:
/* V8 ignore next -- @preserve */ break;
}
const met = getAchievementProgress(condition, state) >= condition.amount;
return met
? { ...achievement, unlockedAt: now }
@@ -452,11 +415,32 @@ export const computePartyCombatPower = (state: GameState): number => {
const basePrestigeThreshold = 1_000_000;
const runestonesPerPrestigeLevelClient = 20;
const maxBaseRunestones = 200;
const maxBaseRunestones = 400;
const milestoneIntervalClient = 5;
const milestoneRunestonesPerIntervalClient = 25;
/**
* Computes the projected runestone reward if the player were to prestige right now.
* Mirrors the server-side calculateRunestones formula exactly.
* Computes the milestone runestone bonus the player would receive if they
* prestiged right now. Mirrors the server-side calculateMilestoneBonus formula:
* every MILESTONE_INTERVAL prestiges awards milestone_number² * MILESTONE_RUNESTONES_PER_INTERVAL stones.
* @param state - The current game state.
* @returns The milestone runestone bonus, or 0 if the next prestige isn't a milestone.
*/
export const computeProjectedMilestoneBonus = (state: GameState): number => {
const nextPrestigeCount = state.prestige.count + 1;
if (nextPrestigeCount % milestoneIntervalClient !== 0) {
return 0;
}
const milestoneNumber = nextPrestigeCount / milestoneIntervalClient;
return (
milestoneNumber * milestoneNumber * milestoneRunestonesPerIntervalClient
);
};
/**
* Computes the projected runestone reward if the player were to prestige right now,
* including any milestone bonus, so callers always see the true total they'd receive.
* Mirrors the server-side calculateRunestones + calculateMilestoneBonus formulas exactly.
* @param state - The current game state.
* @returns The number of runestones the player would earn from a prestige now.
*/
@@ -480,7 +464,10 @@ export const computeProjectedRunestones = (state: GameState): number => {
const runestoneMult = gain1Mult * gain2Mult;
const echoMult: number
= state.transcendence?.echoPrestigeRunestoneMultiplier ?? 1;
return Math.floor(base * runestoneMult * echoMult);
return (
Math.floor(base * runestoneMult * echoMult)
+ computeProjectedMilestoneBonus(state)
);
};
/**
@@ -648,8 +635,9 @@ export const applyTick = (
});
return {
...item,
equipped: slotEmpty || item.equipped,
owned: true,
equipped: slotEmpty || item.equipped,
everOwned: true,
owned: true,
};
});
}
@@ -731,7 +719,11 @@ export const applyTick = (
return zoneBoss.zoneId === boss.zoneId;
});
const [ firstBoss ] = zoneBosses;
if (firstBoss?.id === boss.id && boss.status === "locked") {
const questOk
= boss.unlockQuestId === null
|| boss.unlockQuestId === undefined
|| completedIds.has(boss.unlockQuestId);
if (firstBoss?.id === boss.id && boss.status === "locked" && questOk) {
return { ...boss, status: "available" as const };
}
}
@@ -739,6 +731,45 @@ export const applyTick = (
});
}
/*
* Activate any locked boss whose own unlock quest just became satisfied
* this tick, independent of a zone-unlock or boss-defeat event.
*/
updatedBosses = updatedBosses.map((boss) => {
if (boss.status !== "locked") {
return boss;
}
if (boss.unlockQuestId === null || boss.unlockQuestId === undefined) {
return boss;
}
if (!completedIds.has(boss.unlockQuestId)) {
return boss;
}
if (boss.prestigeRequirement > state.prestige.count) {
return boss;
}
const bossZone = updatedZones.find((zone) => {
return zone.id === boss.zoneId;
});
if (bossZone?.status !== "unlocked") {
return boss;
}
const zoneBosses = updatedBosses.filter((zoneBoss) => {
return zoneBoss.zoneId === boss.zoneId;
});
const bossIndex = zoneBosses.findIndex((zoneBoss) => {
return zoneBoss.id === boss.id;
});
const previousZoneBoss = zoneBosses[bossIndex - 1];
if (
previousZoneBoss !== undefined
&& previousZoneBoss.status !== "defeated"
) {
return boss;
}
return { ...boss, status: "available" as const };
});
// Count quests newly completed this tick and update daily challenge progress
const newlyCompletedQuestCount = updatedQuests.filter((quest, index) => {
const wasNotCompleted = state.quests[index]?.status !== "completed";
+11 -3
View File
@@ -4455,6 +4455,7 @@ body::before {
color: var(--colour-text);
line-height: 1.6;
font-style: italic;
font-size: 0.9rem;
padding: 0.75rem;
background: var(--colour-surface);
border-radius: 0.5rem;
@@ -4583,13 +4584,20 @@ body::before {
.story-chapter-banner {
border-radius: var(--radius);
height: 220px;
height: 260px;
margin-bottom: 1rem;
object-fit: cover;
object-position: top;
object-fit: contain;
cursor: pointer;
width: 100%;
}
.story-chapter-banner-full {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
border-radius: var(--radius);
}
.codex-entry-image {
border-radius: var(--radius);
height: 80px;