Add hierarchical route grouping for multi-area locations

Locations like Mt. Moon (with 1F, B1F, B2F floors) are now grouped so
only one encounter can be logged per location group, enforcing Nuzlocke
first-encounter rules correctly.

- Add parent_route_id column with self-referential FK to routes table
- Add parent/children relationships on Route model
- Update games API to return hierarchical route structure
- Add validation in encounters API to prevent parent route encounters
  and duplicate encounters within sibling routes (409 conflict)
- Update frontend with collapsible RouteGroup component
- Auto-derive route groups from PokeAPI location/location-area structure
- Regenerate seed data with 70 parent routes and 315 child routes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Julian Tabel
2026-02-06 11:07:45 +01:00
parent b434ab52ae
commit 2aa60f0ace
17 changed files with 24876 additions and 23896 deletions

View File

@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from sqlalchemy.orm import joinedload, selectinload
from app.core.database import get_session
from app.models.encounter import Encounter
@@ -33,11 +33,45 @@ async def create_encounter(
if run is None:
raise HTTPException(status_code=404, detail="Run not found")
# Validate route exists
route = await session.get(Route, data.route_id)
# Validate route exists and load its children
result = await session.execute(
select(Route)
.where(Route.id == data.route_id)
.options(selectinload(Route.children))
)
route = result.scalar_one_or_none()
if route is None:
raise HTTPException(status_code=404, detail="Route not found")
# Cannot create encounter on a parent route (routes with children)
if route.children:
raise HTTPException(
status_code=400,
detail="Cannot create encounter on a parent route. Use a child route instead.",
)
# If this route has a parent, check if any sibling already has an encounter
if route.parent_route_id is not None:
# Get all sibling route IDs (routes with same parent, including this one)
siblings_result = await session.execute(
select(Route.id).where(Route.parent_route_id == route.parent_route_id)
)
sibling_ids = [r for r in siblings_result.scalars().all()]
# Check if any sibling already has an encounter in this run
existing_encounter = await session.execute(
select(Encounter)
.where(
Encounter.run_id == run_id,
Encounter.route_id.in_(sibling_ids),
)
)
if existing_encounter.scalar_one_or_none() is not None:
raise HTTPException(
status_code=409,
detail="This location group already has an encounter. Only one encounter per location group is allowed.",
)
# Validate pokemon exists
pokemon = await session.get(Pokemon, data.pokemon_id)
if pokemon is None:

View File

@@ -15,6 +15,7 @@ from app.schemas.game import (
RouteReorderRequest,
RouteResponse,
RouteUpdate,
RouteWithChildrenResponse,
)
router = APIRouter()
@@ -42,10 +43,21 @@ async def get_game(game_id: int, session: AsyncSession = Depends(get_session)):
return game
@router.get("/{game_id}/routes", response_model=list[RouteResponse])
@router.get(
"/{game_id}/routes",
response_model=list[RouteWithChildrenResponse] | list[RouteResponse],
)
async def list_game_routes(
game_id: int, session: AsyncSession = Depends(get_session)
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:
@@ -56,7 +68,36 @@ async def list_game_routes(
.where(Route.game_id == game_id)
.order_by(Route.order)
)
return result.scalars().all()
all_routes = result.scalars().all()
if flat:
return all_routes
# Build hierarchical structure
# Group children by parent_route_id
children_by_parent: dict[int, list[Route]] = {}
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)
# Build response with nested children
response = []
for route in top_level_routes:
route_dict = {
"id": route.id,
"name": route.name,
"game_id": route.game_id,
"order": route.order,
"parent_route_id": route.parent_route_id,
"children": children_by_parent.get(route.id, []),
}
response.append(route_dict)
return response
# --- Admin endpoints ---