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 (