diff --git a/backend/routes/character.js b/backend/routes/character.js index d7c3862..9308b85 100644 --- a/backend/routes/character.js +++ b/backend/routes/character.js @@ -46,16 +46,53 @@ router.get('/search', async (req, res) => { } }); -// OCID로 장착 심볼 조회 +// 이벤트 스킬(보약) 효과에서 일퀘 심볼 보너스 파싱 +// Nexon API의 skill_level 필드는 이벤트 스킬에 한해 실제 레벨이 아닌 1로 고정되므로 +// skill_effect 문자열의 심볼 증가 개수를 이용해 실제 레벨을 역산한다. +// (이벤트마다 테이블이 달라지면 아래 맵을 갱신해야 함) +const ARCANE_SYMBOL_TO_LEVEL = { 2: 1, 4: 2, 8: 3, 12: 4, 16: 5, 20: 6 }; +const AUTHENTIC_SYMBOL_TO_LEVEL = { 2: 1, 3: 2, 4: 3, 5: 4, 7: 5, 9: 6 }; + +function parseEventSkillBonus(skills) { + for (const s of skills || []) { + const eff = s.skill_effect || ''; + const arcane = eff.match(/아케인리버\s*일일퀘스트[^\r\n]*?획득\s*심볼\s*(\d+)\s*개/); + const authentic = eff.match(/그란디스\s*일일퀘스트[^\r\n]*?획득\s*심볼\s*(\d+)\s*개/); + if (arcane || authentic) { + const arcaneDaily = arcane ? Number(arcane[1]) || 0 : 0; + const authenticDaily = authentic ? Number(authentic[1]) || 0 : 0; + const derivedLevel = + AUTHENTIC_SYMBOL_TO_LEVEL[authenticDaily] || + ARCANE_SYMBOL_TO_LEVEL[arcaneDaily] || + 0; + return { + skill_name: s.skill_name, + skill_level: derivedLevel, + arcane_daily: arcaneDaily, + authentic_daily: authenticDaily, + }; + } + } + return null; +} + +// OCID로 장착 심볼 + 이벤트 스킬 보너스 조회 router.get('/symbols', async (req, res) => { const { ocid } = req.query; if (!ocid) return res.status(400).json({ error: 'ocid가 필요합니다' }); try { - const { data } = await axios.get(`${NEXON_API_BASE}/maplestory/v1/character/symbol-equipment`, { - params: { ocid }, - headers: { 'x-nxopen-api-key': process.env.NEXON_API_KEY }, - }); + const [symbolRes, skillRes] = await Promise.all([ + axios.get(`${NEXON_API_BASE}/maplestory/v1/character/symbol-equipment`, { + params: { ocid }, + headers: { 'x-nxopen-api-key': process.env.NEXON_API_KEY }, + }), + axios.get(`${NEXON_API_BASE}/maplestory/v1/character/skill`, { + params: { ocid, character_skill_grade: '0' }, + headers: { 'x-nxopen-api-key': process.env.NEXON_API_KEY }, + }).catch(() => ({ data: { character_skill: [] } })), + ]); + const data = symbolRes.data; const parsed = (data.symbol || []).map((s) => { const [prefix, region] = (s.symbol_name || '').split(' : ').map((t) => t.trim()); @@ -70,7 +107,9 @@ router.get('/symbols', async (req, res) => { }; }); - res.json({ ocid, character_class: data.character_class, symbols: parsed }); + const event_skill = parseEventSkillBonus(skillRes.data?.character_skill); + + res.json({ ocid, character_class: data.character_class, symbols: parsed, event_skill }); } catch (err) { const code = err.response?.data?.error?.name; if (['OPENAPI00001', 'OPENAPI00007', 'OPENAPI00010', 'OPENAPI00011'].includes(code)) { diff --git a/frontend/src/features/symbol/__tests__/utils.test.js b/frontend/src/features/symbol/__tests__/utils.test.js index aafc1df..e6de816 100644 --- a/frontend/src/features/symbol/__tests__/utils.test.js +++ b/frontend/src/features/symbol/__tests__/utils.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { formatKoreanDate, computeCompletion, TYPE_ORDER } from '../utils' +import { formatKoreanDate, computeCompletion, TYPE_ORDER, eventBonusForType } from '../utils' describe('TYPE_ORDER', () => { it('아케인 → 어센틱 → 그랜드 어센틱 순서', () => { @@ -78,3 +78,29 @@ describe('computeCompletion', () => { expect(r.days).toBe(0) }) }) + +describe('eventBonusForType', () => { + const skill = { skill_name: '메이플 스위츠', skill_level: 1, arcane_daily: 3, authentic_daily: 7 } + + it('event_skill이 없으면 0', () => { + expect(eventBonusForType(null, '아케인')).toBe(0) + expect(eventBonusForType(undefined, '어센틱')).toBe(0) + }) + + it('아케인 타입은 arcane_daily 반환', () => { + expect(eventBonusForType(skill, '아케인')).toBe(3) + }) + + it('어센틱/그랜드 어센틱 타입은 authentic_daily 반환', () => { + expect(eventBonusForType(skill, '어센틱')).toBe(7) + expect(eventBonusForType(skill, '그랜드 어센틱')).toBe(7) + }) + + it('알 수 없는 타입은 0', () => { + expect(eventBonusForType(skill, '기타')).toBe(0) + }) + + it('해당 필드가 누락되어도 0으로 처리', () => { + expect(eventBonusForType({ skill_name: 'X', skill_level: 1 }, '아케인')).toBe(0) + }) +}) diff --git a/frontend/src/features/symbol/pc/Symbol.jsx b/frontend/src/features/symbol/pc/Symbol.jsx index ba620b7..735eb47 100644 --- a/frontend/src/features/symbol/pc/Symbol.jsx +++ b/frontend/src/features/symbol/pc/Symbol.jsx @@ -6,7 +6,7 @@ import Tooltip from '../../../components/common/Tooltip' import CharacterSuggestDropdown from '../../../components/common/CharacterSuggestDropdown' import { useSymbolStore } from '../store' import { formatMesoKorean } from '../../../utils/formatting' -import { formatKoreanDate, computeCompletion, TYPE_ORDER } from '../utils' +import { formatKoreanDate, computeCompletion, TYPE_ORDER, eventBonusForType } from '../utils' import CharacterCard from './user/CharacterCard' import SymbolCard from './user/SymbolCard' @@ -104,6 +104,11 @@ export default function Symbol() { } } syncCharacterSymbols(c.id, equippedMap) + const nextEs = q.data.event_skill ?? null + const prevEs = c.event_skill ?? null + if (JSON.stringify(nextEs) !== JSON.stringify(prevEs)) { + updateCharacter(c.id, { event_skill: nextEs }) + } }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [allSymbols, symbolQueries.map((q) => q.dataUpdatedAt).join(',')]) @@ -141,6 +146,7 @@ export default function Symbol() { const isEquipped = (symbolId) => !!progress?.[symbolId]?.equipped // 현재 탭의 누적 메소 + 최종 완료일 계산 + const selectedChar = characters.find((c) => c.id === selectedCharId) const { totalRequiredMeso, totalArrearMeso, overallDate } = useMemo(() => { let req = 0, arr = 0, latest = null for (const s of symbols) { @@ -172,9 +178,11 @@ export default function Symbol() { req += l.meso_cost } if (effMax) continue + const bonus = eventBonusForType(selectedChar?.event_skill, s.type) + const dailyValue = p.daily !== undefined ? p.daily : (s.daily_default ?? 0) + bonus const { date } = computeCompletion({ remainingSymbols: remaining, - daily: p.daily ?? s.daily_default ?? 0, + daily: dailyValue, weeklyPerWeek: (p.weeklyCount ?? 3) * (s.weekly_default || 0), extra: p.extra || 0, dailyDone: !!p.dailyDone, @@ -182,7 +190,7 @@ export default function Symbol() { if (date && (!latest || date > latest)) latest = date } return { totalRequiredMeso: req, totalArrearMeso: arr, overallDate: latest } - }, [symbols, progress]) + }, [symbols, progress, selectedChar?.event_skill]) return (
diff --git a/frontend/src/features/symbol/pc/user/SymbolCard.jsx b/frontend/src/features/symbol/pc/user/SymbolCard.jsx index d166bee..18404e2 100644 --- a/frontend/src/features/symbol/pc/user/SymbolCard.jsx +++ b/frontend/src/features/symbol/pc/user/SymbolCard.jsx @@ -3,7 +3,7 @@ import Select from '../../../../components/common/Select' import Tooltip from '../../../../components/common/Tooltip' import { useSymbolStore } from '../../store' import { formatMesoKorean } from '../../../../utils/formatting' -import { formatKoreanDate, computeCompletion } from '../../utils' +import { formatKoreanDate, computeCompletion, eventBonusForType } from '../../utils' const INPUT_CLASS = "w-full h-10 rounded-md border px-3 text-base text-right tabular-nums outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)] disabled:opacity-50" const INPUT_STYLE = { @@ -15,12 +15,19 @@ const INPUT_STYLE = { function SymbolCard({ symbol, equipped, charId }) { const progress = useSymbolStore((s) => s.progress?.[charId]?.[symbol.id]) const updateSymbol = useSymbolStore((s) => s.updateSymbol) + const eventSkill = useSymbolStore((s) => s.characters.find((c) => c.id === charId)?.event_skill) const dailyDone = progress?.dailyDone ?? false const weeklyCount = progress?.weeklyCount ?? 3 - const daily = progress?.daily ?? symbol.daily_default + const baseDefault = symbol.daily_default ?? 0 + const eventBonus = eventBonusForType(eventSkill, symbol.type) + const hasDailyOverride = progress?.daily !== undefined + const daily = hasDailyOverride ? progress.daily : baseDefault + eventBonus const extra = progress?.extra ?? 0 const patch = (p) => charId && updateSymbol(charId, symbol.id, p) + const dailyTooltip = !hasDailyOverride && eventBonus > 0 && eventSkill + ? `기본 ${baseDefault} + 보약 ${eventBonus} (${eventSkill.skill_name} Lv.${eventSkill.skill_level})` + : null const level = progress?.level ?? 0 const growth = progress?.growth ?? 0 @@ -184,6 +191,7 @@ function SymbolCard({ symbol, equipped, charId }) { disabled={!interactable} className={INPUT_CLASS} style={INPUT_STYLE} + {...(dailyTooltip ? { title: dailyTooltip } : {})} />
{symbol.weekly_default > 0 && ( diff --git a/frontend/src/features/symbol/store.js b/frontend/src/features/symbol/store.js index c55a19f..1662d8c 100644 --- a/frontend/src/features/symbol/store.js +++ b/frontend/src/features/symbol/store.js @@ -3,7 +3,8 @@ import { persist } from 'zustand/middleware' /** * 심볼 계산기 상태 - * characters: [{ id, character_name, character_level, job_name, character_image, ... }] + * characters: [{ id, character_name, character_level, job_name, character_image, event_skill, ... }] + * - event_skill: { skill_name, skill_level, arcane_daily, authentic_daily } | null * selectedCharId: 현재 선택된 캐릭터 id (ocid) * progress: { * [charId]: { diff --git a/frontend/src/features/symbol/utils.js b/frontend/src/features/symbol/utils.js index 6eaf3a4..87ff59b 100644 --- a/frontend/src/features/symbol/utils.js +++ b/frontend/src/features/symbol/utils.js @@ -36,3 +36,11 @@ export function computeCompletion({ remainingSymbols, daily, weeklyPerWeek, extr } export const TYPE_ORDER = ['아케인', '어센틱', '그랜드 어센틱'] + +// 이벤트 스킬(보약) 보너스 중 해당 심볼 타입에 적용되는 일퀘 증가량 반환 +export function eventBonusForType(eventSkill, type) { + if (!eventSkill) return 0 + if (type === '아케인') return eventSkill.arcane_daily || 0 + if (type === '어센틱' || type === '그랜드 어센틱') return eventSkill.authentic_daily || 0 + return 0 +}