From 07343e94e296893f7808955375f763b9af5aea47 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Mon, 9 Feb 2026 09:47:28 +0100 Subject: [PATCH] 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 --- ...uzlocke-tracker-25mh--genlocke-tracking.md | 4 +- ...-tracker-thbz--genlocke-leg-progression.md | 20 ++-- backend/src/app/api/genlockes.py | 83 ++++++++++++++ backend/src/app/api/runs.py | 55 ++++++++- backend/src/app/schemas/__init__.py | 3 +- backend/src/app/schemas/run.py | 9 ++ frontend/src/api/genlockes.ts | 4 + frontend/src/components/EndRunModal.tsx | 19 +++- frontend/src/hooks/useGenlockes.ts | 13 ++- frontend/src/pages/RunEncounters.tsx | 106 ++++++++++++------ frontend/src/types/game.ts | 9 ++ 11 files changed, 271 insertions(+), 54 deletions(-) diff --git a/.beans/nuzlocke-tracker-25mh--genlocke-tracking.md b/.beans/nuzlocke-tracker-25mh--genlocke-tracking.md index f9eac27..78be75f 100644 --- a/.beans/nuzlocke-tracker-25mh--genlocke-tracking.md +++ b/.beans/nuzlocke-tracker-25mh--genlocke-tracking.md @@ -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 diff --git a/.beans/nuzlocke-tracker-thbz--genlocke-leg-progression.md b/.beans/nuzlocke-tracker-thbz--genlocke-leg-progression.md index deb97e9..fb4b22a 100644 --- a/.beans/nuzlocke-tracker-thbz--genlocke-leg-progression.md +++ b/.beans/nuzlocke-tracker-thbz--genlocke-leg-progression.md @@ -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? \ No newline at end of file diff --git a/backend/src/app/api/genlockes.py b/backend/src/app/api/genlockes.py index 18e06d7..314e392 100644 --- a/backend/src/app/api/genlockes.py +++ b/backend/src/app/api/genlockes.py @@ -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() diff --git a/backend/src/app/api/runs.py b/backend/src/app/api/runs.py index 1053149..a0989ee 100644 --- a/backend/src/app/api/runs.py +++ b/backend/src/app/api/runs.py @@ -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 diff --git a/backend/src/app/schemas/__init__.py b/backend/src/app/schemas/__init__.py index 263bd7c..201cd5a 100644 --- a/backend/src/app/schemas/__init__.py +++ b/backend/src/app/schemas/__init__.py @@ -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", ] diff --git a/backend/src/app/schemas/run.py b/backend/src/app/schemas/run.py index f00f3b7..1f1c842 100644 --- a/backend/src/app/schemas/run.py +++ b/backend/src/app/schemas/run.py @@ -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 diff --git a/frontend/src/api/genlockes.ts b/frontend/src/api/genlockes.ts index e9c9b30..e8e3df6 100644 --- a/frontend/src/api/genlockes.ts +++ b/frontend/src/api/genlockes.ts @@ -8,3 +8,7 @@ export function createGenlocke(data: CreateGenlockeInput): Promise { export function getGamesByRegion(): Promise { return api.get('/games/by-region') } + +export function advanceLeg(genlockeId: number, legOrder: number): Promise { + return api.post(`/genlockes/${genlockeId}/legs/${legOrder}/advance`, {}) +} diff --git a/frontend/src/components/EndRunModal.tsx b/frontend/src/components/EndRunModal.tsx index e14962c..253799c 100644 --- a/frontend/src/components/EndRunModal.tsx +++ b/frontend/src/components/EndRunModal.tsx @@ -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 (
@@ -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" >
Victory
-
Beat the game successfully
+
{victoryDescription}
diff --git a/frontend/src/hooks/useGenlockes.ts b/frontend/src/hooks/useGenlockes.ts index 61a4e68..8fd63d2 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 { 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'] }) + }, + }) +} diff --git a/frontend/src/pages/RunEncounters.tsx b/frontend/src/pages/RunEncounters.tsx index 9811f0c..8b0c6ef 100644 --- a/frontend/src/pages/RunEncounters.tsx +++ b/frontend/src/pages/RunEncounters.tsx @@ -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', })}

+ {run.genlocke && ( +

+ Leg {run.genlocke.legOrder} of {run.genlocke.totalLegs} — {run.genlocke.genlockeName} +

+ )}
{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' }`} > -
- {run.status === 'completed' ? '\u{1f3c6}' : '\u{1faa6}'} -
-

- {run.status === 'completed' ? 'Victory!' : 'Defeat'} -

-

- {run.completedAt && ( - <> - Ended{' '} - {new Date(run.completedAt).toLocaleDateString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', - })} - {' \u00b7 '} - Duration: {formatDuration(run.startedAt, run.completedAt)} - - )} -

+
+
+ {run.status === 'completed' ? '\u{1f3c6}' : '\u{1faa6}'} +
+

+ {run.status === 'completed' + ? run.genlocke?.isFinalLeg + ? 'Genlocke Complete!' + : 'Victory!' + : run.genlocke + ? 'Genlocke Failed' + : 'Defeat'} +

+

+ {run.completedAt && ( + <> + Ended{' '} + {new Date(run.completedAt).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + })} + {' \u00b7 '} + Duration: {formatDuration(run.startedAt, run.completedAt)} + + )} +

+
+ {run.status === 'completed' && run.genlocke && !run.genlocke.isFinalLeg && ( + + )}
)} @@ -1323,6 +1362,7 @@ export function RunEncounters() { }} onClose={() => setShowEndRun(false)} isPending={updateRun.isPending} + genlockeContext={run.genlocke} /> )}
diff --git a/frontend/src/types/game.ts b/frontend/src/types/game.ts index a990f1c..9ec96a4 100644 --- a/frontend/src/types/game.ts +++ b/frontend/src/types/game.ts @@ -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 {