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:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user