From 9728773a9461bb0a0e18799130a6068b2c1db469 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Thu, 5 Feb 2026 19:26:49 +0100 Subject: [PATCH] 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 --- ...f6a7_add_evolutions_and_current_pokemon.py | 42 + backend/src/app/api/encounters.py | 25 +- backend/src/app/api/pokemon.py | 18 + backend/src/app/api/runs.py | 2 + backend/src/app/models/__init__.py | 2 + backend/src/app/models/encounter.py | 10 +- backend/src/app/models/evolution.py | 23 + backend/src/app/schemas/__init__.py | 2 + backend/src/app/schemas/encounter.py | 3 + backend/src/app/schemas/pokemon.py | 11 + .../app/seeds/data/evolution_overrides.json | 5 + backend/src/app/seeds/data/evolutions.json | 722 ++++++++++++++++++ backend/src/app/seeds/loader.py | 34 + backend/src/app/seeds/run.py | 10 + frontend/src/api/encounters.ts | 11 +- frontend/src/components/PokemonCard.tsx | 24 +- frontend/src/components/StatusChangeModal.tsx | 146 +++- frontend/src/hooks/useEncounters.ts | 11 +- frontend/src/types/game.ts | 14 + 19 files changed, 1077 insertions(+), 38 deletions(-) create mode 100644 backend/src/app/alembic/versions/b2c3d4e5f6a7_add_evolutions_and_current_pokemon.py create mode 100644 backend/src/app/models/evolution.py create mode 100644 backend/src/app/seeds/data/evolution_overrides.json create mode 100644 backend/src/app/seeds/data/evolutions.json diff --git a/backend/src/app/alembic/versions/b2c3d4e5f6a7_add_evolutions_and_current_pokemon.py b/backend/src/app/alembic/versions/b2c3d4e5f6a7_add_evolutions_and_current_pokemon.py new file mode 100644 index 0000000..6b45ec8 --- /dev/null +++ b/backend/src/app/alembic/versions/b2c3d4e5f6a7_add_evolutions_and_current_pokemon.py @@ -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') diff --git a/backend/src/app/api/encounters.py b/backend/src/app/api/encounters.py index 94f8229..4b292e7 100644 --- a/backend/src/app/api/encounters.py +++ b/backend/src/app/api/encounters.py @@ -1,12 +1,19 @@ from fastapi import APIRouter, Depends, HTTPException, Response +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload from app.core.database import get_session from app.models.encounter import Encounter from app.models.nuzlocke_run import NuzlockeRun from app.models.pokemon import Pokemon 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() @@ -50,7 +57,7 @@ async def create_encounter( return encounter -@router.patch("/encounters/{encounter_id}", response_model=EncounterResponse) +@router.patch("/encounters/{encounter_id}", response_model=EncounterDetailResponse) async def update_encounter( encounter_id: int, data: EncounterUpdate, @@ -65,8 +72,18 @@ async def update_encounter( setattr(encounter, field, value) 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) diff --git a/backend/src/app/api/pokemon.py b/backend/src/app/api/pokemon.py index 3143ecd..7114ab5 100644 --- a/backend/src/app/api/pokemon.py +++ b/backend/src/app/api/pokemon.py @@ -4,12 +4,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload, selectinload from app.core.database import get_session +from app.models.evolution import Evolution from app.models.pokemon import Pokemon from app.models.route import Route from app.models.route_encounter import RouteEncounter from app.schemas.pokemon import ( BulkImportItem, BulkImportResult, + EvolutionResponse, PokemonCreate, PokemonResponse, PokemonUpdate, @@ -101,6 +103,22 @@ async def get_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) async def update_pokemon( pokemon_id: int, diff --git a/backend/src/app/api/runs.py b/backend/src/app/api/runs.py index 0dcd5cc..fb78ec6 100644 --- a/backend/src/app/api/runs.py +++ b/backend/src/app/api/runs.py @@ -51,6 +51,8 @@ async def get_run(run_id: int, session: AsyncSession = Depends(get_session)): selectinload(NuzlockeRun.encounters) .joinedload(Encounter.pokemon), selectinload(NuzlockeRun.encounters) + .joinedload(Encounter.current_pokemon), + selectinload(NuzlockeRun.encounters) .joinedload(Encounter.route), ) ) diff --git a/backend/src/app/models/__init__.py b/backend/src/app/models/__init__.py index 4f0cdba..73c49dc 100644 --- a/backend/src/app/models/__init__.py +++ b/backend/src/app/models/__init__.py @@ -1,4 +1,5 @@ from app.models.encounter import Encounter +from app.models.evolution import Evolution from app.models.game import Game from app.models.nuzlocke_run import NuzlockeRun from app.models.pokemon import Pokemon @@ -7,6 +8,7 @@ from app.models.route_encounter import RouteEncounter __all__ = [ "Encounter", + "Evolution", "Game", "NuzlockeRun", "Pokemon", diff --git a/backend/src/app/models/encounter.py b/backend/src/app/models/encounter.py index cc8f1af..597ba10 100644 --- a/backend/src/app/models/encounter.py +++ b/backend/src/app/models/encounter.py @@ -18,13 +18,21 @@ class Encounter(Base): catch_level: Mapped[int | None] = mapped_column(SmallInteger) faint_level: Mapped[int | None] = mapped_column(SmallInteger) 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( DateTime(timezone=True), server_default=func.now() ) run: Mapped["NuzlockeRun"] = 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: return f"" diff --git a/backend/src/app/models/evolution.py b/backend/src/app/models/evolution.py new file mode 100644 index 0000000..82a7afd --- /dev/null +++ b/backend/src/app/models/evolution.py @@ -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"" diff --git a/backend/src/app/schemas/__init__.py b/backend/src/app/schemas/__init__.py index 4a6626f..2aecc76 100644 --- a/backend/src/app/schemas/__init__.py +++ b/backend/src/app/schemas/__init__.py @@ -17,6 +17,7 @@ from app.schemas.game import ( from app.schemas.pokemon import ( BulkImportItem, BulkImportResult, + EvolutionResponse, PokemonCreate, PokemonResponse, PokemonUpdate, @@ -34,6 +35,7 @@ __all__ = [ "EncounterDetailResponse", "EncounterResponse", "EncounterUpdate", + "EvolutionResponse", "GameCreate", "GameDetailResponse", "GameResponse", diff --git a/backend/src/app/schemas/encounter.py b/backend/src/app/schemas/encounter.py index 83d50b1..f7f8d54 100644 --- a/backend/src/app/schemas/encounter.py +++ b/backend/src/app/schemas/encounter.py @@ -18,6 +18,7 @@ class EncounterUpdate(CamelModel): status: str | None = None faint_level: int | None = None death_cause: str | None = None + current_pokemon_id: int | None = None class EncounterResponse(CamelModel): @@ -25,6 +26,7 @@ class EncounterResponse(CamelModel): run_id: int route_id: int pokemon_id: int + current_pokemon_id: int | None nickname: str | None status: str catch_level: int | None @@ -35,4 +37,5 @@ class EncounterResponse(CamelModel): class EncounterDetailResponse(EncounterResponse): pokemon: PokemonResponse + current_pokemon: PokemonResponse | None route: RouteResponse diff --git a/backend/src/app/schemas/pokemon.py b/backend/src/app/schemas/pokemon.py index 6c48c8b..15d3e0b 100644 --- a/backend/src/app/schemas/pokemon.py +++ b/backend/src/app/schemas/pokemon.py @@ -11,6 +11,17 @@ class PokemonResponse(CamelModel): 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): id: int route_id: int diff --git a/backend/src/app/seeds/data/evolution_overrides.json b/backend/src/app/seeds/data/evolution_overrides.json new file mode 100644 index 0000000..1638aa9 --- /dev/null +++ b/backend/src/app/seeds/data/evolution_overrides.json @@ -0,0 +1,5 @@ +{ + "remove": [], + "add": [], + "modify": [] +} diff --git a/backend/src/app/seeds/data/evolutions.json b/backend/src/app/seeds/data/evolutions.json new file mode 100644 index 0000000..60b6d36 --- /dev/null +++ b/backend/src/app/seeds/data/evolutions.json @@ -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" + } +] \ No newline at end of file diff --git a/backend/src/app/seeds/loader.py b/backend/src/app/seeds/loader.py index a9d0c5f..90fc166 100644 --- a/backend/src/app/seeds/loader.py +++ b/backend/src/app/seeds/loader.py @@ -4,6 +4,7 @@ from sqlalchemy import select from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession +from app.models.evolution import Evolution from app.models.game import Game from app.models.pokemon import Pokemon from app.models.route import Route @@ -118,3 +119,36 @@ async def upsert_route_encounters( count += 1 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 diff --git a/backend/src/app/seeds/run.py b/backend/src/app/seeds/run.py index 4a2dc5a..b58193e 100644 --- a/backend/src/app/seeds/run.py +++ b/backend/src/app/seeds/run.py @@ -12,6 +12,7 @@ from app.models.pokemon import Pokemon from app.models.route import Route from app.models.route_encounter import RouteEncounter from app.seeds.loader import ( + upsert_evolutions, upsert_games, upsert_pokemon, upsert_route_encounters, @@ -75,6 +76,15 @@ async def seed(): print(f"\nTotal routes: {total_routes}") 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!") diff --git a/frontend/src/api/encounters.ts b/frontend/src/api/encounters.ts index bc5c3c6..f153b62 100644 --- a/frontend/src/api/encounters.ts +++ b/frontend/src/api/encounters.ts @@ -1,24 +1,29 @@ import { api } from './client' import type { - Encounter, + EncounterDetail, CreateEncounterInput, UpdateEncounterInput, + Evolution, } from '../types/game' export function createEncounter( runId: number, data: CreateEncounterInput, -): Promise { +): Promise { return api.post(`/runs/${runId}/encounters`, data) } export function updateEncounter( id: number, data: UpdateEncounterInput, -): Promise { +): Promise { return api.patch(`/encounters/${id}`, data) } export function deleteEncounter(id: number): Promise { return api.del(`/encounters/${id}`) } + +export function fetchEvolutions(pokemonId: number): Promise { + return api.get(`/pokemon/${pokemonId}/evolutions`) +} diff --git a/frontend/src/components/PokemonCard.tsx b/frontend/src/components/PokemonCard.tsx index 14cc889..2171db4 100644 --- a/frontend/src/components/PokemonCard.tsx +++ b/frontend/src/components/PokemonCard.tsx @@ -28,8 +28,10 @@ const typeColors: Record = { } 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 displayPokemon = currentPokemon ?? pokemon + const isEvolved = currentPokemon !== null return (
- {pokemon.spriteUrl ? ( + {displayPokemon.spriteUrl ? ( {pokemon.name} ) : (
- {pokemon.name[0].toUpperCase()} + {displayPokemon.name[0].toUpperCase()}
)} @@ -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'}`} /> - {nickname || pokemon.name} + {nickname || displayPokemon.name}
{nickname && (
- {pokemon.name} + {displayPokemon.name}
)}
- {pokemon.types.map((type) => ( + {displayPokemon.types.map((type) => ( + {isEvolved && ( +
+ Originally: {pokemon.name} +
+ )} + {isDead && deathCause && (
{deathCause} diff --git a/frontend/src/components/StatusChangeModal.tsx b/frontend/src/components/StatusChangeModal.tsx index bfef675..7d3d98d 100644 --- a/frontend/src/components/StatusChangeModal.tsx +++ b/frontend/src/components/StatusChangeModal.tsx @@ -1,11 +1,12 @@ import { useState } from 'react' -import type { EncounterDetail } from '../types' +import type { EncounterDetail, UpdateEncounterInput } from '../types' +import { useEvolutions } from '../hooks/useEncounters' interface StatusChangeModalProps { encounter: EncounterDetail onUpdate: (data: { id: number - data: { faintLevel?: number; deathCause?: string } + data: UpdateEncounterInput }) => void onClose: () => void isPending: boolean @@ -32,19 +33,48 @@ const typeColors: Record = { 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({ encounter, onUpdate, onClose, isPending, }: StatusChangeModalProps) { - const { pokemon, route, nickname, catchLevel, faintLevel, deathCause } = + const { pokemon, currentPokemon, route, nickname, catchLevel, faintLevel, deathCause } = encounter const isDead = faintLevel !== null + const displayPokemon = currentPokemon ?? pokemon const [showConfirm, setShowConfirm] = useState(false) + const [showEvolve, setShowEvolve] = useState(false) const [deathLevel, setDeathLevel] = useState('') const [cause, setCause] = useState('') + const activePokemonId = currentPokemon?.id ?? pokemon.id + const { data: evolutions, isLoading: evolutionsLoading } = useEvolutions( + showEvolve ? activePokemonId : null + ) + const handleConfirmDeath = () => { onUpdate({ id: encounter.id, @@ -55,6 +85,13 @@ export function StatusChangeModal({ }) } + const handleEvolve = (toPokemonId: number) => { + onUpdate({ + id: encounter.id, + data: { currentPokemonId: toPokemonId }, + }) + } + return (
@@ -87,28 +124,28 @@ export function StatusChangeModal({
{/* Pokemon info */}
- {pokemon.spriteUrl ? ( + {displayPokemon.spriteUrl ? ( {pokemon.name} ) : (
- {pokemon.name[0].toUpperCase()} + {displayPokemon.name[0].toUpperCase()}
)}
- {nickname || pokemon.name} + {nickname || displayPokemon.name}
{nickname && (
- {pokemon.name} + {displayPokemon.name}
)}
- {pokemon.types.map((type) => ( + {displayPokemon.types.map((type) => ( Lv. {catchLevel ?? '?'} · {route.name}
+ {currentPokemon && ( +
+ Originally: {pokemon.name} +
+ )}
@@ -149,15 +191,77 @@ export function StatusChangeModal({
)} - {/* Alive pokemon: mark as dead */} - {!isDead && !showConfirm && ( - + {/* Alive pokemon: actions */} + {!isDead && !showConfirm && !showEvolve && ( +
+ + +
+ )} + + {/* Evolution selection */} + {!isDead && showEvolve && ( +
+
+

+ Evolve into: +

+ +
+ {evolutionsLoading && ( +

Loading evolutions...

+ )} + {!evolutionsLoading && evolutions && evolutions.length === 0 && ( +

No evolutions available

+ )} + {!evolutionsLoading && evolutions && evolutions.length > 0 && ( +
+ {evolutions.map((evo) => ( + + ))} +
+ )} +
)} {/* Confirmation form */} @@ -229,8 +333,8 @@ export function StatusChangeModal({ )}
- {/* Footer for dead/no-confirm views */} - {(isDead || (!isDead && !showConfirm)) && ( + {/* Footer for dead/no-confirm/no-evolve views */} + {(isDead || (!isDead && !showConfirm && !showEvolve)) && (