From 46f246028f1e68cfc69b5458064f5b31952d0a0d Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Sun, 8 Feb 2026 13:14:43 +0100 Subject: [PATCH] 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 --- ...er-ymbd--implement-randomize-encounters.md | 24 ++ backend/src/app/api/encounters.py | 245 ++++++++++++++++++ backend/src/app/schemas/encounter.py | 5 + frontend/src/api/encounters.ts | 4 + frontend/src/components/EncounterModal.tsx | 41 ++- frontend/src/hooks/useEncounters.ts | 11 + frontend/src/pages/RunEncounters.tsx | 26 +- 7 files changed, 349 insertions(+), 7 deletions(-) create mode 100644 .beans/nuzlocke-tracker-ymbd--implement-randomize-encounters.md diff --git a/.beans/nuzlocke-tracker-ymbd--implement-randomize-encounters.md b/.beans/nuzlocke-tracker-ymbd--implement-randomize-encounters.md new file mode 100644 index 0000000..a1c43c0 --- /dev/null +++ b/.beans/nuzlocke-tracker-ymbd--implement-randomize-encounters.md @@ -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 \ No newline at end of file diff --git a/backend/src/app/api/encounters.py b/backend/src/app/api/encounters.py index 6eb420e..d21c7f0 100644 --- a/backend/src/app/api/encounters.py +++ b/backend/src/app/api/encounters.py @@ -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, + ) diff --git a/backend/src/app/schemas/encounter.py b/backend/src/app/schemas/encounter.py index f39095f..92b88fc 100644 --- a/backend/src/app/schemas/encounter.py +++ b/backend/src/app/schemas/encounter.py @@ -41,3 +41,8 @@ class EncounterDetailResponse(EncounterResponse): pokemon: PokemonResponse current_pokemon: PokemonResponse | None route: RouteResponse + + +class BulkRandomizeResponse(CamelModel): + created: list[EncounterResponse] + skipped_routes: int diff --git a/frontend/src/api/encounters.ts b/frontend/src/api/encounters.ts index c521c15..f3a34c6 100644 --- a/frontend/src/api/encounters.ts +++ b/frontend/src/api/encounters.ts @@ -33,3 +33,7 @@ export function fetchEvolutions(pokemonId: number, region?: string): Promise { return api.get(`/pokemon/${pokemonId}/forms`) } + +export function bulkRandomizeEncounters(runId: number): Promise<{ created: unknown[]; skippedRoutes: number }> { + return api.post(`/runs/${runId}/encounters/bulk-randomize`, {}) +} diff --git a/frontend/src/components/EncounterModal.tsx b/frontend/src/components/EncounterModal.tsx index fc385d8..6f5d15b 100644 --- a/frontend/src/components/EncounterModal.tsx +++ b/frontend/src/components/EncounterModal.tsx @@ -77,6 +77,17 @@ function groupByMethod(pokemon: RouteEncounterDetail[]): { method: string; pokem .map(([method, pokemon]) => ({ method, pokemon })) } +function pickRandomPokemon( + pokemon: RouteEncounterDetail[], + dupedIds?: Set, +): 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 && (
- +
+ + {!loadingPokemon && routePokemon && routePokemon.length > 0 && ( + + )} +
{loadingPokemon ? (
diff --git a/frontend/src/hooks/useEncounters.ts b/frontend/src/hooks/useEncounters.ts index 0ec145e..1d59d7b 100644 --- a/frontend/src/hooks/useEncounters.ts +++ b/frontend/src/hooks/useEncounters.ts @@ -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] }) + }, + }) +} diff --git a/frontend/src/pages/RunEncounters.tsx b/frontend/src/pages/RunEncounters.tsx index 57208f8..79a3fcc 100644 --- a/frontend/src/pages/RunEncounters.tsx +++ b/frontend/src/pages/RunEncounters.tsx @@ -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 */}
-

- Encounters -

+
+

+ Encounters +

+ {isActive && completedCount < totalLocations && ( + + )} +
{completedCount} / {totalLocations} locations