Files
nuzlocke-tracker/backend/src/app/api/encounters.py
Julian Tabel f0307f0625 Guard genlocke data integrity edge cases
Block deletion of runs linked to a genlocke leg, prevent reactivating
completed/failed genlocke-linked runs, and guard encounter deletion
against genlocke transfer references with clear 400 errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 12:03:58 +01:00

406 lines
15 KiB
Python

import random
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.evolution import Evolution
from app.models.genlocke_transfer import GenlockeTransfer
from app.models.genlocke import GenlockeLeg
from app.models.nuzlocke_run import NuzlockeRun
from app.models.pokemon import Pokemon
from app.models.route import Route
from app.models.route_encounter import RouteEncounter
from app.schemas.encounter import (
BulkRandomizeResponse,
EncounterCreate,
EncounterDetailResponse,
EncounterResponse,
EncounterUpdate,
)
from app.services.families import build_families
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.",
)
# Shiny clause: shiny encounters bypass the route-lock check
shiny_clause_on = run.rules.get("shinyClause", True) if run.rules else True
skip_route_lock = (data.is_shiny and shiny_clause_on) or data.origin in ("shed_evolution", "egg", "transfer")
# If this route has a parent, check if sibling already has an encounter
if route.parent_route_id is not None and not skip_route_lock:
# Get all sibling routes (routes with same parent, including this one)
siblings_result = await session.execute(
select(Route).where(Route.parent_route_id == route.parent_route_id)
)
siblings = siblings_result.scalars().all()
# Determine which siblings to check based on pinwheel clause
pinwheel_on = run.rules.get("pinwheelClause", True) if run.rules else True
any_has_zone = any(s.pinwheel_zone is not None for s in siblings)
if pinwheel_on and any_has_zone:
# Zone-aware: only check siblings in the same zone (null treated as 0)
my_zone = route.pinwheel_zone if route.pinwheel_zone is not None else 0
sibling_ids = [
s.id for s in siblings
if (s.pinwheel_zone if s.pinwheel_zone is not None else 0) == my_zone
]
else:
# No pinwheel clause or no zones defined: all siblings share
sibling_ids = [s.id for s in siblings]
# Check if any relevant sibling already has an encounter in this run
# Exclude transfer-target encounters so they don't block the starter
transfer_target_ids = select(GenlockeTransfer.target_encounter_id)
existing_encounter = await session.execute(
select(Encounter)
.where(
Encounter.run_id == run_id,
Encounter.route_id.in_(sibling_ids),
~Encounter.id.in_(transfer_target_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,
is_shiny=data.is_shiny,
)
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")
# Block deletion if encounter is referenced by a genlocke transfer
transfer_result = await session.execute(
select(GenlockeTransfer.id).where(
(GenlockeTransfer.source_encounter_id == encounter_id)
| (GenlockeTransfer.target_encounter_id == encounter_id)
)
)
if transfer_result.scalar_one_or_none() is not None:
raise HTTPException(
status_code=400,
detail="Cannot delete an encounter that is part of a genlocke transfer.",
)
await session.delete(encounter)
await session.commit()
return Response(status_code=204)
@router.post(
"/runs/{run_id}/encounters/bulk-randomize",
response_model=BulkRandomizeResponse,
status_code=201,
)
async def bulk_randomize_encounters(
run_id: int,
session: AsyncSession = Depends(get_session),
):
# 1. Validate run
run = await session.get(NuzlockeRun, run_id)
if run is None:
raise HTTPException(status_code=404, detail="Run not found")
if run.status != "active":
raise HTTPException(status_code=400, detail="Run is not active")
game_id = run.game_id
# 2. Get version_group_id from game
from app.models.game import Game
game = await session.get(Game, game_id)
if game is None or game.version_group_id is None:
raise HTTPException(status_code=400, detail="Game has no version group")
version_group_id = game.version_group_id
# 3. Load all routes for this version group (with children)
routes_result = await session.execute(
select(Route)
.where(Route.version_group_id == version_group_id)
.options(selectinload(Route.children))
.order_by(Route.order)
)
all_routes = routes_result.scalars().unique().all()
# 4. Load existing encounters for this run
existing_result = await session.execute(
select(Encounter).where(Encounter.run_id == run_id)
)
existing_encounters = existing_result.scalars().all()
encountered_route_ids = {enc.route_id for enc in existing_encounters}
# 5. Load all route_encounters for this game
re_result = await session.execute(
select(RouteEncounter).where(RouteEncounter.game_id == game_id)
)
all_route_encounters = re_result.scalars().all()
# Build route_id → [pokemon_id, ...] mapping
route_pokemon: dict[int, list[int]] = {}
for re in all_route_encounters:
route_pokemon.setdefault(re.route_id, [])
if re.pokemon_id not in route_pokemon[re.route_id]:
route_pokemon[re.route_id].append(re.pokemon_id)
# 6. Load evolution families
dupes_clause_on = run.rules.get("duplicatesClause", True) if run.rules else True
pokemon_to_family: dict[int, list[int]] = {}
if dupes_clause_on:
evo_result = await session.execute(select(Evolution))
evolutions = evo_result.scalars().all()
pokemon_to_family = build_families(evolutions)
# 7. Build initial duped set from existing caught encounters
duped: set[int] = set()
if dupes_clause_on:
for enc in existing_encounters:
if enc.status != "caught":
continue
duped.add(enc.pokemon_id)
family = pokemon_to_family.get(enc.pokemon_id, [])
for member in family:
duped.add(member)
# Seed duped set with retired Pokemon IDs from prior genlocke legs
leg_result = await session.execute(
select(GenlockeLeg).where(GenlockeLeg.run_id == run_id)
)
leg = leg_result.scalar_one_or_none()
if leg:
genlocke_result = await session.execute(
select(GenlockeLeg.retired_pokemon_ids)
.where(
GenlockeLeg.genlocke_id == leg.genlocke_id,
GenlockeLeg.leg_order < leg.leg_order,
GenlockeLeg.retired_pokemon_ids.isnot(None),
)
)
for (retired_ids,) in genlocke_result:
duped.update(retired_ids)
# 8. Organize routes: identify top-level and children
routes_by_id = {r.id: r for r in all_routes}
top_level = [r for r in all_routes if r.parent_route_id is None]
children_by_parent: dict[int, list[Route]] = {}
for r in all_routes:
if r.parent_route_id is not None:
children_by_parent.setdefault(r.parent_route_id, []).append(r)
pinwheel_on = run.rules.get("pinwheelClause", True) if run.rules else True
# 9. Process routes in order, collecting target leaf routes
created_encounters: list[Encounter] = []
skipped = 0
for parent_route in top_level:
children = children_by_parent.get(parent_route.id, [])
if len(children) == 0:
# Standalone leaf route
if parent_route.id in encountered_route_ids:
continue
available = route_pokemon.get(parent_route.id, [])
eligible = [p for p in available if p not in duped] if dupes_clause_on else available
if not eligible:
skipped += 1
continue
picked = random.choice(eligible)
enc = Encounter(
run_id=run_id,
route_id=parent_route.id,
pokemon_id=picked,
status="caught",
)
session.add(enc)
created_encounters.append(enc)
encountered_route_ids.add(parent_route.id)
if dupes_clause_on:
duped.add(picked)
for member in pokemon_to_family.get(picked, []):
duped.add(member)
else:
# Route group — determine zone behavior
any_has_zone = any(c.pinwheel_zone is not None for c in children)
use_pinwheel = pinwheel_on and any_has_zone
if use_pinwheel:
# Zone-aware: one encounter per zone
zones: dict[int, list[Route]] = {}
for c in children:
zone = c.pinwheel_zone if c.pinwheel_zone is not None else 0
zones.setdefault(zone, []).append(c)
for zone_num in sorted(zones.keys()):
zone_children = zones[zone_num]
# Check if any child in this zone already has an encounter
zone_has_encounter = any(
c.id in encountered_route_ids for c in zone_children
)
if zone_has_encounter:
continue
# Collect all pokemon from all children in this zone
zone_pokemon: list[int] = []
for c in zone_children:
for p in route_pokemon.get(c.id, []):
if p not in zone_pokemon:
zone_pokemon.append(p)
eligible = [p for p in zone_pokemon if p not in duped] if dupes_clause_on else zone_pokemon
if not eligible:
skipped += 1
continue
picked = random.choice(eligible)
# Pick a random child route in this zone to place the encounter
target_child = random.choice(zone_children)
enc = Encounter(
run_id=run_id,
route_id=target_child.id,
pokemon_id=picked,
status="caught",
)
session.add(enc)
created_encounters.append(enc)
encountered_route_ids.add(target_child.id)
if dupes_clause_on:
duped.add(picked)
for member in pokemon_to_family.get(picked, []):
duped.add(member)
else:
# Classic: one encounter for the whole group
group_has_encounter = any(
c.id in encountered_route_ids for c in children
)
if group_has_encounter:
continue
# Collect all pokemon from all children
group_pokemon: list[int] = []
for c in children:
for p in route_pokemon.get(c.id, []):
if p not in group_pokemon:
group_pokemon.append(p)
eligible = [p for p in group_pokemon if p not in duped] if dupes_clause_on else group_pokemon
if not eligible:
skipped += 1
continue
picked = random.choice(eligible)
# Pick a random child route to place the encounter
target_child = random.choice(children)
enc = Encounter(
run_id=run_id,
route_id=target_child.id,
pokemon_id=picked,
status="caught",
)
session.add(enc)
created_encounters.append(enc)
encountered_route_ids.add(target_child.id)
if dupes_clause_on:
duped.add(picked)
for member in pokemon_to_family.get(picked, []):
duped.add(member)
await session.commit()
# Refresh all created encounters to get server-generated fields
for enc in created_encounters:
await session.refresh(enc)
return BulkRandomizeResponse(
created=created_encounters,
skipped_routes=skipped,
)