Enforce Dupes Clause and Shiny Clause rules

Dupes Clause greys out Pokemon in the encounter modal whose evolution
family has already been caught, preventing duplicate selections. Shiny
Clause adds a dedicated Shiny Box and lets shiny catches bypass the
one-per-route lock via a new is_shiny column on encounters and a
/pokemon/families endpoint that computes evolution family groups.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 21:08:25 +01:00
parent 7b7945246d
commit ad1eb0524c
15 changed files with 599 additions and 54 deletions

View File

@@ -0,0 +1,29 @@
"""add is_shiny to encounters
Revision ID: b1c2d3e4f5a6
Revises: f6a7b8c9d0e1
Create Date: 2026-02-07 18:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b1c2d3e4f5a6'
down_revision: Union[str, Sequence[str], None] = 'f6a7b8c9d0e1'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'encounters',
sa.Column('is_shiny', sa.Boolean(), nullable=False, server_default=sa.text('false')),
)
def downgrade() -> None:
op.drop_column('encounters', 'is_shiny')

View File

@@ -50,8 +50,12 @@ async def create_encounter(
detail="Cannot create encounter on a parent route. Use a child route instead.",
)
# Shiny clause: shiny encounters bypass the route-lock check
shiny_clause_on = run.rules.get("shinyClause", True) if run.rules else True
skip_route_lock = data.is_shiny and shiny_clause_on
# If this route has a parent, check if sibling already has an encounter
if route.parent_route_id is not None:
if route.parent_route_id is not None and not skip_route_lock:
# Get all sibling routes (routes with same parent, including this one)
siblings_result = await session.execute(
select(Route).where(Route.parent_route_id == route.parent_route_id)
@@ -99,6 +103,7 @@ async def create_encounter(
nickname=data.nickname,
status=data.status,
catch_level=data.catch_level,
is_shiny=data.is_shiny,
)
session.add(encounter)
await session.commit()

View File

@@ -12,6 +12,7 @@ from app.schemas.pokemon import (
BulkImportItem,
BulkImportResult,
EvolutionResponse,
FamiliesResponse,
PaginatedPokemonResponse,
PokemonCreate,
PokemonResponse,
@@ -109,6 +110,44 @@ async def create_pokemon(
return pokemon
@router.get("/pokemon/families", response_model=FamiliesResponse)
async def get_pokemon_families(
session: AsyncSession = Depends(get_session),
):
"""Return evolution families as connected components of Pokemon IDs."""
from collections import deque
result = await session.execute(select(Evolution))
evolutions = result.scalars().all()
# Build undirected adjacency list
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)
# BFS to find connected components
visited: set[int] = set()
families: list[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)
families.append(sorted(component))
return FamiliesResponse(families=families)
@router.get("/pokemon/{pokemon_id}", response_model=PokemonResponse)
async def get_pokemon(
pokemon_id: int, session: AsyncSession = Depends(get_session)

View File

@@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, SmallInteger, String, func
from sqlalchemy import Boolean, DateTime, ForeignKey, SmallInteger, String, func, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
@@ -21,6 +21,7 @@ class Encounter(Base):
current_pokemon_id: Mapped[int | None] = mapped_column(
ForeignKey("pokemon.id"), index=True
)
is_shiny: Mapped[bool] = mapped_column(Boolean, default=False, server_default=text("false"))
caught_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)

View File

@@ -11,6 +11,7 @@ class EncounterCreate(CamelModel):
nickname: str | None = None
status: str
catch_level: int | None = None
is_shiny: bool = False
class EncounterUpdate(CamelModel):
@@ -32,6 +33,7 @@ class EncounterResponse(CamelModel):
catch_level: int | None
faint_level: int | None
death_cause: str | None
is_shiny: bool
caught_at: datetime

View File

@@ -31,6 +31,10 @@ class EvolutionResponse(CamelModel):
region: str | None
class FamiliesResponse(CamelModel):
families: list[list[int]]
class RouteEncounterResponse(CamelModel):
id: int
route_id: int