심볼 계산기에 이벤트 스킬(보약) 일퀘 보너스 자동 반영
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:
parent
3a1d8a63ac
commit
edbaaf09aa
6 changed files with 103 additions and 13 deletions
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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]: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue