"""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": "horde", "sos-encounter": "sos", "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": "horde", "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", } # PokeDB type IDs → type names (from PokeDB's type system) TYPE_ID_MAP: dict[int, str] = { 1: "normal", 2: "fighting", 3: "flying", 4: "poison", 5: "ground", 6: "rock", 7: "bug", 8: "ghost", 9: "steel", 10: "fire", 11: "water", 12: "grass", 13: "electric", 14: "psychic", 15: "ice", 16: "dragon", 17: "dark", 18: "fairy", } 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._existing_types: dict[int, list[str]] = {} # pokeapi_id → types (fallback) 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) if p.get("types"): self._existing_types[pid] = p["types"] # 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] = {} # Reverse index: pokeapi_id → PokeDB form record (for non-encountered lookups) self._id_to_pokedb_form: dict[int, dict] = {} for form in pokedb.pokemon_forms: identifier = form.get("identifier", "") if identifier: self._pokedb_form_index[identifier] = form # Build reverse index from pokeapi_id → PokeDB form # First, for all encountered lookups that succeed, we cache the mapping. # Here we pre-build for default forms using ndex_id. for form in pokedb.pokemon_forms: ndex = form.get("ndex_id") if ndex and form.get("is_default_form"): # Default form matches the base species (ndex == pokeapi_id for base forms) if ndex in self._id_to_info: self._id_to_pokedb_form[ndex] = form # Also look for alternate-form pokeapi_ids that share the same ndex for pid, (p_ndex, _) in self._id_to_info.items(): if p_ndex == ndex and pid not in self._id_to_pokedb_form: self._id_to_pokedb_form[pid] = form # Map non-default forms to their specific pokeapi_ids where possible for form in pokedb.pokemon_forms: identifier = form.get("identifier", "") if not identifier or form.get("is_default_form"): continue slug = _normalize_slug(identifier) if slug in self._slug_to_info: pid, _ = self._slug_to_info[slug] self._id_to_pokedb_form[pid] = 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 lookup_by_id(self, pokeapi_id: int) -> tuple[int, str] | None: """Look up a pokemon by its pokeapi_id. Returns (pokeapi_id, display_name) or None if not found. """ if pokeapi_id in self._id_to_info: _, name = self._id_to_info[pokeapi_id] return (pokeapi_id, name) return None def get_sprite_url(self, pokemon_form_identifier: str | None) -> str | None: """Get the PokeDB CDN sprite URL (100x100 medium) for a form identifier.""" if not pokemon_form_identifier: return None form_record = self._pokedb_form_index.get(pokemon_form_identifier, {}) return form_record.get("main_image_normal_path_medium") def get_form_data(self, pokemon_form_identifier: str | None) -> dict | None: """Get the full PokeDB form record for a form identifier.""" if not pokemon_form_identifier: return None return self._pokedb_form_index.get(pokemon_form_identifier) def all_pokemon(self) -> list[tuple[int, tuple[int, str]]]: """Return all known pokemon as [(pokeapi_id, (national_dex, name)), ...]. Sourced from the existing pokemon.json. """ return sorted(self._id_to_info.items()) def get_types_for_id(self, pokeapi_id: int) -> list[str]: """Get types for a pokemon by pokeapi_id, looking up via PokeDB form data. Falls back to existing types from pokemon.json if no PokeDB form found. """ form = self._find_form_for_id(pokeapi_id) if form: types = [] t1 = form.get("type_1_id") t2 = form.get("type_2_id") if t1 and t1 in TYPE_ID_MAP: types.append(TYPE_ID_MAP[t1]) if t2 and t2 in TYPE_ID_MAP: types.append(TYPE_ID_MAP[t2]) if types: return types # Fallback to existing types from pokemon.json return self._existing_types.get(pokeapi_id, []) def _find_form_for_id(self, pokeapi_id: int) -> dict | None: """Find the PokeDB form record for a pokeapi_id.""" # Check pre-built reverse index first if pokeapi_id in self._id_to_pokedb_form: return self._id_to_pokedb_form[pokeapi_id] if pokeapi_id not in self._id_to_info: return None _, name = self._id_to_info[pokeapi_id] slug = _name_to_slug(name) for suffix in ["-default", ""]: candidate = slug + suffix if candidate in self._pokedb_form_index: return self._pokedb_form_index[candidate] form_slug = _name_to_form_slug(name) if form_slug: for suffix in ["-default", ""]: candidate = form_slug + suffix if candidate in self._pokedb_form_index: return self._pokedb_form_index[candidate] return None def has_sprite_for_id(self, pokeapi_id: int) -> bool: """Check if a sprite exists for a pokemon by pokeapi_id.""" form = self._find_form_for_id(pokeapi_id) return bool(form and form.get("main_image_normal_path_medium")) def get_sprite_url_for_id(self, pokeapi_id: int) -> str | None: """Get the PokeDB CDN sprite URL for a pokemon by pokeapi_id.""" form = self._find_form_for_id(pokeapi_id) return form.get("main_image_normal_path_medium") if form else 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, "")