dex/src/components/MovesList.js
chris 99d472f469 Fix learnset levels and scroll-position loss on detail visits
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
2026-08-28 21:42:42 -04:00

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),
);
}