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:
@@ -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
|
||||
|
||||
@@ -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?
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`, {})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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} — {run.genlocke.genlockeName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isActive && run.rules?.shinyClause && (
|
||||
@@ -789,39 +797,70 @@ export function RunEncounters() {
|
||||
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{run.status === 'completed' ? '\u{1f3c6}' : '\u{1faa6}'}</span>
|
||||
<div>
|
||||
<p
|
||||
className={`font-semibold ${
|
||||
run.status === 'completed'
|
||||
? 'text-blue-800 dark:text-blue-200'
|
||||
: 'text-red-800 dark:text-red-200'
|
||||
}`}
|
||||
>
|
||||
{run.status === 'completed' ? 'Victory!' : 'Defeat'}
|
||||
</p>
|
||||
<p
|
||||
className={`text-sm ${
|
||||
run.status === 'completed'
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}
|
||||
>
|
||||
{run.completedAt && (
|
||||
<>
|
||||
Ended{' '}
|
||||
{new Date(run.completedAt).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
{' \u00b7 '}
|
||||
Duration: {formatDuration(run.startedAt, run.completedAt)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<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>
|
||||
<p
|
||||
className={`font-semibold ${
|
||||
run.status === 'completed'
|
||||
? 'text-blue-800 dark:text-blue-200'
|
||||
: 'text-red-800 dark:text-red-200'
|
||||
}`}
|
||||
>
|
||||
{run.status === 'completed'
|
||||
? run.genlocke?.isFinalLeg
|
||||
? 'Genlocke Complete!'
|
||||
: 'Victory!'
|
||||
: run.genlocke
|
||||
? 'Genlocke Failed'
|
||||
: 'Defeat'}
|
||||
</p>
|
||||
<p
|
||||
className={`text-sm ${
|
||||
run.status === 'completed'
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}
|
||||
>
|
||||
{run.completedAt && (
|
||||
<>
|
||||
Ended{' '}
|
||||
{new Date(run.completedAt).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
{' \u00b7 '}
|
||||
Duration: {formatDuration(run.startedAt, run.completedAt)}
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user