MovesList: a move can have several version_group_details for one game — one per learn method, plus a Gen 1/2 data artifact that repeats a level-up move at level 1 next to its real level (e.g. Gyarados / Dragon Rage in Red-Blue: level 1, level 25 and a machine entry). The old `.find()` grabbed the first, so Dragon Rage showed "Lv 1". Now collect every matching detail, group by method, and keep the highest level per method — a move can also legitimately land in both "By level up" and "By TM / HM". PokemonDetail: the recently-viewed write used the function form of ui.set(), which *replaces* state instead of merging. Every Pokémon visit was wiping feedScroll (so Back returned to the top of the dex) along with the saved sort / filters / tab. Spread `...s`. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
273 lines
8.9 KiB
JavaScript
273 lines
8.9 KiB
JavaScript
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),
|
|
);
|
|
}
|