fix(api): fix api integration for quest map and adjacent components
This commit is contained in:
120
src/stores/useInventoryStore.ts
Normal file
120
src/stores/useInventoryStore.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import { create } from "zustand";
|
||||
import { persist, createJSONStorage } from "zustand/middleware";
|
||||
import type {
|
||||
InventoryItem,
|
||||
ActiveEffect,
|
||||
UserInventory,
|
||||
} from "../types/quest";
|
||||
|
||||
// ─── Store interface ──────────────────────────────────────────────────────────
|
||||
|
||||
interface InventoryStore {
|
||||
// Raw inventory from API
|
||||
items: InventoryItem[];
|
||||
activeEffects: ActiveEffect[];
|
||||
|
||||
// Loading / error
|
||||
loading: boolean;
|
||||
activatingId: string | null; // item id currently being activated
|
||||
error: string | null;
|
||||
lastActivatedId: string | null; // shows success state briefly
|
||||
|
||||
// Actions
|
||||
syncFromAPI: (inv: UserInventory) => void;
|
||||
activateItemOptimistic: (itemId: string) => void;
|
||||
activateItemSuccess: (inv: UserInventory, itemId: string) => void;
|
||||
activateItemError: (itemId: string, error: string) => void;
|
||||
clearLastActivated: () => void;
|
||||
setLoading: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export const useInventoryStore = create<InventoryStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
items: [],
|
||||
activeEffects: [],
|
||||
loading: false,
|
||||
activatingId: null,
|
||||
error: null,
|
||||
lastActivatedId: null,
|
||||
|
||||
syncFromAPI: (inv) =>
|
||||
set({ items: inv.items, activeEffects: inv.active_effects }),
|
||||
|
||||
// Optimistic — mark as "activating" immediately for instant UI feedback
|
||||
activateItemOptimistic: (itemId) =>
|
||||
set({ activatingId: itemId, error: null }),
|
||||
|
||||
// On API success — replace inventory with fresh server state
|
||||
activateItemSuccess: (inv, itemId) =>
|
||||
set({
|
||||
items: inv.items,
|
||||
activeEffects: inv.active_effects,
|
||||
activatingId: null,
|
||||
lastActivatedId: itemId,
|
||||
}),
|
||||
|
||||
activateItemError: (itemId, error) => set({ activatingId: null, error }),
|
||||
|
||||
clearLastActivated: () => set({ lastActivatedId: null }),
|
||||
|
||||
setLoading: (v) => set({ loading: v }),
|
||||
}),
|
||||
{
|
||||
name: "inventory-store",
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
// Persist items + active effects so the app can show active item banners
|
||||
// without waiting for a network request on every mount
|
||||
partialize: (state) => ({
|
||||
items: state.items,
|
||||
activeEffects: state.activeEffects,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// ─── Selector helpers (call these in components, not inside the store) ─────────
|
||||
|
||||
/** Returns true if any active effect has the given effect_type */
|
||||
export function hasActiveEffect(
|
||||
activeEffects: ActiveEffect[],
|
||||
effectType: string,
|
||||
): boolean {
|
||||
const now = Date.now();
|
||||
return activeEffects.some(
|
||||
(e) =>
|
||||
e.item.effect_type === effectType &&
|
||||
new Date(e.expires_at).getTime() > now,
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns the active effect for a given effect_type, or null */
|
||||
export function getActiveEffect(
|
||||
activeEffects: ActiveEffect[],
|
||||
effectType: string,
|
||||
): ActiveEffect | null {
|
||||
const now = Date.now();
|
||||
return (
|
||||
activeEffects.find(
|
||||
(e) =>
|
||||
e.item.effect_type === effectType &&
|
||||
new Date(e.expires_at).getTime() > now,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns all non-expired active effects */
|
||||
export function getLiveEffects(activeEffects: ActiveEffect[]): ActiveEffect[] {
|
||||
const now = Date.now();
|
||||
return activeEffects.filter((e) => new Date(e.expires_at).getTime() > now);
|
||||
}
|
||||
|
||||
/** Formats time remaining as "2h 14m" or "43m" */
|
||||
export function formatTimeLeft(expiresAt: string): string {
|
||||
const msLeft = new Date(expiresAt).getTime() - Date.now();
|
||||
if (msLeft <= 0) return "Expired";
|
||||
const totalMin = Math.floor(msLeft / 60_000);
|
||||
const h = Math.floor(totalMin / 60);
|
||||
const m = totalMin % 60;
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import { persist, createJSONStorage } from "zustand/middleware";
|
||||
import type { QuestArc, QuestNode, NodeStatus } from "../types/quest";
|
||||
import type { QuestArc, QuestNode } from "../types/quest";
|
||||
import { CREW_RANKS } from "../types/quest";
|
||||
import { QUEST_ARCS } from "../data/questData";
|
||||
|
||||
@ -21,24 +21,37 @@ export interface QuestSummary {
|
||||
activeNodes: number;
|
||||
claimableNodes: number;
|
||||
lockedNodes: number;
|
||||
totalXP: number;
|
||||
earnedXP: number;
|
||||
// totalXP removed — node definitions no longer carry an XP value.
|
||||
// Awarded XP only comes back from ClaimedRewardResponse at claim time.
|
||||
earnedXP: number; // accumulated from claim responses, stored in state
|
||||
arcsCompleted: number;
|
||||
totalArcs: number;
|
||||
earnedTitles: string[];
|
||||
earnedTitles: string[]; // accumulated from claim responses, stored in state
|
||||
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.
|
||||
// ─── Store ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface QuestStore {
|
||||
arcs: QuestArc[];
|
||||
activeArcId: string;
|
||||
// XP and titles are no longer derivable from node fields alone —
|
||||
// they come from ClaimedRewardResponse at claim time, so we track them here.
|
||||
earnedXP: number;
|
||||
earnedTitles: string[];
|
||||
|
||||
setActiveArc: (arcId: string) => void;
|
||||
claimNode: (arcId: string, nodeId: string) => void;
|
||||
/**
|
||||
* Call this after a successful /journey/claim API response.
|
||||
* Pass the xp and titles returned by ClaimedRewardResponse so the store
|
||||
* stays in sync without needing to re-fetch the whole journey.
|
||||
*/
|
||||
claimNode: (
|
||||
arcId: string,
|
||||
nodeId: string,
|
||||
xpAwarded?: number,
|
||||
titlesAwarded?: string[],
|
||||
) => void;
|
||||
syncFromAPI: (arcs: QuestArc[]) => void;
|
||||
}
|
||||
|
||||
@ -47,22 +60,29 @@ export const useQuestStore = create<QuestStore>()(
|
||||
(set) => ({
|
||||
arcs: QUEST_ARCS,
|
||||
activeArcId: QUEST_ARCS[0].id,
|
||||
earnedXP: 0,
|
||||
earnedTitles: [],
|
||||
|
||||
setActiveArc: (arcId) => set({ activeArcId: arcId }),
|
||||
|
||||
claimNode: (arcId, nodeId) =>
|
||||
claimNode: (arcId, nodeId, xpAwarded = 0, titlesAwarded = []) =>
|
||||
set((state) => ({
|
||||
// Accumulate XP and titles from the claim response
|
||||
earnedXP: state.earnedXP + xpAwarded,
|
||||
earnedTitles: [...state.earnedTitles, ...titlesAwarded],
|
||||
|
||||
arcs: state.arcs.map((arc) => {
|
||||
if (arc.id !== arcId) return arc;
|
||||
const nodeIdx = arc.nodes.findIndex((n) => n.id === nodeId);
|
||||
// node_id is the new primary key — replaces old n.id
|
||||
const nodeIdx = arc.nodes.findIndex((n) => n.node_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 (n.node_id === nodeId) return { ...n, status: "completed" };
|
||||
// Unlock the next locked node in sequence
|
||||
if (i === nodeIdx + 1 && n.status === "locked")
|
||||
return { ...n, status: "active" as NodeStatus };
|
||||
return { ...n, status: "active" };
|
||||
return n;
|
||||
}),
|
||||
};
|
||||
@ -77,34 +97,21 @@ export const useQuestStore = create<QuestStore>()(
|
||||
partialize: (state) => ({
|
||||
arcs: state.arcs,
|
||||
activeArcId: state.activeArcId,
|
||||
earnedXP: state.earnedXP,
|
||||
earnedTitles: state.earnedTitles,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// ─── 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);
|
||||
export function getCrewRank(earnedXP: number): CrewRank {
|
||||
// Accepts earnedXP directly — no longer iterates nodes (reward.xp is gone)
|
||||
const ladder = [...CREW_RANKS];
|
||||
let idx = 0;
|
||||
for (let i = ladder.length - 1; i >= 0; i--) {
|
||||
if (xp >= ladder[i].xpRequired) {
|
||||
if (earnedXP >= ladder[i].xpRequired) {
|
||||
idx = i;
|
||||
break;
|
||||
}
|
||||
@ -116,7 +123,7 @@ export function getCrewRank(arcs: QuestArc[]): CrewRank {
|
||||
progressToNext: nextRank
|
||||
? Math.min(
|
||||
1,
|
||||
(xp - current.xpRequired) /
|
||||
(earnedXP - current.xpRequired) /
|
||||
(nextRank.xpRequired - current.xpRequired),
|
||||
)
|
||||
: 1,
|
||||
@ -126,25 +133,25 @@ export function getCrewRank(arcs: QuestArc[]): CrewRank {
|
||||
};
|
||||
}
|
||||
|
||||
export function getQuestSummary(arcs: QuestArc[]): QuestSummary {
|
||||
export function getQuestSummary(
|
||||
arcs: QuestArc[],
|
||||
earnedXP: number,
|
||||
earnedTitles: string[],
|
||||
): 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),
|
||||
earnedTitles,
|
||||
crewRank: getCrewRank(earnedXP),
|
||||
};
|
||||
}
|
||||
|
||||
@ -153,11 +160,12 @@ export function getClaimableCount(arcs: QuestArc[]): number {
|
||||
.length;
|
||||
}
|
||||
|
||||
// node_id is the new primary key — replaces old n.id
|
||||
export function getNode(
|
||||
arcs: QuestArc[],
|
||||
nodeId: string,
|
||||
): QuestNode | undefined {
|
||||
return arcs.flatMap((a) => a.nodes).find((n) => n.id === nodeId);
|
||||
return arcs.flatMap((a) => a.nodes).find((n) => n.node_id === nodeId);
|
||||
}
|
||||
|
||||
export function getActiveArc(arcs: QuestArc[], activeArcId: string): QuestArc {
|
||||
|
||||
Reference in New Issue
Block a user