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
425 lines
14 KiB
JavaScript
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);
|
|
});
|