Add genlocke transfer UI with transfer selection modal and backend support

When advancing to the next genlocke leg, users can now select surviving
Pokemon to transfer. Transferred Pokemon are bred down to their base
evolutionary form and appear as level-1 egg encounters in the next leg.
A GenlockeTransfer record links source and target encounters for lineage tracking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Julian Tabel
2026-02-09 11:20:49 +01:00
parent 3bd4250305
commit c5910ec75c
15 changed files with 470 additions and 29 deletions

View File

@@ -11,8 +11,11 @@ from app.models.game import Game
from app.models.genlocke import Genlocke, GenlockeLeg
from app.models.nuzlocke_run import NuzlockeRun
from app.models.pokemon import Pokemon
from app.models.genlocke_transfer import GenlockeTransfer
from app.models.route import Route
from app.schemas.genlocke import (
AddLegRequest,
AdvanceLegRequest,
GenlockeCreate,
GenlockeDetailResponse,
GenlockeGraveyardResponse,
@@ -24,9 +27,10 @@ from app.schemas.genlocke import (
GraveyardEntryResponse,
GraveyardLegSummary,
RetiredPokemonResponse,
SurvivorResponse,
)
from app.schemas.pokemon import PokemonResponse
from app.services.families import build_families
from app.services.families import build_families, resolve_base_form
router = APIRouter()
@@ -326,6 +330,63 @@ async def create_genlocke(
return result.scalar_one()
@router.get(
"/{genlocke_id}/legs/{leg_order}/survivors",
response_model=list[SurvivorResponse],
)
async def get_leg_survivors(
genlocke_id: int,
leg_order: int,
session: AsyncSession = Depends(get_session),
):
# Find the leg
result = await session.execute(
select(GenlockeLeg).where(
GenlockeLeg.genlocke_id == genlocke_id,
GenlockeLeg.leg_order == leg_order,
)
)
leg = result.scalar_one_or_none()
if leg is None:
raise HTTPException(status_code=404, detail="Leg not found")
if leg.run_id is None:
raise HTTPException(status_code=400, detail="Leg has no run")
# Query surviving encounters: caught and alive (no faint_level)
enc_result = await session.execute(
select(Encounter)
.where(
Encounter.run_id == leg.run_id,
Encounter.status == "caught",
Encounter.faint_level.is_(None),
)
.options(
selectinload(Encounter.pokemon),
selectinload(Encounter.current_pokemon),
selectinload(Encounter.route),
)
)
encounters = enc_result.scalars().all()
return [
SurvivorResponse(
id=enc.id,
pokemon=PokemonResponse.model_validate(enc.pokemon),
current_pokemon=(
PokemonResponse.model_validate(enc.current_pokemon)
if enc.current_pokemon
else None
),
nickname=enc.nickname,
catch_level=enc.catch_level,
is_shiny=enc.is_shiny,
route_name=enc.route.name,
)
for enc in encounters
]
@router.post(
"/{genlocke_id}/legs/{leg_order}/advance",
response_model=GenlockeResponse,
@@ -333,6 +394,7 @@ async def create_genlocke(
async def advance_leg(
genlocke_id: int,
leg_order: int,
data: AdvanceLegRequest | None = None,
session: AsyncSession = Depends(get_session),
):
# Load genlocke with legs
@@ -434,6 +496,94 @@ async def advance_leg(
await session.flush()
next_leg.run_id = new_run.id
# Handle transfers if requested
transfer_ids = data.transfer_encounter_ids if data else []
if transfer_ids:
# Validate all encounter IDs belong to the current leg's run, are caught, and alive
enc_result = await session.execute(
select(Encounter).where(
Encounter.id.in_(transfer_ids),
Encounter.run_id == current_leg.run_id,
Encounter.status == "caught",
Encounter.faint_level.is_(None),
)
)
source_encounters = enc_result.scalars().all()
if len(source_encounters) != len(transfer_ids):
found_ids = {e.id for e in source_encounters}
missing = [eid for eid in transfer_ids if eid not in found_ids]
raise HTTPException(
status_code=400,
detail=f"Invalid transfer encounter IDs: {missing}. Must be alive, caught encounters from the current leg.",
)
# Load evolutions once for base form resolution
evo_result = await session.execute(select(Evolution))
evolutions = evo_result.scalars().all()
# Find the first leaf route in the next leg's game for hatch location
next_game = await session.get(Game, next_leg.game_id)
if next_game is None or next_game.version_group_id is None:
raise HTTPException(
status_code=400,
detail="Next leg's game has no version group configured",
)
route_result = await session.execute(
select(Route)
.where(
Route.version_group_id == next_game.version_group_id,
Route.parent_route_id.is_(None),
)
.options(selectinload(Route.children))
.order_by(Route.order)
)
routes = route_result.scalars().all()
hatch_route = None
for r in routes:
if r.children:
# Pick the first child as the leaf
hatch_route = min(r.children, key=lambda c: c.order)
break
else:
hatch_route = r
break
if hatch_route is None:
raise HTTPException(
status_code=400,
detail="No routes found for the next leg's game. Cannot place transferred Pokemon.",
)
# Create egg encounters and transfer records
for source_enc in source_encounters:
# Resolve base form (breed down)
pokemon_id = source_enc.current_pokemon_id or source_enc.pokemon_id
base_form_id = resolve_base_form(pokemon_id, evolutions)
egg_encounter = Encounter(
run_id=new_run.id,
route_id=hatch_route.id,
pokemon_id=base_form_id,
nickname=source_enc.nickname,
status="caught",
catch_level=1,
is_shiny=source_enc.is_shiny,
)
session.add(egg_encounter)
await session.flush()
transfer = GenlockeTransfer(
genlocke_id=genlocke_id,
source_encounter_id=source_enc.id,
target_encounter_id=egg_encounter.id,
source_leg_order=leg_order,
target_leg_order=next_leg.leg_order,
)
session.add(transfer)
await session.commit()
# Reload with relationships