해방 주간 보스 설정 + 완료일 계산 로직

- 주간 보스 설정 카드: 보스별 난이도/파티/완료 토글, '격파 불가' 옵션
- 주간/월간 획득 포인트 분리 표시
- 완료일 계산: 시작일 주를 1주차로 포함, 매주 목요일 리셋 기준
- 공식: ceil((남은 흔적 + 완료 보스 포인트) / 주간 획득) + 월간 보스 달력 월(1일) 리셋 반영
- 전체 초기화 버튼
- 보스 이름 파일 경로 수정 (진 힐라, 검은 마법사 띄어쓰기)
- 보스 순서 수정 (더스크 → 진 힐라 → 듄켈)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-14 09:54:06 +09:00
parent f0c0ea3c1c
commit 8eaf27d143
3 changed files with 260 additions and 48 deletions

View file

@ -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() {
<div className="space-y-1.5">
<label className="block text-xs text-gray-400">시작 날짜</label>
<DatePicker
value={formatDate(state.weeks[0].startDate)}
onChange={setFirstWeekDate}
value={formatDate(state.startDate)}
onChange={(d) => setState((prev) => ({ ...prev, startDate: dayjs(d).toISOString() }))}
/>
</div>
@ -211,6 +287,26 @@ export default function Liberation() {
</div>
</div>
</div>
<WeeklyDefault
weekly={state.weekly}
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
totalWeekly={weeklyEarn}
totalMonthly={monthlyEarn}
/>
<div className="max-w-2xl mx-auto flex justify-end">
<button
type="button"
onClick={resetAll}
className="inline-flex items-center gap-2 rounded-lg border border-red-500/50 bg-red-500/10 hover:bg-red-500/20 text-red-300 hover:text-red-200 px-5 py-2.5 text-sm font-semibold transition shadow-lg shadow-red-500/10"
>
<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>
</div>
)
}

View file

@ -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 <span className="text-gray-500">격파 불가</span>
const earned = calcPoints(d.points, party)
return (
<span>
{d.label} <span className="text-emerald-400">+{earned}</span>
</span>
)
}
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 (
<div className={`flex items-center gap-3 rounded-lg px-3 h-14 transition ${rowStyle}`}>
<Tooltip text={boss.name}>
<img src={`${LIBERATION_BOSS_IMAGE_BASE}/${boss.image}`} alt="" className="w-8 h-8 rounded object-cover shrink-0" />
</Tooltip>
<span className="text-sm font-medium flex-1 truncate">
{boss.name}
{monthly && <span className="ml-1.5 text-[10px] text-amber-400/80">월간</span>}
</span>
<div className="w-36">
<Select
value={sel.difficulty}
onChange={(v) => {
if (v === 'none') onChange({ difficulty: 'none', done: false })
else onChange({ difficulty: v })
}}
options={difficultyOptions}
/>
</div>
<div className="w-20">
<Select
value={sel.party}
onChange={(v) => onChange({ party: v })}
options={PARTY_OPTIONS}
disabled={disabled}
/>
</div>
<button
type="button"
disabled={disabled}
onClick={() => onChange({ done: !sel.done })}
className={`shrink-0 w-20 rounded-md h-8 text-xs font-semibold transition border ${
disabled
? 'border-white/5 text-gray-700 cursor-not-allowed'
: sel.done
? 'bg-emerald-500/20 border-emerald-500/50 text-emerald-300'
: 'border-white/10 text-gray-500 hover:border-white/20 hover:text-gray-300'
}`}
>
{sel.done ? '완료' : '미완료'}
</button>
</div>
)
}
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 (
<div className="max-w-2xl mx-auto rounded-2xl border border-white/10 bg-gray-900/60 p-6 space-y-4">
<div className="flex items-baseline justify-between">
<div className="text-lg font-semibold text-emerald-300">주간 보스 설정</div>
<div className="text-sm text-gray-400 flex items-baseline gap-3">
<span>
주간 획득 <span className="text-emerald-300 font-semibold tabular-nums">+{totalWeekly}</span>
</span>
<span>
월간 획득 <span className="text-amber-300 font-semibold tabular-nums">+{totalMonthly}</span>
</span>
</div>
</div>
<div className="divide-y divide-white/5">
{WEEKLY_BOSSES.map((boss) => (
<BossRow
key={boss.key}
boss={boss}
sel={weekly.bosses[boss.key]}
onChange={(patch) => updateBoss(boss.key, patch)}
/>
))}
{MONTHLY_BOSSES.map((boss) => (
<BossRow
key={boss.key}
boss={boss}
sel={weekly.blackMage}
onChange={updateBlackMage}
monthly
/>
))}
</div>
</div>
)
}

View file

@ -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 },