2026-02-07 13:12:56 +01:00
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
2026-02-05 15:09:05 +01:00
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Response
|
2026-02-09 09:47:28 +01:00
|
|
|
from sqlalchemy import func, select
|
2026-02-05 15:09:05 +01:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
from sqlalchemy.orm import joinedload, selectinload
|
|
|
|
|
|
|
|
|
|
from app.core.database import get_session
|
2026-02-09 11:28:22 +01:00
|
|
|
from app.models.boss_result import BossResult
|
2026-02-05 15:09:05 +01:00
|
|
|
from app.models.encounter import Encounter
|
|
|
|
|
from app.models.game import Game
|
2026-02-09 09:47:28 +01:00
|
|
|
from app.models.genlocke import Genlocke, GenlockeLeg
|
2026-02-09 11:28:22 +01:00
|
|
|
from app.models.genlocke_transfer import GenlockeTransfer
|
2026-02-05 15:09:05 +01:00
|
|
|
from app.models.nuzlocke_run import NuzlockeRun
|
2026-02-09 09:47:28 +01:00
|
|
|
from app.schemas.run import RunCreate, RunDetailResponse, RunGenlockeContext, RunResponse, RunUpdate
|
2026-02-05 15:09:05 +01:00
|
|
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("", response_model=RunResponse, status_code=201)
|
|
|
|
|
async def create_run(
|
|
|
|
|
data: RunCreate, session: AsyncSession = Depends(get_session)
|
|
|
|
|
):
|
|
|
|
|
# Validate game exists
|
|
|
|
|
game = await session.get(Game, data.game_id)
|
|
|
|
|
if game is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Game not found")
|
|
|
|
|
|
|
|
|
|
run = NuzlockeRun(
|
|
|
|
|
game_id=data.game_id,
|
|
|
|
|
name=data.name,
|
|
|
|
|
status="active",
|
|
|
|
|
rules=data.rules,
|
|
|
|
|
)
|
|
|
|
|
session.add(run)
|
|
|
|
|
await session.commit()
|
|
|
|
|
await session.refresh(run)
|
|
|
|
|
return run
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("", response_model=list[RunResponse])
|
|
|
|
|
async def list_runs(session: AsyncSession = Depends(get_session)):
|
|
|
|
|
result = await session.execute(
|
|
|
|
|
select(NuzlockeRun).order_by(NuzlockeRun.started_at.desc())
|
|
|
|
|
)
|
|
|
|
|
return result.scalars().all()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{run_id}", response_model=RunDetailResponse)
|
|
|
|
|
async def get_run(run_id: int, session: AsyncSession = Depends(get_session)):
|
|
|
|
|
result = await session.execute(
|
|
|
|
|
select(NuzlockeRun)
|
|
|
|
|
.where(NuzlockeRun.id == run_id)
|
|
|
|
|
.options(
|
|
|
|
|
joinedload(NuzlockeRun.game),
|
|
|
|
|
selectinload(NuzlockeRun.encounters)
|
|
|
|
|
.joinedload(Encounter.pokemon),
|
|
|
|
|
selectinload(NuzlockeRun.encounters)
|
2026-02-05 19:26:49 +01:00
|
|
|
.joinedload(Encounter.current_pokemon),
|
|
|
|
|
selectinload(NuzlockeRun.encounters)
|
2026-02-05 15:09:05 +01:00
|
|
|
.joinedload(Encounter.route),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
run = result.scalar_one_or_none()
|
|
|
|
|
if run is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Run not found")
|
2026-02-09 09:47:28 +01:00
|
|
|
|
|
|
|
|
# Check if this run belongs to a genlocke
|
|
|
|
|
genlocke_context = None
|
|
|
|
|
leg_result = await session.execute(
|
|
|
|
|
select(GenlockeLeg)
|
|
|
|
|
.where(GenlockeLeg.run_id == run_id)
|
|
|
|
|
.options(joinedload(GenlockeLeg.genlocke))
|
|
|
|
|
)
|
|
|
|
|
leg = leg_result.scalar_one_or_none()
|
|
|
|
|
if leg:
|
|
|
|
|
total_legs_result = await session.execute(
|
|
|
|
|
select(func.count())
|
|
|
|
|
.select_from(GenlockeLeg)
|
|
|
|
|
.where(GenlockeLeg.genlocke_id == leg.genlocke_id)
|
|
|
|
|
)
|
|
|
|
|
total_legs = total_legs_result.scalar_one()
|
2026-02-09 10:05:03 +01:00
|
|
|
|
|
|
|
|
# Aggregate retired Pokemon IDs from prior legs (retireHoF rule)
|
|
|
|
|
retired_pokemon_ids: list[int] = []
|
|
|
|
|
if leg.genlocke.genlocke_rules.get("retireHoF", False) and leg.leg_order > 1:
|
|
|
|
|
prior_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),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
cumulative: set[int] = set()
|
|
|
|
|
for (ids,) in prior_result:
|
|
|
|
|
cumulative.update(ids)
|
|
|
|
|
retired_pokemon_ids = sorted(cumulative)
|
|
|
|
|
|
2026-02-09 09:47:28 +01:00
|
|
|
genlocke_context = RunGenlockeContext(
|
|
|
|
|
genlocke_id=leg.genlocke_id,
|
|
|
|
|
genlocke_name=leg.genlocke.name,
|
|
|
|
|
leg_order=leg.leg_order,
|
|
|
|
|
total_legs=total_legs,
|
|
|
|
|
is_final_leg=leg.leg_order == total_legs,
|
2026-02-09 10:05:03 +01:00
|
|
|
retired_pokemon_ids=retired_pokemon_ids,
|
2026-02-09 09:47:28 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
response = RunDetailResponse.model_validate(run)
|
|
|
|
|
response.genlocke = genlocke_context
|
|
|
|
|
return response
|
2026-02-05 15:09:05 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/{run_id}", response_model=RunResponse)
|
|
|
|
|
async def update_run(
|
|
|
|
|
run_id: int,
|
|
|
|
|
data: RunUpdate,
|
|
|
|
|
session: AsyncSession = Depends(get_session),
|
|
|
|
|
):
|
|
|
|
|
run = await session.get(NuzlockeRun, run_id)
|
|
|
|
|
if run is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Run not found")
|
|
|
|
|
|
|
|
|
|
update_data = data.model_dump(exclude_unset=True)
|
2026-02-07 13:12:56 +01:00
|
|
|
|
2026-02-09 10:19:56 +01:00
|
|
|
# Validate hof_encounter_ids if provided
|
|
|
|
|
if "hof_encounter_ids" in update_data and update_data["hof_encounter_ids"] is not None:
|
|
|
|
|
hof_ids = update_data["hof_encounter_ids"]
|
|
|
|
|
if len(hof_ids) > 6:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400, detail="HoF team cannot have more than 6 Pokemon"
|
|
|
|
|
)
|
|
|
|
|
if hof_ids:
|
|
|
|
|
# Validate all encounter IDs belong to this run and are alive
|
|
|
|
|
enc_result = await session.execute(
|
|
|
|
|
select(Encounter).where(
|
|
|
|
|
Encounter.id.in_(hof_ids),
|
|
|
|
|
Encounter.run_id == run_id,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
found = {e.id: e for e in enc_result.scalars().all()}
|
|
|
|
|
missing = [eid for eid in hof_ids if eid not in found]
|
|
|
|
|
if missing:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail=f"Encounters not found in this run: {missing}",
|
|
|
|
|
)
|
|
|
|
|
not_alive = [
|
|
|
|
|
eid for eid, e in found.items()
|
|
|
|
|
if e.status != "caught" or e.faint_level is not None
|
|
|
|
|
]
|
|
|
|
|
if not_alive:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail=f"Encounters are not alive: {not_alive}",
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-07 13:12:56 +01:00
|
|
|
# Auto-set completed_at when ending a run
|
|
|
|
|
if "status" in update_data and update_data["status"] in ("completed", "failed"):
|
|
|
|
|
if run.status != "active":
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400, detail="Only active runs can be ended"
|
|
|
|
|
)
|
|
|
|
|
update_data["completed_at"] = datetime.now(timezone.utc)
|
|
|
|
|
|
2026-02-05 15:09:05 +01:00
|
|
|
for field, value in update_data.items():
|
|
|
|
|
setattr(run, field, value)
|
|
|
|
|
|
2026-02-09 09:47:28 +01:00
|
|
|
# Genlocke side effects when run status changes
|
|
|
|
|
if "status" in update_data and update_data["status"] in ("completed", "failed"):
|
|
|
|
|
leg_result = await session.execute(
|
|
|
|
|
select(GenlockeLeg)
|
|
|
|
|
.where(GenlockeLeg.run_id == run_id)
|
|
|
|
|
.options(joinedload(GenlockeLeg.genlocke))
|
|
|
|
|
)
|
|
|
|
|
leg = leg_result.scalar_one_or_none()
|
|
|
|
|
if leg:
|
|
|
|
|
genlocke = leg.genlocke
|
|
|
|
|
if update_data["status"] == "failed":
|
|
|
|
|
genlocke.status = "failed"
|
|
|
|
|
elif update_data["status"] == "completed":
|
|
|
|
|
total_legs_result = await session.execute(
|
|
|
|
|
select(func.count())
|
|
|
|
|
.select_from(GenlockeLeg)
|
|
|
|
|
.where(GenlockeLeg.genlocke_id == genlocke.id)
|
|
|
|
|
)
|
|
|
|
|
total_legs = total_legs_result.scalar_one()
|
|
|
|
|
if leg.leg_order == total_legs:
|
|
|
|
|
genlocke.status = "completed"
|
|
|
|
|
|
2026-02-05 15:09:05 +01:00
|
|
|
await session.commit()
|
|
|
|
|
await session.refresh(run)
|
|
|
|
|
return run
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/{run_id}", status_code=204)
|
|
|
|
|
async def delete_run(
|
|
|
|
|
run_id: int, session: AsyncSession = Depends(get_session)
|
|
|
|
|
):
|
|
|
|
|
run = await session.get(NuzlockeRun, run_id)
|
|
|
|
|
if run is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Run not found")
|
|
|
|
|
|
2026-02-09 11:28:22 +01:00
|
|
|
# Delete associated boss results first
|
|
|
|
|
boss_results = await session.execute(
|
|
|
|
|
select(BossResult).where(BossResult.run_id == run_id)
|
|
|
|
|
)
|
|
|
|
|
for br in boss_results.scalars():
|
|
|
|
|
await session.delete(br)
|
|
|
|
|
|
|
|
|
|
# Delete genlocke transfers referencing this run's encounters
|
|
|
|
|
encounter_ids_result = await session.execute(
|
|
|
|
|
select(Encounter.id).where(Encounter.run_id == run_id)
|
|
|
|
|
)
|
|
|
|
|
encounter_ids = [row[0] for row in encounter_ids_result]
|
|
|
|
|
if encounter_ids:
|
|
|
|
|
transfers = await session.execute(
|
|
|
|
|
select(GenlockeTransfer).where(
|
|
|
|
|
GenlockeTransfer.source_encounter_id.in_(encounter_ids)
|
|
|
|
|
| GenlockeTransfer.target_encounter_id.in_(encounter_ids)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
for t in transfers.scalars():
|
|
|
|
|
await session.delete(t)
|
|
|
|
|
|
|
|
|
|
# Delete associated encounters
|
2026-02-05 15:09:05 +01:00
|
|
|
encounters = await session.execute(
|
|
|
|
|
select(Encounter).where(Encounter.run_id == run_id)
|
|
|
|
|
)
|
|
|
|
|
for enc in encounters.scalars():
|
|
|
|
|
await session.delete(enc)
|
|
|
|
|
|
2026-02-09 11:28:22 +01:00
|
|
|
# Unlink from any genlocke leg
|
|
|
|
|
leg_result = await session.execute(
|
|
|
|
|
select(GenlockeLeg).where(GenlockeLeg.run_id == run_id)
|
|
|
|
|
)
|
|
|
|
|
for leg in leg_result.scalars():
|
|
|
|
|
leg.run_id = None
|
|
|
|
|
|
2026-02-05 15:09:05 +01:00
|
|
|
await session.delete(run)
|
|
|
|
|
await session.commit()
|
|
|
|
|
return Response(status_code=204)
|