dex/scripts/build-snapshot.mjs
chris c9807127d6 Dex grid: ability, egg group, min BST and fully-evolved filters
Extends the existing filter drawer (type/gen) with four more:

- Ability — snapshot abilities already carry a reverse pokemon[] index,
  so this is a plain lookup, no new data needed.
- Egg group — options derived from the species list's own eggGroups.
- Min BST — a number field; hard filter, distinct from sorting by BST.
- Fully evolved only — new species.evolvesFromId field (from
  pokemon-species' evolves_from_species, already fetched during the
  snapshot build, so no extra requests). "Fully evolved" is computed
  client-side as "no other species points here via evolvesFromId" —
  correctly handles branching lines (Eevee's evolutions all count as
  fully evolved; Eevee itself doesn't).

All four persist via the ui store and count toward the filter badge, same
as the existing filters.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-04 12:14:36 -04:00

425 lines
14 KiB
JavaScript

#!/usr/bin/env node
/**
* Build a compact, offline-first data snapshot from PokéAPI.
*
* The app runs its list, search, sort, filters and game switching entirely
* off this file — no network until you open a detail page. It carries, per
* species: name, generation, current + past typings, the six base stats and
* their total, height, weight, base experience, and species flags
* (legendary / mythical / baby, capture rate, growth rate, colour, egg
* groups, gender rate). Plus every regional Pokédex and every version group.
*
* Richer detail (abilities, moves, flavour text, evolution, encounters) is
* fetched on demand by the app and cached by the service worker.
*
* Output: src/data/snapshot.json (imported by src/data/snapshot.js)
* Usage: node scripts/build-snapshot.mjs [--force]
*/
import { writeFile, mkdir, stat } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT = resolve(__dirname, '../src/data/snapshot.json');
const BASE = 'https://pokeapi.co/api/v2';
const MAX_AGE_DAYS = 30;
const FORCE = process.argv.includes('--force');
const CONCURRENCY = 16;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
const pretty = (k) => k.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const FORM_LABELS = {
mega: 'Mega',
'mega-x': 'Mega X',
'mega-y': 'Mega Y',
gmax: 'Gigantamax',
alola: 'Alolan',
galar: 'Galarian',
hisui: 'Hisuian',
paldea: 'Paldean',
primal: 'Primal',
origin: 'Origin',
altered: 'Altered',
incarnate: 'Incarnate',
therian: 'Therian',
};
function formLabel(slug, speciesName) {
const rest = slug.startsWith(`${speciesName}-`) ? slug.slice(speciesName.length + 1) : slug;
return FORM_LABELS[rest] || pretty(rest);
}
function formCategory(slug, speciesName) {
const rest = slug.startsWith(`${speciesName}-`) ? slug.slice(speciesName.length + 1) : slug;
if (rest.startsWith('mega')) return 'mega';
if (rest === 'gmax') return 'gmax';
if (['alola', 'galar', 'hisui', 'paldea'].includes(rest)) return 'regional';
if (rest === 'primal') return 'primal';
return 'other';
}
const _cache = new Map();
async function api(path) {
if (_cache.has(path)) return _cache.get(path);
const url = path.startsWith('http') ? path : `${BASE}/${path}`;
let lastErr;
for (let attempt = 0; attempt < 5; attempt++) {
try {
const res = await fetch(url);
if (res.ok) {
const json = await res.json();
_cache.set(path, json);
return json;
}
if (res.status === 404) throw new Error(`404 ${url}`);
lastErr = new Error(`${res.status} ${url}`);
} catch (err) {
lastErr = err;
}
await sleep(400 * (attempt + 1));
}
throw lastErr;
}
async function mapLimit(items, limit, fn) {
const out = new Array(items.length);
let i = 0;
let done = 0;
const total = items.length;
const workers = Array.from({ length: Math.min(limit, total) }, async () => {
while (i < total) {
const idx = i++;
out[idx] = await fn(items[idx], idx);
if (++done % 200 === 0 || done === total) {
process.stdout.write(`\r${done}/${total} species`);
}
}
});
await Promise.all(workers);
if (total) process.stdout.write('\n');
return out;
}
async function isFresh() {
try {
const s = await stat(OUT);
return (Date.now() - s.mtimeMs) / 86_400_000 < MAX_AGE_DAYS;
} catch {
return false;
}
}
async function main() {
if (!FORCE && (await isFresh())) {
console.log(
`snapshot.json is fresh (< ${MAX_AGE_DAYS} days old). Use --force to rebuild.`,
);
return;
}
console.log('Building snapshot from PokéAPI …');
const t0 = Date.now();
// ---- Type name list ------------------------------------------------
const typeIndex = await api('type?limit=100');
const types = typeIndex.results
.map((r) => r.name)
.filter((n) => !['unknown', 'shadow', 'stellar'].includes(n));
// ---- Generations (for display names / regions) ------------------
const genIndex = await api('generation?limit=50');
const generations = [];
await mapLimit(genIndex.results, 8, async (g) => {
const data = await api(`generation/${g.name}`);
generations.push({
id: data.id,
name: pretty(data.main_region?.name || g.name),
region: data.main_region?.name || null,
});
});
generations.sort((a, b) => a.id - b.id);
// ---- Pokédexes ----------------------------------------------------
const dexIndex = await api('pokedex?limit=100');
const pokedexes = [];
await mapLimit(dexIndex.results, 8, async (d) => {
const data = await api(`pokedex/${d.name}`);
pokedexes.push({
key: data.name,
name: pretty(data.name),
region: data.region?.name || null,
isMainSeries: data.is_main_series,
versionGroups: data.version_groups.map((v) => v.name),
entries: data.pokemon_entries
.map((e) => [idFromUrl(e.pokemon_species.url), e.entry_number])
.sort((a, b) => a[1] - b[1]),
});
});
pokedexes.sort((a, b) => a.key.localeCompare(b.key));
// ---- Version groups (the "games") -----------------------------
const vgIndex = await api('version-group?limit=100');
const versionGroups = [];
await mapLimit(vgIndex.results, 8, async (v) => {
const data = await api(`version-group/${v.name}`);
versionGroups.push({
key: data.name,
name: pretty(data.name),
generation: idFromUrl(data.generation.url),
versions: data.versions.map((x) => x.name),
pokedexKeys: data.pokedexes.map((x) => x.name),
});
});
versionGroups.sort(
(a, b) => a.generation - b.generation || a.key.localeCompare(b.key),
);
// ---- Species: the data-rich part -----------------------------
const speciesIndex = await api('pokemon-species?limit=100000');
const ids = speciesIndex.results
.map((r) => idFromUrl(r.url))
.sort((a, b) => a - b);
const statMap = (pk) => {
const s = Object.fromEntries(pk.stats.map((x) => [x.stat.name, x.base_stat]));
return {
hp: s.hp ?? 0,
atk: s.attack ?? 0,
def: s.defense ?? 0,
spa: s['special-attack'] ?? 0,
spd: s['special-defense'] ?? 0,
spe: s.speed ?? 0,
};
};
const bstOf = (st) => st.hp + st.atk + st.def + st.spa + st.spd + st.spe;
const abilKey = (pk) => pk.abilities.map((a) => a.ability.name).sort().join(',');
const statKey = (st) => `${st.hp}/${st.atk}/${st.def}/${st.spa}/${st.spd}/${st.spe}`;
console.log(`Fetching ${ids.length} species (stats, typings, flags, forms) …`);
const species = await mapLimit(ids, CONCURRENCY, async (id) => {
const [pk, sp] = await Promise.all([
api(`pokemon/${id}`),
api(`pokemon-species/${id}`),
]);
const stats = statMap(pk);
const baseTypes = pk.types.slice().sort((a, b) => a.slot - b.slot).map((t) => t.type.name);
const baseAbil = abilKey(pk);
const baseStatKey = statKey(stats);
// Non-default varieties = forms (megas, G-max, regional, alt formes).
const variantSlugs = (sp.varieties || [])
.filter((v) => !v.is_default)
.map((v) => v.pokemon.name);
const forms = [];
// Sprite-only variants (Pikachu caps, Alcremie decorations, Gastrodon
// east/west, Unown letters, Vivillon patterns, Furfrou trims, seasonal
// Deerling/Sawsbuck, Flabébé/Floette/Florges colours…). Share the base
// Pokémon's typing, stats, abilities and learnset — only the art differs,
// so they get a lightweight { slug, id, name, sprite } record and a
// separate, compact picker in the UI.
const cosmeticForms = [];
for (const slug of variantSlugs) {
let fp;
try {
fp = await api(`pokemon/${slug}`);
} catch {
continue;
}
const fstats = statMap(fp);
const ftypes = fp.types.slice().sort((a, b) => a.slot - b.slot).map((t) => t.type.name);
const cat = formCategory(slug, sp.name);
const entry = {
slug,
id: fp.id,
name: formLabel(slug, sp.name),
category: cat,
types: ftypes,
stats: fstats,
bst: bstOf(fstats),
height: fp.height,
weight: fp.weight,
};
// Purely-cosmetic forms: same typing, stats AND abilities as the base,
// and not a named category (mega/gmax/regional/primal).
const cosmetic =
cat === 'other' &&
ftypes.join() === baseTypes.join() &&
statKey(fstats) === baseStatKey &&
abilKey(fp) === baseAbil;
if (cosmetic) {
cosmeticForms.push({
slug,
id: fp.id,
name: formLabel(slug, sp.name),
sprite: fp.sprites?.front_default || null,
});
continue;
}
forms.push(entry);
}
// Cosmetic forms that live on /pokemon-form rather than as varieties
// (Unown, Vivillon, Furfrou, Deerling, Sawsbuck, Flabébé line…).
if ((pk.forms || []).length > 1) {
const known = new Set([...forms, ...cosmeticForms].map((f) => f.slug));
for (const fref of pk.forms) {
if (fref.name === pk.name || known.has(fref.name)) continue;
let fm;
try {
fm = await api(fref.url);
} catch {
continue;
}
if (fm.is_default || fm.is_battle_only || fm.is_mega) continue;
const rest = fm.name.startsWith(`${sp.name}-`)
? fm.name.slice(sp.name.length + 1)
: fm.form_name || fm.name;
cosmeticForms.push({
slug: fm.name,
id: fm.id,
name: pretty(rest || fm.form_name || fm.name),
sprite: fm.sprites?.front_default || null,
});
known.add(fm.name);
}
}
return {
forms,
...(cosmeticForms.length ? { cosmeticForms } : {}),
id,
name: sp.name,
generation: idFromUrl(sp.generation.url),
types: pk.types
.slice()
.sort((a, b) => a.slot - b.slot)
.map((t) => t.type.name),
pastTypes: (pk.past_types || []).map((p) => ({
gen: idFromUrl(p.generation.url),
types: p.types.slice().sort((a, b) => a.slot - b.slot).map((t) => t.type.name),
})),
stats,
bst: stats.hp + stats.atk + stats.def + stats.spa + stats.spd + stats.spe,
height: pk.height,
weight: pk.weight,
baseExp: pk.base_experience ?? null,
isLegendary: sp.is_legendary,
isMythical: sp.is_mythical,
isBaby: sp.is_baby,
captureRate: sp.capture_rate,
baseHappiness: sp.base_happiness,
growthRate: sp.growth_rate?.name || null,
color: sp.color?.name || null,
eggGroups: sp.egg_groups.map((g) => g.name),
genderRate: sp.gender_rate,
// Species this evolves FROM (null for base forms) — lets the app
// derive "fully evolved" client-side without fetching every
// evolution chain: a species nothing points to via this field has no
// further evolution.
evolvesFromId: sp.evolves_from_species ? idFromUrl(sp.evolves_from_species.url) : null,
};
});
// ---- Moves index -------------------------------------------
const moveIndex = await api('move?limit=100000');
console.log(`Fetching ${moveIndex.results.length} moves …`);
const moves = await mapLimit(moveIndex.results, CONCURRENCY, async (r) => {
const m = await api(r.url);
return {
id: m.id,
name: m.name,
type: m.type?.name || 'normal',
damageClass: m.damage_class?.name || null,
power: m.power,
accuracy: m.accuracy,
pp: m.pp,
priority: m.priority,
generation: idFromUrl(m.generation.url),
// version-group -> machine id (resolve to TM/HM number on demand)
machines: (m.machines || []).map((x) => ({
vg: x.version_group.name,
id: idFromUrl(x.machine.url),
})),
};
});
moves.sort((a, b) => a.id - b.id);
// ---- Abilities index ------------------------------------------
const abilityIndex = await api('ability?limit=100000');
console.log(`Fetching ${abilityIndex.results.length} abilities …`);
const abilities = await mapLimit(abilityIndex.results, CONCURRENCY, async (r) => {
const a = await api(r.url);
const en = (a.effect_entries || []).find((e) => e.language.name === 'en');
const flavour = [...(a.flavor_text_entries || [])]
.reverse()
.find((e) => e.language.name === 'en');
return {
id: a.id,
name: a.name,
generation: idFromUrl(a.generation.url),
isMainSeries: a.is_main_series,
effect:
(en && (en.short_effect || en.effect)) ||
(flavour && flavour.flavor_text) ||
'',
pokemon: [
...new Set(a.pokemon.map((x) => idFromUrl(x.pokemon.url)).filter((n) => n <= 100000)),
],
};
});
abilities.sort((a, b) => a.id - b.id);
// ---- Item categories (gives every item a category cheaply) ----
const catIndex = await api('item-category?limit=100');
const items = [];
await mapLimit(catIndex.results, CONCURRENCY, async (c) => {
const cat = await api(c.name.startsWith('http') ? c.name : `item-category/${c.name}`);
for (const it of cat.items) {
items.push({ id: idFromUrl(it.url), name: it.name, category: cat.name });
}
});
items.sort((a, b) => a.id - b.id);
// ---- Write ----------------------------------------------------
const snapshot = {
meta: {
generatedAt: new Date().toISOString(),
source: BASE,
counts: {
species: species.length,
moves: moves.length,
items: items.length,
abilities: abilities.length,
pokedexes: pokedexes.length,
versionGroups: versionGroups.length,
},
},
types,
generations,
species,
moves,
items,
abilities,
pokedexes,
versionGroups,
};
await mkdir(dirname(OUT), { recursive: true });
const body = JSON.stringify(snapshot);
await writeFile(OUT, body);
console.log(
`Wrote ${OUT}${species.length} species, ${pokedexes.length} pokédexes, ` +
`${versionGroups.length} version groups (${(body.length / 1024).toFixed(0)} KB) in ` +
`${((Date.now() - t0) / 1000).toFixed(1)}s`,
);
}
main().catch((err) => {
console.error('\nSnapshot build failed:', err.message);
console.error(
'If you are offline, commit a previously generated src/data/snapshot.json ' +
'or retry when you have a connection.',
);
process.exit(1);
});