From 190b08eb2654786e61ac222b38deb4cb52ab8a5a Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Sun, 8 Feb 2026 11:16:13 +0100 Subject: [PATCH] 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 --- ...er-gvom--boss-battles-level-caps-badges.md | 27 ++ .../versions/c2d3e4f5a6b7_add_boss_battles.py | 61 ++++ backend/src/app/api/bosses.py | 240 +++++++++++++ backend/src/app/api/routes.py | 3 +- backend/src/app/models/__init__.py | 6 + backend/src/app/models/boss_battle.py | 31 ++ backend/src/app/models/boss_pokemon.py | 22 ++ backend/src/app/models/boss_result.py | 30 ++ backend/src/app/models/game.py | 1 + backend/src/app/models/nuzlocke_run.py | 1 + backend/src/app/schemas/__init__.py | 18 + backend/src/app/schemas/boss.py | 80 +++++ frontend/src/api/admin.ts | 17 + frontend/src/api/bosses.ts | 18 + frontend/src/components/BossDefeatModal.tsx | 120 +++++++ .../components/admin/BossBattleFormModal.tsx | 183 ++++++++++ .../src/components/admin/BossTeamEditor.tsx | 129 +++++++ frontend/src/hooks/useAdmin.ts | 54 +++ frontend/src/hooks/useBosses.ts | 43 +++ frontend/src/pages/RunEncounters.tsx | 339 +++++++++++++++--- frontend/src/pages/admin/AdminGameDetail.tsx | 180 +++++++++- frontend/src/types/admin.ts | 31 ++ frontend/src/types/game.ts | 41 +++ 23 files changed, 1614 insertions(+), 61 deletions(-) create mode 100644 .beans/nuzlocke-tracker-gvom--boss-battles-level-caps-badges.md create mode 100644 backend/src/app/alembic/versions/c2d3e4f5a6b7_add_boss_battles.py create mode 100644 backend/src/app/api/bosses.py create mode 100644 backend/src/app/models/boss_battle.py create mode 100644 backend/src/app/models/boss_pokemon.py create mode 100644 backend/src/app/models/boss_result.py create mode 100644 backend/src/app/schemas/boss.py create mode 100644 frontend/src/api/bosses.ts create mode 100644 frontend/src/components/BossDefeatModal.tsx create mode 100644 frontend/src/components/admin/BossBattleFormModal.tsx create mode 100644 frontend/src/components/admin/BossTeamEditor.tsx create mode 100644 frontend/src/hooks/useBosses.ts diff --git a/.beans/nuzlocke-tracker-gvom--boss-battles-level-caps-badges.md b/.beans/nuzlocke-tracker-gvom--boss-battles-level-caps-badges.md new file mode 100644 index 0000000..843630a --- /dev/null +++ b/.beans/nuzlocke-tracker-gvom--boss-battles-level-caps-badges.md @@ -0,0 +1,27 @@ +--- +# nuzlocke-tracker-gvom +title: Boss Battles, Level Caps & Badges +status: completed +type: feature +priority: normal +created_at: 2026-02-08T10:09:33Z +updated_at: 2026-02-08T10:15:38Z +--- + +Add boss battle data models, API endpoints, and UI for gym leaders, elite four, champion, etc. Includes: +- Backend models (BossBattle, BossPokemon, BossResult) +- Database migration +- API endpoints for CRUD and run tracking +- Frontend types, API client, hooks +- Sticky level cap bar on run page +- Boss battle cards interleaved in encounter list +- Admin panel for managing boss battles + +## Checklist + +- [x] Phase 1: Backend models & migration +- [x] Phase 2: Backend schemas +- [x] Phase 3: Backend API endpoints +- [x] Phase 4: Frontend types, API & hooks +- [x] Phase 5: Frontend run page (level cap bar + boss cards) +- [x] Phase 6: Frontend admin panel \ No newline at end of file diff --git a/backend/src/app/alembic/versions/c2d3e4f5a6b7_add_boss_battles.py b/backend/src/app/alembic/versions/c2d3e4f5a6b7_add_boss_battles.py new file mode 100644 index 0000000..193ac7d --- /dev/null +++ b/backend/src/app/alembic/versions/c2d3e4f5a6b7_add_boss_battles.py @@ -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') diff --git a/backend/src/app/api/bosses.py b/backend/src/app/api/bosses.py new file mode 100644 index 0000000..0bcc4db --- /dev/null +++ b/backend/src/app/api/bosses.py @@ -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) diff --git a/backend/src/app/api/routes.py b/backend/src/app/api/routes.py index da8b9ee..1678e94 100644 --- a/backend/src/app/api/routes.py +++ b/backend/src/app/api/routes.py @@ -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"]) diff --git a/backend/src/app/models/__init__.py b/backend/src/app/models/__init__.py index 73c49dc..a762748 100644 --- a/backend/src/app/models/__init__.py +++ b/backend/src/app/models/__init__.py @@ -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", diff --git a/backend/src/app/models/boss_battle.py b/backend/src/app/models/boss_battle.py new file mode 100644 index 0000000..525e9f3 --- /dev/null +++ b/backend/src/app/models/boss_battle.py @@ -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"" diff --git a/backend/src/app/models/boss_pokemon.py b/backend/src/app/models/boss_pokemon.py new file mode 100644 index 0000000..39014f5 --- /dev/null +++ b/backend/src/app/models/boss_pokemon.py @@ -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"" diff --git a/backend/src/app/models/boss_result.py b/backend/src/app/models/boss_result.py new file mode 100644 index 0000000..4cd4ca7 --- /dev/null +++ b/backend/src/app/models/boss_result.py @@ -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"" diff --git a/backend/src/app/models/game.py b/backend/src/app/models/game.py index 5fca8e3..84b36e8 100644 --- a/backend/src/app/models/game.py +++ b/backend/src/app/models/game.py @@ -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"" diff --git a/backend/src/app/models/nuzlocke_run.py b/backend/src/app/models/nuzlocke_run.py index a2dfd18..69d4f15 100644 --- a/backend/src/app/models/nuzlocke_run.py +++ b/backend/src/app/models/nuzlocke_run.py @@ -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"" diff --git a/backend/src/app/schemas/__init__.py b/backend/src/app/schemas/__init__.py index 2aecc76..6547db7 100644 --- a/backend/src/app/schemas/__init__.py +++ b/backend/src/app/schemas/__init__.py @@ -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", diff --git a/backend/src/app/schemas/boss.py b/backend/src/app/schemas/boss.py new file mode 100644 index 0000000..42c3a54 --- /dev/null +++ b/backend/src/app/schemas/boss.py @@ -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 diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 739ac16..0ed7570 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -4,6 +4,7 @@ import type { Route, Pokemon, RouteEncounterDetail, + BossBattle, CreateGameInput, UpdateGameInput, CreateRouteInput, @@ -19,6 +20,9 @@ import type { PaginatedEvolutions, CreateEvolutionInput, UpdateEvolutionInput, + CreateBossBattleInput, + UpdateBossBattleInput, + BossPokemonInput, } from '../types' // Games @@ -105,3 +109,16 @@ export const updateRouteEncounter = (routeId: number, encounterId: number, data: export const removeRouteEncounter = (routeId: number, encounterId: number) => api.del(`/routes/${routeId}/pokemon/${encounterId}`) + +// Boss Battles +export const createBossBattle = (gameId: number, data: CreateBossBattleInput) => + api.post(`/games/${gameId}/bosses`, data) + +export const updateBossBattle = (gameId: number, bossId: number, data: UpdateBossBattleInput) => + api.put(`/games/${gameId}/bosses/${bossId}`, data) + +export const deleteBossBattle = (gameId: number, bossId: number) => + api.del(`/games/${gameId}/bosses/${bossId}`) + +export const setBossTeam = (gameId: number, bossId: number, team: BossPokemonInput[]) => + api.put(`/games/${gameId}/bosses/${bossId}/pokemon`, team) diff --git a/frontend/src/api/bosses.ts b/frontend/src/api/bosses.ts new file mode 100644 index 0000000..1f4f971 --- /dev/null +++ b/frontend/src/api/bosses.ts @@ -0,0 +1,18 @@ +import { api } from './client' +import type { BossBattle, BossResult, CreateBossResultInput } from '../types/game' + +export function getGameBosses(gameId: number): Promise { + return api.get(`/games/${gameId}/bosses`) +} + +export function getBossResults(runId: number): Promise { + return api.get(`/runs/${runId}/boss-results`) +} + +export function createBossResult(runId: number, data: CreateBossResultInput): Promise { + return api.post(`/runs/${runId}/boss-results`, data) +} + +export function deleteBossResult(runId: number, resultId: number): Promise { + return api.del(`/runs/${runId}/boss-results/${resultId}`) +} diff --git a/frontend/src/components/BossDefeatModal.tsx b/frontend/src/components/BossDefeatModal.tsx new file mode 100644 index 0000000..e3c2089 --- /dev/null +++ b/frontend/src/components/BossDefeatModal.tsx @@ -0,0 +1,120 @@ +import { type FormEvent, useState } from 'react' +import type { BossBattle, CreateBossResultInput } from '../types/game' + +interface BossDefeatModalProps { + boss: BossBattle + onSubmit: (data: CreateBossResultInput) => void + onClose: () => void + isPending?: boolean +} + +export function BossDefeatModal({ boss, onSubmit, onClose, isPending }: BossDefeatModalProps) { + const [result, setResult] = useState<'won' | 'lost'>('won') + const [attempts, setAttempts] = useState('1') + + const handleSubmit = (e: FormEvent) => { + e.preventDefault() + onSubmit({ + bossBattleId: boss.id, + result, + attempts: Number(attempts) || 1, + }) + } + + return ( +
+
+
+
+

Battle: {boss.name}

+

{boss.location}

+
+ + {/* Boss team preview */} + {boss.pokemon.length > 0 && ( +
+
+ {boss.pokemon + .sort((a, b) => a.order - b.order) + .map((bp) => ( +
+ {bp.pokemon.spriteUrl ? ( + {bp.pokemon.name} + ) : ( +
+ )} + + {bp.pokemon.name} + + + Lv.{bp.level} + +
+ ))} +
+
+ )} + +
+
+
+ +
+ + +
+
+ +
+ + setAttempts(e.target.value)} + className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" + /> +
+
+ +
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/components/admin/BossBattleFormModal.tsx b/frontend/src/components/admin/BossBattleFormModal.tsx new file mode 100644 index 0000000..b297a88 --- /dev/null +++ b/frontend/src/components/admin/BossBattleFormModal.tsx @@ -0,0 +1,183 @@ +import { type FormEvent, useState } from 'react' +import { FormModal } from './FormModal' +import type { BossBattle, Route } from '../../types/game' +import type { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/admin' + +interface BossBattleFormModalProps { + boss?: BossBattle + routes: Route[] + nextOrder: number + onSubmit: (data: CreateBossBattleInput | UpdateBossBattleInput) => void + onClose: () => void + isSubmitting?: boolean +} + +const BOSS_TYPES = [ + { value: 'gym_leader', label: 'Gym Leader' }, + { value: 'elite_four', label: 'Elite Four' }, + { value: 'champion', label: 'Champion' }, + { value: 'rival', label: 'Rival' }, + { value: 'evil_team', label: 'Evil Team' }, + { value: 'other', label: 'Other' }, +] + +export function BossBattleFormModal({ + boss, + routes, + nextOrder, + onSubmit, + onClose, + isSubmitting, +}: BossBattleFormModalProps) { + const [name, setName] = useState(boss?.name ?? '') + const [bossType, setBossType] = useState(boss?.bossType ?? 'gym_leader') + const [badgeName, setBadgeName] = useState(boss?.badgeName ?? '') + const [badgeImageUrl, setBadgeImageUrl] = useState(boss?.badgeImageUrl ?? '') + const [levelCap, setLevelCap] = useState(String(boss?.levelCap ?? '')) + const [order, setOrder] = useState(String(boss?.order ?? nextOrder)) + const [afterRouteId, setAfterRouteId] = useState(String(boss?.afterRouteId ?? '')) + const [location, setLocation] = useState(boss?.location ?? '') + const [spriteUrl, setSpriteUrl] = useState(boss?.spriteUrl ?? '') + + const handleSubmit = (e: FormEvent) => { + e.preventDefault() + onSubmit({ + name, + bossType, + badgeName: badgeName || null, + badgeImageUrl: badgeImageUrl || null, + levelCap: Number(levelCap), + order: Number(order), + afterRouteId: afterRouteId ? Number(afterRouteId) : null, + location, + spriteUrl: spriteUrl || null, + }) + } + + // Sort routes by order for the dropdown + const sortedRoutes = [...routes].sort((a, b) => a.order - b.order) + + return ( + +
+
+ + setName(e.target.value)} + placeholder="e.g. Brock" + className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" + /> +
+
+ + +
+
+ +
+ + setLocation(e.target.value)} + placeholder="e.g. Pewter City Gym" + className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" + /> +
+ +
+
+ + setLevelCap(e.target.value)} + className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" + /> +
+
+ + setOrder(e.target.value)} + className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" + /> +
+
+ +
+ + +
+ +
+
+ + setBadgeName(e.target.value)} + placeholder="Optional" + className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" + /> +
+
+ + setBadgeImageUrl(e.target.value)} + placeholder="Optional" + className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" + /> +
+
+ +
+ + setSpriteUrl(e.target.value)} + placeholder="Optional" + className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" + /> +
+
+ ) +} diff --git a/frontend/src/components/admin/BossTeamEditor.tsx b/frontend/src/components/admin/BossTeamEditor.tsx new file mode 100644 index 0000000..f9a347e --- /dev/null +++ b/frontend/src/components/admin/BossTeamEditor.tsx @@ -0,0 +1,129 @@ +import { type FormEvent, useState } from 'react' +import { PokemonSelector } from './PokemonSelector' +import type { BossBattle } from '../../types/game' +import type { BossPokemonInput } from '../../types/admin' + +interface BossTeamEditorProps { + boss: BossBattle + onSave: (team: BossPokemonInput[]) => void + onClose: () => void + isSaving?: boolean +} + +export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEditorProps) { + const [team, setTeam] = useState>( + boss.pokemon.length > 0 + ? boss.pokemon + .sort((a, b) => a.order - b.order) + .map((bp) => ({ + pokemonId: bp.pokemonId, + pokemonName: bp.pokemon.name, + level: String(bp.level), + order: bp.order, + })) + : [{ pokemonId: null, pokemonName: '', level: '', order: 1 }], + ) + + const addSlot = () => { + setTeam((prev) => [ + ...prev, + { pokemonId: null, pokemonName: '', level: '', order: prev.length + 1 }, + ]) + } + + const removeSlot = (index: number) => { + setTeam((prev) => prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, order: i + 1 }))) + } + + const updateSlot = (index: number, field: string, value: number | string | null) => { + setTeam((prev) => + prev.map((item, i) => (i === index ? { ...item, [field]: value } : item)), + ) + } + + const handleSubmit = (e: FormEvent) => { + e.preventDefault() + const validTeam: BossPokemonInput[] = team + .filter((t) => t.pokemonId != null && t.level) + .map((t, i) => ({ + pokemonId: t.pokemonId!, + level: Number(t.level), + order: i + 1, + })) + onSave(validTeam) + } + + return ( +
+
+
+
+

{boss.name}'s Team

+
+ +
+
+ {team.map((slot, index) => ( +
+
+ updateSlot(index, 'pokemonId', id)} + /> +
+
+ + updateSlot(index, 'level', e.target.value)} + className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" + /> +
+ +
+ ))} + + {team.length < 6 && ( + + )} +
+ +
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/hooks/useAdmin.ts b/frontend/src/hooks/useAdmin.ts index 66a5038..70bf448 100644 --- a/frontend/src/hooks/useAdmin.ts +++ b/frontend/src/hooks/useAdmin.ts @@ -13,6 +13,9 @@ import type { UpdateRouteEncounterInput, CreateEvolutionInput, UpdateEvolutionInput, + CreateBossBattleInput, + UpdateBossBattleInput, + BossPokemonInput, } from '../types' // --- Queries --- @@ -256,3 +259,54 @@ export function useRemoveRouteEncounter(routeId: number) { onError: (err) => toast.error(`Failed to remove encounter: ${err.message}`), }) } + +// --- Boss Battle Mutations --- + +export function useCreateBossBattle(gameId: number) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (data: CreateBossBattleInput) => adminApi.createBossBattle(gameId, data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['games', gameId, 'bosses'] }) + toast.success('Boss battle created') + }, + onError: (err) => toast.error(`Failed to create boss battle: ${err.message}`), + }) +} + +export function useUpdateBossBattle(gameId: number) { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ bossId, data }: { bossId: number; data: UpdateBossBattleInput }) => + adminApi.updateBossBattle(gameId, bossId, data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['games', gameId, 'bosses'] }) + toast.success('Boss battle updated') + }, + onError: (err) => toast.error(`Failed to update boss battle: ${err.message}`), + }) +} + +export function useDeleteBossBattle(gameId: number) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (bossId: number) => adminApi.deleteBossBattle(gameId, bossId), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['games', gameId, 'bosses'] }) + toast.success('Boss battle deleted') + }, + onError: (err) => toast.error(`Failed to delete boss battle: ${err.message}`), + }) +} + +export function useSetBossTeam(gameId: number, bossId: number) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (team: BossPokemonInput[]) => adminApi.setBossTeam(gameId, bossId, team), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['games', gameId, 'bosses'] }) + toast.success('Boss team updated') + }, + onError: (err) => toast.error(`Failed to update boss team: ${err.message}`), + }) +} diff --git a/frontend/src/hooks/useBosses.ts b/frontend/src/hooks/useBosses.ts new file mode 100644 index 0000000..17f52c9 --- /dev/null +++ b/frontend/src/hooks/useBosses.ts @@ -0,0 +1,43 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import { getGameBosses, getBossResults, createBossResult, deleteBossResult } from '../api/bosses' +import type { CreateBossResultInput } from '../types/game' + +export function useGameBosses(gameId: number | null) { + return useQuery({ + queryKey: ['games', gameId, 'bosses'], + queryFn: () => getGameBosses(gameId!), + enabled: gameId != null, + }) +} + +export function useBossResults(runId: number) { + return useQuery({ + queryKey: ['runs', runId, 'boss-results'], + queryFn: () => getBossResults(runId), + }) +} + +export function useCreateBossResult(runId: number) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (data: CreateBossResultInput) => createBossResult(runId, data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['runs', runId, 'boss-results'] }) + toast.success('Boss result recorded') + }, + onError: (err) => toast.error(`Failed to record result: ${err.message}`), + }) +} + +export function useDeleteBossResult(runId: number) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (resultId: number) => deleteBossResult(runId, resultId), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['runs', runId, 'boss-results'] }) + toast.success('Boss result removed') + }, + onError: (err) => toast.error(`Failed to remove result: ${err.message}`), + }) +} diff --git a/frontend/src/pages/RunEncounters.tsx b/frontend/src/pages/RunEncounters.tsx index 5a44402..f1456f8 100644 --- a/frontend/src/pages/RunEncounters.tsx +++ b/frontend/src/pages/RunEncounters.tsx @@ -4,6 +4,7 @@ import { useRun, useUpdateRun } from '../hooks/useRuns' import { useGameRoutes } from '../hooks/useGames' import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters' import { usePokemonFamilies } from '../hooks/usePokemon' +import { useGameBosses, useBossResults, useCreateBossResult } from '../hooks/useBosses' import { EncounterModal, EncounterMethodBadge, @@ -15,6 +16,7 @@ import { ShinyBox, ShinyEncounterModal, } from '../components' +import { BossDefeatModal } from '../components/BossDefeatModal' import type { Route, RouteWithChildren, @@ -22,6 +24,7 @@ import type { EncounterDetail, EncounterStatus, CreateEncounterInput, + BossBattle, } from '../types' const statusStyles: Record = { @@ -322,8 +325,12 @@ export function RunEncounters() { const updateEncounter = useUpdateEncounter(runIdNum) const updateRun = useUpdateRun(runIdNum) const { data: familiesData } = usePokemonFamilies() + const { data: bosses } = useGameBosses(run?.gameId ?? null) + const { data: bossResults } = useBossResults(runIdNum) + const createBossResult = useCreateBossResult(runIdNum) const [selectedRoute, setSelectedRoute] = useState(null) + const [selectedBoss, setSelectedBoss] = useState(null) const [editingEncounter, setEditingEncounter] = useState(null) const [selectedTeamEncounter, setSelectedTeamEncounter] = @@ -420,6 +427,50 @@ export function RunEncounters() { return duped.size > 0 ? duped : undefined }, [run, normalEncounters, familiesData]) + // Boss battle data + const defeatedBossIds = useMemo(() => { + const set = new Set() + if (bossResults) { + for (const r of bossResults) { + if (r.result === 'won') set.add(r.bossBattleId) + } + } + return set + }, [bossResults]) + + const sortedBosses = useMemo(() => { + if (!bosses) return [] + return [...bosses].sort((a, b) => a.order - b.order) + }, [bosses]) + + const nextBoss = useMemo(() => { + return sortedBosses.find((b) => !defeatedBossIds.has(b.id)) ?? null + }, [sortedBosses, defeatedBossIds]) + + const currentLevelCap = useMemo(() => { + if (!nextBoss) { + // All defeated — no cap (or use last boss's level) + return sortedBosses.length > 0 + ? sortedBosses[sortedBosses.length - 1].levelCap + : null + } + return nextBoss.levelCap + }, [nextBoss, sortedBosses]) + + // Map afterRouteId → BossBattle[] for interleaving + const bossesAfterRoute = useMemo(() => { + const map = new Map() + if (!bosses) return map + for (const boss of bosses) { + if (boss.afterRouteId != null) { + const list = map.get(boss.afterRouteId) ?? [] + list.push(boss) + map.set(boss.afterRouteId, list) + } + } + return map + }, [bosses]) + // Auto-expand the first unvisited group on initial load useEffect(() => { if (organizedRoutes.length === 0 || expandedGroups.size > 0) return @@ -677,6 +728,65 @@ export function RunEncounters() { />
+ {/* Level Cap Bar */} + {run.rules?.levelCaps && sortedBosses.length > 0 && ( +
+
+
+ + Level Cap: {currentLevelCap ?? '—'} + + {nextBoss && ( + + Next: {nextBoss.name} + + )} + {!nextBoss && ( + + All bosses defeated! + + )} +
+ + {defeatedBossIds.size}/{sortedBosses.length} defeated + +
+ {/* Badge row — gym leaders only */} + {sortedBosses.some((b) => b.bossType === 'gym_leader') && ( +
+ {sortedBosses + .filter((b) => b.bossType === 'gym_leader') + .map((boss) => { + const earned = defeatedBossIds.has(boss.id) + return ( +
+ {boss.badgeImageUrl ? ( + {boss.badgeName + ) : ( +
+ {boss.order} +
+ )} +
+ ) + })} +
+ )} +
+ )} + {/* Rules */}

@@ -813,71 +923,168 @@ export function RunEncounters() {

)} {filteredRoutes.map((route) => { - // Render as group if it has children - if (route.children.length > 0) { - return ( - toggleGroup(route.id)} - onRouteClick={handleRouteClick} - filter={filter} - pinwheelClause={pinwheelClause} - /> - ) + // Collect all route IDs to check for boss cards after + const routeIds: number[] = route.children.length > 0 + ? [route.id, ...route.children.map((c) => c.id)] + : [route.id] + + // Find boss battles positioned after this route (or any of its children) + const bossesHere: BossBattle[] = [] + for (const rid of routeIds) { + const b = bossesAfterRoute.get(rid) + if (b) bossesHere.push(...b) } - // Standalone route (no children) - const encounter = encounterByRoute.get(route.id) - const rs = getRouteStatus(encounter) - const si = statusIndicator[rs] + const routeElement = route.children.length > 0 ? ( + toggleGroup(route.id)} + onRouteClick={handleRouteClick} + filter={filter} + pinwheelClause={pinwheelClause} + /> + ) : (() => { + const encounter = encounterByRoute.get(route.id) + const rs = getRouteStatus(encounter) + const si = statusIndicator[rs] + + return ( + + ) + })() return ( - + ) : null} +

+
+ {/* Boss pokemon team */} + {boss.pokemon.length > 0 && ( +
+ {boss.pokemon + .sort((a, b) => a.order - b.order) + .map((bp) => ( +
+ {bp.pokemon.spriteUrl ? ( + {bp.pokemon.name} + ) : ( +
+ )} + + {bp.level} + +
+ ))} +
)} - - {encounter.nickname ?? encounter.pokemon.name} - {encounter.status === 'caught' && - encounter.faintLevel !== null && - (encounter.deathCause - ? ` — ${encounter.deathCause}` - : ' (dead)')} -
- ) : route.encounterMethods.length > 0 && ( -
- {route.encounterMethods.map((m) => ( - - ))} -
- )} -
- - {si.label} - - + ) + })} + ) })} @@ -923,6 +1130,20 @@ export function RunEncounters() { /> )} + {/* Boss Defeat Modal */} + {selectedBoss && ( + { + createBossResult.mutate(data, { + onSuccess: () => setSelectedBoss(null), + }) + }} + onClose={() => setSelectedBoss(null)} + isPending={createBossResult.isPending} + /> + )} + {/* End Run Modal */} {showEndRun && ( (null) const [deleting, setDeleting] = useState(null) + const [showCreateBoss, setShowCreateBoss] = useState(false) + const [editingBoss, setEditingBoss] = useState(null) + const [deletingBoss, setDeletingBoss] = useState(null) + const [editingTeam, setEditingTeam] = useState(null) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), @@ -268,6 +284,168 @@ export function AdminGameDetail() { isDeleting={deleteRoute.isPending} /> )} + + {/* Boss Battles Section */} +
+
+

Boss Battles ({bosses?.length ?? 0})

+ +
+ + {!bosses || bosses.length === 0 ? ( +
+ No boss battles yet. Add one to get started. +
+ ) : ( +
+
+ + + + + + + + + + + + + + {bosses.map((boss) => ( + + + + + + + + + + ))} + +
+ Order + + Name + + Type + + Location + + Lv Cap + + Team + + Actions +
{boss.order}{boss.name} + {boss.bossType.replace('_', ' ')} + {boss.location}{boss.levelCap}{boss.pokemon.length} +
+ + + +
+
+
+
+ )} +
+ + {/* Boss Battle Modals */} + {showCreateBoss && ( + b.order)) + 1 : 1} + onSubmit={(data) => + createBoss.mutate(data as CreateBossBattleInput, { + onSuccess: () => setShowCreateBoss(false), + }) + } + onClose={() => setShowCreateBoss(false)} + isSubmitting={createBoss.isPending} + /> + )} + + {editingBoss && ( + + updateBoss.mutate( + { bossId: editingBoss.id, data: data as UpdateBossBattleInput }, + { onSuccess: () => setEditingBoss(null) }, + ) + } + onClose={() => setEditingBoss(null)} + isSubmitting={updateBoss.isPending} + /> + )} + + {deletingBoss && ( + + deleteBoss.mutate(deletingBoss.id, { + onSuccess: () => setDeletingBoss(null), + }) + } + onCancel={() => setDeletingBoss(null)} + isDeleting={deleteBoss.isPending} + /> + )} + + {editingTeam && ( + setEditingTeam(null)} + /> + )} ) } + +function BossTeamEditorWrapper({ + gameId, + boss, + onClose, +}: { + gameId: number + boss: BossBattle + onClose: () => void +}) { + const setBossTeam = useSetBossTeam(gameId, boss.id) + return ( + + setBossTeam.mutate(team, { onSuccess: onClose }) + } + onClose={onClose} + isSaving={setBossTeam.isPending} + /> + ) +} diff --git a/frontend/src/types/admin.ts b/frontend/src/types/admin.ts index 0c51032..5951111 100644 --- a/frontend/src/types/admin.ts +++ b/frontend/src/types/admin.ts @@ -119,3 +119,34 @@ export interface UpdateEvolutionInput { condition?: string | null region?: string | null } + +// Boss battles admin +export interface CreateBossBattleInput { + name: string + bossType: string + badgeName?: string | null + badgeImageUrl?: string | null + levelCap: number + order: number + afterRouteId?: number | null + location: string + spriteUrl?: string | null +} + +export interface UpdateBossBattleInput { + name?: string + bossType?: string + badgeName?: string | null + badgeImageUrl?: string | null + levelCap?: number + order?: number + afterRouteId?: number | null + location?: string + spriteUrl?: string | null +} + +export interface BossPokemonInput { + pokemonId: number + level: number + order: number +} diff --git a/frontend/src/types/game.ts b/frontend/src/types/game.ts index f7c9183..5834c09 100644 --- a/frontend/src/types/game.ts +++ b/frontend/src/types/game.ts @@ -127,6 +127,47 @@ export interface UpdateEncounterInput { currentPokemonId?: number } +// Boss battles +export type BossType = 'gym_leader' | 'elite_four' | 'champion' | 'rival' | 'evil_team' | 'other' + +export interface BossPokemon { + id: number + pokemonId: number + level: number + order: number + pokemon: Pokemon +} + +export interface BossBattle { + id: number + gameId: number + name: string + bossType: BossType + badgeName: string | null + badgeImageUrl: string | null + levelCap: number + order: number + afterRouteId: number | null + location: string + spriteUrl: string | null + pokemon: BossPokemon[] +} + +export interface BossResult { + id: number + runId: number + bossBattleId: number + result: 'won' | 'lost' + attempts: number + completedAt: string | null +} + +export interface CreateBossResultInput { + bossBattleId: number + result: 'won' | 'lost' + attempts?: number +} + // Re-export for convenience import type { NuzlockeRules } from './rules' export type { NuzlockeRules }