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
@@ -1,10 +1,9 @@
import type { Achievement } from "@elysium/types";
import { useState } from "react";
import { useGame } from "../../context/GameContext.js";
import { formatNumber } from "../../utils/format.js";
import { LockToggle } from "../ui/LockToggle.js";
const conditionDescription = (achievement: Achievement): string => {
const conditionDescription = (achievement: Achievement, formatNumber: (n: number) => string): string => {
const { condition } = achievement;
switch (condition.type) {
case "totalGoldEarned":
@@ -26,9 +25,10 @@ const conditionDescription = (achievement: Achievement): string => {
interface AchievementCardProps {
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;
return (
@@ -37,7 +37,7 @@ const AchievementCard = ({ achievement }: AchievementCardProps): React.JSX.Eleme
<div className="achievement-info">
<h3>{achievement.name}</h3>
<p>{achievement.description}</p>
<p className="achievement-condition">{conditionDescription(achievement)}</p>
<p className="achievement-condition">{conditionDescription(achievement, formatNumber)}</p>
{achievement.reward?.crystals != null && (
<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 => {
const { state } = useGame();
const { state, formatNumber } = useGame();
const [showLocked, setShowLocked] = useState(true);
if (!state) return <section className="panel"><p>Loading...</p></section>;
@@ -79,7 +79,7 @@ export const AchievementPanel = (): React.JSX.Element => {
</p>
<div className="achievement-list">
{visible.map((achievement) => (
<AchievementCard key={achievement.id} achievement={achievement} />
<AchievementCard key={achievement.id} achievement={achievement} formatNumber={formatNumber} />
))}
</div>
</section>
+2 -6
View File
@@ -1,4 +1,5 @@
import type { BattleResult } from "../../context/GameContext.js";
import { useGame } from "../../context/GameContext.js";
import { useEffect, useState } from "react";
interface BattleModalProps {
@@ -6,17 +7,12 @@ interface BattleModalProps {
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 = ({
battle,
onDismiss,
}: BattleModalProps): React.JSX.Element => {
const { result, bossName } = battle;
const { formatNumber } = useGame();
const [phase, setPhase] = useState<"animating" | "result">("animating");
+4 -2
View File
@@ -1,7 +1,6 @@
import type { Boss } from "@elysium/types";
import { useState } from "react";
import { useGame } from "../../context/GameContext.js";
import { formatNumber } from "../../utils/format.js";
import { LockToggle } from "../ui/LockToggle.js";
import { ZoneSelector } from "./ZoneSelector.js";
@@ -11,6 +10,7 @@ interface BossCardProps {
onChallenge: (bossId: string) => void;
isChallenging: boolean;
unlockHint?: string | undefined;
formatNumber: (n: number) => string;
}
const BossCard = ({
@@ -19,6 +19,7 @@ const BossCard = ({
onChallenge,
isChallenging,
unlockHint,
formatNumber,
}: BossCardProps): React.JSX.Element => {
const hpPercent = (boss.currentHp / boss.maxHp) * 100;
const isPrestigeLocked = boss.prestigeRequirement > prestigeCount;
@@ -92,7 +93,7 @@ const BossCard = ({
};
export const BossPanel = (): React.JSX.Element => {
const { state, challengeBoss } = useGame();
const { state, challengeBoss, formatNumber } = useGame();
const [challengingBossId, setChallengingBossId] = useState<string | null>(null);
const [activeZoneId, setActiveZoneId] = useState("verdant_vale");
const [showLocked, setShowLocked] = useState(true);
@@ -212,6 +213,7 @@ export const BossPanel = (): React.JSX.Element => {
<BossCard
key={boss.id}
boss={boss}
formatNumber={formatNumber}
isChallenging={challengingBossId === boss.id}
prestigeCount={state.prestige.count}
unlockHint={bossUnlockHints.get(boss.id)}
+1 -2
View File
@@ -1,7 +1,6 @@
import { useCallback, useRef, useState } from "react";
import { useGame } from "../../context/GameContext.js";
import { calculateClickPower } from "../../engine/tick.js";
import { formatNumber } from "../../utils/format.js";
interface FloatText {
id: number;
@@ -11,7 +10,7 @@ interface FloatText {
}
export const ClickArea = (): React.JSX.Element => {
const { state, handleClick } = useGame();
const { state, handleClick, formatNumber } = useGame();
const [floats, setFloats] = useState<FloatText[]>([]);
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 { useEffect, useState } from "react";
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 => {
const { state } = useGame();
const { state, numberFormat: currentNumberFormat, setNumberFormat } = useGame();
const player = state?.player;
const [characterName, setCharacterName] = useState(player?.characterName ?? "");
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 [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -59,6 +62,7 @@ export const EditProfileModal = ({ onClose }: EditProfileModalProps): React.JSX.
setError(null);
try {
await updateProfile({ characterName, bio, profileSettings: settings });
setNumberFormat(settings.numberFormat);
setSaved(true);
setTimeout(onClose, 900);
} catch (err: unknown) {
@@ -139,6 +143,30 @@ export const EditProfileModal = ({ onClose }: EditProfileModalProps): React.JSX.
</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>}
<div className="edit-profile-actions">
+2 -1
View File
@@ -1,12 +1,13 @@
import type { PublicProfileResponse } from "@elysium/types";
import { useEffect, useState } from "react";
import { formatNumber } from "../../utils/format.js";
import { useGame } from "../../context/GameContext.js";
interface ProfilePageProps {
discordId: string;
}
export const ProfilePage = ({ discordId }: ProfilePageProps): React.JSX.Element => {
const { formatNumber } = useGame();
const [profile, setProfile] = useState<PublicProfileResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
+1 -2
View File
@@ -1,7 +1,6 @@
import type { Quest } from "@elysium/types";
import { useState } from "react";
import { useGame } from "../../context/GameContext.js";
import { formatNumber } from "../../utils/format.js";
import { LockToggle } from "../ui/LockToggle.js";
import { ZoneSelector } from "./ZoneSelector.js";
@@ -24,7 +23,7 @@ interface QuestCardProps {
}
const QuestCard = ({ quest, unlockHint, zoneHint }: QuestCardProps): React.JSX.Element => {
const { startQuest } = useGame();
const { startQuest, formatNumber } = useGame();
return (
<div className={`quest-card quest-${quest.status}`}>
+25 -7
View File
@@ -1,5 +1,6 @@
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 {
resources: Resource;
@@ -21,6 +22,8 @@ const formatRelativeTime = (timestamp: number): string => {
return `${hours}h ago`;
};
const RESOURCE_FULL_TOOLTIP = "This resource is full! Consider spending some or prestiging to keep earning.";
export const ResourceBar = ({
resources,
prestigeCount,
@@ -29,27 +32,35 @@ export const ResourceBar = ({
lastSavedAt,
isSyncing,
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">
<div className="resource">
<div className={`resource${resources.gold >= RESOURCE_CAP ? " resource-full" : ""}`}>
<span className="resource-icon">🪙</span>
<span className="resource-value">{formatNumber(resources.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 className="resource">
<div className={`resource${resources.essence >= RESOURCE_CAP ? " resource-full" : ""}`}>
<span className="resource-icon"></span>
<span className="resource-value">{formatNumber(resources.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 className="resource">
<div className={`resource${resources.crystals >= RESOURCE_CAP ? " resource-full" : ""}`}>
<span className="resource-icon">💎</span>
<span className="resource-value">{formatNumber(resources.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 className="resource">
<div className={`resource${resources.runestones >= RESOURCE_CAP ? " resource-full" : ""}`}>
<span className="resource-icon">🔮</span>
<span className="resource-value">{formatNumber(resources.runestones)}</span>
<span className="resource-label">Runestones</span>
{resources.runestones >= RESOURCE_CAP && <span className="resource-cap-badge" title={RESOURCE_FULL_TOOLTIP}>FULL</span>}
</div>
{prestigeCount > 0 && (
<div className="prestige-badge">
@@ -90,4 +101,11 @@ export const ResourceBar = ({
</button>
</div>
</header>
);
{anyFull && (
<div className="resource-cap-notice">
One or more resources are full! Consider spending some or prestiging to keep earning.
</div>
)}
</>
);
};