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:
@@ -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/`
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
238
tools/import-pokedb/import_pokedb/output.py
Normal file
238
tools/import-pokedb/import_pokedb/output.py
Normal 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
|
||||||
Reference in New Issue
Block a user