데스티니 해방 계산 로직 완성 + 완료일 색상 정리
- 포인트 이월(cascade), 남은 포인트, 예상 해방일 계산을 Destiny에 연결 (computeCompletionDate에 bosses/monthlyBoss/makeEmptyConfig 파라미터 추가) - loop 후에도 미달이면 정상 상태 주간 획득량으로 선형 외삽해서 날짜 반환. Genesis·Destiny 모두 적용 (제네시스도 낮은 설정에서 '미정' 떴던 버그 해결) - 전체 초기화 버튼 + ConfirmDialog 데스티니에도 추가 - --genesis-date / --destiny-date 토큰 분리. 다크는 amber-300 / sky-400, 라이트는 가독성을 위해 amber-500 / sky-500로 한 톤 어둡게 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ee30c87518
commit
1ee3f19f4f
4 changed files with 124 additions and 35 deletions
|
|
@ -1,31 +1,73 @@
|
||||||
|
import { useState, useMemo } from 'react'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import {
|
import {
|
||||||
DESTINY_CHAPTERS,
|
DESTINY_CHAPTERS,
|
||||||
|
DESTINY_TOTAL,
|
||||||
DESTINY_QUEST_IMAGE_BASE,
|
DESTINY_QUEST_IMAGE_BASE,
|
||||||
DESTINY_BOSSES,
|
DESTINY_BOSSES,
|
||||||
DESTINY_BOSS_IMAGE_BASE,
|
DESTINY_BOSS_IMAGE_BASE,
|
||||||
formatDate,
|
formatDate,
|
||||||
} from '../data'
|
} from '../data'
|
||||||
import { useLiberationStore, makeEmptyDestinyWeekly } from '../store'
|
import { useLiberationStore, makeEmptyDestinyWeekly } from '../store'
|
||||||
import { calcWeekPoints } from '../utils'
|
import { calcWeekPoints, calcDoneEarn, computeCompletionDate } from '../utils'
|
||||||
import ProgressBar from './components/ProgressBar'
|
import ProgressBar from './components/ProgressBar'
|
||||||
import QuestSelector from './components/QuestSelector'
|
import QuestSelector from './components/QuestSelector'
|
||||||
import PointsInput from './components/PointsInput'
|
import PointsInput from './components/PointsInput'
|
||||||
import WeeklyDefault from './components/WeeklyDefault'
|
import WeeklyDefault from './components/WeeklyDefault'
|
||||||
import DatePicker from '../../../components/common/DatePicker'
|
import DatePicker from '../../../components/common/DatePicker'
|
||||||
|
import ConfirmDialog from '../../../components/common/ConfirmDialog'
|
||||||
|
|
||||||
export default function Destiny() {
|
export default function Destiny() {
|
||||||
const calcMode = useLiberationStore((s) => s.destinyCalcMode)
|
const calcMode = useLiberationStore((s) => s.destinyCalcMode)
|
||||||
const setCalcMode = useLiberationStore((s) => s.setDestinyCalcMode)
|
const setCalcMode = useLiberationStore((s) => s.setDestinyCalcMode)
|
||||||
const state = useLiberationStore((s) => s.destinyCalcMode === 'weekly' ? s.destinyWeekly : s.destinySimple)
|
const state = useLiberationStore((s) => s.destinyCalcMode === 'weekly' ? s.destinyWeekly : s.destinySimple)
|
||||||
const updateSlot = useLiberationStore((s) => s.updateDestinySlot)
|
const updateSlot = useLiberationStore((s) => s.updateDestinySlot)
|
||||||
|
const resetSlot = useLiberationStore((s) => s.resetDestinySlot)
|
||||||
const setState = (updater) => updateSlot(updater)
|
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 weeklyEarn = calcWeekPoints(state.weekly, DESTINY_BOSSES)
|
||||||
|
const doneEarn = calcDoneEarn(state.weekly, DESTINY_BOSSES)
|
||||||
|
|
||||||
const headerWeekly = calcMode === 'weekly'
|
const headerWeekly = calcMode === 'weekly'
|
||||||
? (state.schedulerWeeks || []).reduce((s, w) => s + calcWeekPoints(w.config, DESTINY_BOSSES), 0)
|
? (state.schedulerWeeks || []).reduce((s, w) => s + calcWeekPoints(w.config, DESTINY_BOSSES), 0)
|
||||||
: weeklyEarn
|
: 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 계산 모드 탭 */}
|
{/* 계산 모드 탭 */}
|
||||||
|
|
@ -65,7 +107,7 @@ export default function Destiny() {
|
||||||
imageBase={DESTINY_QUEST_IMAGE_BASE}
|
imageBase={DESTINY_QUEST_IMAGE_BASE}
|
||||||
startChapter={state.startChapter}
|
startChapter={state.startChapter}
|
||||||
currentPoints={state.currentPoints}
|
currentPoints={state.currentPoints}
|
||||||
completionDate={null}
|
completionDate={isDone ? formatDate(completionDate) : null}
|
||||||
completionColor="var(--destiny-date)"
|
completionColor="var(--destiny-date)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -136,12 +178,40 @@ export default function Destiny() {
|
||||||
weekly={state.weekly}
|
weekly={state.weekly}
|
||||||
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
|
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
|
||||||
totalWeekly={headerWeekly}
|
totalWeekly={headerWeekly}
|
||||||
remaining={0}
|
remaining={remaining}
|
||||||
mode={calcMode}
|
mode={calcMode}
|
||||||
startDate={state.startDate}
|
startDate={state.startDate}
|
||||||
weeks={state.schedulerWeeks}
|
weeks={state.schedulerWeeks}
|
||||||
onChangeWeeks={(w) => setState((prev) => ({ ...prev, schedulerWeeks: w }))}
|
onChangeWeeks={(w) => setState((prev) => ({ ...prev, schedulerWeeks: w }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="max-w-3xl mx-auto flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setResetOpen(true)}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border px-5 py-2.5 text-sm font-semibold hover:bg-[var(--danger-bg-hover)]"
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--icon-danger-border)',
|
||||||
|
background: 'var(--icon-danger-bg)',
|
||||||
|
color: 'var(--danger-text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M2 3H14M6 3V2C6 1.45 6.45 1 7 1H9C9.55 1 10 1.45 10 2V3M3 3L4 14C4 14.55 4.45 15 5 15H11C11.55 15 12 14.55 12 14L13 3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
전체 초기화
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={resetOpen}
|
||||||
|
onClose={() => setResetOpen(false)}
|
||||||
|
onConfirm={doReset}
|
||||||
|
title="전체 초기화"
|
||||||
|
description={`${calcMode === 'simple' ? '일반' : '주차별'} 모드의 입력을 모두 초기화하시겠습니까?\n\n시작 날짜, 현재 진행 상태, 주간 보스 설정이 모두 초기값으로 되돌아갑니다.\n다른 모드의 값은 유지됩니다.`}
|
||||||
|
confirmText="초기화"
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,7 @@ export default function Genesis() {
|
||||||
startChapter={state.startChapter}
|
startChapter={state.startChapter}
|
||||||
currentPoints={state.currentPoints}
|
currentPoints={state.currentPoints}
|
||||||
completionDate={isDone ? formatDate(completionDate) : null}
|
completionDate={isDone ? formatDate(completionDate) : null}
|
||||||
|
completionColor="var(--genesis-date)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 현재 진행 상태 입력 */}
|
{/* 현재 진행 상태 입력 */}
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,9 @@ export function getSchedulerWeekRange(startDateStr, weekIdx) {
|
||||||
export function computeCompletionDate({
|
export function computeCompletionDate({
|
||||||
calcMode, state, alreadyDone, remaining,
|
calcMode, state, alreadyDone, remaining,
|
||||||
weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth,
|
weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth,
|
||||||
|
bosses = WEEKLY_BOSSES,
|
||||||
|
monthlyBoss = MONTHLY_BOSSES[0],
|
||||||
|
makeEmptyConfig = makeEmptyWeekly,
|
||||||
}) {
|
}) {
|
||||||
if (alreadyDone) return todayKST()
|
if (alreadyDone) return todayKST()
|
||||||
if (remaining <= 0) return dayjs(state.startDate).tz(KST).startOf('day').toDate()
|
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
|
const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
|
||||||
|
|
||||||
// 1주차: 시작일 당일에 (주간 - done) 적립
|
// 1주차: 시작일 당일에 (주간 - done) 적립
|
||||||
const week1Cfg = sw[0]?.config || makeEmptyWeekly()
|
const week1Cfg = sw[0]?.config || makeEmptyConfig()
|
||||||
const w1Weekly = calcWeekPoints(week1Cfg)
|
const w1Weekly = calcWeekPoints(week1Cfg, bosses)
|
||||||
const w1Done = calcDoneEarn(week1Cfg)
|
const w1Done = calcDoneEarn(week1Cfg, bosses)
|
||||||
events.push({ date: startKST, amount: Math.max(w1Weekly - w1Done, 0) })
|
events.push({ date: startKST, amount: Math.max(w1Weekly - w1Done, 0) })
|
||||||
|
|
||||||
// 2주차 이후: 각 목요일에 해당 주차 설정의 주간 합 적립
|
// 2주차 이후: 각 목요일에 해당 주차 설정의 주간 합 적립
|
||||||
// 마지막 주차 이후로는 마지막 주차 설정 반복 적용
|
// 마지막 주차 이후로는 마지막 주차 설정 반복 적용
|
||||||
let nextThu = startKST.add(daysToNextThu, 'day')
|
let nextThu = startKST.add(daysToNextThu, 'day')
|
||||||
for (let i = 1; i < 520; i++) {
|
for (let i = 1; i < 520; i++) {
|
||||||
const cfg = sw[i]?.config || sw[sw.length - 1]?.config || makeEmptyWeekly()
|
const cfg = sw[i]?.config || sw[sw.length - 1]?.config || makeEmptyConfig()
|
||||||
events.push({ date: nextThu, amount: calcWeekPoints(cfg) })
|
events.push({ date: nextThu, amount: calcWeekPoints(cfg, bosses) })
|
||||||
nextThu = nextThu.add(1, 'week')
|
nextThu = nextThu.add(1, 'week')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 검은 마법사: 슬롯 배정에 따라 해당 주차 첫날(or 1주차이면 시작일)에 적립
|
// 월간 보스: 슬롯 배정에 따라 해당 주차 첫날(or 1주차이면 시작일)에 적립
|
||||||
const claimed = {}
|
const claimed = {}
|
||||||
sw.forEach((w, i) => {
|
if (monthlyBoss) {
|
||||||
const diff = w.config.blackMage?.difficulty
|
sw.forEach((w, i) => {
|
||||||
if (!diff || diff === 'none') return
|
const diff = w.config.blackMage?.difficulty
|
||||||
const range = getSchedulerWeekRange(state.startDate, i + 1)
|
if (!diff || diff === 'none') return
|
||||||
const months = [range.start.format('YYYY-MM'), range.end.format('YYYY-MM')]
|
const range = getSchedulerWeekRange(state.startDate, i + 1)
|
||||||
for (const m of months) {
|
const months = [range.start.format('YYYY-MM'), range.end.format('YYYY-MM')]
|
||||||
if (!(m in claimed)) {
|
for (const m of months) {
|
||||||
claimed[m] = {
|
if (!(m in claimed)) {
|
||||||
weekIdx: i,
|
claimed[m] = {
|
||||||
earn: bossEarn(MONTHLY_BOSSES[0], w.config.blackMage),
|
weekIdx: i,
|
||||||
done: !!w.config.blackMage.done,
|
earn: bossEarn(monthlyBoss, w.config.blackMage),
|
||||||
|
done: !!w.config.blackMage.done,
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
})
|
Object.entries(claimed).forEach(([, info]) => {
|
||||||
Object.entries(claimed).forEach(([, info]) => {
|
if (info.done) return
|
||||||
if (info.done) return
|
const wIdx = info.weekIdx
|
||||||
const wIdx = info.weekIdx
|
const date = wIdx === 0
|
||||||
const date = wIdx === 0
|
? startKST
|
||||||
? startKST
|
: startKST.add(daysToNextThu + (wIdx - 1) * 7, 'day')
|
||||||
: startKST.add(daysToNextThu + (wIdx - 1) * 7, 'day')
|
events.push({ date, amount: info.earn })
|
||||||
events.push({ date, amount: info.earn })
|
})
|
||||||
})
|
}
|
||||||
|
|
||||||
// 마지막 주차 이후로는 마지막 주차의 검은 마법사 설정을 매월 반복 적용
|
// 마지막 주차 이후로는 마지막 주차의 월간 설정을 매월 반복 적용
|
||||||
const lastCfg = sw[sw.length - 1]?.config
|
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) {
|
if (lastBmEarn > 0) {
|
||||||
const lastWeekStart = sw.length === 1
|
const lastWeekStart = sw.length === 1
|
||||||
? startKST
|
? startKST
|
||||||
|
|
@ -166,9 +171,20 @@ export function computeCompletionDate({
|
||||||
|
|
||||||
events.sort((a, b) => a.date.diff(b.date))
|
events.sort((a, b) => a.date.diff(b.date))
|
||||||
let cumulative = 0
|
let cumulative = 0
|
||||||
|
let lastEventDate = startKST
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
cumulative += e.amount
|
cumulative += e.amount
|
||||||
|
lastEventDate = e.date
|
||||||
if (cumulative >= remaining) return e.date.toDate()
|
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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@
|
||||||
--warning-text-bright: #fcd34d;
|
--warning-text-bright: #fcd34d;
|
||||||
--warning-text-dim: rgba(252, 211, 77, 0.4);
|
--warning-text-dim: rgba(252, 211, 77, 0.4);
|
||||||
|
|
||||||
|
--genesis-date: #fcd34d;
|
||||||
--destiny-date: #38bdf8;
|
--destiny-date: #38bdf8;
|
||||||
|
|
||||||
--progress-track: #0f172a;
|
--progress-track: #0f172a;
|
||||||
|
|
@ -258,7 +259,8 @@
|
||||||
--warning-text-bright: #ea580c;
|
--warning-text-bright: #ea580c;
|
||||||
--warning-text-dim: rgba(234, 88, 12, 0.4);
|
--warning-text-dim: rgba(234, 88, 12, 0.4);
|
||||||
|
|
||||||
--destiny-date: #0284c7;
|
--genesis-date: #f59e0b;
|
||||||
|
--destiny-date: #0ea5e9;
|
||||||
|
|
||||||
--progress-track: #e5e7eb;
|
--progress-track: #e5e7eb;
|
||||||
--progress-emerald: #10b981;
|
--progress-emerald: #10b981;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue