Files
edbridge-scholars/src/stores/useQuestStore.ts

166 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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; // 01 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];
}