Add seed JSON output (per-game, games.json, pokemon.json)

Wire output module into CLI pipeline: route ordering, special encounter
merging, and JSON writing for per-game encounters, global games list,
and pokemon list with types and sprite paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Julian Tabel
2026-02-11 10:59:56 +01:00
parent 29b954726a
commit df55233c62
4 changed files with 281 additions and 10 deletions

View File

@@ -1,11 +1,11 @@
--- ---
# nuzlocke-tracker-gkcy # nuzlocke-tracker-gkcy
title: Output seed JSON title: Output seed JSON
status: todo status: in-progress
type: task type: task
priority: normal priority: normal
created_at: 2026-02-11T08:43:21Z 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 parent: nuzlocke-tracker-bs05
blocking: blocking:
- nuzlocke-tracker-vdks - nuzlocke-tracker-vdks
@@ -15,15 +15,15 @@ Generate the final per-game JSON files in the existing seed format.
## Checklist ## 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. - [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.
- [ ] **Merge special encounters**: Integrate starters, gifts, fossils, and trades from `backend/src/app/seeds/special_encounters.json` into the appropriate routes. - [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.
- [ ] **Output per-game JSON**: Write `{game-slug}.json` files matching the existing format: - [x] **Output per-game JSON**: Write `{game-slug}.json` files matching the existing format:
```json ```json
[{"name": "Route 1", "order": 3, "encounters": [...], "children": []}] [{"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). - [x] **Output games.json**: Generate the global games list from `version_groups.json` — 38 games written, matching existing count.
- [ ] **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] **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] **Handle version exclusives**: Encounters are filtered by `version_identifiers` per game — verified FireRed vs LeafGreen have 18 exclusives each.
## Notes ## Notes
- The output must be a drop-in replacement for the existing files in `backend/src/app/seeds/data/` - The output must be a drop-in replacement for the existing files in `backend/src/app/seeds/data/`

View File

@@ -18,6 +18,7 @@ from pathlib import Path
from .loader import load_pokedb_data, load_seed_config from .loader import load_pokedb_data, load_seed_config
from .mappings import PokemonMapper, LocationMapper, build_version_map, map_encounter_method 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 .processing import filter_encounters_for_game, process_encounters, build_routes
from .sprites import download_sprites from .sprites import download_sprites
@@ -157,6 +158,8 @@ def main(argv: list[str] | None = None) -> None:
continue continue
games_to_process.append((vg_key, slug, generation)) 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: for vg_key, game_slug, generation in games_to_process:
print(f"\n--- {game_slug} ---") print(f"\n--- {game_slug} ---")
@@ -168,6 +171,12 @@ def main(argv: list[str] | None = None) -> None:
print(f" Raw encounters: {len(game_encounters)}") 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 # Process into grouped encounters
encounters_by_area = process_encounters( encounters_by_area = process_encounters(
game_encounters, generation, pokemon_mapper, location_mapper, game_encounters, generation, pokemon_mapper, location_mapper,
@@ -177,6 +186,12 @@ def main(argv: list[str] | None = None) -> None:
# Build route hierarchy # Build route hierarchy
routes = build_routes(encounters_by_area, location_mapper) 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 # Stats
total_routes = sum(1 + len(r.children) for r in routes) total_routes = sum(1 + len(r.children) for r in routes)
total_enc = sum( total_enc = sum(
@@ -186,13 +201,21 @@ def main(argv: list[str] | None = None) -> None:
print(f" Routes: {total_routes}") print(f" Routes: {total_routes}")
print(f" Encounter entries: {total_enc}") print(f" Encounter entries: {total_enc}")
# Write per-game JSON
write_game_json(routes, output_dir, game_slug)
# Download sprites for all encountered pokemon # Download sprites for all encountered pokemon
print("\nDownloading sprites...") print("\nDownloading sprites...")
sprites_dir = output_dir / "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(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__": if __name__ == "__main__":

View File

@@ -464,6 +464,16 @@ class PokemonMapper:
return None 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: def get_sprite_url(self, pokemon_form_identifier: str | None) -> str | None:
"""Get the PokeDB CDN sprite URL (100x100 medium) for a form identifier.""" """Get the PokeDB CDN sprite URL (100x100 medium) for a form identifier."""
if not pokemon_form_identifier: if not pokemon_form_identifier:

View File

@@ -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