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

@@ -0,0 +1,35 @@
"""add parent_route_id for route grouping
Revision ID: c3d4e5f6a7b8
Revises: b2c3d4e5f6a7
Create Date: 2026-02-06 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c3d4e5f6a7b8'
down_revision: Union[str, Sequence[str], None] = 'b2c3d4e5f6a7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'routes',
sa.Column(
'parent_route_id',
sa.Integer(),
sa.ForeignKey('routes.id', ondelete='CASCADE'),
nullable=True,
index=True,
),
)
def downgrade() -> None:
op.drop_column('routes', 'parent_route_id')

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 ---

View File

@@ -14,6 +14,9 @@ class Route(Base):
name: Mapped[str] = mapped_column(String(100))
game_id: Mapped[int] = mapped_column(ForeignKey("games.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
)
game: Mapped["Game"] = relationship(back_populates="routes")
route_encounters: Mapped[list["RouteEncounter"]] = relationship(
@@ -21,5 +24,13 @@ class Route(Base):
)
encounters: Mapped[list["Encounter"]] = relationship(back_populates="route")
# Self-referential relationships for route grouping
parent: Mapped["Route | None"] = relationship(
back_populates="children", remote_side=[id]
)
children: Mapped[list["Route"]] = relationship(
back_populates="parent", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<Route(id={self.id}, name='{self.name}')>"

View File

@@ -6,6 +6,7 @@ class RouteResponse(CamelModel):
name: str
game_id: int
order: int
parent_route_id: int | None = None
class GameResponse(CamelModel):
@@ -18,6 +19,10 @@ class GameResponse(CamelModel):
release_year: int | None
class RouteWithChildrenResponse(RouteResponse):
children: list[RouteResponse] = []
class GameDetailResponse(GameResponse):
routes: list[RouteResponse] = []
@@ -46,11 +51,13 @@ class GameUpdate(CamelModel):
class RouteCreate(CamelModel):
name: str
order: int
parent_route_id: int | None = None
class RouteUpdate(CamelModel):
name: str | None = None
order: int | None = None
parent_route_id: int | None = None
class RouteReorderItem(CamelModel):

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -187,7 +187,11 @@ def aggregate_encounters(raw_encounters: list[dict]) -> list[dict]:
def process_version(version_name: str, vg_info: dict) -> list[dict]:
"""Process all locations for a specific game version."""
"""Process all locations for a specific game version.
Creates hierarchical route structure where locations with multiple areas
become parent routes with child routes for each area.
"""
print(f"\n--- Processing {version_name} ---")
region = load_resource("region", vg_info["region_id"])
@@ -230,8 +234,38 @@ def process_version(version_name: str, vg_info: dict) -> list[dict]:
else:
all_encounters.extend(encounters)
# Area-specific encounters become separate routes
if area_specific:
# If we have multiple area-specific encounters, create a parent route
# with child routes for each area (hierarchical grouping)
if area_specific and len(area_specific) > 1:
# Create parent route (no encounters - just a logical grouping)
parent_order = order
order += 1
child_routes = []
for area_suffix, area_encs in area_specific.items():
aggregated = aggregate_encounters(area_encs)
if aggregated:
route_name = f"{display_name} ({area_suffix})"
for enc in aggregated:
all_pokemon_dex.add(enc["national_dex"])
child_routes.append({
"name": route_name,
"order": order,
"encounters": aggregated,
})
order += 1
# Only add parent if we have child routes
if child_routes:
routes.append({
"name": display_name,
"order": parent_order,
"encounters": [], # Parent routes have no encounters
"children": child_routes,
})
elif area_specific:
# Only one area-specific route - don't create parent/child, just use suffix
for area_suffix, area_encs in area_specific.items():
aggregated = aggregate_encounters(area_encs)
if aggregated:
@@ -245,6 +279,7 @@ def process_version(version_name: str, vg_info: dict) -> list[dict]:
})
order += 1
# Non-area-specific encounters (or single area without suffix)
if all_encounters:
aggregated = aggregate_encounters(all_encounters)
if aggregated:
@@ -257,8 +292,13 @@ def process_version(version_name: str, vg_info: dict) -> list[dict]:
})
order += 1
print(f" Routes with encounters: {len(routes)}")
total_enc = sum(len(r["encounters"]) for r in routes)
# Count routes including children
total_routes = sum(1 + len(r.get("children", [])) for r in routes)
print(f" Routes with encounters: {total_routes}")
total_enc = sum(
len(r["encounters"]) + sum(len(c["encounters"]) for c in r.get("children", []))
for r in routes
)
print(f" Total encounter entries: {total_enc}")
return routes

View File

@@ -66,20 +66,54 @@ async def upsert_routes(
game_id: int,
routes: list[dict],
) -> dict[str, int]:
"""Upsert route records for a game, return {name: id} mapping."""
"""Upsert route records for a game, return {name: id} mapping.
Handles hierarchical routes: routes with 'children' are parent routes,
and their children get parent_route_id set accordingly.
"""
# First pass: upsert all parent routes (without parent_route_id)
for route in routes:
stmt = insert(Route).values(
name=route["name"],
game_id=game_id,
order=route["order"],
parent_route_id=None, # Parent routes have no parent
).on_conflict_do_update(
constraint="uq_routes_game_name",
set_={"order": route["order"]},
set_={"order": route["order"], "parent_route_id": None},
)
await session.execute(stmt)
await session.flush()
# Get mapping of parent routes
result = await session.execute(
select(Route.name, Route.id).where(Route.game_id == game_id)
)
name_to_id = {row.name: row.id for row in result}
# Second pass: upsert child routes with parent_route_id
for route in routes:
children = route.get("children", [])
if not children:
continue
parent_id = name_to_id[route["name"]]
for child in children:
stmt = insert(Route).values(
name=child["name"],
game_id=game_id,
order=child["order"],
parent_route_id=parent_id,
).on_conflict_do_update(
constraint="uq_routes_game_name",
set_={"order": child["order"], "parent_route_id": parent_id},
)
await session.execute(stmt)
await session.flush()
# Return full mapping including children
result = await session.execute(
select(Route.name, Route.id).where(Route.game_id == game_id)
)

View File

@@ -66,10 +66,24 @@ async def seed():
print(f" Warning: route '{route['name']}' not found")
continue
enc_count = await upsert_route_encounters(
session, route_id, route["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
)
total_encounters += enc_count
# 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
)
total_encounters += enc_count
print(f" {game_slug}: {len(route_map)} routes")