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