보스 수익/해방 계산기도 zustand로 전환

- boss-crystal/store.js: characters/selectedChar/selections + persist
- liberation/store.js: calcMode + simple/weekly slot + persist
- 세 스토어(symbol 포함)에서 version/migrate/구 localStorage 호환 코드 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-16 19:20:50 +09:00
parent df0bb7d14b
commit 2d43b78ce4
5 changed files with 143 additions and 136 deletions

View file

@ -1,36 +1,23 @@
import { useState, useEffect } from 'react' import { useEffect } from 'react'
import { useQuery, useQueries } from '@tanstack/react-query' import { useQuery, useQueries } from '@tanstack/react-query'
import { api } from '../../api/client' import { api } from '../../api/client'
import { useLayout } from '../../components/Layout' import { useLayout } from '../../components/Layout'
import CharacterPanel from './user/CharacterPanel' import CharacterPanel from './user/CharacterPanel'
import BossSelector from './user/BossSelector' import BossSelector from './user/BossSelector'
import { useBossStore } from './store'
const STORAGE_CHARS = 'maple-bc-characters'
const STORAGE_SELECTIONS = 'maple-bc-selections'
const MAX_PER_CHARACTER = 12 const MAX_PER_CHARACTER = 12
export default function BossCrystal() { export default function BossCrystal() {
const [characters, setCharacters] = useState(() => { const characters = useBossStore((s) => s.characters)
const saved = localStorage.getItem(STORAGE_CHARS) const selectedChar = useBossStore((s) => s.selectedChar)
return saved ? JSON.parse(saved) : [] const selections = useBossStore((s) => s.selections)
}) const addCharacter = useBossStore((s) => s.addCharacter)
const [selectedChar, setSelectedChar] = useState(() => { const removeCharacter = useBossStore((s) => s.removeCharacter)
const saved = localStorage.getItem(STORAGE_CHARS) const selectCharacter = useBossStore((s) => s.selectCharacter)
const list = saved ? JSON.parse(saved) : [] const reorderCharacters = useBossStore((s) => s.reorderCharacters)
return list[0]?.character_name || null const setBossSelection = useBossStore((s) => s.setBossSelection)
}) const updateCharacter = useBossStore((s) => s.updateCharacter)
const [allSelections, setAllSelections] = useState(() => {
const saved = localStorage.getItem(STORAGE_SELECTIONS)
return saved ? JSON.parse(saved) : {}
})
useEffect(() => {
localStorage.setItem(STORAGE_CHARS, JSON.stringify(characters))
}, [characters])
useEffect(() => {
localStorage.setItem(STORAGE_SELECTIONS, JSON.stringify(allSelections))
}, [allSelections])
// ( + ) // ( + )
const { setFullscreen } = useLayout() const { setFullscreen } = useLayout()
@ -44,7 +31,7 @@ export default function BossCrystal() {
queryFn: () => api('/api/boss-crystal/bosses').catch(() => []), queryFn: () => api('/api/boss-crystal/bosses').catch(() => []),
}) })
// ( ) //
const charRefreshQueries = useQueries({ const charRefreshQueries = useQueries({
queries: characters.map((c) => ({ queries: characters.map((c) => ({
queryKey: ['character', 'basic', c.character_name], queryKey: ['character', 'basic', c.character_name],
@ -57,63 +44,31 @@ export default function BossCrystal() {
}) })
useEffect(() => { useEffect(() => {
if (!charRefreshQueries.length) return characters.forEach((c, i) => {
let changed = false
const next = characters.map((c, i) => {
const d = charRefreshQueries[i]?.data const d = charRefreshQueries[i]?.data
if (!d) return c if (!d) return
if (d.character_image !== c.character_image || d.character_level !== c.character_level || d.job_name !== c.job_name) { if (d.character_image !== c.character_image || d.character_level !== c.character_level || d.job_name !== c.job_name) {
changed = true updateCharacter(c.character_name, {
return { ...c, ...d } character_image: d.character_image,
character_level: d.character_level,
job_name: d.job_name,
world_name: d.world_name,
ocid: d.ocid,
})
} }
return c
}) })
if (changed) setCharacters(next)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [charRefreshQueries.map((q) => q.dataUpdatedAt).join(',')]) }, [charRefreshQueries.map((q) => q.dataUpdatedAt).join(',')])
const handleAddCharacter = (char) => {
setCharacters((prev) => [...prev, char])
setSelectedChar(char.character_name)
}
const handleRemoveCharacter = (name) => {
setCharacters((prev) => {
const next = prev.filter((c) => c.character_name !== name)
if (selectedChar === name) {
setSelectedChar(next[0]?.character_name || null)
}
return next
})
setAllSelections((prev) => {
const next = { ...prev }
delete next[name]
return next
})
}
const handleReorderCharacters = (next) => {
setCharacters(next)
}
const handleBossChange = (bossId, sel) => { const handleBossChange = (bossId, sel) => {
if (!selectedChar) return if (!selectedChar) return
setAllSelections((prev) => { setBossSelection(selectedChar, bossId, sel)
const charSel = { ...(prev[selectedChar] || {}) }
if (sel === null) {
delete charSel[bossId]
} else {
charSel[bossId] = sel
}
return { ...prev, [selectedChar]: charSel }
})
} }
const currentSelections = selectedChar ? (allSelections[selectedChar] || {}) : {} const currentSelections = selectedChar ? (selections[selectedChar] || {}) : {}
const currentSelectedCount = Object.values(currentSelections).filter(Boolean).length const currentSelectedCount = Object.values(currentSelections).filter(Boolean).length
const isMaxReached = currentSelectedCount >= MAX_PER_CHARACTER const isMaxReached = currentSelectedCount >= MAX_PER_CHARACTER
return ( return (
<div className="h-full"> <div className="h-full">
{isLoading ? ( {isLoading ? (
@ -122,21 +77,19 @@ export default function BossCrystal() {
</div> </div>
) : ( ) : (
<div className="grid gap-4 lg:grid-cols-[420px_1fr] h-full min-h-0"> <div className="grid gap-4 lg:grid-cols-[420px_1fr] h-full min-h-0">
{/* 좌측: 캐릭터 + 결과 통합 (총 수익/추가 고정 + 목록 스크롤) */}
<div className="rounded-2xl border border-white/5 bg-gray-900/40 p-4 min-h-0 max-h-full self-start overflow-hidden flex flex-col"> <div className="rounded-2xl border border-white/5 bg-gray-900/40 p-4 min-h-0 max-h-full self-start overflow-hidden flex flex-col">
<CharacterPanel <CharacterPanel
characters={characters} characters={characters}
selectedName={selectedChar} selectedName={selectedChar}
allSelections={allSelections} allSelections={selections}
bosses={bosses} bosses={bosses}
onSelect={setSelectedChar} onSelect={selectCharacter}
onAdd={handleAddCharacter} onAdd={addCharacter}
onRemove={handleRemoveCharacter} onRemove={removeCharacter}
onReorder={handleReorderCharacters} onReorder={reorderCharacters}
/> />
</div> </div>
{/* 우측: 보스 선택 (헤더 고정 + 목록 스크롤) */}
<div className="min-h-0"> <div className="min-h-0">
<BossSelector <BossSelector
characterName={selectedChar} characterName={selectedChar}

View file

@ -0,0 +1,55 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
/**
* 보스 수익 계산기 상태
* characters: [{ character_name, character_image, character_level, job_name, ... }]
* selectedChar: 선택된 캐릭터 닉네임
* selections: { [character_name]: { [bossId]: { difficulty, party } } }
*/
export const useBossStore = create(persist(
(set) => ({
characters: [],
selectedChar: null,
selections: {},
setCharacters: (next) => set((s) => ({
characters: typeof next === 'function' ? next(s.characters) : next,
})),
addCharacter: (char) => set((s) => {
if (s.characters.find((c) => c.character_name === char.character_name)) return s
return {
characters: [...s.characters, char],
selectedChar: char.character_name,
}
}),
removeCharacter: (name) => set((s) => {
const next = s.characters.filter((c) => c.character_name !== name)
const nextSel = { ...s.selections }
delete nextSel[name]
return {
characters: next,
selections: nextSel,
selectedChar: s.selectedChar === name ? (next[0]?.character_name || null) : s.selectedChar,
}
}),
selectCharacter: (name) => set({ selectedChar: name }),
updateCharacter: (name, patch) => set((s) => ({
characters: s.characters.map((c) => (c.character_name === name ? { ...c, ...patch } : c)),
})),
reorderCharacters: (next) => set({ characters: next }),
setBossSelection: (charName, bossId, sel) => set((s) => {
const charSel = { ...(s.selections[charName] || {}) }
if (sel === null) delete charSel[bossId]
else charSel[bossId] = sel
return { selections: { ...s.selections, [charName]: charSel } }
}),
}),
{ name: 'maple-boss-crystal' },
))

View file

@ -11,6 +11,7 @@ import {
formatDate, formatDate,
todayKST, todayKST,
} from './data' } from './data'
import { useLiberationStore } from './store'
import QuestSelector from './components/QuestSelector' import QuestSelector from './components/QuestSelector'
import PointsInput from './components/PointsInput' import PointsInput from './components/PointsInput'
import ProgressBar from './components/ProgressBar' import ProgressBar from './components/ProgressBar'
@ -19,8 +20,6 @@ import DatePicker from '../../components/DatePicker'
import ConfirmDialog from '../../components/ConfirmDialog' import ConfirmDialog from '../../components/ConfirmDialog'
import { useLayout } from '../../components/Layout' import { useLayout } from '../../components/Layout'
const STORAGE_KEY = 'maple-liberation'
function makeEmptyWeekly() { function makeEmptyWeekly() {
const bosses = {} const bosses = {}
WEEKLY_BOSSES.forEach((b) => { WEEKLY_BOSSES.forEach((b) => {
@ -81,50 +80,12 @@ export default function Liberation() {
staleTime: Infinity, staleTime: Infinity,
}) })
const makeInitialSlot = () => ({ const calcMode = useLiberationStore((s) => s.calcMode)
startChapter: 0, const state = useLiberationStore((s) => s[s.calcMode])
currentPoints: 0, const setCalcMode = useLiberationStore((s) => s.setCalcMode)
startDate: dayjs(todayKST()).toISOString(), const updateSlot = useLiberationStore((s) => s.updateSlot)
weekly: makeEmptyWeekly(), const resetSlot = useLiberationStore((s) => s.resetSlot)
schedulerWeeks: [{ id: 1, config: makeEmptyWeekly() }], const setState = (updater) => updateSlot(updater)
})
const [root, setRoot] = useState(() => {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
try {
const parsed = JSON.parse(saved)
// ( slot)
if (!parsed.calcMode) {
if (!parsed.weekly) parsed.weekly = makeEmptyWeekly()
if (!parsed.startDate) parsed.startDate = dayjs(todayKST()).toISOString()
if (!parsed.schedulerWeeks) parsed.schedulerWeeks = [{ id: 1, config: makeEmptyWeekly() }]
return { calcMode: 'simple', simple: parsed, weekly: makeInitialSlot() }
}
;['simple', 'weekly'].forEach((k) => {
if (parsed[k] && !parsed[k].schedulerWeeks) {
parsed[k].schedulerWeeks = [{ id: 1, config: makeEmptyWeekly() }]
}
})
return parsed
} catch { /* ignore */ }
}
return { calcMode: 'simple', simple: makeInitialSlot(), weekly: makeInitialSlot() }
})
const calcMode = root.calcMode
const state = root[calcMode]
const setState = (updater) => {
setRoot((prev) => ({
...prev,
[prev.calcMode]: typeof updater === 'function' ? updater(prev[prev.calcMode]) : updater,
}))
}
const setCalcMode = (mode) => setRoot((prev) => ({ ...prev, calcMode: mode }))
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(root))
}, [root])
// : required // : required
const priorConsumed = GENESIS_CHAPTERS const priorConsumed = GENESIS_CHAPTERS
@ -305,7 +266,7 @@ export default function Liberation() {
const [resetOpen, setResetOpen] = useState(false) const [resetOpen, setResetOpen] = useState(false)
const doReset = () => { const doReset = () => {
setState(makeInitialSlot()) resetSlot()
setResetOpen(false) setResetOpen(false)
} }

View file

@ -0,0 +1,51 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import dayjs from 'dayjs'
import { WEEKLY_BOSSES, MONTHLY_BOSSES, todayKST } from './data'
function makeEmptyWeekly() {
const bosses = {}
WEEKLY_BOSSES.forEach((b) => {
bosses[b.key] = { difficulty: 'none', party: 1, done: false }
})
return {
bosses,
blackMage: { difficulty: 'none', party: 1, done: false },
}
}
function makeInitialSlot() {
return {
startChapter: 0,
currentPoints: 0,
startDate: dayjs(todayKST()).toISOString(),
weekly: makeEmptyWeekly(),
schedulerWeeks: [{ id: 1, config: makeEmptyWeekly() }],
}
}
/**
* 해방 계산기 상태
* calcMode: 'simple' | 'weekly'
* simple / weekly: 모드 독립 슬롯
*/
export const useLiberationStore = create(persist(
(set) => ({
calcMode: 'simple',
simple: makeInitialSlot(),
weekly: makeInitialSlot(),
setCalcMode: (mode) => set({ calcMode: mode }),
updateSlot: (patch) => set((s) => ({
[s.calcMode]: typeof patch === 'function'
? patch(s[s.calcMode])
: { ...s[s.calcMode], ...patch },
})),
resetSlot: () => set((s) => ({ [s.calcMode]: makeInitialSlot() })),
}),
{ name: 'maple-liberation' },
))
export { makeEmptyWeekly, makeInitialSlot }

View file

@ -105,18 +105,5 @@ export const useSymbolStore = create(persist(
return { progress: { ...s.progress, [charId]: charProg } } return { progress: { ...s.progress, [charId]: charProg } }
}), }),
}), }),
{ { name: 'maple-symbol' },
name: 'maple-symbol',
version: 2,
migrate: (persisted) => {
// 이전 버전(단순 characters/selectedCharId 저장) 마이그레이션
if (!persisted) return { characters: [], selectedCharId: null, progress: {} }
return {
characters: persisted.characters || [],
selectedCharId: persisted.selectedCharId ?? null,
progress: persisted.progress || {},
selectedTabs: persisted.selectedTabs || {},
}
},
},
)) ))