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

@@ -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,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>

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 {