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>
This commit is contained in:
2026-02-07 14:32:13 +01:00
parent 5edda2dba9
commit 9cec9836b4
4 changed files with 420 additions and 80 deletions

View File

@@ -85,71 +85,9 @@
}
]
},
{
"name": "Eterna City",
"order": 2,
"encounters": [
{
"national_dex": 129,
"pokemon_name": "magikarp",
"method": "old-rod",
"encounter_rate": 100,
"min_level": 3,
"max_level": 15
},
{
"national_dex": 54,
"pokemon_name": "psyduck",
"method": "surf",
"encounter_rate": 90,
"min_level": 20,
"max_level": 30
},
{
"national_dex": 130,
"pokemon_name": "gyarados",
"method": "super-rod",
"encounter_rate": 55,
"min_level": 30,
"max_level": 55
},
{
"national_dex": 129,
"pokemon_name": "magikarp",
"method": "good-rod",
"encounter_rate": 55,
"min_level": 10,
"max_level": 25
},
{
"national_dex": 339,
"pokemon_name": "barboach",
"method": "good-rod",
"encounter_rate": 45,
"min_level": 10,
"max_level": 25
},
{
"national_dex": 340,
"pokemon_name": "whiscash",
"method": "super-rod",
"encounter_rate": 45,
"min_level": 30,
"max_level": 55
},
{
"national_dex": 55,
"pokemon_name": "golduck",
"method": "surf",
"encounter_rate": 10,
"min_level": 20,
"max_level": 40
}
]
},
{
"name": "Pastoria City",
"order": 3,
"order": 2,
"encounters": [
{
"national_dex": 129,
@@ -235,7 +173,7 @@
},
{
"name": "Sunyshore City",
"order": 4,
"order": 3,
"encounters": [
{
"national_dex": 129,
@@ -321,7 +259,7 @@
},
{
"name": "Pokemon League",
"order": 5,
"order": 4,
"encounters": [
{
"national_dex": 129,
@@ -399,12 +337,12 @@
},
{
"name": "Oreburgh Mine",
"order": 6,
"order": 5,
"encounters": [],
"children": [
{
"name": "Oreburgh Mine (1F)",
"order": 7,
"order": 6,
"encounters": [
{
"national_dex": 74,
@@ -434,7 +372,7 @@
},
{
"name": "Oreburgh Mine (B1F)",
"order": 8,
"order": 7,
"encounters": [
{
"national_dex": 74,
@@ -466,7 +404,7 @@
},
{
"name": "Valley Windworks",
"order": 9,
"order": 8,
"encounters": [
{
"national_dex": 129,
@@ -600,7 +538,7 @@
},
{
"name": "Eterna Forest",
"order": 10,
"order": 9,
"encounters": [
{
"national_dex": 406,
@@ -756,6 +694,68 @@
}
]
},
{
"name": "Eterna City",
"order": 10,
"encounters": [
{
"national_dex": 129,
"pokemon_name": "magikarp",
"method": "old-rod",
"encounter_rate": 100,
"min_level": 3,
"max_level": 15
},
{
"national_dex": 54,
"pokemon_name": "psyduck",
"method": "surf",
"encounter_rate": 90,
"min_level": 20,
"max_level": 30
},
{
"national_dex": 130,
"pokemon_name": "gyarados",
"method": "super-rod",
"encounter_rate": 55,
"min_level": 30,
"max_level": 55
},
{
"national_dex": 129,
"pokemon_name": "magikarp",
"method": "good-rod",
"encounter_rate": 55,
"min_level": 10,
"max_level": 25
},
{
"national_dex": 339,
"pokemon_name": "barboach",
"method": "good-rod",
"encounter_rate": 45,
"min_level": 10,
"max_level": 25
},
{
"national_dex": 340,
"pokemon_name": "whiscash",
"method": "super-rod",
"encounter_rate": 45,
"min_level": 30,
"max_level": 55
},
{
"national_dex": 55,
"pokemon_name": "golduck",
"method": "surf",
"encounter_rate": 10,
"min_level": 20,
"max_level": 40
}
]
},
{
"name": "Fuego Ironworks",
"order": 11,

View File

@@ -8724,5 +8724,145 @@
"ghost"
],
"sprite_url": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/1025.png"
},
{
"national_dex": 10016,
"name": "Basculin (Blue Striped)",
"types": [
"water"
],
"sprite_url": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/10016.png"
},
{
"national_dex": 10091,
"name": "Rattata (Alola)",
"types": [
"dark",
"normal"
],
"sprite_url": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/10091.png"
},
{
"national_dex": 10092,
"name": "Raticate (Alola)",
"types": [
"dark",
"normal"
],
"sprite_url": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/10092.png"
},
{
"national_dex": 10101,
"name": "Sandshrew (Alola)",
"types": [
"ice",
"steel"
],
"sprite_url": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/10101.png"
},
{
"national_dex": 10103,
"name": "Vulpix (Alola)",
"types": [
"ice"
],
"sprite_url": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/10103.png"
},
{
"national_dex": 10105,
"name": "Diglett (Alola)",
"types": [
"ground",
"steel"
],
"sprite_url": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/10105.png"
},
{
"national_dex": 10106,
"name": "Dugtrio (Alola)",
"types": [
"ground",
"steel"
],
"sprite_url": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/10106.png"
},
{
"national_dex": 10107,
"name": "Meowth (Alola)",
"types": [
"dark"
],
"sprite_url": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/10107.png"
},
{
"national_dex": 10109,
"name": "Geodude (Alola)",
"types": [
"rock",
"electric"
],
"sprite_url": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/10109.png"
},
{
"national_dex": 10110,
"name": "Graveler (Alola)",
"types": [
"rock",
"electric"
],
"sprite_url": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/10110.png"
},
{
"national_dex": 10112,
"name": "Grimer (Alola)",
"types": [
"poison",
"dark"
],
"sprite_url": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/10112.png"
},
{
"national_dex": 10114,
"name": "Exeggutor (Alola)",
"types": [
"grass",
"dragon"
],
"sprite_url": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/10114.png"
},
{
"national_dex": 10123,
"name": "Oricorio (Pom Pom)",
"types": [
"electric",
"flying"
],
"sprite_url": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/10123.png"
},
{
"national_dex": 10124,
"name": "Oricorio (Pau)",
"types": [
"psychic",
"flying"
],
"sprite_url": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/10124.png"
},
{
"national_dex": 10125,
"name": "Oricorio (Sensu)",
"types": [
"ghost",
"flying"
],
"sprite_url": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/10125.png"
},
{
"national_dex": 10126,
"name": "Lycanroc (Midnight)",
"types": [
"rock"
],
"sprite_url": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/10126.png"
}
]

View File

@@ -19,7 +19,8 @@ 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"
DATA_DIR = Path(__file__).parent / "data"
SEEDS_DIR = Path(__file__).parent
DATA_DIR = SEEDS_DIR / "data"
def load_resource(endpoint: str, resource_id: int) -> dict:
@@ -704,6 +705,170 @@ ROUTE_ORDER: dict[str, list[str]] = {
"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
@@ -975,22 +1140,42 @@ def process_version(version_name: str, vg_info: dict, vg_key: str) -> list[dict]
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 from the local PokeAPI data."""
"""Fetch all Pokemon species + encountered forms from the local PokeAPI data."""
pokemon_dir = POKEAPI_DIR / "pokemon-species"
# Get all species IDs (directories with numeric names, excluding forms 10000+)
# 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: # Exclude alternate forms
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 ---")
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"]]
@@ -1000,9 +1185,22 @@ def fetch_all_pokemon() -> list[dict]:
"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)}")
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"])
@@ -1081,8 +1279,9 @@ def fetch_evolution_data(seeded_dex: set[int]) -> list[dict]:
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(seeded_dex)
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)