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

@@ -1,11 +1,11 @@
---
# nuzlocke-tracker-lsdy
title: Genlocke cumulative graveyard
status: in-progress
status: completed
type: feature
priority: normal
created_at: 2026-02-09T07:42:46Z
updated_at: 2026-02-09T09:58:56Z
updated_at: 2026-02-09T10:00:43Z
parent: nuzlocke-tracker-25mh
---

View File

@@ -1,11 +1,11 @@
---
# nuzlocke-tracker-p74f
title: Genlocke transfer UI
status: todo
status: in-progress
type: feature
priority: normal
created_at: 2026-02-09T07:42:33Z
updated_at: 2026-02-09T07:46:06Z
updated_at: 2026-02-09T10:14:34Z
parent: nuzlocke-tracker-25mh
blocking:
- nuzlocke-tracker-lsc2

View File

@@ -0,0 +1,36 @@
"""add genlocke_transfers table
Revision ID: e5f6a7b9c0d1
Revises: d4e5f6a7b9c0
Create Date: 2026-02-09 22:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e5f6a7b9c0d1'
down_revision: Union[str, Sequence[str], None] = 'd4e5f6a7b9c0'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'genlocke_transfers',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('genlocke_id', sa.Integer(), sa.ForeignKey('genlockes.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('source_encounter_id', sa.Integer(), sa.ForeignKey('encounters.id'), nullable=False, index=True),
sa.Column('target_encounter_id', sa.Integer(), sa.ForeignKey('encounters.id'), nullable=False, unique=True),
sa.Column('source_leg_order', sa.SmallInteger(), nullable=False),
sa.Column('target_leg_order', sa.SmallInteger(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint('target_encounter_id', name='uq_genlocke_transfers_target'),
)
def downgrade() -> None:
op.drop_table('genlocke_transfers')

View File

@@ -59,7 +59,7 @@ async def create_encounter(
# 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")
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:

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

View File

@@ -5,6 +5,7 @@ from app.models.encounter import Encounter
from app.models.evolution import Evolution
from app.models.game import Game
from app.models.genlocke import Genlocke, 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
@@ -20,6 +21,7 @@ __all__ = [
"Game",
"Genlocke",
"GenlockeLeg",
"GenlockeTransfer",
"NuzlockeRun",
"Pokemon",
"Route",

View File

@@ -0,0 +1,32 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, SmallInteger, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class GenlockeTransfer(Base):
__tablename__ = "genlocke_transfers"
__table_args__ = (
UniqueConstraint("target_encounter_id", name="uq_genlocke_transfers_target"),
)
id: Mapped[int] = mapped_column(primary_key=True)
genlocke_id: Mapped[int] = mapped_column(
ForeignKey("genlockes.id", ondelete="CASCADE"), index=True
)
source_encounter_id: Mapped[int] = mapped_column(
ForeignKey("encounters.id"), index=True
)
target_encounter_id: Mapped[int] = mapped_column(
ForeignKey("encounters.id"), unique=True
)
source_leg_order: Mapped[int] = mapped_column(SmallInteger)
target_leg_order: Mapped[int] = mapped_column(SmallInteger)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
def __repr__(self) -> str:
return f"<GenlockeTransfer(id={self.id}, source={self.source_encounter_id}, target={self.target_encounter_id})>"

View File

@@ -31,6 +31,20 @@ class GenlockeLegResponse(CamelModel):
game: GameResponse
class SurvivorResponse(CamelModel):
id: int
pokemon: PokemonResponse
current_pokemon: PokemonResponse | None = None
nickname: str | None = None
catch_level: int | None = None
is_shiny: bool = False
route_name: str
class AdvanceLegRequest(CamelModel):
transfer_encounter_ids: list[int] = []
class GenlockeResponse(CamelModel):
id: int
name: str

View File

@@ -3,6 +3,26 @@ from collections import deque
from app.models.evolution import Evolution
def resolve_base_form(pokemon_id: int, evolutions: list[Evolution]) -> int:
"""Walk backward through evolution edges to find the base form of a Pokemon."""
# Build reverse map: to_pokemon_id -> from_pokemon_id
reverse: dict[int, list[int]] = {}
for evo in evolutions:
reverse.setdefault(evo.to_pokemon_id, []).append(evo.from_pokemon_id)
visited: set[int] = set()
current = pokemon_id
while current not in visited:
visited.add(current)
predecessors = reverse.get(current)
if not predecessors:
break
# Follow the first predecessor (handles Shedinja -> Nincada correctly
# since Nincada evolves *to* both Ninjask and Shedinja)
current = predecessors[0]
return current
def build_families(evolutions: list[Evolution]) -> dict[int, list[int]]:
"""Build pokemon_id -> family members mapping using BFS on evolution graph."""
adj: dict[int, set[int]] = {}

View File

@@ -1,5 +1,5 @@
import { api } from './client'
import type { Genlocke, GenlockeListItem, GenlockeDetail, GenlockeGraveyard, CreateGenlockeInput, Region } from '../types/game'
import type { Genlocke, GenlockeListItem, GenlockeDetail, GenlockeGraveyard, CreateGenlockeInput, Region, SurvivorEncounter, AdvanceLegInput } from '../types/game'
export function getGenlockes(): Promise<GenlockeListItem[]> {
return api.get('/genlockes')
@@ -21,6 +21,10 @@ export function getGenlockeGraveyard(id: number): Promise<GenlockeGraveyard> {
return api.get(`/genlockes/${id}/graveyard`)
}
export function advanceLeg(genlockeId: number, legOrder: number): Promise<Genlocke> {
return api.post(`/genlockes/${genlockeId}/legs/${legOrder}/advance`, {})
export function getLegSurvivors(genlockeId: number, legOrder: number): Promise<SurvivorEncounter[]> {
return api.get(`/genlockes/${genlockeId}/legs/${legOrder}/survivors`)
}
export function advanceLeg(genlockeId: number, legOrder: number, data?: AdvanceLegInput): Promise<Genlocke> {
return api.post(`/genlockes/${genlockeId}/legs/${legOrder}/advance`, data ?? {})
}

View File

@@ -0,0 +1,118 @@
import { useState } from 'react'
import type { SurvivorEncounter } from '../types'
interface TransferModalProps {
survivors: SurvivorEncounter[]
onSubmit: (encounterIds: number[]) => void
onSkip: () => void
isPending: boolean
}
export function TransferModal({ survivors, onSubmit, onSkip, isPending }: TransferModalProps) {
const [selected, setSelected] = useState<Set<number>>(
() => new Set(survivors.map((s) => s.id)),
)
const toggle = (id: number) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/50" />
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4 rounded-t-xl">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Transfer Pokemon to Next Leg
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Selected Pokemon will be bred down to their base form and appear as level 1 encounters in the next leg.
</p>
</div>
<div className="px-6 py-4">
{survivors.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-8">
No surviving Pokemon to transfer.
</p>
) : (
<div className="grid grid-cols-3 gap-2">
{survivors.map((survivor) => {
const displayPokemon = survivor.currentPokemon ?? survivor.pokemon
const isSelected = selected.has(survivor.id)
return (
<button
key={survivor.id}
type="button"
onClick={() => toggle(survivor.id)}
className={`flex flex-col items-center p-3 rounded-lg border-2 text-center transition-colors ${
isSelected
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
{displayPokemon.spriteUrl ? (
<img
src={displayPokemon.spriteUrl}
alt={displayPokemon.name}
className="w-14 h-14"
/>
) : (
<div className="w-14 h-14 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-lg font-bold">
{displayPokemon.name[0].toUpperCase()}
</div>
)}
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 mt-1 capitalize">
{survivor.nickname || displayPokemon.name}
</span>
{survivor.nickname && (
<span className="text-[10px] text-gray-400">
{displayPokemon.name}
</span>
)}
<span className="text-[10px] text-gray-400 mt-0.5">
{survivor.routeName}
</span>
</button>
)
})}
</div>
)}
</div>
<div className="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-6 py-4 rounded-b-xl flex items-center justify-between">
<button
type="button"
onClick={onSkip}
disabled={isPending}
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 disabled:opacity-50"
>
Skip (No Transfers)
</button>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-400 dark:text-gray-500">
{selected.size}/{survivors.length} selected
</span>
<button
type="button"
disabled={selected.size === 0 || isPending}
onClick={() => onSubmit([...selected])}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isPending ? 'Transferring...' : 'Transfer & Advance'}
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -12,6 +12,7 @@ export { RuleBadges } from './RuleBadges'
export { ShinyBox } from './ShinyBox'
export { ShinyEncounterModal } from './ShinyEncounterModal'
export { StatusChangeModal } from './StatusChangeModal'
export { TransferModal } from './TransferModal'
export { RuleToggle } from './RuleToggle'
export { RulesConfiguration } from './RulesConfiguration'
export { StatCard } from './StatCard'

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { advanceLeg, createGenlocke, getGamesByRegion, getGenlockes, getGenlocke, getGenlockeGraveyard } from '../api/genlockes'
import type { CreateGenlockeInput } from '../types/game'
import { advanceLeg, createGenlocke, getGamesByRegion, getGenlockes, getGenlocke, getGenlockeGraveyard, getLegSurvivors } from '../api/genlockes'
import type { AdvanceLegInput, CreateGenlockeInput } from '../types/game'
export function useGenlockes() {
return useQuery({
@@ -41,11 +41,19 @@ export function useCreateGenlocke() {
})
}
export function useLegSurvivors(genlockeId: number, legOrder: number, enabled: boolean) {
return useQuery({
queryKey: ['genlockes', genlockeId, 'legs', legOrder, 'survivors'],
queryFn: () => getLegSurvivors(genlockeId, legOrder),
enabled,
})
}
export function useAdvanceLeg() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ genlockeId, legOrder }: { genlockeId: number; legOrder: number }) =>
advanceLeg(genlockeId, legOrder),
mutationFn: ({ genlockeId, legOrder, transferEncounterIds }: { genlockeId: number; legOrder: number; transferEncounterIds?: number[] }) =>
advanceLeg(genlockeId, legOrder, transferEncounterIds ? { transferEncounterIds } : undefined),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['runs'] })
queryClient.invalidateQueries({ queryKey: ['genlockes'] })

View File

@@ -1,7 +1,7 @@
import { useState, useMemo, useEffect, useCallback } from 'react'
import { useParams, Link, useNavigate } from 'react-router-dom'
import { useRun, useUpdateRun } from '../hooks/useRuns'
import { useAdvanceLeg } from '../hooks/useGenlockes'
import { useAdvanceLeg, useLegSurvivors } from '../hooks/useGenlockes'
import { useGameRoutes } from '../hooks/useGames'
import { useCreateEncounter, useUpdateEncounter, useBulkRandomize } from '../hooks/useEncounters'
import { usePokemonFamilies } from '../hooks/usePokemon'
@@ -11,6 +11,7 @@ import {
EncounterModal,
EncounterMethodBadge,
HofTeamModal,
TransferModal,
StatCard,
PokemonCard,
StatusChangeModal,
@@ -395,6 +396,11 @@ export function RunEncounters() {
const runIdNum = Number(runId)
const { data: run, isLoading, error } = useRun(runIdNum)
const advanceLeg = useAdvanceLeg()
const { data: survivors } = useLegSurvivors(
run?.genlocke?.genlockeId ?? 0,
run?.genlocke?.legOrder ?? 0,
showTransferModal && !!run?.genlocke,
)
const { data: routes, isLoading: loadingRoutes } = useGameRoutes(
run?.gameId ?? null,
)
@@ -417,6 +423,7 @@ export function RunEncounters() {
const [showHofModal, setShowHofModal] = useState(false)
const [showShinyModal, setShowShinyModal] = useState(false)
const [showEggModal, setShowEggModal] = useState(false)
const [showTransferModal, setShowTransferModal] = useState(false)
const [expandedBosses, setExpandedBosses] = useState<Set<number>>(new Set())
const [showTeam, setShowTeam] = useState(true)
const [filter, setFilter] = useState<'all' | RouteStatus>('all')
@@ -864,21 +871,7 @@ export function RunEncounters() {
</div>
{run.status === 'completed' && run.genlocke && !run.genlocke.isFinalLeg && (
<button
onClick={() => {
advanceLeg.mutate(
{ genlockeId: run.genlocke!.genlockeId, legOrder: run.genlocke!.legOrder },
{
onSuccess: (genlocke) => {
const nextLeg = genlocke.legs.find(
(l) => l.legOrder === run.genlocke!.legOrder + 1,
)
if (nextLeg?.runId) {
navigate(`/runs/${nextLeg.runId}`)
}
},
},
)
}}
onClick={() => setShowTransferModal(true)}
disabled={advanceLeg.isPending}
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
@@ -1454,6 +1447,53 @@ export function RunEncounters() {
isPending={updateRun.isPending}
/>
)}
{/* Transfer Modal */}
{showTransferModal && survivors && (
<TransferModal
survivors={survivors}
onSubmit={(encounterIds) => {
advanceLeg.mutate(
{
genlockeId: run!.genlocke!.genlockeId,
legOrder: run!.genlocke!.legOrder,
transferEncounterIds: encounterIds,
},
{
onSuccess: (genlocke) => {
setShowTransferModal(false)
const nextLeg = genlocke.legs.find(
(l) => l.legOrder === run!.genlocke!.legOrder + 1,
)
if (nextLeg?.runId) {
navigate(`/runs/${nextLeg.runId}`)
}
},
},
)
}}
onSkip={() => {
advanceLeg.mutate(
{
genlockeId: run!.genlocke!.genlockeId,
legOrder: run!.genlocke!.legOrder,
},
{
onSuccess: (genlocke) => {
setShowTransferModal(false)
const nextLeg = genlocke.legs.find(
(l) => l.legOrder === run!.genlocke!.legOrder + 1,
)
if (nextLeg?.runId) {
navigate(`/runs/${nextLeg.runId}`)
}
},
},
)
}}
isPending={advanceLeg.isPending}
/>
)}
</div>
)
}

View File

@@ -285,6 +285,22 @@ export interface GenlockeDetail {
retiredPokemon: Record<number, RetiredPokemon>
}
// Transfer types
export interface SurvivorEncounter {
id: number
pokemon: Pokemon
currentPokemon: Pokemon | null
nickname: string | null
catchLevel: number | null
isShiny: boolean
routeName: string
}
export interface AdvanceLegInput {
transferEncounterIds: number[]
}
// Graveyard types
export interface GraveyardEntry {