dex/scripts/build-snapshot.mjs
chris 40667cbdb6 Forms & variants
- Snapshot: each species gains a forms[] list (slug, form id, label,
  category, types, stats, BST, height, weight) built from its non-default
  varieties — 264 forms across 203 species (Megas, G-max, regional,
  alt formes; cosmetic-only forms filtered out). Snapshot ~914 KB.
- Detail page: a form switcher (Base + each form). Selecting a form
  re-fetches its /pokemon and rebuilds the type-dependent parts —
  hero name/types/artwork, --type theming, stat bars + total, defensive
  matchups, STAB coverage, abilities, EV yield, held items, and learnset.
  Species-level data (flavour, breeding, evolution) stays put.
- Grid cards show a '+N forms' badge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 11:11:07 -04:00

373 lines
12 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 sameShape = (a, b) =>
a.types.join() === b.types.join() && a.bst === b.bst &&
a.stats.hp === b.stats.hp && a.stats.spe === b.stats.spe && a.stats.atk === b.stats.atk;
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);
// 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 = [];
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,
};
// Drop purely-cosmetic forms (identical stats & typing, no named category).
if (cat === 'other' && sameShape(entry, { types: baseTypes, bst: bstOf(stats), stats })) {
continue;
}
forms.push(entry);
}
return {
forms,
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,
};
});
// ---- 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);
});