Add version groups to share routes and boss battles across games

Routes and boss battles now belong to a version_group instead of
individual games, so paired versions (e.g. Red/Blue, Gold/Silver)
share the same route structure and boss battles. Route encounters
gain a game_id column to support game-specific encounter tables
within a shared route. Includes migration, updated seeds, API
changes, and frontend type updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 12:07:42 +01:00
parent 979f57f184
commit 3e88ba50fa
22 changed files with 631 additions and 161 deletions

View File

@@ -0,0 +1,20 @@
---
# nuzlocke-tracker-lab1
title: Add version_groups table, share routes & boss battles across version groups
status: completed
type: feature
priority: normal
created_at: 2026-02-08T10:39:00Z
updated_at: 2026-02-08T10:46:43Z
---
Introduce version_groups table and re-parent routes and boss_battles from game_id to version_group_id. Add game_id to route_encounters for per-game encounter data.
## Checklist
- [x] Phase 1: Backend models (VersionGroup, Game, Route, RouteEncounter, BossBattle)
- [x] Phase 2: Alembic migration
- [x] Phase 3: Backend schemas
- [x] Phase 4: Backend API updates
- [x] Phase 5: Seed loader updates
- [x] Phase 6: Frontend type/API/hook updates
- [x] Verification: migration runs, TypeScript compiles

View File

@@ -0,0 +1,267 @@
"""add version groups
Revision ID: d3e4f5a6b7c8
Revises: c2d3e4f5a6b7
Create Date: 2026-02-08 14:00:00.000000
"""
import json
from pathlib import Path
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd3e4f5a6b7c8'
down_revision: Union[str, Sequence[str], None] = 'c2d3e4f5a6b7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. Create version_groups table
op.create_table(
'version_groups',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('name', sa.String(100), nullable=False),
sa.Column('slug', sa.String(100), nullable=False, unique=True),
)
# 2. Populate version groups from seed data
vg_json_path = Path(__file__).resolve().parents[2] / "seeds" / "version_groups.json"
with open(vg_json_path) as f:
vg_data = json.load(f)
conn = op.get_bind()
vg_table = sa.table(
'version_groups',
sa.column('id', sa.Integer),
sa.column('name', sa.String),
sa.column('slug', sa.String),
)
# Build slug -> id mapping and game_slug -> vg_id mapping
slug_to_vg_id = {}
game_slug_to_vg_id = {}
for vg_idx, (vg_slug, vg_info) in enumerate(vg_data.items(), start=1):
vg_id = vg_idx
# Use the slug as a readable name (e.g., "red-blue" -> "Red / Blue")
vg_name = " / ".join(
g["name"].replace("Pokemon ", "")
for g in vg_info["games"].values()
)
conn.execute(vg_table.insert().values(id=vg_id, name=vg_name, slug=vg_slug))
slug_to_vg_id[vg_slug] = vg_id
for game_slug in vg_info["games"]:
game_slug_to_vg_id[game_slug] = vg_id
# 3. Add version_group_id to games (nullable initially)
op.add_column('games', sa.Column('version_group_id', sa.Integer(),
sa.ForeignKey('version_groups.id'), nullable=True))
op.create_index('ix_games_version_group_id', 'games', ['version_group_id'])
# Populate games.version_group_id from the mapping
games_table = sa.table(
'games',
sa.column('id', sa.Integer),
sa.column('slug', sa.String),
sa.column('version_group_id', sa.Integer),
)
rows = conn.execute(sa.select(games_table.c.id, games_table.c.slug)).fetchall()
for game_id, game_slug in rows:
vg_id = game_slug_to_vg_id.get(game_slug)
if vg_id is not None:
conn.execute(
games_table.update()
.where(games_table.c.id == game_id)
.values(version_group_id=vg_id)
)
# 4. Add game_id to route_encounters (nullable initially), populate from routes.game_id
op.add_column('route_encounters', sa.Column('game_id', sa.Integer(),
sa.ForeignKey('games.id'), nullable=True))
op.create_index('ix_route_encounters_game_id', 'route_encounters', ['game_id'])
routes_table = sa.table(
'routes',
sa.column('id', sa.Integer),
sa.column('name', sa.String),
sa.column('game_id', sa.Integer),
)
re_table = sa.table(
'route_encounters',
sa.column('id', sa.Integer),
sa.column('route_id', sa.Integer),
sa.column('game_id', sa.Integer),
)
# Populate route_encounters.game_id from routes.game_id via join
conn.execute(
re_table.update()
.where(re_table.c.route_id == routes_table.c.id)
.values(game_id=routes_table.c.game_id)
)
# 5. Drop old unique constraint on route_encounters, add new one with game_id
op.drop_constraint('uq_route_pokemon_method', 'route_encounters', type_='unique')
op.create_unique_constraint(
'uq_route_pokemon_method_game', 'route_encounters',
['route_id', 'pokemon_id', 'encounter_method', 'game_id']
)
# 6. Deduplicate routes within version groups
# For multi-game version groups, keep routes from the lowest game_id (canonical)
# and re-point route_encounters, encounters, and boss_battles to canonical routes
encounters_table = sa.table(
'encounters',
sa.column('id', sa.Integer),
sa.column('route_id', sa.Integer),
)
boss_battles_table = sa.table(
'boss_battles',
sa.column('id', sa.Integer),
sa.column('game_id', sa.Integer),
sa.column('after_route_id', sa.Integer),
)
# Get all version groups that have more than one game
for vg_slug, vg_info in vg_data.items():
game_slugs = list(vg_info["games"].keys())
if len(game_slugs) <= 1:
continue
vg_id = slug_to_vg_id[vg_slug]
# Get game IDs for this version group, ordered
game_rows = conn.execute(
sa.select(games_table.c.id, games_table.c.slug)
.where(games_table.c.version_group_id == vg_id)
.order_by(games_table.c.id)
).fetchall()
if len(game_rows) <= 1:
continue
canonical_game_id = game_rows[0][0]
non_canonical_game_ids = [r[0] for r in game_rows[1:]]
# Get canonical routes (by name)
canonical_routes = conn.execute(
sa.select(routes_table.c.id, routes_table.c.name)
.where(routes_table.c.game_id == canonical_game_id)
).fetchall()
canonical_name_to_id = {name: rid for rid, name in canonical_routes}
# For each non-canonical game, re-point references to canonical routes
for nc_game_id in non_canonical_game_ids:
nc_routes = conn.execute(
sa.select(routes_table.c.id, routes_table.c.name)
.where(routes_table.c.game_id == nc_game_id)
).fetchall()
for old_route_id, route_name in nc_routes:
canonical_id = canonical_name_to_id.get(route_name)
if canonical_id is None:
continue
# Re-point route_encounters
conn.execute(
re_table.update()
.where(re_table.c.route_id == old_route_id)
.values(route_id=canonical_id)
)
# Re-point encounters
conn.execute(
encounters_table.update()
.where(encounters_table.c.route_id == old_route_id)
.values(route_id=canonical_id)
)
# Re-point boss_battles.after_route_id
conn.execute(
boss_battles_table.update()
.where(boss_battles_table.c.after_route_id == old_route_id)
.values(after_route_id=canonical_id)
)
# Delete non-canonical routes (children first due to parent FK)
# First delete child routes
conn.execute(
sa.text(
"DELETE FROM routes WHERE parent_route_id IS NOT NULL AND game_id IN :nc_ids"
).bindparams(sa.bindparam('nc_ids', expanding=True)),
{"nc_ids": non_canonical_game_ids}
)
# Then delete parent routes
conn.execute(
sa.text(
"DELETE FROM routes WHERE game_id IN :nc_ids"
).bindparams(sa.bindparam('nc_ids', expanding=True)),
{"nc_ids": non_canonical_game_ids}
)
# 7. Add version_group_id to routes (nullable), populate from games.version_group_id
op.add_column('routes', sa.Column('version_group_id', sa.Integer(),
sa.ForeignKey('version_groups.id'), nullable=True))
op.create_index('ix_routes_version_group_id', 'routes', ['version_group_id'])
# Need to re-declare routes_table with version_group_id
routes_table_v2 = sa.table(
'routes',
sa.column('id', sa.Integer),
sa.column('name', sa.String),
sa.column('game_id', sa.Integer),
sa.column('version_group_id', sa.Integer),
)
# Populate routes.version_group_id from the game's version_group_id
conn.execute(
routes_table_v2.update()
.where(routes_table_v2.c.game_id == games_table.c.id)
.values(version_group_id=games_table.c.version_group_id)
)
# 8. Drop routes.game_id, drop old unique constraint, add new one
op.drop_constraint('uq_routes_game_name', 'routes', type_='unique')
op.drop_index('ix_routes_game_id', 'routes')
op.drop_column('routes', 'game_id')
op.create_unique_constraint(
'uq_routes_version_group_name', 'routes',
['version_group_id', 'name']
)
# 9. Add version_group_id to boss_battles (nullable), populate from games.version_group_id
op.add_column('boss_battles', sa.Column('version_group_id', sa.Integer(),
sa.ForeignKey('version_groups.id'), nullable=True))
op.create_index('ix_boss_battles_version_group_id', 'boss_battles', ['version_group_id'])
bb_table_v2 = sa.table(
'boss_battles',
sa.column('id', sa.Integer),
sa.column('game_id', sa.Integer),
sa.column('version_group_id', sa.Integer),
)
conn.execute(
bb_table_v2.update()
.where(bb_table_v2.c.game_id == games_table.c.id)
.values(version_group_id=games_table.c.version_group_id)
)
# 10. Drop boss_battles.game_id
op.drop_index('ix_boss_battles_game_id', 'boss_battles')
op.drop_column('boss_battles', 'game_id')
# 11. Make columns non-nullable
op.alter_column('route_encounters', 'game_id', nullable=False)
op.alter_column('routes', 'version_group_id', nullable=False)
op.alter_column('boss_battles', 'version_group_id', nullable=False)
op.alter_column('games', 'version_group_id', nullable=False)
def downgrade() -> None:
# This migration is not reversible in a meaningful way due to data deduplication
raise NotImplementedError("Cannot downgrade: route deduplication is not reversible")

View File

@@ -23,6 +23,15 @@ from app.schemas.boss import (
router = APIRouter() router = APIRouter()
async def _get_version_group_id(session: AsyncSession, game_id: int) -> int:
game = await session.get(Game, game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
if game.version_group_id is None:
raise HTTPException(status_code=400, detail="Game has no version group assigned")
return game.version_group_id
# --- Game-scoped (admin) endpoints --- # --- Game-scoped (admin) endpoints ---
@@ -30,13 +39,11 @@ router = APIRouter()
async def list_bosses( async def list_bosses(
game_id: int, session: AsyncSession = Depends(get_session) game_id: int, session: AsyncSession = Depends(get_session)
): ):
game = await session.get(Game, game_id) vg_id = await _get_version_group_id(session, game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
result = await session.execute( result = await session.execute(
select(BossBattle) select(BossBattle)
.where(BossBattle.game_id == game_id) .where(BossBattle.version_group_id == vg_id)
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon)) .options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
.order_by(BossBattle.order) .order_by(BossBattle.order)
) )
@@ -49,11 +56,9 @@ async def create_boss(
data: BossBattleCreate, data: BossBattleCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
game = await session.get(Game, game_id) vg_id = await _get_version_group_id(session, game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
boss = BossBattle(game_id=game_id, **data.model_dump()) boss = BossBattle(version_group_id=vg_id, **data.model_dump())
session.add(boss) session.add(boss)
await session.commit() await session.commit()
@@ -73,9 +78,11 @@ async def update_boss(
data: BossBattleUpdate, data: BossBattleUpdate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
vg_id = await _get_version_group_id(session, game_id)
result = await session.execute( result = await session.execute(
select(BossBattle) select(BossBattle)
.where(BossBattle.id == boss_id, BossBattle.game_id == game_id) .where(BossBattle.id == boss_id, BossBattle.version_group_id == vg_id)
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon)) .options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
) )
boss = result.scalar_one_or_none() boss = result.scalar_one_or_none()
@@ -103,8 +110,10 @@ async def delete_boss(
boss_id: int, boss_id: int,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
vg_id = await _get_version_group_id(session, game_id)
result = await session.execute( result = await session.execute(
select(BossBattle).where(BossBattle.id == boss_id, BossBattle.game_id == game_id) select(BossBattle).where(BossBattle.id == boss_id, BossBattle.version_group_id == vg_id)
) )
boss = result.scalar_one_or_none() boss = result.scalar_one_or_none()
if boss is None: if boss is None:
@@ -125,9 +134,11 @@ async def set_boss_team(
team: list[BossPokemonInput], team: list[BossPokemonInput],
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
vg_id = await _get_version_group_id(session, game_id)
result = await session.execute( result = await session.execute(
select(BossBattle) select(BossBattle)
.where(BossBattle.id == boss_id, BossBattle.game_id == game_id) .where(BossBattle.id == boss_id, BossBattle.version_group_id == vg_id)
.options(selectinload(BossBattle.pokemon)) .options(selectinload(BossBattle.pokemon))
) )
boss = result.scalar_one_or_none() boss = result.scalar_one_or_none()

View File

@@ -1,9 +1,10 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.core.database import get_session from app.core.database import get_session
from app.models.boss_battle import BossBattle
from app.models.game import Game from app.models.game import Game
from app.models.route import Route from app.models.route import Route
from app.models.route_encounter import RouteEncounter from app.models.route_encounter import RouteEncounter
@@ -22,6 +23,20 @@ from app.schemas.game import (
router = APIRouter() router = APIRouter()
async def _get_game_or_404(session: AsyncSession, game_id: int) -> Game:
game = await session.get(Game, game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
return game
async def _get_version_group_id(session: AsyncSession, game_id: int) -> int:
game = await _get_game_or_404(session, game_id)
if game.version_group_id is None:
raise HTTPException(status_code=400, detail="Game has no version group assigned")
return game.version_group_id
@router.get("", response_model=list[GameResponse]) @router.get("", response_model=list[GameResponse])
async def list_games(session: AsyncSession = Depends(get_session)): async def list_games(session: AsyncSession = Depends(get_session)):
result = await session.execute(select(Game).order_by(Game.id)) result = await session.execute(select(Game).order_by(Game.id))
@@ -30,18 +45,41 @@ async def list_games(session: AsyncSession = Depends(get_session)):
@router.get("/{game_id}", response_model=GameDetailResponse) @router.get("/{game_id}", response_model=GameDetailResponse)
async def get_game(game_id: int, session: AsyncSession = Depends(get_session)): async def get_game(game_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute( game = await _get_game_or_404(session, game_id)
select(Game) vg_id = game.version_group_id
.where(Game.id == game_id)
.options(selectinload(Game.routes))
)
game = result.scalar_one_or_none()
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
# Sort routes by order for the response # Load routes via version_group_id
game.routes.sort(key=lambda r: r.order) result = await session.execute(
return game select(Route)
.where(Route.version_group_id == vg_id)
.order_by(Route.order)
)
routes = result.scalars().all()
# Attach routes to game for serialization
return {
"id": game.id,
"name": game.name,
"slug": game.slug,
"generation": game.generation,
"region": game.region,
"box_art_url": game.box_art_url,
"release_year": game.release_year,
"color": game.color,
"version_group_id": game.version_group_id,
"routes": [
{
"id": r.id,
"name": r.name,
"version_group_id": r.version_group_id,
"order": r.order,
"parent_route_id": r.parent_route_id,
"pinwheel_zone": r.pinwheel_zone,
"encounter_methods": [],
}
for r in routes
],
}
@router.get( @router.get(
@@ -59,25 +97,26 @@ async def list_game_routes(
By default, returns a hierarchical structure with top-level routes containing By default, returns a hierarchical structure with top-level routes containing
nested children. Use `flat=True` to get a flat list of all routes. nested children. Use `flat=True` to get a flat list of all routes.
""" """
# Verify game exists vg_id = await _get_version_group_id(session, game_id)
game = await session.get(Game, game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
result = await session.execute( result = await session.execute(
select(Route) select(Route)
.where(Route.game_id == game_id) .where(Route.version_group_id == vg_id)
.options(selectinload(Route.route_encounters)) .options(selectinload(Route.route_encounters))
.order_by(Route.order) .order_by(Route.order)
) )
all_routes = result.scalars().all() all_routes = result.scalars().all()
def route_to_dict(route: Route) -> dict: def route_to_dict(route: Route) -> dict:
methods = sorted({re.encounter_method for re in route.route_encounters}) # Only show encounter methods for the requested game
methods = sorted({
re.encounter_method for re in route.route_encounters
if re.game_id == game_id
})
return { return {
"id": route.id, "id": route.id,
"name": route.name, "name": route.name,
"game_id": route.game_id, "version_group_id": route.version_group_id,
"order": route.order, "order": route.order,
"parent_route_id": route.parent_route_id, "parent_route_id": route.parent_route_id,
"pinwheel_zone": route.pinwheel_zone, "pinwheel_zone": route.pinwheel_zone,
@@ -171,11 +210,38 @@ async def delete_game(
detail="Cannot delete game with existing runs. Delete the runs first.", detail="Cannot delete game with existing runs. Delete the runs first.",
) )
# Delete routes (and their route_encounters via cascade) vg_id = game.version_group_id
routes = await session.execute(
select(Route).where(Route.game_id == game_id) # Delete game-specific route_encounters
await session.execute(
delete(RouteEncounter).where(RouteEncounter.game_id == game_id)
) )
for route in routes.scalars().all():
# Check if this is the last game in the version group
other_games = await session.execute(
select(Game).where(Game.version_group_id == vg_id, Game.id != game_id)
)
is_last_in_group = other_games.scalar_one_or_none() is None
if is_last_in_group and vg_id is not None:
# Delete boss battles
await session.execute(
delete(BossBattle).where(BossBattle.version_group_id == vg_id)
)
# Delete routes (children first due to parent FK)
child_routes = await session.execute(
select(Route).where(
Route.version_group_id == vg_id,
Route.parent_route_id.isnot(None),
)
)
for route in child_routes.scalars().all():
await session.delete(route)
await session.flush()
parent_routes = await session.execute(
select(Route).where(Route.version_group_id == vg_id)
)
for route in parent_routes.scalars().all():
await session.delete(route) await session.delete(route)
await session.delete(game) await session.delete(game)
@@ -186,11 +252,9 @@ async def delete_game(
async def create_route( async def create_route(
game_id: int, data: RouteCreate, session: AsyncSession = Depends(get_session) game_id: int, data: RouteCreate, session: AsyncSession = Depends(get_session)
): ):
game = await session.get(Game, game_id) vg_id = await _get_version_group_id(session, game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
route = Route(game_id=game_id, **data.model_dump()) route = Route(version_group_id=vg_id, **data.model_dump())
session.add(route) session.add(route)
await session.commit() await session.commit()
await session.refresh(route) await session.refresh(route)
@@ -203,13 +267,11 @@ async def reorder_routes(
data: RouteReorderRequest, data: RouteReorderRequest,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
game = await session.get(Game, game_id) vg_id = await _get_version_group_id(session, game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
for item in data.routes: for item in data.routes:
route = await session.get(Route, item.id) route = await session.get(Route, item.id)
if route is None or route.game_id != game_id: if route is None or route.version_group_id != vg_id:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Route {item.id} not found in this game", detail=f"Route {item.id} not found in this game",
@@ -219,7 +281,7 @@ async def reorder_routes(
await session.commit() await session.commit()
result = await session.execute( result = await session.execute(
select(Route).where(Route.game_id == game_id).order_by(Route.order) select(Route).where(Route.version_group_id == vg_id).order_by(Route.order)
) )
return result.scalars().all() return result.scalars().all()
@@ -231,8 +293,10 @@ async def update_route(
data: RouteUpdate, data: RouteUpdate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
vg_id = await _get_version_group_id(session, game_id)
route = await session.get(Route, route_id) route = await session.get(Route, route_id)
if route is None or route.game_id != game_id: if route is None or route.version_group_id != vg_id:
raise HTTPException(status_code=404, detail="Route not found in this game") raise HTTPException(status_code=404, detail="Route not found in this game")
for field, value in data.model_dump(exclude_unset=True).items(): for field, value in data.model_dump(exclude_unset=True).items():
@@ -249,9 +313,11 @@ async def delete_route(
route_id: int, route_id: int,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
vg_id = await _get_version_group_id(session, game_id)
result = await session.execute( result = await session.execute(
select(Route) select(Route)
.where(Route.id == route_id, Route.game_id == game_id) .where(Route.id == route_id, Route.version_group_id == vg_id)
.options(selectinload(Route.encounters)) .options(selectinload(Route.encounters))
) )
route = result.scalar_one_or_none() route = result.scalar_one_or_none()

View File

@@ -256,19 +256,25 @@ async def delete_pokemon(
response_model=list[RouteEncounterDetailResponse], response_model=list[RouteEncounterDetailResponse],
) )
async def list_route_encounters( async def list_route_encounters(
route_id: int, session: AsyncSession = Depends(get_session) route_id: int,
game_id: int | None = Query(None),
session: AsyncSession = Depends(get_session),
): ):
# Verify route exists # Verify route exists
route = await session.get(Route, route_id) route = await session.get(Route, route_id)
if route is None: if route is None:
raise HTTPException(status_code=404, detail="Route not found") raise HTTPException(status_code=404, detail="Route not found")
result = await session.execute( query = (
select(RouteEncounter) select(RouteEncounter)
.where(RouteEncounter.route_id == route_id) .where(RouteEncounter.route_id == route_id)
.options(joinedload(RouteEncounter.pokemon)) .options(joinedload(RouteEncounter.pokemon))
.order_by(RouteEncounter.encounter_rate.desc()) .order_by(RouteEncounter.encounter_rate.desc())
) )
if game_id is not None:
query = query.where(RouteEncounter.game_id == game_id)
result = await session.execute(query)
return result.scalars().unique().all() return result.scalars().unique().all()

View File

@@ -8,6 +8,7 @@ from app.models.nuzlocke_run import NuzlockeRun
from app.models.pokemon import Pokemon from app.models.pokemon import Pokemon
from app.models.route import Route from app.models.route import Route
from app.models.route_encounter import RouteEncounter from app.models.route_encounter import RouteEncounter
from app.models.version_group import VersionGroup
__all__ = [ __all__ = [
"BossBattle", "BossBattle",
@@ -20,4 +21,5 @@ __all__ = [
"Pokemon", "Pokemon",
"Route", "Route",
"RouteEncounter", "RouteEncounter",
"VersionGroup",
] ]

View File

@@ -8,7 +8,9 @@ class BossBattle(Base):
__tablename__ = "boss_battles" __tablename__ = "boss_battles"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
game_id: Mapped[int] = mapped_column(ForeignKey("games.id"), index=True) version_group_id: Mapped[int] = mapped_column(
ForeignKey("version_groups.id"), index=True
)
name: Mapped[str] = mapped_column(String(100)) name: Mapped[str] = mapped_column(String(100))
boss_type: Mapped[str] = mapped_column(String(20)) # gym_leader, elite_four, champion, rival, evil_team, other boss_type: Mapped[str] = mapped_column(String(20)) # gym_leader, elite_four, champion, rival, evil_team, other
badge_name: Mapped[str | None] = mapped_column(String(100)) badge_name: Mapped[str | None] = mapped_column(String(100))
@@ -21,7 +23,9 @@ class BossBattle(Base):
location: Mapped[str] = mapped_column(String(200)) location: Mapped[str] = mapped_column(String(200))
sprite_url: Mapped[str | None] = mapped_column(String(500)) sprite_url: Mapped[str | None] = mapped_column(String(500))
game: Mapped["Game"] = relationship(back_populates="boss_battles") version_group: Mapped["VersionGroup"] = relationship(
back_populates="boss_battles"
)
after_route: Mapped["Route | None"] = relationship() after_route: Mapped["Route | None"] = relationship()
pokemon: Mapped[list["BossPokemon"]] = relationship( pokemon: Mapped[list["BossPokemon"]] = relationship(
back_populates="boss_battle", cascade="all, delete-orphan" back_populates="boss_battle", cascade="all, delete-orphan"

View File

@@ -1,4 +1,4 @@
from sqlalchemy import SmallInteger, String from sqlalchemy import ForeignKey, SmallInteger, String
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base from app.core.database import Base
@@ -15,10 +15,14 @@ class Game(Base):
box_art_url: Mapped[str | None] = mapped_column(String(500)) box_art_url: Mapped[str | None] = mapped_column(String(500))
release_year: Mapped[int | None] = mapped_column(SmallInteger) release_year: Mapped[int | None] = mapped_column(SmallInteger)
color: Mapped[str | None] = mapped_column(String(7)) # Hex color e.g. #FF0000 color: Mapped[str | None] = mapped_column(String(7)) # Hex color e.g. #FF0000
version_group_id: Mapped[int | None] = mapped_column(
ForeignKey("version_groups.id"), index=True
)
routes: Mapped[list["Route"]] = relationship(back_populates="game") version_group: Mapped["VersionGroup | None"] = relationship(
back_populates="games"
)
runs: Mapped[list["NuzlockeRun"]] = relationship(back_populates="game") runs: Mapped[list["NuzlockeRun"]] = relationship(back_populates="game")
boss_battles: Mapped[list["BossBattle"]] = relationship(back_populates="game")
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Game(id={self.id}, name='{self.name}')>" return f"<Game(id={self.id}, name='{self.name}')>"

View File

@@ -7,19 +7,21 @@ from app.core.database import Base
class Route(Base): class Route(Base):
__tablename__ = "routes" __tablename__ = "routes"
__table_args__ = ( __table_args__ = (
UniqueConstraint("game_id", "name", name="uq_routes_game_name"), UniqueConstraint("version_group_id", "name", name="uq_routes_version_group_name"),
) )
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100)) name: Mapped[str] = mapped_column(String(100))
game_id: Mapped[int] = mapped_column(ForeignKey("games.id"), index=True) version_group_id: Mapped[int] = mapped_column(
ForeignKey("version_groups.id"), index=True
)
order: Mapped[int] = mapped_column(SmallInteger) order: Mapped[int] = mapped_column(SmallInteger)
parent_route_id: Mapped[int | None] = mapped_column( parent_route_id: Mapped[int | None] = mapped_column(
ForeignKey("routes.id", ondelete="CASCADE"), index=True, default=None ForeignKey("routes.id", ondelete="CASCADE"), index=True, default=None
) )
pinwheel_zone: Mapped[int | None] = mapped_column(SmallInteger, default=None) pinwheel_zone: Mapped[int | None] = mapped_column(SmallInteger, default=None)
game: Mapped["Game"] = relationship(back_populates="routes") version_group: Mapped["VersionGroup"] = relationship(back_populates="routes")
route_encounters: Mapped[list["RouteEncounter"]] = relationship( route_encounters: Mapped[list["RouteEncounter"]] = relationship(
back_populates="route" back_populates="route"
) )

View File

@@ -8,13 +8,15 @@ class RouteEncounter(Base):
__tablename__ = "route_encounters" __tablename__ = "route_encounters"
__table_args__ = ( __table_args__ = (
UniqueConstraint( UniqueConstraint(
"route_id", "pokemon_id", "encounter_method", name="uq_route_pokemon_method" "route_id", "pokemon_id", "encounter_method", "game_id",
name="uq_route_pokemon_method_game"
), ),
) )
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
route_id: Mapped[int] = mapped_column(ForeignKey("routes.id"), index=True) route_id: Mapped[int] = mapped_column(ForeignKey("routes.id"), index=True)
pokemon_id: Mapped[int] = mapped_column(ForeignKey("pokemon.id"), index=True) pokemon_id: Mapped[int] = mapped_column(ForeignKey("pokemon.id"), index=True)
game_id: Mapped[int] = mapped_column(ForeignKey("games.id"), index=True)
encounter_method: Mapped[str] = mapped_column(String(30)) encounter_method: Mapped[str] = mapped_column(String(30))
encounter_rate: Mapped[int] = mapped_column(SmallInteger) encounter_rate: Mapped[int] = mapped_column(SmallInteger)
min_level: Mapped[int] = mapped_column(SmallInteger) min_level: Mapped[int] = mapped_column(SmallInteger)
@@ -22,6 +24,7 @@ class RouteEncounter(Base):
route: Mapped["Route"] = relationship(back_populates="route_encounters") route: Mapped["Route"] = relationship(back_populates="route_encounters")
pokemon: Mapped["Pokemon"] = relationship(back_populates="route_encounters") pokemon: Mapped["Pokemon"] = relationship(back_populates="route_encounters")
game: Mapped["Game"] = relationship()
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<RouteEncounter(route_id={self.route_id}, pokemon_id={self.pokemon_id}, method='{self.encounter_method}')>" return f"<RouteEncounter(route_id={self.route_id}, pokemon_id={self.pokemon_id}, method='{self.encounter_method}')>"

View File

@@ -0,0 +1,21 @@
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class VersionGroup(Base):
__tablename__ = "version_groups"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
slug: Mapped[str] = mapped_column(String(100), unique=True)
games: Mapped[list["Game"]] = relationship(back_populates="version_group")
routes: Mapped[list["Route"]] = relationship(back_populates="version_group")
boss_battles: Mapped[list["BossBattle"]] = relationship(
back_populates="version_group"
)
def __repr__(self) -> str:
return f"<VersionGroup(id={self.id}, name='{self.name}')>"

View File

@@ -14,7 +14,7 @@ class BossPokemonResponse(CamelModel):
class BossBattleResponse(CamelModel): class BossBattleResponse(CamelModel):
id: int id: int
game_id: int version_group_id: int
name: str name: str
boss_type: str boss_type: str
badge_name: str | None badge_name: str | None

View File

@@ -4,7 +4,7 @@ from app.schemas.base import CamelModel
class RouteResponse(CamelModel): class RouteResponse(CamelModel):
id: int id: int
name: str name: str
game_id: int version_group_id: int
order: int order: int
parent_route_id: int | None = None parent_route_id: int | None = None
pinwheel_zone: int | None = None pinwheel_zone: int | None = None
@@ -20,6 +20,7 @@ class GameResponse(CamelModel):
box_art_url: str | None box_art_url: str | None
release_year: int | None release_year: int | None
color: str | None color: str | None
version_group_id: int | None = None
class RouteWithChildrenResponse(RouteResponse): class RouteWithChildrenResponse(RouteResponse):

View File

@@ -39,6 +39,7 @@ class RouteEncounterResponse(CamelModel):
id: int id: int
route_id: int route_id: int
pokemon_id: int pokemon_id: int
game_id: int
encounter_method: str encounter_method: str
encounter_rate: int encounter_rate: int
min_level: int min_level: int
@@ -70,6 +71,7 @@ class PokemonUpdate(CamelModel):
class RouteEncounterCreate(CamelModel): class RouteEncounterCreate(CamelModel):
pokemon_id: int pokemon_id: int
game_id: int
encounter_method: str encounter_method: str
encounter_rate: int encounter_rate: int
min_level: int min_level: int

View File

@@ -9,27 +9,65 @@ from app.models.game import Game
from app.models.pokemon import Pokemon from app.models.pokemon import Pokemon
from app.models.route import Route from app.models.route import Route
from app.models.route_encounter import RouteEncounter from app.models.route_encounter import RouteEncounter
from app.models.version_group import VersionGroup
async def upsert_games(session: AsyncSession, games: list[dict]) -> dict[str, int]: async def upsert_version_groups(
"""Upsert game records, return {slug: id} mapping.""" session: AsyncSession,
for game in games: vg_data: dict[str, dict],
stmt = insert(Game).values( ) -> dict[str, int]:
name=game["name"], """Upsert version group records, return {slug: id} mapping."""
slug=game["slug"], for vg_slug, vg_info in vg_data.items():
generation=game["generation"], vg_name = " / ".join(
region=game["region"], g["name"].replace("Pokemon ", "")
release_year=game.get("release_year"), for g in vg_info["games"].values()
color=game.get("color"), )
stmt = insert(VersionGroup).values(
name=vg_name,
slug=vg_slug,
).on_conflict_do_update( ).on_conflict_do_update(
index_elements=["slug"], index_elements=["slug"],
set_={ set_={"name": vg_name},
)
await session.execute(stmt)
await session.flush()
result = await session.execute(select(VersionGroup.slug, VersionGroup.id))
return {row.slug: row.id for row in result}
async def upsert_games(
session: AsyncSession,
games: list[dict],
slug_to_vg_id: dict[str, int] | None = None,
) -> dict[str, int]:
"""Upsert game records, return {slug: id} mapping."""
for game in games:
values = {
"name": game["name"],
"slug": game["slug"],
"generation": game["generation"],
"region": game["region"],
"release_year": game.get("release_year"),
"color": game.get("color"),
}
update_set = {
"name": game["name"], "name": game["name"],
"generation": game["generation"], "generation": game["generation"],
"region": game["region"], "region": game["region"],
"release_year": game.get("release_year"), "release_year": game.get("release_year"),
"color": game.get("color"), "color": game.get("color"),
}, }
if slug_to_vg_id is not None:
vg_id = slug_to_vg_id.get(game["slug"])
if vg_id is not None:
values["version_group_id"] = vg_id
update_set["version_group_id"] = vg_id
stmt = insert(Game).values(**values).on_conflict_do_update(
index_elements=["slug"],
set_=update_set,
) )
await session.execute(stmt) await session.execute(stmt)
@@ -67,10 +105,10 @@ async def upsert_pokemon(session: AsyncSession, pokemon_list: list[dict]) -> dic
async def upsert_routes( async def upsert_routes(
session: AsyncSession, session: AsyncSession,
game_id: int, version_group_id: int,
routes: list[dict], routes: list[dict],
) -> dict[str, int]: ) -> dict[str, int]:
"""Upsert route records for a game, return {name: id} mapping. """Upsert route records for a version group, return {name: id} mapping.
Handles hierarchical routes: routes with 'children' are parent routes, Handles hierarchical routes: routes with 'children' are parent routes,
and their children get parent_route_id set accordingly. and their children get parent_route_id set accordingly.
@@ -79,11 +117,11 @@ async def upsert_routes(
for route in routes: for route in routes:
stmt = insert(Route).values( stmt = insert(Route).values(
name=route["name"], name=route["name"],
game_id=game_id, version_group_id=version_group_id,
order=route["order"], order=route["order"],
parent_route_id=None, # Parent routes have no parent parent_route_id=None, # Parent routes have no parent
).on_conflict_do_update( ).on_conflict_do_update(
constraint="uq_routes_game_name", constraint="uq_routes_version_group_name",
set_={"order": route["order"], "parent_route_id": None}, set_={"order": route["order"], "parent_route_id": None},
) )
await session.execute(stmt) await session.execute(stmt)
@@ -92,7 +130,7 @@ async def upsert_routes(
# Get mapping of parent routes # Get mapping of parent routes
result = await session.execute( result = await session.execute(
select(Route.name, Route.id).where(Route.game_id == game_id) select(Route.name, Route.id).where(Route.version_group_id == version_group_id)
) )
name_to_id = {row.name: row.id for row in result} name_to_id = {row.name: row.id for row in result}
@@ -106,12 +144,12 @@ async def upsert_routes(
for child in children: for child in children:
stmt = insert(Route).values( stmt = insert(Route).values(
name=child["name"], name=child["name"],
game_id=game_id, version_group_id=version_group_id,
order=child["order"], order=child["order"],
parent_route_id=parent_id, parent_route_id=parent_id,
pinwheel_zone=child.get("pinwheel_zone"), pinwheel_zone=child.get("pinwheel_zone"),
).on_conflict_do_update( ).on_conflict_do_update(
constraint="uq_routes_game_name", constraint="uq_routes_version_group_name",
set_={ set_={
"order": child["order"], "order": child["order"],
"parent_route_id": parent_id, "parent_route_id": parent_id,
@@ -124,7 +162,7 @@ async def upsert_routes(
# Return full mapping including children # Return full mapping including children
result = await session.execute( result = await session.execute(
select(Route.name, Route.id).where(Route.game_id == game_id) select(Route.name, Route.id).where(Route.version_group_id == version_group_id)
) )
return {row.name: row.id for row in result} return {row.name: row.id for row in result}
@@ -134,8 +172,9 @@ async def upsert_route_encounters(
route_id: int, route_id: int,
encounters: list[dict], encounters: list[dict],
dex_to_id: dict[int, int], dex_to_id: dict[int, int],
game_id: int,
) -> int: ) -> int:
"""Upsert encounters for a route, return count of upserted rows.""" """Upsert encounters for a route and game, return count of upserted rows."""
count = 0 count = 0
for enc in encounters: for enc in encounters:
pokemon_id = dex_to_id.get(enc["pokeapi_id"]) pokemon_id = dex_to_id.get(enc["pokeapi_id"])
@@ -146,12 +185,13 @@ async def upsert_route_encounters(
stmt = insert(RouteEncounter).values( stmt = insert(RouteEncounter).values(
route_id=route_id, route_id=route_id,
pokemon_id=pokemon_id, pokemon_id=pokemon_id,
game_id=game_id,
encounter_method=enc["method"], encounter_method=enc["method"],
encounter_rate=enc["encounter_rate"], encounter_rate=enc["encounter_rate"],
min_level=enc["min_level"], min_level=enc["min_level"],
max_level=enc["max_level"], max_level=enc["max_level"],
).on_conflict_do_update( ).on_conflict_do_update(
constraint="uq_route_pokemon_method", constraint="uq_route_pokemon_method_game",
set_={ set_={
"encounter_rate": enc["encounter_rate"], "encounter_rate": enc["encounter_rate"],
"min_level": enc["min_level"], "min_level": enc["min_level"],

View File

@@ -11,40 +11,21 @@ from app.models.game import Game
from app.models.pokemon import Pokemon from app.models.pokemon import Pokemon
from app.models.route import Route from app.models.route import Route
from app.models.route_encounter import RouteEncounter from app.models.route_encounter import RouteEncounter
from app.models.version_group import VersionGroup
from app.seeds.loader import ( from app.seeds.loader import (
upsert_evolutions, upsert_evolutions,
upsert_games, upsert_games,
upsert_pokemon, upsert_pokemon,
upsert_route_encounters, upsert_route_encounters,
upsert_routes, upsert_routes,
upsert_version_groups,
) )
DATA_DIR = Path(__file__).parent / "data" DATA_DIR = Path(__file__).parent / "data"
VG_JSON = Path(__file__).parent / "version_groups.json"
# All Gen 1-9 games
GAME_FILES = [
# Gen 1
"red", "blue", "yellow",
# Gen 2
"gold", "silver", "crystal",
# Gen 3
"ruby", "sapphire", "emerald", "firered", "leafgreen",
# Gen 4
"diamond", "pearl", "platinum", "heartgold", "soulsilver",
# Gen 5
"black", "white", "black-2", "white-2",
# Gen 6
"x", "y", "omega-ruby", "alpha-sapphire",
# Gen 7
"sun", "moon", "ultra-sun", "ultra-moon", "lets-go-pikachu", "lets-go-eevee",
# Gen 8
"sword", "shield", "brilliant-diamond", "shining-pearl", "legends-arceus",
# Gen 9
"scarlet", "violet", "legends-z-a",
]
def load_json(filename: str) -> list[dict]: def load_json(filename: str):
path = DATA_DIR / filename path = DATA_DIR / filename
with open(path) as f: with open(path) as f:
return json.load(f) return json.load(f)
@@ -56,34 +37,67 @@ async def seed():
async with async_session() as session: async with async_session() as session:
async with session.begin(): async with session.begin():
# 1. Upsert games # 1. Upsert version groups
with open(VG_JSON) as f:
vg_data = json.load(f)
vg_slug_to_id = await upsert_version_groups(session, vg_data)
print(f"Version Groups: {len(vg_slug_to_id)} upserted")
# Build game_slug -> vg_id mapping
game_slug_to_vg_id: dict[str, int] = {}
for vg_slug, vg_info in vg_data.items():
vg_id = vg_slug_to_id[vg_slug]
for game_slug in vg_info["games"]:
game_slug_to_vg_id[game_slug] = vg_id
# 2. Upsert games (with version_group_id)
games_data = load_json("games.json") games_data = load_json("games.json")
slug_to_id = await upsert_games(session, games_data) slug_to_id = await upsert_games(session, games_data, game_slug_to_vg_id)
print(f"Games: {len(slug_to_id)} upserted") print(f"Games: {len(slug_to_id)} upserted")
# 2. Upsert Pokemon # 3. Upsert Pokemon
pokemon_data = load_json("pokemon.json") pokemon_data = load_json("pokemon.json")
dex_to_id = await upsert_pokemon(session, pokemon_data) dex_to_id = await upsert_pokemon(session, pokemon_data)
print(f"Pokemon: {len(dex_to_id)} upserted") print(f"Pokemon: {len(dex_to_id)} upserted")
# 3. Per game: upsert routes and encounters # 4. Per version group: upsert routes once, then encounters per game
total_routes = 0 total_routes = 0
total_encounters = 0 total_encounters = 0
for game_slug in GAME_FILES: for vg_slug, vg_info in vg_data.items():
vg_id = vg_slug_to_id[vg_slug]
game_slugs = list(vg_info["games"].keys())
# Use the first game's route JSON for the shared route structure
first_game_slug = game_slugs[0]
routes_file = DATA_DIR / f"{first_game_slug}.json"
if not routes_file.exists():
print(f" {vg_slug}: no route data ({first_game_slug}.json), skipping")
continue
routes_data = load_json(f"{first_game_slug}.json")
if not routes_data:
print(f" {vg_slug}: empty route data, skipping")
continue
# Upsert routes once per version group
route_map = await upsert_routes(session, vg_id, routes_data)
total_routes += len(route_map)
print(f" {vg_slug}: {len(route_map)} routes")
# Upsert encounters per game (each game may have different encounters)
for game_slug in game_slugs:
game_id = slug_to_id.get(game_slug) game_id = slug_to_id.get(game_slug)
if game_id is None: if game_id is None:
print(f" Warning: game '{game_slug}' not found, skipping") print(f" Warning: game '{game_slug}' not found, skipping")
continue continue
routes_data = load_json(f"{game_slug}.json") game_routes_file = DATA_DIR / f"{game_slug}.json"
if not routes_data: if not game_routes_file.exists():
print(f" {game_slug}: no route data, skipping")
continue continue
route_map = await upsert_routes(session, game_id, routes_data)
total_routes += len(route_map)
for route in routes_data: game_routes_data = load_json(f"{game_slug}.json")
for route in game_routes_data:
route_id = route_map.get(route["name"]) route_id = route_map.get(route["name"])
if route_id is None: if route_id is None:
print(f" Warning: route '{route['name']}' not found") print(f" Warning: route '{route['name']}' not found")
@@ -92,7 +106,8 @@ async def seed():
# Parent routes may have empty encounters # Parent routes may have empty encounters
if route["encounters"]: if route["encounters"]:
enc_count = await upsert_route_encounters( enc_count = await upsert_route_encounters(
session, route_id, route["encounters"], dex_to_id session, route_id, route["encounters"],
dex_to_id, game_id,
) )
total_encounters += enc_count total_encounters += enc_count
@@ -104,16 +119,17 @@ async def seed():
continue continue
enc_count = await upsert_route_encounters( enc_count = await upsert_route_encounters(
session, child_id, child["encounters"], dex_to_id session, child_id, child["encounters"],
dex_to_id, game_id,
) )
total_encounters += enc_count total_encounters += enc_count
print(f" {game_slug}: {len(route_map)} routes") print(f" {game_slug}: encounters loaded")
print(f"\nTotal routes: {total_routes}") print(f"\nTotal routes: {total_routes}")
print(f"Total encounters: {total_encounters}") print(f"Total encounters: {total_encounters}")
# 4. Upsert evolutions # 5. Upsert evolutions
evolutions_path = DATA_DIR / "evolutions.json" evolutions_path = DATA_DIR / "evolutions.json"
if evolutions_path.exists(): if evolutions_path.exists():
evolutions_data = load_json("evolutions.json") evolutions_data = load_json("evolutions.json")
@@ -131,32 +147,33 @@ async def verify():
async with async_session() as session: async with async_session() as session:
# Overall counts # Overall counts
vg_count = (await session.execute(select(func.count(VersionGroup.id)))).scalar()
games_count = (await session.execute(select(func.count(Game.id)))).scalar() games_count = (await session.execute(select(func.count(Game.id)))).scalar()
pokemon_count = (await session.execute(select(func.count(Pokemon.id)))).scalar() pokemon_count = (await session.execute(select(func.count(Pokemon.id)))).scalar()
routes_count = (await session.execute(select(func.count(Route.id)))).scalar() routes_count = (await session.execute(select(func.count(Route.id)))).scalar()
enc_count = (await session.execute(select(func.count(RouteEncounter.id)))).scalar() enc_count = (await session.execute(select(func.count(RouteEncounter.id)))).scalar()
print(f"Version Groups: {vg_count}")
print(f"Games: {games_count}") print(f"Games: {games_count}")
print(f"Pokemon: {pokemon_count}") print(f"Pokemon: {pokemon_count}")
print(f"Routes: {routes_count}") print(f"Routes: {routes_count}")
print(f"Route Encounters: {enc_count}") print(f"Route Encounters: {enc_count}")
# Per-game breakdown # Per-version-group route counts
result = await session.execute( result = await session.execute(
select(Game.name, func.count(Route.id)) select(VersionGroup.slug, func.count(Route.id))
.join(Route, Route.game_id == Game.id) .join(Route, Route.version_group_id == VersionGroup.id)
.group_by(Game.name) .group_by(VersionGroup.slug)
.order_by(Game.name) .order_by(VersionGroup.slug)
) )
print("\nRoutes per game:") print("\nRoutes per version group:")
for row in result: for row in result:
print(f" {row[0]}: {row[1]}") print(f" {row[0]}: {row[1]}")
# Per-game encounter counts # Per-game encounter counts
result = await session.execute( result = await session.execute(
select(Game.name, func.count(RouteEncounter.id)) select(Game.name, func.count(RouteEncounter.id))
.join(Route, Route.game_id == Game.id) .join(RouteEncounter, RouteEncounter.game_id == Game.id)
.join(RouteEncounter, RouteEncounter.route_id == Route.id)
.group_by(Game.name) .group_by(Game.name)
.order_by(Game.name) .order_by(Game.name)
) )

View File

@@ -19,6 +19,7 @@ export function getGameRoutes(gameId: number): Promise<Route[]> {
return api.get(`/games/${gameId}/routes?flat=true`) return api.get(`/games/${gameId}/routes?flat=true`)
} }
export function getRoutePokemon(routeId: number): Promise<RouteEncounterDetail[]> { export function getRoutePokemon(routeId: number, gameId?: number): Promise<RouteEncounterDetail[]> {
return api.get(`/routes/${routeId}/pokemon`) const params = gameId != null ? `?game_id=${gameId}` : ''
return api.get(`/routes/${routeId}/pokemon${params}`)
} }

View File

@@ -23,10 +23,10 @@ export function useGameRoutes(gameId: number | null) {
}) })
} }
export function useRoutePokemon(routeId: number | null) { export function useRoutePokemon(routeId: number | null, gameId?: number) {
return useQuery({ return useQuery({
queryKey: ['routes', routeId, 'pokemon'], queryKey: ['routes', routeId, 'pokemon', gameId],
queryFn: () => getRoutePokemon(routeId!), queryFn: () => getRoutePokemon(routeId!, gameId),
enabled: routeId !== null, enabled: routeId !== null,
}) })
} }

View File

@@ -1093,9 +1093,9 @@ export function RunEncounters() {
.map((bp) => ( .map((bp) => (
<div key={bp.id} className="flex items-center gap-1"> <div key={bp.id} className="flex items-center gap-1">
{bp.pokemon.spriteUrl ? ( {bp.pokemon.spriteUrl ? (
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-10 h-10" /> <img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-20 h-20" />
) : ( ) : (
<div className="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded-full" /> <div className="w-20 h-20 bg-gray-200 dark:bg-gray-700 rounded-full" />
)} )}
<span className="text-xs text-gray-500 dark:text-gray-400"> <span className="text-xs text-gray-500 dark:text-gray-400">
Lvl {bp.level} Lvl {bp.level}

View File

@@ -21,7 +21,7 @@ export function AdminRouteDetail() {
const rId = Number(routeId) const rId = Number(routeId)
const { data: game } = useGame(gId) const { data: game } = useGame(gId)
const { data: encounters = [], isLoading } = useRoutePokemon(rId) const { data: encounters = [], isLoading } = useRoutePokemon(rId, gId)
const addEncounter = useAddRouteEncounter(rId) const addEncounter = useAddRouteEncounter(rId)
const updateEncounter = useUpdateRouteEncounter(rId) const updateEncounter = useUpdateRouteEncounter(rId)
@@ -114,7 +114,7 @@ export function AdminRouteDetail() {
{showCreate && ( {showCreate && (
<RouteEncounterFormModal <RouteEncounterFormModal
onSubmit={(data) => onSubmit={(data) =>
addEncounter.mutate(data as CreateRouteEncounterInput, { addEncounter.mutate({ ...data, gameId: gId } as CreateRouteEncounterInput, {
onSuccess: () => setShowCreate(false), onSuccess: () => setShowCreate(false),
}) })
} }

View File

@@ -64,6 +64,7 @@ export interface PaginatedPokemon {
export interface CreateRouteEncounterInput { export interface CreateRouteEncounterInput {
pokemonId: number pokemonId: number
gameId: number
encounterMethod: string encounterMethod: string
encounterRate: number encounterRate: number
minLevel: number minLevel: number

View File

@@ -7,12 +7,13 @@ export interface Game {
boxArtUrl: string | null boxArtUrl: string | null
releaseYear: number | null releaseYear: number | null
color: string | null color: string | null
versionGroupId: number | null
} }
export interface Route { export interface Route {
id: number id: number
name: string name: string
gameId: number versionGroupId: number
order: number order: number
parentRouteId: number | null parentRouteId: number | null
pinwheelZone: number | null pinwheelZone: number | null
@@ -36,6 +37,7 @@ export interface RouteEncounter {
id: number id: number
routeId: number routeId: number
pokemonId: number pokemonId: number
gameId: number
encounterMethod: string encounterMethod: string
encounterRate: number encounterRate: number
minLevel: number minLevel: number
@@ -140,7 +142,7 @@ export interface BossPokemon {
export interface BossBattle { export interface BossBattle {
id: number id: number
gameId: number versionGroupId: number
name: string name: string
bossType: BossType bossType: BossType
badgeName: string | null badgeName: string | null