feat: add number format config, resource cap, and modal scroll fix

- Add user-configurable number format (suffix/scientific/engineering)
  - Suffix: K/M/B/T through Dc (1e33), then letter-based a/b/c... indefinitely
  - Scientific: 1.23e15 style via toExponential
  - Engineering: exponent always a multiple of 3 (1.23E15)
  - Stored in ProfileSettings, fetched from profile API on load
  - Picker UI in EditProfileModal with live examples
- Cap all resource accumulation at 1e300 (RESOURCE_CAP constant)
  - Per-resource FULL badge with tooltip in ResourceBar
  - Amber notice strip when any resource is at cap
  - handleClick also respects the cap
- Make EditProfileModal scrollable with viewport margin
  - Flex column layout with sticky header, scrollable form body
  - Bio textarea preserved as resizable with min-height
- Fix ReferenceError: formatNumber not defined in BossPanel/AchievementPanel
  - Pass formatNumber as prop to BossCard and AchievementCard
  - Pass formatNumber as parameter to conditionDescription
This commit is contained in:
2026-03-06 18:59:43 -08:00
committed by Naomi Carrigan
parent 24beaf3131
commit 5ad2c44399
15 changed files with 290 additions and 65 deletions
+10
View File
@@ -10,9 +10,14 @@ import { authMiddleware } from "../middleware/auth.js";
export const profileRouter = new Hono(); export const profileRouter = new Hono();
const VALID_NUMBER_FORMATS = new Set(["suffix", "scientific", "engineering"]);
const parseProfileSettings = (raw: unknown): ProfileSettings => { const parseProfileSettings = (raw: unknown): ProfileSettings => {
if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) { if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
const obj = raw as Record<string, unknown>; const obj = raw as Record<string, unknown>;
const numberFormat = VALID_NUMBER_FORMATS.has(obj.numberFormat as string)
? (obj.numberFormat as ProfileSettings["numberFormat"])
: "suffix";
return { return {
showTotalGold: obj.showTotalGold !== false, showTotalGold: obj.showTotalGold !== false,
showTotalClicks: obj.showTotalClicks !== false, showTotalClicks: obj.showTotalClicks !== false,
@@ -22,6 +27,7 @@ const parseProfileSettings = (raw: unknown): ProfileSettings => {
showQuestsCompleted: obj.showQuestsCompleted !== false, showQuestsCompleted: obj.showQuestsCompleted !== false,
showAdventurersRecruited: obj.showAdventurersRecruited !== false, showAdventurersRecruited: obj.showAdventurersRecruited !== false,
showAchievementsUnlocked: obj.showAchievementsUnlocked !== false, showAchievementsUnlocked: obj.showAchievementsUnlocked !== false,
numberFormat,
}; };
} }
return { ...DEFAULT_PROFILE_SETTINGS }; return { ...DEFAULT_PROFILE_SETTINGS };
@@ -73,6 +79,9 @@ profileRouter.put("/", authMiddleware, async (context) => {
const characterName = (body.characterName ?? "").trim().slice(0, 32); const characterName = (body.characterName ?? "").trim().slice(0, 32);
const bio = (body.bio ?? "").trim().slice(0, 200); const bio = (body.bio ?? "").trim().slice(0, 200);
const numberFormat = VALID_NUMBER_FORMATS.has(body.profileSettings?.numberFormat as string)
? (body.profileSettings?.numberFormat as ProfileSettings["numberFormat"])
: "suffix";
const profileSettings: ProfileSettings = { const profileSettings: ProfileSettings = {
showTotalGold: body.profileSettings?.showTotalGold !== false, showTotalGold: body.profileSettings?.showTotalGold !== false,
showTotalClicks: body.profileSettings?.showTotalClicks !== false, showTotalClicks: body.profileSettings?.showTotalClicks !== false,
@@ -82,6 +91,7 @@ profileRouter.put("/", authMiddleware, async (context) => {
showQuestsCompleted: body.profileSettings?.showQuestsCompleted !== false, showQuestsCompleted: body.profileSettings?.showQuestsCompleted !== false,
showAdventurersRecruited: body.profileSettings?.showAdventurersRecruited !== false, showAdventurersRecruited: body.profileSettings?.showAdventurersRecruited !== false,
showAchievementsUnlocked: body.profileSettings?.showAchievementsUnlocked !== false, showAchievementsUnlocked: body.profileSettings?.showAchievementsUnlocked !== false,
numberFormat,
}; };
if (!characterName) { if (!characterName) {
@@ -1,10 +1,9 @@
import type { Achievement } from "@elysium/types"; import type { Achievement } from "@elysium/types";
import { useState } from "react"; import { useState } from "react";
import { useGame } from "../../context/GameContext.js"; import { useGame } from "../../context/GameContext.js";
import { formatNumber } from "../../utils/format.js";
import { LockToggle } from "../ui/LockToggle.js"; import { LockToggle } from "../ui/LockToggle.js";
const conditionDescription = (achievement: Achievement): string => { const conditionDescription = (achievement: Achievement, formatNumber: (n: number) => string): string => {
const { condition } = achievement; const { condition } = achievement;
switch (condition.type) { switch (condition.type) {
case "totalGoldEarned": case "totalGoldEarned":
@@ -26,9 +25,10 @@ const conditionDescription = (achievement: Achievement): string => {
interface AchievementCardProps { interface AchievementCardProps {
achievement: Achievement; achievement: Achievement;
formatNumber: (n: number) => string;
} }
const AchievementCard = ({ achievement }: AchievementCardProps): React.JSX.Element => { const AchievementCard = ({ achievement, formatNumber }: AchievementCardProps): React.JSX.Element => {
const isUnlocked = achievement.unlockedAt !== null; const isUnlocked = achievement.unlockedAt !== null;
return ( return (
@@ -37,7 +37,7 @@ const AchievementCard = ({ achievement }: AchievementCardProps): React.JSX.Eleme
<div className="achievement-info"> <div className="achievement-info">
<h3>{achievement.name}</h3> <h3>{achievement.name}</h3>
<p>{achievement.description}</p> <p>{achievement.description}</p>
<p className="achievement-condition">{conditionDescription(achievement)}</p> <p className="achievement-condition">{conditionDescription(achievement, formatNumber)}</p>
{achievement.reward?.crystals != null && ( {achievement.reward?.crystals != null && (
<p className="achievement-reward">💎 +{achievement.reward.crystals} Crystals</p> <p className="achievement-reward">💎 +{achievement.reward.crystals} Crystals</p>
)} )}
@@ -54,7 +54,7 @@ const AchievementCard = ({ achievement }: AchievementCardProps): React.JSX.Eleme
}; };
export const AchievementPanel = (): React.JSX.Element => { export const AchievementPanel = (): React.JSX.Element => {
const { state } = useGame(); const { state, formatNumber } = useGame();
const [showLocked, setShowLocked] = useState(true); const [showLocked, setShowLocked] = useState(true);
if (!state) return <section className="panel"><p>Loading...</p></section>; if (!state) return <section className="panel"><p>Loading...</p></section>;
@@ -79,7 +79,7 @@ export const AchievementPanel = (): React.JSX.Element => {
</p> </p>
<div className="achievement-list"> <div className="achievement-list">
{visible.map((achievement) => ( {visible.map((achievement) => (
<AchievementCard key={achievement.id} achievement={achievement} /> <AchievementCard key={achievement.id} achievement={achievement} formatNumber={formatNumber} />
))} ))}
</div> </div>
</section> </section>
+2 -6
View File
@@ -1,4 +1,5 @@
import type { BattleResult } from "../../context/GameContext.js"; import type { BattleResult } from "../../context/GameContext.js";
import { useGame } from "../../context/GameContext.js";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
interface BattleModalProps { interface BattleModalProps {
@@ -6,17 +7,12 @@ interface BattleModalProps {
onDismiss: () => void; onDismiss: () => void;
} }
const formatNumber = (n: number): string => {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return Math.floor(n).toLocaleString();
};
export const BattleModal = ({ export const BattleModal = ({
battle, battle,
onDismiss, onDismiss,
}: BattleModalProps): React.JSX.Element => { }: BattleModalProps): React.JSX.Element => {
const { result, bossName } = battle; const { result, bossName } = battle;
const { formatNumber } = useGame();
const [phase, setPhase] = useState<"animating" | "result">("animating"); const [phase, setPhase] = useState<"animating" | "result">("animating");
+4 -2
View File
@@ -1,7 +1,6 @@
import type { Boss } from "@elysium/types"; import type { Boss } from "@elysium/types";
import { useState } from "react"; import { useState } from "react";
import { useGame } from "../../context/GameContext.js"; import { useGame } from "../../context/GameContext.js";
import { formatNumber } from "../../utils/format.js";
import { LockToggle } from "../ui/LockToggle.js"; import { LockToggle } from "../ui/LockToggle.js";
import { ZoneSelector } from "./ZoneSelector.js"; import { ZoneSelector } from "./ZoneSelector.js";
@@ -11,6 +10,7 @@ interface BossCardProps {
onChallenge: (bossId: string) => void; onChallenge: (bossId: string) => void;
isChallenging: boolean; isChallenging: boolean;
unlockHint?: string | undefined; unlockHint?: string | undefined;
formatNumber: (n: number) => string;
} }
const BossCard = ({ const BossCard = ({
@@ -19,6 +19,7 @@ const BossCard = ({
onChallenge, onChallenge,
isChallenging, isChallenging,
unlockHint, unlockHint,
formatNumber,
}: BossCardProps): React.JSX.Element => { }: BossCardProps): React.JSX.Element => {
const hpPercent = (boss.currentHp / boss.maxHp) * 100; const hpPercent = (boss.currentHp / boss.maxHp) * 100;
const isPrestigeLocked = boss.prestigeRequirement > prestigeCount; const isPrestigeLocked = boss.prestigeRequirement > prestigeCount;
@@ -92,7 +93,7 @@ const BossCard = ({
}; };
export const BossPanel = (): React.JSX.Element => { export const BossPanel = (): React.JSX.Element => {
const { state, challengeBoss } = useGame(); const { state, challengeBoss, formatNumber } = useGame();
const [challengingBossId, setChallengingBossId] = useState<string | null>(null); const [challengingBossId, setChallengingBossId] = useState<string | null>(null);
const [activeZoneId, setActiveZoneId] = useState("verdant_vale"); const [activeZoneId, setActiveZoneId] = useState("verdant_vale");
const [showLocked, setShowLocked] = useState(true); const [showLocked, setShowLocked] = useState(true);
@@ -212,6 +213,7 @@ export const BossPanel = (): React.JSX.Element => {
<BossCard <BossCard
key={boss.id} key={boss.id}
boss={boss} boss={boss}
formatNumber={formatNumber}
isChallenging={challengingBossId === boss.id} isChallenging={challengingBossId === boss.id}
prestigeCount={state.prestige.count} prestigeCount={state.prestige.count}
unlockHint={bossUnlockHints.get(boss.id)} unlockHint={bossUnlockHints.get(boss.id)}
+1 -2
View File
@@ -1,7 +1,6 @@
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { useGame } from "../../context/GameContext.js"; import { useGame } from "../../context/GameContext.js";
import { calculateClickPower } from "../../engine/tick.js"; import { calculateClickPower } from "../../engine/tick.js";
import { formatNumber } from "../../utils/format.js";
interface FloatText { interface FloatText {
id: number; id: number;
@@ -11,7 +10,7 @@ interface FloatText {
} }
export const ClickArea = (): React.JSX.Element => { export const ClickArea = (): React.JSX.Element => {
const { state, handleClick } = useGame(); const { state, handleClick, formatNumber } = useGame();
const [floats, setFloats] = useState<FloatText[]>([]); const [floats, setFloats] = useState<FloatText[]>([]);
const nextIdRef = useRef(0); const nextIdRef = useRef(0);
@@ -1,4 +1,4 @@
import type { ProfileSettings } from "@elysium/types"; import type { NumberFormat, ProfileSettings } from "@elysium/types";
import { DEFAULT_PROFILE_SETTINGS } from "@elysium/types"; import { DEFAULT_PROFILE_SETTINGS } from "@elysium/types";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { updateProfile } from "../../api/client.js"; import { updateProfile } from "../../api/client.js";
@@ -20,12 +20,15 @@ const STAT_TOGGLES: { key: keyof ProfileSettings; label: string; icon: string }[
]; ];
export const EditProfileModal = ({ onClose }: EditProfileModalProps): React.JSX.Element => { export const EditProfileModal = ({ onClose }: EditProfileModalProps): React.JSX.Element => {
const { state } = useGame(); const { state, numberFormat: currentNumberFormat, setNumberFormat } = useGame();
const player = state?.player; const player = state?.player;
const [characterName, setCharacterName] = useState(player?.characterName ?? ""); const [characterName, setCharacterName] = useState(player?.characterName ?? "");
const [bio, setBio] = useState(""); const [bio, setBio] = useState("");
const [settings, setSettings] = useState<ProfileSettings>({ ...DEFAULT_PROFILE_SETTINGS }); const [settings, setSettings] = useState<ProfileSettings>({
...DEFAULT_PROFILE_SETTINGS,
numberFormat: currentNumberFormat,
});
const [loadingProfile, setLoadingProfile] = useState(true); const [loadingProfile, setLoadingProfile] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -59,6 +62,7 @@ export const EditProfileModal = ({ onClose }: EditProfileModalProps): React.JSX.
setError(null); setError(null);
try { try {
await updateProfile({ characterName, bio, profileSettings: settings }); await updateProfile({ characterName, bio, profileSettings: settings });
setNumberFormat(settings.numberFormat);
setSaved(true); setSaved(true);
setTimeout(onClose, 900); setTimeout(onClose, 900);
} catch (err: unknown) { } catch (err: unknown) {
@@ -139,6 +143,30 @@ export const EditProfileModal = ({ onClose }: EditProfileModalProps): React.JSX.
</div> </div>
</div> </div>
<div className="edit-profile-section">
<p className="edit-profile-label">Number Format</p>
<p className="edit-profile-sublabel">How large numbers appear across the game.</p>
<div className="number-format-picker">
{(
[
{ value: "suffix", label: "Suffix", example: "1.23Qa" },
{ value: "scientific", label: "Scientific", example: "1.23e15" },
{ value: "engineering", label: "Engineering", example: "1.23E15" },
] as { value: NumberFormat; label: string; example: string }[]
).map(({ value, label, example }) => (
<button
key={value}
className={`number-format-btn ${settings.numberFormat === value ? "number-format-active" : ""}`}
onClick={() => { setSettings((prev) => ({ ...prev, numberFormat: value })); }}
type="button"
>
<span className="number-format-label">{label}</span>
<span className="number-format-example">{example}</span>
</button>
))}
</div>
</div>
{error && <p className="edit-profile-error">{error}</p>} {error && <p className="edit-profile-error">{error}</p>}
<div className="edit-profile-actions"> <div className="edit-profile-actions">
+2 -1
View File
@@ -1,12 +1,13 @@
import type { PublicProfileResponse } from "@elysium/types"; import type { PublicProfileResponse } from "@elysium/types";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { formatNumber } from "../../utils/format.js"; import { useGame } from "../../context/GameContext.js";
interface ProfilePageProps { interface ProfilePageProps {
discordId: string; discordId: string;
} }
export const ProfilePage = ({ discordId }: ProfilePageProps): React.JSX.Element => { export const ProfilePage = ({ discordId }: ProfilePageProps): React.JSX.Element => {
const { formatNumber } = useGame();
const [profile, setProfile] = useState<PublicProfileResponse | null>(null); const [profile, setProfile] = useState<PublicProfileResponse | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
+1 -2
View File
@@ -1,7 +1,6 @@
import type { Quest } from "@elysium/types"; import type { Quest } from "@elysium/types";
import { useState } from "react"; import { useState } from "react";
import { useGame } from "../../context/GameContext.js"; import { useGame } from "../../context/GameContext.js";
import { formatNumber } from "../../utils/format.js";
import { LockToggle } from "../ui/LockToggle.js"; import { LockToggle } from "../ui/LockToggle.js";
import { ZoneSelector } from "./ZoneSelector.js"; import { ZoneSelector } from "./ZoneSelector.js";
@@ -24,7 +23,7 @@ interface QuestCardProps {
} }
const QuestCard = ({ quest, unlockHint, zoneHint }: QuestCardProps): React.JSX.Element => { const QuestCard = ({ quest, unlockHint, zoneHint }: QuestCardProps): React.JSX.Element => {
const { startQuest } = useGame(); const { startQuest, formatNumber } = useGame();
return ( return (
<div className={`quest-card quest-${quest.status}`}> <div className={`quest-card quest-${quest.status}`}>
+25 -7
View File
@@ -1,5 +1,6 @@
import type { Resource } from "@elysium/types"; import type { Resource } from "@elysium/types";
import { formatNumber } from "../../utils/format.js"; import { useGame } from "../../context/GameContext.js";
import { RESOURCE_CAP } from "../../engine/tick.js";
interface ResourceBarProps { interface ResourceBarProps {
resources: Resource; resources: Resource;
@@ -21,6 +22,8 @@ const formatRelativeTime = (timestamp: number): string => {
return `${hours}h ago`; return `${hours}h ago`;
}; };
const RESOURCE_FULL_TOOLTIP = "This resource is full! Consider spending some or prestiging to keep earning.";
export const ResourceBar = ({ export const ResourceBar = ({
resources, resources,
prestigeCount, prestigeCount,
@@ -29,27 +32,35 @@ export const ResourceBar = ({
lastSavedAt, lastSavedAt,
isSyncing, isSyncing,
onForceSync, onForceSync,
}: ResourceBarProps): React.JSX.Element => ( }: ResourceBarProps): React.JSX.Element => {
const { formatNumber } = useGame();
const anyFull = Object.values(resources).some((v) => v >= RESOURCE_CAP);
return (
<>
<header className="resource-bar"> <header className="resource-bar">
<div className="resource"> <div className={`resource${resources.gold >= RESOURCE_CAP ? " resource-full" : ""}`}>
<span className="resource-icon">🪙</span> <span className="resource-icon">🪙</span>
<span className="resource-value">{formatNumber(resources.gold)}</span> <span className="resource-value">{formatNumber(resources.gold)}</span>
<span className="resource-label">Gold</span> <span className="resource-label">Gold</span>
{resources.gold >= RESOURCE_CAP && <span className="resource-cap-badge" title={RESOURCE_FULL_TOOLTIP}>FULL</span>}
</div> </div>
<div className="resource"> <div className={`resource${resources.essence >= RESOURCE_CAP ? " resource-full" : ""}`}>
<span className="resource-icon"></span> <span className="resource-icon"></span>
<span className="resource-value">{formatNumber(resources.essence)}</span> <span className="resource-value">{formatNumber(resources.essence)}</span>
<span className="resource-label">Essence</span> <span className="resource-label">Essence</span>
{resources.essence >= RESOURCE_CAP && <span className="resource-cap-badge" title={RESOURCE_FULL_TOOLTIP}>FULL</span>}
</div> </div>
<div className="resource"> <div className={`resource${resources.crystals >= RESOURCE_CAP ? " resource-full" : ""}`}>
<span className="resource-icon">💎</span> <span className="resource-icon">💎</span>
<span className="resource-value">{formatNumber(resources.crystals)}</span> <span className="resource-value">{formatNumber(resources.crystals)}</span>
<span className="resource-label">Crystals</span> <span className="resource-label">Crystals</span>
{resources.crystals >= RESOURCE_CAP && <span className="resource-cap-badge" title={RESOURCE_FULL_TOOLTIP}>FULL</span>}
</div> </div>
<div className="resource"> <div className={`resource${resources.runestones >= RESOURCE_CAP ? " resource-full" : ""}`}>
<span className="resource-icon">🔮</span> <span className="resource-icon">🔮</span>
<span className="resource-value">{formatNumber(resources.runestones)}</span> <span className="resource-value">{formatNumber(resources.runestones)}</span>
<span className="resource-label">Runestones</span> <span className="resource-label">Runestones</span>
{resources.runestones >= RESOURCE_CAP && <span className="resource-cap-badge" title={RESOURCE_FULL_TOOLTIP}>FULL</span>}
</div> </div>
{prestigeCount > 0 && ( {prestigeCount > 0 && (
<div className="prestige-badge"> <div className="prestige-badge">
@@ -90,4 +101,11 @@ export const ResourceBar = ({
</button> </button>
</div> </div>
</header> </header>
); {anyFull && (
<div className="resource-cap-notice">
One or more resources are full! Consider spending some or prestiging to keep earning.
</div>
)}
</>
);
};
+31 -3
View File
@@ -1,4 +1,4 @@
import type { Achievement, BossChallengeResponse, GameState } from "@elysium/types"; import type { Achievement, BossChallengeResponse, GameState, NumberFormat } from "@elysium/types";
import { import {
createContext, createContext,
useCallback, useCallback,
@@ -8,7 +8,8 @@ import {
useState, useState,
} from "react"; } from "react";
import { challengeBoss as challengeBossApi, loadGame, saveGame } from "../api/client.js"; import { challengeBoss as challengeBossApi, loadGame, saveGame } from "../api/client.js";
import { applyTick, calculateClickPower } from "../engine/tick.js"; import { RESOURCE_CAP, applyTick, calculateClickPower } from "../engine/tick.js";
import { formatNumber as formatNumberUtil } from "../utils/format.js";
export interface BattleResult { export interface BattleResult {
@@ -54,6 +55,12 @@ interface GameContextValue {
newAchievements: Achievement[]; newAchievements: Achievement[];
/** Remove an achievement from the toast queue */ /** Remove an achievement from the toast queue */
dismissAchievement: (id: string) => void; dismissAchievement: (id: string) => void;
/** The player's chosen number display format */
numberFormat: NumberFormat;
/** Update the number format preference (persisted to server via profile save) */
setNumberFormat: (format: NumberFormat) => void;
/** Format a number using the player's chosen notation style */
formatNumber: (value: number) => string;
} }
const GameContext = createContext<GameContextValue | null>(null); const GameContext = createContext<GameContextValue | null>(null);
@@ -69,6 +76,7 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
const [newAchievements, setNewAchievements] = useState<Achievement[]>([]); const [newAchievements, setNewAchievements] = useState<Achievement[]>([]);
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null); const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
const [numberFormat, setNumberFormat] = useState<NumberFormat>("suffix");
const stateRef = useRef<GameState | null>(null); const stateRef = useRef<GameState | null>(null);
const lastSaveRef = useRef<number>(Date.now()); const lastSaveRef = useRef<number>(Date.now());
const isSyncingRef = useRef(false); const isSyncingRef = useRef(false);
@@ -87,6 +95,17 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
if (data.offlineGold > 0) { if (data.offlineGold > 0) {
setOfflineGold(data.offlineGold); setOfflineGold(data.offlineGold);
} }
// Fetch number format preference from profile (fire-and-forget, non-blocking)
void fetch(`/api/profile/${data.state.player.discordId}`)
.then(async (res) => {
if (!res.ok) return;
const profile = await res.json() as { profileSettings?: { numberFormat?: NumberFormat } };
const fmt = profile.profileSettings?.numberFormat;
if (fmt === "suffix" || fmt === "scientific" || fmt === "engineering") {
setNumberFormat(fmt);
}
})
.catch(() => { /* fall back to default "suffix" */ });
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to load game"); setError(err instanceof Error ? err.message : "Failed to load game");
} finally { } finally {
@@ -166,9 +185,10 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
setState((prev) => { setState((prev) => {
if (!prev) return prev; if (!prev) return prev;
const clickPower = calculateClickPower(prev); const clickPower = calculateClickPower(prev);
const newGold = Math.min(prev.resources.gold + clickPower, RESOURCE_CAP);
return { return {
...prev, ...prev,
resources: { ...prev.resources, gold: prev.resources.gold + clickPower }, resources: { ...prev.resources, gold: newGold },
player: { player: {
...prev.player, ...prev.player,
totalGoldEarned: prev.player.totalGoldEarned + clickPower, totalGoldEarned: prev.player.totalGoldEarned + clickPower,
@@ -408,6 +428,11 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
setNewAchievements((prev) => prev.filter((a) => a.id !== id)); setNewAchievements((prev) => prev.filter((a) => a.id !== id));
}, []); }, []);
const boundFormatNumber = useCallback(
(value: number) => formatNumberUtil(value, numberFormat),
[numberFormat],
);
return ( return (
<GameContext.Provider <GameContext.Provider
value={{ value={{
@@ -431,6 +456,9 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
dismissBattle, dismissBattle,
newAchievements, newAchievements,
dismissAchievement, dismissAchievement,
numberFormat,
setNumberFormat,
formatNumber: boundFormatNumber,
}} }}
> >
{children} {children}
+9 -4
View File
@@ -40,6 +40,11 @@ const checkAchievements = (state: GameState): Achievement[] => {
}); });
}; };
/** Maximum value any resource can accumulate to. Beyond this JS floats lose all useful precision. */
export const RESOURCE_CAP = 1e300;
const capResource = (value: number): number => Math.min(value, RESOURCE_CAP);
/** /**
* Pure function — applies one game tick to the state. * Pure function — applies one game tick to the state.
* deltaSeconds: time elapsed since last tick. * deltaSeconds: time elapsed since last tick.
@@ -187,8 +192,8 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
}); });
} }
const newGold = state.resources.gold + goldGained + questGold; const newGold = capResource(state.resources.gold + goldGained + questGold);
const newEssence = state.resources.essence + essenceGained + questEssence; const newEssence = capResource(state.resources.essence + essenceGained + questEssence);
const newTotalGoldEarned = state.player.totalGoldEarned + goldGained + questGold; const newTotalGoldEarned = state.player.totalGoldEarned + goldGained + questGold;
const partialState: GameState = { const partialState: GameState = {
@@ -197,7 +202,7 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
...state.resources, ...state.resources,
gold: newGold, gold: newGold,
essence: newEssence, essence: newEssence,
crystals: state.resources.crystals + questCrystals, crystals: capResource(state.resources.crystals + questCrystals),
}, },
player: { player: {
...state.player, ...state.player,
@@ -228,7 +233,7 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
achievements: updatedAchievements, achievements: updatedAchievements,
resources: { resources: {
...partialState.resources, ...partialState.resources,
crystals: partialState.resources.crystals + crystalsFromAchievements, crystals: capResource(partialState.resources.crystals + crystalsFromAchievements),
}, },
}; };
}; };
+79 -2
View File
@@ -71,6 +71,30 @@ body {
font-weight: 600; font-weight: 600;
} }
.resource-full .resource-value {
color: var(--colour-warning, #f59e0b);
}
.resource-cap-badge {
background: var(--colour-warning, #f59e0b);
border-radius: 0.25rem;
color: #000;
cursor: help;
font-size: 0.6rem;
font-weight: 800;
letter-spacing: 0.05em;
padding: 0.1rem 0.3rem;
}
.resource-cap-notice {
background: rgba(245, 158, 11, 0.12);
border-bottom: 1px solid rgba(245, 158, 11, 0.35);
color: #f59e0b;
font-size: 0.82rem;
padding: 0.4rem 1.5rem;
text-align: center;
}
/* ===================== GAME LAYOUT ===================== */ /* ===================== GAME LAYOUT ===================== */
.game-layout { .game-layout {
display: flex; display: flex;
@@ -1366,7 +1390,11 @@ body {
/* ── Edit Profile Modal ─────────────────────────────────────────────────── */ /* ── Edit Profile Modal ─────────────────────────────────────────────────── */
.edit-profile-modal { .edit-profile-modal {
display: flex;
flex-direction: column;
max-height: calc(100dvh - 4rem);
max-width: 480px; max-width: 480px;
padding: 0;
text-align: left; text-align: left;
width: 100%; width: 100%;
} }
@@ -1374,14 +1402,19 @@ body {
.modal-header { .modal-header {
align-items: center; align-items: center;
display: flex; display: flex;
flex-shrink: 0;
justify-content: space-between; justify-content: space-between;
margin-bottom: 1rem; padding: 1.5rem 2rem 1rem;
} }
.modal-header h2 { .modal-header h2 {
margin: 0; margin: 0;
} }
.edit-profile-modal .edit-profile-loading {
padding: 0 2rem 1.5rem;
}
.modal-close { .modal-close {
background: transparent; background: transparent;
border: none; border: none;
@@ -1401,6 +1434,8 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
overflow-y: auto;
padding: 0 2rem 1.5rem;
} }
.edit-profile-label { .edit-profile-label {
@@ -1425,10 +1460,14 @@ body {
font-family: inherit; font-family: inherit;
font-size: 0.9rem; font-size: 0.9rem;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
resize: vertical;
width: 100%; width: 100%;
} }
.edit-profile-textarea {
min-height: 5rem;
resize: vertical;
}
.edit-profile-input:focus, .edit-profile-input:focus,
.edit-profile-textarea:focus { .edit-profile-textarea:focus {
border-color: var(--colour-primary); border-color: var(--colour-primary);
@@ -1484,6 +1523,44 @@ body {
color: var(--colour-success); color: var(--colour-success);
} }
.number-format-picker {
display: flex;
gap: 0.5rem;
}
.number-format-btn {
align-items: center;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(147, 51, 234, 0.25);
border-radius: 0.4rem;
color: var(--colour-text-muted);
cursor: pointer;
display: flex;
flex: 1;
flex-direction: column;
font-family: inherit;
gap: 0.2rem;
padding: 0.5rem 0.75rem;
transition: all 0.15s;
}
.number-format-active {
background: rgba(147, 51, 234, 0.12);
border-color: rgba(147, 51, 234, 0.5);
color: var(--colour-text);
}
.number-format-label {
font-size: 0.85rem;
font-weight: 600;
}
.number-format-example {
color: var(--colour-accent);
font-family: monospace;
font-size: 0.8rem;
}
.edit-profile-error { .edit-profile-error {
color: var(--colour-error); color: var(--colour-error);
font-size: 0.82rem; font-size: 0.82rem;
+84 -26
View File
@@ -1,32 +1,90 @@
import type { NumberFormat } from "@elysium/types";
// Named suffixes up to 1e33 (Decillion). Letter-based suffixes take over from 1e36 onwards.
const NAMED_SUFFIXES: { threshold: number; suffix: string }[] = [
{ threshold: 1e33, suffix: "Dc" }, // Decillion
{ threshold: 1e30, suffix: "No" }, // Nonillion
{ threshold: 1e27, suffix: "Oc" }, // Octillion
{ threshold: 1e24, suffix: "Sp" }, // Septillion
{ threshold: 1e21, suffix: "Sx" }, // Sextillion
{ threshold: 1e18, suffix: "Qi" }, // Quintillion
{ threshold: 1e15, suffix: "Qa" }, // Quadrillion
{ threshold: 1e12, suffix: "T" }, // Trillion
{ threshold: 1e9, suffix: "B" }, // Billion
{ threshold: 1e6, suffix: "M" }, // Million
{ threshold: 1e3, suffix: "K" }, // Thousand
];
// Letter suffixes start at 1e36 ("a"), stepping by 1000 each time (i.e. +3 exponent per letter).
const LETTER_BASE_EXP = 36;
/** /**
* Formats a number with K/M/B/T/Q/Qt/S/Sp suffixes for display. * Generates an alphabetic suffix for a given index:
* Numbers below 1000 show one decimal place. * 0 → "a", 1 → "b", ..., 25 → "z",
* 26 → "aa", 27 → "ab", ..., 701 → "zz", 702 → "aaa", ...
*/ */
export const formatNumber = (value: number): string => { const getLetterSuffix = (index: number): string => {
if (!isFinite(value) || isNaN(value)) return "0"; let result = "";
if (value >= 1e24) { let n = index;
return `${(value / 1e24).toFixed(2)}Sp`; do {
result = String.fromCharCode(97 + (n % 26)) + result;
n = Math.floor(n / 26) - 1;
} while (n >= 0);
return result;
};
const formatSuffix = (value: number): string => {
if (value >= Math.pow(10, LETTER_BASE_EXP)) {
const exp = Math.floor(Math.log10(value));
const stepsAboveBase = Math.floor((exp - LETTER_BASE_EXP) / 3);
const divisorExp = LETTER_BASE_EXP + stepsAboveBase * 3;
const divisor = Math.pow(10, divisorExp);
return `${(value / divisor).toFixed(2)}${getLetterSuffix(stepsAboveBase)}`;
} }
if (value >= 1e21) { for (const { threshold, suffix } of NAMED_SUFFIXES) {
return `${(value / 1e21).toFixed(2)}S`; if (value >= threshold) {
} return `${(value / threshold).toFixed(2)}${suffix}`;
if (value >= 1e18) { }
return `${(value / 1e18).toFixed(2)}Qt`;
}
if (value >= 1e15) {
return `${(value / 1e15).toFixed(2)}Q`;
}
if (value >= 1_000_000_000_000) {
return `${(value / 1_000_000_000_000).toFixed(2)}T`;
}
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(2)}B`;
}
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(2)}M`;
}
if (value >= 1_000) {
return `${(value / 1_000).toFixed(2)}K`;
} }
return value.toFixed(1); return value.toFixed(1);
}; };
/**
* Formats a number in scientific notation: e.g. 1.23e15.
* Falls back to K/M/B/T style below 1 million.
*/
const formatScientific = (value: number): string => {
if (value < 1e6) return formatSuffix(value);
// toExponential handles all magnitudes JS can represent (up to ~1.8e308)
return value.toExponential(2).replace("e+", "e");
};
/**
* 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.
*/
const formatEngineering = (value: number): string => {
if (value < 1e6) return formatSuffix(value);
const exp = Math.floor(Math.log10(value));
const engExp = Math.floor(exp / 3) * 3;
const mantissa = value / Math.pow(10, engExp);
return `${mantissa.toFixed(2)}E${engExp}`;
};
/**
* Formats a number for display using the player's chosen notation style.
* Negative values are formatted with a leading minus sign.
*/
export const formatNumber = (value: number, format: NumberFormat = "suffix"): string => {
if (!isFinite(value) || isNaN(value)) return "0";
if (value < 0) return `-${formatNumber(-value, format)}`;
switch (format) {
case "scientific":
return formatScientific(value);
case "engineering":
return formatEngineering(value);
default:
return formatSuffix(value);
}
};
+1 -1
View File
@@ -41,5 +41,5 @@ export type {
UpgradeTarget, UpgradeTarget,
} from "./interfaces/Upgrade.js"; } from "./interfaces/Upgrade.js";
export type { Zone, ZoneStatus } from "./interfaces/Zone.js"; export type { Zone, ZoneStatus } from "./interfaces/Zone.js";
export type { ProfileSettings } from "./interfaces/ProfileSettings.js"; export type { NumberFormat, ProfileSettings } from "./interfaces/ProfileSettings.js";
export { DEFAULT_PROFILE_SETTINGS } from "./interfaces/ProfileSettings.js"; export { DEFAULT_PROFILE_SETTINGS } from "./interfaces/ProfileSettings.js";
@@ -1,3 +1,5 @@
export type NumberFormat = "suffix" | "scientific" | "engineering";
export interface ProfileSettings { export interface ProfileSettings {
showTotalGold: boolean; showTotalGold: boolean;
showTotalClicks: boolean; showTotalClicks: boolean;
@@ -7,6 +9,7 @@ export interface ProfileSettings {
showQuestsCompleted: boolean; showQuestsCompleted: boolean;
showAdventurersRecruited: boolean; showAdventurersRecruited: boolean;
showAchievementsUnlocked: boolean; showAchievementsUnlocked: boolean;
numberFormat: NumberFormat;
} }
export const DEFAULT_PROFILE_SETTINGS: ProfileSettings = { export const DEFAULT_PROFILE_SETTINGS: ProfileSettings = {
@@ -18,4 +21,5 @@ export const DEFAULT_PROFILE_SETTINGS: ProfileSettings = {
showQuestsCompleted: true, showQuestsCompleted: true,
showAdventurersRecruited: true, showAdventurersRecruited: true,
showAchievementsUnlocked: true, showAchievementsUnlocked: true,
numberFormat: "suffix",
}; };