Allows each sub-zone within a route group to have its own independent encounter when the Pinwheel Clause rule is enabled (default on), instead of the entire group sharing a single encounter. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
269 lines
8.0 KiB
Python
269 lines
8.0 KiB
Python
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.core.database import get_session
|
|
from app.models.game import Game
|
|
from app.models.route import Route
|
|
from app.models.route_encounter import RouteEncounter
|
|
from app.schemas.game import (
|
|
GameCreate,
|
|
GameDetailResponse,
|
|
GameResponse,
|
|
GameUpdate,
|
|
RouteCreate,
|
|
RouteReorderRequest,
|
|
RouteResponse,
|
|
RouteUpdate,
|
|
RouteWithChildrenResponse,
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@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))
|
|
return result.scalars().all()
|
|
|
|
|
|
@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")
|
|
|
|
# Sort routes by order for the response
|
|
game.routes.sort(key=lambda r: r.order)
|
|
return game
|
|
|
|
|
|
@router.get(
|
|
"/{game_id}/routes",
|
|
response_model=list[RouteWithChildrenResponse] | list[RouteResponse],
|
|
)
|
|
async def list_game_routes(
|
|
game_id: int,
|
|
flat: bool = False,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""
|
|
List routes for a game.
|
|
|
|
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")
|
|
|
|
result = await session.execute(
|
|
select(Route)
|
|
.where(Route.game_id == game_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})
|
|
return {
|
|
"id": route.id,
|
|
"name": route.name,
|
|
"game_id": route.game_id,
|
|
"order": route.order,
|
|
"parent_route_id": route.parent_route_id,
|
|
"pinwheel_zone": route.pinwheel_zone,
|
|
"encounter_methods": methods,
|
|
}
|
|
|
|
if flat:
|
|
return [route_to_dict(r) for r in all_routes]
|
|
|
|
# Build hierarchical structure
|
|
# Group children by parent_route_id
|
|
children_by_parent: dict[int, list[dict]] = {}
|
|
top_level_routes: list[Route] = []
|
|
|
|
for route in all_routes:
|
|
if route.parent_route_id is None:
|
|
top_level_routes.append(route)
|
|
else:
|
|
children_by_parent.setdefault(route.parent_route_id, []).append(
|
|
route_to_dict(route)
|
|
)
|
|
|
|
# Build response with nested children
|
|
response = []
|
|
for route in top_level_routes:
|
|
route_dict = route_to_dict(route)
|
|
route_dict["children"] = children_by_parent.get(route.id, [])
|
|
response.append(route_dict)
|
|
|
|
return response
|
|
|
|
|
|
# --- Admin endpoints ---
|
|
|
|
|
|
@router.post("", response_model=GameResponse, status_code=201)
|
|
async def create_game(
|
|
data: GameCreate, session: AsyncSession = Depends(get_session)
|
|
):
|
|
existing = await session.execute(
|
|
select(Game).where(Game.slug == data.slug)
|
|
)
|
|
if existing.scalar_one_or_none() is not None:
|
|
raise HTTPException(status_code=409, detail="Game with this slug already exists")
|
|
|
|
game = Game(**data.model_dump())
|
|
session.add(game)
|
|
await session.commit()
|
|
await session.refresh(game)
|
|
return game
|
|
|
|
|
|
@router.put("/{game_id}", response_model=GameResponse)
|
|
async def update_game(
|
|
game_id: int, data: GameUpdate, 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")
|
|
|
|
update_data = data.model_dump(exclude_unset=True)
|
|
if "slug" in update_data:
|
|
existing = await session.execute(
|
|
select(Game).where(Game.slug == update_data["slug"], Game.id != game_id)
|
|
)
|
|
if existing.scalar_one_or_none() is not None:
|
|
raise HTTPException(status_code=409, detail="Game with this slug already exists")
|
|
|
|
for field, value in update_data.items():
|
|
setattr(game, field, value)
|
|
|
|
await session.commit()
|
|
await session.refresh(game)
|
|
return game
|
|
|
|
|
|
@router.delete("/{game_id}", status_code=204)
|
|
async def delete_game(
|
|
game_id: int, session: AsyncSession = Depends(get_session)
|
|
):
|
|
result = await session.execute(
|
|
select(Game).where(Game.id == game_id).options(selectinload(Game.runs))
|
|
)
|
|
game = result.scalar_one_or_none()
|
|
if game is None:
|
|
raise HTTPException(status_code=404, detail="Game not found")
|
|
|
|
if game.runs:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
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)
|
|
)
|
|
for route in routes.scalars().all():
|
|
await session.delete(route)
|
|
|
|
await session.delete(game)
|
|
await session.commit()
|
|
|
|
|
|
@router.post("/{game_id}/routes", response_model=RouteResponse, status_code=201)
|
|
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")
|
|
|
|
route = Route(game_id=game_id, **data.model_dump())
|
|
session.add(route)
|
|
await session.commit()
|
|
await session.refresh(route)
|
|
return route
|
|
|
|
|
|
@router.put("/{game_id}/routes/reorder", response_model=list[RouteResponse])
|
|
async def reorder_routes(
|
|
game_id: int,
|
|
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")
|
|
|
|
for item in data.routes:
|
|
route = await session.get(Route, item.id)
|
|
if route is None or route.game_id != game_id:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Route {item.id} not found in this game",
|
|
)
|
|
route.order = item.order
|
|
|
|
await session.commit()
|
|
|
|
result = await session.execute(
|
|
select(Route).where(Route.game_id == game_id).order_by(Route.order)
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
@router.put("/{game_id}/routes/{route_id}", response_model=RouteResponse)
|
|
async def update_route(
|
|
game_id: int,
|
|
route_id: int,
|
|
data: RouteUpdate,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
route = await session.get(Route, route_id)
|
|
if route is None or route.game_id != game_id:
|
|
raise HTTPException(status_code=404, detail="Route not found in this game")
|
|
|
|
for field, value in data.model_dump(exclude_unset=True).items():
|
|
setattr(route, field, value)
|
|
|
|
await session.commit()
|
|
await session.refresh(route)
|
|
return route
|
|
|
|
|
|
@router.delete("/{game_id}/routes/{route_id}", status_code=204)
|
|
async def delete_route(
|
|
game_id: int,
|
|
route_id: int,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
result = await session.execute(
|
|
select(Route)
|
|
.where(Route.id == route_id, Route.game_id == game_id)
|
|
.options(selectinload(Route.encounters))
|
|
)
|
|
route = result.scalar_one_or_none()
|
|
if route is None:
|
|
raise HTTPException(status_code=404, detail="Route not found in this game")
|
|
|
|
if route.encounters:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Cannot delete route with existing encounters. Delete the encounters first.",
|
|
)
|
|
|
|
await session.delete(route)
|
|
await session.commit()
|