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

@@ -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}')>"