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:
2026-02-08 13:14:43 +01:00
parent 6779e3effa
commit 46f246028f
7 changed files with 349 additions and 7 deletions

View File

@@ -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,
)