심볼 계산기에 이벤트 스킬(보약) 일퀘 보너스 자동 반영

Nexon Open API의 character/skill(grade=0) 응답에서 '그란디스/아케인리버
일일퀘스트 완료 시 획득 심볼 N개 증가' 문구를 파싱해 심볼 타입별 보너스를
일퀘 획득량 기본값에 바로 합산한다.

skill_level 필드는 이벤트 스킬에 한해 실제 레벨이 아닌 1로 고정 반환되므로
심볼 증가 개수 → 레벨 역산 테이블로 실제 레벨을 복원한다. 입력창 hover 시
'기본 X + 보약 Y (메이플 스위츠 Lv.Z)' 툴팁으로 근거를 노출.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-20 01:03:29 +09:00
parent 3a1d8a63ac
commit edbaaf09aa
6 changed files with 103 additions and 13 deletions

View file

@ -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)) {

View file

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

View file

@ -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 (
<div className="space-y-6 pb-10 max-w-5xl mx-auto">

View file

@ -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 } : {})}
/>
</div>
{symbol.weekly_default > 0 && (

View file

@ -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]: {

View file

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