generated from nhcarrigan/template
e780dc5f6c
Zones now unlock in strict linear order. Each zone requires both the final boss AND the final quest of the previous zone to be completed: Verdant Vale → Shattered Ruins (forest_giant + ancient_ruins) Shattered Ruins → Frozen Peaks (elder_dragon + dragon_lair) Frozen Peaks → Shadow Marshes (void_titan + storm_citadel) Shadow Marshes → Volcanic Depths (mud_kraken + plague_ruins) Volcanic Depths → Astral Void (phoenix_lord + the_forge) - Zone type gains `unlockQuestId` field alongside `unlockBossId` - boss.ts checks both conditions before unlocking zone on boss defeat - tick.ts checks both conditions and unlocks zones + first boss on quest completion if the boss condition is already met - GameContext optimistic update also respects dual-condition logic - BossPanel zone-gate hints now show both "⚔️ Defeat: X & 📜 Complete: Y" - game.ts backfill syncs unlockQuestId and re-verifies zone status using both conditions, reverting incorrectly-unlocked saves
418 lines
14 KiB
TypeScript
418 lines
14 KiB
TypeScript
import type { Achievement, BossChallengeResponse, GameState } from "@elysium/types";
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
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;
|
|
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>;
|
|
/** 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);
|
|
|
|
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 stateRef = useRef<GameState | null>(null);
|
|
const lastSaveRef = useRef<number>(Date.now());
|
|
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);
|
|
if (data.offlineGold > 0) {
|
|
setOfflineGold(data.offlineGold);
|
|
}
|
|
} 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 });
|
|
}
|
|
}
|
|
|
|
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 handleClick = useCallback(() => {
|
|
setState((prev) => {
|
|
if (!prev) return prev;
|
|
const clickPower = calculateClickPower(prev);
|
|
return {
|
|
...prev,
|
|
resources: { ...prev.resources, gold: prev.resources.gold + clickPower },
|
|
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));
|
|
}, []);
|
|
|
|
return (
|
|
<GameContext.Provider
|
|
value={{
|
|
state,
|
|
isLoading,
|
|
error,
|
|
handleClick,
|
|
buyAdventurer,
|
|
buyUpgrade,
|
|
buyEquipment,
|
|
startQuest,
|
|
challengeBoss,
|
|
equipItem,
|
|
reload,
|
|
offlineGold,
|
|
dismissOfflineGold,
|
|
battleResult,
|
|
dismissBattle,
|
|
newAchievements,
|
|
dismissAchievement,
|
|
}}
|
|
>
|
|
{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;
|
|
};
|