Files
elysium/apps/web/src/context/GameContext.tsx
T
hikari 5ad2c44399 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
2026-03-06 18:59:43 -08:00

476 lines
16 KiB
TypeScript

import type { Achievement, BossChallengeResponse, GameState, NumberFormat } from "@elysium/types";
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { challengeBoss as challengeBossApi, loadGame, saveGame } from "../api/client.js";
import { RESOURCE_CAP, applyTick, calculateClickPower } from "../engine/tick.js";
import { formatNumber as formatNumberUtil } from "../utils/format.js";
export interface BattleResult {
bossName: string;
result: BossChallengeResponse;
}
interface GameContextValue {
state: GameState | null;
isLoading: boolean;
error: string | null;
/** Click the crystal to earn gold */
handleClick: () => void;
/** Buy an adventurer */
buyAdventurer: (adventurerId: string) => void;
/** Buy an upgrade */
buyUpgrade: (upgradeId: string) => void;
/** Purchase a buyable equipment item */
buyEquipment: (equipmentId: string) => void;
/** Start a quest */
startQuest: (questId: string) => void;
/** Challenge a boss — runs full server-side simulation */
challengeBoss: (bossId: string) => Promise<void>;
/** Equip an owned equipment item (auto-unequips the same slot) */
equipItem: (equipmentId: string) => void;
/** Reload state from the server */
reload: () => Promise<void>;
/** Unix timestamp of the last successful cloud save (null until first save response) */
lastSavedAt: number | null;
/** True whilst a forced save is in-flight */
isSyncing: boolean;
/** Immediately save to the server and reset the auto-save timer */
forceSync: () => Promise<void>;
/** Offline gold earned on login */
offlineGold: number;
/** Dismiss the offline gold notification */
dismissOfflineGold: () => void;
/** Battle result to display in the modal (null when no battle pending) */
battleResult: BattleResult | null;
/** Dismiss the battle result modal */
dismissBattle: () => void;
/** Queue of newly unlocked achievements (for toasts) */
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);
const AUTO_SAVE_INTERVAL_MS = 30_000;
export const GameProvider = ({ children }: { children: React.ReactNode }): React.JSX.Element => {
const [state, setState] = useState<GameState | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [offlineGold, setOfflineGold] = useState(0);
const [battleResult, setBattleResult] = useState<BattleResult | null>(null);
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);
const rafRef = useRef<number | null>(null);
const newlyUnlockedRef = useRef<Achievement[]>([]);
stateRef.current = state;
const reload = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await loadGame();
setState(data.state);
setLastSavedAt(data.state.player.lastSavedAt);
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 {
setIsLoading(false);
}
}, []);
useEffect(() => {
void reload();
}, [reload]);
// Game loop via requestAnimationFrame
useEffect(() => {
if (!state) return;
let lastTime = performance.now();
const tick = (now: number): void => {
const deltaSeconds = (now - lastTime) / 1000;
lastTime = now;
setState((prev) => {
if (!prev) return prev;
const next = applyTick(prev, deltaSeconds);
// Detect newly unlocked achievements
newlyUnlockedRef.current = next.achievements.filter((a, i) => {
const wasLocked = (prev.achievements ?? [])[i]?.unlockedAt === null;
return wasLocked && a.unlockedAt !== null;
});
return next;
});
if (newlyUnlockedRef.current.length > 0) {
setNewAchievements((prev) => [...prev, ...newlyUnlockedRef.current]);
newlyUnlockedRef.current = [];
}
// Auto-save every 30 seconds
if (Date.now() - lastSaveRef.current >= AUTO_SAVE_INTERVAL_MS) {
lastSaveRef.current = Date.now();
if (stateRef.current) {
void saveGame({ state: stateRef.current }).then((response) => {
setLastSavedAt(response.savedAt);
});
}
}
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- only run when state becomes available
}, [state !== null]);
const forceSync = useCallback(async () => {
if (!stateRef.current || isSyncingRef.current) return;
isSyncingRef.current = true;
setIsSyncing(true);
try {
const response = await saveGame({ state: stateRef.current });
setLastSavedAt(response.savedAt);
lastSaveRef.current = Date.now();
} finally {
isSyncingRef.current = false;
setIsSyncing(false);
}
}, []);
const handleClick = useCallback(() => {
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: newGold },
player: {
...prev.player,
totalGoldEarned: prev.player.totalGoldEarned + clickPower,
totalClicks: prev.player.totalClicks + 1,
},
};
});
}, []);
const buyAdventurer = useCallback((adventurerId: string) => {
setState((prev) => {
if (!prev) return prev;
const adventurer = prev.adventurers.find((a) => a.id === adventurerId);
if (!adventurer || !adventurer.unlocked) return prev;
const cost = 10 * Math.pow(1.15, adventurer.count);
if (prev.resources.gold < cost) return prev;
return {
...prev,
resources: { ...prev.resources, gold: prev.resources.gold - cost },
adventurers: prev.adventurers.map((a) =>
a.id === adventurerId ? { ...a, count: a.count + 1 } : a,
),
};
});
}, []);
const buyUpgrade = useCallback((upgradeId: string) => {
setState((prev) => {
if (!prev) return prev;
const upgrade = prev.upgrades.find((u) => u.id === upgradeId);
if (!upgrade || !upgrade.unlocked || upgrade.purchased) return prev;
if (prev.resources.gold < upgrade.costGold) return prev;
if (prev.resources.essence < upgrade.costEssence) return prev;
if (prev.resources.crystals < (upgrade.costCrystals ?? 0)) return prev;
return {
...prev,
resources: {
...prev.resources,
gold: prev.resources.gold - upgrade.costGold,
essence: prev.resources.essence - upgrade.costEssence,
crystals: prev.resources.crystals - (upgrade.costCrystals ?? 0),
},
upgrades: prev.upgrades.map((u) =>
u.id === upgradeId ? { ...u, purchased: true } : u,
),
};
});
}, []);
const startQuest = useCallback((questId: string) => {
setState((prev) => {
if (!prev) return prev;
const quest = prev.quests.find((q) => q.id === questId);
if (!quest || quest.status !== "available") return prev;
return {
...prev,
quests: prev.quests.map((q) =>
q.id === questId
? { ...q, status: "active" as const, startedAt: Date.now() }
: q,
),
};
});
}, []);
const equipItem = useCallback((equipmentId: string) => {
setState((prev) => {
if (!prev) return prev;
const item = (prev.equipment ?? []).find((e) => e.id === equipmentId);
if (!item || !item.owned) return prev;
return {
...prev,
equipment: (prev.equipment ?? []).map((e) => {
if (e.id === equipmentId) return { ...e, equipped: true };
// Unequip the previously-equipped item in the same slot
if (e.type === item.type && e.equipped) return { ...e, equipped: false };
return e;
}),
};
});
}, []);
const buyEquipment = useCallback((equipmentId: string) => {
setState((prev) => {
if (!prev) return prev;
const item = (prev.equipment ?? []).find((e) => e.id === equipmentId);
if (!item || item.owned || !item.cost) return prev;
const { gold, essence, crystals } = item.cost;
if (prev.resources.gold < gold) return prev;
if (prev.resources.essence < essence) return prev;
if (prev.resources.crystals < crystals) return prev;
const slotAlreadyEquipped = (prev.equipment ?? []).some(
(e) => e.type === item.type && e.equipped,
);
return {
...prev,
resources: {
...prev.resources,
gold: prev.resources.gold - gold,
essence: prev.resources.essence - essence,
crystals: prev.resources.crystals - crystals,
},
equipment: (prev.equipment ?? []).map((e) => {
if (e.id === equipmentId) return { ...e, owned: true, equipped: !slotAlreadyEquipped };
return e;
}),
};
});
}, []);
const challengeBoss = useCallback(async (bossId: string) => {
if (!stateRef.current) return;
const boss = stateRef.current.bosses.find((b) => b.id === bossId);
if (!boss) return;
try {
const result = await challengeBossApi({ bossId });
// Update local state to match server result
setState((prev) => {
if (!prev) return prev;
if (result.won) {
const defeatedBoss = prev.bosses.find((b) => b.id === bossId);
const zoneBosses = prev.bosses.filter((b) => b.zoneId === defeatedBoss?.zoneId);
const zoneIdx = zoneBosses.findIndex((b) => b.id === bossId);
const nextZoneBossId = zoneBosses[zoneIdx + 1]?.id;
// Find newly unlocked zones and their first bosses
// A zone unlocks when BOTH the gate boss is defeated AND the gate quest is completed
const newlyUnlockedZones = (prev.zones ?? []).filter((z) => {
if (z.status !== "locked" || z.unlockBossId !== bossId) return false;
const questOk =
z.unlockQuestId == null ||
prev.quests.some((q) => q.id === z.unlockQuestId && q.status === "completed");
return questOk;
});
const newZoneFirstBossIds = newlyUnlockedZones.map((z) => {
const firstBoss = prev.bosses.find((b) => b.zoneId === z.id);
return firstBoss?.id;
}).filter(Boolean);
return {
...prev,
bosses: prev.bosses.map((b) => {
if (b.id === bossId) return { ...b, status: "defeated" as const, currentHp: 0 };
if (b.id === nextZoneBossId && b.prestigeRequirement <= prev.prestige.count) {
return { ...b, status: "available" as const };
}
if (newZoneFirstBossIds.includes(b.id) && b.prestigeRequirement <= prev.prestige.count) {
return { ...b, status: "available" as const };
}
return b;
}),
zones: (prev.zones ?? []).map((z) => {
if (z.status !== "locked" || z.unlockBossId !== bossId) return z;
const questOk =
z.unlockQuestId == null ||
prev.quests.some((q) => q.id === z.unlockQuestId && q.status === "completed");
return questOk ? { ...z, status: "unlocked" as const } : z;
}),
resources: result.rewards
? {
...prev.resources,
gold: prev.resources.gold + result.rewards.gold,
essence: prev.resources.essence + result.rewards.essence,
crystals: prev.resources.crystals + result.rewards.crystals,
}
: prev.resources,
player: result.rewards
? {
...prev.player,
totalGoldEarned:
prev.player.totalGoldEarned + result.rewards.gold,
}
: prev.player,
upgrades: result.rewards
? prev.upgrades.map((u) =>
result.rewards!.upgradeIds.includes(u.id)
? { ...u, unlocked: true }
: u,
)
: prev.upgrades,
equipment: result.rewards
? (prev.equipment ?? []).map((e) => {
if (!result.rewards!.equipmentIds.includes(e.id)) return e;
const slotEmpty = !(prev.equipment ?? []).some(
(other) => other.type === e.type && other.equipped,
);
return { ...e, owned: true, equipped: slotEmpty || e.equipped };
})
: prev.equipment ?? [],
};
}
// Loss: reset boss HP and apply casualties
return {
...prev,
bosses: prev.bosses.map((b) =>
b.id === bossId
? { ...b, status: "available" as const, currentHp: b.maxHp }
: b,
),
adventurers: prev.adventurers.map((a) => {
const casualty = result.casualties?.find(
(c) => c.adventurerId === a.id,
);
if (!casualty) return a;
return { ...a, count: Math.max(0, a.count - casualty.killed) };
}),
};
});
setBattleResult({ bossName: boss.name, result });
} catch {
// Silently ignore — server errors shouldn't crash the UI
}
}, []);
const dismissOfflineGold = useCallback(() => {
setOfflineGold(0);
}, []);
const dismissBattle = useCallback(() => {
setBattleResult(null);
}, []);
const dismissAchievement = useCallback((id: string) => {
setNewAchievements((prev) => prev.filter((a) => a.id !== id));
}, []);
const boundFormatNumber = useCallback(
(value: number) => formatNumberUtil(value, numberFormat),
[numberFormat],
);
return (
<GameContext.Provider
value={{
state,
isLoading,
error,
handleClick,
buyAdventurer,
buyUpgrade,
buyEquipment,
startQuest,
challengeBoss,
equipItem,
reload,
lastSavedAt,
isSyncing,
forceSync,
offlineGold,
dismissOfflineGold,
battleResult,
dismissBattle,
newAchievements,
dismissAchievement,
numberFormat,
setNumberFormat,
formatNumber: boundFormatNumber,
}}
>
{children}
</GameContext.Provider>
);
};
export const useGame = (): GameContextValue => {
const context = useContext(GameContext);
if (!context) {
throw new Error("useGame must be used within a GameProvider");
}
return context;
};