generated from nhcarrigan/template
feat: content expansion, prestige shop, and offline earnings improvements
- Expand content to 18 zones, 72 bosses, 95 quests, 32 adventurer tiers - Add prestige shop with 24 runestone upgrades across 5 categories - Add PrestigeUpgrade type, data files, API routes, and frontend panel - Fix offline earnings to include equipment and runestone multipliers - Add offline essence calculation alongside offline gold - Update OfflineModal to display both gold and essence earned - Add IDEAS.md for tracking planned features
This commit is contained in:
@@ -2,6 +2,8 @@ import type {
|
||||
AuthResponse,
|
||||
BossChallengeRequest,
|
||||
BossChallengeResponse,
|
||||
BuyPrestigeUpgradeRequest,
|
||||
BuyPrestigeUpgradeResponse,
|
||||
LoadResponse,
|
||||
PrestigeRequest,
|
||||
PrestigeResponse,
|
||||
@@ -77,6 +79,14 @@ export const prestige = async (body: PrestigeRequest): Promise<PrestigeResponse>
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
export const buyPrestigeUpgrade = async (
|
||||
body: BuyPrestigeUpgradeRequest,
|
||||
): Promise<BuyPrestigeUpgradeResponse> =>
|
||||
request<BuyPrestigeUpgradeResponse>("/prestige/buy-upgrade", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
export const getPublicProfile = async (
|
||||
discordId: string,
|
||||
): Promise<PublicProfileResponse> =>
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
|
||||
export const OfflineModal = (): React.JSX.Element | null => {
|
||||
const { offlineGold, dismissOfflineGold } = useGame();
|
||||
const { offlineGold, offlineEssence, dismissOfflineGold, formatNumber } = useGame();
|
||||
|
||||
if (offlineGold <= 0) return null;
|
||||
if (offlineGold <= 0 && offlineEssence <= 0) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<h2>Welcome back!</h2>
|
||||
<p>
|
||||
Your adventurers kept working whilst you were away and earned{" "}
|
||||
<strong>🪙 {offlineGold.toFixed(0)} gold</strong>!
|
||||
</p>
|
||||
<p>Your adventurers kept working whilst you were away and earned:</p>
|
||||
{offlineGold > 0 && (
|
||||
<p>
|
||||
<strong>🪙 {formatNumber(offlineGold)} gold</strong>
|
||||
</p>
|
||||
)}
|
||||
{offlineEssence > 0 && (
|
||||
<p>
|
||||
<strong>✨ {formatNumber(offlineEssence)} essence</strong>
|
||||
</p>
|
||||
)}
|
||||
<p className="modal-note">Offline progress is calculated up to 8 hours.</p>
|
||||
<button
|
||||
className="modal-close-button"
|
||||
|
||||
@@ -1,90 +1,236 @@
|
||||
import type { PrestigeUpgradeCategory } from "@elysium/types";
|
||||
import { useState } from "react";
|
||||
import { prestige } from "../../api/client.js";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import {
|
||||
PRESTIGE_UPGRADES,
|
||||
PRESTIGE_UPGRADE_CATEGORY_LABELS,
|
||||
} from "../../data/prestigeUpgrades.js";
|
||||
|
||||
const PRESTIGE_THRESHOLD = 1_000_000;
|
||||
const BASE_THRESHOLD = 1_000_000;
|
||||
const THRESHOLD_SCALE = 5;
|
||||
const RUNESTONES_PER_LEVEL = 10;
|
||||
|
||||
const calculateThreshold = (prestigeCount: number): number =>
|
||||
BASE_THRESHOLD * Math.pow(THRESHOLD_SCALE, prestigeCount);
|
||||
|
||||
const calculateProductionMultiplier = (prestigeCount: number): number =>
|
||||
Math.pow(1.15, prestigeCount);
|
||||
|
||||
const calculateRunestonePreview = (
|
||||
totalGoldEarned: number,
|
||||
prestigeCount: number,
|
||||
purchasedUpgradeIds: string[],
|
||||
): number => {
|
||||
const threshold = calculateThreshold(prestigeCount);
|
||||
const base = Math.floor(Math.sqrt(totalGoldEarned / threshold)) * RUNESTONES_PER_LEVEL;
|
||||
const runestoneMult = PRESTIGE_UPGRADES
|
||||
.filter((u) => u.category === "runestones" && purchasedUpgradeIds.includes(u.id))
|
||||
.reduce((mult, u) => mult * u.multiplier, 1);
|
||||
return Math.floor(base * runestoneMult);
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER: PrestigeUpgradeCategory[] = [
|
||||
"income",
|
||||
"click",
|
||||
"essence",
|
||||
"crystals",
|
||||
"runestones",
|
||||
];
|
||||
|
||||
export const PrestigePanel = (): React.JSX.Element => {
|
||||
const { state, reload, formatNumber } = useGame();
|
||||
const { state, reload, formatNumber, buyPrestigeUpgrade } = useGame();
|
||||
const [characterName, setCharacterName] = useState("");
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [result, setResult] = useState<{ runestones: number; count: number } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [prestigeError, setPrestigeError] = useState<string | null>(null);
|
||||
const [buyingId, setBuyingId] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<"prestige" | "shop">("prestige");
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const isEligible = state.player.totalGoldEarned >= PRESTIGE_THRESHOLD;
|
||||
const { prestige: prestigeData, player } = state;
|
||||
const threshold = calculateThreshold(prestigeData.count);
|
||||
const isEligible = player.totalGoldEarned >= threshold;
|
||||
const runestonePreview = calculateRunestonePreview(
|
||||
player.totalGoldEarned,
|
||||
prestigeData.count,
|
||||
prestigeData.purchasedUpgradeIds,
|
||||
);
|
||||
const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1);
|
||||
|
||||
const handlePrestige = async (): Promise<void> => {
|
||||
if (!characterName.trim()) return;
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
setPrestigeError(null);
|
||||
try {
|
||||
const data = await prestige({ characterName: characterName.trim() });
|
||||
setResult({ runestones: data.runestones, count: data.newPrestigeCount });
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Prestige failed");
|
||||
setPrestigeError(err instanceof Error ? err.message : "Prestige failed");
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBuyUpgrade = async (upgradeId: string): Promise<void> => {
|
||||
setBuyingId(upgradeId);
|
||||
try {
|
||||
await buyPrestigeUpgrade(upgradeId);
|
||||
} finally {
|
||||
setBuyingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const upgradesByCategory = CATEGORY_ORDER.map((category) => ({
|
||||
category,
|
||||
label: PRESTIGE_UPGRADE_CATEGORY_LABELS[category] ?? category,
|
||||
upgrades: PRESTIGE_UPGRADES.filter((u) => u.category === category),
|
||||
}));
|
||||
|
||||
return (
|
||||
<section className="panel prestige-panel">
|
||||
<h2>⭐ Prestige</h2>
|
||||
<p>
|
||||
Prestige resets your progress but grants <strong>Runestones</strong> — permanent
|
||||
currency used for powerful upgrades. Each prestige also increases your global
|
||||
production multiplier by 10%.
|
||||
</p>
|
||||
|
||||
<div className="prestige-status">
|
||||
<p>
|
||||
Total gold earned:{" "}
|
||||
<strong>{formatNumber(state.player.totalGoldEarned)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Required: <strong>{formatNumber(PRESTIGE_THRESHOLD)}</strong>
|
||||
</p>
|
||||
<p>Current prestige count: <strong>{state.prestige.count}</strong></p>
|
||||
<p>
|
||||
Production multiplier:{" "}
|
||||
<strong>×{state.prestige.productionMultiplier.toFixed(1)}</strong>
|
||||
</p>
|
||||
<div className="prestige-tabs">
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "prestige" ? "active" : ""}`}
|
||||
onClick={() => { setActiveTab("prestige"); }}
|
||||
type="button"
|
||||
>
|
||||
Ascend
|
||||
</button>
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "shop" ? "active" : ""}`}
|
||||
onClick={() => { setActiveTab("shop"); }}
|
||||
type="button"
|
||||
>
|
||||
🔮 Runestone Shop ({formatNumber(prestigeData.runestones)} stones)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isEligible ? (
|
||||
<div className="prestige-form">
|
||||
<p>You are ready to prestige! Choose your new character name:</p>
|
||||
<input
|
||||
type="text"
|
||||
value={characterName}
|
||||
onChange={(e) => { setCharacterName(e.target.value); }}
|
||||
placeholder="Character name..."
|
||||
maxLength={32}
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
className="prestige-button"
|
||||
onClick={() => { void handlePrestige(); }}
|
||||
disabled={isPending || !characterName.trim()}
|
||||
type="button"
|
||||
>
|
||||
{isPending ? "Ascending..." : "✨ Ascend"}
|
||||
</button>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{result && (
|
||||
<p className="success">
|
||||
Ascended to Prestige {result.count}! Earned {result.runestones} Runestones.
|
||||
{activeTab === "prestige" && (
|
||||
<>
|
||||
<p>
|
||||
Prestige resets your progress but grants <strong>Runestones</strong> — permanent
|
||||
currency used for powerful upgrades. Each prestige multiplies your global production
|
||||
by ×1.15 (compounding each run).
|
||||
</p>
|
||||
|
||||
<div className="prestige-status">
|
||||
<p>
|
||||
Total gold this run:{" "}
|
||||
<strong>{formatNumber(player.totalGoldEarned)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Required to prestige: <strong>{formatNumber(threshold)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Prestige count: <strong>{prestigeData.count}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Current production multiplier:{" "}
|
||||
<strong>×{prestigeData.productionMultiplier.toFixed(2)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
After next prestige:{" "}
|
||||
<strong>×{nextMultiplier.toFixed(2)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Runestones: <strong>{formatNumber(prestigeData.runestones)}</strong>
|
||||
</p>
|
||||
{isEligible && (
|
||||
<p className="runestone-preview">
|
||||
Runestones on prestige: <strong>+{formatNumber(runestonePreview)}</strong>
|
||||
</p>
|
||||
)}
|
||||
{!isEligible && (
|
||||
<p className="prestige-progress">
|
||||
Progress: {formatNumber(player.totalGoldEarned)} / {formatNumber(threshold)}{" "}
|
||||
({((player.totalGoldEarned / threshold) * 100).toFixed(1)}%)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEligible ? (
|
||||
<div className="prestige-form">
|
||||
<p>You are ready to prestige! Choose your new character name:</p>
|
||||
<input
|
||||
disabled={isPending}
|
||||
maxLength={32}
|
||||
onChange={(e) => { setCharacterName(e.target.value); }}
|
||||
placeholder="Character name..."
|
||||
type="text"
|
||||
value={characterName}
|
||||
/>
|
||||
<button
|
||||
className="prestige-button"
|
||||
disabled={isPending || !characterName.trim()}
|
||||
onClick={() => { void handlePrestige(); }}
|
||||
type="button"
|
||||
>
|
||||
{isPending ? "Ascending..." : `✨ Ascend (+${formatNumber(runestonePreview)} Runestones)`}
|
||||
</button>
|
||||
{prestigeError && <p className="error">{prestigeError}</p>}
|
||||
{result && (
|
||||
<p className="success">
|
||||
Ascended to Prestige {result.count}! Earned {formatNumber(result.runestones)} Runestones.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="prestige-locked">
|
||||
Earn {formatNumber(threshold - player.totalGoldEarned)} more gold to unlock prestige.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === "shop" && (
|
||||
<div className="runestone-shop">
|
||||
<p className="shop-balance">
|
||||
Balance: <strong>{formatNumber(prestigeData.runestones)} Runestones</strong>
|
||||
</p>
|
||||
|
||||
{upgradesByCategory.map(({ category, label, upgrades }) => (
|
||||
<div key={category} className="shop-category">
|
||||
<h3>{label}</h3>
|
||||
<div className="shop-upgrades">
|
||||
{upgrades.map((upgrade) => {
|
||||
const purchased = prestigeData.purchasedUpgradeIds.includes(upgrade.id);
|
||||
const canAfford = prestigeData.runestones >= upgrade.runestonesCost;
|
||||
const isLoading = buyingId === upgrade.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={upgrade.id}
|
||||
className={`shop-upgrade-card ${purchased ? "purchased" : ""} ${!canAfford && !purchased ? "unaffordable" : ""}`}
|
||||
>
|
||||
<div className="shop-upgrade-info">
|
||||
<h4>{upgrade.name}</h4>
|
||||
<p>{upgrade.description}</p>
|
||||
<p className="upgrade-cost">
|
||||
{purchased ? "✅ Purchased" : `🔮 ${formatNumber(upgrade.runestonesCost)} Runestones`}
|
||||
</p>
|
||||
</div>
|
||||
{!purchased && (
|
||||
<button
|
||||
className="buy-upgrade-button"
|
||||
disabled={!canAfford || isLoading || buyingId !== null}
|
||||
onClick={() => { void handleBuyUpgrade(upgrade.id); }}
|
||||
type="button"
|
||||
>
|
||||
{isLoading ? "Buying..." : "Buy"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="prestige-locked">
|
||||
Earn {formatNumber(PRESTIGE_THRESHOLD - state.player.totalGoldEarned)} more
|
||||
gold to unlock prestige.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -18,18 +18,27 @@ const questTimeRemaining = (quest: Quest): number => {
|
||||
|
||||
interface QuestCardProps {
|
||||
quest: Quest;
|
||||
partyCombatPower: number;
|
||||
unlockHint?: string | undefined;
|
||||
zoneHint?: string | undefined;
|
||||
}
|
||||
|
||||
const QuestCard = ({ quest, unlockHint, zoneHint }: QuestCardProps): React.JSX.Element => {
|
||||
const QuestCard = ({ quest, partyCombatPower, unlockHint, zoneHint }: QuestCardProps): React.JSX.Element => {
|
||||
const { startQuest, formatNumber } = useGame();
|
||||
const cpRequired = quest.combatPowerRequired ?? 0;
|
||||
const meetsCP = partyCombatPower >= cpRequired;
|
||||
|
||||
return (
|
||||
<div className={`quest-card quest-${quest.status}`}>
|
||||
<div className="quest-info">
|
||||
<h3>{quest.name}</h3>
|
||||
<p>{quest.description}</p>
|
||||
{cpRequired > 0 && (
|
||||
<p className={`quest-cp-requirement ${meetsCP ? "cp-met" : "cp-unmet"}`}>
|
||||
⚔️ Requires {formatNumber(cpRequired)} Combat Power
|
||||
{quest.status === "available" && (meetsCP ? " ✓" : ` (you have ${formatNumber(partyCombatPower)})`)}
|
||||
</p>
|
||||
)}
|
||||
<div className="quest-rewards">
|
||||
{quest.rewards.map((reward, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key -- rewards have no unique id
|
||||
@@ -54,7 +63,9 @@ const QuestCard = ({ quest, unlockHint, zoneHint }: QuestCardProps): React.JSX.E
|
||||
{quest.status === "available" && (
|
||||
<button
|
||||
className="start-quest-button"
|
||||
disabled={!meetsCP}
|
||||
onClick={() => { startQuest(quest.id); }}
|
||||
title={meetsCP ? undefined : `Need ${formatNumber(cpRequired)} combat power`}
|
||||
type="button"
|
||||
>
|
||||
Send Party ({formatDuration(quest.durationSeconds)})
|
||||
@@ -78,6 +89,11 @@ export const QuestPanel = (): React.JSX.Element => {
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const partyCombatPower = state.adventurers.reduce(
|
||||
(total, a) => total + a.combatPower * a.count,
|
||||
0,
|
||||
);
|
||||
|
||||
const zones = state.zones ?? [];
|
||||
const zoneQuests = state.quests.filter((q) => q.zoneId === activeZoneId);
|
||||
const lockedCount = zoneQuests.filter((q) => q.status === "locked").length;
|
||||
@@ -124,7 +140,13 @@ export const QuestPanel = (): React.JSX.Element => {
|
||||
|
||||
<div className="quest-list">
|
||||
{visibleQuests.map((quest) => (
|
||||
<QuestCard key={quest.id} quest={quest} unlockHint={questUnlockHints.get(quest.id)} zoneHint={questZoneHints.get(quest.id)} />
|
||||
<QuestCard
|
||||
key={quest.id}
|
||||
partyCombatPower={partyCombatPower}
|
||||
quest={quest}
|
||||
unlockHint={questUnlockHints.get(quest.id)}
|
||||
zoneHint={questZoneHints.get(quest.id)}
|
||||
/>
|
||||
))}
|
||||
{visibleQuests.length === 0 && (
|
||||
<p className="empty-zone">No quests to show in this zone.</p>
|
||||
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { challengeBoss as challengeBossApi, loadGame, saveGame } from "../api/client.js";
|
||||
import {
|
||||
buyPrestigeUpgrade as buyPrestigeUpgradeApi,
|
||||
challengeBoss as challengeBossApi,
|
||||
loadGame,
|
||||
saveGame,
|
||||
} from "../api/client.js";
|
||||
import { RESOURCE_CAP, applyTick, calculateClickPower } from "../engine/tick.js";
|
||||
import { formatNumber as formatNumberUtil } from "../utils/format.js";
|
||||
|
||||
@@ -47,7 +52,9 @@ interface GameContextValue {
|
||||
syncError: string | null;
|
||||
/** Offline gold earned on login */
|
||||
offlineGold: number;
|
||||
/** Dismiss the offline gold notification */
|
||||
/** Offline essence earned on login */
|
||||
offlineEssence: number;
|
||||
/** Dismiss the offline earnings notification */
|
||||
dismissOfflineGold: () => void;
|
||||
/** Battle result to display in the modal (null when no battle pending) */
|
||||
battleResult: BattleResult | null;
|
||||
@@ -63,6 +70,8 @@ interface GameContextValue {
|
||||
setNumberFormat: (format: NumberFormat) => void;
|
||||
/** Format a number using the player's chosen notation style */
|
||||
formatNumber: (value: number) => string;
|
||||
/** Buy a prestige upgrade from the runestone shop */
|
||||
buyPrestigeUpgrade: (upgradeId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const GameContext = createContext<GameContextValue | null>(null);
|
||||
@@ -74,6 +83,7 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [offlineGold, setOfflineGold] = useState(0);
|
||||
const [offlineEssence, setOfflineEssence] = useState(0);
|
||||
const [battleResult, setBattleResult] = useState<BattleResult | null>(null);
|
||||
const [newAchievements, setNewAchievements] = useState<Achievement[]>([]);
|
||||
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
|
||||
@@ -104,6 +114,9 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
if (data.offlineGold > 0) {
|
||||
setOfflineGold(data.offlineGold);
|
||||
}
|
||||
if (data.offlineEssence > 0) {
|
||||
setOfflineEssence(data.offlineEssence);
|
||||
}
|
||||
// Fetch number format preference from profile (fire-and-forget, non-blocking)
|
||||
void fetch(`/api/profile/${data.state.player.discordId}`)
|
||||
.then(async (res) => {
|
||||
@@ -353,6 +366,29 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
});
|
||||
}, []);
|
||||
|
||||
const buyPrestigeUpgrade = useCallback(async (upgradeId: string) => {
|
||||
try {
|
||||
const result = await buyPrestigeUpgradeApi({ upgradeId });
|
||||
setState((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
prestige: {
|
||||
...prev.prestige,
|
||||
runestones: result.runestonesRemaining,
|
||||
purchasedUpgradeIds: result.purchasedUpgradeIds,
|
||||
runestonesIncomeMultiplier: result.runestonesIncomeMultiplier,
|
||||
runestonesClickMultiplier: result.runestonesClickMultiplier,
|
||||
runestonesEssenceMultiplier: result.runestonesEssenceMultiplier,
|
||||
runestonesCrystalMultiplier: result.runestonesCrystalMultiplier,
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// Silently ignore — server errors shouldn't crash the UI
|
||||
}
|
||||
}, []);
|
||||
|
||||
const challengeBoss = useCallback(async (bossId: string) => {
|
||||
if (!stateRef.current) return;
|
||||
const boss = stateRef.current.bosses.find((b) => b.id === bossId);
|
||||
@@ -464,6 +500,7 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
|
||||
const dismissOfflineGold = useCallback(() => {
|
||||
setOfflineGold(0);
|
||||
setOfflineEssence(0);
|
||||
}, []);
|
||||
|
||||
const dismissBattle = useCallback(() => {
|
||||
@@ -498,6 +535,7 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
forceSync,
|
||||
syncError,
|
||||
offlineGold,
|
||||
offlineEssence,
|
||||
dismissOfflineGold,
|
||||
battleResult,
|
||||
dismissBattle,
|
||||
@@ -506,6 +544,7 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
numberFormat,
|
||||
setNumberFormat,
|
||||
formatNumber: boundFormatNumber,
|
||||
buyPrestigeUpgrade,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { PrestigeUpgrade } from "@elysium/types";
|
||||
|
||||
export const PRESTIGE_UPGRADES: PrestigeUpgrade[] = [
|
||||
// ── Global Income Tiers ───────────────────────────────────────────────────
|
||||
{
|
||||
id: "income_1",
|
||||
name: "Runestone Blessing I",
|
||||
description: "The first runestone awakens dormant power in your guild. All production ×1.25.",
|
||||
category: "income",
|
||||
runestonesCost: 10,
|
||||
multiplier: 1.25,
|
||||
},
|
||||
{
|
||||
id: "income_2",
|
||||
name: "Runestone Blessing II",
|
||||
description: "Deeper runestone resonance amplifies your workforce. All production ×1.5.",
|
||||
category: "income",
|
||||
runestonesCost: 25,
|
||||
multiplier: 1.5,
|
||||
},
|
||||
{
|
||||
id: "income_3",
|
||||
name: "Runestone Blessing III",
|
||||
description: "The runes sing with accumulated wisdom. All production ×2.",
|
||||
category: "income",
|
||||
runestonesCost: 60,
|
||||
multiplier: 2,
|
||||
},
|
||||
{
|
||||
id: "income_4",
|
||||
name: "Runic Surge I",
|
||||
description: "Runestone energy surges through your guild's operations. All production ×3.",
|
||||
category: "income",
|
||||
runestonesCost: 150,
|
||||
multiplier: 3,
|
||||
},
|
||||
{
|
||||
id: "income_5",
|
||||
name: "Runic Surge II",
|
||||
description: "The surge intensifies, pushing limits thought impossible. All production ×5.",
|
||||
category: "income",
|
||||
runestonesCost: 350,
|
||||
multiplier: 5,
|
||||
},
|
||||
{
|
||||
id: "income_6",
|
||||
name: "Runic Surge III",
|
||||
description: "An overwhelming tide of runic energy floods your operations. All production ×10.",
|
||||
category: "income",
|
||||
runestonesCost: 800,
|
||||
multiplier: 10,
|
||||
},
|
||||
{
|
||||
id: "income_7",
|
||||
name: "Ancient Inscription I",
|
||||
description: "You decipher ancient runic inscriptions that unlock vast potential. All production ×25.",
|
||||
category: "income",
|
||||
runestonesCost: 2_000,
|
||||
multiplier: 25,
|
||||
},
|
||||
{
|
||||
id: "income_8",
|
||||
name: "Ancient Inscription II",
|
||||
description: "Deeper inscriptions reveal secrets of primordial power. All production ×50.",
|
||||
category: "income",
|
||||
runestonesCost: 5_000,
|
||||
multiplier: 50,
|
||||
},
|
||||
{
|
||||
id: "income_9",
|
||||
name: "Ancient Inscription III",
|
||||
description: "The full inscription blazes with world-shaping power. All production ×100.",
|
||||
category: "income",
|
||||
runestonesCost: 12_000,
|
||||
multiplier: 100,
|
||||
},
|
||||
{
|
||||
id: "income_10",
|
||||
name: "Eternal Rune I",
|
||||
description: "The oldest runes, carved before memory began, yield their secrets at last. All production ×500.",
|
||||
category: "income",
|
||||
runestonesCost: 30_000,
|
||||
multiplier: 500,
|
||||
},
|
||||
{
|
||||
id: "income_11",
|
||||
name: "Eternal Rune II",
|
||||
description: "Eternal runes resonate with the heartbeat of creation itself. All production ×1,000.",
|
||||
category: "income",
|
||||
runestonesCost: 80_000,
|
||||
multiplier: 1_000,
|
||||
},
|
||||
// ── Click Power ───────────────────────────────────────────────────────────
|
||||
{
|
||||
id: "click_power_1",
|
||||
name: "Runic Strike I",
|
||||
description: "Infuse your personal strikes with runestone energy. Click power ×2.",
|
||||
category: "click",
|
||||
runestonesCost: 15,
|
||||
multiplier: 2,
|
||||
},
|
||||
{
|
||||
id: "click_power_2",
|
||||
name: "Runic Strike II",
|
||||
description: "Your strikes crackle with compounded runic force. Click power ×5.",
|
||||
category: "click",
|
||||
runestonesCost: 75,
|
||||
multiplier: 5,
|
||||
},
|
||||
{
|
||||
id: "click_power_3",
|
||||
name: "Runic Strike III",
|
||||
description: "Every click channels the weight of all your past lives. Click power ×20.",
|
||||
category: "click",
|
||||
runestonesCost: 400,
|
||||
multiplier: 20,
|
||||
},
|
||||
{
|
||||
id: "click_power_4",
|
||||
name: "World-Breaker Click",
|
||||
description: "A single click now carries the force of a falling empire. Click power ×100.",
|
||||
category: "click",
|
||||
runestonesCost: 2_500,
|
||||
multiplier: 100,
|
||||
},
|
||||
// ── Essence Production ────────────────────────────────────────────────────
|
||||
{
|
||||
id: "essence_1",
|
||||
name: "Essence Attunement I",
|
||||
description: "Runestone resonance amplifies your essence gathering. Essence production ×2.",
|
||||
category: "essence",
|
||||
runestonesCost: 20,
|
||||
multiplier: 2,
|
||||
},
|
||||
{
|
||||
id: "essence_2",
|
||||
name: "Essence Attunement II",
|
||||
description: "Deep attunement draws essence from previously invisible sources. Essence production ×5.",
|
||||
category: "essence",
|
||||
runestonesCost: 120,
|
||||
multiplier: 5,
|
||||
},
|
||||
{
|
||||
id: "essence_3",
|
||||
name: "Essence Attunement III",
|
||||
description: "Your guild breathes essence as naturally as air. Essence production ×20.",
|
||||
category: "essence",
|
||||
runestonesCost: 700,
|
||||
multiplier: 20,
|
||||
},
|
||||
{
|
||||
id: "essence_4",
|
||||
name: "Essence Attunement IV",
|
||||
description: "Essence flows in torrents from every corner of every world. Essence production ×100.",
|
||||
category: "essence",
|
||||
runestonesCost: 4_000,
|
||||
multiplier: 100,
|
||||
},
|
||||
// ── Crystal Production ────────────────────────────────────────────────────
|
||||
{
|
||||
id: "crystal_1",
|
||||
name: "Crystal Resonance I",
|
||||
description: "Runestones vibrate in harmony with crystal structures. Crystal rewards ×2.",
|
||||
category: "crystals",
|
||||
runestonesCost: 30,
|
||||
multiplier: 2,
|
||||
},
|
||||
{
|
||||
id: "crystal_2",
|
||||
name: "Crystal Resonance II",
|
||||
description: "The resonance deepens, shattering crystal barriers. Crystal rewards ×5.",
|
||||
category: "crystals",
|
||||
runestonesCost: 200,
|
||||
multiplier: 5,
|
||||
},
|
||||
{
|
||||
id: "crystal_3",
|
||||
name: "Crystal Resonance III",
|
||||
description: "Pure resonance crystallises reality into abundance. Crystal rewards ×25.",
|
||||
category: "crystals",
|
||||
runestonesCost: 1_200,
|
||||
multiplier: 25,
|
||||
},
|
||||
// ── Runestone Meta-Upgrades ───────────────────────────────────────────────
|
||||
{
|
||||
id: "runestone_gain_1",
|
||||
name: "Runic Legacy",
|
||||
description: "Your runestone attunement grows with each prestige. Earn 25% more runestones from future prestiges.",
|
||||
category: "runestones",
|
||||
runestonesCost: 50,
|
||||
multiplier: 1.25,
|
||||
},
|
||||
{
|
||||
id: "runestone_gain_2",
|
||||
name: "Eternal Legacy",
|
||||
description: "Your legend transcends individual lifetimes. Earn 50% more runestones from future prestiges.",
|
||||
category: "runestones",
|
||||
runestonesCost: 500,
|
||||
multiplier: 1.5,
|
||||
},
|
||||
];
|
||||
|
||||
export const PRESTIGE_UPGRADE_CATEGORY_LABELS: Record<string, string> = {
|
||||
income: "🪙 Global Income",
|
||||
click: "👆 Click Power",
|
||||
essence: "✨ Essence Production",
|
||||
crystals: "💎 Crystal Rewards",
|
||||
runestones: "🔮 Runestone Gain",
|
||||
};
|
||||
@@ -57,6 +57,10 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
|
||||
1,
|
||||
);
|
||||
|
||||
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
|
||||
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
|
||||
const runestonesCrystal = state.prestige.runestonesCrystalMultiplier ?? 1;
|
||||
|
||||
let goldGained = 0;
|
||||
let essenceGained = 0;
|
||||
|
||||
@@ -81,6 +85,7 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
|
||||
adventurer.count *
|
||||
upgradeMultiplier *
|
||||
prestige *
|
||||
runestonesIncome *
|
||||
equipmentGoldMultiplier *
|
||||
deltaSeconds;
|
||||
|
||||
@@ -89,6 +94,7 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
|
||||
adventurer.count *
|
||||
upgradeMultiplier *
|
||||
prestige *
|
||||
runestonesEssence *
|
||||
deltaSeconds;
|
||||
}
|
||||
|
||||
@@ -117,7 +123,7 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
|
||||
} else if (reward.type === "essence" && reward.amount != null) {
|
||||
questEssence += reward.amount;
|
||||
} else if (reward.type === "crystals" && reward.amount != null) {
|
||||
questCrystals += reward.amount;
|
||||
questCrystals += reward.amount * runestonesCrystal;
|
||||
} else if (reward.type === "upgrade" && reward.targetId != null) {
|
||||
updatedUpgrades = updatedUpgrades.map((u) =>
|
||||
u.id === reward.targetId ? { ...u, unlocked: true } : u,
|
||||
@@ -250,5 +256,13 @@ export const calculateClickPower = (state: GameState): number => {
|
||||
.filter((e) => e.equipped && e.bonus.clickMultiplier != null)
|
||||
.reduce((mult, e) => mult * (e.bonus.clickMultiplier ?? 1), 1);
|
||||
|
||||
return state.baseClickPower * clickMultiplier * state.prestige.productionMultiplier * equipmentClickMultiplier;
|
||||
const runestonesClick = state.prestige.runestonesClickMultiplier ?? 1;
|
||||
|
||||
return (
|
||||
state.baseClickPower *
|
||||
clickMultiplier *
|
||||
state.prestige.productionMultiplier *
|
||||
runestonesClick *
|
||||
equipmentClickMultiplier
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user