From c5910ec75c7ffb2c4e211d5bcc881cad9f992c44 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Mon, 9 Feb 2026 11:20:49 +0100 Subject: [PATCH] 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 --- ...ker-lsdy--genlocke-cumulative-graveyard.md | 4 +- ...ocke-tracker-p74f--genlocke-transfer-ui.md | 4 +- ...f6a7b9c0d1_add_genlocke_transfers_table.py | 36 +++++ backend/src/app/api/encounters.py | 2 +- backend/src/app/api/genlockes.py | 152 +++++++++++++++++- backend/src/app/models/__init__.py | 2 + backend/src/app/models/genlocke_transfer.py | 32 ++++ backend/src/app/schemas/genlocke.py | 14 ++ backend/src/app/services/families.py | 20 +++ frontend/src/api/genlockes.ts | 10 +- frontend/src/components/TransferModal.tsx | 118 ++++++++++++++ frontend/src/components/index.ts | 1 + frontend/src/hooks/useGenlockes.ts | 16 +- frontend/src/pages/RunEncounters.tsx | 72 +++++++-- frontend/src/types/game.ts | 16 ++ 15 files changed, 470 insertions(+), 29 deletions(-) create mode 100644 backend/src/app/alembic/versions/e5f6a7b9c0d1_add_genlocke_transfers_table.py create mode 100644 backend/src/app/models/genlocke_transfer.py create mode 100644 frontend/src/components/TransferModal.tsx diff --git a/.beans/nuzlocke-tracker-lsdy--genlocke-cumulative-graveyard.md b/.beans/nuzlocke-tracker-lsdy--genlocke-cumulative-graveyard.md index 527e174..adfd541 100644 --- a/.beans/nuzlocke-tracker-lsdy--genlocke-cumulative-graveyard.md +++ b/.beans/nuzlocke-tracker-lsdy--genlocke-cumulative-graveyard.md @@ -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 --- diff --git a/.beans/nuzlocke-tracker-p74f--genlocke-transfer-ui.md b/.beans/nuzlocke-tracker-p74f--genlocke-transfer-ui.md index 53e5e53..b79a2ab 100644 --- a/.beans/nuzlocke-tracker-p74f--genlocke-transfer-ui.md +++ b/.beans/nuzlocke-tracker-p74f--genlocke-transfer-ui.md @@ -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 diff --git a/backend/src/app/alembic/versions/e5f6a7b9c0d1_add_genlocke_transfers_table.py b/backend/src/app/alembic/versions/e5f6a7b9c0d1_add_genlocke_transfers_table.py new file mode 100644 index 0000000..b53b01e --- /dev/null +++ b/backend/src/app/alembic/versions/e5f6a7b9c0d1_add_genlocke_transfers_table.py @@ -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') diff --git a/backend/src/app/api/encounters.py b/backend/src/app/api/encounters.py index 765fe26..4107734 100644 --- a/backend/src/app/api/encounters.py +++ b/backend/src/app/api/encounters.py @@ -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: diff --git a/backend/src/app/api/genlockes.py b/backend/src/app/api/genlockes.py index 72ed25b..1793d35 100644 --- a/backend/src/app/api/genlockes.py +++ b/backend/src/app/api/genlockes.py @@ -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 diff --git a/backend/src/app/models/__init__.py b/backend/src/app/models/__init__.py index eb3cdfc..16568f9 100644 --- a/backend/src/app/models/__init__.py +++ b/backend/src/app/models/__init__.py @@ -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", diff --git a/backend/src/app/models/genlocke_transfer.py b/backend/src/app/models/genlocke_transfer.py new file mode 100644 index 0000000..e0703b8 --- /dev/null +++ b/backend/src/app/models/genlocke_transfer.py @@ -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"" diff --git a/backend/src/app/schemas/genlocke.py b/backend/src/app/schemas/genlocke.py index 305e46c..8e0fc68 100644 --- a/backend/src/app/schemas/genlocke.py +++ b/backend/src/app/schemas/genlocke.py @@ -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 diff --git a/backend/src/app/services/families.py b/backend/src/app/services/families.py index 510f485..21ae16c 100644 --- a/backend/src/app/services/families.py +++ b/backend/src/app/services/families.py @@ -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]] = {} diff --git a/frontend/src/api/genlockes.ts b/frontend/src/api/genlockes.ts index e1d97e1..bf57457 100644 --- a/frontend/src/api/genlockes.ts +++ b/frontend/src/api/genlockes.ts @@ -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 { return api.get('/genlockes') @@ -21,6 +21,10 @@ export function getGenlockeGraveyard(id: number): Promise { return api.get(`/genlockes/${id}/graveyard`) } -export function advanceLeg(genlockeId: number, legOrder: number): Promise { - return api.post(`/genlockes/${genlockeId}/legs/${legOrder}/advance`, {}) +export function getLegSurvivors(genlockeId: number, legOrder: number): Promise { + return api.get(`/genlockes/${genlockeId}/legs/${legOrder}/survivors`) +} + +export function advanceLeg(genlockeId: number, legOrder: number, data?: AdvanceLegInput): Promise { + return api.post(`/genlockes/${genlockeId}/legs/${legOrder}/advance`, data ?? {}) } diff --git a/frontend/src/components/TransferModal.tsx b/frontend/src/components/TransferModal.tsx new file mode 100644 index 0000000..e0ea836 --- /dev/null +++ b/frontend/src/components/TransferModal.tsx @@ -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>( + () => 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 ( +
+
+
+
+

+ Transfer Pokemon to Next Leg +

+

+ Selected Pokemon will be bred down to their base form and appear as level 1 encounters in the next leg. +

+
+ +
+ {survivors.length === 0 ? ( +

+ No surviving Pokemon to transfer. +

+ ) : ( +
+ {survivors.map((survivor) => { + const displayPokemon = survivor.currentPokemon ?? survivor.pokemon + const isSelected = selected.has(survivor.id) + + return ( + + ) + })} +
+ )} +
+ +
+ +
+ + {selected.size}/{survivors.length} selected + + +
+
+
+
+ ) +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 72c65f2..671e91e 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -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' diff --git a/frontend/src/hooks/useGenlockes.ts b/frontend/src/hooks/useGenlockes.ts index d44578b..3452f2a 100644 --- a/frontend/src/hooks/useGenlockes.ts +++ b/frontend/src/hooks/useGenlockes.ts @@ -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'] }) diff --git a/frontend/src/pages/RunEncounters.tsx b/frontend/src/pages/RunEncounters.tsx index 4640cfd..c1a59aa 100644 --- a/frontend/src/pages/RunEncounters.tsx +++ b/frontend/src/pages/RunEncounters.tsx @@ -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>(new Set()) const [showTeam, setShowTeam] = useState(true) const [filter, setFilter] = useState<'all' | RouteStatus>('all') @@ -864,21 +871,7 @@ export function RunEncounters() {
{run.status === 'completed' && run.genlocke && !run.genlocke.isFinalLeg && (