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 && (