feat: v1 prototype — core game systems #30

Merged
naomi merged 84 commits from feat/prototype into main 2026-03-08 15:53:39 -07:00
15 changed files with 290 additions and 65 deletions
Showing only changes of commit 5ad2c44399 - Show all commits
+10
View File
@@ -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>
+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>
)}
</>
);
};
+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 {
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}
+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.
* 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
View File
@@ -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;
+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.
* 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);
}
};
+1 -1
View File
@@ -41,5 +41,5 @@ export type {
UpgradeTarget,
} from "./interfaces/Upgrade.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";
@@ -1,3 +1,5 @@
export type NumberFormat = "suffix" | "scientific" | "engineering";
export interface ProfileSettings {
showTotalGold: boolean;
showTotalClicks: boolean;
@@ -7,6 +9,7 @@ export interface ProfileSettings {
showQuestsCompleted: boolean;
showAdventurersRecruited: boolean;
showAchievementsUnlocked: boolean;
numberFormat: NumberFormat;
}
export const DEFAULT_PROFILE_SETTINGS: ProfileSettings = {
@@ -18,4 +21,5 @@ export const DEFAULT_PROFILE_SETTINGS: ProfileSettings = {
showQuestsCompleted: true,
showAdventurersRecruited: true,
showAchievementsUnlocked: true,
numberFormat: "suffix",
};