diff --git a/frontend/src/features/liberation/pc/Destiny.jsx b/frontend/src/features/liberation/pc/Destiny.jsx index 51769b1..6d92049 100644 --- a/frontend/src/features/liberation/pc/Destiny.jsx +++ b/frontend/src/features/liberation/pc/Destiny.jsx @@ -1,31 +1,73 @@ +import { useState, useMemo } from 'react' import dayjs from 'dayjs' import { DESTINY_CHAPTERS, + DESTINY_TOTAL, DESTINY_QUEST_IMAGE_BASE, DESTINY_BOSSES, DESTINY_BOSS_IMAGE_BASE, formatDate, } from '../data' import { useLiberationStore, makeEmptyDestinyWeekly } from '../store' -import { calcWeekPoints } from '../utils' +import { calcWeekPoints, calcDoneEarn, computeCompletionDate } 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' +import ConfirmDialog from '../../../components/common/ConfirmDialog' export default function Destiny() { const calcMode = useLiberationStore((s) => s.destinyCalcMode) const setCalcMode = useLiberationStore((s) => s.setDestinyCalcMode) const state = useLiberationStore((s) => s.destinyCalcMode === 'weekly' ? s.destinyWeekly : s.destinySimple) const updateSlot = useLiberationStore((s) => s.updateDestinySlot) + const resetSlot = useLiberationStore((s) => s.resetDestinySlot) const setState = (updater) => updateSlot(updater) + // 포인트 이월: 현재 퀘스트 required 를 초과하면 다음 퀘스트로 넘어감 + const priorConsumed = DESTINY_CHAPTERS + .slice(0, state.startChapter) + .reduce((s, c) => s + c.required, 0) + let cascadeIdx = state.startChapter + let cascadeRemain = state.currentPoints + let cascadeConsumed = 0 + while (cascadeIdx < DESTINY_CHAPTERS.length && cascadeRemain >= DESTINY_CHAPTERS[cascadeIdx].required) { + cascadeConsumed += DESTINY_CHAPTERS[cascadeIdx].required + cascadeRemain -= DESTINY_CHAPTERS[cascadeIdx].required + cascadeIdx++ + } + const initialAccumulated = priorConsumed + cascadeConsumed + cascadeRemain + const alreadyDone = initialAccumulated >= DESTINY_TOTAL + const remaining = Math.max(DESTINY_TOTAL - initialAccumulated, 0) + const weeklyEarn = calcWeekPoints(state.weekly, DESTINY_BOSSES) + const doneEarn = calcDoneEarn(state.weekly, DESTINY_BOSSES) + const headerWeekly = calcMode === 'weekly' ? (state.schedulerWeeks || []).reduce((s, w) => s + calcWeekPoints(w.config, DESTINY_BOSSES), 0) : weeklyEarn + const completionDate = useMemo( + () => computeCompletionDate({ + calcMode, state, alreadyDone, remaining, + weeklyEarn, doneEarn, + monthlyEarn: 0, + monthlyDoneThisMonth: false, + bosses: DESTINY_BOSSES, + monthlyBoss: null, + makeEmptyConfig: makeEmptyDestinyWeekly, + }), + [calcMode, state, alreadyDone, remaining, weeklyEarn, doneEarn], + ) + const isDone = completionDate !== null + + const [resetOpen, setResetOpen] = useState(false) + const doReset = () => { + resetSlot() + setResetOpen(false) + } + return ( <> {/* 계산 모드 탭 */} @@ -65,7 +107,7 @@ export default function Destiny() { imageBase={DESTINY_QUEST_IMAGE_BASE} startChapter={state.startChapter} currentPoints={state.currentPoints} - completionDate={null} + completionDate={isDone ? formatDate(completionDate) : null} completionColor="var(--destiny-date)" /> @@ -136,12 +178,40 @@ export default function Destiny() { weekly={state.weekly} onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))} totalWeekly={headerWeekly} - remaining={0} + remaining={remaining} mode={calcMode} startDate={state.startDate} weeks={state.schedulerWeeks} onChangeWeeks={(w) => setState((prev) => ({ ...prev, schedulerWeeks: w }))} /> + +
+ +
+ + setResetOpen(false)} + onConfirm={doReset} + title="전체 초기화" + description={`${calcMode === 'simple' ? '일반' : '주차별'} 모드의 입력을 모두 초기화하시겠습니까?\n\n시작 날짜, 현재 진행 상태, 주간 보스 설정이 모두 초기값으로 되돌아갑니다.\n다른 모드의 값은 유지됩니다.`} + confirmText="초기화" + destructive + /> ) } diff --git a/frontend/src/features/liberation/pc/Genesis.jsx b/frontend/src/features/liberation/pc/Genesis.jsx index 9b45394..6f3621e 100644 --- a/frontend/src/features/liberation/pc/Genesis.jsx +++ b/frontend/src/features/liberation/pc/Genesis.jsx @@ -132,6 +132,7 @@ export default function Genesis() { startChapter={state.startChapter} currentPoints={state.currentPoints} completionDate={isDone ? formatDate(completionDate) : null} + completionColor="var(--genesis-date)" /> {/* 현재 진행 상태 입력 */} diff --git a/frontend/src/features/liberation/utils.js b/frontend/src/features/liberation/utils.js index 90177c9..4f54ea4 100644 --- a/frontend/src/features/liberation/utils.js +++ b/frontend/src/features/liberation/utils.js @@ -64,6 +64,9 @@ export function getSchedulerWeekRange(startDateStr, weekIdx) { export function computeCompletionDate({ calcMode, state, alreadyDone, remaining, weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth, + bosses = WEEKLY_BOSSES, + monthlyBoss = MONTHLY_BOSSES[0], + makeEmptyConfig = makeEmptyWeekly, }) { if (alreadyDone) return todayKST() if (remaining <= 0) return dayjs(state.startDate).tz(KST).startOf('day').toDate() @@ -78,50 +81,52 @@ export function computeCompletionDate({ const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow // 1주차: 시작일 당일에 (주간 - done) 적립 - const week1Cfg = sw[0]?.config || makeEmptyWeekly() - const w1Weekly = calcWeekPoints(week1Cfg) - const w1Done = calcDoneEarn(week1Cfg) + const week1Cfg = sw[0]?.config || makeEmptyConfig() + const w1Weekly = calcWeekPoints(week1Cfg, bosses) + const w1Done = calcDoneEarn(week1Cfg, bosses) events.push({ date: startKST, amount: Math.max(w1Weekly - w1Done, 0) }) // 2주차 이후: 각 목요일에 해당 주차 설정의 주간 합 적립 // 마지막 주차 이후로는 마지막 주차 설정 반복 적용 let nextThu = startKST.add(daysToNextThu, 'day') for (let i = 1; i < 520; i++) { - const cfg = sw[i]?.config || sw[sw.length - 1]?.config || makeEmptyWeekly() - events.push({ date: nextThu, amount: calcWeekPoints(cfg) }) + const cfg = sw[i]?.config || sw[sw.length - 1]?.config || makeEmptyConfig() + events.push({ date: nextThu, amount: calcWeekPoints(cfg, bosses) }) nextThu = nextThu.add(1, 'week') } - // 검은 마법사: 슬롯 배정에 따라 해당 주차 첫날(or 1주차이면 시작일)에 적립 + // 월간 보스: 슬롯 배정에 따라 해당 주차 첫날(or 1주차이면 시작일)에 적립 const claimed = {} - sw.forEach((w, i) => { - const diff = w.config.blackMage?.difficulty - if (!diff || diff === 'none') return - const range = getSchedulerWeekRange(state.startDate, i + 1) - const months = [range.start.format('YYYY-MM'), range.end.format('YYYY-MM')] - for (const m of months) { - if (!(m in claimed)) { - claimed[m] = { - weekIdx: i, - earn: bossEarn(MONTHLY_BOSSES[0], w.config.blackMage), - done: !!w.config.blackMage.done, + if (monthlyBoss) { + sw.forEach((w, i) => { + const diff = w.config.blackMage?.difficulty + if (!diff || diff === 'none') return + const range = getSchedulerWeekRange(state.startDate, i + 1) + const months = [range.start.format('YYYY-MM'), range.end.format('YYYY-MM')] + for (const m of months) { + if (!(m in claimed)) { + claimed[m] = { + weekIdx: i, + earn: bossEarn(monthlyBoss, w.config.blackMage), + done: !!w.config.blackMage.done, + } + return } - return } - } - }) - Object.entries(claimed).forEach(([, info]) => { - if (info.done) return - const wIdx = info.weekIdx - const date = wIdx === 0 - ? startKST - : startKST.add(daysToNextThu + (wIdx - 1) * 7, 'day') - events.push({ date, amount: info.earn }) - }) + }) + Object.entries(claimed).forEach(([, info]) => { + if (info.done) return + const wIdx = info.weekIdx + const date = wIdx === 0 + ? startKST + : startKST.add(daysToNextThu + (wIdx - 1) * 7, 'day') + events.push({ date, amount: info.earn }) + }) + } - // 마지막 주차 이후로는 마지막 주차의 검은 마법사 설정을 매월 반복 적용 + // 마지막 주차 이후로는 마지막 주차의 월간 설정을 매월 반복 적용 const lastCfg = sw[sw.length - 1]?.config - const lastBmEarn = lastCfg ? bossEarn(MONTHLY_BOSSES[0], lastCfg.blackMage) : 0 + const lastBmEarn = monthlyBoss && lastCfg ? bossEarn(monthlyBoss, lastCfg.blackMage) : 0 if (lastBmEarn > 0) { const lastWeekStart = sw.length === 1 ? startKST @@ -166,9 +171,20 @@ export function computeCompletionDate({ events.sort((a, b) => a.date.diff(b.date)) let cumulative = 0 + let lastEventDate = startKST for (const e of events) { cumulative += e.amount + lastEventDate = e.date if (cumulative >= remaining) return e.date.toDate() } - return null + + // 10년 loop 내에 도달 못 한 경우: 정상 상태 주간 획득량으로 선형 외삽 + // 단순 모드: weeklyEarn / 주차별 모드: 마지막 주차 설정의 주간 합 + const steadyWeekly = calcMode === 'simple' + ? weeklyEarn + : calcWeekPoints((state.schedulerWeeks || []).slice(-1)[0]?.config || makeEmptyConfig(), bosses) + if (steadyWeekly <= 0) return null + const deficit = remaining - cumulative + const weeksNeeded = Math.ceil(deficit / steadyWeekly) + return lastEventDate.add(weeksNeeded * 7, 'day').toDate() } diff --git a/frontend/src/index.css b/frontend/src/index.css index 181b68c..de69ef1 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -105,6 +105,7 @@ --warning-text-bright: #fcd34d; --warning-text-dim: rgba(252, 211, 77, 0.4); + --genesis-date: #fcd34d; --destiny-date: #38bdf8; --progress-track: #0f172a; @@ -258,7 +259,8 @@ --warning-text-bright: #ea580c; --warning-text-dim: rgba(234, 88, 12, 0.4); - --destiny-date: #0284c7; + --genesis-date: #f59e0b; + --destiny-date: #0ea5e9; --progress-track: #e5e7eb; --progress-emerald: #10b981;