Add user authentication with login/signup/protected routes, boss pokemon detail fields and result team tracking, moves and abilities selector components and API, run ownership and visibility controls, and various UI improvements across encounters, run list, and journal pages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
433 lines
16 KiB
Python
433 lines
16 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.auth import AuthUser, require_auth
|
|
from app.core.database import get_session
|
|
from app.models.encounter import Encounter
|
|
from app.models.evolution import Evolution
|
|
from app.models.genlocke import GenlockeLeg
|
|
from app.models.genlocke_transfer import GenlockeTransfer
|
|
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),
|
|
_user: AuthUser = Depends(require_auth),
|
|
):
|
|
# 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/gift clause: certain encounters bypass the route-lock check
|
|
shiny_clause_on = run.rules.get("shinyClause", True) if run.rules else True
|
|
gift_clause_on = run.rules.get("giftClause", False) if run.rules else False
|
|
skip_route_lock = (
|
|
(data.is_shiny and shiny_clause_on)
|
|
or (data.origin == "gift" and gift_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)
|
|
lock_query = select(Encounter).where(
|
|
Encounter.run_id == run_id,
|
|
Encounter.route_id.in_(sibling_ids),
|
|
~Encounter.id.in_(transfer_target_ids),
|
|
)
|
|
# Gift-origin encounters don't count toward route lock
|
|
if gift_clause_on:
|
|
lock_query = lock_query.where(
|
|
Encounter.origin.is_(None) | (Encounter.origin != "gift")
|
|
)
|
|
existing_encounter = await session.execute(lock_query)
|
|
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,
|
|
origin=data.origin,
|
|
)
|
|
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),
|
|
_user: AuthUser = Depends(require_auth),
|
|
):
|
|
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),
|
|
_user: AuthUser = Depends(require_auth),
|
|
):
|
|
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),
|
|
_user: AuthUser = Depends(require_auth),
|
|
):
|
|
# 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
|
|
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,
|
|
)
|