Files
nuzlocke-tracker/backend/src/app/seeds/fetch_pokeapi.py
Julian Tabel 9cec9836b4 Add Pokemon form support to seeding (Alolan, regional variants, etc.)
Pokemon forms with dex IDs >= 10000 (e.g., Alolan Rattata = 10091) were
being collected in encounter data but missing from pokemon.json, causing
them to be silently dropped during DB seeding. Now fetch_all_pokemon()
also fetches form entries that appear in encounter data, with clean
display names like "Rattata (Alola)" and correct form-specific types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:32:13 +01:00

1415 lines
43 KiB
Python

"""Fetch game data from local PokeAPI submodule and write static JSON seed files.
Reads from the PokeAPI/api-data git submodule at data/pokeapi/ — no network
access or container needed. Only uses Python stdlib.
Usage:
python -m app.seeds.fetch_pokeapi
Requires the submodule to be initialized:
git submodule update --init
"""
import json
import re
import sys
from pathlib import Path
from app.seeds.special_encounters import SPECIAL_ENCOUNTERS
REPO_ROOT = Path(__file__).parents[4] # backend/src/app/seeds -> repo root
POKEAPI_DIR = REPO_ROOT / "data" / "pokeapi" / "data" / "api" / "v2"
SEEDS_DIR = Path(__file__).parent
DATA_DIR = SEEDS_DIR / "data"
def load_resource(endpoint: str, resource_id: int) -> dict:
"""Load a PokeAPI resource from the local submodule data."""
path = POKEAPI_DIR / endpoint / str(resource_id) / "index.json"
with open(path) as f:
return json.load(f)
def extract_id(url: str) -> int:
"""Extract the numeric ID from a PokeAPI URL (absolute or relative)."""
return int(url.rstrip("/").split("/")[-1])
# Game definitions - Gen 1 through Gen 8
VERSION_GROUPS = {
# === Generation 1 ===
"red-blue": {
"versions": ["red", "blue"],
"generation": 1,
"region": "kanto",
"region_id": 1,
"games": {
"red": {
"name": "Pokemon Red",
"slug": "red",
"release_year": 1996,
"color": "#FF1111",
},
"blue": {
"name": "Pokemon Blue",
"slug": "blue",
"release_year": 1996,
"color": "#1111FF",
},
},
},
"yellow": {
"versions": ["yellow"],
"generation": 1,
"region": "kanto",
"region_id": 1,
"games": {
"yellow": {
"name": "Pokemon Yellow",
"slug": "yellow",
"release_year": 1998,
"color": "#FFD733",
},
},
},
# === Generation 2 ===
"gold-silver": {
"versions": ["gold", "silver"],
"generation": 2,
"region": "johto",
"region_id": 2,
"extra_regions": [1], # Kanto post-game
"games": {
"gold": {
"name": "Pokemon Gold",
"slug": "gold",
"release_year": 1999,
"color": "#DAA520",
},
"silver": {
"name": "Pokemon Silver",
"slug": "silver",
"release_year": 1999,
"color": "#C0C0C0",
},
},
},
"crystal": {
"versions": ["crystal"],
"generation": 2,
"region": "johto",
"region_id": 2,
"extra_regions": [1], # Kanto post-game
"games": {
"crystal": {
"name": "Pokemon Crystal",
"slug": "crystal",
"release_year": 2000,
"color": "#4FD9FF",
},
},
},
# === Generation 3 ===
"ruby-sapphire": {
"versions": ["ruby", "sapphire"],
"generation": 3,
"region": "hoenn",
"region_id": 3,
"games": {
"ruby": {
"name": "Pokemon Ruby",
"slug": "ruby",
"release_year": 2002,
"color": "#A00000",
},
"sapphire": {
"name": "Pokemon Sapphire",
"slug": "sapphire",
"release_year": 2002,
"color": "#0000A0",
},
},
},
"emerald": {
"versions": ["emerald"],
"generation": 3,
"region": "hoenn",
"region_id": 3,
"games": {
"emerald": {
"name": "Pokemon Emerald",
"slug": "emerald",
"release_year": 2005,
"color": "#00A000",
},
},
},
"firered-leafgreen": {
"versions": ["firered", "leafgreen"],
"generation": 3,
"region": "kanto",
"region_id": 1,
"games": {
"firered": {
"name": "Pokemon FireRed",
"slug": "firered",
"release_year": 2004,
"color": "#FF7327",
},
"leafgreen": {
"name": "Pokemon LeafGreen",
"slug": "leafgreen",
"release_year": 2004,
"color": "#00DD00",
},
},
},
# === Generation 4 ===
"diamond-pearl": {
"versions": ["diamond", "pearl"],
"generation": 4,
"region": "sinnoh",
"region_id": 4,
"games": {
"diamond": {
"name": "Pokemon Diamond",
"slug": "diamond",
"release_year": 2006,
"color": "#AAAAFF",
},
"pearl": {
"name": "Pokemon Pearl",
"slug": "pearl",
"release_year": 2006,
"color": "#FFAAAA",
},
},
},
"platinum": {
"versions": ["platinum"],
"generation": 4,
"region": "sinnoh",
"region_id": 4,
"games": {
"platinum": {
"name": "Pokemon Platinum",
"slug": "platinum",
"release_year": 2008,
"color": "#999999",
},
},
},
"heartgold-soulsilver": {
"versions": ["heartgold", "soulsilver"],
"generation": 4,
"region": "johto",
"region_id": 2,
"extra_regions": [1], # Kanto post-game
"games": {
"heartgold": {
"name": "Pokemon HeartGold",
"slug": "heartgold",
"release_year": 2010,
"color": "#B69E00",
},
"soulsilver": {
"name": "Pokemon SoulSilver",
"slug": "soulsilver",
"release_year": 2010,
"color": "#C0C0E0",
},
},
},
# === Generation 5 ===
"black-white": {
"versions": ["black", "white"],
"generation": 5,
"region": "unova",
"region_id": 5,
"games": {
"black": {
"name": "Pokemon Black",
"slug": "black",
"release_year": 2010,
"color": "#444444",
},
"white": {
"name": "Pokemon White",
"slug": "white",
"release_year": 2010,
"color": "#E1E1E1",
},
},
},
"black-2-white-2": {
"versions": ["black-2", "white-2"],
"generation": 5,
"region": "unova",
"region_id": 5,
"games": {
"black-2": {
"name": "Pokemon Black 2",
"slug": "black-2",
"release_year": 2012,
"color": "#424B50",
},
"white-2": {
"name": "Pokemon White 2",
"slug": "white-2",
"release_year": 2012,
"color": "#E3CED0",
},
},
},
# === Generation 6 ===
"x-y": {
"versions": ["x", "y"],
"generation": 6,
"region": "kalos",
"region_id": 6,
"games": {
"x": {
"name": "Pokemon X",
"slug": "x",
"release_year": 2013,
"color": "#025DA6",
},
"y": {
"name": "Pokemon Y",
"slug": "y",
"release_year": 2013,
"color": "#EA1A3E",
},
},
},
"omega-ruby-alpha-sapphire": {
"versions": ["omega-ruby", "alpha-sapphire"],
"generation": 6,
"region": "hoenn",
"region_id": 3,
"games": {
"omega-ruby": {
"name": "Pokemon Omega Ruby",
"slug": "omega-ruby",
"release_year": 2014,
"color": "#CF3025",
},
"alpha-sapphire": {
"name": "Pokemon Alpha Sapphire",
"slug": "alpha-sapphire",
"release_year": 2014,
"color": "#26649C",
},
},
},
# === Generation 7 ===
"sun-moon": {
"versions": ["sun", "moon"],
"generation": 7,
"region": "alola",
"region_id": 7,
"games": {
"sun": {
"name": "Pokemon Sun",
"slug": "sun",
"release_year": 2016,
"color": "#F1912B",
},
"moon": {
"name": "Pokemon Moon",
"slug": "moon",
"release_year": 2016,
"color": "#5599CA",
},
},
},
"ultra-sun-ultra-moon": {
"versions": ["ultra-sun", "ultra-moon"],
"generation": 7,
"region": "alola",
"region_id": 7,
"games": {
"ultra-sun": {
"name": "Pokemon Ultra Sun",
"slug": "ultra-sun",
"release_year": 2017,
"color": "#E95B2B",
},
"ultra-moon": {
"name": "Pokemon Ultra Moon",
"slug": "ultra-moon",
"release_year": 2017,
"color": "#204E8C",
},
},
},
"lets-go": {
"versions": ["lets-go-pikachu", "lets-go-eevee"],
"generation": 7,
"region": "kanto",
"region_id": 1,
"games": {
"lets-go-pikachu": {
"name": "Pokemon Let's Go Pikachu",
"slug": "lets-go-pikachu",
"release_year": 2018,
"color": "#F5DA00",
},
"lets-go-eevee": {
"name": "Pokemon Let's Go Eevee",
"slug": "lets-go-eevee",
"release_year": 2018,
"color": "#D4924B",
},
},
},
# === Generation 8 ===
"sword-shield": {
"versions": ["sword", "shield"],
"generation": 8,
"region": "galar",
"region_id": 8,
"games": {
"sword": {
"name": "Pokemon Sword",
"slug": "sword",
"release_year": 2019,
"color": "#00D4E7",
},
"shield": {
"name": "Pokemon Shield",
"slug": "shield",
"release_year": 2019,
"color": "#EF3B6E",
},
},
},
"brilliant-diamond-shining-pearl": {
"versions": ["brilliant-diamond", "shining-pearl"],
"generation": 8,
"region": "sinnoh",
"region_id": 4,
"games": {
"brilliant-diamond": {
"name": "Pokemon Brilliant Diamond",
"slug": "brilliant-diamond",
"release_year": 2021,
"color": "#44BAE5",
},
"shining-pearl": {
"name": "Pokemon Shining Pearl",
"slug": "shining-pearl",
"release_year": 2021,
"color": "#E18AAA",
},
},
},
"legends-arceus": {
"versions": ["legends-arceus"],
"generation": 8,
"region": "hisui",
"region_id": 9,
"games": {
"legends-arceus": {
"name": "Pokemon Legends: Arceus",
"slug": "legends-arceus",
"release_year": 2022,
"color": "#36597B",
},
},
},
# === Generation 9 ===
"scarlet-violet": {
"versions": ["scarlet", "violet"],
"generation": 9,
"region": "paldea",
"region_id": 10,
"games": {
"scarlet": {
"name": "Pokemon Scarlet",
"slug": "scarlet",
"release_year": 2022,
"color": "#F93C3C",
},
"violet": {
"name": "Pokemon Violet",
"slug": "violet",
"release_year": 2022,
"color": "#A96EEC",
},
},
},
}
# Encounter methods to include (excludes gift, legendary-only, etc.)
INCLUDED_METHODS = {
"walk",
"surf",
"old-rod",
"good-rod",
"super-rod",
"rock-smash",
"headbutt",
}
# Route progression order by version group.
# Keys are version group names from VERSION_GROUPS. Names must match
# clean_location_name() output. Routes not listed fall to the end alphabetically.
ROUTE_ORDER: dict[str, list[str]] = {
"firered-leafgreen": [
# Main Kanto progression
"Starter",
"Pallet Town",
"Route 1",
"Viridian City",
"Route 22",
"Route 2",
"Viridian Forest",
"Pewter City",
"Route 3",
"Mt Moon",
"Route 4",
"Cerulean City",
"Route 24",
"Route 25",
"Route 5",
"Route 6",
"Vermilion City",
"Ss Anne",
"Route 11",
"Digletts Cave",
"Route 9",
"Route 10",
"Rock Tunnel",
"Power Plant",
"Route 8",
"Route 7",
"Celadon City",
"Saffron City",
"Lavender Town",
"Route 16",
"Route 17",
"Route 18",
"Fuchsia City",
"Safari Zone",
"Route 15",
"Route 14",
"Route 13",
"Route 12",
"Pokemon Tower",
"Sea Route 19",
"Sea Route 20",
"Seafoam Islands",
"Cinnabar Island",
"Pokemon Mansion",
"Sea Route 21",
"Route 23",
"Victory Road 2",
"Cerulean Cave",
# Sevii Islands
"One Island",
"Kindle Road",
"Treasure Beach",
"Mt Ember",
"Cape Brink",
"Berry Forest",
"Bond Bridge",
"Three Isle Port",
"Four Island",
"Icefall Cave",
"Resort Gorgeous",
"Water Labyrinth",
"Five Island",
"Five Isle Meadow",
"Memorial Pillar",
"Outcast Island",
"Green Path",
"Water Path",
"Ruin Valley",
"Lost Cave",
"Pattern Bush",
"Trainer Tower",
"Canyon Entrance",
"Sevault Canyon",
"Tanoby Ruins",
"Monean Chamber",
"Liptoo Chamber",
"Weepth Chamber",
"Dilford Chamber",
"Scufib Chamber",
"Rixy Chamber",
"Viapos Chamber",
"Altering Cave",
],
"heartgold-soulsilver": [
# Johto
"Starter",
"New Bark Town",
"Route 29",
"Cherrygrove City",
"Route 30",
"Route 31",
"Dark Cave",
"Violet City",
"Sprout Tower",
"Route 32",
"Ruins Of Alph",
"Union Cave",
"Route 33",
"Slowpoke Well",
"Route 34",
"Ilex Forest",
"National Park",
"Route 35",
"Goldenrod City",
"Route 36",
"Route 37",
"Ecruteak City",
"Burned Tower",
"Bell Tower",
"Route 38",
"Route 39",
"Olivine City",
"Sea Route 40",
"Sea Route 41",
"Cianwood City",
"Route 42",
"Mt Mortar",
"Lake Of Rage",
"Route 43",
"Route 44",
"Ice Path",
"Blackthorn City",
"Dragons Den",
"Route 45",
"Route 46",
"Route 47",
"Route 48",
"Safari Zone",
"Whirl Islands",
"Tohjo Falls",
# Kanto post-game
"Route 27",
"Route 26",
"Victory Road 1",
"Route 28",
"Mt Silver",
"Pallet Town",
"Route 1",
"Viridian City",
"Viridian Forest",
"Pewter City",
"Route 2",
"Route 3",
"Mt Moon",
"Route 4",
"Cerulean City",
"Route 24",
"Route 25",
"Cerulean Cave",
"Route 5",
"Route 6",
"Vermilion City",
"Route 7",
"Route 8",
"Route 9",
"Route 10",
"Rock Tunnel",
"Route 11",
"Digletts Cave",
"Route 12",
"Route 13",
"Route 14",
"Route 15",
"Fuchsia City",
"Route 16",
"Route 17",
"Route 18",
"Celadon City",
"Sea Route 19",
"Sea Route 20",
"Seafoam Islands",
"Cinnabar Island",
"Sea Route 21",
"Route 22",
# Misc
"Unknown All Poliwag",
"Unknown All Rattata",
"Unknown All Bugs",
],
"emerald": [
"Starter",
"Route 101",
"Oldale Town",
"Route 103",
"Route 102",
"Petalburg City",
"Route 104",
"Petalburg Woods",
"Rusturf Tunnel",
"Route 116",
"Rustboro City",
"Route 105",
"Route 106",
"Dewford Town",
"Granite Cave",
"Route 107",
"Route 108",
"Route 109",
"Slateport City",
"Route 110",
"New Mauville",
"Route 117",
"Route 111",
"Mirage Tower",
"Route 112",
"Fiery Path",
"Jagged Pass",
"Lavaridge Town",
"Route 113",
"Route 114",
"Meteor Falls",
"Route 115",
"Route 118",
"Route 119",
"Route 120",
"Route 121",
"Safari Zone",
"Lilycove City",
"Route 122",
"Mt Pyre",
"Route 123",
"Magma Hideout",
"Route 124",
"Mossdeep City",
"Route 125",
"Shoal Cave",
"Route 126",
"Sootopolis City",
"Cave Of Origin",
"Route 127",
"Route 128",
"Seafloor Cavern",
"Route 129",
"Route 130",
"Route 131",
"Pacifidlog Town",
"Route 132",
"Route 133",
"Route 134",
"Ever Grande City",
"Victory Road",
"Sky Pillar",
"Abandoned Ship",
"Desert Underpass",
"Artisan Cave",
"Altering Cave",
],
"platinum": [
"Canalave City",
"Pastoria City",
"Sunyshore City",
"Pokemon League",
"Oreburgh Mine",
"Oreburgh Mine (1F)",
"Oreburgh Mine (B1F)",
"Valley Windworks",
"Eterna Forest",
"Eterna City",
"Fuego Ironworks",
"Mt Coronet",
"Mt Coronet (1F Route 207)",
"Mt Coronet (2F)",
"Mt Coronet (3F)",
"Mt Coronet (Exterior Snowfall)",
"Mt Coronet (Exterior Blizzard)",
"Mt Coronet (4F)",
"Mt Coronet (4F Small Room)",
"Mt Coronet (5F)",
"Mt Coronet (6F)",
"Mt Coronet (1F From Exterior)",
"Mt Coronet (1F Route 216)",
"Mt Coronet (1F Route 211)",
"Mt Coronet (B1F)",
"Great Marsh",
"Great Marsh (Area 1)",
"Great Marsh (Area 2)",
"Great Marsh (Area 3)",
"Great Marsh (Area 4)",
"Great Marsh (Area 5)",
"Great Marsh (Area 6)",
"Solaceon Ruins",
"Solaceon Ruins (2F)",
"Solaceon Ruins (1F)",
"Solaceon Ruins (B1F A)",
"Solaceon Ruins (B1F B)",
"Solaceon Ruins (B1F C)",
"Solaceon Ruins (B2F A)",
"Solaceon Ruins (B2F B)",
"Solaceon Ruins (B2F C)",
"Solaceon Ruins (B3F A)",
"Solaceon Ruins (B3F B)",
"Solaceon Ruins (B3F C)",
"Solaceon Ruins (B3F D)",
"Solaceon Ruins (B3F E)",
"Solaceon Ruins (B4F A)",
"Solaceon Ruins (B4F B)",
"Solaceon Ruins (B4F C)",
"Solaceon Ruins (B4F D)",
"Solaceon Ruins (B5F)",
"Victory Road",
"Victory Road (1F)",
"Victory Road (2F)",
"Victory Road (B1F)",
"Victory Road (Inside B1F)",
"Victory Road (Inside)",
"Victory Road (Inside Exit)",
"Ravaged Path",
"Oreburgh Gate",
"Oreburgh Gate (1F)",
"Oreburgh Gate (B1F)",
"Stark Mountain (Entrance)",
"Stark Mountain (Inside)",
"Stark Mountain",
"Turnback Cave",
"Turnback Cave (Pillar 1)",
"Turnback Cave (Pillar 2)",
"Turnback Cave (Pillar 3)",
"Turnback Cave (Before Pillar 1)",
"Turnback Cave (Between Pillars 1 And 2)",
"Turnback Cave (Between Pillars 2 And 3)",
"Turnback Cave (After Pillar 3)",
"Snowpoint Temple",
"Snowpoint Temple (1F)",
"Snowpoint Temple (B1F)",
"Snowpoint Temple (B2F)",
"Snowpoint Temple (B3F)",
"Snowpoint Temple (B4F)",
"Snowpoint Temple (B5F)",
"Wayward Cave",
"Wayward Cave (1F)",
"Wayward Cave (B1F)",
"Ruin Maniac Cave",
"Ruin Maniac Cave (0 9 Different Unown Caught)",
"Ruin Maniac Cave (10 25 Different Unown Caught)",
"Trophy Garden",
"Iron Island (1F)",
"Iron Island (B1F Left)",
"Iron Island (B1F Right)",
"Iron Island (B2F Right)",
"Iron Island (B2F Left)",
"Iron Island (B3F)",
"Iron Island",
"Old Chateau",
"Old Chateau (Entrance)",
"Old Chateau (Dining Room)",
"Old Chateau (2F Private Room)",
"Old Chateau (2F)",
"Old Chateau (2F Leftmost Room)",
"Old Chateau (2F Left Room)",
"Old Chateau (2F Middle Room)",
"Old Chateau (2F Right Room)",
"Old Chateau (2F Rightmost Room)",
"Lake Verity",
"Lake Verity (Before Galactic Intervention)",
"Lake Verity (After Galactic Intervention)",
"Lake Valor",
"Lake Acuity",
"Valor Lakefront",
"Acuity Lakefront",
"Route 201",
"Route 202",
"Route 203",
"Route 204",
"Route 204 (South Towards Jubilife City)",
"Route 204 (North Towards Floaroma Town)",
"Route 205",
"Route 205 (South Towards Floaroma Town)",
"Route 205 (East Towards Eterna City)",
"Route 206",
"Route 207",
"Route 208",
"Route 209",
"Lost Tower",
"Lost Tower (1F)",
"Lost Tower (2F)",
"Lost Tower (3F)",
"Lost Tower (4F)",
"Lost Tower (5F)",
"Route 210",
"Route 210 (South Towards Solaceon Town)",
"Route 210 (West Towards Celestic Town)",
"Route 211",
"Route 211 (West Towards Eterna City)",
"Route 211 (East Towards Celestic Town)",
"Route 212",
"Route 212 (North Towards Hearthome City)",
"Route 212 (East Towards Pastoria City)",
"Route 213",
"Route 214",
"Route 215",
"Route 216",
"Route 217",
"Route 218",
"Route 219",
"Route 221",
"Route 222",
"Route 224",
"Route 225",
"Route 227",
"Route 228",
"Route 229",
"Twinleaf Town",
"Celestic Town",
"Resort Area",
"Sea Route 220",
"Sea Route 223",
"Sea Route 226",
"Sea Route 230",
"Sendoff Spring",
"Maniac Tunnel"
]
}
# Aliases — version groups sharing same route progression
ROUTE_ORDER["red-blue"] = ROUTE_ORDER["firered-leafgreen"]
ROUTE_ORDER["yellow"] = ROUTE_ORDER["firered-leafgreen"]
ROUTE_ORDER["lets-go"] = ROUTE_ORDER["firered-leafgreen"]
ROUTE_ORDER["gold-silver"] = ROUTE_ORDER["heartgold-soulsilver"]
ROUTE_ORDER["crystal"] = ROUTE_ORDER["heartgold-soulsilver"]
ROUTE_ORDER["ruby-sapphire"] = ROUTE_ORDER["emerald"]
ROUTE_ORDER["omega-ruby-alpha-sapphire"] = ROUTE_ORDER["emerald"]
# Collect all pokemon dex numbers across games
all_pokemon_dex: set[int] = set()
def clean_location_name(name: str) -> str:
"""Convert PokeAPI location slug to a clean display name.
e.g. 'kanto-route-1' -> 'Route 1'
'pallet-town' -> 'Pallet Town'
"""
for prefix in [
"kanto-", "johto-", "hoenn-", "sinnoh-",
"unova-", "kalos-", "alola-", "galar-",
]:
if name.startswith(prefix):
name = name[len(prefix):]
break
name = name.replace("-", " ").title()
name = re.sub(r"Route (\d+)", r"Route \1", name)
return name
def clean_area_name(area_name: str, location_name: str) -> str | None:
"""Extract meaningful area suffix, or None if it's the default area."""
if area_name.startswith(location_name):
suffix = area_name[len(location_name):].strip("-").strip()
if not suffix or suffix == "area":
return None
return suffix.replace("-", " ").title()
return area_name.replace("-", " ").title()
def sort_routes_by_progression(routes: list[dict], vg_key: str) -> list[dict]:
"""Sort routes by game progression order for the given version group.
Routes not in the ordering list fall to the end, sorted alphabetically.
Uses starts-with matching so 'Route 2' matches 'Route 2 (South Towards ...)'.
"""
order_list = ROUTE_ORDER.get(vg_key)
if not order_list:
return routes
def sort_key(route: dict) -> tuple[int, str]:
name = route["name"]
for i, ordered_name in enumerate(order_list):
if name == ordered_name or name.startswith(ordered_name + " ("):
return (i, name)
return (len(order_list), name) # Unmatched → end, alphabetical
return sorted(routes, key=sort_key)
def get_encounters_for_area(area_id: int, version_name: str) -> list[dict]:
"""Get encounter data for a location area, filtered by version."""
area = load_resource("location-area", area_id)
encounters = []
for pe in area["pokemon_encounters"]:
pokemon_url = pe["pokemon"]["url"]
dex_num = extract_id(pokemon_url)
pokemon_name = pe["pokemon"]["name"]
for vd in pe["version_details"]:
if vd["version"]["name"] != version_name:
continue
for enc in vd["encounter_details"]:
method = enc["method"]["name"]
if method not in INCLUDED_METHODS:
continue
encounters.append({
"pokemon_name": pokemon_name,
"national_dex": dex_num,
"method": method,
"chance": enc["chance"],
"min_level": enc["min_level"],
"max_level": enc["max_level"],
})
return encounters
def aggregate_encounters(raw_encounters: list[dict]) -> list[dict]:
"""Aggregate encounter rates by pokemon + method (sum chances across level ranges)."""
agg: dict[tuple[int, str], dict] = {}
for enc in raw_encounters:
key = (enc["national_dex"], enc["method"])
if key not in agg:
agg[key] = {
"national_dex": enc["national_dex"],
"pokemon_name": enc["pokemon_name"],
"method": enc["method"],
"encounter_rate": 0,
"min_level": enc["min_level"],
"max_level": enc["max_level"],
}
agg[key]["encounter_rate"] += enc["chance"]
agg[key]["min_level"] = min(agg[key]["min_level"], enc["min_level"])
agg[key]["max_level"] = max(agg[key]["max_level"], enc["max_level"])
result = list(agg.values())
for r in result:
r["encounter_rate"] = min(r["encounter_rate"], 100)
return sorted(result, key=lambda x: (-x["encounter_rate"], x["pokemon_name"]))
def merge_special_encounters(routes: list[dict], special_data: dict[str, list[dict]]) -> list[dict]:
"""Merge special encounters into existing routes or create new ones."""
# Build lookup: route name -> route dict (including children)
route_map: dict[str, dict] = {}
for r in routes:
route_map[r["name"]] = r
for child in r.get("children", []):
route_map[child["name"]] = child
for location_name, encounters in special_data.items():
for enc in encounters:
all_pokemon_dex.add(enc["national_dex"])
if location_name in route_map:
route_map[location_name]["encounters"].extend(encounters)
else:
new_route = {"name": location_name, "order": 0, "encounters": encounters}
routes.append(new_route)
route_map[location_name] = new_route
return routes
def process_version(version_name: str, vg_info: dict, vg_key: str) -> list[dict]:
"""Process all locations for a specific game version.
Creates hierarchical route structure where locations with multiple areas
become parent routes with child routes for each area.
"""
print(f"\n--- Processing {version_name} ---")
region = load_resource("region", vg_info["region_id"])
location_refs = list(region["locations"])
# Include extra regions (e.g., Kanto for Johto games)
for extra_region_id in vg_info.get("extra_regions", []):
extra_region = load_resource("region", extra_region_id)
location_refs = location_refs + list(extra_region["locations"])
print(f" Found {len(location_refs)} locations")
routes = []
for loc_ref in location_refs:
loc_name = loc_ref["name"]
loc_id = extract_id(loc_ref["url"])
display_name = clean_location_name(loc_name)
location = load_resource("location", loc_id)
areas = location["areas"]
if not areas:
continue
all_encounters: list[dict] = []
area_specific: dict[str, list[dict]] = {}
for area_ref in areas:
area_id = extract_id(area_ref["url"])
area_slug = area_ref["name"]
area_suffix = clean_area_name(area_slug, loc_name)
encounters = get_encounters_for_area(area_id, version_name)
if not encounters:
continue
if area_suffix and len(areas) > 1:
area_specific[area_suffix] = encounters
else:
all_encounters.extend(encounters)
# If we have multiple area-specific encounters, create a parent route
# with child routes for each area (hierarchical grouping)
if area_specific and len(area_specific) > 1:
child_routes = []
for area_suffix, area_encs in area_specific.items():
aggregated = aggregate_encounters(area_encs)
if aggregated:
route_name = f"{display_name} ({area_suffix})"
for enc in aggregated:
all_pokemon_dex.add(enc["national_dex"])
child_routes.append({
"name": route_name,
"order": 0,
"encounters": aggregated,
})
# Only add parent if we have child routes
if child_routes:
routes.append({
"name": display_name,
"order": 0,
"encounters": [], # Parent routes have no encounters
"children": child_routes,
})
elif area_specific:
# Only one area-specific route - don't create parent/child, just use suffix
for area_suffix, area_encs in area_specific.items():
aggregated = aggregate_encounters(area_encs)
if aggregated:
route_name = f"{display_name} ({area_suffix})"
for enc in aggregated:
all_pokemon_dex.add(enc["national_dex"])
routes.append({
"name": route_name,
"order": 0,
"encounters": aggregated,
})
# Non-area-specific encounters (or single area without suffix)
if all_encounters:
aggregated = aggregate_encounters(all_encounters)
if aggregated:
for enc in aggregated:
all_pokemon_dex.add(enc["national_dex"])
routes.append({
"name": display_name,
"order": 0,
"encounters": aggregated,
})
# Merge special encounters (starters, gifts, fossils)
special_data = SPECIAL_ENCOUNTERS.get(vg_key, {})
if special_data:
routes = merge_special_encounters(routes, special_data)
# Sort routes by game progression order
routes = sort_routes_by_progression(routes, vg_key)
# Reassign sequential order values
order = 1
for route in routes:
route["order"] = order
order += 1
for child in route.get("children", []):
child["order"] = order
order += 1
# Count routes including children
total_routes = sum(1 + len(r.get("children", [])) for r in routes)
print(f" Routes with encounters: {total_routes}")
total_enc = sum(
len(r["encounters"]) + sum(len(c["encounters"]) for c in r.get("children", []))
for r in routes
)
print(f" Total encounter entries: {total_enc}")
return routes
def format_form_name(poke_data: dict) -> str:
"""Convert a PokeAPI pokemon form entry to a clean display name.
e.g. 'rattata-alola' (species: 'rattata') -> 'Rattata (Alola)'
"""
species_name = poke_data["species"]["name"]
full_name = poke_data["name"]
if full_name.startswith(species_name + "-"):
form_suffix = full_name[len(species_name) + 1:]
base = species_name.title().replace("-", " ")
suffix = form_suffix.title().replace("-", " ")
return f"{base} ({suffix})"
return full_name.title().replace("-", " ")
def fetch_all_pokemon() -> list[dict]:
"""Fetch all Pokemon species + encountered forms from the local PokeAPI data."""
pokemon_dir = POKEAPI_DIR / "pokemon-species"
# Get all base species IDs
all_species = []
for entry in pokemon_dir.iterdir():
if entry.is_dir() and entry.name.isdigit():
dex = int(entry.name)
if dex < 10000:
all_species.append(dex)
# Also include form IDs that appear in encounter data
form_ids = sorted(d for d in all_pokemon_dex if d >= 10000)
all_species.sort()
print(f"\n--- Fetching {len(all_species)} Pokemon species + {len(form_ids)} forms ---")
pokemon_list = []
# Fetch base species
for i, dex in enumerate(all_species, 1):
poke = load_resource("pokemon", dex)
types = [t["type"]["name"] for t in poke["types"]]
pokemon_list.append({
"national_dex": dex,
"name": poke["name"].title().replace("-", " "),
"types": types,
"sprite_url": f"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{dex}.png",
})
if i % 100 == 0 or i == len(all_species):
print(f" Fetched {i}/{len(all_species)} species")
# Fetch forms (from pokemon endpoint, with form-aware naming)
for form_dex in form_ids:
poke = load_resource("pokemon", form_dex)
types = [t["type"]["name"] for t in poke["types"]]
pokemon_list.append({
"national_dex": form_dex,
"name": format_form_name(poke),
"types": types,
"sprite_url": f"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{form_dex}.png",
})
if form_ids:
print(f" Fetched {len(form_ids)} forms")
return sorted(pokemon_list, key=lambda x: x["national_dex"])
def flatten_evolution_chain(chain: dict, seeded_dex: set[int]) -> list[dict]:
"""Recursively flatten a PokeAPI evolution chain into (from, to) pairs."""
pairs = []
from_dex = int(chain["species"]["url"].rstrip("/").split("/")[-1])
for evo in chain.get("evolves_to", []):
to_dex = int(evo["species"]["url"].rstrip("/").split("/")[-1])
for detail in evo["evolution_details"]:
trigger = detail["trigger"]["name"]
min_level = detail.get("min_level")
item = detail.get("item")
if item:
item = item["name"]
held_item = detail.get("held_item")
if held_item:
held_item = held_item["name"]
# Collect other conditions as a string
conditions = []
if detail.get("min_happiness"):
conditions.append(f"happiness >= {detail['min_happiness']}")
if detail.get("min_affection"):
conditions.append(f"affection >= {detail['min_affection']}")
if detail.get("min_beauty"):
conditions.append(f"beauty >= {detail['min_beauty']}")
if detail.get("time_of_day"):
conditions.append(detail["time_of_day"])
if detail.get("known_move"):
conditions.append(f"knows {detail['known_move']['name']}")
if detail.get("known_move_type"):
conditions.append(f"knows {detail['known_move_type']['name']}-type move")
if detail.get("location"):
conditions.append(f"at {detail['location']['name']}")
if detail.get("party_species"):
conditions.append(f"with {detail['party_species']['name']} in party")
if detail.get("party_type"):
conditions.append(f"with {detail['party_type']['name']}-type in party")
if detail.get("gender") is not None:
conditions.append("female" if detail["gender"] == 1 else "male")
if detail.get("needs_overworld_rain"):
conditions.append("raining")
if detail.get("turn_upside_down"):
conditions.append("turn upside down")
if detail.get("trade_species"):
conditions.append(f"trade for {detail['trade_species']['name']}")
if detail.get("relative_physical_stats") is not None:
stat_map = {1: "atk > def", -1: "atk < def", 0: "atk = def"}
conditions.append(stat_map.get(detail["relative_physical_stats"], ""))
condition = ", ".join(conditions) if conditions else None
if from_dex in seeded_dex and to_dex in seeded_dex:
pairs.append({
"from_national_dex": from_dex,
"to_national_dex": to_dex,
"trigger": trigger,
"min_level": min_level,
"item": item,
"held_item": held_item,
"condition": condition,
})
# Recurse into further evolutions
pairs.extend(flatten_evolution_chain(evo, seeded_dex))
return pairs
def fetch_evolution_data(seeded_dex: set[int]) -> list[dict]:
"""Fetch evolution chains from local PokeAPI data for all seeded pokemon."""
print(f"\n--- Fetching evolution chains ---")
# First, get the evolution chain URL for each pokemon species
# Skip form IDs (>= 10000) — they don't have pokemon-species entries
chain_ids: set[int] = set()
dex_sorted = sorted(d for d in seeded_dex if d < 10000)
for i, dex in enumerate(dex_sorted, 1):
species = load_resource("pokemon-species", dex)
chain_url = species["evolution_chain"]["url"]
chain_id = extract_id(chain_url)
chain_ids.add(chain_id)
if i % 50 == 0 or i == len(dex_sorted):
print(f" Species fetched: {i}/{len(dex_sorted)}")
print(f" Found {len(chain_ids)} unique evolution chains")
# Fetch each chain and flatten
all_pairs: list[dict] = []
seen: set[tuple[int, int, str]] = set()
for chain_id in sorted(chain_ids):
chain = load_resource("evolution-chain", chain_id)
pairs = flatten_evolution_chain(chain["chain"], seeded_dex)
for p in pairs:
key = (p["from_national_dex"], p["to_national_dex"], p["trigger"])
if key not in seen:
seen.add(key)
all_pairs.append(p)
print(f" Total evolution pairs: {len(all_pairs)}")
return sorted(all_pairs, key=lambda x: (x["from_national_dex"], x["to_national_dex"]))
def apply_evolution_overrides(evolutions: list[dict]) -> None:
"""Apply overrides from evolution_overrides.json if it exists."""
overrides_path = DATA_DIR / "evolution_overrides.json"
if not overrides_path.exists():
return
with open(overrides_path) as f:
overrides = json.load(f)
# Remove entries
for removal in overrides.get("remove", []):
evolutions[:] = [
e for e in evolutions
if not (e["from_national_dex"] == removal["from_dex"]
and e["to_national_dex"] == removal["to_dex"])
]
# Add entries
for addition in overrides.get("add", []):
evolutions.append({
"from_national_dex": addition["from_dex"],
"to_national_dex": addition["to_dex"],
"trigger": addition.get("trigger", "level-up"),
"min_level": addition.get("min_level"),
"item": addition.get("item"),
"held_item": addition.get("held_item"),
"condition": addition.get("condition"),
})
# Modify entries
for mod in overrides.get("modify", []):
for e in evolutions:
if (e["from_national_dex"] == mod["from_dex"]
and e["to_national_dex"] == mod["to_dex"]):
for key, value in mod.get("set", {}).items():
e[key] = value
# Re-sort
evolutions.sort(key=lambda x: (x["from_national_dex"], x["to_national_dex"]))
print(f" Applied overrides: {len(evolutions)} pairs after overrides")
def write_json(filename: str, data):
path = DATA_DIR / filename
with open(path, "w") as f:
json.dump(data, f, indent=2)
print(f" -> {path}")
def main():
if not POKEAPI_DIR.is_dir():
print(
f"Error: PokeAPI data not found at {POKEAPI_DIR}\n"
"Initialize the submodule with: git submodule update --init",
file=sys.stderr,
)
sys.exit(1)
DATA_DIR.mkdir(parents=True, exist_ok=True)
# Build games.json
games = []
for vg_info in VERSION_GROUPS.values():
for game_info in vg_info["games"].values():
games.append({
"name": game_info["name"],
"slug": game_info["slug"],
"generation": vg_info["generation"],
"region": vg_info["region"],
"release_year": game_info["release_year"],
"color": game_info.get("color"),
})
write_json("games.json", games)
print(f"Wrote {len(games)} games to games.json")
# Process each version
for vg_key, vg_info in VERSION_GROUPS.items():
for ver_name in vg_info["versions"]:
routes = process_version(ver_name, vg_info, vg_key)
write_json(f"{ver_name}.json", routes)
# Fetch all Pokemon species
pokemon = fetch_all_pokemon()
write_json("pokemon.json", pokemon)
print(f"\nWrote {len(pokemon)} Pokemon to pokemon.json")
# Build set of all seeded dex numbers for evolution filtering
all_seeded_dex = {p["national_dex"] for p in pokemon}
# Fetch evolution chains for all seeded Pokemon
evolutions = fetch_evolution_data(all_seeded_dex)
apply_evolution_overrides(evolutions)
write_json("evolutions.json", evolutions)
print(f"\nWrote {len(evolutions)} evolution pairs to evolutions.json")
print("\nDone! JSON files written to seeds/data/")
print("Review route ordering and curate as needed.")
if __name__ == "__main__":
main()