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 (