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 && (