import { create } from "zustand"; import { persist, createJSONStorage } from "zustand/middleware"; import type { QuestArc, QuestNode, NodeStatus } from "../types/quest"; import { CREW_RANKS } from "../types/quest"; import { QUEST_ARCS } from "../data/questData"; // ─── Types ──────────────────────────────────────────────────────────────────── export interface CrewRank { id: string; label: string; emoji: string; xpRequired: number; progressToNext: number; // 0–1 toward next rank next: { label: string; xpRequired: number } | null; } export interface QuestSummary { totalNodes: number; completedNodes: number; activeNodes: number; claimableNodes: number; lockedNodes: number; totalXP: number; earnedXP: number; arcsCompleted: number; totalArcs: number; earnedTitles: string[]; crewRank: CrewRank; } // ─── Store — ONLY raw state + actions, never derived values ─────────────────── // Storing functions that return new objects/arrays in Zustand causes infinite // re-render loops because Zustand uses Object.is to detect changes. // All derived values live below as plain helper functions instead. interface QuestStore { arcs: QuestArc[]; activeArcId: string; setActiveArc: (arcId: string) => void; claimNode: (arcId: string, nodeId: string) => void; syncFromAPI: (arcs: QuestArc[]) => void; } export const useQuestStore = create()( persist( (set) => ({ arcs: QUEST_ARCS, activeArcId: QUEST_ARCS[0].id, setActiveArc: (arcId) => set({ activeArcId: arcId }), claimNode: (arcId, nodeId) => set((state) => ({ arcs: state.arcs.map((arc) => { if (arc.id !== arcId) return arc; const nodeIdx = arc.nodes.findIndex((n) => n.id === nodeId); if (nodeIdx === -1) return arc; return { ...arc, nodes: arc.nodes.map((n, i) => { if (n.id === nodeId) return { ...n, status: "completed" as NodeStatus }; if (i === nodeIdx + 1 && n.status === "locked") return { ...n, status: "active" as NodeStatus }; return n; }), }; }), })), syncFromAPI: (arcs) => set({ arcs }), }), { name: "quest-store", storage: createJSONStorage(() => localStorage), partialize: (state) => ({ arcs: state.arcs, activeArcId: state.activeArcId, }), }, ), ); // ─── Standalone helper functions ────────────────────────────────────────────── // Call these in your components AFTER selecting arcs from the store. // Because they take arcs as an argument (not selected from the store), // they never cause re-render loops. // // Usage: // const arcs = useQuestStore(s => s.arcs); // const summary = getQuestSummary(arcs); // const rank = getCrewRank(arcs); export function getEarnedXP(arcs: QuestArc[]): number { return arcs .flatMap((a) => a.nodes) .filter((n) => n.status === "completed") .reduce((sum, n) => sum + n.reward.xp, 0); } export function getCrewRank(arcs: QuestArc[]): CrewRank { const xp = getEarnedXP(arcs); const ladder = [...CREW_RANKS]; let idx = 0; for (let i = ladder.length - 1; i >= 0; i--) { if (xp >= ladder[i].xpRequired) { idx = i; break; } } const current = ladder[idx]; const nextRank = ladder[idx + 1] ?? null; return { ...current, progressToNext: nextRank ? Math.min( 1, (xp - current.xpRequired) / (nextRank.xpRequired - current.xpRequired), ) : 1, next: nextRank ? { label: nextRank.label, xpRequired: nextRank.xpRequired } : null, }; } export function getQuestSummary(arcs: QuestArc[]): QuestSummary { const allNodes = arcs.flatMap((a) => a.nodes); const earnedXP = getEarnedXP(arcs); return { totalNodes: allNodes.length, completedNodes: allNodes.filter((n) => n.status === "completed").length, activeNodes: allNodes.filter((n) => n.status === "active").length, claimableNodes: allNodes.filter((n) => n.status === "claimable").length, lockedNodes: allNodes.filter((n) => n.status === "locked").length, totalXP: allNodes.reduce((s, n) => s + n.reward.xp, 0), earnedXP, arcsCompleted: arcs.filter((a) => a.nodes.every((n) => n.status === "completed"), ).length, totalArcs: arcs.length, earnedTitles: allNodes .filter((n) => n.status === "completed" && n.reward.title) .map((n) => n.reward.title!), crewRank: getCrewRank(arcs), }; } export function getClaimableCount(arcs: QuestArc[]): number { return arcs.flatMap((a) => a.nodes).filter((n) => n.status === "claimable") .length; } export function getNode( arcs: QuestArc[], nodeId: string, ): QuestNode | undefined { return arcs.flatMap((a) => a.nodes).find((n) => n.id === nodeId); } export function getActiveArc(arcs: QuestArc[], activeArcId: string): QuestArc { return arcs.find((a) => a.id === activeArcId) ?? arcs[0]; }