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:
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-lsdy
|
# nuzlocke-tracker-lsdy
|
||||||
title: Genlocke cumulative graveyard
|
title: Genlocke cumulative graveyard
|
||||||
status: in-progress
|
status: completed
|
||||||
type: feature
|
type: feature
|
||||||
priority: normal
|
priority: normal
|
||||||
created_at: 2026-02-09T07:42:46Z
|
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
|
parent: nuzlocke-tracker-25mh
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-p74f
|
# nuzlocke-tracker-p74f
|
||||||
title: Genlocke transfer UI
|
title: Genlocke transfer UI
|
||||||
status: todo
|
status: in-progress
|
||||||
type: feature
|
type: feature
|
||||||
priority: normal
|
priority: normal
|
||||||
created_at: 2026-02-09T07:42:33Z
|
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
|
parent: nuzlocke-tracker-25mh
|
||||||
blocking:
|
blocking:
|
||||||
- nuzlocke-tracker-lsc2
|
- nuzlocke-tracker-lsc2
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -59,7 +59,7 @@ async def create_encounter(
|
|||||||
|
|
||||||
# Shiny clause: shiny encounters bypass the route-lock check
|
# Shiny clause: shiny encounters bypass the route-lock check
|
||||||
shiny_clause_on = run.rules.get("shinyClause", True) if run.rules else True
|
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 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:
|
if route.parent_route_id is not None and not skip_route_lock:
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ from app.models.game import Game
|
|||||||
from app.models.genlocke import Genlocke, GenlockeLeg
|
from app.models.genlocke import Genlocke, GenlockeLeg
|
||||||
from app.models.nuzlocke_run import NuzlockeRun
|
from app.models.nuzlocke_run import NuzlockeRun
|
||||||
from app.models.pokemon import Pokemon
|
from app.models.pokemon import Pokemon
|
||||||
|
from app.models.genlocke_transfer import GenlockeTransfer
|
||||||
|
from app.models.route import Route
|
||||||
from app.schemas.genlocke import (
|
from app.schemas.genlocke import (
|
||||||
AddLegRequest,
|
AddLegRequest,
|
||||||
|
AdvanceLegRequest,
|
||||||
GenlockeCreate,
|
GenlockeCreate,
|
||||||
GenlockeDetailResponse,
|
GenlockeDetailResponse,
|
||||||
GenlockeGraveyardResponse,
|
GenlockeGraveyardResponse,
|
||||||
@@ -24,9 +27,10 @@ from app.schemas.genlocke import (
|
|||||||
GraveyardEntryResponse,
|
GraveyardEntryResponse,
|
||||||
GraveyardLegSummary,
|
GraveyardLegSummary,
|
||||||
RetiredPokemonResponse,
|
RetiredPokemonResponse,
|
||||||
|
SurvivorResponse,
|
||||||
)
|
)
|
||||||
from app.schemas.pokemon import PokemonResponse
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -326,6 +330,63 @@ async def create_genlocke(
|
|||||||
return result.scalar_one()
|
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(
|
@router.post(
|
||||||
"/{genlocke_id}/legs/{leg_order}/advance",
|
"/{genlocke_id}/legs/{leg_order}/advance",
|
||||||
response_model=GenlockeResponse,
|
response_model=GenlockeResponse,
|
||||||
@@ -333,6 +394,7 @@ async def create_genlocke(
|
|||||||
async def advance_leg(
|
async def advance_leg(
|
||||||
genlocke_id: int,
|
genlocke_id: int,
|
||||||
leg_order: int,
|
leg_order: int,
|
||||||
|
data: AdvanceLegRequest | None = None,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
# Load genlocke with legs
|
# Load genlocke with legs
|
||||||
@@ -434,6 +496,94 @@ async def advance_leg(
|
|||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
next_leg.run_id = new_run.id
|
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()
|
await session.commit()
|
||||||
|
|
||||||
# Reload with relationships
|
# Reload with relationships
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from app.models.encounter import Encounter
|
|||||||
from app.models.evolution import Evolution
|
from app.models.evolution import Evolution
|
||||||
from app.models.game import Game
|
from app.models.game import Game
|
||||||
from app.models.genlocke import Genlocke, GenlockeLeg
|
from app.models.genlocke import Genlocke, GenlockeLeg
|
||||||
|
from app.models.genlocke_transfer import GenlockeTransfer
|
||||||
from app.models.nuzlocke_run import NuzlockeRun
|
from app.models.nuzlocke_run import NuzlockeRun
|
||||||
from app.models.pokemon import Pokemon
|
from app.models.pokemon import Pokemon
|
||||||
from app.models.route import Route
|
from app.models.route import Route
|
||||||
@@ -20,6 +21,7 @@ __all__ = [
|
|||||||
"Game",
|
"Game",
|
||||||
"Genlocke",
|
"Genlocke",
|
||||||
"GenlockeLeg",
|
"GenlockeLeg",
|
||||||
|
"GenlockeTransfer",
|
||||||
"NuzlockeRun",
|
"NuzlockeRun",
|
||||||
"Pokemon",
|
"Pokemon",
|
||||||
"Route",
|
"Route",
|
||||||
|
|||||||
32
backend/src/app/models/genlocke_transfer.py
Normal file
32
backend/src/app/models/genlocke_transfer.py
Normal 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})>"
|
||||||
@@ -31,6 +31,20 @@ class GenlockeLegResponse(CamelModel):
|
|||||||
game: GameResponse
|
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):
|
class GenlockeResponse(CamelModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
|
|||||||
@@ -3,6 +3,26 @@ from collections import deque
|
|||||||
from app.models.evolution import Evolution
|
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]]:
|
def build_families(evolutions: list[Evolution]) -> dict[int, list[int]]:
|
||||||
"""Build pokemon_id -> family members mapping using BFS on evolution graph."""
|
"""Build pokemon_id -> family members mapping using BFS on evolution graph."""
|
||||||
adj: dict[int, set[int]] = {}
|
adj: dict[int, set[int]] = {}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api } from './client'
|
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[]> {
|
export function getGenlockes(): Promise<GenlockeListItem[]> {
|
||||||
return api.get('/genlockes')
|
return api.get('/genlockes')
|
||||||
@@ -21,6 +21,10 @@ export function getGenlockeGraveyard(id: number): Promise<GenlockeGraveyard> {
|
|||||||
return api.get(`/genlockes/${id}/graveyard`)
|
return api.get(`/genlockes/${id}/graveyard`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function advanceLeg(genlockeId: number, legOrder: number): Promise<Genlocke> {
|
export function getLegSurvivors(genlockeId: number, legOrder: number): Promise<SurvivorEncounter[]> {
|
||||||
return api.post(`/genlockes/${genlockeId}/legs/${legOrder}/advance`, {})
|
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 ?? {})
|
||||||
}
|
}
|
||||||
|
|||||||
118
frontend/src/components/TransferModal.tsx
Normal file
118
frontend/src/components/TransferModal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ export { RuleBadges } from './RuleBadges'
|
|||||||
export { ShinyBox } from './ShinyBox'
|
export { ShinyBox } from './ShinyBox'
|
||||||
export { ShinyEncounterModal } from './ShinyEncounterModal'
|
export { ShinyEncounterModal } from './ShinyEncounterModal'
|
||||||
export { StatusChangeModal } from './StatusChangeModal'
|
export { StatusChangeModal } from './StatusChangeModal'
|
||||||
|
export { TransferModal } from './TransferModal'
|
||||||
export { RuleToggle } from './RuleToggle'
|
export { RuleToggle } from './RuleToggle'
|
||||||
export { RulesConfiguration } from './RulesConfiguration'
|
export { RulesConfiguration } from './RulesConfiguration'
|
||||||
export { StatCard } from './StatCard'
|
export { StatCard } from './StatCard'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { advanceLeg, createGenlocke, getGamesByRegion, getGenlockes, getGenlocke, getGenlockeGraveyard } from '../api/genlockes'
|
import { advanceLeg, createGenlocke, getGamesByRegion, getGenlockes, getGenlocke, getGenlockeGraveyard, getLegSurvivors } from '../api/genlockes'
|
||||||
import type { CreateGenlockeInput } from '../types/game'
|
import type { AdvanceLegInput, CreateGenlockeInput } from '../types/game'
|
||||||
|
|
||||||
export function useGenlockes() {
|
export function useGenlockes() {
|
||||||
return useQuery({
|
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() {
|
export function useAdvanceLeg() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ genlockeId, legOrder }: { genlockeId: number; legOrder: number }) =>
|
mutationFn: ({ genlockeId, legOrder, transferEncounterIds }: { genlockeId: number; legOrder: number; transferEncounterIds?: number[] }) =>
|
||||||
advanceLeg(genlockeId, legOrder),
|
advanceLeg(genlockeId, legOrder, transferEncounterIds ? { transferEncounterIds } : undefined),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['runs'] })
|
queryClient.invalidateQueries({ queryKey: ['runs'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['genlockes'] })
|
queryClient.invalidateQueries({ queryKey: ['genlockes'] })
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useMemo, useEffect, useCallback } from 'react'
|
import { useState, useMemo, useEffect, useCallback } from 'react'
|
||||||
import { useParams, Link, useNavigate } from 'react-router-dom'
|
import { useParams, Link, useNavigate } from 'react-router-dom'
|
||||||
import { useRun, useUpdateRun } from '../hooks/useRuns'
|
import { useRun, useUpdateRun } from '../hooks/useRuns'
|
||||||
import { useAdvanceLeg } from '../hooks/useGenlockes'
|
import { useAdvanceLeg, useLegSurvivors } from '../hooks/useGenlockes'
|
||||||
import { useGameRoutes } from '../hooks/useGames'
|
import { useGameRoutes } from '../hooks/useGames'
|
||||||
import { useCreateEncounter, useUpdateEncounter, useBulkRandomize } from '../hooks/useEncounters'
|
import { useCreateEncounter, useUpdateEncounter, useBulkRandomize } from '../hooks/useEncounters'
|
||||||
import { usePokemonFamilies } from '../hooks/usePokemon'
|
import { usePokemonFamilies } from '../hooks/usePokemon'
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
EncounterModal,
|
EncounterModal,
|
||||||
EncounterMethodBadge,
|
EncounterMethodBadge,
|
||||||
HofTeamModal,
|
HofTeamModal,
|
||||||
|
TransferModal,
|
||||||
StatCard,
|
StatCard,
|
||||||
PokemonCard,
|
PokemonCard,
|
||||||
StatusChangeModal,
|
StatusChangeModal,
|
||||||
@@ -395,6 +396,11 @@ export function RunEncounters() {
|
|||||||
const runIdNum = Number(runId)
|
const runIdNum = Number(runId)
|
||||||
const { data: run, isLoading, error } = useRun(runIdNum)
|
const { data: run, isLoading, error } = useRun(runIdNum)
|
||||||
const advanceLeg = useAdvanceLeg()
|
const advanceLeg = useAdvanceLeg()
|
||||||
|
const { data: survivors } = useLegSurvivors(
|
||||||
|
run?.genlocke?.genlockeId ?? 0,
|
||||||
|
run?.genlocke?.legOrder ?? 0,
|
||||||
|
showTransferModal && !!run?.genlocke,
|
||||||
|
)
|
||||||
const { data: routes, isLoading: loadingRoutes } = useGameRoutes(
|
const { data: routes, isLoading: loadingRoutes } = useGameRoutes(
|
||||||
run?.gameId ?? null,
|
run?.gameId ?? null,
|
||||||
)
|
)
|
||||||
@@ -417,6 +423,7 @@ export function RunEncounters() {
|
|||||||
const [showHofModal, setShowHofModal] = useState(false)
|
const [showHofModal, setShowHofModal] = useState(false)
|
||||||
const [showShinyModal, setShowShinyModal] = useState(false)
|
const [showShinyModal, setShowShinyModal] = useState(false)
|
||||||
const [showEggModal, setShowEggModal] = useState(false)
|
const [showEggModal, setShowEggModal] = useState(false)
|
||||||
|
const [showTransferModal, setShowTransferModal] = useState(false)
|
||||||
const [expandedBosses, setExpandedBosses] = useState<Set<number>>(new Set())
|
const [expandedBosses, setExpandedBosses] = useState<Set<number>>(new Set())
|
||||||
const [showTeam, setShowTeam] = useState(true)
|
const [showTeam, setShowTeam] = useState(true)
|
||||||
const [filter, setFilter] = useState<'all' | RouteStatus>('all')
|
const [filter, setFilter] = useState<'all' | RouteStatus>('all')
|
||||||
@@ -864,21 +871,7 @@ export function RunEncounters() {
|
|||||||
</div>
|
</div>
|
||||||
{run.status === 'completed' && run.genlocke && !run.genlocke.isFinalLeg && (
|
{run.status === 'completed' && run.genlocke && !run.genlocke.isFinalLeg && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => setShowTransferModal(true)}
|
||||||
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}`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
disabled={advanceLeg.isPending}
|
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"
|
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}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -285,6 +285,22 @@ export interface GenlockeDetail {
|
|||||||
retiredPokemon: Record<number, RetiredPokemon>
|
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
|
// Graveyard types
|
||||||
|
|
||||||
export interface GraveyardEntry {
|
export interface GraveyardEntry {
|
||||||
|
|||||||
Reference in New Issue
Block a user