feat: another balance and bug fix pass (#238)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m10s
CI / Lint, Build & Test (push) Successful in 1m13s

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:
2026-04-06 18:17:00 -07:00
committed by Naomi Carrigan
parent b0227c1709
commit 1195b657a0
34 changed files with 980 additions and 203 deletions
+10 -1
View File
@@ -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>
+3 -2
View File
@@ -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>
}
+6 -1
View File
@@ -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}
+47 -41
View File
@@ -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}
+65 -24
View File
@@ -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