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

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

View File

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

View File

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