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
+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}