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

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