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:
@@ -0,0 +1,24 @@
|
||||
---
|
||||
# nuzlocke-tracker-ymbd
|
||||
title: Implement randomize encounters
|
||||
status: completed
|
||||
type: feature
|
||||
priority: normal
|
||||
created_at: 2026-02-08T12:12:09Z
|
||||
updated_at: 2026-02-08T12:13:47Z
|
||||
---
|
||||
|
||||
Add per-route Randomize button in EncounterModal and bulk Randomize All on RunEncounters page.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Phase 1: Per-route randomize in EncounterModal.tsx
|
||||
- [ ] Add pickRandomPokemon helper function
|
||||
- [ ] Add Randomize/Re-roll button in Pokemon selection header
|
||||
- [ ] Phase 2: Bulk randomize backend
|
||||
- [ ] Add BulkRandomizeResponse schema in encounter.py
|
||||
- [ ] Add POST /runs/{run_id}/encounters/bulk-randomize endpoint
|
||||
- [ ] Phase 3: Bulk randomize frontend
|
||||
- [ ] Add bulkRandomizeEncounters() API function
|
||||
- [ ] Add useBulkRandomize() hook
|
||||
- [ ] Add Randomize All button on RunEncounters page
|
||||
@@ -1,3 +1,6 @@
|
||||
import random
|
||||
from collections import deque
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -5,10 +8,13 @@ from sqlalchemy.orm import joinedload, selectinload
|
||||
|
||||
from app.core.database import get_session
|
||||
from app.models.encounter import Encounter
|
||||
from app.models.evolution import Evolution
|
||||
from app.models.nuzlocke_run import NuzlockeRun
|
||||
from app.models.pokemon import Pokemon
|
||||
from app.models.route import Route
|
||||
from app.models.route_encounter import RouteEncounter
|
||||
from app.schemas.encounter import (
|
||||
BulkRandomizeResponse,
|
||||
EncounterCreate,
|
||||
EncounterDetailResponse,
|
||||
EncounterResponse,
|
||||
@@ -151,3 +157,242 @@ async def delete_encounter(
|
||||
await session.delete(encounter)
|
||||
await session.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
def _build_families(evolutions: list[Evolution]) -> dict[int, list[int]]:
|
||||
"""Build pokemon_id → family members mapping using BFS on evolution graph."""
|
||||
adj: dict[int, set[int]] = {}
|
||||
for evo in evolutions:
|
||||
adj.setdefault(evo.from_pokemon_id, set()).add(evo.to_pokemon_id)
|
||||
adj.setdefault(evo.to_pokemon_id, set()).add(evo.from_pokemon_id)
|
||||
|
||||
visited: set[int] = set()
|
||||
pokemon_to_family: dict[int, list[int]] = {}
|
||||
for node in adj:
|
||||
if node in visited:
|
||||
continue
|
||||
component: list[int] = []
|
||||
queue = deque([node])
|
||||
while queue:
|
||||
current = queue.popleft()
|
||||
if current in visited:
|
||||
continue
|
||||
visited.add(current)
|
||||
component.append(current)
|
||||
for neighbor in adj.get(current, set()):
|
||||
if neighbor not in visited:
|
||||
queue.append(neighbor)
|
||||
for member in component:
|
||||
pokemon_to_family[member] = component
|
||||
return pokemon_to_family
|
||||
|
||||
|
||||
@router.post(
|
||||
"/runs/{run_id}/encounters/bulk-randomize",
|
||||
response_model=BulkRandomizeResponse,
|
||||
status_code=201,
|
||||
)
|
||||
async def bulk_randomize_encounters(
|
||||
run_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
# 1. Validate run
|
||||
run = await session.get(NuzlockeRun, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
if run.status != "active":
|
||||
raise HTTPException(status_code=400, detail="Run is not active")
|
||||
|
||||
game_id = run.game_id
|
||||
|
||||
# 2. Get version_group_id from game
|
||||
from app.models.game import Game
|
||||
game = await session.get(Game, game_id)
|
||||
if game is None or game.version_group_id is None:
|
||||
raise HTTPException(status_code=400, detail="Game has no version group")
|
||||
version_group_id = game.version_group_id
|
||||
|
||||
# 3. Load all routes for this version group (with children)
|
||||
routes_result = await session.execute(
|
||||
select(Route)
|
||||
.where(Route.version_group_id == version_group_id)
|
||||
.options(selectinload(Route.children))
|
||||
.order_by(Route.order)
|
||||
)
|
||||
all_routes = routes_result.scalars().unique().all()
|
||||
|
||||
# 4. Load existing encounters for this run
|
||||
existing_result = await session.execute(
|
||||
select(Encounter).where(Encounter.run_id == run_id)
|
||||
)
|
||||
existing_encounters = existing_result.scalars().all()
|
||||
encountered_route_ids = {enc.route_id for enc in existing_encounters}
|
||||
|
||||
# 5. Load all route_encounters for this game
|
||||
re_result = await session.execute(
|
||||
select(RouteEncounter).where(RouteEncounter.game_id == game_id)
|
||||
)
|
||||
all_route_encounters = re_result.scalars().all()
|
||||
|
||||
# Build route_id → [pokemon_id, ...] mapping
|
||||
route_pokemon: dict[int, list[int]] = {}
|
||||
for re in all_route_encounters:
|
||||
route_pokemon.setdefault(re.route_id, [])
|
||||
if re.pokemon_id not in route_pokemon[re.route_id]:
|
||||
route_pokemon[re.route_id].append(re.pokemon_id)
|
||||
|
||||
# 6. Load evolution families
|
||||
dupes_clause_on = run.rules.get("duplicatesClause", True) if run.rules else True
|
||||
pokemon_to_family: dict[int, list[int]] = {}
|
||||
if dupes_clause_on:
|
||||
evo_result = await session.execute(select(Evolution))
|
||||
evolutions = evo_result.scalars().all()
|
||||
pokemon_to_family = _build_families(evolutions)
|
||||
|
||||
# 7. Build initial duped set from existing caught encounters
|
||||
duped: set[int] = set()
|
||||
if dupes_clause_on:
|
||||
for enc in existing_encounters:
|
||||
if enc.status != "caught":
|
||||
continue
|
||||
duped.add(enc.pokemon_id)
|
||||
family = pokemon_to_family.get(enc.pokemon_id, [])
|
||||
for member in family:
|
||||
duped.add(member)
|
||||
|
||||
# 8. Organize routes: identify top-level and children
|
||||
routes_by_id = {r.id: r for r in all_routes}
|
||||
top_level = [r for r in all_routes if r.parent_route_id is None]
|
||||
children_by_parent: dict[int, list[Route]] = {}
|
||||
for r in all_routes:
|
||||
if r.parent_route_id is not None:
|
||||
children_by_parent.setdefault(r.parent_route_id, []).append(r)
|
||||
|
||||
pinwheel_on = run.rules.get("pinwheelClause", True) if run.rules else True
|
||||
|
||||
# 9. Process routes in order, collecting target leaf routes
|
||||
created_encounters: list[Encounter] = []
|
||||
skipped = 0
|
||||
|
||||
for parent_route in top_level:
|
||||
children = children_by_parent.get(parent_route.id, [])
|
||||
|
||||
if len(children) == 0:
|
||||
# Standalone leaf route
|
||||
if parent_route.id in encountered_route_ids:
|
||||
continue
|
||||
available = route_pokemon.get(parent_route.id, [])
|
||||
eligible = [p for p in available if p not in duped] if dupes_clause_on else available
|
||||
if not eligible:
|
||||
skipped += 1
|
||||
continue
|
||||
picked = random.choice(eligible)
|
||||
enc = Encounter(
|
||||
run_id=run_id,
|
||||
route_id=parent_route.id,
|
||||
pokemon_id=picked,
|
||||
status="caught",
|
||||
)
|
||||
session.add(enc)
|
||||
created_encounters.append(enc)
|
||||
encountered_route_ids.add(parent_route.id)
|
||||
if dupes_clause_on:
|
||||
duped.add(picked)
|
||||
for member in pokemon_to_family.get(picked, []):
|
||||
duped.add(member)
|
||||
else:
|
||||
# Route group — determine zone behavior
|
||||
any_has_zone = any(c.pinwheel_zone is not None for c in children)
|
||||
use_pinwheel = pinwheel_on and any_has_zone
|
||||
|
||||
if use_pinwheel:
|
||||
# Zone-aware: one encounter per zone
|
||||
zones: dict[int, list[Route]] = {}
|
||||
for c in children:
|
||||
zone = c.pinwheel_zone if c.pinwheel_zone is not None else 0
|
||||
zones.setdefault(zone, []).append(c)
|
||||
|
||||
for zone_num in sorted(zones.keys()):
|
||||
zone_children = zones[zone_num]
|
||||
# Check if any child in this zone already has an encounter
|
||||
zone_has_encounter = any(
|
||||
c.id in encountered_route_ids for c in zone_children
|
||||
)
|
||||
if zone_has_encounter:
|
||||
continue
|
||||
|
||||
# Collect all pokemon from all children in this zone
|
||||
zone_pokemon: list[int] = []
|
||||
for c in zone_children:
|
||||
for p in route_pokemon.get(c.id, []):
|
||||
if p not in zone_pokemon:
|
||||
zone_pokemon.append(p)
|
||||
|
||||
eligible = [p for p in zone_pokemon if p not in duped] if dupes_clause_on else zone_pokemon
|
||||
if not eligible:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
picked = random.choice(eligible)
|
||||
# Pick a random child route in this zone to place the encounter
|
||||
target_child = random.choice(zone_children)
|
||||
enc = Encounter(
|
||||
run_id=run_id,
|
||||
route_id=target_child.id,
|
||||
pokemon_id=picked,
|
||||
status="caught",
|
||||
)
|
||||
session.add(enc)
|
||||
created_encounters.append(enc)
|
||||
encountered_route_ids.add(target_child.id)
|
||||
if dupes_clause_on:
|
||||
duped.add(picked)
|
||||
for member in pokemon_to_family.get(picked, []):
|
||||
duped.add(member)
|
||||
else:
|
||||
# Classic: one encounter for the whole group
|
||||
group_has_encounter = any(
|
||||
c.id in encountered_route_ids for c in children
|
||||
)
|
||||
if group_has_encounter:
|
||||
continue
|
||||
|
||||
# Collect all pokemon from all children
|
||||
group_pokemon: list[int] = []
|
||||
for c in children:
|
||||
for p in route_pokemon.get(c.id, []):
|
||||
if p not in group_pokemon:
|
||||
group_pokemon.append(p)
|
||||
|
||||
eligible = [p for p in group_pokemon if p not in duped] if dupes_clause_on else group_pokemon
|
||||
if not eligible:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
picked = random.choice(eligible)
|
||||
# Pick a random child route to place the encounter
|
||||
target_child = random.choice(children)
|
||||
enc = Encounter(
|
||||
run_id=run_id,
|
||||
route_id=target_child.id,
|
||||
pokemon_id=picked,
|
||||
status="caught",
|
||||
)
|
||||
session.add(enc)
|
||||
created_encounters.append(enc)
|
||||
encountered_route_ids.add(target_child.id)
|
||||
if dupes_clause_on:
|
||||
duped.add(picked)
|
||||
for member in pokemon_to_family.get(picked, []):
|
||||
duped.add(member)
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Refresh all created encounters to get server-generated fields
|
||||
for enc in created_encounters:
|
||||
await session.refresh(enc)
|
||||
|
||||
return BulkRandomizeResponse(
|
||||
created=created_encounters,
|
||||
skipped_routes=skipped,
|
||||
)
|
||||
|
||||
@@ -41,3 +41,8 @@ class EncounterDetailResponse(EncounterResponse):
|
||||
pokemon: PokemonResponse
|
||||
current_pokemon: PokemonResponse | None
|
||||
route: RouteResponse
|
||||
|
||||
|
||||
class BulkRandomizeResponse(CamelModel):
|
||||
created: list[EncounterResponse]
|
||||
skipped_routes: int
|
||||
|
||||
@@ -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