feat(treasure): add treasure quest, quest modal, island node, quest widget

This commit is contained in:
shafin-r
2026-02-26 01:31:48 +06:00
parent 894863c196
commit f64d2cac4a
12 changed files with 4018 additions and 19 deletions

165
src/stores/useQuestStore.ts Normal file
View 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; // 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];
}