diff --git a/.beans/nuzlocke-tracker-o18w--add-region-field-to-evolutions.md b/.beans/nuzlocke-tracker-o18w--add-region-field-to-evolutions.md new file mode 100644 index 0000000..8133909 --- /dev/null +++ b/.beans/nuzlocke-tracker-o18w--add-region-field-to-evolutions.md @@ -0,0 +1,11 @@ +--- +# nuzlocke-tracker-o18w +title: Add region field to evolutions +status: completed +type: feature +priority: normal +created_at: 2026-02-07T18:55:07Z +updated_at: 2026-02-07T18:57:25Z +--- + +Add nullable region field to evolutions for regional form evolution filtering (e.g., Pikachu → Alolan Raichu only in Alola). Full-stack: DB migration, model, schemas, API, seeder, Go tool, frontend types/API/hook/components. \ No newline at end of file diff --git a/backend/src/app/alembic/versions/f6a7b8c9d0e1_add_region_to_evolutions.py b/backend/src/app/alembic/versions/f6a7b8c9d0e1_add_region_to_evolutions.py new file mode 100644 index 0000000..4c94cde --- /dev/null +++ b/backend/src/app/alembic/versions/f6a7b8c9d0e1_add_region_to_evolutions.py @@ -0,0 +1,29 @@ +"""add region to evolutions + +Revision ID: f6a7b8c9d0e1 +Revises: e5f6a7b8c9d0 +Create Date: 2026-02-07 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f6a7b8c9d0e1' +down_revision: Union[str, Sequence[str], None] = 'e5f6a7b8c9d0' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + 'evolutions', + sa.Column('region', sa.String(30), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column('evolutions', 'region') diff --git a/backend/src/app/api/pokemon.py b/backend/src/app/api/pokemon.py index 538efd7..c732f58 100644 --- a/backend/src/app/api/pokemon.py +++ b/backend/src/app/api/pokemon.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import func, select +from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload, selectinload @@ -121,18 +121,40 @@ async def get_pokemon( @router.get("/pokemon/{pokemon_id}/evolutions", response_model=list[EvolutionResponse]) async def get_pokemon_evolutions( - pokemon_id: int, session: AsyncSession = Depends(get_session) + pokemon_id: int, + region: str | None = Query(None), + session: AsyncSession = Depends(get_session), ): pokemon = await session.get(Pokemon, pokemon_id) if pokemon is None: raise HTTPException(status_code=404, detail="Pokemon not found") - result = await session.execute( + query = ( select(Evolution) .where(Evolution.from_pokemon_id == pokemon_id) .options(joinedload(Evolution.to_pokemon)) ) - return result.scalars().unique().all() + if region is not None: + query = query.where( + or_(Evolution.region.is_(None), Evolution.region == region) + ) + result = await session.execute(query) + evolutions = result.scalars().unique().all() + + if region is not None: + # Regional evolutions replace the non-regional one that shares the + # same trigger + item (e.g. Pikachu + thunder-stone → Alolan Raichu + # replaces Pikachu + thunder-stone → Raichu in Alola). + regional_keys = { + (e.trigger, e.item) for e in evolutions if e.region is not None + } + if regional_keys: + evolutions = [ + e for e in evolutions + if e.region is not None or (e.trigger, e.item) not in regional_keys + ] + + return evolutions @router.put("/pokemon/{pokemon_id}", response_model=PokemonResponse) diff --git a/backend/src/app/models/evolution.py b/backend/src/app/models/evolution.py index 82a7afd..a876280 100644 --- a/backend/src/app/models/evolution.py +++ b/backend/src/app/models/evolution.py @@ -15,6 +15,7 @@ class Evolution(Base): item: Mapped[str | None] = mapped_column(String(50)) # e.g. thunder-stone held_item: Mapped[str | None] = mapped_column(String(50)) condition: Mapped[str | None] = mapped_column(String(200)) # catch-all for other conditions + region: Mapped[str | None] = mapped_column(String(30)) from_pokemon: Mapped["Pokemon"] = relationship(foreign_keys=[from_pokemon_id]) to_pokemon: Mapped["Pokemon"] = relationship(foreign_keys=[to_pokemon_id]) diff --git a/backend/src/app/schemas/pokemon.py b/backend/src/app/schemas/pokemon.py index d342e07..e408f65 100644 --- a/backend/src/app/schemas/pokemon.py +++ b/backend/src/app/schemas/pokemon.py @@ -28,6 +28,7 @@ class EvolutionResponse(CamelModel): item: str | None held_item: str | None condition: str | None + region: str | None class RouteEncounterResponse(CamelModel): @@ -106,6 +107,7 @@ class EvolutionAdminResponse(CamelModel): item: str | None held_item: str | None condition: str | None + region: str | None class PaginatedEvolutionResponse(CamelModel): @@ -123,6 +125,7 @@ class EvolutionCreate(CamelModel): item: str | None = None held_item: str | None = None condition: str | None = None + region: str | None = None class EvolutionUpdate(CamelModel): @@ -133,3 +136,4 @@ class EvolutionUpdate(CamelModel): item: str | None = None held_item: str | None = None condition: str | None = None + region: str | None = None diff --git a/backend/src/app/seeds/data/evolution_overrides.json b/backend/src/app/seeds/data/evolution_overrides.json index 1638aa9..9a429c7 100644 --- a/backend/src/app/seeds/data/evolution_overrides.json +++ b/backend/src/app/seeds/data/evolution_overrides.json @@ -1,5 +1,42 @@ { "remove": [], - "add": [], + "add": [ + { + "from_dex": 25, + "to_dex": 10100, + "trigger": "use-item", + "item": "thunder-stone", + "region": "alola" + }, + { + "from_dex": 102, + "to_dex": 10101, + "trigger": "use-item", + "item": "leaf-stone", + "region": "alola" + }, + { + "from_dex": 77, + "to_dex": 10162, + "trigger": "level-up", + "min_level": 40, + "region": "galar" + }, + { + "from_dex": 263, + "to_dex": 10176, + "trigger": "level-up", + "min_level": 20, + "region": "galar" + }, + { + "from_dex": 10176, + "to_dex": 10177, + "trigger": "level-up", + "min_level": 35, + "condition": "nighttime", + "region": "galar" + } + ], "modify": [] } diff --git a/backend/src/app/seeds/loader.py b/backend/src/app/seeds/loader.py index 5837c61..b575559 100644 --- a/backend/src/app/seeds/loader.py +++ b/backend/src/app/seeds/loader.py @@ -184,6 +184,7 @@ async def upsert_evolutions( item=evo.get("item"), held_item=evo.get("held_item"), condition=evo.get("condition"), + region=evo.get("region"), ) session.add(evolution) count += 1 diff --git a/frontend/src/api/encounters.ts b/frontend/src/api/encounters.ts index f153b62..9a6efc9 100644 --- a/frontend/src/api/encounters.ts +++ b/frontend/src/api/encounters.ts @@ -24,6 +24,7 @@ export function deleteEncounter(id: number): Promise { return api.del(`/encounters/${id}`) } -export function fetchEvolutions(pokemonId: number): Promise { - return api.get(`/pokemon/${pokemonId}/evolutions`) +export function fetchEvolutions(pokemonId: number, region?: string): Promise { + const params = region ? `?region=${encodeURIComponent(region)}` : '' + return api.get(`/pokemon/${pokemonId}/evolutions${params}`) } diff --git a/frontend/src/components/StatusChangeModal.tsx b/frontend/src/components/StatusChangeModal.tsx index 7d3d98d..2f53b6f 100644 --- a/frontend/src/components/StatusChangeModal.tsx +++ b/frontend/src/components/StatusChangeModal.tsx @@ -10,6 +10,7 @@ interface StatusChangeModalProps { }) => void onClose: () => void isPending: boolean + region?: string } const typeColors: Record = { @@ -60,6 +61,7 @@ export function StatusChangeModal({ onUpdate, onClose, isPending, + region, }: StatusChangeModalProps) { const { pokemon, currentPokemon, route, nickname, catchLevel, faintLevel, deathCause } = encounter @@ -72,7 +74,8 @@ export function StatusChangeModal({ const activePokemonId = currentPokemon?.id ?? pokemon.id const { data: evolutions, isLoading: evolutionsLoading } = useEvolutions( - showEvolve ? activePokemonId : null + showEvolve ? activePokemonId : null, + region, ) const handleConfirmDeath = () => { diff --git a/frontend/src/components/admin/EvolutionFormModal.tsx b/frontend/src/components/admin/EvolutionFormModal.tsx index 3c10023..ed49021 100644 --- a/frontend/src/components/admin/EvolutionFormModal.tsx +++ b/frontend/src/components/admin/EvolutionFormModal.tsx @@ -29,6 +29,7 @@ export function EvolutionFormModal({ const [item, setItem] = useState(evolution?.item ?? '') const [heldItem, setHeldItem] = useState(evolution?.heldItem ?? '') const [condition, setCondition] = useState(evolution?.condition ?? '') + const [region, setRegion] = useState(evolution?.region ?? '') const handleSubmit = (e: FormEvent) => { e.preventDefault() @@ -41,6 +42,7 @@ export function EvolutionFormModal({ item: item || null, heldItem: heldItem || null, condition: condition || null, + region: region || null, }) } @@ -119,6 +121,16 @@ export function EvolutionFormModal({ className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" /> +
+ + setRegion(e.target.value)} + placeholder="e.g. alola (blank = all regions)" + className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" + /> +
) } diff --git a/frontend/src/hooks/useEncounters.ts b/frontend/src/hooks/useEncounters.ts index 81ab3e6..58fff7b 100644 --- a/frontend/src/hooks/useEncounters.ts +++ b/frontend/src/hooks/useEncounters.ts @@ -43,10 +43,10 @@ export function useDeleteEncounter(runId: number) { }) } -export function useEvolutions(pokemonId: number | null) { +export function useEvolutions(pokemonId: number | null, region?: string) { return useQuery({ - queryKey: ['evolutions', pokemonId], - queryFn: () => fetchEvolutions(pokemonId!), + queryKey: ['evolutions', pokemonId, region], + queryFn: () => fetchEvolutions(pokemonId!, region), enabled: pokemonId !== null, }) } diff --git a/frontend/src/pages/RunDashboard.tsx b/frontend/src/pages/RunDashboard.tsx index 88ecae9..843c181 100644 --- a/frontend/src/pages/RunDashboard.tsx +++ b/frontend/src/pages/RunDashboard.tsx @@ -242,6 +242,7 @@ export function RunDashboard() { }} onClose={() => setSelectedEncounter(null)} isPending={updateEncounter.isPending} + region={run?.game.region} /> )} diff --git a/frontend/src/pages/RunEncounters.tsx b/frontend/src/pages/RunEncounters.tsx index ebdd2f1..9ea7950 100644 --- a/frontend/src/pages/RunEncounters.tsx +++ b/frontend/src/pages/RunEncounters.tsx @@ -748,6 +748,7 @@ export function RunEncounters() { }} onClose={() => setSelectedTeamEncounter(null)} isPending={updateEncounter.isPending} + region={run?.game.region} /> )} diff --git a/frontend/src/types/admin.ts b/frontend/src/types/admin.ts index 0f9781d..21be737 100644 --- a/frontend/src/types/admin.ts +++ b/frontend/src/types/admin.ts @@ -86,6 +86,7 @@ export interface EvolutionAdmin { item: string | null heldItem: string | null condition: string | null + region: string | null } export interface PaginatedEvolutions { @@ -103,6 +104,7 @@ export interface CreateEvolutionInput { item?: string | null heldItem?: string | null condition?: string | null + region?: string | null } export interface UpdateEvolutionInput { @@ -113,4 +115,5 @@ export interface UpdateEvolutionInput { item?: string | null heldItem?: string | null condition?: string | null + region?: string | null } diff --git a/frontend/src/types/game.ts b/frontend/src/types/game.ts index cfe1cec..cc7f1e0 100644 --- a/frontend/src/types/game.ts +++ b/frontend/src/types/game.ts @@ -93,6 +93,7 @@ export interface Evolution { item: string | null heldItem: string | null condition: string | null + region: string | null } export interface CreateRunInput { diff --git a/tools/fetch-pokeapi/evolutions.go b/tools/fetch-pokeapi/evolutions.go index 0210109..39b0087 100644 --- a/tools/fetch-pokeapi/evolutions.go +++ b/tools/fetch-pokeapi/evolutions.go @@ -165,6 +165,7 @@ type EvolutionOverrides struct { Item *string `json:"item"` HeldItem *string `json:"held_item"` Condition *string `json:"condition"` + Region *string `json:"region"` } `json:"add"` Modify []struct { FromDex int `json:"from_dex"` @@ -213,6 +214,7 @@ func applyEvolutionOverrides(evolutions []EvolutionOutput, overridesPath string) Item: addition.Item, HeldItem: addition.HeldItem, Condition: addition.Condition, + Region: addition.Region, }) } @@ -252,6 +254,12 @@ func applyEvolutionOverrides(evolutions []EvolutionOutput, overridesPath string) } else if value == nil { e.Condition = nil } + case "region": + if s, ok := value.(string); ok { + e.Region = &s + } else if value == nil { + e.Region = nil + } } } } diff --git a/tools/fetch-pokeapi/models.go b/tools/fetch-pokeapi/models.go index 714c048..9ba5ddf 100644 --- a/tools/fetch-pokeapi/models.go +++ b/tools/fetch-pokeapi/models.go @@ -27,6 +27,7 @@ type EvolutionOutput struct { Item *string `json:"item"` HeldItem *string `json:"held_item"` Condition *string `json:"condition"` + Region *string `json:"region"` } type RouteOutput struct {