Add genlocke leg progression with advance endpoint and run context

When a run belonging to a genlocke is completed or failed, the genlocke
status updates accordingly. The run detail API now includes genlocke
context (leg order, total legs, genlocke name). A new advance endpoint
creates the next leg's run, and the frontend shows genlocke-aware UI
including a "Leg X of Y" banner, advance button, and contextual
messaging in the end-run modal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Julian Tabel
2026-02-09 09:47:28 +01:00
parent 96178622f9
commit 07343e94e2
11 changed files with 271 additions and 54 deletions

View File

@@ -80,8 +80,8 @@ A dedicated page showing:
- [x] The first leg starts automatically upon genlocke creation
- [ ] Each leg is a full nuzlocke run, tracked identically to standalone runs
- [ ] Completing a leg triggers a transfer step where surviving Pokemon can be carried forward
- [ ] Failing a leg marks the entire genlocke as failed
- [ ] Completing the final leg marks the genlocke as completed
- [x] Failing a leg marks the entire genlocke as failed
- [x] Completing the final leg marks the genlocke as completed
- [ ] A genlocke overview page shows progress, configuration, cumulative stats, lineage, and graveyard
- [ ] Transferred Pokemon appear as eggs (base form, level 1) in the next leg
- [ ] Pokemon lineage is trackable across multiple legs

View File

@@ -1,11 +1,11 @@
---
# nuzlocke-tracker-thbz
title: Genlocke leg progression
status: todo
status: in-progress
type: feature
priority: normal
created_at: 2026-02-09T07:42:33Z
updated_at: 2026-02-09T07:45:55Z
updated_at: 2026-02-09T08:42:15Z
parent: nuzlocke-tracker-25mh
blocking:
- nuzlocke-tracker-p74f
@@ -45,12 +45,12 @@ Handle the sequential progression of legs within a genlocke. When one leg is com
- After completion, redirect to the transfer step (or to the genlocke overview if it was the final leg)
## Checklist
- [ ] Add backend logic to detect when a completed/failed run belongs to a genlocke (query GenlockeLeg by run_id)
- [ ] On run completion: update GenlockeLeg status, determine if there's a next leg
- [ ] On run failure: mark the genlocke as failed, prevent further leg creation
- [ ] On final leg completion: mark the genlocke as completed
- [ ] Implement `POST /api/v1/genlockes/{id}/legs/{leg_order}/advance` endpoint to create the next leg's run
- [ ] Add genlocke context to the run detail API response (genlocke name, leg number, total legs) when the run belongs to a genlocke
- [ ] Update the frontend run page header to show "Leg X of Y — {Genlocke Name}" when applicable
- [ ] Update the run completion flow on the frontend to detect genlocke membership and redirect to transfer step instead of the standard completion screen
- [x] Add backend logic to detect when a completed/failed run belongs to a genlocke (query GenlockeLeg by run_id)
- [x] On run completion: update GenlockeLeg status, determine if there's a next leg
- [x] On run failure: mark the genlocke as failed, prevent further leg creation
- [x] On final leg completion: mark the genlocke as completed
- [x] Implement `POST /api/v1/genlockes/{id}/legs/{leg_order}/advance` endpoint to create the next leg's run
- [x] Add genlocke context to the run detail API response (genlocke name, leg number, total legs) when the run belongs to a genlocke
- [x] Update the frontend run page header to show "Leg X of Y — {Genlocke Name}" when applicable
- [x] Update the run completion flow on the frontend to detect genlocke membership and redirect to transfer step instead of the standard completion screen
- [ ] Handle edge case: what happens if a genlocke run is manually deleted?

View File

@@ -79,3 +79,86 @@ async def create_genlocke(
)
)
return result.scalar_one()
@router.post(
"/{genlocke_id}/legs/{leg_order}/advance",
response_model=GenlockeResponse,
)
async def advance_leg(
genlocke_id: int,
leg_order: int,
session: AsyncSession = Depends(get_session),
):
# Load genlocke with legs
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")
if genlocke.status != "active":
raise HTTPException(
status_code=400, detail="Genlocke is not active"
)
# Find the current leg
current_leg = None
next_leg = None
for leg in genlocke.legs:
if leg.leg_order == leg_order:
current_leg = leg
elif leg.leg_order == leg_order + 1:
next_leg = leg
if current_leg is None:
raise HTTPException(status_code=404, detail="Leg not found")
# Verify current leg's run is completed
if current_leg.run_id is None:
raise HTTPException(
status_code=400, detail="Current leg has no run"
)
current_run = await session.get(NuzlockeRun, current_leg.run_id)
if current_run is None or current_run.status != "completed":
raise HTTPException(
status_code=400, detail="Current leg's run is not completed"
)
if next_leg is None:
raise HTTPException(
status_code=400, detail="No next leg to advance to"
)
if next_leg.run_id is not None:
raise HTTPException(
status_code=400, detail="Next leg already has a run"
)
# Create a new run for the next leg
new_run = NuzlockeRun(
game_id=next_leg.game_id,
name=f"{genlocke.name} \u2014 Leg {next_leg.leg_order}",
status="active",
rules=genlocke.nuzlocke_rules,
)
session.add(new_run)
await session.flush()
next_leg.run_id = new_run.id
await session.commit()
# Reload with relationships
result = await session.execute(
select(Genlocke)
.where(Genlocke.id == genlocke_id)
.options(
selectinload(Genlocke.legs).selectinload(GenlockeLeg.game),
)
)
return result.scalar_one()

View File

@@ -1,15 +1,16 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload, selectinload
from app.core.database import get_session
from app.models.encounter import Encounter
from app.models.game import Game
from app.models.genlocke import Genlocke, GenlockeLeg
from app.models.nuzlocke_run import NuzlockeRun
from app.schemas.run import RunCreate, RunDetailResponse, RunResponse, RunUpdate
from app.schemas.run import RunCreate, RunDetailResponse, RunGenlockeContext, RunResponse, RunUpdate
router = APIRouter()
@@ -61,7 +62,33 @@ async def get_run(run_id: int, session: AsyncSession = Depends(get_session)):
run = result.scalar_one_or_none()
if run is None:
raise HTTPException(status_code=404, detail="Run not found")
return run
# Check if this run belongs to a genlocke
genlocke_context = None
leg_result = await session.execute(
select(GenlockeLeg)
.where(GenlockeLeg.run_id == run_id)
.options(joinedload(GenlockeLeg.genlocke))
)
leg = leg_result.scalar_one_or_none()
if leg:
total_legs_result = await session.execute(
select(func.count())
.select_from(GenlockeLeg)
.where(GenlockeLeg.genlocke_id == leg.genlocke_id)
)
total_legs = total_legs_result.scalar_one()
genlocke_context = RunGenlockeContext(
genlocke_id=leg.genlocke_id,
genlocke_name=leg.genlocke.name,
leg_order=leg.leg_order,
total_legs=total_legs,
is_final_leg=leg.leg_order == total_legs,
)
response = RunDetailResponse.model_validate(run)
response.genlocke = genlocke_context
return response
@router.patch("/{run_id}", response_model=RunResponse)
@@ -87,6 +114,28 @@ async def update_run(
for field, value in update_data.items():
setattr(run, field, value)
# Genlocke side effects when run status changes
if "status" in update_data and update_data["status"] in ("completed", "failed"):
leg_result = await session.execute(
select(GenlockeLeg)
.where(GenlockeLeg.run_id == run_id)
.options(joinedload(GenlockeLeg.genlocke))
)
leg = leg_result.scalar_one_or_none()
if leg:
genlocke = leg.genlocke
if update_data["status"] == "failed":
genlocke.status = "failed"
elif update_data["status"] == "completed":
total_legs_result = await session.execute(
select(func.count())
.select_from(GenlockeLeg)
.where(GenlockeLeg.genlocke_id == genlocke.id)
)
total_legs = total_legs_result.scalar_one()
if leg.leg_order == total_legs:
genlocke.status = "completed"
await session.commit()
await session.refresh(run)
return run

View File

@@ -37,7 +37,7 @@ from app.schemas.pokemon import (
RouteEncounterResponse,
RouteEncounterUpdate,
)
from app.schemas.run import RunCreate, RunDetailResponse, RunResponse, RunUpdate
from app.schemas.run import RunCreate, RunDetailResponse, RunGenlockeContext, RunResponse, RunUpdate
__all__ = [
"BossBattleCreate",
@@ -75,6 +75,7 @@ __all__ = [
"RouteUpdate",
"RunCreate",
"RunDetailResponse",
"RunGenlockeContext",
"RunResponse",
"RunUpdate",
]

View File

@@ -27,6 +27,15 @@ class RunResponse(CamelModel):
completed_at: datetime | None
class RunGenlockeContext(CamelModel):
genlocke_id: int
genlocke_name: str
leg_order: int
total_legs: int
is_final_leg: bool
class RunDetailResponse(RunResponse):
game: GameResponse
encounters: list[EncounterDetailResponse] = []
genlocke: RunGenlockeContext | None = None

View File

@@ -8,3 +8,7 @@ export function createGenlocke(data: CreateGenlockeInput): Promise<Genlocke> {
export function getGamesByRegion(): Promise<Region[]> {
return api.get('/games/by-region')
}
export function advanceLeg(genlockeId: number, legOrder: number): Promise<Genlocke> {
return api.post(`/genlockes/${genlockeId}/legs/${legOrder}/advance`, {})
}

View File

@@ -1,12 +1,23 @@
import type { RunStatus } from '../types'
import type { RunStatus, RunGenlockeContext } from '../types'
interface EndRunModalProps {
onConfirm: (status: RunStatus) => void
onClose: () => void
isPending?: boolean
genlockeContext?: RunGenlockeContext | null
}
export function EndRunModal({ onConfirm, onClose, isPending }: EndRunModalProps) {
export function EndRunModal({ onConfirm, onClose, isPending, genlockeContext }: EndRunModalProps) {
const victoryDescription = genlockeContext
? genlockeContext.isFinalLeg
? 'Complete the final leg of your genlocke!'
: 'Complete this leg and continue your genlocke'
: 'Beat the game successfully'
const defeatDescription = genlockeContext
? 'This will end the entire genlocke'
: 'All Pokemon fainted or gave up'
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
@@ -25,7 +36,7 @@ export function EndRunModal({ onConfirm, onClose, isPending }: EndRunModalProps)
className="w-full px-4 py-3 rounded-lg font-medium text-left border-2 border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 hover:border-blue-400 dark:hover:border-blue-600 disabled:opacity-50 transition-colors"
>
<div className="font-semibold">Victory</div>
<div className="text-sm opacity-80">Beat the game successfully</div>
<div className="text-sm opacity-80">{victoryDescription}</div>
</button>
<button
onClick={() => onConfirm('failed')}
@@ -33,7 +44,7 @@ export function EndRunModal({ onConfirm, onClose, isPending }: EndRunModalProps)
className="w-full px-4 py-3 rounded-lg font-medium text-left border-2 border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 hover:border-red-400 dark:hover:border-red-600 disabled:opacity-50 transition-colors"
>
<div className="font-semibold">Defeat</div>
<div className="text-sm opacity-80">All Pokemon fainted or gave up</div>
<div className="text-sm opacity-80">{defeatDescription}</div>
</button>
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createGenlocke, getGamesByRegion } from '../api/genlockes'
import { advanceLeg, createGenlocke, getGamesByRegion } from '../api/genlockes'
import type { CreateGenlockeInput } from '../types/game'
export function useRegions() {
@@ -18,3 +18,14 @@ export function useCreateGenlocke() {
},
})
}
export function useAdvanceLeg() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ genlockeId, legOrder }: { genlockeId: number; legOrder: number }) =>
advanceLeg(genlockeId, legOrder),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['runs'] })
},
})
}

View File

@@ -1,6 +1,7 @@
import { useState, useMemo, useEffect, useCallback } from 'react'
import { useParams, Link } from 'react-router-dom'
import { useParams, Link, useNavigate } from 'react-router-dom'
import { useRun, useUpdateRun } from '../hooks/useRuns'
import { useAdvanceLeg } from '../hooks/useGenlockes'
import { useGameRoutes } from '../hooks/useGames'
import { useCreateEncounter, useUpdateEncounter, useBulkRandomize } from '../hooks/useEncounters'
import { usePokemonFamilies } from '../hooks/usePokemon'
@@ -389,8 +390,10 @@ function RouteGroup({
export function RunEncounters() {
const { runId } = useParams<{ runId: string }>()
const navigate = useNavigate()
const runIdNum = Number(runId)
const { data: run, isLoading, error } = useRun(runIdNum)
const advanceLeg = useAdvanceLeg()
const { data: routes, isLoading: loadingRoutes } = useGameRoutes(
run?.gameId ?? null,
)
@@ -745,6 +748,11 @@ export function RunEncounters() {
day: 'numeric',
})}
</p>
{run.genlocke && (
<p className="text-sm text-purple-600 dark:text-purple-400 mt-1 font-medium">
Leg {run.genlocke.legOrder} of {run.genlocke.totalLegs} &mdash; {run.genlocke.genlockeName}
</p>
)}
</div>
<div className="flex items-center gap-2">
{isActive && run.rules?.shinyClause && (
@@ -789,6 +797,7 @@ export function RunEncounters() {
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">{run.status === 'completed' ? '\u{1f3c6}' : '\u{1faa6}'}</span>
<div>
@@ -799,7 +808,13 @@ export function RunEncounters() {
: 'text-red-800 dark:text-red-200'
}`}
>
{run.status === 'completed' ? 'Victory!' : 'Defeat'}
{run.status === 'completed'
? run.genlocke?.isFinalLeg
? 'Genlocke Complete!'
: 'Victory!'
: run.genlocke
? 'Genlocke Failed'
: 'Defeat'}
</p>
<p
className={`text-sm ${
@@ -823,6 +838,30 @@ export function RunEncounters() {
</p>
</div>
</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}`)
}
},
},
)
}}
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"
>
{advanceLeg.isPending ? 'Advancing...' : 'Advance to Next Leg'}
</button>
)}
</div>
</div>
)}
@@ -1323,6 +1362,7 @@ export function RunEncounters() {
}}
onClose={() => setShowEndRun(false)}
isPending={updateRun.isPending}
genlockeContext={run.genlocke}
/>
)}
</div>

View File

@@ -93,9 +93,18 @@ export interface NuzlockeRun {
completedAt: string | null
}
export interface RunGenlockeContext {
genlockeId: number
genlockeName: string
legOrder: number
totalLegs: number
isFinalLeg: boolean
}
export interface RunDetail extends NuzlockeRun {
game: Game
encounters: EncounterDetail[]
genlocke: RunGenlockeContext | null
}
export interface EncounterDetail extends Encounter {