Add boss battles, level caps, and badge tracking

Introduces full boss battle system: data models (BossBattle, BossPokemon,
BossResult), API endpoints for CRUD and per-run defeat tracking, and frontend
UI including a sticky level cap bar with badge display on the run page,
interleaved boss battle cards in the encounter list, and an admin panel
section for managing boss battles and their pokemon teams.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 11:16:13 +01:00
parent 3b87397432
commit 190b08eb26
23 changed files with 1614 additions and 61 deletions

View File

@@ -0,0 +1,61 @@
"""add boss battles
Revision ID: c2d3e4f5a6b7
Revises: b1c2d3e4f5a6
Create Date: 2026-02-08 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c2d3e4f5a6b7'
down_revision: Union[str, Sequence[str], None] = 'b1c2d3e4f5a6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'boss_battles',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('game_id', sa.Integer(), sa.ForeignKey('games.id'), nullable=False, index=True),
sa.Column('name', sa.String(100), nullable=False),
sa.Column('boss_type', sa.String(20), nullable=False),
sa.Column('badge_name', sa.String(100), nullable=True),
sa.Column('badge_image_url', sa.String(500), nullable=True),
sa.Column('level_cap', sa.SmallInteger(), nullable=False),
sa.Column('order', sa.SmallInteger(), nullable=False),
sa.Column('after_route_id', sa.Integer(), sa.ForeignKey('routes.id'), nullable=True, index=True),
sa.Column('location', sa.String(200), nullable=False),
sa.Column('sprite_url', sa.String(500), nullable=True),
)
op.create_table(
'boss_pokemon',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('boss_battle_id', sa.Integer(), sa.ForeignKey('boss_battles.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('pokemon_id', sa.Integer(), sa.ForeignKey('pokemon.id'), nullable=False, index=True),
sa.Column('level', sa.SmallInteger(), nullable=False),
sa.Column('order', sa.SmallInteger(), nullable=False),
)
op.create_table(
'boss_results',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('run_id', sa.Integer(), sa.ForeignKey('nuzlocke_runs.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('boss_battle_id', sa.Integer(), sa.ForeignKey('boss_battles.id'), nullable=False, index=True),
sa.Column('result', sa.String(10), nullable=False),
sa.Column('attempts', sa.SmallInteger(), nullable=False, server_default='1'),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.UniqueConstraint('run_id', 'boss_battle_id', name='uq_boss_results_run_boss'),
)
def downgrade() -> None:
op.drop_table('boss_results')
op.drop_table('boss_pokemon')
op.drop_table('boss_battles')

View File

@@ -0,0 +1,240 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.database import get_session
from app.models.boss_battle import BossBattle
from app.models.boss_pokemon import BossPokemon
from app.models.boss_result import BossResult
from app.models.game import Game
from app.models.nuzlocke_run import NuzlockeRun
from app.schemas.boss import (
BossBattleCreate,
BossBattleResponse,
BossBattleUpdate,
BossPokemonInput,
BossResultCreate,
BossResultResponse,
)
router = APIRouter()
# --- Game-scoped (admin) endpoints ---
@router.get("/games/{game_id}/bosses", response_model=list[BossBattleResponse])
async def list_bosses(
game_id: int, session: AsyncSession = Depends(get_session)
):
game = await session.get(Game, game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
result = await session.execute(
select(BossBattle)
.where(BossBattle.game_id == game_id)
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
.order_by(BossBattle.order)
)
return result.scalars().all()
@router.post("/games/{game_id}/bosses", response_model=BossBattleResponse, status_code=201)
async def create_boss(
game_id: int,
data: BossBattleCreate,
session: AsyncSession = Depends(get_session),
):
game = await session.get(Game, game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
boss = BossBattle(game_id=game_id, **data.model_dump())
session.add(boss)
await session.commit()
# Re-fetch with eager loading
result = await session.execute(
select(BossBattle)
.where(BossBattle.id == boss.id)
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
)
return result.scalar_one()
@router.put("/games/{game_id}/bosses/{boss_id}", response_model=BossBattleResponse)
async def update_boss(
game_id: int,
boss_id: int,
data: BossBattleUpdate,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(
select(BossBattle)
.where(BossBattle.id == boss_id, BossBattle.game_id == game_id)
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
)
boss = result.scalar_one_or_none()
if boss is None:
raise HTTPException(status_code=404, detail="Boss battle not found")
for field, value in data.model_dump(exclude_unset=True).items():
setattr(boss, field, value)
await session.commit()
await session.refresh(boss)
# Re-fetch with eager loading
result = await session.execute(
select(BossBattle)
.where(BossBattle.id == boss.id)
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
)
return result.scalar_one()
@router.delete("/games/{game_id}/bosses/{boss_id}", status_code=204)
async def delete_boss(
game_id: int,
boss_id: int,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(
select(BossBattle).where(BossBattle.id == boss_id, BossBattle.game_id == game_id)
)
boss = result.scalar_one_or_none()
if boss is None:
raise HTTPException(status_code=404, detail="Boss battle not found")
await session.delete(boss)
await session.commit()
return Response(status_code=204)
@router.put(
"/games/{game_id}/bosses/{boss_id}/pokemon",
response_model=BossBattleResponse,
)
async def set_boss_team(
game_id: int,
boss_id: int,
team: list[BossPokemonInput],
session: AsyncSession = Depends(get_session),
):
result = await session.execute(
select(BossBattle)
.where(BossBattle.id == boss_id, BossBattle.game_id == game_id)
.options(selectinload(BossBattle.pokemon))
)
boss = result.scalar_one_or_none()
if boss is None:
raise HTTPException(status_code=404, detail="Boss battle not found")
# Remove existing team
for p in boss.pokemon:
await session.delete(p)
# Add new team
for item in team:
bp = BossPokemon(
boss_battle_id=boss_id,
pokemon_id=item.pokemon_id,
level=item.level,
order=item.order,
)
session.add(bp)
await session.commit()
# Re-fetch with eager loading
result = await session.execute(
select(BossBattle)
.where(BossBattle.id == boss.id)
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
)
return result.scalar_one()
# --- Run-scoped endpoints ---
@router.get("/runs/{run_id}/boss-results", response_model=list[BossResultResponse])
async def list_boss_results(
run_id: int, session: AsyncSession = Depends(get_session)
):
run = await session.get(NuzlockeRun, run_id)
if run is None:
raise HTTPException(status_code=404, detail="Run not found")
result = await session.execute(
select(BossResult)
.where(BossResult.run_id == run_id)
.order_by(BossResult.id)
)
return result.scalars().all()
@router.post("/runs/{run_id}/boss-results", response_model=BossResultResponse, status_code=201)
async def create_boss_result(
run_id: int,
data: BossResultCreate,
session: AsyncSession = Depends(get_session),
):
run = await session.get(NuzlockeRun, run_id)
if run is None:
raise HTTPException(status_code=404, detail="Run not found")
boss = await session.get(BossBattle, data.boss_battle_id)
if boss is None:
raise HTTPException(status_code=404, detail="Boss battle not found")
# Check for existing result (upsert)
existing = await session.execute(
select(BossResult).where(
BossResult.run_id == run_id,
BossResult.boss_battle_id == data.boss_battle_id,
)
)
result = existing.scalar_one_or_none()
if result:
result.result = data.result
result.attempts = data.attempts
result.completed_at = datetime.now(timezone.utc) if data.result == "won" else None
else:
result = BossResult(
run_id=run_id,
boss_battle_id=data.boss_battle_id,
result=data.result,
attempts=data.attempts,
completed_at=datetime.now(timezone.utc) if data.result == "won" else None,
)
session.add(result)
await session.commit()
await session.refresh(result)
return result
@router.delete("/runs/{run_id}/boss-results/{result_id}", status_code=204)
async def delete_boss_result(
run_id: int,
result_id: int,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(
select(BossResult).where(
BossResult.id == result_id, BossResult.run_id == run_id
)
)
boss_result = result.scalar_one_or_none()
if boss_result is None:
raise HTTPException(status_code=404, detail="Boss result not found")
await session.delete(boss_result)
await session.commit()
return Response(status_code=204)

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api import encounters, evolutions, export, games, health, pokemon, runs, stats
from app.api import bosses, encounters, evolutions, export, games, health, pokemon, runs, stats
api_router = APIRouter()
api_router.include_router(health.router)
@@ -10,4 +10,5 @@ api_router.include_router(evolutions.router, tags=["evolutions"])
api_router.include_router(runs.router, prefix="/runs", tags=["runs"])
api_router.include_router(encounters.router, tags=["encounters"])
api_router.include_router(stats.router, prefix="/stats", tags=["stats"])
api_router.include_router(bosses.router, tags=["bosses"])
api_router.include_router(export.router, prefix="/export", tags=["export"])

View File

@@ -1,3 +1,6 @@
from app.models.boss_battle import BossBattle
from app.models.boss_pokemon import BossPokemon
from app.models.boss_result import BossResult
from app.models.encounter import Encounter
from app.models.evolution import Evolution
from app.models.game import Game
@@ -7,6 +10,9 @@ from app.models.route import Route
from app.models.route_encounter import RouteEncounter
__all__ = [
"BossBattle",
"BossPokemon",
"BossResult",
"Encounter",
"Evolution",
"Game",

View File

@@ -0,0 +1,31 @@
from sqlalchemy import ForeignKey, SmallInteger, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class BossBattle(Base):
__tablename__ = "boss_battles"
id: Mapped[int] = mapped_column(primary_key=True)
game_id: Mapped[int] = mapped_column(ForeignKey("games.id"), index=True)
name: Mapped[str] = mapped_column(String(100))
boss_type: Mapped[str] = mapped_column(String(20)) # gym_leader, elite_four, champion, rival, evil_team, other
badge_name: Mapped[str | None] = mapped_column(String(100))
badge_image_url: Mapped[str | None] = mapped_column(String(500))
level_cap: Mapped[int] = mapped_column(SmallInteger)
order: Mapped[int] = mapped_column(SmallInteger)
after_route_id: Mapped[int | None] = mapped_column(
ForeignKey("routes.id"), index=True, default=None
)
location: Mapped[str] = mapped_column(String(200))
sprite_url: Mapped[str | None] = mapped_column(String(500))
game: Mapped["Game"] = relationship(back_populates="boss_battles")
after_route: Mapped["Route | None"] = relationship()
pokemon: Mapped[list["BossPokemon"]] = relationship(
back_populates="boss_battle", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<BossBattle(id={self.id}, name='{self.name}', type='{self.boss_type}')>"

View File

@@ -0,0 +1,22 @@
from sqlalchemy import ForeignKey, SmallInteger
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class BossPokemon(Base):
__tablename__ = "boss_pokemon"
id: Mapped[int] = mapped_column(primary_key=True)
boss_battle_id: Mapped[int] = mapped_column(
ForeignKey("boss_battles.id", ondelete="CASCADE"), index=True
)
pokemon_id: Mapped[int] = mapped_column(ForeignKey("pokemon.id"), index=True)
level: Mapped[int] = mapped_column(SmallInteger)
order: Mapped[int] = mapped_column(SmallInteger)
boss_battle: Mapped["BossBattle"] = relationship(back_populates="pokemon")
pokemon: Mapped["Pokemon"] = relationship()
def __repr__(self) -> str:
return f"<BossPokemon(id={self.id}, boss_battle_id={self.boss_battle_id}, pokemon_id={self.pokemon_id})>"

View File

@@ -0,0 +1,30 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, SmallInteger, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class BossResult(Base):
__tablename__ = "boss_results"
__table_args__ = (
UniqueConstraint("run_id", "boss_battle_id", name="uq_boss_results_run_boss"),
)
id: Mapped[int] = mapped_column(primary_key=True)
run_id: Mapped[int] = mapped_column(
ForeignKey("nuzlocke_runs.id", ondelete="CASCADE"), index=True
)
boss_battle_id: Mapped[int] = mapped_column(
ForeignKey("boss_battles.id"), index=True
)
result: Mapped[str] = mapped_column(String(10)) # won, lost
attempts: Mapped[int] = mapped_column(SmallInteger, default=1)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
run: Mapped["NuzlockeRun"] = relationship(back_populates="boss_results")
boss_battle: Mapped["BossBattle"] = relationship()
def __repr__(self) -> str:
return f"<BossResult(id={self.id}, run_id={self.run_id}, boss_battle_id={self.boss_battle_id}, result='{self.result}')>"

View File

@@ -18,6 +18,7 @@ class Game(Base):
routes: Mapped[list["Route"]] = relationship(back_populates="game")
runs: Mapped[list["NuzlockeRun"]] = relationship(back_populates="game")
boss_battles: Mapped[list["BossBattle"]] = relationship(back_populates="game")
def __repr__(self) -> str:
return f"<Game(id={self.id}, name='{self.name}')>"

View File

@@ -22,6 +22,7 @@ class NuzlockeRun(Base):
game: Mapped["Game"] = relationship(back_populates="runs")
encounters: Mapped[list["Encounter"]] = relationship(back_populates="run")
boss_results: Mapped[list["BossResult"]] = relationship(back_populates="run")
def __repr__(self) -> str:
return f"<NuzlockeRun(id={self.id}, name='{self.name}', status='{self.status}')>"

View File

@@ -1,3 +1,13 @@
from app.schemas.boss import (
BossBattleCreate,
BossBattleResponse,
BossBattleUpdate,
BossPokemonInput,
BossPokemonResponse,
BossResultCreate,
BossResultResponse,
BossResultUpdate,
)
from app.schemas.encounter import (
EncounterCreate,
EncounterDetailResponse,
@@ -29,6 +39,14 @@ from app.schemas.pokemon import (
from app.schemas.run import RunCreate, RunDetailResponse, RunResponse, RunUpdate
__all__ = [
"BossBattleCreate",
"BossBattleResponse",
"BossBattleUpdate",
"BossPokemonInput",
"BossPokemonResponse",
"BossResultCreate",
"BossResultResponse",
"BossResultUpdate",
"BulkImportItem",
"BulkImportResult",
"EncounterCreate",

View File

@@ -0,0 +1,80 @@
from datetime import datetime
from app.schemas.base import CamelModel
from app.schemas.pokemon import PokemonResponse
class BossPokemonResponse(CamelModel):
id: int
pokemon_id: int
level: int
order: int
pokemon: PokemonResponse
class BossBattleResponse(CamelModel):
id: int
game_id: int
name: str
boss_type: str
badge_name: str | None
badge_image_url: str | None
level_cap: int
order: int
after_route_id: int | None
location: str
sprite_url: str | None
pokemon: list[BossPokemonResponse] = []
class BossResultResponse(CamelModel):
id: int
run_id: int
boss_battle_id: int
result: str
attempts: int
completed_at: datetime | None
# --- Input schemas ---
class BossBattleCreate(CamelModel):
name: str
boss_type: str
badge_name: str | None = None
badge_image_url: str | None = None
level_cap: int
order: int
after_route_id: int | None = None
location: str
sprite_url: str | None = None
class BossBattleUpdate(CamelModel):
name: str | None = None
boss_type: str | None = None
badge_name: str | None = None
badge_image_url: str | None = None
level_cap: int | None = None
order: int | None = None
after_route_id: int | None = None
location: str | None = None
sprite_url: str | None = None
class BossPokemonInput(CamelModel):
pokemon_id: int
level: int
order: int
class BossResultCreate(CamelModel):
boss_battle_id: int
result: str
attempts: int = 1
class BossResultUpdate(CamelModel):
result: str | None = None
attempts: int | None = None