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:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user