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>
406 lines
15 KiB
Python
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,
|
|
)
|