generated from nhcarrigan/template
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70e031eed7 | |||
| f09fcca0ef | |||
| 9bb1d01d2b |
@@ -681,6 +681,45 @@ const validateAndSanitize = (
|
|||||||
storySpread = { story: previous.story };
|
storySpread = { story: previous.story };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Merge daily challenge progress: take the maximum progress for each
|
||||||
|
* challenge so a stale auto-save arriving after a craft/boss/etc. update
|
||||||
|
* cannot silently roll back server-side challenge completions.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 35 -- @preserve */
|
||||||
|
let dailyChallengesSpread: object = {};
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Long condition; splitting would reduce readability
|
||||||
|
if (incoming.dailyChallenges !== undefined && previous.dailyChallenges !== undefined) {
|
||||||
|
const previousChallengeMap = new Map(
|
||||||
|
previous.dailyChallenges.challenges.map((challenge) => {
|
||||||
|
return [ challenge.id, challenge ];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Long chain; splitting would reduce readability
|
||||||
|
const mergedChallenges = incoming.dailyChallenges.challenges.map((challenge) => {
|
||||||
|
const serverChallenge = previousChallengeMap.get(challenge.id);
|
||||||
|
if (serverChallenge === undefined) {
|
||||||
|
return challenge;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Long expression; splitting would reduce readability
|
||||||
|
const bestProgress = Math.max(challenge.progress, serverChallenge.progress);
|
||||||
|
return {
|
||||||
|
...challenge,
|
||||||
|
completed: bestProgress >= challenge.target,
|
||||||
|
progress: bestProgress,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
dailyChallengesSpread = {
|
||||||
|
dailyChallenges: {
|
||||||
|
...incoming.dailyChallenges,
|
||||||
|
challenges: mergedChallenges,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else if (previous.dailyChallenges !== undefined) {
|
||||||
|
dailyChallengesSpread = { dailyChallenges: previous.dailyChallenges };
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...incoming,
|
...incoming,
|
||||||
achievements,
|
achievements,
|
||||||
@@ -693,6 +732,7 @@ const validateAndSanitize = (
|
|||||||
...apotheosisSpread,
|
...apotheosisSpread,
|
||||||
...explorationSpread,
|
...explorationSpread,
|
||||||
...storySpread,
|
...storySpread,
|
||||||
|
...dailyChallengesSpread,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1024,7 +1064,8 @@ gameRouter.post("/save", async(context) => {
|
|||||||
const companionUnlocks = computeUnlockedCompanionIds({
|
const companionUnlocks = computeUnlockedCompanionIds({
|
||||||
apotheosisCount: stateToSave.apotheosis?.count ?? 0,
|
apotheosisCount: stateToSave.apotheosis?.count ?? 0,
|
||||||
lifetimeBossesDefeated: playerRecord?.lifetimeBossesDefeated ?? 0,
|
lifetimeBossesDefeated: playerRecord?.lifetimeBossesDefeated ?? 0,
|
||||||
lifetimeGoldEarned: playerRecord?.lifetimeGoldEarned ?? 0,
|
// eslint-disable-next-line stylistic/max-len -- Long property; splitting would reduce readability
|
||||||
|
lifetimeGoldEarned: (playerRecord?.lifetimeGoldEarned ?? 0) + stateToSave.player.totalGoldEarned,
|
||||||
lifetimeQuestsCompleted: playerRecord?.lifetimeQuestsCompleted ?? 0,
|
lifetimeQuestsCompleted: playerRecord?.lifetimeQuestsCompleted ?? 0,
|
||||||
prestigeCount: stateToSave.prestige.count,
|
prestigeCount: stateToSave.prestige.count,
|
||||||
transcendenceCount: stateToSave.transcendence?.count ?? 0,
|
transcendenceCount: stateToSave.transcendence?.count ?? 0,
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ const BattleModal = ({
|
|||||||
flushBossLoreToasts,
|
flushBossLoreToasts,
|
||||||
formatInteger,
|
formatInteger,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
|
numberFormat,
|
||||||
} = useGame();
|
} = useGame();
|
||||||
|
|
||||||
const [ phase, setPhase ] = useState<"animating" | "result">("animating");
|
const [ phase, setPhase ] = useState<"animating" | "result">("animating");
|
||||||
@@ -242,14 +243,14 @@ const BattleModal = ({
|
|||||||
{result.rewards.crystals > 0
|
{result.rewards.crystals > 0
|
||||||
&& <span>
|
&& <span>
|
||||||
{"💎 "}
|
{"💎 "}
|
||||||
{formatInteger(result.rewards.crystals)}
|
{formatInteger(result.rewards.crystals, numberFormat)}
|
||||||
{" crystals"}
|
{" crystals"}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
{result.rewards.bountyRunestones > 0
|
{result.rewards.bountyRunestones > 0
|
||||||
&& <span className="battle-bounty">
|
&& <span className="battle-bounty">
|
||||||
{"🔮 "}
|
{"🔮 "}
|
||||||
{formatInteger(result.rewards.bountyRunestones)}
|
{formatInteger(result.rewards.bountyRunestones, numberFormat)}
|
||||||
{" runestones (first kill!)"}
|
{" runestones (first kill!)"}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { computePartyCombatPower } from "../../engine/tick.js";
|
|||||||
import { cdnImage } from "../../utils/cdn.js";
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import { ZoneSelector } from "./zoneSelector.js";
|
import { ZoneSelector } from "./zoneSelector.js";
|
||||||
import type { Boss } from "@elysium/types";
|
import type { Boss, NumberFormat } from "@elysium/types";
|
||||||
|
|
||||||
interface BossCardProperties {
|
interface BossCardProperties {
|
||||||
readonly boss: Boss;
|
readonly boss: Boss;
|
||||||
@@ -23,7 +23,7 @@ interface BossCardProperties {
|
|||||||
readonly onChallenge: (bossId: string)=> void;
|
readonly onChallenge: (bossId: string)=> void;
|
||||||
readonly isChallenging: boolean;
|
readonly isChallenging: boolean;
|
||||||
readonly unlockHint: string | undefined;
|
readonly unlockHint: string | undefined;
|
||||||
readonly formatInteger: (n: number)=> string;
|
readonly formatInteger: (n: number, format: NumberFormat)=> string;
|
||||||
readonly formatNumber: (n: number)=> string;
|
readonly formatNumber: (n: number)=> string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ const BossCard = ({
|
|||||||
{boss.crystalReward > 0
|
{boss.crystalReward > 0
|
||||||
&& <span>
|
&& <span>
|
||||||
{"💎 "}
|
{"💎 "}
|
||||||
{formatInteger(boss.crystalReward)}
|
{formatInteger(boss.crystalReward, numberFormat)}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
{boss.equipmentRewards.length > 0
|
{boss.equipmentRewards.length > 0
|
||||||
@@ -175,6 +175,7 @@ const BossPanel = (): JSX.Element => {
|
|||||||
autoBossLastResult,
|
autoBossLastResult,
|
||||||
autoBossError,
|
autoBossError,
|
||||||
bossError,
|
bossError,
|
||||||
|
numberFormat,
|
||||||
} = useGame();
|
} = useGame();
|
||||||
const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
|
const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
@@ -403,6 +404,7 @@ const BossPanel = (): JSX.Element => {
|
|||||||
formatNumber={formatNumber}
|
formatNumber={formatNumber}
|
||||||
isChallenging={challengingBossId === bossId}
|
isChallenging={challengingBossId === bossId}
|
||||||
key={bossId}
|
key={bossId}
|
||||||
|
numberFormat={numberFormat}
|
||||||
onChallenge={handleChallengeClick}
|
onChallenge={handleChallengeClick}
|
||||||
prestigeCount={prestigeCount}
|
prestigeCount={prestigeCount}
|
||||||
unlockHint={bossUnlockHints.get(bossId)}
|
unlockHint={bossUnlockHints.get(bossId)}
|
||||||
|
|||||||
@@ -163,7 +163,8 @@ const CompanionPanel = (): JSX.Element => {
|
|||||||
const progressByUnlockType: Record<string, number> = {
|
const progressByUnlockType: Record<string, number> = {
|
||||||
apotheosis: state.apotheosis?.count ?? 0,
|
apotheosis: state.apotheosis?.count ?? 0,
|
||||||
lifetimeBosses: state.player.lifetimeBossesDefeated,
|
lifetimeBosses: state.player.lifetimeBossesDefeated,
|
||||||
lifetimeGold: state.player.lifetimeGoldEarned,
|
// eslint-disable-next-line stylistic/max-len -- Long expression; splitting would reduce readability
|
||||||
|
lifetimeGold: state.player.lifetimeGoldEarned + state.player.totalGoldEarned,
|
||||||
lifetimeQuests: state.player.lifetimeQuestsCompleted,
|
lifetimeQuests: state.player.lifetimeQuestsCompleted,
|
||||||
prestige: state.prestige.count,
|
prestige: state.prestige.count,
|
||||||
transcendence: state.transcendence?.count ?? 0,
|
transcendence: state.transcendence?.count ?? 0,
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
toggleAutoPrestige,
|
toggleAutoPrestige,
|
||||||
toggleAutoPrestigeMaxRunestones,
|
toggleAutoPrestigeMaxRunestones,
|
||||||
triggerPrestigeToast,
|
triggerPrestigeToast,
|
||||||
|
numberFormat,
|
||||||
} = useGame();
|
} = useGame();
|
||||||
const [ isPending, setIsPending ] = useState(false);
|
const [ isPending, setIsPending ] = useState(false);
|
||||||
const [ result, setResult ] = useState<{
|
const [ result, setResult ] = useState<{
|
||||||
@@ -198,7 +199,7 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{"🔮 Runestone Shop ("}
|
{"🔮 Runestone Shop ("}
|
||||||
{formatInteger(prestigeData.runestones)}
|
{formatInteger(prestigeData.runestones, numberFormat)}
|
||||||
{" stones)"}
|
{" stones)"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,14 +243,14 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{"Runestones: "}
|
{"Runestones: "}
|
||||||
<strong>{formatInteger(prestigeData.runestones)}</strong>
|
<strong>{formatInteger(prestigeData.runestones, numberFormat)}</strong>
|
||||||
</p>
|
</p>
|
||||||
{isEligible
|
{isEligible
|
||||||
? <p className="runestone-preview">
|
? <p className="runestone-preview">
|
||||||
{"Runestones on prestige: "}
|
{"Runestones on prestige: "}
|
||||||
<strong>
|
<strong>
|
||||||
{"+"}
|
{"+"}
|
||||||
{formatInteger(runestonePreview)}
|
{formatInteger(runestonePreview, numberFormat)}
|
||||||
</strong>
|
</strong>
|
||||||
{isAtMaxRunestones
|
{isAtMaxRunestones
|
||||||
? <span className="runestone-max-badge">{" ⚡ MAX"}</span>
|
? <span className="runestone-max-badge">{" ⚡ MAX"}</span>
|
||||||
@@ -289,7 +290,7 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
>
|
>
|
||||||
{isPending
|
{isPending
|
||||||
? "Ascending..."
|
? "Ascending..."
|
||||||
: `✨ Ascend (+${formatInteger(runestonePreview)} Runestones)`}
|
: `✨ Ascend (+${formatInteger(runestonePreview, numberFormat)} Runestones)`}
|
||||||
</button>
|
</button>
|
||||||
{prestigeError === null
|
{prestigeError === null
|
||||||
? null
|
? null
|
||||||
@@ -301,12 +302,12 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
{"Ascended to Prestige "}
|
{"Ascended to Prestige "}
|
||||||
{result.count}
|
{result.count}
|
||||||
{"! Earned "}
|
{"! Earned "}
|
||||||
{formatInteger(result.runestones)}
|
{formatInteger(result.runestones, numberFormat)}
|
||||||
{" Runestones."}
|
{" Runestones."}
|
||||||
{result.milestoneRunestones > 0
|
{result.milestoneRunestones > 0
|
||||||
&& <>
|
&& <>
|
||||||
{" 🎉 Milestone bonus: +"}
|
{" 🎉 Milestone bonus: +"}
|
||||||
{formatInteger(result.milestoneRunestones)}
|
{formatInteger(result.milestoneRunestones, numberFormat)}
|
||||||
{" Runestones!"}
|
{" Runestones!"}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -327,7 +328,7 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
<p className="shop-balance">
|
<p className="shop-balance">
|
||||||
{"Balance: "}
|
{"Balance: "}
|
||||||
<strong>
|
<strong>
|
||||||
{formatInteger(prestigeData.runestones)}
|
{formatInteger(prestigeData.runestones, numberFormat)}
|
||||||
{" Runestones"}
|
{" Runestones"}
|
||||||
</strong>
|
</strong>
|
||||||
</p>
|
</p>
|
||||||
@@ -381,7 +382,7 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
<p className="upgrade-cost">
|
<p className="upgrade-cost">
|
||||||
{purchased
|
{purchased
|
||||||
? "✅ Purchased"
|
? "✅ Purchased"
|
||||||
: `🔮 ${formatInteger(upgrade.runestonesCost)} Runestones`}
|
: `🔮 ${formatInteger(upgrade.runestonesCost, numberFormat)} Runestones`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{isAutoAdventurerToggle
|
{isAutoAdventurerToggle
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ const QuestCard = ({
|
|||||||
}
|
}
|
||||||
<div className="quest-rewards">
|
<div className="quest-rewards">
|
||||||
{quest.rewards.map((reward, rewardIndex) => {
|
{quest.rewards.map((reward, rewardIndex) => {
|
||||||
|
if (reward.type === "crystals" && (reward.amount ?? 0) === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<span className="reward-tag" key={`${reward.type}-${reward.targetId ?? String(reward.amount ?? rewardIndex)}`}>
|
<span className="reward-tag" key={`${reward.type}-${reward.targetId ?? String(reward.amount ?? rewardIndex)}`}>
|
||||||
{reward.type === "gold"
|
{reward.type === "gold"
|
||||||
@@ -121,7 +124,6 @@ const QuestCard = ({
|
|||||||
{reward.type === "essence"
|
{reward.type === "essence"
|
||||||
&& `✨ ${formatNumber(reward.amount ?? 0)}`}
|
&& `✨ ${formatNumber(reward.amount ?? 0)}`}
|
||||||
{reward.type === "crystals"
|
{reward.type === "crystals"
|
||||||
&& (reward.amount ?? 0) > 0
|
|
||||||
&& `💎 ${formatNumber(reward.amount ?? 0)}`}
|
&& `💎 ${formatNumber(reward.amount ?? 0)}`}
|
||||||
{reward.type === "upgrade" && "🔓 Upgrade"}
|
{reward.type === "upgrade" && "🔓 Upgrade"}
|
||||||
{reward.type === "adventurer" && "👥 New Adventurer"}
|
{reward.type === "adventurer" && "👥 New Adventurer"}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ const StatCard = ({
|
|||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
*/
|
*/
|
||||||
const StatisticsPanel = (): JSX.Element => {
|
const StatisticsPanel = (): JSX.Element => {
|
||||||
const { state, formatInteger, formatNumber } = useGame();
|
const { state, formatInteger, formatNumber, numberFormat } = useGame();
|
||||||
|
|
||||||
if (state === null) {
|
if (state === null) {
|
||||||
return (
|
return (
|
||||||
@@ -152,13 +152,13 @@ const StatisticsPanel = (): JSX.Element => {
|
|||||||
<StatCard
|
<StatCard
|
||||||
icon="💎"
|
icon="💎"
|
||||||
label="Crystals"
|
label="Crystals"
|
||||||
value={formatInteger(resources.crystals)}
|
value={formatInteger(resources.crystals, numberFormat)}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon="🔮"
|
icon="🔮"
|
||||||
label="Runestones"
|
label="Runestones"
|
||||||
sub="permanent currency"
|
sub="permanent currency"
|
||||||
value={formatInteger(prestige.runestones)}
|
value={formatInteger(prestige.runestones, numberFormat)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const categoryOrder: Array<TranscendenceUpgradeCategory> = [
|
|||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
*/
|
*/
|
||||||
const TranscendencePanel = (): JSX.Element => {
|
const TranscendencePanel = (): JSX.Element => {
|
||||||
const { state, formatInteger, transcend, buyEchoUpgrade } = useGame();
|
const { state, formatInteger, transcend, buyEchoUpgrade, numberFormat } = useGame();
|
||||||
const [ isPending, setIsPending ] = useState(false);
|
const [ isPending, setIsPending ] = useState(false);
|
||||||
const [ result, setResult ] = useState<{
|
const [ result, setResult ] = useState<{
|
||||||
echoes: number;
|
echoes: number;
|
||||||
@@ -152,7 +152,7 @@ const TranscendencePanel = (): JSX.Element => {
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{"✨ Echo Shop ("}
|
{"✨ Echo Shop ("}
|
||||||
{formatInteger(currentEchoes)}
|
{formatInteger(currentEchoes, numberFormat)}
|
||||||
{" echoes)"}
|
{" echoes)"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,7 +184,7 @@ const TranscendencePanel = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
<p>
|
<p>
|
||||||
{"Current Echoes: "}
|
{"Current Echoes: "}
|
||||||
<strong>{formatInteger(currentEchoes)}</strong>
|
<strong>{formatInteger(currentEchoes, numberFormat)}</strong>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{"Current prestige count: "}
|
{"Current prestige count: "}
|
||||||
@@ -195,7 +195,7 @@ const TranscendencePanel = (): JSX.Element => {
|
|||||||
{"Echoes on transcendence: "}
|
{"Echoes on transcendence: "}
|
||||||
<strong>
|
<strong>
|
||||||
{"+"}
|
{"+"}
|
||||||
{formatInteger(echoPreview)}
|
{formatInteger(echoPreview, numberFormat)}
|
||||||
</strong>
|
</strong>
|
||||||
{echoMetaMultiplier > 1
|
{echoMetaMultiplier > 1
|
||||||
&& <span className="echo-meta-bonus">
|
&& <span className="echo-meta-bonus">
|
||||||
@@ -238,7 +238,7 @@ const TranscendencePanel = (): JSX.Element => {
|
|||||||
>
|
>
|
||||||
{isPending
|
{isPending
|
||||||
? "Transcending..."
|
? "Transcending..."
|
||||||
: `🌌 Transcend (+${formatInteger(echoPreview)} Echoes)`}
|
: `🌌 Transcend (+${formatInteger(echoPreview, numberFormat)} Echoes)`}
|
||||||
</button>
|
</button>
|
||||||
{error === null
|
{error === null
|
||||||
? null
|
? null
|
||||||
@@ -248,7 +248,7 @@ const TranscendencePanel = (): JSX.Element => {
|
|||||||
: <p className="success">
|
: <p className="success">
|
||||||
{"Transcended! Earned "}
|
{"Transcended! Earned "}
|
||||||
<strong>
|
<strong>
|
||||||
{formatInteger(result.echoes)}
|
{formatInteger(result.echoes, numberFormat)}
|
||||||
{" Echoes"}
|
{" Echoes"}
|
||||||
</strong>
|
</strong>
|
||||||
{". This is Transcendence "}
|
{". This is Transcendence "}
|
||||||
@@ -266,7 +266,7 @@ const TranscendencePanel = (): JSX.Element => {
|
|||||||
<p className="shop-balance">
|
<p className="shop-balance">
|
||||||
{"Balance: "}
|
{"Balance: "}
|
||||||
<strong>
|
<strong>
|
||||||
{formatInteger(currentEchoes)}
|
{formatInteger(currentEchoes, numberFormat)}
|
||||||
{" Echoes"}
|
{" Echoes"}
|
||||||
</strong>
|
</strong>
|
||||||
</p>
|
</p>
|
||||||
@@ -314,7 +314,7 @@ const TranscendencePanel = (): JSX.Element => {
|
|||||||
<p className="upgrade-cost">
|
<p className="upgrade-cost">
|
||||||
{purchased
|
{purchased
|
||||||
? "✅ Purchased"
|
? "✅ Purchased"
|
||||||
: `✨ ${formatInteger(upgrade.cost)} Echoes`}
|
: `✨ ${formatInteger(upgrade.cost, numberFormat)} Echoes`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{purchased
|
{purchased
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ const ResourceBar = ({
|
|||||||
isSyncing,
|
isSyncing,
|
||||||
onForceSync,
|
onForceSync,
|
||||||
}: ResourceBarProperties): JSX.Element => {
|
}: ResourceBarProperties): JSX.Element => {
|
||||||
const { formatInteger, formatNumber, syncError, state } = useGame();
|
const { formatInteger, formatNumber, syncError, state, numberFormat } = useGame();
|
||||||
const [ isProfileOpen, setIsProfileOpen ] = useState(false);
|
const [ isProfileOpen, setIsProfileOpen ] = useState(false);
|
||||||
const [ isResourcesOpen, setIsResourcesOpen ] = useState(false);
|
const [ isResourcesOpen, setIsResourcesOpen ] = useState(false);
|
||||||
|
|
||||||
@@ -218,7 +218,7 @@ const ResourceBar = ({
|
|||||||
: ""}`}>
|
: ""}`}>
|
||||||
<span className="resource-icon">{"💎"}</span>
|
<span className="resource-icon">{"💎"}</span>
|
||||||
<span className="resource-value">
|
<span className="resource-value">
|
||||||
{formatInteger(crystals)}
|
{formatNumber(crystals)}
|
||||||
</span>
|
</span>
|
||||||
<span className="resource-label">{"Crystals"}</span>
|
<span className="resource-label">{"Crystals"}</span>
|
||||||
{crystalsFull
|
{crystalsFull
|
||||||
@@ -233,14 +233,14 @@ const ResourceBar = ({
|
|||||||
<div className="resource">
|
<div className="resource">
|
||||||
<span className="resource-icon">{"🔮"}</span>
|
<span className="resource-icon">{"🔮"}</span>
|
||||||
<span className="resource-value">
|
<span className="resource-value">
|
||||||
{formatInteger(runestones)}
|
{formatInteger(runestones, numberFormat)}
|
||||||
</span>
|
</span>
|
||||||
<span className="resource-label">{"Runestones"}</span>
|
<span className="resource-label">{"Runestones"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="resource">
|
<div className="resource">
|
||||||
<span className="resource-icon">{"⭐"}</span>
|
<span className="resource-icon">{"⭐"}</span>
|
||||||
<span className="resource-value">
|
<span className="resource-value">
|
||||||
{`+${formatInteger(projectedRunestones)}`}
|
{`+${formatInteger(projectedRunestones, numberFormat)}`}
|
||||||
</span>
|
</span>
|
||||||
<span className="resource-label">{"On Prestige"}</span>
|
<span className="resource-label">{"On Prestige"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -462,12 +462,12 @@ interface GameContextValue {
|
|||||||
/**
|
/**
|
||||||
* Format a number using the player's chosen notation style.
|
* Format a number using the player's chosen notation style.
|
||||||
*/
|
*/
|
||||||
formatNumber: (value: number)=> string;
|
formatNumber: (value: number, format?: NumberFormat)=> string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a whole-number value without decimal places.
|
* Format a whole-number value without decimal places.
|
||||||
*/
|
*/
|
||||||
formatInteger: (value: number)=> string;
|
formatInteger: (value: number, format?: NumberFormat)=> string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Buy a prestige upgrade from the runestone shop.
|
* Buy a prestige upgrade from the runestone shop.
|
||||||
@@ -1356,10 +1356,11 @@ export const GameProvider = ({
|
|||||||
newlyFailedQuestsReference.current = [];
|
newlyFailedQuestsReference.current = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-save every 30 seconds (skip if a force sync is in-flight to avoid signature collisions)
|
// 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) {
|
if (Date.now() - lastSaveReference.current >= autoSaveIntervalMs) {
|
||||||
lastSaveReference.current = Date.now();
|
lastSaveReference.current = Date.now();
|
||||||
if (stateReference.current !== null && !isSyncingReference.current) {
|
// eslint-disable-next-line stylistic/max-len -- Long condition; splitting would reduce readability
|
||||||
|
if (stateReference.current !== null && !isSyncingReference.current && !isAutoPrestigingReference.current) {
|
||||||
void saveGame({
|
void saveGame({
|
||||||
state: stateReference.current,
|
state: stateReference.current,
|
||||||
...signatureReference.current === null
|
...signatureReference.current === null
|
||||||
@@ -1856,6 +1857,13 @@ export const GameProvider = ({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Buying a prestige upgrade mutates DB state; clear the cached signature
|
||||||
|
* so the next auto-save doesn't collide with a stale one.
|
||||||
|
*/
|
||||||
|
signatureReference.current = null;
|
||||||
|
localStorage.removeItem("elysium_save_signature");
|
||||||
} catch (error_: unknown) {
|
} catch (error_: unknown) {
|
||||||
logError("buy_prestige_upgrade", error_);
|
logError("buy_prestige_upgrade", error_);
|
||||||
// Silently ignore — server errors shouldn't crash the UI
|
// Silently ignore — server errors shouldn't crash the UI
|
||||||
|
|||||||
@@ -57,22 +57,32 @@ const getLetterSuffix = (index: number): string => {
|
|||||||
/**
|
/**
|
||||||
* Formats a number with a named or letter-based suffix.
|
* Formats a number with a named or letter-based suffix.
|
||||||
* @param value - The number to format.
|
* @param value - The number to format.
|
||||||
|
* @param round - Display zero numbers after a decimal point.
|
||||||
* @returns The formatted string with suffix.
|
* @returns The formatted string with suffix.
|
||||||
*/
|
*/
|
||||||
const formatSuffix = (value: number): string => {
|
const formatSuffix = (value: number, round = false): string => {
|
||||||
if (value >= Math.pow(10, letterBaseExp)) {
|
if (value >= Math.pow(10, letterBaseExp)) {
|
||||||
const exp = Math.floor(Math.log10(value));
|
const exp = Math.floor(Math.log10(value));
|
||||||
const stepsAboveBase = Math.floor((exp - letterBaseExp) / 3);
|
const stepsAboveBase = Math.floor((exp - letterBaseExp) / 3);
|
||||||
const steps = stepsAboveBase * 3;
|
const steps = stepsAboveBase * 3;
|
||||||
const divisorExp = letterBaseExp + steps;
|
const divisorExp = letterBaseExp + steps;
|
||||||
const divisor = Math.pow(10, divisorExp);
|
const divisor = Math.pow(10, divisorExp);
|
||||||
return `${(value / divisor).toFixed(2)}${getLetterSuffix(stepsAboveBase)}`;
|
return `${round
|
||||||
|
? String(Math.round(value / divisor))
|
||||||
|
: (value / divisor).toFixed(2)}${getLetterSuffix(stepsAboveBase)}`;
|
||||||
}
|
}
|
||||||
for (const { threshold, suffix } of namedSuffixes) {
|
for (const { threshold, suffix } of namedSuffixes) {
|
||||||
if (value >= threshold) {
|
if (value >= threshold) {
|
||||||
return `${(value / threshold).toFixed(2)}${suffix}`;
|
return `${round
|
||||||
|
? String(Math.floor(value / threshold))
|
||||||
|
: (value / threshold).toFixed(2)}${suffix}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (round) {
|
||||||
|
return String(Math.floor(value));
|
||||||
|
}
|
||||||
|
|
||||||
return value < 1
|
return value < 1
|
||||||
? value.toFixed(2)
|
? value.toFixed(2)
|
||||||
: value.toFixed(1);
|
: value.toFixed(1);
|
||||||
@@ -82,12 +92,18 @@ const formatSuffix = (value: number): string => {
|
|||||||
* Formats a number in scientific notation: e.g. 1.23e15.
|
* Formats a number in scientific notation: e.g. 1.23e15.
|
||||||
* Falls back to K/M/B/T style below 1 million.
|
* Falls back to K/M/B/T style below 1 million.
|
||||||
* @param value - The number to format.
|
* @param value - The number to format.
|
||||||
|
* @param round - Display zero numbers after a decimal point.
|
||||||
* @returns The formatted string in scientific notation.
|
* @returns The formatted string in scientific notation.
|
||||||
*/
|
*/
|
||||||
const formatScientific = (value: number): string => {
|
const formatScientific = (value: number, round = false): string => {
|
||||||
if (value < 1e6) {
|
if (value < 1e6) {
|
||||||
return formatSuffix(value);
|
return formatSuffix(value, round);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (round) {
|
||||||
|
return value.toExponential(0).replace("e+", "e");
|
||||||
|
}
|
||||||
|
|
||||||
// ToExponential handles all magnitudes JS can represent (up to ~1.8e308)
|
// ToExponential handles all magnitudes JS can represent (up to ~1.8e308)
|
||||||
return value.toExponential(2).replace("e+", "e");
|
return value.toExponential(2).replace("e+", "e");
|
||||||
};
|
};
|
||||||
@@ -96,45 +112,50 @@ const formatScientific = (value: number): string => {
|
|||||||
* Formats a number in engineering notation (exponent always a multiple of 3):
|
* Formats a number in engineering notation (exponent always a multiple of 3):
|
||||||
* e.g. 12.35E12, 1.23E300. Falls back to K/M/B/T style below 1 million.
|
* e.g. 12.35E12, 1.23E300. Falls back to K/M/B/T style below 1 million.
|
||||||
* @param value - The number to format.
|
* @param value - The number to format.
|
||||||
|
* @param round - Display zero numbers after a decimal point.
|
||||||
* @returns The formatted string in engineering notation.
|
* @returns The formatted string in engineering notation.
|
||||||
*/
|
*/
|
||||||
const formatEngineering = (value: number): string => {
|
const formatEngineering = (value: number, round = false): string => {
|
||||||
if (value < 1e6) {
|
if (value < 1e6) {
|
||||||
return formatSuffix(value);
|
return formatSuffix(value, round);
|
||||||
}
|
}
|
||||||
const exp = Math.floor(Math.log10(value));
|
const exp = Math.floor(Math.log10(value));
|
||||||
const engExp = Math.floor(exp / 3) * 3;
|
const engExp = Math.floor(exp / 3) * 3;
|
||||||
const mantissa = value / Math.pow(10, engExp);
|
const mantissa = value / Math.pow(10, engExp);
|
||||||
return `${mantissa.toFixed(2)}E${String(engExp)}`;
|
return `${round
|
||||||
|
? String(Math.round(mantissa))
|
||||||
|
: mantissa.toFixed(2)}E${String(engExp)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a whole-number value for display without decimal places.
|
* Formats a whole-number value for display without decimal places.
|
||||||
* Uses the same suffix/letter logic as formatNumber but rounds to integers.
|
* Uses the same suffix/letter logic as formatNumber but rounds to integers.
|
||||||
* @param value - The integer value to format.
|
* @param value - The integer value to format.
|
||||||
|
* @param format
|
||||||
* @returns The formatted string with no decimal places.
|
* @returns The formatted string with no decimal places.
|
||||||
*/
|
*/
|
||||||
const formatInteger = (value: number): string => {
|
const formatInteger = (value: number,
|
||||||
|
format: NumberFormat = "suffix"): string => {
|
||||||
if (!Number.isFinite(value) || Number.isNaN(value)) {
|
if (!Number.isFinite(value) || Number.isNaN(value)) {
|
||||||
return "0";
|
return "0";
|
||||||
}
|
}
|
||||||
if (value < 0) {
|
if (value < 0) {
|
||||||
return `-${formatInteger(-value)}`;
|
return `-${formatInteger(-value)}`;
|
||||||
}
|
}
|
||||||
if (value >= Math.pow(10, letterBaseExp)) {
|
|
||||||
const exp = Math.floor(Math.log10(value));
|
const roundedValue = Math.floor(value);
|
||||||
const stepsAboveBase = Math.floor((exp - letterBaseExp) / 3);
|
switch (format) {
|
||||||
const steps = stepsAboveBase * 3;
|
case "scientific":
|
||||||
const divisorExp = letterBaseExp + steps;
|
return formatScientific(roundedValue, true);
|
||||||
const divisor = Math.pow(10, divisorExp);
|
case "engineering":
|
||||||
return `${String(Math.round(value / divisor))}${getLetterSuffix(stepsAboveBase)}`;
|
return formatEngineering(value, true);
|
||||||
}
|
case "suffix":
|
||||||
for (const { threshold, suffix } of namedSuffixes) {
|
return formatSuffix(value, true);
|
||||||
if (value >= threshold) {
|
default: {
|
||||||
return `${String(Math.floor(value / threshold))}${suffix}`;
|
/* V8 ignore next -- @preserve */
|
||||||
|
return formatSuffix(value, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return String(Math.floor(value));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
|
||||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||||
/* eslint-disable max-nested-callbacks -- Vitest structure requires nesting */
|
/* eslint-disable max-nested-callbacks -- Vitest structure requires nesting */
|
||||||
/**
|
/**
|
||||||
@@ -197,4 +196,43 @@ describe("formatInteger", () => {
|
|||||||
expect(formatInteger(1e39)).toBe("1b");
|
expect(formatInteger(1e39)).toBe("1b");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("scientific format", () => {
|
||||||
|
it("should fall back to suffix format below 1e6", () => {
|
||||||
|
expect(formatInteger(500, "scientific")).toBe("500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format values >= 1e6 in scientific notation", () => {
|
||||||
|
expect(formatInteger(1_230_000, "scientific")).toBe("1e6");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format large values in scientific notation", () => {
|
||||||
|
expect(formatInteger(1e18, "scientific")).toBe("1e18");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("engineering format", () => {
|
||||||
|
it("should fall back to suffix format below 1e6", () => {
|
||||||
|
expect(formatInteger(500, "engineering")).toBe("500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format values >= 1e6 with exponent multiple of 3", () => {
|
||||||
|
expect(formatInteger(1_230_000, "engineering")).toBe("1E6");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format 1e9 correctly in engineering notation", () => {
|
||||||
|
expect(formatInteger(1e9, "engineering")).toBe("1E9");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format 12350000 correctly in engineering notation", () => {
|
||||||
|
expect(formatInteger(12_350_000, "engineering")).toBe("12E6");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unknown format (default branch)", () => {
|
||||||
|
it("should fall back to suffix format for an unrecognised format string", () => {
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Testing unreachable default branch */
|
||||||
|
expect(formatInteger(1000, "unknown" as never)).toBe("1K");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user