diff --git a/frontend/src/features/liberation/Liberation.jsx b/frontend/src/features/liberation/Liberation.jsx index 7df661b..5252762 100644 --- a/frontend/src/features/liberation/Liberation.jsx +++ b/frontend/src/features/liberation/Liberation.jsx @@ -14,56 +14,90 @@ import WeekCard from './components/WeekCard' import QuestSelector from './components/QuestSelector' import PointsInput from './components/PointsInput' import ProgressBar from './components/ProgressBar' +import WeeklyDefault from './components/WeeklyDefault' import DatePicker from '../../components/DatePicker' const STORAGE_KEY = 'maple-liberation' function makeEmptyWeek(startDate) { - const bosses = {} - WEEKLY_BOSSES.forEach((b) => { - bosses[b.key] = { - enabled: false, - difficulty: b.difficulties[0].key, - party: 1, - } - }) return { startDate: dayjs(startDate).toISOString(), - bosses, - blackMage: { - enabled: false, - difficulty: MONTHLY_BOSSES[0].difficulties[0].key, - party: 1, - }, + ...makeEmptyWeekly(), } } +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 bossEarn(boss, sel) { + if (!sel) return 0 + const d = boss.difficulties.find((x) => x.key === sel.difficulty) + if (!d) return 0 + return calcPoints(d.points, sel.party) +} + function calcWeekPoints(weekData) { let points = 0 WEEKLY_BOSSES.forEach((b) => { - const sel = weekData.bosses[b.key] - if (!sel?.enabled) return - const diff = b.difficulties.find((d) => d.key === sel.difficulty) - if (!diff) return - points += calcPoints(diff.points, sel.party) + points += bossEarn(b, weekData.bosses[b.key]) }) - if (weekData.blackMage?.enabled) { - const bm = MONTHLY_BOSSES[0] - const diff = bm.difficulties.find((d) => d.key === weekData.blackMage.difficulty) - if (diff) points += calcPoints(diff.points, weekData.blackMage.party) - } return points } +function calcDoneEarn(weekData) { + let points = 0 + WEEKLY_BOSSES.forEach((b) => { + const sel = weekData.bosses[b.key] + if (sel?.done) points += bossEarn(b, sel) + }) + return points +} + +function calcMonthlyEarn(weekData) { + return bossEarn(MONTHLY_BOSSES[0], weekData.blackMage) +} + +function calcMonthlyDoneEarn(weekData) { + return weekData.blackMage?.done ? bossEarn(MONTHLY_BOSSES[0], weekData.blackMage) : 0 +} + export default function Liberation() { const [state, setState] = useState(() => { const saved = localStorage.getItem(STORAGE_KEY) if (saved) { - try { return JSON.parse(saved) } catch { /* ignore */ } + try { + const parsed = JSON.parse(saved) + if (!parsed.weekly) parsed.weekly = makeEmptyWeekly() + if (!parsed.startDate) parsed.startDate = dayjs(todayKST()).toISOString() + // enabled/'none' 필드 제거 마이그레이션 + const migrate = (sel, defaultDiff) => { + if (!sel) return sel + if (!sel.difficulty || sel.difficulty === 'none') sel.difficulty = defaultDiff + delete sel.enabled + return sel + } + WEEKLY_BOSSES.forEach((b) => { + if (parsed.weekly.bosses?.[b.key]) { + parsed.weekly.bosses[b.key] = migrate(parsed.weekly.bosses[b.key], b.difficulties[0].key) + } + }) + parsed.weekly.blackMage = migrate(parsed.weekly.blackMage, MONTHLY_BOSSES[0].difficulties[0].key) + return parsed + } catch { /* ignore */ } } return { startChapter: 0, currentPoints: 0, + startDate: dayjs(todayKST()).toISOString(), + weekly: makeEmptyWeekly(), weeks: [makeEmptyWeek(todayKST())], } }) @@ -112,18 +146,58 @@ export default function Liberation() { return result }, [state]) - const initialCap = GENESIS_CHAPTERS[state.startChapter]?.required ?? 0 - const initialClamped = Math.min(state.currentPoints, initialCap) + const currentChapterCap = GENESIS_CHAPTERS[state.startChapter]?.required ?? 0 + const currentChapterFilled = Math.min(state.currentPoints, currentChapterCap) const initialAccumulated = GENESIS_CHAPTERS .slice(0, state.startChapter) - .reduce((s, c) => s + c.required, 0) + initialClamped + .reduce((s, c) => s + c.required, 0) + currentChapterFilled const alreadyDone = initialAccumulated >= GENESIS_TOTAL - const completedWeekIdx = progressByWeek.findIndex((w) => w.completed) - const isDone = alreadyDone || completedWeekIdx >= 0 + const weeklyEarn = calcWeekPoints(state.weekly) + const remaining = Math.max(GENESIS_TOTAL - initialAccumulated, 0) + // 주간/월간 분리 계산 + const doneEarn = calcDoneEarn(state.weekly) + const monthlyEarn = calcMonthlyEarn(state.weekly) + const monthlyDoneEarn = calcMonthlyDoneEarn(state.weekly) + + function monthResetsBetween(start, end) { + let count = 0 + let cursor = start.add(1, 'month').startOf('month') + while (!cursor.isAfter(end)) { + count++ + cursor = cursor.add(1, 'month').startOf('month') + } + return count + } + + const startKST = dayjs(state.startDate).tz('Asia/Seoul') + const currentMonthBMAvailable = monthlyEarn > 0 && monthlyDoneEarn === 0 ? monthlyEarn : 0 + const adjustedRemaining = remaining + doneEarn + monthlyDoneEarn + let weeksNeeded = null + if (!alreadyDone && (weeklyEarn > 0 || monthlyEarn > 0)) { + for (let N = 1; N <= 520; N++) { + const weeklyCum = N * weeklyEarn + const endKST = startKST.add(N, 'week') + const monthlyCum = currentMonthBMAvailable + monthResetsBetween(startKST, endKST) * monthlyEarn + if (weeklyCum + monthlyCum >= adjustedRemaining) { + weeksNeeded = N + break + } + } + } + + // 시작 날짜 기준 다음(또는 당일) 목요일 + const startDay = dayjs(state.startDate).tz('Asia/Seoul') + const dow = startDay.day() // 0=일 ... 4=목 + const daysToThu = dow <= 4 ? 4 - dow : 11 - dow + const upcomingThursday = startDay.add(daysToThu, 'day').startOf('day') + + const isDone = alreadyDone || weeksNeeded !== null + // 시작일의 주 = 1주차, 다음 목요일 = 2주차 시작 + // 완료일 = N주차의 목요일 = upcomingThursday + (weeksNeeded - 2) * 7일 const completionDate = alreadyDone ? todayKST() - : completedWeekIdx >= 0 - ? addWeeks(state.weeks[completedWeekIdx].startDate, 1) + : weeksNeeded !== null + ? upcomingThursday.add(weeksNeeded - 2, 'week').toDate() : null const updateWeek = (idx, newWeekData) => { @@ -153,6 +227,8 @@ export default function Liberation() { setState({ startChapter: 0, currentPoints: 0, + startDate: dayjs(todayKST()).toISOString(), + weekly: makeEmptyWeekly(), weeks: [makeEmptyWeek(todayKST())], }) } @@ -187,8 +263,8 @@ export default function Liberation() {