데스티니 해방에 단순 계산 모드 주간 보스 설정 추가

WeeklyDefault/BossRow를 bosses/imageBase/hasScheduler prop 받도록 일반화.
데스티니 단순 계산 모드에서 8개 보스의 난이도·파티 인원·완료 상태를 설정할
수 있도록 구현. 주차별 계산은 플레이스홀더.

store에 destiny weekly 슬롯 추가하고 v1 migrate로 기존 사용자의 localStorage
backfill 처리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-21 19:59:43 +09:00
parent d506c022ca
commit 99500d91af
5 changed files with 101 additions and 18 deletions

View file

@ -1,9 +1,17 @@
import dayjs from 'dayjs'
import { DESTINY_CHAPTERS, DESTINY_QUEST_IMAGE_BASE, formatDate } from '../data'
import {
DESTINY_CHAPTERS,
DESTINY_QUEST_IMAGE_BASE,
DESTINY_BOSSES,
DESTINY_BOSS_IMAGE_BASE,
formatDate,
} from '../data'
import { useLiberationStore } from '../store'
import { calcWeekPoints } from '../utils'
import ProgressBar from './components/ProgressBar'
import QuestSelector from './components/QuestSelector'
import PointsInput from './components/PointsInput'
import WeeklyDefault from './components/WeeklyDefault'
import DatePicker from '../../../components/common/DatePicker'
export default function Destiny() {
@ -13,6 +21,8 @@ export default function Destiny() {
const updateSlot = useLiberationStore((s) => s.updateDestinySlot)
const setState = (updater) => updateSlot(updater)
const weeklyEarn = calcWeekPoints(state.weekly, DESTINY_BOSSES)
return (
<>
{/* 계산 모드 탭 */}
@ -115,6 +125,31 @@ export default function Destiny() {
</div>
</div>
</div>
{calcMode === 'simple' ? (
<WeeklyDefault
bosses={DESTINY_BOSSES}
imageBase={DESTINY_BOSS_IMAGE_BASE}
weekly={state.weekly}
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
totalWeekly={weeklyEarn}
remaining={0}
mode="simple"
hasScheduler={false}
/>
) : (
<div
className="max-w-3xl mx-auto rounded-2xl border p-16 text-center"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
boxShadow: 'var(--panel-shadow)',
}}
>
<div className="text-xl font-bold" style={{ color: 'var(--text-emphasis)' }}>주차별 계산 준비 </div>
<div className="text-sm mt-2" style={{ color: 'var(--text-dim)' }}>단순 계산 탭을 이용해주세요.</div>
</div>
)}
</>
)
}

View file

@ -3,8 +3,10 @@ import dayjs from 'dayjs'
import {
GENESIS_CHAPTERS,
GENESIS_TOTAL,
WEEKLY_BOSSES,
MONTHLY_BOSSES,
QUEST_BOSS_IMAGE_BASE,
LIBERATION_BOSS_IMAGE_BASE,
formatDate,
} from '../data'
import { useLiberationStore } from '../store'
@ -193,6 +195,9 @@ export default function Genesis() {
</div>
<WeeklyDefault
bosses={WEEKLY_BOSSES}
monthlyBosses={MONTHLY_BOSSES}
imageBase={LIBERATION_BOSS_IMAGE_BASE}
weekly={state.weekly}
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
totalWeekly={headerWeekly}

View file

@ -1,7 +1,7 @@
import Select from '../../../../components/common/Select'
import Tooltip from '../../../../components/common/Tooltip'
import WeeklyScheduler from './WeeklyScheduler'
import { WEEKLY_BOSSES, MONTHLY_BOSSES, LIBERATION_BOSS_IMAGE_BASE, calcPoints } from '../../data'
import { calcPoints } from '../../data'
const PARTY_OPTIONS = [1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n}` }))
const NONE_DIFFICULTY = { key: 'none', label: '격파 불가', points: 0 }
@ -16,7 +16,7 @@ function diffLabel(d, party) {
)
}
export function BossRow({ boss, sel, onChange, monthly = false, showDone = true }) {
export function BossRow({ boss, sel, onChange, imageBase, monthly = false, showDone = true }) {
const disabled = sel.difficulty === 'none'
const difficultyOptions = [NONE_DIFFICULTY, ...boss.difficulties]
.map((d) => ({ value: d.key, label: diffLabel(d, sel.party) }))
@ -24,7 +24,7 @@ export function BossRow({ boss, sel, onChange, monthly = false, showDone = true
return (
<div className="flex items-center gap-3 rounded-lg px-3 h-16">
<Tooltip text={boss.name}>
<img src={`${LIBERATION_BOSS_IMAGE_BASE}/${boss.image}`} alt="" className="w-10 h-10 rounded-md object-cover shrink-0" />
<img src={`${imageBase}/${boss.image}`} alt="" className="w-10 h-10 rounded-md object-cover shrink-0" />
</Tooltip>
<span className="text-base font-semibold flex-1 truncate">
{boss.name}
@ -81,7 +81,22 @@ export function BossRow({ boss, sel, onChange, monthly = false, showDone = true
)
}
export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMonthly, remaining, mode = 'simple', startDate, weeks, onChangeWeeks }) {
export default function WeeklyDefault({
bosses,
monthlyBosses = [],
imageBase,
weekly,
onChange,
totalWeekly,
totalMonthly = 0,
remaining,
mode = 'simple',
startDate,
weeks,
onChangeWeeks,
hasScheduler = true,
label = '주간 보스 설정',
}) {
const updateBoss = (key, patch) => {
onChange({ ...weekly, bosses: { ...weekly.bosses, [key]: { ...weekly.bosses[key], ...patch } } })
}
@ -99,13 +114,17 @@ export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMont
}}
>
<div className="flex items-center justify-between">
<div className="text-lg font-semibold" style={{ color: 'var(--accent-bright)' }}>주간 보스 설정</div>
<div className="text-lg font-semibold" style={{ color: 'var(--accent-bright)' }}>{label}</div>
<div className="text-sm tabular-nums">
{mode === 'weekly' ? (
<>
<span className="font-semibold" style={{ color: 'var(--accent-bright)' }}>{totalWeekly}</span>
<span className="mx-1" style={{ color: 'var(--text-dim)' }}>+</span>
<span className="font-semibold" style={{ color: 'var(--warning-text-bright)' }}>{totalMonthly}</span>
{monthlyBosses.length > 0 && (
<>
<span className="mx-1" style={{ color: 'var(--text-dim)' }}>+</span>
<span className="font-semibold" style={{ color: 'var(--warning-text-bright)' }}>{totalMonthly}</span>
</>
)}
<span className="mx-1" style={{ color: 'var(--text-dim)' }}>/</span>
<span className="font-semibold" style={{ color: 'var(--text-emphasis)' }}>{(remaining ?? 0).toLocaleString()}</span>
</>
@ -115,9 +134,9 @@ export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMont
</div>
</div>
{mode === 'simple' ? (
{mode === 'simple' || !hasScheduler ? (
<div>
{WEEKLY_BOSSES.map((boss, i) => (
{bosses.map((boss, i) => (
<div
key={boss.key}
className={i > 0 ? 'border-t' : ''}
@ -127,10 +146,11 @@ export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMont
boss={boss}
sel={weekly.bosses[boss.key]}
onChange={(patch) => updateBoss(boss.key, patch)}
imageBase={imageBase}
/>
</div>
))}
{MONTHLY_BOSSES.map((boss) => (
{monthlyBosses.map((boss) => (
<div
key={boss.key}
className="border-t"
@ -140,6 +160,7 @@ export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMont
boss={boss}
sel={weekly.blackMage}
onChange={updateBlackMage}
imageBase={imageBase}
monthly
/>
</div>

View file

@ -1,7 +1,7 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import dayjs from 'dayjs'
import { WEEKLY_BOSSES, MONTHLY_BOSSES, todayKST } from './data'
import { WEEKLY_BOSSES, MONTHLY_BOSSES, DESTINY_BOSSES, todayKST } from './data'
function makeEmptyWeekly() {
const bosses = {}
@ -14,6 +14,14 @@ function makeEmptyWeekly() {
}
}
function makeEmptyDestinyWeekly() {
const bosses = {}
DESTINY_BOSSES.forEach((b) => {
bosses[b.key] = { difficulty: 'none', party: 1, done: false }
})
return { bosses }
}
function makeInitialSlot() {
return {
startChapter: 0,
@ -29,6 +37,7 @@ function makeInitialDestinySlot() {
startChapter: 0,
currentPoints: 0,
startDate: dayjs(todayKST()).toISOString(),
weekly: makeEmptyDestinyWeekly(),
}
}
@ -72,7 +81,20 @@ export const useLiberationStore = create(persist(
return { [key]: makeInitialDestinySlot() }
}),
}),
{ name: 'maple-liberation' },
{
name: 'maple-liberation',
version: 1,
migrate: (persisted) => {
if (!persisted) return persisted
// v0→v1: 데스티니 슬롯에 weekly 필드가 없으면 빈 값으로 채움
const fill = (slot) => slot ? { ...slot, weekly: slot.weekly || makeEmptyDestinyWeekly() } : slot
return {
...persisted,
destinySimple: fill(persisted.destinySimple),
destinyWeekly: fill(persisted.destinyWeekly),
}
},
},
))
export { makeEmptyWeekly, makeInitialSlot, makeInitialDestinySlot }
export { makeEmptyWeekly, makeEmptyDestinyWeekly, makeInitialSlot, makeInitialDestinySlot }

View file

@ -11,17 +11,17 @@ export function bossEarn(boss, sel) {
return calcPoints(d.points, sel.party)
}
export function calcWeekPoints(weekData) {
export function calcWeekPoints(weekData, bosses = WEEKLY_BOSSES) {
let points = 0
WEEKLY_BOSSES.forEach((b) => {
bosses.forEach((b) => {
points += bossEarn(b, weekData.bosses[b.key])
})
return points
}
export function calcDoneEarn(weekData) {
export function calcDoneEarn(weekData, bosses = WEEKLY_BOSSES) {
let points = 0
WEEKLY_BOSSES.forEach((b) => {
bosses.forEach((b) => {
const sel = weekData.bosses[b.key]
if (sel?.done) points += bossEarn(b, sel)
})