Add randomize encounters feature (per-route + bulk)
Per-route: Randomize/Re-roll button in EncounterModal picks a uniform
random pokemon from eligible (non-duped) encounters. Bulk: new
POST /runs/{run_id}/encounters/bulk-randomize endpoint fills all
remaining routes in order, respecting dupes clause cascading, pinwheel
zones, and route group locking. Frontend Randomize All button on the
run page triggers the bulk endpoint with a confirm dialog.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -33,3 +33,7 @@ export function fetchEvolutions(pokemonId: number, region?: string): Promise<Evo
|
||||
export function fetchForms(pokemonId: number): Promise<Pokemon[]> {
|
||||
return api.get(`/pokemon/${pokemonId}/forms`)
|
||||
}
|
||||
|
||||
export function bulkRandomizeEncounters(runId: number): Promise<{ created: unknown[]; skippedRoutes: number }> {
|
||||
return api.post(`/runs/${runId}/encounters/bulk-randomize`, {})
|
||||
}
|
||||
|
||||
@@ -77,6 +77,17 @@ function groupByMethod(pokemon: RouteEncounterDetail[]): { method: string; pokem
|
||||
.map(([method, pokemon]) => ({ method, pokemon }))
|
||||
}
|
||||
|
||||
function pickRandomPokemon(
|
||||
pokemon: RouteEncounterDetail[],
|
||||
dupedIds?: Set<number>,
|
||||
): RouteEncounterDetail | null {
|
||||
const eligible = dupedIds
|
||||
? pokemon.filter((rp) => !dupedIds.has(rp.pokemonId))
|
||||
: pokemon
|
||||
if (eligible.length === 0) return null
|
||||
return eligible[Math.floor(Math.random() * eligible.length)]
|
||||
}
|
||||
|
||||
export function EncounterModal({
|
||||
route,
|
||||
gameId,
|
||||
@@ -188,9 +199,33 @@ export function EncounterModal({
|
||||
{/* Pokemon Selection (only for new encounters) */}
|
||||
{!isEditing && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Pokemon
|
||||
</label>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Pokemon
|
||||
</label>
|
||||
{!loadingPokemon && routePokemon && routePokemon.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
loadingPokemon ||
|
||||
!routePokemon ||
|
||||
(dupedPokemonIds
|
||||
? routePokemon.every((rp) => dupedPokemonIds.has(rp.pokemonId))
|
||||
: false)
|
||||
}
|
||||
onClick={() => {
|
||||
if (routePokemon) {
|
||||
setSelectedPokemon(
|
||||
pickRandomPokemon(routePokemon, dupedPokemonIds),
|
||||
)
|
||||
}
|
||||
}}
|
||||
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-300 dark:border-purple-600 text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{selectedPokemon ? 'Re-roll' : 'Randomize'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{loadingPokemon ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="w-10 h-10 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
deleteEncounter,
|
||||
fetchEvolutions,
|
||||
fetchForms,
|
||||
bulkRandomizeEncounters,
|
||||
} from '../api/encounters'
|
||||
import type { CreateEncounterInput, UpdateEncounterInput } from '../types/game'
|
||||
|
||||
@@ -59,3 +60,13 @@ export function useForms(pokemonId: number | null) {
|
||||
enabled: pokemonId !== null,
|
||||
})
|
||||
}
|
||||
|
||||
export function useBulkRandomize(runId: number) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () => bulkRandomizeEncounters(runId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['runs', runId] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useMemo, useEffect, useCallback } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useRun, useUpdateRun } from '../hooks/useRuns'
|
||||
import { useGameRoutes } from '../hooks/useGames'
|
||||
import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters'
|
||||
import { useCreateEncounter, useUpdateEncounter, useBulkRandomize } from '../hooks/useEncounters'
|
||||
import { usePokemonFamilies } from '../hooks/usePokemon'
|
||||
import { useGameBosses, useBossResults, useCreateBossResult } from '../hooks/useBosses'
|
||||
import {
|
||||
@@ -323,6 +323,7 @@ export function RunEncounters() {
|
||||
)
|
||||
const createEncounter = useCreateEncounter(runIdNum)
|
||||
const updateEncounter = useUpdateEncounter(runIdNum)
|
||||
const bulkRandomize = useBulkRandomize(runIdNum)
|
||||
const updateRun = useUpdateRun(runIdNum)
|
||||
const { data: familiesData } = usePokemonFamilies()
|
||||
const { data: bosses } = useGameBosses(run?.gameId ?? null)
|
||||
@@ -872,9 +873,26 @@ export function RunEncounters() {
|
||||
{/* Progress bar */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Encounters
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Encounters
|
||||
</h2>
|
||||
{isActive && completedCount < totalLocations && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={bulkRandomize.isPending}
|
||||
onClick={() => {
|
||||
const remaining = totalLocations - completedCount
|
||||
if (window.confirm(`Randomize encounters for all ${remaining} remaining locations?`)) {
|
||||
bulkRandomize.mutate()
|
||||
}
|
||||
}}
|
||||
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-300 dark:border-purple-600 text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{bulkRandomize.isPending ? 'Randomizing...' : 'Randomize All'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{completedCount} / {totalLocations} locations
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user