diff --git a/.beans/nuzlocke-tracker-lab1--add-version-groups-table-share-routes-boss-battles.md b/.beans/nuzlocke-tracker-lab1--add-version-groups-table-share-routes-boss-battles.md new file mode 100644 index 0000000..dd039f5 --- /dev/null +++ b/.beans/nuzlocke-tracker-lab1--add-version-groups-table-share-routes-boss-battles.md @@ -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 \ No newline at end of file diff --git a/backend/src/app/alembic/versions/d3e4f5a6b7c8_add_version_groups.py b/backend/src/app/alembic/versions/d3e4f5a6b7c8_add_version_groups.py new file mode 100644 index 0000000..b2c41e2 --- /dev/null +++ b/backend/src/app/alembic/versions/d3e4f5a6b7c8_add_version_groups.py @@ -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") diff --git a/backend/src/app/api/bosses.py b/backend/src/app/api/bosses.py index 0bcc4db..7d5921b 100644 --- a/backend/src/app/api/bosses.py +++ b/backend/src/app/api/bosses.py @@ -23,6 +23,15 @@ from app.schemas.boss import ( 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 --- @@ -30,13 +39,11 @@ router = APIRouter() async def list_bosses( game_id: int, session: AsyncSession = Depends(get_session) ): - game = await session.get(Game, game_id) - if game is None: - raise HTTPException(status_code=404, detail="Game not found") + vg_id = await _get_version_group_id(session, game_id) result = await session.execute( select(BossBattle) - .where(BossBattle.game_id == game_id) + .where(BossBattle.version_group_id == vg_id) .options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon)) .order_by(BossBattle.order) ) @@ -49,11 +56,9 @@ async def create_boss( data: BossBattleCreate, session: AsyncSession = Depends(get_session), ): - game = await session.get(Game, game_id) - if game is None: - raise HTTPException(status_code=404, detail="Game not found") + vg_id = await _get_version_group_id(session, game_id) - boss = BossBattle(game_id=game_id, **data.model_dump()) + boss = BossBattle(version_group_id=vg_id, **data.model_dump()) session.add(boss) await session.commit() @@ -73,9 +78,11 @@ async def update_boss( data: BossBattleUpdate, session: AsyncSession = Depends(get_session), ): + vg_id = await _get_version_group_id(session, game_id) + result = await session.execute( 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)) ) boss = result.scalar_one_or_none() @@ -103,8 +110,10 @@ async def delete_boss( boss_id: int, session: AsyncSession = Depends(get_session), ): + vg_id = await _get_version_group_id(session, game_id) + 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() if boss is None: @@ -125,9 +134,11 @@ async def set_boss_team( team: list[BossPokemonInput], session: AsyncSession = Depends(get_session), ): + vg_id = await _get_version_group_id(session, game_id) + result = await session.execute( 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)) ) boss = result.scalar_one_or_none() diff --git a/backend/src/app/api/games.py b/backend/src/app/api/games.py index 126374b..e7203b1 100644 --- a/backend/src/app/api/games.py +++ b/backend/src/app/api/games.py @@ -1,9 +1,10 @@ from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.core.database import get_session +from app.models.boss_battle import BossBattle from app.models.game import Game from app.models.route import Route from app.models.route_encounter import RouteEncounter @@ -22,6 +23,20 @@ from app.schemas.game import ( 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]) async def list_games(session: AsyncSession = Depends(get_session)): 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) async def get_game(game_id: int, session: AsyncSession = Depends(get_session)): - result = await session.execute( - select(Game) - .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") + game = await _get_game_or_404(session, game_id) + vg_id = game.version_group_id - # Sort routes by order for the response - game.routes.sort(key=lambda r: r.order) - return game + # Load routes via version_group_id + result = await session.execute( + 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( @@ -59,25 +97,26 @@ async def list_game_routes( By default, returns a hierarchical structure with top-level routes containing nested children. Use `flat=True` to get a flat list of all routes. """ - # Verify game exists - game = await session.get(Game, game_id) - if game is None: - raise HTTPException(status_code=404, detail="Game not found") + vg_id = await _get_version_group_id(session, game_id) result = await session.execute( select(Route) - .where(Route.game_id == game_id) + .where(Route.version_group_id == vg_id) .options(selectinload(Route.route_encounters)) .order_by(Route.order) ) all_routes = result.scalars().all() 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 { "id": route.id, "name": route.name, - "game_id": route.game_id, + "version_group_id": route.version_group_id, "order": route.order, "parent_route_id": route.parent_route_id, "pinwheel_zone": route.pinwheel_zone, @@ -171,12 +210,39 @@ async def delete_game( detail="Cannot delete game with existing runs. Delete the runs first.", ) - # Delete routes (and their route_encounters via cascade) - routes = await session.execute( - select(Route).where(Route.game_id == game_id) + vg_id = game.version_group_id + + # Delete game-specific route_encounters + await session.execute( + delete(RouteEncounter).where(RouteEncounter.game_id == game_id) ) - for route in routes.scalars().all(): - await session.delete(route) + + # 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(game) await session.commit() @@ -186,11 +252,9 @@ async def delete_game( async def create_route( game_id: int, data: RouteCreate, session: AsyncSession = Depends(get_session) ): - game = await session.get(Game, game_id) - if game is None: - raise HTTPException(status_code=404, detail="Game not found") + vg_id = await _get_version_group_id(session, game_id) - route = Route(game_id=game_id, **data.model_dump()) + route = Route(version_group_id=vg_id, **data.model_dump()) session.add(route) await session.commit() await session.refresh(route) @@ -203,13 +267,11 @@ async def reorder_routes( data: RouteReorderRequest, session: AsyncSession = Depends(get_session), ): - game = await session.get(Game, game_id) - if game is None: - raise HTTPException(status_code=404, detail="Game not found") + vg_id = await _get_version_group_id(session, game_id) for item in data.routes: 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( status_code=400, detail=f"Route {item.id} not found in this game", @@ -219,7 +281,7 @@ async def reorder_routes( await session.commit() 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() @@ -231,8 +293,10 @@ async def update_route( data: RouteUpdate, session: AsyncSession = Depends(get_session), ): + vg_id = await _get_version_group_id(session, game_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") for field, value in data.model_dump(exclude_unset=True).items(): @@ -249,9 +313,11 @@ async def delete_route( route_id: int, session: AsyncSession = Depends(get_session), ): + vg_id = await _get_version_group_id(session, game_id) + result = await session.execute( 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)) ) route = result.scalar_one_or_none() diff --git a/backend/src/app/api/pokemon.py b/backend/src/app/api/pokemon.py index 779abae..493bb74 100644 --- a/backend/src/app/api/pokemon.py +++ b/backend/src/app/api/pokemon.py @@ -256,19 +256,25 @@ async def delete_pokemon( response_model=list[RouteEncounterDetailResponse], ) 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 route = await session.get(Route, route_id) if route is None: raise HTTPException(status_code=404, detail="Route not found") - result = await session.execute( + query = ( select(RouteEncounter) .where(RouteEncounter.route_id == route_id) .options(joinedload(RouteEncounter.pokemon)) .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() diff --git a/backend/src/app/models/__init__.py b/backend/src/app/models/__init__.py index a762748..3bc4c51 100644 --- a/backend/src/app/models/__init__.py +++ b/backend/src/app/models/__init__.py @@ -8,6 +8,7 @@ from app.models.nuzlocke_run import NuzlockeRun from app.models.pokemon import Pokemon from app.models.route import Route from app.models.route_encounter import RouteEncounter +from app.models.version_group import VersionGroup __all__ = [ "BossBattle", @@ -20,4 +21,5 @@ __all__ = [ "Pokemon", "Route", "RouteEncounter", + "VersionGroup", ] diff --git a/backend/src/app/models/boss_battle.py b/backend/src/app/models/boss_battle.py index 525e9f3..ebdd0fe 100644 --- a/backend/src/app/models/boss_battle.py +++ b/backend/src/app/models/boss_battle.py @@ -8,7 +8,9 @@ class BossBattle(Base): __tablename__ = "boss_battles" 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)) 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)) @@ -21,7 +23,9 @@ class BossBattle(Base): location: Mapped[str] = mapped_column(String(200)) 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() pokemon: Mapped[list["BossPokemon"]] = relationship( back_populates="boss_battle", cascade="all, delete-orphan" diff --git a/backend/src/app/models/game.py b/backend/src/app/models/game.py index 84b36e8..a137d05 100644 --- a/backend/src/app/models/game.py +++ b/backend/src/app/models/game.py @@ -1,4 +1,4 @@ -from sqlalchemy import SmallInteger, String +from sqlalchemy import ForeignKey, SmallInteger, String from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.database import Base @@ -15,10 +15,14 @@ class Game(Base): box_art_url: Mapped[str | None] = mapped_column(String(500)) release_year: Mapped[int | None] = mapped_column(SmallInteger) 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") - boss_battles: Mapped[list["BossBattle"]] = relationship(back_populates="game") def __repr__(self) -> str: return f"" diff --git a/backend/src/app/models/route.py b/backend/src/app/models/route.py index 2d89a91..228cd53 100644 --- a/backend/src/app/models/route.py +++ b/backend/src/app/models/route.py @@ -7,19 +7,21 @@ from app.core.database import Base class Route(Base): __tablename__ = "routes" __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) 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) parent_route_id: Mapped[int | None] = mapped_column( ForeignKey("routes.id", ondelete="CASCADE"), index=True, 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( back_populates="route" ) diff --git a/backend/src/app/models/route_encounter.py b/backend/src/app/models/route_encounter.py index fa490d3..35c5542 100644 --- a/backend/src/app/models/route_encounter.py +++ b/backend/src/app/models/route_encounter.py @@ -8,13 +8,15 @@ class RouteEncounter(Base): __tablename__ = "route_encounters" __table_args__ = ( 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) route_id: Mapped[int] = mapped_column(ForeignKey("routes.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_rate: 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") pokemon: Mapped["Pokemon"] = relationship(back_populates="route_encounters") + game: Mapped["Game"] = relationship() def __repr__(self) -> str: return f"" diff --git a/backend/src/app/models/version_group.py b/backend/src/app/models/version_group.py new file mode 100644 index 0000000..e0f0ccc --- /dev/null +++ b/backend/src/app/models/version_group.py @@ -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"" diff --git a/backend/src/app/schemas/boss.py b/backend/src/app/schemas/boss.py index 42c3a54..3ad605b 100644 --- a/backend/src/app/schemas/boss.py +++ b/backend/src/app/schemas/boss.py @@ -14,7 +14,7 @@ class BossPokemonResponse(CamelModel): class BossBattleResponse(CamelModel): id: int - game_id: int + version_group_id: int name: str boss_type: str badge_name: str | None diff --git a/backend/src/app/schemas/game.py b/backend/src/app/schemas/game.py index d903549..ab12974 100644 --- a/backend/src/app/schemas/game.py +++ b/backend/src/app/schemas/game.py @@ -4,7 +4,7 @@ from app.schemas.base import CamelModel class RouteResponse(CamelModel): id: int name: str - game_id: int + version_group_id: int order: int parent_route_id: int | None = None pinwheel_zone: int | None = None @@ -20,6 +20,7 @@ class GameResponse(CamelModel): box_art_url: str | None release_year: int | None color: str | None + version_group_id: int | None = None class RouteWithChildrenResponse(RouteResponse): diff --git a/backend/src/app/schemas/pokemon.py b/backend/src/app/schemas/pokemon.py index d67246b..bd33186 100644 --- a/backend/src/app/schemas/pokemon.py +++ b/backend/src/app/schemas/pokemon.py @@ -39,6 +39,7 @@ class RouteEncounterResponse(CamelModel): id: int route_id: int pokemon_id: int + game_id: int encounter_method: str encounter_rate: int min_level: int @@ -70,6 +71,7 @@ class PokemonUpdate(CamelModel): class RouteEncounterCreate(CamelModel): pokemon_id: int + game_id: int encounter_method: str encounter_rate: int min_level: int diff --git a/backend/src/app/seeds/loader.py b/backend/src/app/seeds/loader.py index d44dbfe..de15d9d 100644 --- a/backend/src/app/seeds/loader.py +++ b/backend/src/app/seeds/loader.py @@ -9,27 +9,65 @@ from app.models.game import Game from app.models.pokemon import Pokemon from app.models.route import Route 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]: - """Upsert game records, return {slug: id} mapping.""" - for game in games: - stmt = insert(Game).values( - name=game["name"], - slug=game["slug"], - generation=game["generation"], - region=game["region"], - release_year=game.get("release_year"), - color=game.get("color"), +async def upsert_version_groups( + session: AsyncSession, + vg_data: dict[str, dict], +) -> dict[str, int]: + """Upsert version group records, return {slug: id} mapping.""" + for vg_slug, vg_info in vg_data.items(): + vg_name = " / ".join( + g["name"].replace("Pokemon ", "") + for g in vg_info["games"].values() + ) + stmt = insert(VersionGroup).values( + name=vg_name, + slug=vg_slug, ).on_conflict_do_update( index_elements=["slug"], - set_={ - "name": game["name"], - "generation": game["generation"], - "region": game["region"], - "release_year": game.get("release_year"), - "color": game.get("color"), - }, + 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"], + "generation": game["generation"], + "region": game["region"], + "release_year": game.get("release_year"), + "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) @@ -67,10 +105,10 @@ async def upsert_pokemon(session: AsyncSession, pokemon_list: list[dict]) -> dic async def upsert_routes( session: AsyncSession, - game_id: int, + version_group_id: int, routes: list[dict], ) -> 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, and their children get parent_route_id set accordingly. @@ -79,11 +117,11 @@ async def upsert_routes( for route in routes: stmt = insert(Route).values( name=route["name"], - game_id=game_id, + version_group_id=version_group_id, order=route["order"], parent_route_id=None, # Parent routes have no parent ).on_conflict_do_update( - constraint="uq_routes_game_name", + constraint="uq_routes_version_group_name", set_={"order": route["order"], "parent_route_id": None}, ) await session.execute(stmt) @@ -92,7 +130,7 @@ async def upsert_routes( # Get mapping of parent routes 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} @@ -106,12 +144,12 @@ async def upsert_routes( for child in children: stmt = insert(Route).values( name=child["name"], - game_id=game_id, + version_group_id=version_group_id, order=child["order"], parent_route_id=parent_id, pinwheel_zone=child.get("pinwheel_zone"), ).on_conflict_do_update( - constraint="uq_routes_game_name", + constraint="uq_routes_version_group_name", set_={ "order": child["order"], "parent_route_id": parent_id, @@ -124,7 +162,7 @@ async def upsert_routes( # Return full mapping including children 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} @@ -134,8 +172,9 @@ async def upsert_route_encounters( route_id: int, encounters: list[dict], dex_to_id: dict[int, int], + game_id: 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 for enc in encounters: pokemon_id = dex_to_id.get(enc["pokeapi_id"]) @@ -146,12 +185,13 @@ async def upsert_route_encounters( stmt = insert(RouteEncounter).values( route_id=route_id, pokemon_id=pokemon_id, + game_id=game_id, encounter_method=enc["method"], encounter_rate=enc["encounter_rate"], min_level=enc["min_level"], max_level=enc["max_level"], ).on_conflict_do_update( - constraint="uq_route_pokemon_method", + constraint="uq_route_pokemon_method_game", set_={ "encounter_rate": enc["encounter_rate"], "min_level": enc["min_level"], diff --git a/backend/src/app/seeds/run.py b/backend/src/app/seeds/run.py index 858c94b..9539109 100644 --- a/backend/src/app/seeds/run.py +++ b/backend/src/app/seeds/run.py @@ -11,40 +11,21 @@ from app.models.game import Game from app.models.pokemon import Pokemon from app.models.route import Route from app.models.route_encounter import RouteEncounter +from app.models.version_group import VersionGroup from app.seeds.loader import ( upsert_evolutions, upsert_games, upsert_pokemon, upsert_route_encounters, upsert_routes, + upsert_version_groups, ) DATA_DIR = Path(__file__).parent / "data" - -# 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", -] +VG_JSON = Path(__file__).parent / "version_groups.json" -def load_json(filename: str) -> list[dict]: +def load_json(filename: str): path = DATA_DIR / filename with open(path) as f: return json.load(f) @@ -56,64 +37,99 @@ async def seed(): async with async_session() as session: 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") - 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") - # 2. Upsert Pokemon + # 3. Upsert Pokemon pokemon_data = load_json("pokemon.json") dex_to_id = await upsert_pokemon(session, pokemon_data) 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_encounters = 0 - for game_slug in GAME_FILES: - game_id = slug_to_id.get(game_slug) - if game_id is None: - print(f"Warning: game '{game_slug}' not found, skipping") + 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"{game_slug}.json") + routes_data = load_json(f"{first_game_slug}.json") if not routes_data: - print(f" {game_slug}: no route data, skipping") + print(f" {vg_slug}: empty route data, skipping") continue - route_map = await upsert_routes(session, game_id, routes_data) - total_routes += len(route_map) - for route in routes_data: - route_id = route_map.get(route["name"]) - if route_id is None: - print(f" Warning: route '{route['name']}' not found") + # 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) + if game_id is None: + print(f" Warning: game '{game_slug}' not found, skipping") continue - # Parent routes may have empty encounters - if route["encounters"]: - enc_count = await upsert_route_encounters( - session, route_id, route["encounters"], dex_to_id - ) - total_encounters += enc_count + game_routes_file = DATA_DIR / f"{game_slug}.json" + if not game_routes_file.exists(): + continue - # Handle child routes - for child in route.get("children", []): - child_id = route_map.get(child["name"]) - if child_id is None: - print(f" Warning: child route '{child['name']}' not found") + game_routes_data = load_json(f"{game_slug}.json") + for route in game_routes_data: + route_id = route_map.get(route["name"]) + if route_id is None: + print(f" Warning: route '{route['name']}' not found") continue - enc_count = await upsert_route_encounters( - session, child_id, child["encounters"], dex_to_id - ) - total_encounters += enc_count + # Parent routes may have empty encounters + if route["encounters"]: + enc_count = await upsert_route_encounters( + session, route_id, route["encounters"], + dex_to_id, game_id, + ) + total_encounters += enc_count - print(f" {game_slug}: {len(route_map)} routes") + # Handle child routes + for child in route.get("children", []): + child_id = route_map.get(child["name"]) + if child_id is None: + print(f" Warning: child route '{child['name']}' not found") + continue + + enc_count = await upsert_route_encounters( + session, child_id, child["encounters"], + dex_to_id, game_id, + ) + total_encounters += enc_count + + print(f" {game_slug}: encounters loaded") print(f"\nTotal routes: {total_routes}") print(f"Total encounters: {total_encounters}") - # 4. Upsert evolutions + # 5. Upsert evolutions evolutions_path = DATA_DIR / "evolutions.json" if evolutions_path.exists(): evolutions_data = load_json("evolutions.json") @@ -131,32 +147,33 @@ async def verify(): async with async_session() as session: # Overall counts + vg_count = (await session.execute(select(func.count(VersionGroup.id)))).scalar() games_count = (await session.execute(select(func.count(Game.id)))).scalar() pokemon_count = (await session.execute(select(func.count(Pokemon.id)))).scalar() routes_count = (await session.execute(select(func.count(Route.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"Pokemon: {pokemon_count}") print(f"Routes: {routes_count}") print(f"Route Encounters: {enc_count}") - # Per-game breakdown + # Per-version-group route counts result = await session.execute( - select(Game.name, func.count(Route.id)) - .join(Route, Route.game_id == Game.id) - .group_by(Game.name) - .order_by(Game.name) + select(VersionGroup.slug, func.count(Route.id)) + .join(Route, Route.version_group_id == VersionGroup.id) + .group_by(VersionGroup.slug) + .order_by(VersionGroup.slug) ) - print("\nRoutes per game:") + print("\nRoutes per version group:") for row in result: print(f" {row[0]}: {row[1]}") # Per-game encounter counts result = await session.execute( select(Game.name, func.count(RouteEncounter.id)) - .join(Route, Route.game_id == Game.id) - .join(RouteEncounter, RouteEncounter.route_id == Route.id) + .join(RouteEncounter, RouteEncounter.game_id == Game.id) .group_by(Game.name) .order_by(Game.name) ) diff --git a/frontend/src/api/games.ts b/frontend/src/api/games.ts index b955610..a092816 100644 --- a/frontend/src/api/games.ts +++ b/frontend/src/api/games.ts @@ -19,6 +19,7 @@ export function getGameRoutes(gameId: number): Promise { return api.get(`/games/${gameId}/routes?flat=true`) } -export function getRoutePokemon(routeId: number): Promise { - return api.get(`/routes/${routeId}/pokemon`) +export function getRoutePokemon(routeId: number, gameId?: number): Promise { + const params = gameId != null ? `?game_id=${gameId}` : '' + return api.get(`/routes/${routeId}/pokemon${params}`) } diff --git a/frontend/src/hooks/useGames.ts b/frontend/src/hooks/useGames.ts index ff57818..02187b5 100644 --- a/frontend/src/hooks/useGames.ts +++ b/frontend/src/hooks/useGames.ts @@ -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({ - queryKey: ['routes', routeId, 'pokemon'], - queryFn: () => getRoutePokemon(routeId!), + queryKey: ['routes', routeId, 'pokemon', gameId], + queryFn: () => getRoutePokemon(routeId!, gameId), enabled: routeId !== null, }) } diff --git a/frontend/src/pages/RunEncounters.tsx b/frontend/src/pages/RunEncounters.tsx index f740a24..f94b223 100644 --- a/frontend/src/pages/RunEncounters.tsx +++ b/frontend/src/pages/RunEncounters.tsx @@ -1093,9 +1093,9 @@ export function RunEncounters() { .map((bp) => (
{bp.pokemon.spriteUrl ? ( - {bp.pokemon.name} + {bp.pokemon.name} ) : ( -
+
)} Lvl {bp.level} diff --git a/frontend/src/pages/admin/AdminRouteDetail.tsx b/frontend/src/pages/admin/AdminRouteDetail.tsx index c7d4942..b11d7b2 100644 --- a/frontend/src/pages/admin/AdminRouteDetail.tsx +++ b/frontend/src/pages/admin/AdminRouteDetail.tsx @@ -21,7 +21,7 @@ export function AdminRouteDetail() { const rId = Number(routeId) const { data: game } = useGame(gId) - const { data: encounters = [], isLoading } = useRoutePokemon(rId) + const { data: encounters = [], isLoading } = useRoutePokemon(rId, gId) const addEncounter = useAddRouteEncounter(rId) const updateEncounter = useUpdateRouteEncounter(rId) @@ -114,7 +114,7 @@ export function AdminRouteDetail() { {showCreate && ( - addEncounter.mutate(data as CreateRouteEncounterInput, { + addEncounter.mutate({ ...data, gameId: gId } as CreateRouteEncounterInput, { onSuccess: () => setShowCreate(false), }) } diff --git a/frontend/src/types/admin.ts b/frontend/src/types/admin.ts index 5951111..de269d8 100644 --- a/frontend/src/types/admin.ts +++ b/frontend/src/types/admin.ts @@ -64,6 +64,7 @@ export interface PaginatedPokemon { export interface CreateRouteEncounterInput { pokemonId: number + gameId: number encounterMethod: string encounterRate: number minLevel: number diff --git a/frontend/src/types/game.ts b/frontend/src/types/game.ts index 5834c09..66a290a 100644 --- a/frontend/src/types/game.ts +++ b/frontend/src/types/game.ts @@ -7,12 +7,13 @@ export interface Game { boxArtUrl: string | null releaseYear: number | null color: string | null + versionGroupId: number | null } export interface Route { id: number name: string - gameId: number + versionGroupId: number order: number parentRouteId: number | null pinwheelZone: number | null @@ -36,6 +37,7 @@ export interface RouteEncounter { id: number routeId: number pokemonId: number + gameId: number encounterMethod: string encounterRate: number minLevel: number @@ -140,7 +142,7 @@ export interface BossPokemon { export interface BossBattle { id: number - gameId: number + versionGroupId: number name: string bossType: BossType badgeName: string | null