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