Filter by game version, parse levels and rate variants across all generations, aggregate encounters by pokemon+method, and build parent/child route hierarchy. Also completes encounter method coverage (73/73) and pokemon form mapping (1180/1181) with manual overrides. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
551 lines
18 KiB
Python
551 lines
18 KiB
Python
"""Reference data mappings: PokeDB identifiers → seed format values."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from .loader import PokeDBData
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Encounter method mapping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# PokeDB encounter_method_identifier → our simplified method name.
|
|
# Keys can be exact matches or prefix patterns (ending with *).
|
|
ENCOUNTER_METHOD_MAP: dict[str, str] = {
|
|
# Walking / grass / cave
|
|
"walking-tall-grass": "walk",
|
|
"walking-long-grass": "walk",
|
|
"walking-cave": "walk",
|
|
"walking-bridge": "walk",
|
|
"walking-building": "walk",
|
|
"walking-sand": "walk",
|
|
"walking-snow": "walk",
|
|
"walking-rough-terrain": "walk",
|
|
"walking-marsh": "walk",
|
|
"walking-puddle": "walk",
|
|
"walking-flower-field": "walk",
|
|
"walking-ice": "walk",
|
|
"walking-forest": "walk",
|
|
"walking-snowfield": "walk",
|
|
"dark-grass": "walk",
|
|
"shaking-grass": "walk",
|
|
"rustling-grass": "walk",
|
|
"yellow-flowers": "walk",
|
|
"red-flowers": "walk",
|
|
"purple-flowers": "walk",
|
|
# Surfing
|
|
"surfing": "surf",
|
|
"surfing-ocean": "surf",
|
|
"surfing-puddle": "surf",
|
|
"surfing-rapids": "surf",
|
|
"surfing-underwater": "surf",
|
|
"rippling-water": "surf",
|
|
# Fishing
|
|
"fishing-old-rod": "old-rod",
|
|
"fishing-good-rod": "good-rod",
|
|
"fishing-super-rod": "super-rod",
|
|
"fishing": "fishing",
|
|
"fishing-special": "fishing",
|
|
# Rock smash
|
|
"rock-smash": "rock-smash",
|
|
# Headbutt
|
|
"headbutt-low": "headbutt",
|
|
"headbutt-normal": "headbutt",
|
|
"headbutt-high": "headbutt",
|
|
# Gift / special acquisition
|
|
"npc-gift": "gift",
|
|
"egg": "gift",
|
|
"revive": "gift",
|
|
"fossil": "gift",
|
|
# Trade
|
|
"npc-trade": "trade",
|
|
# Overworld / symbol encounters (Gen 8+)
|
|
"symbol-encounter": "walk",
|
|
"wanderer": "walk",
|
|
"flying": "walk",
|
|
# Static / fixed
|
|
"fixed-encounter": "static",
|
|
"static-encounter": "static",
|
|
"legendary-encounter": "static",
|
|
"interactable": "static",
|
|
# Special methods
|
|
"swarm": "swarm",
|
|
"poke-radar": "pokeradar",
|
|
"dual-slot-mode": "dual-slot",
|
|
"honey-tree": "honey",
|
|
"trophy-garden": "walk",
|
|
"great-marsh": "walk",
|
|
"cave-spot": "walk",
|
|
"bubble-spot": "surf",
|
|
"sand-spot": "walk",
|
|
"horde": "walk",
|
|
"sos-encounter": "walk",
|
|
"ambush": "walk",
|
|
# Seaweed / diving
|
|
"diving": "surf",
|
|
"diving-seaweed": "surf",
|
|
"seaweed": "surf",
|
|
# Raids
|
|
"max-raid": "raid",
|
|
"max-raid-battle": "raid",
|
|
"dynamax-adventure": "raid",
|
|
"tera-raid": "raid",
|
|
"tera-raid-battle": "raid",
|
|
"fixed-tera-encounter": "static",
|
|
# Misc
|
|
"roaming": "roaming",
|
|
"safari-zone": "walk",
|
|
"bug-contest": "walk",
|
|
"dust-cloud": "walk",
|
|
"hidden-grotto": "static",
|
|
"hidden-encounter": "walk",
|
|
"horde-encounter": "walk",
|
|
"shaking-trees": "walk",
|
|
"shaking-ore-deposits": "walk",
|
|
"island-scan": "static",
|
|
"mass-outbreak": "swarm",
|
|
"npc-buy": "gift",
|
|
"special-encounter": "static",
|
|
"sea-skim": "surf",
|
|
"midair": "walk",
|
|
"mr-backlot": "walk",
|
|
"hoenn-sound": "walk",
|
|
"sinnoh-sound": "walk",
|
|
"curry": "gift",
|
|
"boxes": "gift",
|
|
"berry-tree": "walk",
|
|
"zygarde-cube-assemble": "static",
|
|
"contact-flock": "walk",
|
|
"contact-space-time-distortion": "walk",
|
|
"contact-unown-reasearch-notes": "static",
|
|
"flying-pokemon-shadow": "walk",
|
|
}
|
|
|
|
# Prefix-based fallbacks for methods not explicitly listed above.
|
|
_METHOD_PREFIX_MAP: list[tuple[str, str]] = [
|
|
("walking-", "walk"),
|
|
("surfing-", "surf"),
|
|
("fishing-", "fishing"),
|
|
("headbutt-", "headbutt"),
|
|
("flying-", "walk"),
|
|
("ambush-", "walk"),
|
|
("contact-", "walk"),
|
|
]
|
|
|
|
|
|
def map_encounter_method(method_identifier: str) -> str | None:
|
|
"""Map a PokeDB encounter method to our simplified method name.
|
|
|
|
Returns None if the method is unrecognized.
|
|
"""
|
|
if method_identifier in ENCOUNTER_METHOD_MAP:
|
|
return ENCOUNTER_METHOD_MAP[method_identifier]
|
|
|
|
for prefix, mapped in _METHOD_PREFIX_MAP:
|
|
if method_identifier.startswith(prefix):
|
|
return mapped
|
|
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Version mapping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# PokeDB version identifiers that differ from our game slugs.
|
|
# Most are 1:1, these handle exceptions.
|
|
_VERSION_OVERRIDES: dict[str, str] = {
|
|
"lets-go-pikachu": "lets-go-pikachu",
|
|
"lets-go-eevee": "lets-go-eevee",
|
|
}
|
|
|
|
|
|
def build_version_map(
|
|
pokedb: PokeDBData,
|
|
version_groups: dict[str, Any],
|
|
) -> dict[str, str]:
|
|
"""Build a mapping from PokeDB version_identifier → our game slug.
|
|
|
|
Returns the mapping dict. Logs warnings for unmapped versions.
|
|
"""
|
|
# Collect all our known game slugs
|
|
our_slugs: set[str] = set()
|
|
for vg in version_groups.values():
|
|
for slug in vg.get("versions", []):
|
|
our_slugs.add(slug)
|
|
|
|
# Collect all PokeDB version identifiers
|
|
pokedb_versions: set[str] = set()
|
|
for v in pokedb.versions:
|
|
identifier = v.get("identifier", "")
|
|
if identifier:
|
|
pokedb_versions.add(identifier)
|
|
|
|
mapping: dict[str, str] = {}
|
|
|
|
for pdb_ver in sorted(pokedb_versions):
|
|
if pdb_ver in _VERSION_OVERRIDES:
|
|
mapping[pdb_ver] = _VERSION_OVERRIDES[pdb_ver]
|
|
elif pdb_ver in our_slugs:
|
|
mapping[pdb_ver] = pdb_ver
|
|
# else: PokeDB version not in our version_groups (expected for some)
|
|
|
|
# Report our games that have no PokeDB mapping
|
|
mapped_slugs = set(mapping.values())
|
|
unmapped_ours = our_slugs - mapped_slugs
|
|
if unmapped_ours:
|
|
print(f" Versions in our config with no PokeDB match: {sorted(unmapped_ours)}")
|
|
|
|
return mapping
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pokemon form mapping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# PokeDB uses adjectival region forms ("alolan") while PokeAPI/our data uses
|
|
# region names ("alola"). This maps PokeDB suffixes → our suffixes.
|
|
_FORM_SUFFIX_MAP: dict[str, str] = {
|
|
"alolan": "alola",
|
|
"galarian": "galar",
|
|
"hisuian": "hisui",
|
|
"paldean": "paldea",
|
|
# Totem forms
|
|
"alolan-totem": "totem-alola",
|
|
# Basculin stripes
|
|
"blue-stripe": "blue-striped",
|
|
"red-stripe": "red-striped",
|
|
"white-stripe": "white-striped",
|
|
# Sea forms
|
|
"west-sea": "west",
|
|
"east-sea": "east",
|
|
# Cloak forms
|
|
"plant-cloak": "plant",
|
|
"sandy-cloak": "sandy",
|
|
"trash-cloak": "trash",
|
|
# Eiscue
|
|
"ice-face": "ice",
|
|
# Misc forms
|
|
"pompom": "pom-pom",
|
|
"10p": "10",
|
|
"50p": "50",
|
|
"owntempo": "own-tempo",
|
|
"two": "two-segment",
|
|
"chest": "chest-form",
|
|
"ice-rider": "ice",
|
|
"shadow-rider": "shadow",
|
|
"apex": "apex-build",
|
|
"ultimate": "ultimate-mode",
|
|
"black-activated": "black",
|
|
"white-activated": "white",
|
|
"hero": "hero",
|
|
"sword": "crowned",
|
|
"shield": "crowned",
|
|
# Gigantamax
|
|
"gigantamax": "gmax",
|
|
# Partner forms
|
|
"partner": "partner-cap",
|
|
# Flabébé / Floette / Florges color forms — these don't have form suffixes in our data
|
|
# since each color is just the base form. Map to base.
|
|
"blue": "blue",
|
|
"orange": "orange",
|
|
"red": "red",
|
|
"white": "white",
|
|
"yellow": "yellow",
|
|
# Gender forms
|
|
"female": "female",
|
|
"male": "male",
|
|
# Furfrou
|
|
"natural": "natural",
|
|
# Cherrim
|
|
"overcast": "overcast",
|
|
# Sinistea / Polteageist
|
|
"antique": "antique",
|
|
"phony": "phony",
|
|
# Poltchageist / Sinistcha
|
|
"artisan": "artisan",
|
|
"counterfeit": "counterfeit",
|
|
"masterpiece": "masterpiece",
|
|
"unremarkable": "unremarkable",
|
|
# Minior cores
|
|
"blue-core": "blue",
|
|
"green-core": "green",
|
|
"indigo-core": "indigo",
|
|
"orange-core": "orange",
|
|
"red-core": "red",
|
|
"violet-core": "violet",
|
|
"yellow-core": "yellow",
|
|
# Vivillon
|
|
"fancy": "fancy",
|
|
# Squawkabilly
|
|
# these use same name
|
|
# Xerneas
|
|
"neutral": "neutral",
|
|
# Deerling / Sawsbuck
|
|
"spring": "spring",
|
|
"summer": "summer",
|
|
"autumn": "autumn",
|
|
"winter": "winter",
|
|
# Spiky-ears Pichu
|
|
"spiky-ears": "spiky-eared",
|
|
# Paldean breeds
|
|
"paldean-combat-breed": "paldea-combat-breed",
|
|
"paldean-blaze-breed": "paldea-blaze-breed",
|
|
"paldean-aqua-breed": "paldea-aqua-breed",
|
|
}
|
|
|
|
|
|
def _normalize_slug(identifier: str) -> str:
|
|
"""Normalize a PokeDB pokemon_form_identifier to a PokeAPI-style slug.
|
|
|
|
PokeDB uses "pidgey-default" for base forms — strip the "-default" suffix.
|
|
For alternate forms, translate PokeDB naming conventions to ours.
|
|
"""
|
|
if identifier.endswith("-default"):
|
|
return identifier[: -len("-default")]
|
|
|
|
# Try suffix-based mapping: split into species + form suffix
|
|
# e.g. "rattata-alolan" → species="rattata", suffix="alolan"
|
|
# e.g. "mr-mime-galarian" → need to find the right split point
|
|
# Strategy: try longest suffix first
|
|
for pokedb_suffix, our_suffix in sorted(
|
|
_FORM_SUFFIX_MAP.items(), key=lambda x: -len(x[0])
|
|
):
|
|
if identifier.endswith("-" + pokedb_suffix):
|
|
species = identifier[: -(len(pokedb_suffix) + 1)]
|
|
return f"{species}-{our_suffix}"
|
|
|
|
return identifier
|
|
|
|
|
|
def _name_to_slug(name: str) -> str:
|
|
"""Convert a display name to a PokeAPI-style slug.
|
|
|
|
"Bulbasaur" → "bulbasaur"
|
|
"Mr. Mime" → "mr-mime"
|
|
"Farfetch'd" → "farfetchd"
|
|
"Nidoran♀" → "nidoran-f"
|
|
"Nidoran♂" → "nidoran-m"
|
|
"Flabébé" → "flabebe"
|
|
"Type: Null" → "type-null"
|
|
"""
|
|
s = name.lower()
|
|
s = s.replace("♀", "-f").replace("♂", "-m")
|
|
s = s.replace("'", "").replace("'", "").replace(".", "").replace(":", "")
|
|
s = s.replace("é", "e").replace("É", "e")
|
|
s = s.replace(" ", "-")
|
|
# Collapse multiple hyphens
|
|
s = re.sub(r"-+", "-", s)
|
|
return s.strip("-")
|
|
|
|
|
|
def _name_to_form_slug(name: str) -> str | None:
|
|
"""Convert a display name with form suffix to a PokeAPI-style slug.
|
|
|
|
"Rattata (Alola)" → "rattata-alola"
|
|
"Basculin (Blue Striped)" → "basculin-blue-striped"
|
|
"Deoxys Normal" → "deoxys-normal" (space-separated variant)
|
|
"""
|
|
# Try parenthesized form: "Base (Suffix)"
|
|
m = re.match(r"^(.+?)\s*\((.+)\)$", name)
|
|
if m:
|
|
base = _name_to_slug(m.group(1))
|
|
suffix = _name_to_slug(m.group(2))
|
|
return f"{base}-{suffix}"
|
|
|
|
# Try space-separated form: "Deoxys Normal"
|
|
parts = name.split()
|
|
if len(parts) >= 2:
|
|
return _name_to_slug(name)
|
|
|
|
return None
|
|
|
|
|
|
# Manual overrides for PokeDB identifiers that can't be resolved generically.
|
|
# These are cases where our pokemon.json uses non-standard base form names
|
|
# (e.g. "Deoxys Normal" instead of "Deoxys").
|
|
_FORM_OVERRIDES: dict[str, tuple[int, str]] = {
|
|
"deoxys-default": (386, "Deoxys Normal"),
|
|
"darmanitan-galarian": (10177, "Darmanitan (Galar Standard)"),
|
|
"mimikyu-totem": (10144, "Mimikyu (Totem Disguised)"),
|
|
"squawkabilly-green": (931, "Squawkabilly Green Plumage"),
|
|
"squawkabilly-blue": (10260, "Squawkabilly (Blue Plumage)"),
|
|
"squawkabilly-white": (10262, "Squawkabilly (White Plumage)"),
|
|
"squawkabilly-yellow": (10261, "Squawkabilly (Yellow Plumage)"),
|
|
"toxtricity-gigantamax": (849, "Toxtricity Amped"),
|
|
}
|
|
|
|
|
|
class PokemonMapper:
|
|
"""Maps PokeDB pokemon_form_identifier → (pokeapi_id, display_name)."""
|
|
|
|
def __init__(self, pokemon_json_path: Path, pokedb: PokeDBData) -> None:
|
|
# Build slug → (pokeapi_id, name) from existing pokemon.json
|
|
self._slug_to_info: dict[str, tuple[int, str]] = {}
|
|
self._id_to_info: dict[int, tuple[int, str]] = {} # pokeapi_id → (national_dex, name)
|
|
self._unmapped: set[str] = set()
|
|
|
|
if pokemon_json_path.exists():
|
|
with open(pokemon_json_path) as f:
|
|
pokemon_list = json.load(f)
|
|
|
|
for p in pokemon_list:
|
|
pid = p["pokeapi_id"]
|
|
name = p["name"]
|
|
ndex = p["national_dex"]
|
|
self._id_to_info[pid] = (ndex, name)
|
|
|
|
# Index by base slug (from pokeapi_id for base forms)
|
|
slug = _name_to_slug(name)
|
|
self._slug_to_info[slug] = (pid, name)
|
|
|
|
# Also index by form slug if it has a form suffix
|
|
form_slug = _name_to_form_slug(name)
|
|
if form_slug and form_slug != slug:
|
|
self._slug_to_info[form_slug] = (pid, name)
|
|
|
|
# Build index from PokeDB pokemon_forms.json if it has useful fields
|
|
self._pokedb_form_index: dict[str, dict] = {}
|
|
for form in pokedb.pokemon_forms:
|
|
identifier = form.get("identifier", "")
|
|
if identifier:
|
|
self._pokedb_form_index[identifier] = form
|
|
|
|
def lookup(self, pokemon_form_identifier: str | None) -> tuple[int, str] | None:
|
|
"""Look up a PokeDB pokemon_form_identifier.
|
|
|
|
Returns (pokeapi_id, display_name) or None if unmapped.
|
|
"""
|
|
if not pokemon_form_identifier:
|
|
return None
|
|
|
|
# Check manual overrides first
|
|
if pokemon_form_identifier in _FORM_OVERRIDES:
|
|
return _FORM_OVERRIDES[pokemon_form_identifier]
|
|
|
|
slug = _normalize_slug(pokemon_form_identifier)
|
|
|
|
# Direct slug match
|
|
if slug in self._slug_to_info:
|
|
return self._slug_to_info[slug]
|
|
|
|
# Try the PokeDB form record for a pokemon_id field
|
|
form_record = self._pokedb_form_index.get(pokemon_form_identifier, {})
|
|
pokemon_id = form_record.get("pokemon_id")
|
|
if pokemon_id and pokemon_id in self._id_to_info:
|
|
ndex, name = self._id_to_info[pokemon_id]
|
|
# Cache for future lookups
|
|
self._slug_to_info[slug] = (pokemon_id, name)
|
|
return (pokemon_id, name)
|
|
|
|
# Fallback: strip form suffix to find base species.
|
|
# Many cosmetic forms (colors, genders, seasons) don't have separate
|
|
# entries in our pokemon.json — they use the base species entry.
|
|
# Try progressively shorter slugs: "flabebe-blue" → "flabebe"
|
|
parts = slug.split("-")
|
|
for i in range(len(parts) - 1, 0, -1):
|
|
base = "-".join(parts[:i])
|
|
if base in self._slug_to_info:
|
|
result = self._slug_to_info[base]
|
|
# Cache for future lookups
|
|
self._slug_to_info[slug] = result
|
|
return result
|
|
|
|
# Track unmapped
|
|
if pokemon_form_identifier not in self._unmapped:
|
|
self._unmapped.add(pokemon_form_identifier)
|
|
|
|
return None
|
|
|
|
def report_unmapped(self) -> None:
|
|
"""Print warnings for any unmapped identifiers."""
|
|
if self._unmapped:
|
|
print(
|
|
f"\nWarning: {len(self._unmapped)} unmapped pokemon form identifiers:",
|
|
file=sys.stderr,
|
|
)
|
|
for ident in sorted(self._unmapped):
|
|
print(f" - {ident}", file=sys.stderr)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Location area mapping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Region prefixes to strip from location identifiers (matching Go tool behavior).
|
|
_REGION_PREFIXES = [
|
|
"kanto-", "johto-", "hoenn-", "sinnoh-",
|
|
"unova-", "kalos-", "alola-", "galar-",
|
|
"hisui-", "paldea-",
|
|
]
|
|
|
|
|
|
def _identifier_to_name(identifier: str) -> str:
|
|
"""Convert a hyphenated identifier to a Title Case display name.
|
|
|
|
"route-01-kanto" → "Route 01 Kanto" (region stripping done separately)
|
|
"viridian-forest" → "Viridian Forest"
|
|
"""
|
|
return identifier.replace("-", " ").title()
|
|
|
|
|
|
class LocationMapper:
|
|
"""Maps PokeDB location_area_identifier → (location_name, area_suffix)."""
|
|
|
|
def __init__(self, pokedb: PokeDBData) -> None:
|
|
# Build location_area_identifier → location_identifier lookup
|
|
self._area_to_location: dict[str, str] = {}
|
|
# location_identifier → location display name
|
|
self._location_names: dict[str, str] = {}
|
|
# location_area_identifier → area display name
|
|
self._area_names: dict[str, str] = {}
|
|
|
|
# Index locations
|
|
for loc in pokedb.locations:
|
|
identifier = loc.get("identifier", "")
|
|
name = loc.get("name", "")
|
|
if identifier:
|
|
self._location_names[identifier] = name if name else self._clean_location_name(identifier)
|
|
|
|
# Index location areas
|
|
for area in pokedb.location_areas:
|
|
area_id = area.get("identifier", "")
|
|
loc_id = area.get("location_identifier", "")
|
|
area_name = area.get("name", "")
|
|
if area_id:
|
|
self._area_to_location[area_id] = loc_id
|
|
self._area_names[area_id] = area_name if area_name else ""
|
|
|
|
@staticmethod
|
|
def _clean_location_name(identifier: str) -> str:
|
|
"""Clean a location identifier into a display name."""
|
|
name = identifier
|
|
for prefix in _REGION_PREFIXES:
|
|
if name.startswith(prefix):
|
|
name = name[len(prefix):]
|
|
break
|
|
return _identifier_to_name(name)
|
|
|
|
def get_location_name(self, location_area_identifier: str) -> str:
|
|
"""Get the display name for a location area's parent location."""
|
|
loc_id = self._area_to_location.get(location_area_identifier, "")
|
|
if loc_id and loc_id in self._location_names:
|
|
return self._location_names[loc_id]
|
|
# Fallback: derive from the area identifier itself
|
|
return self._clean_location_name(location_area_identifier)
|
|
|
|
def get_area_name(self, location_area_identifier: str) -> str:
|
|
"""Get the display name for a specific location area."""
|
|
return self._area_names.get(location_area_identifier, "")
|
|
|
|
def get_location_identifier(self, location_area_identifier: str) -> str:
|
|
"""Get the parent location identifier for a location area."""
|
|
return self._area_to_location.get(location_area_identifier, "")
|