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,24 @@
---
# nuzlocke-tracker-b9oj
title: Implement Dupes Clause & Shiny Clause Enforcement
status: completed
type: feature
priority: normal
created_at: 2026-02-07T20:03:12Z
updated_at: 2026-02-07T20:07:50Z
---
Add enforcement for duplicatesClause and shinyClause rules:
- Dupes Clause: Grey out Pokemon in encounter modal whose evolution family is already caught
- Shiny Clause: Dedicated Shiny Box for bonus shiny catches that bypass route locks
## Checklist
- [x] Migration: Add is_shiny column to encounters table
- [x] Backend model + schema: Add is_shiny field
- [x] Backend: Shiny route-lock bypass in create_encounter
- [x] Backend: Evolution families endpoint (GET /pokemon/families)
- [x] Frontend types + API: Add isShiny fields and fetchPokemonFamilies
- [x] Frontend: Dupes Clause greying in EncounterModal
- [x] Frontend: ShinyEncounterModal + ShinyBox components
- [x] Frontend: RunEncounters orchestration (split encounters, duped IDs, shiny box)
- [x] TypeScript type check passes

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.", 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 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) # Get all sibling routes (routes with same parent, including this one)
siblings_result = await session.execute( siblings_result = await session.execute(
select(Route).where(Route.parent_route_id == route.parent_route_id) select(Route).where(Route.parent_route_id == route.parent_route_id)
@@ -99,6 +103,7 @@ async def create_encounter(
nickname=data.nickname, nickname=data.nickname,
status=data.status, status=data.status,
catch_level=data.catch_level, catch_level=data.catch_level,
is_shiny=data.is_shiny,
) )
session.add(encounter) session.add(encounter)
await session.commit() await session.commit()

View File

@@ -12,6 +12,7 @@ from app.schemas.pokemon import (
BulkImportItem, BulkImportItem,
BulkImportResult, BulkImportResult,
EvolutionResponse, EvolutionResponse,
FamiliesResponse,
PaginatedPokemonResponse, PaginatedPokemonResponse,
PokemonCreate, PokemonCreate,
PokemonResponse, PokemonResponse,
@@ -109,6 +110,44 @@ async def create_pokemon(
return 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) @router.get("/pokemon/{pokemon_id}", response_model=PokemonResponse)
async def get_pokemon( async def get_pokemon(
pokemon_id: int, session: AsyncSession = Depends(get_session) pokemon_id: int, session: AsyncSession = Depends(get_session)

View File

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

View File

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

View File

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

View File

@@ -4,3 +4,7 @@ import type { Pokemon } from '../types/game'
export function getPokemon(id: number): Promise<Pokemon> { export function getPokemon(id: number): Promise<Pokemon> {
return api.get(`/pokemon/${id}`) return api.get(`/pokemon/${id}`)
} }
export function fetchPokemonFamilies(): Promise<{ families: number[][] }> {
return api.get('/pokemon/families')
}

View File

@@ -15,6 +15,7 @@ import type {
interface EncounterModalProps { interface EncounterModalProps {
route: Route route: Route
existing?: EncounterDetail existing?: EncounterDetail
dupedPokemonIds?: Set<number>
onSubmit: (data: { onSubmit: (data: {
routeId: number routeId: number
pokemonId: number pokemonId: number
@@ -78,6 +79,7 @@ function groupByMethod(pokemon: RouteEncounterDetail[]): { method: string; pokem
export function EncounterModal({ export function EncounterModal({
route, route,
existing, existing,
dupedPokemonIds,
onSubmit, onSubmit,
onUpdate, onUpdate,
onClose, onClose,
@@ -213,40 +215,53 @@ export function EncounterModal({
</div> </div>
)} )}
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{pokemon.map((rp) => ( {pokemon.map((rp) => {
<button const isDuped = dupedPokemonIds?.has(rp.pokemonId) ?? false
key={rp.id} return (
type="button" <button
onClick={() => setSelectedPokemon(rp)} key={rp.id}
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${ type="button"
selectedPokemon?.id === rp.id onClick={() => !isDuped && setSelectedPokemon(rp)}
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30' disabled={isDuped}
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600' className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
}`} isDuped
> ? 'opacity-40 cursor-not-allowed border-gray-200 dark:border-gray-700'
{rp.pokemon.spriteUrl ? ( : selectedPokemon?.id === rp.id
<img ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30'
src={rp.pokemon.spriteUrl} : 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
alt={rp.pokemon.name} }`}
className="w-10 h-10" >
/> {rp.pokemon.spriteUrl ? (
) : ( <img
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold"> src={rp.pokemon.spriteUrl}
{rp.pokemon.name[0].toUpperCase()} alt={rp.pokemon.name}
</div> className="w-10 h-10"
)} />
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize"> ) : (
{rp.pokemon.name} <div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
</span> {rp.pokemon.name[0].toUpperCase()}
{SPECIAL_METHODS.includes(rp.encounterMethod) && ( </div>
<EncounterMethodBadge method={rp.encounterMethod} /> )}
)} <span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
<span className="text-[10px] text-gray-400"> {rp.pokemon.name}
Lv. {rp.minLevel} </span>
{rp.maxLevel !== rp.minLevel && `${rp.maxLevel}`} {isDuped && (
</span> <span className="text-[10px] text-gray-400 italic">
</button> already caught
))} </span>
)}
{!isDuped && SPECIAL_METHODS.includes(rp.encounterMethod) && (
<EncounterMethodBadge method={rp.encounterMethod} />
)}
{!isDuped && (
<span className="text-[10px] text-gray-400">
Lv. {rp.minLevel}
{rp.maxLevel !== rp.minLevel && `${rp.maxLevel}`}
</span>
)}
</button>
)
})}
</div> </div>
</div> </div>
))} ))}

View File

@@ -0,0 +1,36 @@
import { PokemonCard } from './PokemonCard'
import type { EncounterDetail } from '../types'
interface ShinyBoxProps {
encounters: EncounterDetail[]
onEncounterClick?: (encounter: EncounterDetail) => void
}
export function ShinyBox({ encounters, onEncounterClick }: ShinyBoxProps) {
return (
<div className="border-2 border-yellow-400 dark:border-yellow-600 rounded-lg p-4">
<h3 className="text-sm font-semibold text-yellow-600 dark:text-yellow-400 mb-3 flex items-center gap-1.5">
<span>&#10022;</span>
Shiny Box
<span className="text-xs font-normal text-gray-400 dark:text-gray-500 ml-1">
{encounters.length} {encounters.length === 1 ? 'shiny' : 'shinies'}
</span>
</h3>
{encounters.length > 0 ? (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
{encounters.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
onClick={onEncounterClick ? () => onEncounterClick(enc) : undefined}
/>
))}
</div>
) : (
<p className="text-sm text-gray-400 dark:text-gray-500 text-center py-2">
No shinies found yet
</p>
)}
</div>
)
}

View File

@@ -0,0 +1,294 @@
import { useState, useMemo } from 'react'
import { useRoutePokemon } from '../hooks/useGames'
import {
EncounterMethodBadge,
getMethodLabel,
METHOD_ORDER,
} from './EncounterMethodBadge'
import type { Route, RouteEncounterDetail } from '../types'
interface ShinyEncounterModalProps {
routes: Route[]
onSubmit: (data: {
routeId: number
pokemonId: number
nickname?: string
status: 'caught'
catchLevel?: number
isShiny: true
}) => void
onClose: () => void
isPending: boolean
}
const SPECIAL_METHODS = ['starter', 'gift', 'fossil', 'trade']
function groupByMethod(pokemon: RouteEncounterDetail[]): { method: string; pokemon: RouteEncounterDetail[] }[] {
const groups = new Map<string, RouteEncounterDetail[]>()
for (const rp of pokemon) {
const list = groups.get(rp.encounterMethod) ?? []
list.push(rp)
groups.set(rp.encounterMethod, list)
}
return [...groups.entries()]
.sort(([a], [b]) => {
const ai = METHOD_ORDER.indexOf(a)
const bi = METHOD_ORDER.indexOf(b)
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi)
})
.map(([method, pokemon]) => ({ method, pokemon }))
}
export function ShinyEncounterModal({
routes,
onSubmit,
onClose,
isPending,
}: ShinyEncounterModalProps) {
const [selectedRouteId, setSelectedRouteId] = useState<number | null>(null)
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
selectedRouteId,
)
const [selectedPokemon, setSelectedPokemon] =
useState<RouteEncounterDetail | null>(null)
const [nickname, setNickname] = useState('')
const [catchLevel, setCatchLevel] = useState<string>('')
const [search, setSearch] = useState('')
const filteredPokemon = routePokemon?.filter((rp) =>
rp.pokemon.name.toLowerCase().includes(search.toLowerCase()),
)
const groupedPokemon = useMemo(
() => (filteredPokemon ? groupByMethod(filteredPokemon) : []),
[filteredPokemon],
)
const hasMultipleGroups = groupedPokemon.length > 1
// Reset selection when route changes
const handleRouteChange = (routeId: number) => {
setSelectedRouteId(routeId)
setSelectedPokemon(null)
setSearch('')
}
const handleSubmit = () => {
if (selectedPokemon && selectedRouteId) {
onSubmit({
routeId: selectedRouteId,
pokemonId: selectedPokemon.pokemonId,
nickname: nickname || undefined,
status: 'caught',
catchLevel: catchLevel ? Number(catchLevel) : undefined,
isShiny: true,
})
}
}
// Only show leaf routes (no children, i.e. routes that aren't parents)
const parentIds = new Set(routes.filter(r => r.parentRouteId !== null).map(r => r.parentRouteId))
const leafRoutes = routes.filter(r => !parentIds.has(r.id))
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-yellow-300 dark:border-yellow-600 px-6 py-4 rounded-t-xl">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<span className="text-yellow-500">&#10022;</span>
Log Shiny Encounter
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<p className="text-sm text-yellow-600 dark:text-yellow-400 mt-1">
Shiny catches bypass the one-per-route rule
</p>
</div>
<div className="px-6 py-4 space-y-4">
{/* Route selector */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Route
</label>
<select
value={selectedRouteId ?? ''}
onChange={(e) => handleRouteChange(Number(e.target.value))}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-yellow-500"
>
<option value="">Select a route...</option>
{leafRoutes.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</div>
{/* Pokemon Selection */}
{selectedRouteId && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Pokemon
</label>
{loadingPokemon ? (
<div className="flex items-center justify-center py-4">
<div className="w-6 h-6 border-2 border-yellow-500 border-t-transparent rounded-full animate-spin" />
</div>
) : filteredPokemon && filteredPokemon.length > 0 ? (
<>
{(routePokemon?.length ?? 0) > 6 && (
<input
type="text"
placeholder="Search pokemon..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full px-3 py-1.5 mb-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-yellow-500"
/>
)}
<div className="max-h-64 overflow-y-auto space-y-3">
{groupedPokemon.map(({ method, pokemon }, groupIdx) => (
<div key={method}>
{groupIdx > 0 && (
<div className="border-t border-gray-200 dark:border-gray-700 mb-3" />
)}
{hasMultipleGroups && (
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">
{getMethodLabel(method)}
</div>
)}
<div className="grid grid-cols-3 gap-2">
{pokemon.map((rp) => (
<button
key={rp.id}
type="button"
onClick={() => setSelectedPokemon(rp)}
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
selectedPokemon?.id === rp.id
? 'border-yellow-500 bg-yellow-50 dark:bg-yellow-900/30'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
{rp.pokemon.spriteUrl ? (
<img
src={rp.pokemon.spriteUrl}
alt={rp.pokemon.name}
className="w-10 h-10"
/>
) : (
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
{rp.pokemon.name[0].toUpperCase()}
</div>
)}
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
{rp.pokemon.name}
</span>
{SPECIAL_METHODS.includes(rp.encounterMethod) && (
<EncounterMethodBadge method={rp.encounterMethod} />
)}
<span className="text-[10px] text-gray-400">
Lv. {rp.minLevel}
{rp.maxLevel !== rp.minLevel && `${rp.maxLevel}`}
</span>
</button>
))}
</div>
</div>
))}
</div>
</>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400 py-2">
No pokemon data for this route
</p>
)}
</div>
)}
{/* Nickname */}
{selectedPokemon && (
<div>
<label
htmlFor="shiny-nickname"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Nickname
</label>
<input
id="shiny-nickname"
type="text"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder="Give it a name..."
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-yellow-500"
/>
</div>
)}
{/* Catch Level */}
{selectedPokemon && (
<div>
<label
htmlFor="shiny-catch-level"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Catch Level
</label>
<input
id="shiny-catch-level"
type="number"
min={1}
max={100}
value={catchLevel}
onChange={(e) => setCatchLevel(e.target.value)}
placeholder={
selectedPokemon
? `${selectedPokemon.minLevel}${selectedPokemon.maxLevel}`
: 'Level'
}
className="w-24 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-yellow-500"
/>
</div>
)}
</div>
<div className="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-6 py-4 rounded-b-xl flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
Cancel
</button>
<button
type="button"
disabled={!selectedPokemon || isPending}
onClick={handleSubmit}
className="px-4 py-2 bg-yellow-500 text-white rounded-lg font-medium hover:bg-yellow-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isPending ? 'Saving...' : 'Log Shiny'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -6,6 +6,8 @@ export { GameGrid } from './GameGrid'
export { Layout } from './Layout' export { Layout } from './Layout'
export { PokemonCard } from './PokemonCard' export { PokemonCard } from './PokemonCard'
export { RuleBadges } from './RuleBadges' export { RuleBadges } from './RuleBadges'
export { ShinyBox } from './ShinyBox'
export { ShinyEncounterModal } from './ShinyEncounterModal'
export { StatusChangeModal } from './StatusChangeModal' export { StatusChangeModal } from './StatusChangeModal'
export { RuleToggle } from './RuleToggle' export { RuleToggle } from './RuleToggle'
export { RulesConfiguration } from './RulesConfiguration' export { RulesConfiguration } from './RulesConfiguration'

View File

@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { getPokemon } from '../api/pokemon' import { getPokemon, fetchPokemonFamilies } from '../api/pokemon'
export function usePokemon(id: number | null) { export function usePokemon(id: number | null) {
return useQuery({ return useQuery({
@@ -8,3 +8,11 @@ export function usePokemon(id: number | null) {
enabled: id !== null, enabled: id !== null,
}) })
} }
export function usePokemonFamilies() {
return useQuery({
queryKey: ['pokemon', 'families'],
queryFn: fetchPokemonFamilies,
staleTime: Infinity,
})
}

View File

@@ -3,6 +3,7 @@ import { useParams, Link } from 'react-router-dom'
import { useRun, useUpdateRun } from '../hooks/useRuns' import { useRun, useUpdateRun } from '../hooks/useRuns'
import { useGameRoutes } from '../hooks/useGames' import { useGameRoutes } from '../hooks/useGames'
import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters' import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters'
import { usePokemonFamilies } from '../hooks/usePokemon'
import { import {
EncounterModal, EncounterModal,
EncounterMethodBadge, EncounterMethodBadge,
@@ -11,6 +12,8 @@ import {
StatusChangeModal, StatusChangeModal,
EndRunModal, EndRunModal,
RuleBadges, RuleBadges,
ShinyBox,
ShinyEncounterModal,
} from '../components' } from '../components'
import type { import type {
Route, Route,
@@ -18,6 +21,7 @@ import type {
RunStatus, RunStatus,
EncounterDetail, EncounterDetail,
EncounterStatus, EncounterStatus,
CreateEncounterInput,
} from '../types' } from '../types'
const statusStyles: Record<RunStatus, string> = { const statusStyles: Record<RunStatus, string> = {
@@ -317,6 +321,7 @@ export function RunEncounters() {
const createEncounter = useCreateEncounter(runIdNum) const createEncounter = useCreateEncounter(runIdNum)
const updateEncounter = useUpdateEncounter(runIdNum) const updateEncounter = useUpdateEncounter(runIdNum)
const updateRun = useUpdateRun(runIdNum) const updateRun = useUpdateRun(runIdNum)
const { data: familiesData } = usePokemonFamilies()
const [selectedRoute, setSelectedRoute] = useState<Route | null>(null) const [selectedRoute, setSelectedRoute] = useState<Route | null>(null)
const [editingEncounter, setEditingEncounter] = const [editingEncounter, setEditingEncounter] =
@@ -324,6 +329,7 @@ export function RunEncounters() {
const [selectedTeamEncounter, setSelectedTeamEncounter] = const [selectedTeamEncounter, setSelectedTeamEncounter] =
useState<EncounterDetail | null>(null) useState<EncounterDetail | null>(null)
const [showEndRun, setShowEndRun] = useState(false) const [showEndRun, setShowEndRun] = useState(false)
const [showShinyModal, setShowShinyModal] = useState(false)
const [showTeam, setShowTeam] = useState(true) const [showTeam, setShowTeam] = useState(true)
const [filter, setFilter] = useState<'all' | RouteStatus>('all') const [filter, setFilter] = useState<'all' | RouteStatus>('all')
@@ -353,17 +359,67 @@ export function RunEncounters() {
return organizeRoutes(routes) return organizeRoutes(routes)
}, [routes]) }, [routes])
// Map routeId → encounter for quick lookup // Split encounters into normal (non-shiny) and shiny
const encounterByRoute = useMemo(() => { const { normalEncounters, shinyEncounters } = useMemo(() => {
const map = new Map<number, EncounterDetail>() if (!run) return { normalEncounters: [], shinyEncounters: [] }
if (run) { const normal: EncounterDetail[] = []
for (const enc of run.encounters) { const shiny: EncounterDetail[] = []
map.set(enc.routeId, enc) for (const enc of run.encounters) {
if (enc.isShiny) {
shiny.push(enc)
} else {
normal.push(enc)
} }
} }
return map return { normalEncounters: normal, shinyEncounters: shiny }
}, [run]) }, [run])
// Map routeId → encounter for quick lookup (normal encounters only)
const encounterByRoute = useMemo(() => {
const map = new Map<number, EncounterDetail>()
for (const enc of normalEncounters) {
map.set(enc.routeId, enc)
}
return map
}, [normalEncounters])
// Build set of duped Pokemon IDs (for duplicates clause)
const dupedPokemonIds = useMemo(() => {
const dupesClauseOn = run?.rules?.duplicatesClause ?? true
if (!dupesClauseOn || !familiesData) return undefined
// Build pokemonId → family members map
const pokemonToFamily = new Map<number, number[]>()
for (const family of familiesData.families) {
for (const id of family) {
pokemonToFamily.set(id, family)
}
}
const duped = new Set<number>()
for (const enc of normalEncounters) {
if (enc.status !== 'caught') continue
const pokemonId = enc.currentPokemonId ?? enc.pokemonId
// Add the pokemon itself and all family members
duped.add(pokemonId)
duped.add(enc.pokemonId)
const family = pokemonToFamily.get(pokemonId)
if (family) {
for (const memberId of family) {
duped.add(memberId)
}
}
// Also check original pokemon's family
const origFamily = pokemonToFamily.get(enc.pokemonId)
if (origFamily) {
for (const memberId of origFamily) {
duped.add(memberId)
}
}
}
return duped.size > 0 ? duped : undefined
}, [run, normalEncounters, familiesData])
// Auto-expand the first unvisited group on initial load // Auto-expand the first unvisited group on initial load
useEffect(() => { useEffect(() => {
if (organizedRoutes.length === 0 || expandedGroups.size > 0) return if (organizedRoutes.length === 0 || expandedGroups.size > 0) return
@@ -429,10 +485,10 @@ export function RunEncounters() {
} }
const isActive = run.status === 'active' const isActive = run.status === 'active'
const alive = run.encounters.filter( const alive = normalEncounters.filter(
(e) => e.status === 'caught' && e.faintLevel === null, (e) => e.status === 'caught' && e.faintLevel === null,
) )
const dead = run.encounters.filter( const dead = normalEncounters.filter(
(e) => e.status === 'caught' && e.faintLevel !== null, (e) => e.status === 'caught' && e.faintLevel !== null,
) )
@@ -458,17 +514,12 @@ export function RunEncounters() {
setSelectedRoute(route) setSelectedRoute(route)
} }
const handleCreate = (data: { const handleCreate = (data: CreateEncounterInput) => {
routeId: number
pokemonId: number
nickname?: string
status: EncounterStatus
catchLevel?: number
}) => {
createEncounter.mutate(data, { createEncounter.mutate(data, {
onSuccess: () => { onSuccess: () => {
setSelectedRoute(null) setSelectedRoute(null)
setEditingEncounter(null) setEditingEncounter(null)
setShowShinyModal(false)
}, },
}) })
} }
@@ -538,6 +589,14 @@ export function RunEncounters() {
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isActive && run.rules?.shinyClause && (
<button
onClick={() => setShowShinyModal(true)}
className="px-3 py-1 text-sm border border-yellow-400 dark:border-yellow-600 text-yellow-600 dark:text-yellow-400 rounded-full font-medium hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors"
>
&#10022; Log Shiny
</button>
)}
{isActive && ( {isActive && (
<button <button
onClick={() => setShowEndRun(true)} onClick={() => setShowEndRun(true)}
@@ -605,7 +664,7 @@ export function RunEncounters() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6"> <div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<StatCard <StatCard
label="Encounters" label="Encounters"
value={run.encounters.length} value={normalEncounters.length}
color="blue" color="blue"
/> />
<StatCard label="Alive" value={alive.length} color="green" /> <StatCard label="Alive" value={alive.length} color="green" />
@@ -689,6 +748,16 @@ export function RunEncounters() {
</div> </div>
)} )}
{/* Shiny Box */}
{run.rules?.shinyClause && shinyEncounters.length > 0 && (
<div className="mb-6">
<ShinyBox
encounters={shinyEncounters}
onEncounterClick={isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined}
/>
</div>
)}
{/* Progress bar */} {/* Progress bar */}
<div className="mb-4"> <div className="mb-4">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
@@ -818,6 +887,7 @@ export function RunEncounters() {
<EncounterModal <EncounterModal
route={selectedRoute} route={selectedRoute}
existing={editingEncounter ?? undefined} existing={editingEncounter ?? undefined}
dupedPokemonIds={dupedPokemonIds}
onSubmit={handleCreate} onSubmit={handleCreate}
onUpdate={handleUpdate} onUpdate={handleUpdate}
onClose={() => { onClose={() => {
@@ -828,6 +898,16 @@ export function RunEncounters() {
/> />
)} )}
{/* Shiny Encounter Modal */}
{showShinyModal && routes && (
<ShinyEncounterModal
routes={routes}
onSubmit={handleCreate}
onClose={() => setShowShinyModal(false)}
isPending={createEncounter.isPending}
/>
)}
{/* Status Change Modal (team pokemon) */} {/* Status Change Modal (team pokemon) */}
{selectedTeamEncounter && ( {selectedTeamEncounter && (
<StatusChangeModal <StatusChangeModal

View File

@@ -59,6 +59,7 @@ export interface Encounter {
catchLevel: number | null catchLevel: number | null
faintLevel: number | null faintLevel: number | null
deathCause: string | null deathCause: string | null
isShiny: boolean
caughtAt: string caughtAt: string
} }
@@ -115,6 +116,7 @@ export interface CreateEncounterInput {
nickname?: string nickname?: string
status: EncounterStatus status: EncounterStatus
catchLevel?: number catchLevel?: number
isShiny?: boolean
} }
export interface UpdateEncounterInput { export interface UpdateEncounterInput {