166 lines
5.2 KiB
TypeScript
166 lines
5.2 KiB
TypeScript
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<QuestStore>()(
|
||
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];
|
||
}
|