Add pokemon evolution support across the full stack

- Evolution model with trigger, level, item, and condition fields
- Encounter.current_pokemon_id tracks evolved species separately
- Alembic migration for evolutions table and current_pokemon_id column
- Seed pipeline loads evolution data with manual overrides
- GET /pokemon/{id}/evolutions and PATCH /encounters/{id} endpoints
- Evolve button in StatusChangeModal with evolution method details
- PokemonCard shows evolved species with "Originally" label

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 19:26:49 +01:00
parent c8d8e4b445
commit 9728773a94
19 changed files with 1077 additions and 38 deletions

View File

@@ -0,0 +1,42 @@
"""add evolutions table and current_pokemon_id to encounters
Revision ID: b2c3d4e5f6a7
Revises: a1b2c3d4e5f6
Create Date: 2026-02-05 18:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b2c3d4e5f6a7'
down_revision: Union[str, Sequence[str], None] = 'a1b2c3d4e5f6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'evolutions',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('from_pokemon_id', sa.Integer(), sa.ForeignKey('pokemon.id'), nullable=False, index=True),
sa.Column('to_pokemon_id', sa.Integer(), sa.ForeignKey('pokemon.id'), nullable=False, index=True),
sa.Column('trigger', sa.String(30), nullable=False),
sa.Column('min_level', sa.SmallInteger(), nullable=True),
sa.Column('item', sa.String(50), nullable=True),
sa.Column('held_item', sa.String(50), nullable=True),
sa.Column('condition', sa.String(200), nullable=True),
)
op.add_column(
'encounters',
sa.Column('current_pokemon_id', sa.Integer(), sa.ForeignKey('pokemon.id'), nullable=True, index=True),
)
def downgrade() -> None:
op.drop_column('encounters', 'current_pokemon_id')
op.drop_table('evolutions')

View File

@@ -1,12 +1,19 @@
from fastapi import APIRouter, Depends, HTTPException, Response from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from app.core.database import get_session from app.core.database import get_session
from app.models.encounter import Encounter from app.models.encounter import Encounter
from app.models.nuzlocke_run import NuzlockeRun from app.models.nuzlocke_run import NuzlockeRun
from app.models.pokemon import Pokemon from app.models.pokemon import Pokemon
from app.models.route import Route from app.models.route import Route
from app.schemas.encounter import EncounterCreate, EncounterResponse, EncounterUpdate from app.schemas.encounter import (
EncounterCreate,
EncounterDetailResponse,
EncounterResponse,
EncounterUpdate,
)
router = APIRouter() router = APIRouter()
@@ -50,7 +57,7 @@ async def create_encounter(
return encounter return encounter
@router.patch("/encounters/{encounter_id}", response_model=EncounterResponse) @router.patch("/encounters/{encounter_id}", response_model=EncounterDetailResponse)
async def update_encounter( async def update_encounter(
encounter_id: int, encounter_id: int,
data: EncounterUpdate, data: EncounterUpdate,
@@ -65,8 +72,18 @@ async def update_encounter(
setattr(encounter, field, value) setattr(encounter, field, value)
await session.commit() await session.commit()
await session.refresh(encounter)
return encounter # Reload with relationships for detail response
result = await session.execute(
select(Encounter)
.where(Encounter.id == encounter_id)
.options(
joinedload(Encounter.pokemon),
joinedload(Encounter.current_pokemon),
joinedload(Encounter.route),
)
)
return result.scalar_one()
@router.delete("/encounters/{encounter_id}", status_code=204) @router.delete("/encounters/{encounter_id}", status_code=204)

View File

@@ -4,12 +4,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.orm import joinedload, selectinload
from app.core.database import get_session from app.core.database import get_session
from app.models.evolution import Evolution
from app.models.pokemon import Pokemon from app.models.pokemon import Pokemon
from app.models.route import Route from app.models.route import Route
from app.models.route_encounter import RouteEncounter from app.models.route_encounter import RouteEncounter
from app.schemas.pokemon import ( from app.schemas.pokemon import (
BulkImportItem, BulkImportItem,
BulkImportResult, BulkImportResult,
EvolutionResponse,
PokemonCreate, PokemonCreate,
PokemonResponse, PokemonResponse,
PokemonUpdate, PokemonUpdate,
@@ -101,6 +103,22 @@ async def get_pokemon(
return pokemon return 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 = await session.get(Pokemon, pokemon_id)
if pokemon is None:
raise HTTPException(status_code=404, detail="Pokemon not found")
result = await session.execute(
select(Evolution)
.where(Evolution.from_pokemon_id == pokemon_id)
.options(joinedload(Evolution.to_pokemon))
)
return result.scalars().unique().all()
@router.put("/pokemon/{pokemon_id}", response_model=PokemonResponse) @router.put("/pokemon/{pokemon_id}", response_model=PokemonResponse)
async def update_pokemon( async def update_pokemon(
pokemon_id: int, pokemon_id: int,

View File

@@ -51,6 +51,8 @@ async def get_run(run_id: int, session: AsyncSession = Depends(get_session)):
selectinload(NuzlockeRun.encounters) selectinload(NuzlockeRun.encounters)
.joinedload(Encounter.pokemon), .joinedload(Encounter.pokemon),
selectinload(NuzlockeRun.encounters) selectinload(NuzlockeRun.encounters)
.joinedload(Encounter.current_pokemon),
selectinload(NuzlockeRun.encounters)
.joinedload(Encounter.route), .joinedload(Encounter.route),
) )
) )

View File

@@ -1,4 +1,5 @@
from app.models.encounter import Encounter from app.models.encounter import Encounter
from app.models.evolution import Evolution
from app.models.game import Game from app.models.game import Game
from app.models.nuzlocke_run import NuzlockeRun from app.models.nuzlocke_run import NuzlockeRun
from app.models.pokemon import Pokemon from app.models.pokemon import Pokemon
@@ -7,6 +8,7 @@ from app.models.route_encounter import RouteEncounter
__all__ = [ __all__ = [
"Encounter", "Encounter",
"Evolution",
"Game", "Game",
"NuzlockeRun", "NuzlockeRun",
"Pokemon", "Pokemon",

View File

@@ -18,13 +18,21 @@ class Encounter(Base):
catch_level: Mapped[int | None] = mapped_column(SmallInteger) catch_level: Mapped[int | None] = mapped_column(SmallInteger)
faint_level: Mapped[int | None] = mapped_column(SmallInteger) faint_level: Mapped[int | None] = mapped_column(SmallInteger)
death_cause: Mapped[str | None] = mapped_column(String(100)) death_cause: Mapped[str | None] = mapped_column(String(100))
current_pokemon_id: Mapped[int | None] = mapped_column(
ForeignKey("pokemon.id"), index=True
)
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()
) )
run: Mapped["NuzlockeRun"] = relationship(back_populates="encounters") run: Mapped["NuzlockeRun"] = relationship(back_populates="encounters")
route: Mapped["Route"] = relationship(back_populates="encounters") route: Mapped["Route"] = relationship(back_populates="encounters")
pokemon: Mapped["Pokemon"] = relationship(back_populates="encounters") pokemon: Mapped["Pokemon"] = relationship(
foreign_keys=[pokemon_id], back_populates="encounters"
)
current_pokemon: Mapped["Pokemon | None"] = relationship(
foreign_keys=[current_pokemon_id]
)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Encounter(id={self.id}, pokemon_id={self.pokemon_id}, status='{self.status}')>" return f"<Encounter(id={self.id}, pokemon_id={self.pokemon_id}, status='{self.status}')>"

View File

@@ -0,0 +1,23 @@
from sqlalchemy import ForeignKey, SmallInteger, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class Evolution(Base):
__tablename__ = "evolutions"
id: Mapped[int] = mapped_column(primary_key=True)
from_pokemon_id: Mapped[int] = mapped_column(ForeignKey("pokemon.id"), index=True)
to_pokemon_id: Mapped[int] = mapped_column(ForeignKey("pokemon.id"), index=True)
trigger: Mapped[str] = mapped_column(String(30)) # level-up, trade, use-item, etc.
min_level: Mapped[int | None] = mapped_column(SmallInteger)
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
from_pokemon: Mapped["Pokemon"] = relationship(foreign_keys=[from_pokemon_id])
to_pokemon: Mapped["Pokemon"] = relationship(foreign_keys=[to_pokemon_id])
def __repr__(self) -> str:
return f"<Evolution(id={self.id}, from={self.from_pokemon_id}, to={self.to_pokemon_id}, trigger='{self.trigger}')>"

View File

@@ -17,6 +17,7 @@ from app.schemas.game import (
from app.schemas.pokemon import ( from app.schemas.pokemon import (
BulkImportItem, BulkImportItem,
BulkImportResult, BulkImportResult,
EvolutionResponse,
PokemonCreate, PokemonCreate,
PokemonResponse, PokemonResponse,
PokemonUpdate, PokemonUpdate,
@@ -34,6 +35,7 @@ __all__ = [
"EncounterDetailResponse", "EncounterDetailResponse",
"EncounterResponse", "EncounterResponse",
"EncounterUpdate", "EncounterUpdate",
"EvolutionResponse",
"GameCreate", "GameCreate",
"GameDetailResponse", "GameDetailResponse",
"GameResponse", "GameResponse",

View File

@@ -18,6 +18,7 @@ class EncounterUpdate(CamelModel):
status: str | None = None status: str | None = None
faint_level: int | None = None faint_level: int | None = None
death_cause: str | None = None death_cause: str | None = None
current_pokemon_id: int | None = None
class EncounterResponse(CamelModel): class EncounterResponse(CamelModel):
@@ -25,6 +26,7 @@ class EncounterResponse(CamelModel):
run_id: int run_id: int
route_id: int route_id: int
pokemon_id: int pokemon_id: int
current_pokemon_id: int | None
nickname: str | None nickname: str | None
status: str status: str
catch_level: int | None catch_level: int | None
@@ -35,4 +37,5 @@ class EncounterResponse(CamelModel):
class EncounterDetailResponse(EncounterResponse): class EncounterDetailResponse(EncounterResponse):
pokemon: PokemonResponse pokemon: PokemonResponse
current_pokemon: PokemonResponse | None
route: RouteResponse route: RouteResponse

View File

@@ -11,6 +11,17 @@ class PokemonResponse(CamelModel):
sprite_url: str | None sprite_url: str | None
class EvolutionResponse(CamelModel):
id: int
from_pokemon_id: int
to_pokemon: PokemonResponse
trigger: str
min_level: int | None
item: str | None
held_item: str | None
condition: str | None
class RouteEncounterResponse(CamelModel): class RouteEncounterResponse(CamelModel):
id: int id: int
route_id: int route_id: int

View File

@@ -0,0 +1,5 @@
{
"remove": [],
"add": [],
"modify": []
}

View File

@@ -0,0 +1,722 @@
[
{
"from_national_dex": 10,
"to_national_dex": 11,
"trigger": "level-up",
"min_level": 7,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 11,
"to_national_dex": 12,
"trigger": "level-up",
"min_level": 10,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 13,
"to_national_dex": 14,
"trigger": "level-up",
"min_level": 7,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 14,
"to_national_dex": 15,
"trigger": "level-up",
"min_level": 10,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 16,
"to_national_dex": 17,
"trigger": "level-up",
"min_level": 18,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 19,
"to_national_dex": 20,
"trigger": "level-up",
"min_level": 20,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 21,
"to_national_dex": 22,
"trigger": "level-up",
"min_level": 20,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 23,
"to_national_dex": 24,
"trigger": "level-up",
"min_level": 22,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 27,
"to_national_dex": 28,
"trigger": "level-up",
"min_level": 22,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 27,
"to_national_dex": 28,
"trigger": "use-item",
"min_level": null,
"item": "ice-stone",
"held_item": null,
"condition": null
},
{
"from_national_dex": 29,
"to_national_dex": 30,
"trigger": "level-up",
"min_level": 16,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 32,
"to_national_dex": 33,
"trigger": "level-up",
"min_level": 16,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 41,
"to_national_dex": 42,
"trigger": "level-up",
"min_level": 22,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 43,
"to_national_dex": 44,
"trigger": "level-up",
"min_level": 21,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 46,
"to_national_dex": 47,
"trigger": "level-up",
"min_level": 24,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 48,
"to_national_dex": 49,
"trigger": "level-up",
"min_level": 31,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 50,
"to_national_dex": 51,
"trigger": "level-up",
"min_level": 26,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 52,
"to_national_dex": 53,
"trigger": "level-up",
"min_level": 28,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 54,
"to_national_dex": 55,
"trigger": "level-up",
"min_level": 33,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 56,
"to_national_dex": 57,
"trigger": "level-up",
"min_level": 28,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 60,
"to_national_dex": 61,
"trigger": "level-up",
"min_level": 25,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 63,
"to_national_dex": 64,
"trigger": "level-up",
"min_level": 16,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 66,
"to_national_dex": 67,
"trigger": "level-up",
"min_level": 28,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 69,
"to_national_dex": 70,
"trigger": "level-up",
"min_level": 21,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 72,
"to_national_dex": 73,
"trigger": "level-up",
"min_level": 30,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 74,
"to_national_dex": 75,
"trigger": "level-up",
"min_level": 25,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 75,
"to_national_dex": 76,
"trigger": "trade",
"min_level": null,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 77,
"to_national_dex": 78,
"trigger": "level-up",
"min_level": 40,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 79,
"to_national_dex": 80,
"trigger": "level-up",
"min_level": 37,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 79,
"to_national_dex": 80,
"trigger": "use-item",
"min_level": null,
"item": "galarica-cuff",
"held_item": null,
"condition": null
},
{
"from_national_dex": 81,
"to_national_dex": 82,
"trigger": "level-up",
"min_level": 30,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 84,
"to_national_dex": 85,
"trigger": "level-up",
"min_level": 31,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 86,
"to_national_dex": 87,
"trigger": "level-up",
"min_level": 34,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 88,
"to_national_dex": 89,
"trigger": "level-up",
"min_level": 38,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 92,
"to_national_dex": 93,
"trigger": "level-up",
"min_level": 25,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 95,
"to_national_dex": 208,
"trigger": "trade",
"min_level": null,
"item": null,
"held_item": "metal-coat",
"condition": null
},
{
"from_national_dex": 96,
"to_national_dex": 97,
"trigger": "level-up",
"min_level": 26,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 98,
"to_national_dex": 99,
"trigger": "level-up",
"min_level": 28,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 100,
"to_national_dex": 101,
"trigger": "level-up",
"min_level": 30,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 104,
"to_national_dex": 105,
"trigger": "level-up",
"min_level": 28,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 109,
"to_national_dex": 110,
"trigger": "level-up",
"min_level": 35,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 116,
"to_national_dex": 117,
"trigger": "level-up",
"min_level": 32,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 118,
"to_national_dex": 119,
"trigger": "level-up",
"min_level": 33,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 129,
"to_national_dex": 130,
"trigger": "level-up",
"min_level": 20,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 147,
"to_national_dex": 148,
"trigger": "level-up",
"min_level": 30,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 161,
"to_national_dex": 162,
"trigger": "level-up",
"min_level": 15,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 163,
"to_national_dex": 164,
"trigger": "level-up",
"min_level": 20,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 165,
"to_national_dex": 166,
"trigger": "level-up",
"min_level": 18,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 167,
"to_national_dex": 168,
"trigger": "level-up",
"min_level": 22,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 170,
"to_national_dex": 171,
"trigger": "level-up",
"min_level": 27,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 177,
"to_national_dex": 178,
"trigger": "level-up",
"min_level": 25,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 179,
"to_national_dex": 180,
"trigger": "level-up",
"min_level": 15,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 187,
"to_national_dex": 188,
"trigger": "level-up",
"min_level": 18,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 194,
"to_national_dex": 195,
"trigger": "level-up",
"min_level": 20,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 216,
"to_national_dex": 217,
"trigger": "level-up",
"min_level": 30,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 218,
"to_national_dex": 219,
"trigger": "level-up",
"min_level": 38,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 223,
"to_national_dex": 224,
"trigger": "level-up",
"min_level": 25,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 231,
"to_national_dex": 232,
"trigger": "level-up",
"min_level": 25,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 246,
"to_national_dex": 247,
"trigger": "level-up",
"min_level": 30,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 261,
"to_national_dex": 262,
"trigger": "level-up",
"min_level": 18,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 263,
"to_national_dex": 264,
"trigger": "level-up",
"min_level": 20,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 265,
"to_national_dex": 266,
"trigger": "level-up",
"min_level": 7,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 265,
"to_national_dex": 268,
"trigger": "level-up",
"min_level": 7,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 270,
"to_national_dex": 271,
"trigger": "level-up",
"min_level": 14,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 273,
"to_national_dex": 274,
"trigger": "level-up",
"min_level": 14,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 276,
"to_national_dex": 277,
"trigger": "level-up",
"min_level": 22,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 278,
"to_national_dex": 279,
"trigger": "level-up",
"min_level": 25,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 293,
"to_national_dex": 294,
"trigger": "level-up",
"min_level": 20,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 296,
"to_national_dex": 297,
"trigger": "level-up",
"min_level": 24,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 304,
"to_national_dex": 305,
"trigger": "level-up",
"min_level": 32,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 309,
"to_national_dex": 310,
"trigger": "level-up",
"min_level": 26,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 318,
"to_national_dex": 319,
"trigger": "level-up",
"min_level": 30,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 320,
"to_national_dex": 321,
"trigger": "level-up",
"min_level": 40,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 333,
"to_national_dex": 334,
"trigger": "level-up",
"min_level": 35,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 339,
"to_national_dex": 340,
"trigger": "level-up",
"min_level": 30,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 343,
"to_national_dex": 344,
"trigger": "level-up",
"min_level": 36,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 353,
"to_national_dex": 354,
"trigger": "level-up",
"min_level": 37,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 360,
"to_national_dex": 202,
"trigger": "level-up",
"min_level": 15,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 363,
"to_national_dex": 364,
"trigger": "level-up",
"min_level": 32,
"item": null,
"held_item": null,
"condition": null
},
{
"from_national_dex": 433,
"to_national_dex": 358,
"trigger": "level-up",
"min_level": null,
"item": null,
"held_item": null,
"condition": "happiness >= 220, night"
}
]

View File

@@ -4,6 +4,7 @@ from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.evolution import Evolution
from app.models.game import Game from app.models.game import Game
from app.models.pokemon import Pokemon from app.models.pokemon import Pokemon
from app.models.route import Route from app.models.route import Route
@@ -118,3 +119,36 @@ async def upsert_route_encounters(
count += 1 count += 1
return count return count
async def upsert_evolutions(
session: AsyncSession,
evolutions: list[dict],
dex_to_id: dict[int, int],
) -> int:
"""Upsert evolution pairs, return count of upserted rows."""
# Clear existing evolutions and re-insert (simpler than complex upsert)
from sqlalchemy import delete
await session.execute(delete(Evolution))
count = 0
for evo in evolutions:
from_id = dex_to_id.get(evo["from_national_dex"])
to_id = dex_to_id.get(evo["to_national_dex"])
if from_id is None or to_id is None:
continue
evolution = Evolution(
from_pokemon_id=from_id,
to_pokemon_id=to_id,
trigger=evo["trigger"],
min_level=evo.get("min_level"),
item=evo.get("item"),
held_item=evo.get("held_item"),
condition=evo.get("condition"),
)
session.add(evolution)
count += 1
await session.flush()
return count

View File

@@ -12,6 +12,7 @@ from app.models.pokemon import Pokemon
from app.models.route import Route from app.models.route import Route
from app.models.route_encounter import RouteEncounter from app.models.route_encounter import RouteEncounter
from app.seeds.loader import ( from app.seeds.loader import (
upsert_evolutions,
upsert_games, upsert_games,
upsert_pokemon, upsert_pokemon,
upsert_route_encounters, upsert_route_encounters,
@@ -75,6 +76,15 @@ async def seed():
print(f"\nTotal routes: {total_routes}") print(f"\nTotal routes: {total_routes}")
print(f"Total encounters: {total_encounters}") print(f"Total encounters: {total_encounters}")
# 4. Upsert evolutions
evolutions_path = DATA_DIR / "evolutions.json"
if evolutions_path.exists():
evolutions_data = load_json("evolutions.json")
evo_count = await upsert_evolutions(session, evolutions_data, dex_to_id)
print(f"Evolutions: {evo_count} upserted")
else:
print("No evolutions.json found, skipping evolutions")
print("Seed complete!") print("Seed complete!")

View File

@@ -1,24 +1,29 @@
import { api } from './client' import { api } from './client'
import type { import type {
Encounter, EncounterDetail,
CreateEncounterInput, CreateEncounterInput,
UpdateEncounterInput, UpdateEncounterInput,
Evolution,
} from '../types/game' } from '../types/game'
export function createEncounter( export function createEncounter(
runId: number, runId: number,
data: CreateEncounterInput, data: CreateEncounterInput,
): Promise<Encounter> { ): Promise<EncounterDetail> {
return api.post(`/runs/${runId}/encounters`, data) return api.post(`/runs/${runId}/encounters`, data)
} }
export function updateEncounter( export function updateEncounter(
id: number, id: number,
data: UpdateEncounterInput, data: UpdateEncounterInput,
): Promise<Encounter> { ): Promise<EncounterDetail> {
return api.patch(`/encounters/${id}`, data) return api.patch(`/encounters/${id}`, data)
} }
export function deleteEncounter(id: number): Promise<void> { export function deleteEncounter(id: number): Promise<void> {
return api.del(`/encounters/${id}`) return api.del(`/encounters/${id}`)
} }
export function fetchEvolutions(pokemonId: number): Promise<Evolution[]> {
return api.get(`/pokemon/${pokemonId}/evolutions`)
}

View File

@@ -28,8 +28,10 @@ const typeColors: Record<string, string> = {
} }
export function PokemonCard({ encounter, showFaintLevel, onClick }: PokemonCardProps) { export function PokemonCard({ encounter, showFaintLevel, onClick }: PokemonCardProps) {
const { pokemon, route, nickname, catchLevel, faintLevel, deathCause } = encounter const { pokemon, currentPokemon, route, nickname, catchLevel, faintLevel, deathCause } = encounter
const isDead = faintLevel !== null const isDead = faintLevel !== null
const displayPokemon = currentPokemon ?? pokemon
const isEvolved = currentPokemon !== null
return ( return (
<div <div
@@ -38,15 +40,15 @@ export function PokemonCard({ encounter, showFaintLevel, onClick }: PokemonCardP
isDead ? 'opacity-60 grayscale' : '' isDead ? 'opacity-60 grayscale' : ''
} ${onClick ? 'cursor-pointer hover:ring-2 hover:ring-blue-400 transition-shadow' : ''}`} } ${onClick ? 'cursor-pointer hover:ring-2 hover:ring-blue-400 transition-shadow' : ''}`}
> >
{pokemon.spriteUrl ? ( {displayPokemon.spriteUrl ? (
<img <img
src={pokemon.spriteUrl} src={displayPokemon.spriteUrl}
alt={pokemon.name} alt={displayPokemon.name}
className="w-16 h-16" className="w-16 h-16"
/> />
) : ( ) : (
<div className="w-16 h-16 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300"> <div className="w-16 h-16 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
{pokemon.name[0].toUpperCase()} {displayPokemon.name[0].toUpperCase()}
</div> </div>
)} )}
@@ -55,17 +57,17 @@ export function PokemonCard({ encounter, showFaintLevel, onClick }: PokemonCardP
className={`w-2 h-2 rounded-full shrink-0 ${isDead ? 'bg-red-500' : 'bg-green-500'}`} className={`w-2 h-2 rounded-full shrink-0 ${isDead ? 'bg-red-500' : 'bg-green-500'}`}
/> />
<span className="font-semibold text-gray-900 dark:text-gray-100 text-sm"> <span className="font-semibold text-gray-900 dark:text-gray-100 text-sm">
{nickname || pokemon.name} {nickname || displayPokemon.name}
</span> </span>
</div> </div>
{nickname && ( {nickname && (
<div className="text-xs text-gray-500 dark:text-gray-400"> <div className="text-xs text-gray-500 dark:text-gray-400">
{pokemon.name} {displayPokemon.name}
</div> </div>
)} )}
<div className="flex gap-1 mt-1"> <div className="flex gap-1 mt-1">
{pokemon.types.map((type) => ( {displayPokemon.types.map((type) => (
<span <span
key={type} key={type}
className={`px-1.5 py-0.5 rounded text-[10px] font-medium text-white ${typeColors[type] ?? 'bg-gray-500'}`} className={`px-1.5 py-0.5 rounded text-[10px] font-medium text-white ${typeColors[type] ?? 'bg-gray-500'}`}
@@ -85,6 +87,12 @@ export function PokemonCard({ encounter, showFaintLevel, onClick }: PokemonCardP
{route.name} {route.name}
</div> </div>
{isEvolved && (
<div className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">
Originally: {pokemon.name}
</div>
)}
{isDead && deathCause && ( {isDead && deathCause && (
<div className="text-[10px] italic text-gray-400 dark:text-gray-500 mt-0.5 line-clamp-2"> <div className="text-[10px] italic text-gray-400 dark:text-gray-500 mt-0.5 line-clamp-2">
{deathCause} {deathCause}

View File

@@ -1,11 +1,12 @@
import { useState } from 'react' import { useState } from 'react'
import type { EncounterDetail } from '../types' import type { EncounterDetail, UpdateEncounterInput } from '../types'
import { useEvolutions } from '../hooks/useEncounters'
interface StatusChangeModalProps { interface StatusChangeModalProps {
encounter: EncounterDetail encounter: EncounterDetail
onUpdate: (data: { onUpdate: (data: {
id: number id: number
data: { faintLevel?: number; deathCause?: string } data: UpdateEncounterInput
}) => void }) => void
onClose: () => void onClose: () => void
isPending: boolean isPending: boolean
@@ -32,19 +33,48 @@ const typeColors: Record<string, string> = {
fairy: 'bg-pink-300', fairy: 'bg-pink-300',
} }
function formatEvolutionMethod(evo: { trigger: string; minLevel: number | null; item: string | null; heldItem: string | null; condition: string | null }): string {
const parts: string[] = []
if (evo.trigger === 'level-up' && evo.minLevel) {
parts.push(`Level ${evo.minLevel}`)
} else if (evo.trigger === 'level-up') {
parts.push('Level up')
} else if (evo.trigger === 'use-item' && evo.item) {
parts.push(evo.item.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()))
} else if (evo.trigger === 'trade') {
parts.push('Trade')
} else {
parts.push(evo.trigger.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()))
}
if (evo.heldItem) {
parts.push(`holding ${evo.heldItem.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}`)
}
if (evo.condition) {
parts.push(evo.condition)
}
return parts.join(', ')
}
export function StatusChangeModal({ export function StatusChangeModal({
encounter, encounter,
onUpdate, onUpdate,
onClose, onClose,
isPending, isPending,
}: StatusChangeModalProps) { }: StatusChangeModalProps) {
const { pokemon, route, nickname, catchLevel, faintLevel, deathCause } = const { pokemon, currentPokemon, route, nickname, catchLevel, faintLevel, deathCause } =
encounter encounter
const isDead = faintLevel !== null const isDead = faintLevel !== null
const displayPokemon = currentPokemon ?? pokemon
const [showConfirm, setShowConfirm] = useState(false) const [showConfirm, setShowConfirm] = useState(false)
const [showEvolve, setShowEvolve] = useState(false)
const [deathLevel, setDeathLevel] = useState('') const [deathLevel, setDeathLevel] = useState('')
const [cause, setCause] = useState('') const [cause, setCause] = useState('')
const activePokemonId = currentPokemon?.id ?? pokemon.id
const { data: evolutions, isLoading: evolutionsLoading } = useEvolutions(
showEvolve ? activePokemonId : null
)
const handleConfirmDeath = () => { const handleConfirmDeath = () => {
onUpdate({ onUpdate({
id: encounter.id, id: encounter.id,
@@ -55,6 +85,13 @@ export function StatusChangeModal({
}) })
} }
const handleEvolve = (toPokemonId: number) => {
onUpdate({
id: encounter.id,
data: { currentPokemonId: toPokemonId },
})
}
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <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="fixed inset-0 bg-black/50" onClick={onClose} />
@@ -87,28 +124,28 @@ export function StatusChangeModal({
<div className="px-6 py-4"> <div className="px-6 py-4">
{/* Pokemon info */} {/* Pokemon info */}
<div className="flex items-center gap-4 mb-4"> <div className="flex items-center gap-4 mb-4">
{pokemon.spriteUrl ? ( {displayPokemon.spriteUrl ? (
<img <img
src={pokemon.spriteUrl} src={displayPokemon.spriteUrl}
alt={pokemon.name} alt={displayPokemon.name}
className={`w-16 h-16 ${isDead ? 'grayscale opacity-60' : ''}`} className={`w-16 h-16 ${isDead ? 'grayscale opacity-60' : ''}`}
/> />
) : ( ) : (
<div className="w-16 h-16 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300"> <div className="w-16 h-16 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
{pokemon.name[0].toUpperCase()} {displayPokemon.name[0].toUpperCase()}
</div> </div>
)} )}
<div> <div>
<div className="font-semibold text-gray-900 dark:text-gray-100"> <div className="font-semibold text-gray-900 dark:text-gray-100">
{nickname || pokemon.name} {nickname || displayPokemon.name}
</div> </div>
{nickname && ( {nickname && (
<div className="text-xs text-gray-500 dark:text-gray-400 capitalize"> <div className="text-xs text-gray-500 dark:text-gray-400 capitalize">
{pokemon.name} {displayPokemon.name}
</div> </div>
)} )}
<div className="flex gap-1 mt-1"> <div className="flex gap-1 mt-1">
{pokemon.types.map((type) => ( {displayPokemon.types.map((type) => (
<span <span
key={type} key={type}
className={`px-1.5 py-0.5 rounded text-[10px] font-medium text-white ${typeColors[type] ?? 'bg-gray-500'}`} className={`px-1.5 py-0.5 rounded text-[10px] font-medium text-white ${typeColors[type] ?? 'bg-gray-500'}`}
@@ -120,6 +157,11 @@ export function StatusChangeModal({
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1"> <div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Lv. {catchLevel ?? '?'} &middot; {route.name} Lv. {catchLevel ?? '?'} &middot; {route.name}
</div> </div>
{currentPokemon && (
<div className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">
Originally: {pokemon.name}
</div>
)}
</div> </div>
</div> </div>
@@ -149,15 +191,77 @@ export function StatusChangeModal({
</div> </div>
)} )}
{/* Alive pokemon: mark as dead */} {/* Alive pokemon: actions */}
{!isDead && !showConfirm && ( {!isDead && !showConfirm && !showEvolve && (
<div className="flex gap-3">
<button
type="button"
onClick={() => setShowEvolve(true)}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
>
Evolve
</button>
<button <button
type="button" type="button"
onClick={() => setShowConfirm(true)} onClick={() => setShowConfirm(true)}
className="w-full px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors" className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
> >
Mark as Dead Mark as Dead
</button> </button>
</div>
)}
{/* Evolution selection */}
{!isDead && showEvolve && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Evolve into:
</h3>
<button
type="button"
onClick={() => setShowEvolve(false)}
className="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
Back
</button>
</div>
{evolutionsLoading && (
<p className="text-sm text-gray-500 dark:text-gray-400">Loading evolutions...</p>
)}
{!evolutionsLoading && evolutions && evolutions.length === 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400">No evolutions available</p>
)}
{!evolutionsLoading && evolutions && evolutions.length > 0 && (
<div className="space-y-2">
{evolutions.map((evo) => (
<button
key={evo.id}
type="button"
disabled={isPending}
onClick={() => handleEvolve(evo.toPokemon.id)}
className="w-full flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:border-blue-300 dark:hover:border-blue-600 transition-colors disabled:opacity-50"
>
{evo.toPokemon.spriteUrl ? (
<img src={evo.toPokemon.spriteUrl} alt={evo.toPokemon.name} className="w-10 h-10" />
) : (
<div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-sm font-bold text-gray-600 dark:text-gray-300">
{evo.toPokemon.name[0].toUpperCase()}
</div>
)}
<div className="text-left">
<div className="font-medium text-gray-900 dark:text-gray-100 text-sm">
{evo.toPokemon.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{formatEvolutionMethod(evo)}
</div>
</div>
</button>
))}
</div>
)}
</div>
)} )}
{/* Confirmation form */} {/* Confirmation form */}
@@ -229,8 +333,8 @@ export function StatusChangeModal({
)} )}
</div> </div>
{/* Footer for dead/no-confirm views */} {/* Footer for dead/no-confirm/no-evolve views */}
{(isDead || (!isDead && !showConfirm)) && ( {(isDead || (!isDead && !showConfirm && !showEvolve)) && (
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end"> <div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end">
<button <button
type="button" type="button"

View File

@@ -1,8 +1,9 @@
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { import {
createEncounter, createEncounter,
updateEncounter, updateEncounter,
deleteEncounter, deleteEncounter,
fetchEvolutions,
} from '../api/encounters' } from '../api/encounters'
import type { CreateEncounterInput, UpdateEncounterInput } from '../types/game' import type { CreateEncounterInput, UpdateEncounterInput } from '../types/game'
@@ -41,3 +42,11 @@ export function useDeleteEncounter(runId: number) {
}, },
}) })
} }
export function useEvolutions(pokemonId: number | null) {
return useQuery({
queryKey: ['evolutions', pokemonId],
queryFn: () => fetchEvolutions(pokemonId!),
enabled: pokemonId !== null,
})
}

View File

@@ -44,6 +44,7 @@ export interface Encounter {
runId: number runId: number
routeId: number routeId: number
pokemonId: number pokemonId: number
currentPokemonId: number | null
nickname: string | null nickname: string | null
status: EncounterStatus status: EncounterStatus
catchLevel: number | null catchLevel: number | null
@@ -71,9 +72,21 @@ export interface RunDetail extends NuzlockeRun {
export interface EncounterDetail extends Encounter { export interface EncounterDetail extends Encounter {
pokemon: Pokemon pokemon: Pokemon
currentPokemon: Pokemon | null
route: Route route: Route
} }
export interface Evolution {
id: number
fromPokemonId: number
toPokemon: Pokemon
trigger: string
minLevel: number | null
item: string | null
heldItem: string | null
condition: string | null
}
export interface CreateRunInput { export interface CreateRunInput {
gameId: number gameId: number
name: string name: string
@@ -99,6 +112,7 @@ export interface UpdateEncounterInput {
status?: EncounterStatus status?: EncounterStatus
faintLevel?: number faintLevel?: number
deathCause?: string deathCause?: string
currentPokemonId?: number
} }
// Re-export for convenience // Re-export for convenience