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() {
setState((prev) => ({ ...prev, startDate: dayjs(d).toISOString() }))} />
@@ -211,6 +287,26 @@ export default function Liberation() { + + setState((prev) => ({ ...prev, weekly: w }))} + totalWeekly={weeklyEarn} + totalMonthly={monthlyEarn} + /> + +
+ +
) } diff --git a/frontend/src/features/liberation/components/WeeklyDefault.jsx b/frontend/src/features/liberation/components/WeeklyDefault.jsx new file mode 100644 index 0000000..318bad5 --- /dev/null +++ b/frontend/src/features/liberation/components/WeeklyDefault.jsx @@ -0,0 +1,114 @@ +import Select from '../../../components/Select' +import Tooltip from '../../../components/Tooltip' +import { WEEKLY_BOSSES, MONTHLY_BOSSES, LIBERATION_BOSS_IMAGE_BASE, 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 } + +function diffLabel(d, party) { + if (d.key === 'none') return 격파 불가 + const earned = calcPoints(d.points, party) + return ( + + {d.label} +{earned} + + ) +} + +function BossRow({ boss, sel, onChange, monthly = false }) { + const disabled = sel.difficulty === 'none' + const rowStyle = '' + + const difficultyOptions = [NONE_DIFFICULTY, ...boss.difficulties] + .map((d) => ({ value: d.key, label: diffLabel(d, sel.party) })) + + return ( +
+ + + + + {boss.name} + {monthly && 월간} + + +
+ onChange({ party: v })} + options={PARTY_OPTIONS} + disabled={disabled} + /> +
+ +
+ ) +} + +export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMonthly }) { + const updateBoss = (key, patch) => { + onChange({ ...weekly, bosses: { ...weekly.bosses, [key]: { ...weekly.bosses[key], ...patch } } }) + } + const updateBlackMage = (patch) => { + onChange({ ...weekly, blackMage: { ...weekly.blackMage, ...patch } }) + } + + return ( +
+
+
주간 보스 설정
+
+ + 주간 획득 +{totalWeekly} + + + 월간 획득 +{totalMonthly} + +
+
+ +
+ {WEEKLY_BOSSES.map((boss) => ( + updateBoss(boss.key, patch)} + /> + ))} + {MONTHLY_BOSSES.map((boss) => ( + + ))} +
+
+ ) +} diff --git a/frontend/src/features/liberation/data.js b/frontend/src/features/liberation/data.js index bdbc50a..fbba23b 100644 --- a/frontend/src/features/liberation/data.js +++ b/frontend/src/features/liberation/data.js @@ -15,13 +15,15 @@ export const GENESIS_CHAPTERS = [ // 퀘스트 이미지 경로 (제네시스) export const QUEST_BOSS_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/liberation/genesis/quest/boss' export const QUEST_BTBOSS_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/liberation/genesis/quest/btboss' +// 주간/월간 보스 초상화 (해방용) +export const LIBERATION_BOSS_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/liberation/genesis/boss' export const GENESIS_TOTAL = GENESIS_CHAPTERS.reduce((s, c) => s + c.required, 0) // 6500 // 주간 보스 (주 1회) export const WEEKLY_BOSSES = [ { - key: 'lotus', name: '스우', image: '스우.webp', + key: 'lotus', name: '스우', image: '스우.png', difficulties: [ { key: 'normal', label: '노말', points: 10 }, { key: 'hard', label: '하드', points: 50 }, @@ -29,14 +31,14 @@ export const WEEKLY_BOSSES = [ ], }, { - key: 'damien', name: '데미안', image: '데미안.webp', + key: 'damien', name: '데미안', image: '데미안.png', difficulties: [ { key: 'normal', label: '노말', points: 10 }, { key: 'hard', label: '하드', points: 50 }, ], }, { - key: 'lucid', name: '루시드', image: '루시드.webp', + key: 'lucid', name: '루시드', image: '루시드.png', difficulties: [ { key: 'easy', label: '이지', points: 15 }, { key: 'normal', label: '노말', points: 20 }, @@ -44,7 +46,7 @@ export const WEEKLY_BOSSES = [ ], }, { - key: 'will', name: '윌', image: '윌.webp', + key: 'will', name: '윌', image: '윌.png', difficulties: [ { key: 'easy', label: '이지', points: 15 }, { key: 'normal', label: '노말', points: 25 }, @@ -52,32 +54,32 @@ export const WEEKLY_BOSSES = [ ], }, { - key: 'dusk', name: '더스크', image: '더스크.webp', + key: 'dusk', name: '더스크', image: '더스크.png', difficulties: [ { key: 'normal', label: '노말', points: 20 }, { key: 'chaos', label: '카오스', points: 65 }, ], }, { - key: 'darknell', name: '듄켈', image: '듄켈.webp', - difficulties: [ - { key: 'normal', label: '노말', points: 25 }, - { key: 'hard', label: '하드', points: 75 }, - ], - }, - { - key: 'jinhilla', name: '진 힐라', image: '진힐라.webp', + key: 'jinhilla', name: '진 힐라', image: '진 힐라.png', difficulties: [ { key: 'normal', label: '노말', points: 45 }, { key: 'hard', label: '하드', points: 90 }, ], }, + { + key: 'darknell', name: '듄켈', image: '듄켈.png', + difficulties: [ + { key: 'normal', label: '노말', points: 25 }, + { key: 'hard', label: '하드', points: 75 }, + ], + }, ] // 월간 보스 export const MONTHLY_BOSSES = [ { - key: 'blackmage', name: '검은 마법사', image: '검은마법사.webp', + key: 'blackmage', name: '검은 마법사', image: '검은 마법사.png', difficulties: [ { key: 'hard', label: '하드', points: 600 }, { key: 'extreme', label: '익스트림', points: 600 },