import { el } from '../lib/dom.js'; import { getMove, getMachine, mapLimit } from '../data/api.js'; import { TypeChip } from './TypeChip.js'; /** Resolve the TM/HM/TR number for a move in a given game, e.g. "TM25". */ export async function machineNumber(moveData, versionGroupKey) { const list = moveData.machines || []; if (!list.length) return null; const hit = list.find((x) => x.version_group.name === versionGroupKey) || list[list.length - 1]; try { const mc = await getMachine(Number(hit.machine.url.replace(/\/$/, '').split('/').pop())); const m = mc.item.name.match(/^([a-z]+)0*(\d+)$/i); return m ? `${m[1].toUpperCase()}${String(Number(m[2])).padStart(2, '0')}` : mc.item.name.toUpperCase(); } catch { return null; } } const METHOD_LABEL = { 'level-up': 'By level up', machine: 'By TM / HM', egg: 'Egg moves', tutor: 'Move tutor', 'form-change': 'On form change', }; const METHOD_ORDER = ['level-up', 'egg', 'machine', 'tutor', 'form-change']; const DAMAGE_CLASS = { physical: 'Phys', special: 'Spec', status: 'Stat' }; const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop()); function englishText(entries, key) { const hit = entries.find((e) => e.language.name === 'en'); return hit ? hit[key].replace(/\s+/g, ' ') : ''; } /** * Some moves' power/accuracy/PP/type changed between generations. PokéAPI * ships the current values plus `past_values`; pick the set that was live in * the selected game's generation. */ // Before Gen 4, a move's physical/special class was decided by its TYPE, // not set per move. const PHYSICAL_TYPES = new Set([ 'normal', 'fighting', 'poison', 'ground', 'flying', 'bug', 'rock', 'ghost', 'steel', ]); function forGeneration(d, gen, genOfVg) { const v = { power: d.power, accuracy: d.accuracy, pp: d.pp, type: d.type.name, damage_class: d.damage_class ? d.damage_class.name : null, effect_chance: d.effect_chance, effect_entries: d.effect_entries, }; const past = (d.past_values || []) .map((p) => ({ ...p, _gen: genOfVg(p.version_group && p.version_group.name) })) .sort((a, b) => a._gen - b._gen); for (const p of past) { if (p._gen >= gen) { if (p.power != null) v.power = p.power; if (p.accuracy != null) v.accuracy = p.accuracy; if (p.pp != null) v.pp = p.pp; if (p.effect_chance != null) v.effect_chance = p.effect_chance; if (p.type) v.type = p.type.name; if (p.effect_entries && p.effect_entries.length) v.effect_entries = p.effect_entries; break; } } if (gen < 4 && v.damage_class && v.damage_class !== 'status') { v.damage_class = PHYSICAL_TYPES.has(v.type) ? 'physical' : 'special'; } return v; } /** * Learnset for the selected game. Rows wrap (no sideways scroll); each row * expands on click to show the move's effect, and power/type/accuracy/PP * load lazily when a group is first opened. */ export function MovesList(pokemonMoves, { versionGroupKey, gen = 9, genOfVg = () => 9 }) { const byMethod = new Map(); for (const entry of pokemonMoves) { const details = entry.version_group_details.filter( (d) => d.version_group.name === versionGroupKey, ); if (!details.length) continue; // One move can list several details for a single game: one per learn // method, plus Gen 1/2 data artifacts that repeat a level-up move at // level 1 (with a non-null `order`) alongside its real learn level. // Collapse to one entry per method, keeping the highest level. const perMethod = new Map(); for (const d of details) { const method = d.move_learn_method.name; const prev = perMethod.get(method); if (!prev || (d.level_learned_at || 0) > (prev.level_learned_at || 0)) { perMethod.set(method, d); } } for (const [method, d] of perMethod) { if (!byMethod.has(method)) byMethod.set(method, []); byMethod.get(method).push({ id: idFromUrl(entry.move.url), name: entry.move.name.replace(/-/g, ' '), level: d.level_learned_at, data: null, }); } } if (byMethod.size === 0) { return el('p', { class: 'detail__muted' }, 'No move data for this game.'); } const wrap = el('div', { class: 'moves' }); const methods = [...byMethod.keys()].sort( (a, b) => METHOD_ORDER.indexOf(a) - METHOD_ORDER.indexOf(b), ); for (const method of methods) { const moves = byMethod.get(method); moves.sort((a, b) => method === 'level-up' ? a.level - b.level || a.name.localeCompare(b.name) : a.name.localeCompare(b.name), ); const list = el('div', { class: 'moves__list' }); const enrichers = []; for (const m of moves) { const meta = el('span', { class: 'moverow__meta' }, el('span', { class: 'moverow__pending' }, '…')); const body = el('div', { class: 'movebody', hidden: true }); const tmCell = method === 'machine' ? el('span', { class: 'moverow__tm' }) : null; let bodyFilled = false; const fillTm = () => { if (tmCell && !tmCell.textContent && m.data) { machineNumber(m.data, versionGroupKey).then((n) => { if (n) tmCell.textContent = n; }); } }; const row = el( 'button', { type: 'button', class: 'moverow', 'aria-expanded': 'false', onclick: async () => { const open = body.hidden; body.hidden = !open; row.setAttribute('aria-expanded', String(open)); if (open && !bodyFilled) { bodyFilled = true; body.replaceChildren(el('span', { class: 'moverow__pending' }, 'Loading…')); try { m.data = m.data || (await getMove(m.id)); const v = forGeneration(m.data, gen, genOfVg); fillMeta(meta, v); fillBody(body, m.data, v); fillTm(); } catch { body.replaceChildren(el('span', { class: 'moverow__pending' }, 'Details unavailable.')); } } }, }, method === 'level-up' ? el('span', { class: 'moverow__lv' }, m.level ? `Lv ${m.level}` : '—') : tmCell, el('span', { class: 'moverow__name' }, m.name), meta, el('span', { class: 'moverow__chev', 'aria-hidden': 'true' }, '▾'), ); list.append(el('div', { class: 'movecard' }, row, body)); enrichers.push(async () => { try { m.data = m.data || (await getMove(m.id)); fillMeta(meta, forGeneration(m.data, gen, genOfVg)); fillTm(); } catch { meta.replaceChildren(el('span', { class: 'moverow__pending' }, '—')); } }); } const group = el( 'details', { class: 'moves__group', open: method === 'level-up' }, el( 'summary', {}, `${METHOD_LABEL[method] || method} `, el('span', { class: 'moves__count' }, `(${moves.length})`), ), list, ); let enriched = false; const runEnrich = async () => { if (enriched) return; enriched = true; await mapLimit(enrichers, 6, (fn) => fn()); }; group.addEventListener('toggle', () => group.open && runEnrich()); if (method === 'level-up') runEnrich(); wrap.append(group); } return wrap; } function fillMeta(meta, v) { meta.replaceChildren( TypeChip(v.type), el( 'span', { class: 'moverow__cat', dataset: { cat: v.damage_class || '' } }, DAMAGE_CLASS[v.damage_class] || '—', ), stat('Pow', v.power ?? '—'), stat('Acc', v.accuracy != null ? `${v.accuracy}%` : '—'), stat('PP', v.pp ?? '—'), ); } function fillBody(body, d, v) { const chance = v.effect_chance; let effect = englishText(v.effect_entries, 'short_effect') || englishText(d.flavor_text_entries, 'flavor_text') || 'No description.'; if (chance != null) effect = effect.replace(/\$effect_chance%?/g, `${chance}%`); const bits = []; if (d.priority) bits.push(`Priority ${d.priority > 0 ? '+' : ''}${d.priority}`); if (d.target?.name) bits.push(`Target: ${d.target.name.replace(/-/g, ' ')}`); if (d.meta?.ailment?.name && d.meta.ailment.name !== 'none') { bits.push(`May ${d.meta.ailment.name.replace(/-/g, ' ')}${d.meta.ailment_chance ? ` (${d.meta.ailment_chance}%)` : ''}`); } if (d.meta?.drain) bits.push(`${d.meta.drain > 0 ? 'Drains' : 'Recoil'} ${Math.abs(d.meta.drain)}%`); if (d.meta?.healing) bits.push(`Heals ${d.meta.healing}%`); if (d.meta?.crit_rate) bits.push(`+${d.meta.crit_rate} crit rate`); if (d.meta?.flinch_chance) bits.push(`Flinch ${d.meta.flinch_chance}%`); body.replaceChildren( el('p', { class: 'movebody__effect' }, effect), bits.length ? el('p', { class: 'movebody__bits' }, bits.join(' · ')) : null, ); } function stat(label, value) { return el( 'span', { class: 'moverow__stat' }, el('span', { class: 'moverow__stat-k' }, label), ' ', String(value), ); }