feat: add equipment, achievements, and visual polish

- Equipment system: 12 items across weapon/armour/trinket slots with
  common/rare/epic/legendary rarities; starter commons auto-equipped,
  higher tiers drop from boss victories
- Achievement system: 15 milestones with typed conditions; checked
  each tick and crystal rewards applied automatically
- Achievement toast: slide-in notification, auto-dismisses after 4s
- Floating click text: +X gold floats on each manual click
- Expanded quests (9 total) and upgrades (12 total)
- Upgrade panel now shows locked upgrades so players can see their
  progression path
- formatNumber utility (K/M/B/T) used consistently across all panels
- Backfill logic for existing saves to add new content gracefully
- types package now emits .d.ts declarations
This commit is contained in:
2026-03-06 13:27:48 -08:00
committed by Naomi Carrigan
parent a3daed1683
commit e9e0df31fd
33 changed files with 2066 additions and 133 deletions
+131 -30
View File
@@ -1,4 +1,4 @@
import type { GameState } from "@elysium/types";
import type { Achievement, BossChallengeResponse, GameState } from "@elysium/types";
import {
createContext,
useCallback,
@@ -7,9 +7,14 @@ import {
useRef,
useState,
} from "react";
import { dealBossDamage, loadGame, saveGame } from "../api/client.js";
import { challengeBoss as challengeBossApi, loadGame, saveGame } from "../api/client.js";
import { applyTick, calculateClickPower } from "../engine/tick.js";
export interface BattleResult {
bossName: string;
result: BossChallengeResponse;
}
interface GameContextValue {
state: GameState | null;
isLoading: boolean;
@@ -22,14 +27,24 @@ interface GameContextValue {
buyUpgrade: (upgradeId: string) => void;
/** Start a quest */
startQuest: (questId: string) => void;
/** Attack the active boss */
attackBoss: (bossId: 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>;
/** 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;
}
const GameContext = createContext<GameContextValue | null>(null);
@@ -41,9 +56,12 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
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 stateRef = useRef<GameState | null>(null);
const lastSaveRef = useRef<number>(Date.now());
const rafRef = useRef<number | null>(null);
const newlyUnlockedRef = useRef<Achievement[]>([]);
stateRef.current = state;
@@ -79,9 +97,22 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
setState((prev) => {
if (!prev) return prev;
return applyTick(prev, deltaSeconds);
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();
@@ -176,50 +207,107 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
});
}, []);
const attackBoss = useCallback(async (bossId: string) => {
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 challengeBoss = useCallback(async (bossId: string) => {
if (!stateRef.current) return;
const clickPower = calculateClickPower(stateRef.current);
const boss = stateRef.current.bosses.find((b) => b.id === bossId);
if (!boss) return;
try {
const result = await dealBossDamage({ bossId, damage: clickPower });
const result = await challengeBossApi({ bossId });
// Update local state to match server result
setState((prev) => {
if (!prev) return prev;
return {
...prev,
bosses: prev.bosses.map((b) =>
b.id === bossId
if (result.won) {
const bossIndex = prev.bosses.findIndex((b) => b.id === bossId);
return {
...prev,
bosses: prev.bosses.map((b, idx) => {
if (b.id === bossId) {
return { ...b, status: "defeated" as const, currentHp: 0 };
}
if (
idx === bossIndex + 1 &&
b.prestigeRequirement <= prev.prestige.count
) {
return { ...b, status: "available" as const };
}
return b;
}),
resources: result.rewards
? {
...b,
status: result.defeated ? ("defeated" as const) : ("in_progress" as const),
currentHp: result.currentHp,
}
: b,
),
...(result.defeated && result.rewards
? {
resources: {
...prev.resources,
gold: prev.resources.gold + result.rewards.gold,
essence: prev.resources.essence + result.rewards.essence,
crystals: prev.resources.crystals + result.rewards.crystals,
},
player: {
}
: prev.resources,
player: result.rewards
? {
...prev.player,
totalGoldEarned:
prev.player.totalGoldEarned + result.rewards.gold,
},
upgrades: prev.upgrades.map((u) =>
}
: 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 {
// Rate limited or other error — silently ignore
// Silently ignore — server errors shouldn't crash the UI
}
}, []);
@@ -227,6 +315,14 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
setOfflineGold(0);
}, []);
const dismissBattle = useCallback(() => {
setBattleResult(null);
}, []);
const dismissAchievement = useCallback((id: string) => {
setNewAchievements((prev) => prev.filter((a) => a.id !== id));
}, []);
return (
<GameContext.Provider
value={{
@@ -237,10 +333,15 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
buyAdventurer,
buyUpgrade,
startQuest,
attackBoss,
challengeBoss,
equipItem,
reload,
offlineGold,
dismissOfflineGold,
battleResult,
dismissBattle,
newAchievements,
dismissAchievement,
}}
>
{children}