feat(treasure): add treasure quest, quest modal, island node, quest widget
This commit is contained in:
165
src/stores/useQuestStore.ts
Normal file
165
src/stores/useQuestStore.ts
Normal file
@ -0,0 +1,165 @@
|
||||
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];
|
||||
}
|
||||
Reference in New Issue
Block a user