diff --git a/.beans/nuzlocke-tracker-b9oj--implement-dupes-clause-shiny-clause-enforcement.md b/.beans/nuzlocke-tracker-b9oj--implement-dupes-clause-shiny-clause-enforcement.md
new file mode 100644
index 0000000..fb32918
--- /dev/null
+++ b/.beans/nuzlocke-tracker-b9oj--implement-dupes-clause-shiny-clause-enforcement.md
@@ -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
\ No newline at end of file
diff --git a/backend/src/app/alembic/versions/b1c2d3e4f5a6_add_is_shiny_to_encounters.py b/backend/src/app/alembic/versions/b1c2d3e4f5a6_add_is_shiny_to_encounters.py
new file mode 100644
index 0000000..9e38279
--- /dev/null
+++ b/backend/src/app/alembic/versions/b1c2d3e4f5a6_add_is_shiny_to_encounters.py
@@ -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')
diff --git a/backend/src/app/api/encounters.py b/backend/src/app/api/encounters.py
index dcedd1f..6eb420e 100644
--- a/backend/src/app/api/encounters.py
+++ b/backend/src/app/api/encounters.py
@@ -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()
diff --git a/backend/src/app/api/pokemon.py b/backend/src/app/api/pokemon.py
index c732f58..779abae 100644
--- a/backend/src/app/api/pokemon.py
+++ b/backend/src/app/api/pokemon.py
@@ -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)
diff --git a/backend/src/app/models/encounter.py b/backend/src/app/models/encounter.py
index 597ba10..f0328a5 100644
--- a/backend/src/app/models/encounter.py
+++ b/backend/src/app/models/encounter.py
@@ -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()
)
diff --git a/backend/src/app/schemas/encounter.py b/backend/src/app/schemas/encounter.py
index f7f8d54..f39095f 100644
--- a/backend/src/app/schemas/encounter.py
+++ b/backend/src/app/schemas/encounter.py
@@ -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
diff --git a/backend/src/app/schemas/pokemon.py b/backend/src/app/schemas/pokemon.py
index e408f65..d67246b 100644
--- a/backend/src/app/schemas/pokemon.py
+++ b/backend/src/app/schemas/pokemon.py
@@ -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
diff --git a/frontend/src/api/pokemon.ts b/frontend/src/api/pokemon.ts
index 883f44b..f64e129 100644
--- a/frontend/src/api/pokemon.ts
+++ b/frontend/src/api/pokemon.ts
@@ -4,3 +4,7 @@ import type { Pokemon } from '../types/game'
export function getPokemon(id: number): Promise {
return api.get(`/pokemon/${id}`)
}
+
+export function fetchPokemonFamilies(): Promise<{ families: number[][] }> {
+ return api.get('/pokemon/families')
+}
diff --git a/frontend/src/components/EncounterModal.tsx b/frontend/src/components/EncounterModal.tsx
index bd6ee14..a51b75c 100644
--- a/frontend/src/components/EncounterModal.tsx
+++ b/frontend/src/components/EncounterModal.tsx
@@ -15,6 +15,7 @@ import type {
interface EncounterModalProps {
route: Route
existing?: EncounterDetail
+ dupedPokemonIds?: Set
onSubmit: (data: {
routeId: number
pokemonId: number
@@ -78,6 +79,7 @@ function groupByMethod(pokemon: RouteEncounterDetail[]): { method: string; pokem
export function EncounterModal({
route,
existing,
+ dupedPokemonIds,
onSubmit,
onUpdate,
onClose,
@@ -213,40 +215,53 @@ export function EncounterModal({
)}
- {pokemon.map((rp) => (
-
- ))}
+ {pokemon.map((rp) => {
+ const isDuped = dupedPokemonIds?.has(rp.pokemonId) ?? false
+ return (
+
+ )
+ })}
))}
diff --git a/frontend/src/components/ShinyBox.tsx b/frontend/src/components/ShinyBox.tsx
new file mode 100644
index 0000000..b041490
--- /dev/null
+++ b/frontend/src/components/ShinyBox.tsx
@@ -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 (
+
+
+ ✦
+ Shiny Box
+
+ {encounters.length} {encounters.length === 1 ? 'shiny' : 'shinies'}
+
+
+ {encounters.length > 0 ? (
+
+ {encounters.map((enc) => (
+
onEncounterClick(enc) : undefined}
+ />
+ ))}
+
+ ) : (
+
+ No shinies found yet
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/ShinyEncounterModal.tsx b/frontend/src/components/ShinyEncounterModal.tsx
new file mode 100644
index 0000000..53da18d
--- /dev/null
+++ b/frontend/src/components/ShinyEncounterModal.tsx
@@ -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()
+ 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(null)
+ const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
+ selectedRouteId,
+ )
+
+ const [selectedPokemon, setSelectedPokemon] =
+ useState(null)
+ const [nickname, setNickname] = useState('')
+ const [catchLevel, setCatchLevel] = useState('')
+ 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 (
+
+
+
+
+
+
+ ✦
+ Log Shiny Encounter
+
+
+
+
+ Shiny catches bypass the one-per-route rule
+
+
+
+
+ {/* Route selector */}
+
+
+
+
+
+ {/* Pokemon Selection */}
+ {selectedRouteId && (
+
+
+ {loadingPokemon ? (
+
+ ) : filteredPokemon && filteredPokemon.length > 0 ? (
+ <>
+ {(routePokemon?.length ?? 0) > 6 && (
+
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"
+ />
+ )}
+
+ {groupedPokemon.map(({ method, pokemon }, groupIdx) => (
+
+ {groupIdx > 0 && (
+
+ )}
+ {hasMultipleGroups && (
+
+ {getMethodLabel(method)}
+
+ )}
+
+ {pokemon.map((rp) => (
+
+ ))}
+
+
+ ))}
+
+ >
+ ) : (
+
+ No pokemon data for this route
+
+ )}
+
+ )}
+
+ {/* Nickname */}
+ {selectedPokemon && (
+
+
+ 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"
+ />
+
+ )}
+
+ {/* Catch Level */}
+ {selectedPokemon && (
+
+
+ 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"
+ />
+
+ )}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts
index 6db3d94..ff72528 100644
--- a/frontend/src/components/index.ts
+++ b/frontend/src/components/index.ts
@@ -6,6 +6,8 @@ export { GameGrid } from './GameGrid'
export { Layout } from './Layout'
export { PokemonCard } from './PokemonCard'
export { RuleBadges } from './RuleBadges'
+export { ShinyBox } from './ShinyBox'
+export { ShinyEncounterModal } from './ShinyEncounterModal'
export { StatusChangeModal } from './StatusChangeModal'
export { RuleToggle } from './RuleToggle'
export { RulesConfiguration } from './RulesConfiguration'
diff --git a/frontend/src/hooks/usePokemon.ts b/frontend/src/hooks/usePokemon.ts
index 63a140f..01794f3 100644
--- a/frontend/src/hooks/usePokemon.ts
+++ b/frontend/src/hooks/usePokemon.ts
@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'
-import { getPokemon } from '../api/pokemon'
+import { getPokemon, fetchPokemonFamilies } from '../api/pokemon'
export function usePokemon(id: number | null) {
return useQuery({
@@ -8,3 +8,11 @@ export function usePokemon(id: number | null) {
enabled: id !== null,
})
}
+
+export function usePokemonFamilies() {
+ return useQuery({
+ queryKey: ['pokemon', 'families'],
+ queryFn: fetchPokemonFamilies,
+ staleTime: Infinity,
+ })
+}
diff --git a/frontend/src/pages/RunEncounters.tsx b/frontend/src/pages/RunEncounters.tsx
index b3fcadf..919e8f9 100644
--- a/frontend/src/pages/RunEncounters.tsx
+++ b/frontend/src/pages/RunEncounters.tsx
@@ -3,6 +3,7 @@ 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 { usePokemonFamilies } from '../hooks/usePokemon'
import {
EncounterModal,
EncounterMethodBadge,
@@ -11,6 +12,8 @@ import {
StatusChangeModal,
EndRunModal,
RuleBadges,
+ ShinyBox,
+ ShinyEncounterModal,
} from '../components'
import type {
Route,
@@ -18,6 +21,7 @@ import type {
RunStatus,
EncounterDetail,
EncounterStatus,
+ CreateEncounterInput,
} from '../types'
const statusStyles: Record = {
@@ -317,6 +321,7 @@ export function RunEncounters() {
const createEncounter = useCreateEncounter(runIdNum)
const updateEncounter = useUpdateEncounter(runIdNum)
const updateRun = useUpdateRun(runIdNum)
+ const { data: familiesData } = usePokemonFamilies()
const [selectedRoute, setSelectedRoute] = useState(null)
const [editingEncounter, setEditingEncounter] =
@@ -324,6 +329,7 @@ export function RunEncounters() {
const [selectedTeamEncounter, setSelectedTeamEncounter] =
useState(null)
const [showEndRun, setShowEndRun] = useState(false)
+ const [showShinyModal, setShowShinyModal] = useState(false)
const [showTeam, setShowTeam] = useState(true)
const [filter, setFilter] = useState<'all' | RouteStatus>('all')
@@ -353,17 +359,67 @@ export function RunEncounters() {
return organizeRoutes(routes)
}, [routes])
- // Map routeId → encounter for quick lookup
- const encounterByRoute = useMemo(() => {
- const map = new Map()
- if (run) {
- for (const enc of run.encounters) {
- map.set(enc.routeId, enc)
+ // Split encounters into normal (non-shiny) and shiny
+ const { normalEncounters, shinyEncounters } = useMemo(() => {
+ if (!run) return { normalEncounters: [], shinyEncounters: [] }
+ const normal: EncounterDetail[] = []
+ const shiny: EncounterDetail[] = []
+ for (const enc of run.encounters) {
+ if (enc.isShiny) {
+ shiny.push(enc)
+ } else {
+ normal.push(enc)
}
}
- return map
+ return { normalEncounters: normal, shinyEncounters: shiny }
}, [run])
+ // Map routeId → encounter for quick lookup (normal encounters only)
+ const encounterByRoute = useMemo(() => {
+ const map = new Map()
+ 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()
+ for (const family of familiesData.families) {
+ for (const id of family) {
+ pokemonToFamily.set(id, family)
+ }
+ }
+
+ const duped = new Set()
+ 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
useEffect(() => {
if (organizedRoutes.length === 0 || expandedGroups.size > 0) return
@@ -429,10 +485,10 @@ export function RunEncounters() {
}
const isActive = run.status === 'active'
- const alive = run.encounters.filter(
+ const alive = normalEncounters.filter(
(e) => e.status === 'caught' && e.faintLevel === null,
)
- const dead = run.encounters.filter(
+ const dead = normalEncounters.filter(
(e) => e.status === 'caught' && e.faintLevel !== null,
)
@@ -458,17 +514,12 @@ export function RunEncounters() {
setSelectedRoute(route)
}
- const handleCreate = (data: {
- routeId: number
- pokemonId: number
- nickname?: string
- status: EncounterStatus
- catchLevel?: number
- }) => {
+ const handleCreate = (data: CreateEncounterInput) => {
createEncounter.mutate(data, {
onSuccess: () => {
setSelectedRoute(null)
setEditingEncounter(null)
+ setShowShinyModal(false)
},
})
}
@@ -538,6 +589,14 @@ export function RunEncounters() {
+ {isActive && run.rules?.shinyClause && (
+
+ )}
{isActive && (