Files
nuzlocke-tracker/backend/src/app/api/encounters.py
Julian Tabel 2aa60f0ace 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>
2026-02-06 11:07:45 +01:00

134 lines
4.3 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload, selectinload
from app.core.database import get_session
from app.models.encounter import Encounter
from app.models.nuzlocke_run import NuzlockeRun
from app.models.pokemon import Pokemon
from app.models.route import Route
from app.schemas.encounter import (
EncounterCreate,
EncounterDetailResponse,
EncounterResponse,
EncounterUpdate,
)
router = APIRouter()
@router.post(
"/runs/{run_id}/encounters",
response_model=EncounterResponse,
status_code=201,
)
async def create_encounter(
run_id: int,
data: EncounterCreate,
session: AsyncSession = Depends(get_session),
):
# Validate run exists
run = await session.get(NuzlockeRun, run_id)
if run is None:
raise HTTPException(status_code=404, detail="Run not found")
# 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:
raise HTTPException(status_code=404, detail="Pokemon not found")
encounter = Encounter(
run_id=run_id,
route_id=data.route_id,
pokemon_id=data.pokemon_id,
nickname=data.nickname,
status=data.status,
catch_level=data.catch_level,
)
session.add(encounter)
await session.commit()
await session.refresh(encounter)
return encounter
@router.patch("/encounters/{encounter_id}", response_model=EncounterDetailResponse)
async def update_encounter(
encounter_id: int,
data: EncounterUpdate,
session: AsyncSession = Depends(get_session),
):
encounter = await session.get(Encounter, encounter_id)
if encounter is None:
raise HTTPException(status_code=404, detail="Encounter not found")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(encounter, field, value)
await session.commit()
# Reload with relationships for detail response
result = await session.execute(
select(Encounter)
.where(Encounter.id == encounter_id)
.options(
joinedload(Encounter.pokemon),
joinedload(Encounter.current_pokemon),
joinedload(Encounter.route),
)
)
return result.scalar_one()
@router.delete("/encounters/{encounter_id}", status_code=204)
async def delete_encounter(
encounter_id: int, session: AsyncSession = Depends(get_session)
):
encounter = await session.get(Encounter, encounter_id)
if encounter is None:
raise HTTPException(status_code=404, detail="Encounter not found")
await session.delete(encounter)
await session.commit()
return Response(status_code=204)