diff --git a/.beans/nuzlocke-tracker-lsc2--genlocke-lineage-tracking.md b/.beans/nuzlocke-tracker-lsc2--genlocke-lineage-tracking.md index 6cfbd32..882c49b 100644 --- a/.beans/nuzlocke-tracker-lsc2--genlocke-lineage-tracking.md +++ b/.beans/nuzlocke-tracker-lsc2--genlocke-lineage-tracking.md @@ -1,11 +1,11 @@ --- # nuzlocke-tracker-lsc2 title: Genlocke lineage tracking -status: todo +status: in-progress type: feature priority: normal created_at: 2026-02-09T07:42:41Z -updated_at: 2026-02-09T07:46:15Z +updated_at: 2026-02-09T10:51:22Z parent: nuzlocke-tracker-25mh --- diff --git a/backend/src/app/api/genlockes.py b/backend/src/app/api/genlockes.py index 1793d35..f8bb8ed 100644 --- a/backend/src/app/api/genlockes.py +++ b/backend/src/app/api/genlockes.py @@ -20,12 +20,15 @@ from app.schemas.genlocke import ( GenlockeDetailResponse, GenlockeGraveyardResponse, GenlockeLegDetailResponse, + GenlockeLineageResponse, GenlockeListItem, GenlockeResponse, GenlockeStatsResponse, GenlockeUpdate, GraveyardEntryResponse, GraveyardLegSummary, + LineageEntry, + LineageLegEntry, RetiredPokemonResponse, SurvivorResponse, ) @@ -261,6 +264,171 @@ async def get_genlocke_graveyard( ) +@router.get( + "/{genlocke_id}/lineages", + response_model=GenlockeLineageResponse, +) +async def get_genlocke_lineages( + genlocke_id: int, session: AsyncSession = Depends(get_session) +): + # Load genlocke with legs + game + result = await session.execute( + select(Genlocke) + .where(Genlocke.id == genlocke_id) + .options( + selectinload(Genlocke.legs).selectinload(GenlockeLeg.game), + ) + ) + genlocke = result.scalar_one_or_none() + if genlocke is None: + raise HTTPException(status_code=404, detail="Genlocke not found") + + # Query all transfers for this genlocke + transfer_result = await session.execute( + select(GenlockeTransfer).where( + GenlockeTransfer.genlocke_id == genlocke_id + ) + ) + transfers = transfer_result.scalars().all() + + if not transfers: + return GenlockeLineageResponse(lineages=[], total_lineages=0) + + # Build forward/backward maps + forward: dict[int, GenlockeTransfer] = {} # source_encounter_id -> transfer + backward: set[int] = set() # target_encounter_ids + for t in transfers: + forward[t.source_encounter_id] = t + backward.add(t.target_encounter_id) + + # Find roots: sources that are NOT targets + roots = [t.source_encounter_id for t in transfers if t.source_encounter_id not in backward] + # Deduplicate while preserving order + seen_roots: set[int] = set() + unique_roots: list[int] = [] + for r in roots: + if r not in seen_roots: + seen_roots.add(r) + unique_roots.append(r) + + # Walk forward from each root to build chains + chains: list[list[int]] = [] + for root in unique_roots: + chain = [root] + current = root + while current in forward: + target = forward[current].target_encounter_id + chain.append(target) + current = target + chains.append(chain) + + # Batch-load all encounters in the chains + all_encounter_ids: set[int] = set() + for chain in chains: + all_encounter_ids.update(chain) + + enc_result = await session.execute( + select(Encounter) + .where(Encounter.id.in_(all_encounter_ids)) + .options( + selectinload(Encounter.pokemon), + selectinload(Encounter.current_pokemon), + selectinload(Encounter.route), + ) + ) + encounter_map: dict[int, Encounter] = { + enc.id: enc for enc in enc_result.scalars().all() + } + + # Build run_id -> (leg_order, game_name) lookup + run_lookup: dict[int, tuple[int, str]] = {} + for leg in genlocke.legs: + if leg.run_id is not None: + run_lookup[leg.run_id] = (leg.leg_order, leg.game.name) + + # Load HoF encounter IDs for all runs + run_ids = [leg.run_id for leg in genlocke.legs if leg.run_id is not None] + hof_encounter_ids: set[int] = set() + if run_ids: + run_result = await session.execute( + select(NuzlockeRun.id, NuzlockeRun.hof_encounter_ids).where( + NuzlockeRun.id.in_(run_ids) + ) + ) + for row in run_result: + if row.hof_encounter_ids: + hof_encounter_ids.update(row.hof_encounter_ids) + + # Build lineage entries + lineages: list[LineageEntry] = [] + for chain in chains: + legs: list[LineageLegEntry] = [] + first_pokemon = None + + for enc_id in chain: + enc = encounter_map.get(enc_id) + if enc is None: + continue + + leg_order, game_name = run_lookup.get(enc.run_id, (0, "Unknown")) + is_alive = enc.faint_level is None and enc.status == "caught" + entered_hof = enc.id in hof_encounter_ids + was_transferred = enc.id in forward + + pokemon_resp = PokemonResponse.model_validate(enc.pokemon) + if first_pokemon is None: + first_pokemon = pokemon_resp + + current_pokemon_resp = ( + PokemonResponse.model_validate(enc.current_pokemon) + if enc.current_pokemon + else None + ) + + legs.append( + LineageLegEntry( + leg_order=leg_order, + game_name=game_name, + encounter_id=enc.id, + pokemon=pokemon_resp, + current_pokemon=current_pokemon_resp, + nickname=enc.nickname, + catch_level=enc.catch_level, + faint_level=enc.faint_level, + death_cause=enc.death_cause, + is_shiny=enc.is_shiny, + route_name=enc.route.name if enc.route else "Unknown", + is_alive=is_alive, + entered_hof=entered_hof, + was_transferred=was_transferred, + ) + ) + + if not legs or first_pokemon is None: + continue + + # Status based on last encounter in the chain + last_leg = legs[-1] + status = "alive" if last_leg.is_alive else "dead" + + lineages.append( + LineageEntry( + nickname=legs[0].nickname, + pokemon=first_pokemon, + legs=legs, + status=status, + ) + ) + + # Sort by first leg order, then by encounter ID + lineages.sort(key=lambda l: (l.legs[0].leg_order, l.legs[0].encounter_id)) + + return GenlockeLineageResponse( + lineages=lineages, + total_lineages=len(lineages), + ) + + @router.post("", response_model=GenlockeResponse, status_code=201) async def create_genlocke( data: GenlockeCreate, session: AsyncSession = Depends(get_session) diff --git a/backend/src/app/schemas/genlocke.py b/backend/src/app/schemas/genlocke.py index 8e0fc68..8b52f26 100644 --- a/backend/src/app/schemas/genlocke.py +++ b/backend/src/app/schemas/genlocke.py @@ -132,3 +132,35 @@ class GenlockeGraveyardResponse(CamelModel): total_deaths: int deaths_per_leg: list[GraveyardLegSummary] deadliest_leg: GraveyardLegSummary | None = None + + +# --- Lineage schemas --- + + +class LineageLegEntry(CamelModel): + leg_order: int + game_name: str + encounter_id: int + pokemon: PokemonResponse + current_pokemon: PokemonResponse | None = None + nickname: str | None = None + catch_level: int | None = None + faint_level: int | None = None + death_cause: str | None = None + is_shiny: bool = False + route_name: str + is_alive: bool + entered_hof: bool + was_transferred: bool + + +class LineageEntry(CamelModel): + nickname: str | None + pokemon: PokemonResponse # base form from first leg + legs: list[LineageLegEntry] + status: str # "alive" | "dead" + + +class GenlockeLineageResponse(CamelModel): + lineages: list[LineageEntry] + total_lineages: int diff --git a/frontend/src/api/genlockes.ts b/frontend/src/api/genlockes.ts index bf57457..618cc3f 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, SurvivorEncounter, AdvanceLegInput } from '../types/game' +import type { Genlocke, GenlockeListItem, GenlockeDetail, GenlockeGraveyard, GenlockeLineage, 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 getGenlockeLineages(id: number): Promise { + return api.get(`/genlockes/${id}/lineages`) +} + export function getLegSurvivors(genlockeId: number, legOrder: number): Promise { return api.get(`/genlockes/${genlockeId}/legs/${legOrder}/survivors`) } diff --git a/frontend/src/components/GenlockeLineage.tsx b/frontend/src/components/GenlockeLineage.tsx new file mode 100644 index 0000000..f2b6368 --- /dev/null +++ b/frontend/src/components/GenlockeLineage.tsx @@ -0,0 +1,276 @@ +import { useMemo } from 'react' +import { useGenlockeLineages } from '../hooks/useGenlockes' +import type { LineageEntry, LineageLegEntry } from '../types' + +interface GenlockeLineageProps { + genlockeId: number +} + +function LegDot({ leg }: { leg: LineageLegEntry }) { + let color: string + let label: string + + if (leg.faintLevel !== null) { + color = 'bg-red-500' + label = 'Dead' + } else if (leg.wasTransferred) { + color = 'bg-blue-500' + label = 'Transferred' + } else if (leg.enteredHof) { + color = 'bg-yellow-500' + label = 'Hall of Fame' + } else { + color = 'bg-green-500' + label = 'Alive' + } + + const displayPokemon = leg.currentPokemon ?? leg.pokemon + + return ( +
+
+ + {/* Tooltip */} +
+
+
{leg.gameName}
+
+ {displayPokemon.spriteUrl && ( + {displayPokemon.name} + )} + {displayPokemon.name} +
+ {leg.catchLevel !== null && ( +
Caught Lv. {leg.catchLevel}
+ )} + {leg.faintLevel !== null && ( +
Died Lv. {leg.faintLevel}
+ )} + {leg.deathCause && ( +
{leg.deathCause}
+ )} +
+ {label} +
+ {leg.enteredHof && leg.faintLevel === null && ( +
Hall of Fame
+ )} +
+
+
+
+ ) +} + +function TimelineGrid({ + lineage, + allLegOrders, +}: { + lineage: LineageEntry + allLegOrders: number[] +}) { + const legMap = new Map(lineage.legs.map((l) => [l.legOrder, l])) + const minLeg = lineage.legs[0].legOrder + const maxLeg = lineage.legs[lineage.legs.length - 1].legOrder + + return ( +
+ {allLegOrders.map((legOrder, i) => { + const leg = legMap.get(legOrder) + const inRange = legOrder >= minLeg && legOrder <= maxLeg + const showLeftLine = inRange && i > 0 && allLegOrders[i - 1] >= minLeg + const showRightLine = + inRange && + i < allLegOrders.length - 1 && + allLegOrders[i + 1] <= maxLeg + + return ( +
+ {/* Left half connector */} + {showLeftLine && ( +
+ )} + {/* Right half connector */} + {showRightLine && ( +
+ )} + {/* Dot or empty */} + {leg ? ( +
+ +
+ ) : ( +
+ )} +
+ ) + })} +
+ ) +} + +function LineageCard({ + lineage, + allLegOrders, +}: { + lineage: LineageEntry + allLegOrders: number[] +}) { + const firstLeg = lineage.legs[0] + const displayPokemon = firstLeg.currentPokemon ?? firstLeg.pokemon + + return ( +
+ {/* Left: Pokemon sprite + nickname */} +
+ {displayPokemon.spriteUrl ? ( + {displayPokemon.name} + ) : ( +
+ {displayPokemon.name[0].toUpperCase()} +
+ )} + + {lineage.nickname || lineage.pokemon.name} + + {lineage.nickname && ( + + {lineage.pokemon.name} + + )} +
+ + {/* Center: Timeline */} +
+ +
+ + {/* Right: Status badge */} +
+ + {lineage.status === 'alive' ? 'Alive' : 'Dead'} + +
+
+ ) +} + +export function GenlockeLineage({ genlockeId }: GenlockeLineageProps) { + const { data, isLoading, error } = useGenlockeLineages(genlockeId) + + const allLegOrders = useMemo(() => { + if (!data) return [] + return [...new Set(data.lineages.flatMap((l) => l.legs.map((leg) => leg.legOrder)))].sort( + (a, b) => a - b + ) + }, [data]) + + const legGameNames = useMemo(() => { + if (!data) return new Map() + const map = new Map() + for (const lineage of data.lineages) { + for (const leg of lineage.legs) { + map.set(leg.legOrder, leg.gameName) + } + } + return map + }, [data]) + + if (isLoading) { + return ( +
+
+
+ ) + } + + if (error) { + return ( +
+ Failed to load lineage data. +
+ ) + } + + if (!data || data.totalLineages === 0) { + return ( +
+ No Pokemon have been transferred between legs yet. +
+ ) + } + + return ( +
+ {/* Summary bar */} +
+ + {data.totalLineages} lineage{data.totalLineages !== 1 ? 's' : ''} across{' '} + {allLegOrders.length} leg{allLegOrders.length !== 1 ? 's' : ''} + +
+ + {/* Column header row */} +
+ {/* Spacer matching pokemon info column */} +
+ {/* Leg headers */} +
+ {allLegOrders.map((legOrder) => ( +
+ + Leg {legOrder} + + + {legGameNames.get(legOrder)} + +
+ ))} +
+ {/* Spacer matching status badge */} +
+
+ + {/* Lineage cards */} +
+ {data.lineages.map((lineage) => ( + + ))} +
+
+ ) +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 671e91e..8fe5979 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -4,6 +4,7 @@ export { EncounterModal } from './EncounterModal' export { EndRunModal } from './EndRunModal' export { GameCard } from './GameCard' export { GenlockeGraveyard } from './GenlockeGraveyard' +export { GenlockeLineage } from './GenlockeLineage' export { HofTeamModal } from './HofTeamModal' export { GameGrid } from './GameGrid' export { Layout } from './Layout' diff --git a/frontend/src/hooks/useGenlockes.ts b/frontend/src/hooks/useGenlockes.ts index 3452f2a..1b6c831 100644 --- a/frontend/src/hooks/useGenlockes.ts +++ b/frontend/src/hooks/useGenlockes.ts @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { advanceLeg, createGenlocke, getGamesByRegion, getGenlockes, getGenlocke, getGenlockeGraveyard, getLegSurvivors } from '../api/genlockes' +import { advanceLeg, createGenlocke, getGamesByRegion, getGenlockes, getGenlocke, getGenlockeGraveyard, getGenlockeLineages, getLegSurvivors } from '../api/genlockes' import type { AdvanceLegInput, CreateGenlockeInput } from '../types/game' export function useGenlockes() { @@ -23,6 +23,13 @@ export function useGenlockeGraveyard(id: number) { }) } +export function useGenlockeLineages(id: number) { + return useQuery({ + queryKey: ['genlockes', id, 'lineages'], + queryFn: () => getGenlockeLineages(id), + }) +} + export function useRegions() { return useQuery({ queryKey: ['games', 'by-region'], diff --git a/frontend/src/pages/GenlockeDetail.tsx b/frontend/src/pages/GenlockeDetail.tsx index a4b9b36..6beed17 100644 --- a/frontend/src/pages/GenlockeDetail.tsx +++ b/frontend/src/pages/GenlockeDetail.tsx @@ -1,7 +1,7 @@ import { Link, useParams } from 'react-router-dom' import { useGenlocke } from '../hooks/useGenlockes' import { usePokemonFamilies } from '../hooks/usePokemon' -import { GenlockeGraveyard, StatCard, RuleBadges } from '../components' +import { GenlockeGraveyard, GenlockeLineage, StatCard, RuleBadges } from '../components' import type { GenlockeLegDetail, RetiredPokemon, RunStatus } from '../types' import { useMemo, useState } from 'react' @@ -87,6 +87,7 @@ export function GenlockeDetail() { const { data: familiesData } = usePokemonFamilies() const [showGraveyard, setShowGraveyard] = useState(false) + const [showLineage, setShowLineage] = useState(false) const activeLeg = useMemo(() => { if (!genlocke) return null @@ -297,9 +298,12 @@ export function GenlockeDetail() { Graveyard @@ -315,6 +319,16 @@ export function GenlockeDetail() { )} + + {/* Lineage */} + {showLineage && ( +
+

+ Pokemon Lineages +

+ +
+ )}
) } diff --git a/frontend/src/types/game.ts b/frontend/src/types/game.ts index e1a1bd1..8937bf5 100644 --- a/frontend/src/types/game.ts +++ b/frontend/src/types/game.ts @@ -302,6 +302,37 @@ export interface AdvanceLegInput { transferEncounterIds: number[] } +// Lineage types + +export interface LineageLegEntry { + legOrder: number + gameName: string + encounterId: number + pokemon: Pokemon + currentPokemon: Pokemon | null + nickname: string | null + catchLevel: number | null + faintLevel: number | null + deathCause: string | null + isShiny: boolean + routeName: string + isAlive: boolean + enteredHof: boolean + wasTransferred: boolean +} + +export interface LineageEntry { + nickname: string | null + pokemon: Pokemon + legs: LineageLegEntry[] + status: 'alive' | 'dead' +} + +export interface GenlockeLineage { + lineages: LineageEntry[] + totalLineages: number +} + // Graveyard types export interface GraveyardEntry {