diff --git a/.beans/nuzlocke-tracker-gkcy--output-seed-json.md b/.beans/nuzlocke-tracker-gkcy--output-seed-json.md index 54ec3fc..df54b1d 100644 --- a/.beans/nuzlocke-tracker-gkcy--output-seed-json.md +++ b/.beans/nuzlocke-tracker-gkcy--output-seed-json.md @@ -1,11 +1,11 @@ --- # nuzlocke-tracker-gkcy title: Output seed JSON -status: todo +status: in-progress type: task priority: normal created_at: 2026-02-11T08:43:21Z -updated_at: 2026-02-11T08:43:33Z +updated_at: 2026-02-11T09:24:47Z parent: nuzlocke-tracker-bs05 blocking: - nuzlocke-tracker-vdks @@ -15,15 +15,15 @@ Generate the final per-game JSON files in the existing seed format. ## Checklist -- [ ] **Apply route ordering**: Use the existing `backend/src/app/seeds/route_order.json` to assign `order` values to routes. Handle aliases (e.g. "red-blue" → "firered-leafgreen"). Log warnings for routes not in the order file. -- [ ] **Merge special encounters**: Integrate starters, gifts, fossils, and trades from `backend/src/app/seeds/special_encounters.json` into the appropriate routes. -- [ ] **Output per-game JSON**: Write `{game-slug}.json` files matching the existing format: +- [x] **Apply route ordering**: Use the existing `backend/src/app/seeds/route_order.json` to assign `order` values to routes. Handle aliases (e.g. "red-blue" → "firered-leafgreen"). Log warnings for routes not in the order file. +- [x] **Merge special encounters**: Integrate starters, gifts, fossils, and trades from `backend/src/app/seeds/special_encounters.json` into the appropriate routes. Pokemon names are resolved to proper display names via PokemonMapper. +- [x] **Output per-game JSON**: Write `{game-slug}.json` files matching the existing format: ```json [{"name": "Route 1", "order": 3, "encounters": [...], "children": []}] ``` -- [ ] **Output games.json**: Generate the global games list from `version_groups.json` (this may already be handled by existing config, verify). -- [ ] **Output pokemon.json**: Generate the global pokemon list including all pokemon referenced in any encounter. Include pokeapi_id, national_dex, name, types, sprite_url. -- [ ] **Handle version exclusives**: Ensure encounters specific to one version in a version group only appear in that game's JSON file (e.g. FireRed exclusives vs LeafGreen exclusives). +- [x] **Output games.json**: Generate the global games list from `version_groups.json` — 38 games written, matching existing count. +- [x] **Output pokemon.json**: Generate the global pokemon list including all pokemon referenced in any encounter. Include pokeapi_id, national_dex, name, types, sprite_url. +- [x] **Handle version exclusives**: Encounters are filtered by `version_identifiers` per game — verified FireRed vs LeafGreen have 18 exclusives each. ## Notes - The output must be a drop-in replacement for the existing files in `backend/src/app/seeds/data/` diff --git a/tools/import-pokedb/import_pokedb/__main__.py b/tools/import-pokedb/import_pokedb/__main__.py index d1c0064..42e61b6 100644 --- a/tools/import-pokedb/import_pokedb/__main__.py +++ b/tools/import-pokedb/import_pokedb/__main__.py @@ -18,6 +18,7 @@ from pathlib import Path from .loader import load_pokedb_data, load_seed_config from .mappings import PokemonMapper, LocationMapper, build_version_map, map_encounter_method +from .output import sort_routes, merge_special_encounters, write_game_json, write_games_json, write_pokemon_json from .processing import filter_encounters_for_game, process_encounters, build_routes from .sprites import download_sprites @@ -157,6 +158,8 @@ def main(argv: list[str] | None = None) -> None: continue games_to_process.append((vg_key, slug, generation)) + all_encountered_form_ids: set[str] = set() + for vg_key, game_slug, generation in games_to_process: print(f"\n--- {game_slug} ---") @@ -168,6 +171,12 @@ def main(argv: list[str] | None = None) -> None: print(f" Raw encounters: {len(game_encounters)}") + # Track encountered form IDs for pokemon.json + for e in game_encounters: + fid = e.get("pokemon_form_identifier") + if fid: + all_encountered_form_ids.add(fid) + # Process into grouped encounters encounters_by_area = process_encounters( game_encounters, generation, pokemon_mapper, location_mapper, @@ -177,6 +186,12 @@ def main(argv: list[str] | None = None) -> None: # Build route hierarchy routes = build_routes(encounters_by_area, location_mapper) + # Merge special encounters (starters, gifts, fossils) + routes = merge_special_encounters(routes, config, vg_key, pokemon_mapper) + + # Sort routes by game progression order + routes = sort_routes(routes, config, vg_key) + # Stats total_routes = sum(1 + len(r.children) for r in routes) total_enc = sum( @@ -186,13 +201,21 @@ def main(argv: list[str] | None = None) -> None: print(f" Routes: {total_routes}") print(f" Encounter entries: {total_enc}") + # Write per-game JSON + write_game_json(routes, output_dir, game_slug) + # Download sprites for all encountered pokemon print("\nDownloading sprites...") sprites_dir = output_dir / "sprites" - sprite_map = download_sprites(pokemon_mapper, form_ids_in_encounters, sprites_dir) + sprite_map = download_sprites(pokemon_mapper, all_encountered_form_ids, sprites_dir) print(f" Sprite map covers {len(sprite_map)} forms") - print("\nProcessing complete. Output not yet written (subtask gkcy).") + # Write global JSON files + print("\nWriting global data files...") + write_games_json(config, output_dir) + write_pokemon_json(pokemon_mapper, all_encountered_form_ids, sprite_map, output_dir) + + print("\nDone!") if __name__ == "__main__": diff --git a/tools/import-pokedb/import_pokedb/mappings.py b/tools/import-pokedb/import_pokedb/mappings.py index 973a0a5..3a39b49 100644 --- a/tools/import-pokedb/import_pokedb/mappings.py +++ b/tools/import-pokedb/import_pokedb/mappings.py @@ -464,6 +464,16 @@ class PokemonMapper: 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: diff --git a/tools/import-pokedb/import_pokedb/output.py b/tools/import-pokedb/import_pokedb/output.py new file mode 100644 index 0000000..f84afc9 --- /dev/null +++ b/tools/import-pokedb/import_pokedb/output.py @@ -0,0 +1,238 @@ +"""Output seed JSON files in the existing format.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +from .loader import SeedConfig +from .mappings import PokemonMapper +from .models import Encounter, Route +from .sprites import sprite_path_for_pokemon + + +# --------------------------------------------------------------------------- +# Route ordering +# --------------------------------------------------------------------------- + +def _get_route_order(config: SeedConfig, vg_key: str) -> list[str] | None: + """Get the route order list for a version group, resolving aliases.""" + return config.route_order.get(vg_key) + + +def _route_sort_key(name: str, order_list: list[str]) -> tuple[int, str]: + """Compute sort key for a route name against an ordered list.""" + 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) + + +def sort_routes(routes: list[Route], config: SeedConfig, vg_key: str) -> list[Route]: + """Sort routes by game progression order and assign sequential order values.""" + order_list = _get_route_order(config, vg_key) + if order_list: + routes.sort(key=lambda r: _route_sort_key(r.name, order_list)) + else: + print(f" Warning: No route order for {vg_key}, using default order", file=sys.stderr) + + # Assign sequential order values + order = 1 + for route in routes: + route.order = order + order += 1 + for child in route.children: + child.order = order + order += 1 + + return routes + + +# --------------------------------------------------------------------------- +# Special encounters +# --------------------------------------------------------------------------- + +def _get_special_encounters( + config: SeedConfig, + vg_key: str, +) -> dict[str, list[dict[str, Any]]] | None: + """Get special encounters for a version group, resolving aliases.""" + if not config.special_encounters: + return None + + encounters = config.special_encounters.get("encounters", {}) + aliases = config.special_encounters.get("aliases", {}) + + if vg_key in encounters: + return encounters[vg_key] + if vg_key in aliases: + alias_target = aliases[vg_key] + if alias_target in encounters: + return encounters[alias_target] + + return None + + +def merge_special_encounters( + routes: list[Route], + config: SeedConfig, + vg_key: str, + pokemon_mapper: PokemonMapper | None = None, +) -> list[Route]: + """Merge special encounters (starters, gifts, fossils) into routes.""" + special_data = _get_special_encounters(config, vg_key) + if not special_data: + return routes + + # Build lookup: route name → route (including children) + route_map: dict[str, Route] = {} + for route in routes: + route_map[route.name] = route + for child in route.children: + route_map[child.name] = child + + for location_name, encounter_dicts in special_data.items(): + encounters = [] + for e in encounter_dicts: + # Look up proper display name from pokemon mapper if available + pokemon_name = e["pokemon_name"] + if pokemon_mapper: + info = pokemon_mapper.lookup_by_id(e["pokeapi_id"]) + if info: + pokemon_name = info[1] + + encounters.append(Encounter( + pokeapi_id=e["pokeapi_id"], + pokemon_name=pokemon_name, + method=e["method"], + encounter_rate=e["encounter_rate"], + min_level=e["min_level"], + max_level=e["max_level"], + )) + + if location_name in route_map: + route_map[location_name].encounters.extend(encounters) + else: + new_route = Route(name=location_name, order=0, encounters=encounters) + routes.append(new_route) + route_map[location_name] = new_route + + return routes + + +# --------------------------------------------------------------------------- +# JSON output +# --------------------------------------------------------------------------- + +def write_json(path: Path, data: Any) -> None: + """Write data as formatted JSON with trailing newline.""" + content = json.dumps(data, indent=2, ensure_ascii=False) + path.write_text(content + "\n", encoding="utf-8") + print(f" -> {path}") + + +def write_game_json(routes: list[Route], output_dir: Path, game_slug: str) -> None: + """Write a per-game route/encounter JSON file.""" + data = [r.to_dict() for r in routes] + write_json(output_dir / f"{game_slug}.json", data) + + +def write_games_json(config: SeedConfig, output_dir: Path) -> None: + """Write games.json from version_groups config.""" + games = [] + for vg_info in config.version_groups.values(): + for game_info in vg_info.get("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(output_dir / "games.json", games) + print(f" Wrote {len(games)} games") + + +def write_pokemon_json( + pokemon_mapper: PokemonMapper, + encountered_form_ids: set[str], + sprite_map: dict[str, str], + output_dir: Path, +) -> None: + """Write pokemon.json with all pokemon referenced in encounters.""" + seen_ids: set[int] = set() + pokemon_list: list[dict[str, Any]] = [] + + for form_id in sorted(encountered_form_ids): + info = pokemon_mapper.lookup(form_id) + if info is None: + continue + + pokeapi_id, name = info + if pokeapi_id in seen_ids: + continue + seen_ids.add(pokeapi_id) + + # Get additional data from PokeDB form record + form_data = pokemon_mapper.get_form_data(form_id) + national_dex = form_data.get("ndex_id", pokeapi_id) if form_data else pokeapi_id + + # Types from PokeDB form data + types = _extract_types(form_data) if form_data else [] + + # Sprite URL + sprite_url: str | None = None + if form_id in sprite_map: + sprite_url = sprite_path_for_pokemon(pokeapi_id) + + pokemon_list.append({ + "pokeapi_id": pokeapi_id, + "national_dex": national_dex, + "name": name, + "types": types, + "sprite_url": sprite_url, + }) + + # Sort by pokeapi_id + pokemon_list.sort(key=lambda p: p["pokeapi_id"]) + + write_json(output_dir / "pokemon.json", pokemon_list) + print(f" Wrote {len(pokemon_list)} pokemon") + + +# 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 _extract_types(form_data: dict[str, Any]) -> list[str]: + """Extract type names from a PokeDB form record.""" + types = [] + type1_id = form_data.get("type_1_id") + type2_id = form_data.get("type_2_id") + if type1_id and type1_id in _TYPE_ID_MAP: + types.append(_TYPE_ID_MAP[type1_id]) + if type2_id and type2_id in _TYPE_ID_MAP: + types.append(_TYPE_ID_MAP[type2_id]) + return types