Files
personal-website/scripts/build-pokemon-catalog.py
Stan Runge a10585a4d2
All checks were successful
Test and deploy staging / staging (push) Successful in 18s
add game tracker with owned pokemon data and call of duty mastery
Fold catchemall into /projects/game-tracker (old URL redirects) and add
Call of Duty tracking for Black Ops through WWII.

- Pokémon catalog is now owned: locations, encounters and per-game
  evolution methods are built from the pinned PokéAPI snapshot and seeded.
- Pokémon you can evolve or breed in a game count as obtainable; mythicals
  without another route are marked event-only.
- Detail dialog with evolution chain and where to find it, location pages
  with admin notes and pictures, and admin corrections that survive reseeds.
- Call of Duty weapons, milestones and Steam achievements per game, with
  derived Diamond / Dark Matter / Solar / Black Sky / Chrome tiers.
- New migration (db:migrate:game-tracker), seeds (db:seed, db:seed:cod) and
  deploy preflight checks for them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:36:18 +00:00

424 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Build the owned Pokémon catalog from a pinned PokéAPI CSV snapshot.
Writes two files:
data/pokemon-catalog.json species, games, per-game availability, evolutions
data/pokemon-locations.json locations and aggregated wild/gift/static encounters
Availability per species/game:
catchable documented encounter (wild, gift, static, in-game trade, ...)
evolve a pre-evolution is obtainable in that game and the method exists there
breed an obtainable relative can breed it in that game
event_only mythical Pokémon present in the game without another route
unknown present in the game's Pokédex/index data, route not confirmed
Run with --source-dir to reuse downloaded CSVs. Default downloads into a temporary
folder. Encounter absence is not evidence of trade/transfer/event exclusivity.
"""
import argparse
import concurrent.futures
import csv
import json
from pathlib import Path
import re
import tempfile
import urllib.request
REVISION = '575291cdb197a7e3a320297be276c9de4ef8401a'
TABLES = ['pokemon', 'pokemon_species', 'pokemon_species_names', 'pokemon_types',
'pokemon_stats', 'type_names', 'versions', 'version_names', 'version_groups',
'version_group_regions', 'regions', 'region_names', 'pokemon_dex_numbers',
'pokedex_version_groups', 'pokemon_game_indices', 'encounters', 'encounter_slots',
'encounter_methods', 'encounter_method_prose', 'encounter_condition_values',
'encounter_condition_value_prose', 'encounter_condition_value_map', 'locations',
'location_names', 'location_areas', 'location_area_prose', 'pokemon_evolution',
'evolution_trigger_prose', 'item_names', 'move_names', 'pokemon_egg_groups',
'pokemon_forms', 'pokemon_form_names']
# Version groups without a Day Care, Nursery or picnic eggs.
NO_BREEDING = {'red-blue', 'yellow', 'red-green-japan', 'blue-japan', 'colosseum', 'xd',
'lets-go-pikachu-lets-go-eevee', 'legends-arceus', 'legends-za',
'mega-dimension', 'champions'}
# Baby Pokémon that only hatch when a parent holds an incense; species number -> item.
INCENSE = {298: 'Sea Incense', 360: 'Lax Incense', 406: 'Rose Incense', 433: 'Pure Incense',
438: 'Rock Incense', 439: 'Odd Incense', 440: 'Luck Incense', 446: 'Full Incense',
458: 'Wave Incense'}
NO_EGGS = 15
METHOD_LABELS = {
'walk': 'Grass or cave', 'old-rod': 'Old Rod', 'good-rod': 'Good Rod', 'super-rod': 'Super Rod',
'surf': 'Surfing', 'rock-smash': 'Rock Smash', 'headbutt': 'Headbutt', 'dark-grass': 'Dark grass',
'grass-spots': 'Rustling grass', 'cave-spots': 'Dust cloud', 'bridge-spots': 'Bridge shadow',
'super-rod-spots': 'Super Rod (rippling water)', 'surf-spots': 'Surfing (rippling water)',
'yellow-flowers': 'Yellow flowers', 'purple-flowers': 'Purple flowers', 'red-flowers': 'Red flowers',
'rough-terrain': 'Rough terrain', 'gift': 'Gift', 'gift-egg': 'Gift egg', 'static': 'Static encounter',
'pokeflute': 'Poké Flute', 'headbutt-low': 'Headbutt (rare trees)', 'headbutt-normal': 'Headbutt (common trees)',
'headbutt-high': 'Headbutt (frequent trees)', 'squirt-bottle': 'Squirt Bottle', 'wailmer-pail': 'Wailmer Pail',
'seaweed': 'Diving in seaweed', 'roaming-grass': 'Roaming (grass)', 'roaming-water': 'Roaming (water)',
'devon-scope': 'Devon Scope', 'feebas-tile-fishing': 'Fishing on Feebas tiles', 'island-scan': 'Island Scan',
'sos': 'SOS battle', 'bubbling-spots': 'Fishing in bubbling spots', 'berry-trees': 'Berry tree',
'npc-trade': 'In-game trade', 'sos-from-bubbling-spot': 'SOS battle (bubbling spot)',
'overworld': 'Overworld', 'overworld-water': 'Overworld (water)', 'overworld-flying': 'Overworld (flying)',
'overworld-special': 'Overworld (special)', 'overworld-flying-special': 'Overworld (flying, special)',
'overworld-water-special': 'Overworld (water, special)', 'overworld-dirt': 'Overworld (dirt)',
'horde': 'Horde encounter', 'snag': 'Snag from a trainer', 'snag-rematch': 'Snag from a trainer (rematch)',
'pokespot': 'Poké Spot', 'hidden-grotto': 'Hidden Grotto', 'honey-tree': 'Honey tree',
'wanderer': 'Wanderer', 'wanderer-water': 'Wanderer (water)', 'chase-water': 'Chasing (water)',
'dynamax-adventure': 'Dynamax Adventure', 'max-raid': 'Max Raid Battle',
}
TIMES = {'day': 'during the day', 'night': 'at night', 'dusk': 'at dusk', 'full-moon': 'during a full moon'}
def build(directory):
def rows(name):
with (directory / f'{name}.csv').open() as f:
return list(csv.DictReader(f))
def english(name, key, field='name'):
return {int(r[key]): r[field] for r in rows(name) if r['local_language_id'] == '9'}
def number(value):
return int(value) if value not in ('', None) else None
species = rows('pokemon_species')
pokemon = rows('pokemon')
species_for = {int(p['id']): int(p['species_id']) for p in pokemon}
defaults = {int(p['species_id']): int(p['id']) for p in pokemon if p['is_default'] == '1'}
names = english('pokemon_species_names', 'pokemon_species_id')
type_names = english('type_names', 'type_id')
types = {}
for p in rows('pokemon_types'):
types.setdefault(int(p['pokemon_id']), {})[int(p['slot'])] = type_names[int(p['type_id'])]
stats = {}
for p in rows('pokemon_stats'):
stats.setdefault(int(p['pokemon_id']), {})[int(p['stat_id'])] = int(p['base_stat'])
stat_fields = ['hp', 'attack', 'defense', 'specialAttack', 'specialDefense', 'speed']
egg_groups = {}
for p in rows('pokemon_egg_groups'):
egg_groups.setdefault(int(p['species_id']), set()).add(int(p['egg_group_id']))
evolves_from = {int(s['id']): number(s['evolves_from_species_id']) for s in species}
chain_of = {int(s['id']): int(s['evolution_chain_id']) for s in species}
mythical = {int(s['id']) for s in species if s['is_mythical'] == '1'}
mons = []
for s in species:
n = int(s['id'])
default = defaults[n]
mons.append(dict(number=n, name=names[n], generation=int(s['generation_id']),
type1=types[default][1], type2=types[default].get(2),
**{name: stats[default][i + 1] for i, name in enumerate(stat_fields)},
evolvesFrom=evolves_from[n], evolutionChain=chain_of[n],
isBaby=s['is_baby'] == '1', isLegendary=s['is_legendary'] == '1',
isMythical=n in mythical))
# ── Games ────────────────────────────────────────────────────────────
groups = {int(g['id']): g for g in rows('version_groups')}
group_order = {g: int(groups[g]['order']) for g in groups}
regions_for_group = {}
for r in rows('version_group_regions'):
regions_for_group.setdefault(int(r['version_group_id']), set()).add(int(r['region_id']))
region_names = english('region_names', 'region_id')
versions = rows('versions')
game_names = english('version_names', 'version_id')
games, version_group = [], {}
for v in versions:
vid, group = int(v['id']), int(v['version_group_id'])
version_group[vid] = group
name = game_names.get(vid, v['identifier'].replace('-', ' ').title())
if v['identifier'].endswith('-japan'):
name += ' (Japan)'
games.append(dict(sourceId=vid, name=name, slug=v['identifier'],
generation=int(groups[group]['generation_id']), sortOrder=group_order[group],
breeding=groups[group]['identifier'] not in NO_BREEDING))
versions_for_group = {}
for v in versions:
versions_for_group.setdefault(int(v['version_group_id']), []).append(int(v['id']))
# ── Presence (Pokédex membership and game indices) ───────────────────
groups_for_dex = {}
for d in rows('pokedex_version_groups'):
groups_for_dex.setdefault(int(d['pokedex_id']), []).append(int(d['version_group_id']))
present = set()
for p in rows('pokemon_dex_numbers'):
for group in groups_for_dex.get(int(p['pokedex_id']), []):
for version in versions_for_group.get(group, []):
present.add((int(p['species_id']), version))
for p in rows('pokemon_game_indices'):
if int(p['game_index']) > 0:
present.add((species_for[int(p['pokemon_id'])], int(p['version_id'])))
# ── Locations and encounters ─────────────────────────────────────────
location_names = english('location_names', 'location_id')
location_rows = rows('locations')
location_region = {int(l['id']): number(l['region_id']) for l in location_rows}
locations = [dict(sourceId=int(l['id']), slug=l['identifier'],
name=location_names.get(int(l['id']), l['identifier'].replace('-', ' ').title()),
region=region_names.get(number(l['region_id']) or 0))
for l in location_rows]
location_label = {l['sourceId']: l['name'] for l in locations}
area_prose = english('location_area_prose', 'location_area_id')
areas = {}
for a in rows('location_areas'):
location = int(a['location_id'])
label = None
if a['identifier']:
prose = area_prose.get(int(a['id']), '')
inner = re.fullmatch(re.escape(location_names.get(location, '')) + r' \((.+)\)', prose)
label = inner.group(1) if inner else ' '.join(
w.upper() if re.fullmatch(r'b?\d+f', w) else w for w in a['identifier'].split('-')).capitalize()
areas[int(a['id'])] = (location, label)
method_prose = english('encounter_method_prose', 'encounter_method_id')
method_label = {int(m['id']): METHOD_LABELS.get(m['identifier'], method_prose.get(int(m['id']), m['identifier']))
for m in rows('encounter_methods')}
slots = {int(s['id']): (int(s['encounter_method_id']), number(s['rarity'])) for s in rows('encounter_slots')}
condition_prose = english('encounter_condition_value_prose', 'encounter_condition_value_id')
species_ids = {s['identifier']: int(s['id']) for s in species}
condition_text = {}
for c in rows('encounter_condition_values'):
value, kind, identifier = int(c['id']), int(c['encounter_condition_id']), c['identifier']
if kind == 15: # in-game trade: the Pokémon the NPC wants
wanted = identifier.removeprefix('trade-')
condition_text[value] = f"In exchange for {names[species_ids[wanted]] if wanted in species_ids else wanted.replace('-', ' ')}"
elif kind == 16: # Game Corner prize
condition_text[value] = f"{identifier.removeprefix('coins-')} Game Corner coins"
elif c['is_default'] != '1':
condition_text[value] = condition_prose.get(value) or re.sub(
r'^(other|story-progress|item)-', '', identifier).replace('-', ' ').capitalize()
conditions = {}
for c in rows('encounter_condition_value_map'):
value = int(c['encounter_condition_value_id'])
if value in condition_text:
conditions.setdefault(int(c['encounter_id']), set()).add(condition_text[value])
form_names = {int(f['pokemon_form_id']): f['form_name'] for f in rows('pokemon_form_names')
if f['local_language_id'] == '9' and f['form_name']}
form_for = {int(f['pokemon_id']): form_names.get(int(f['id'])) for f in rows('pokemon_forms')
if f['is_default'] == '1' and int(f['pokemon_id']) not in defaults.values()}
grouped = {}
for e in rows('encounters'):
pid, version = int(e['pokemon_id']), int(e['version_id'])
location, area = areas[int(e['location_area_id'])]
method, rarity = slots[int(e['encounter_slot_id'])]
key = (species_for[pid], version, location, area, method_label[method], form_for.get(pid),
tuple(sorted(conditions.get(int(e['id']), ()))))
low, high, total = grouped.get(key, (999, 0, 0))
grouped[key] = (min(low, int(e['min_level'])), max(high, int(e['max_level'])), total + (rarity or 0))
# Max Raid dens repeat every species per den and star level; fold them per location.
raids = {}
for key in [k for k in grouped if k[4] == METHOD_LABELS['max-raid']]:
s, v, loc, area, method, form, conds = key
low, high, _ = grouped.pop(key)
raid = raids.setdefault((s, v, loc, form), dict(dens=set(), stars=set(), beams=set(), low=999, high=0))
if area:
raid['dens'].add(area.removeprefix('Max Den '))
raid['low'], raid['high'] = min(raid['low'], low), max(raid['high'], high)
for condition in conds:
star = re.search(r'difficulty of (\d) star', condition)
if star:
raid['stars'].add(int(star.group(1)))
elif 'pillar of light' in condition:
raid['beams'].add('Wild Area News' if 'News' in condition else 'purple' if 'purple' in condition else 'red')
for (s, v, loc, form), raid in raids.items():
stars = sorted(raid['stars'])
conds = [f'{stars[0]}★' + (f'–{stars[-1]}★' if len(stars) > 1 else '') + ' raids'] if stars else []
if raid['beams'] == {'purple'}:
conds.append('Rare (purple) beam dens only')
if 'Wild Area News' in raid['beams']:
conds.append('Wild Area News event dens')
dens = sorted(raid['dens'])
area = (('Max Den ' if len(dens) == 1 else 'Max Dens ') + ', '.join(dens)) if dens else None
grouped[(s, v, loc, area, METHOD_LABELS['max-raid'], form, tuple(conds))] = (raid['low'], raid['high'], 0)
encounters = [[s, v, loc, area, method, low, high, min(total, 100) or None, form, list(conds)]
for (s, v, loc, area, method, form, conds), (low, high, total) in sorted(
grouped.items(), key=lambda item: tuple('' if x is None else str(x) for x in item[0]))]
catchable, where = set(), {}
for s, v, loc, *_ in encounters:
catchable.add((s, v))
seen = where.setdefault((s, v), [])
if location_label[loc] not in seen:
seen.append(location_label[loc])
# ── Evolution methods per game ───────────────────────────────────────
items = english('item_names', 'item_id')
moves = english('move_names', 'move_id')
triggers = english('evolution_trigger_prose', 'evolution_trigger_id')
def describe(r):
trigger = int(r['evolution_trigger_id'])
level = number(r['minimum_level'])
if trigger == 1:
text = f'Level {level}+' if level else 'Level up'
elif trigger == 2:
text = 'Trade'
elif trigger == 3:
text = f"Use a {items[int(r['trigger_item_id'])]}"
else:
text = triggers[trigger]
parts = [text]
if r['held_item_id']:
parts.append(f"holding a {items[int(r['held_item_id'])]}")
if r['known_move_id']:
parts.append(f"knowing {moves[int(r['known_move_id'])]}")
if r['known_move_type_id']:
parts.append(f"knowing a {type_names[int(r['known_move_type_id'])]}-type move")
if r['minimum_happiness']:
parts.append('with high friendship')
if r['minimum_affection']:
parts.append('with high affection')
if r['minimum_beauty']:
parts.append('with high Beauty')
if r['time_of_day']:
parts.append(TIMES.get(r['time_of_day'], r['time_of_day']))
if r['near_special_rock'] == '1':
parts.append('near the special rock')
if r['location_id']:
parts.append(f"at {location_label[int(r['location_id'])]}")
if r['party_species_id']:
parts.append(f"with {names[int(r['party_species_id'])]} in the party")
if r['party_type_id']:
parts.append(f"with a {type_names[int(r['party_type_id'])]}-type Pokémon in the party")
if r['trade_species_id']:
parts.append(f"for a {names[int(r['trade_species_id'])]}")
if r['relative_physical_stats']:
parts.append({'1': 'if Attack > Defense', '0': 'if Attack = Defense', '-1': 'if Attack < Defense'}[r['relative_physical_stats']])
if r['gender_id']:
parts.append('(female)' if r['gender_id'] == '1' else '(male)')
if r['needs_overworld_rain'] == '1':
parts.append('while it is raining')
if r['turn_upside_down'] == '1':
parts.append('holding the console upside down')
if r['used_move_id']:
parts.append(f"after using {moves[int(r['used_move_id'])]} {r['minimum_move_count'] or ''} times".replace(' ', ' '))
if r['minimum_steps']:
parts.append(f"after walking {r['minimum_steps']} steps with it")
if r['minimum_damage_taken']:
parts.append(f"after losing {r['minimum_damage_taken']}+ HP without fainting")
if r['needs_multiplayer'] == '1':
parts.append('in Union Circle')
if r['nature_bitmask']:
parts.append('(form depends on nature)')
if r['percentage_chance']:
parts.append(f"({r['percentage_chance']}% chance)")
if r['region_id']:
parts.append(f"in {region_names[int(r['region_id'])]}")
return ' '.join(parts)
evolution_rows = {}
for r in rows('pokemon_evolution'):
evolution_rows.setdefault(int(r['evolved_species_id']), []).append(r)
# Methods are dated from Red/Blue; the earlier Japanese Red/Green/Blue share them.
first_order = min(group_order[int(r['version_group_id'])] for rs in evolution_rows.values() for r in rs)
def methods(target, version):
group = version_group[version]
regions = regions_for_group.get(group, set())
valid = [r for r in evolution_rows.get(target, [])
if group_order[int(r['version_group_id'])] <= max(group_order[group], first_order)
and (not r['region_id'] or int(r['region_id']) in regions)
and (not r['location_id'] or location_region[int(r['location_id'])] in regions)]
# A regional variant (e.g. Alolan Raichu in Alola) replaces the generic method.
regional = {r['required_pokemon_form_id'] for r in valid if r['region_id']}
valid = [r for r in valid if r['region_id'] or r['required_pokemon_form_id'] not in regional]
# Each form variant uses the method from the latest version group that defines it.
latest = {}
for r in valid:
key = (r['required_pokemon_form_id'], r['evolved_pokemon_form_id'])
latest[key] = max(latest.get(key, 0), group_order[int(r['version_group_id'])])
chosen = [describe(r) for r in valid
if group_order[int(r['version_group_id'])] == latest[(r['required_pokemon_form_id'], r['evolved_pokemon_form_id'])]]
return list(dict.fromkeys(chosen))
# ── Availability ─────────────────────────────────────────────────────
availability, notes = {}, {}
for key in present | catchable:
availability[key] = 'catchable' if key in catchable else 'unknown'
if key in catchable:
places = where[key]
notes[key] = ', '.join(places[:3]) + (f' (+{len(places) - 3} more)' if len(places) > 3 else '')
chain_members = {}
for n, chain in chain_of.items():
chain_members.setdefault(chain, []).append(n)
obtainable = {'catchable', 'evolve', 'breed'}
evolutions = {}
for game in games:
v = game['sourceId']
changed = True
while changed:
changed = False
for n in evolves_from:
key = (n, v)
if availability.get(key) != 'unknown':
continue
parent = evolves_from[n]
if parent and availability.get((parent, v)) in obtainable:
found = methods(n, v)
if found:
availability[key], notes[key] = 'evolve', f"Evolve {names[parent]}: {' or '.join(found)}"
changed = True
continue
if game['breeding'] and not parent:
parents = [m for m in chain_members[chain_of[n]] if m != n
and availability.get((m, v)) in obtainable
and NO_EGGS not in egg_groups.get(m, {NO_EGGS})]
if parents:
incense = f' holding a {INCENSE[n]}' if n in INCENSE else ''
availability[key], notes[key] = 'breed', f'Breed {names[parents[0]]}{incense}'
changed = True
for n in mythical:
if availability.get((n, v)) == 'unknown':
availability[(n, v)] = 'event_only'
notes[(n, v)] = 'Mythical: normally only through an event distribution or special unlock'
for n, parent in evolves_from.items():
if parent and (n, v) in availability and (parent, v) in availability:
for method in methods(n, v):
evolutions.setdefault((parent, n, method), []).append(v)
entries = []
for (n, v), value in sorted(availability.items()):
entries.append([n, v, value, notes[(n, v)]] if (n, v) in notes else [n, v, value])
source = {'repository': 'https://github.com/PokeAPI/pokeapi', 'revision': REVISION}
catalog = dict(
source=dict(source, scope='All species and game versions in this snapshot; default-form types/stats. '
'Evolve/breed/event availability is derived from evolution, egg group and mythical data. '
'Unknown means presence without a confirmed route. Missing pairs have no source record, not proven absence.'),
pokemon=mons, games=games, entries=entries,
evolutions=[[a, b, versions, method] for (a, b, method), versions in sorted(evolutions.items())])
# Methods and conditions repeat thousands of times; store them once and reference by index.
methods_used = sorted({e[4] for e in encounters})
conditions_used = sorted({c for e in encounters for c in e[9]})
for e in encounters:
e[4] = methods_used.index(e[4])
e[9] = [conditions_used.index(c) for c in e[9]]
location_catalog = dict(source=dict(source, scope='Locations and encounters aggregated per species, game, '
'area, method, form and non-default condition. Rarity is the summed slot '
'percentage. Encounter rows are [species, version, location, area, method index, '
'min level, max level, rarity, form, condition indexes].'),
locations=locations, methods=methods_used, conditions=conditions_used,
encounters=encounters)
return catalog, location_catalog
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--source-dir', type=Path)
args = parser.parse_args()
with tempfile.TemporaryDirectory() as temporary:
directory = args.source_dir or Path(temporary)
if not args.source_dir:
def download(table):
url = f'https://raw.githubusercontent.com/PokeAPI/pokeapi/{REVISION}/data/v2/csv/{table}.csv'
with urllib.request.urlopen(url, timeout=60) as response:
(directory / f'{table}.csv').write_bytes(response.read())
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
list(executor.map(download, TABLES))
catalog, locations = build(directory)
data = Path(__file__).resolve().parent.parent / 'data'
for name, content in [('pokemon-catalog.json', catalog), ('pokemon-locations.json', locations)]:
(data / name).write_text(json.dumps(content, ensure_ascii=False, separators=(',', ':')) + '\n')
counts = {}
for entry in catalog['entries']:
counts[entry[2]] = counts.get(entry[2], 0) + 1
print(f"Built {len(catalog['pokemon'])} species, {len(catalog['games'])} versions, "
f"{len(catalog['entries'])} entries {counts}, {len(catalog['evolutions'])} evolution methods, "
f"{len(locations['locations'])} locations, {len(locations['encounters'])} encounters")
if __name__ == '__main__':
main()