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 }