generated from nhcarrigan/template
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:
@@ -10,9 +10,14 @@ import { authMiddleware } from "../middleware/auth.js";
|
||||
|
||||
export const profileRouter = new Hono();
|
||||
|
||||
const VALID_NUMBER_FORMATS = new Set(["suffix", "scientific", "engineering"]);
|
||||
|
||||
const parseProfileSettings = (raw: unknown): ProfileSettings => {
|
||||
if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const numberFormat = VALID_NUMBER_FORMATS.has(obj.numberFormat as string)
|
||||
? (obj.numberFormat as ProfileSettings["numberFormat"])
|
||||
: "suffix";
|
||||
return {
|
||||
showTotalGold: obj.showTotalGold !== false,
|
||||
showTotalClicks: obj.showTotalClicks !== false,
|
||||
@@ -22,6 +27,7 @@ const parseProfileSettings = (raw: unknown): ProfileSettings => {
|
||||
showQuestsCompleted: obj.showQuestsCompleted !== false,
|
||||
showAdventurersRecruited: obj.showAdventurersRecruited !== false,
|
||||
showAchievementsUnlocked: obj.showAchievementsUnlocked !== false,
|
||||
numberFormat,
|
||||
};
|
||||
}
|
||||
return { ...DEFAULT_PROFILE_SETTINGS };
|
||||
@@ -73,6 +79,9 @@ profileRouter.put("/", authMiddleware, async (context) => {
|
||||
|
||||
const characterName = (body.characterName ?? "").trim().slice(0, 32);
|
||||
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 = {
|
||||
showTotalGold: body.profileSettings?.showTotalGold !== false,
|
||||
showTotalClicks: body.profileSettings?.showTotalClicks !== false,
|
||||
@@ -82,6 +91,7 @@ profileRouter.put("/", authMiddleware, async (context) => {
|
||||
showQuestsCompleted: body.profileSettings?.showQuestsCompleted !== false,
|
||||
showAdventurersRecruited: body.profileSettings?.showAdventurersRecruited !== false,
|
||||
showAchievementsUnlocked: body.profileSettings?.showAchievementsUnlocked !== false,
|
||||
numberFormat,
|
||||
};
|
||||
|
||||
if (!characterName) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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,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}`}>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Achievement, BossChallengeResponse, GameState } from "@elysium/types";
|
||||
import type { Achievement, BossChallengeResponse, GameState, NumberFormat } from "@elysium/types";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
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 {
|
||||
@@ -54,6 +55,12 @@ interface GameContextValue {
|
||||
newAchievements: Achievement[];
|
||||
/** Remove an achievement from the toast queue */
|
||||
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);
|
||||
@@ -69,6 +76,7 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
const [newAchievements, setNewAchievements] = useState<Achievement[]>([]);
|
||||
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [numberFormat, setNumberFormat] = useState<NumberFormat>("suffix");
|
||||
const stateRef = useRef<GameState | null>(null);
|
||||
const lastSaveRef = useRef<number>(Date.now());
|
||||
const isSyncingRef = useRef(false);
|
||||
@@ -87,6 +95,17 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
if (data.offlineGold > 0) {
|
||||
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) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load game");
|
||||
} finally {
|
||||
@@ -166,9 +185,10 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
setState((prev) => {
|
||||
if (!prev) return prev;
|
||||
const clickPower = calculateClickPower(prev);
|
||||
const newGold = Math.min(prev.resources.gold + clickPower, RESOURCE_CAP);
|
||||
return {
|
||||
...prev,
|
||||
resources: { ...prev.resources, gold: prev.resources.gold + clickPower },
|
||||
resources: { ...prev.resources, gold: newGold },
|
||||
player: {
|
||||
...prev.player,
|
||||
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));
|
||||
}, []);
|
||||
|
||||
const boundFormatNumber = useCallback(
|
||||
(value: number) => formatNumberUtil(value, numberFormat),
|
||||
[numberFormat],
|
||||
);
|
||||
|
||||
return (
|
||||
<GameContext.Provider
|
||||
value={{
|
||||
@@ -431,6 +456,9 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
dismissBattle,
|
||||
newAchievements,
|
||||
dismissAchievement,
|
||||
numberFormat,
|
||||
setNumberFormat,
|
||||
formatNumber: boundFormatNumber,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -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.
|
||||
* 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 newEssence = state.resources.essence + essenceGained + questEssence;
|
||||
const newGold = capResource(state.resources.gold + goldGained + questGold);
|
||||
const newEssence = capResource(state.resources.essence + essenceGained + questEssence);
|
||||
const newTotalGoldEarned = state.player.totalGoldEarned + goldGained + questGold;
|
||||
|
||||
const partialState: GameState = {
|
||||
@@ -197,7 +202,7 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
|
||||
...state.resources,
|
||||
gold: newGold,
|
||||
essence: newEssence,
|
||||
crystals: state.resources.crystals + questCrystals,
|
||||
crystals: capResource(state.resources.crystals + questCrystals),
|
||||
},
|
||||
player: {
|
||||
...state.player,
|
||||
@@ -228,7 +233,7 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
|
||||
achievements: updatedAchievements,
|
||||
resources: {
|
||||
...partialState.resources,
|
||||
crystals: partialState.resources.crystals + crystalsFromAchievements,
|
||||
crystals: capResource(partialState.resources.crystals + crystalsFromAchievements),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
+79
-2
@@ -71,6 +71,30 @@ body {
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -1366,7 +1390,11 @@ body {
|
||||
/* ── Edit Profile Modal ─────────────────────────────────────────────────── */
|
||||
|
||||
.edit-profile-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100dvh - 4rem);
|
||||
max-width: 480px;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1374,14 +1402,19 @@ body {
|
||||
.modal-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1.5rem 2rem 1rem;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.edit-profile-modal .edit-profile-loading {
|
||||
padding: 0 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -1401,6 +1434,8 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
overflow-y: auto;
|
||||
padding: 0 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.edit-profile-label {
|
||||
@@ -1425,10 +1460,14 @@ body {
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
resize: vertical;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.edit-profile-textarea {
|
||||
min-height: 5rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.edit-profile-input:focus,
|
||||
.edit-profile-textarea:focus {
|
||||
border-color: var(--colour-primary);
|
||||
@@ -1484,6 +1523,44 @@ body {
|
||||
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 {
|
||||
color: var(--colour-error);
|
||||
font-size: 0.82rem;
|
||||
|
||||
@@ -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.
|
||||
* Numbers below 1000 show one decimal place.
|
||||
* Generates an alphabetic suffix for a given index:
|
||||
* 0 → "a", 1 → "b", ..., 25 → "z",
|
||||
* 26 → "aa", 27 → "ab", ..., 701 → "zz", 702 → "aaa", ...
|
||||
*/
|
||||
export const formatNumber = (value: number): string => {
|
||||
if (!isFinite(value) || isNaN(value)) return "0";
|
||||
if (value >= 1e24) {
|
||||
return `${(value / 1e24).toFixed(2)}Sp`;
|
||||
const getLetterSuffix = (index: number): string => {
|
||||
let result = "";
|
||||
let n = index;
|
||||
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) {
|
||||
return `${(value / 1e21).toFixed(2)}S`;
|
||||
}
|
||||
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`;
|
||||
for (const { threshold, suffix } of NAMED_SUFFIXES) {
|
||||
if (value >= threshold) {
|
||||
return `${(value / threshold).toFixed(2)}${suffix}`;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user