"""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 REPO_ROOT = Path(__file__).parents[4] # backend/src/app/seeds -> repo root POKEAPI_DIR = REPO_ROOT / "data" / "pokeapi" / "data" / "api" / "v2" DATA_DIR = Path(__file__).parent / "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", } # 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 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 process_version(version_name: str, vg_info: dict) -> 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 = [] order = 1 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: # Create parent route (no encounters - just a logical grouping) parent_order = order order += 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": order, "encounters": aggregated, }) order += 1 # Only add parent if we have child routes if child_routes: routes.append({ "name": display_name, "order": parent_order, "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": order, "encounters": aggregated, }) order += 1 # 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": order, "encounters": aggregated, }) 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 fetch_all_pokemon() -> list[dict]: """Fetch all Pokemon species from the local PokeAPI data.""" pokemon_dir = POKEAPI_DIR / "pokemon-species" # Get all species IDs (directories with numeric names, excluding forms 10000+) all_species = [] for entry in pokemon_dir.iterdir(): if entry.is_dir() and entry.name.isdigit(): dex = int(entry.name) if dex < 10000: # Exclude alternate forms all_species.append(dex) all_species.sort() print(f"\n--- Fetching {len(all_species)} Pokemon species ---") pokemon_list = [] 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)}") 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 chain_ids: set[int] = set() dex_sorted = sorted(seeded_dex) 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_info in VERSION_GROUPS.values(): for ver_name in vg_info["versions"]: routes = process_version(ver_name, vg_info) 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()