"""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()