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