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:
@@ -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
|
||||
@@ -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')
|
||||
240
backend/src/app/api/bosses.py
Normal file
240
backend/src/app/api/bosses.py
Normal file
@@ -0,0 +1,240 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.database import get_session
|
||||
from app.models.boss_battle import BossBattle
|
||||
from app.models.boss_pokemon import BossPokemon
|
||||
from app.models.boss_result import BossResult
|
||||
from app.models.game import Game
|
||||
from app.models.nuzlocke_run import NuzlockeRun
|
||||
from app.schemas.boss import (
|
||||
BossBattleCreate,
|
||||
BossBattleResponse,
|
||||
BossBattleUpdate,
|
||||
BossPokemonInput,
|
||||
BossResultCreate,
|
||||
BossResultResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --- Game-scoped (admin) endpoints ---
|
||||
|
||||
|
||||
@router.get("/games/{game_id}/bosses", response_model=list[BossBattleResponse])
|
||||
async def list_bosses(
|
||||
game_id: int, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
game = await session.get(Game, game_id)
|
||||
if game is None:
|
||||
raise HTTPException(status_code=404, detail="Game not found")
|
||||
|
||||
result = await session.execute(
|
||||
select(BossBattle)
|
||||
.where(BossBattle.game_id == game_id)
|
||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
||||
.order_by(BossBattle.order)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/games/{game_id}/bosses", response_model=BossBattleResponse, status_code=201)
|
||||
async def create_boss(
|
||||
game_id: int,
|
||||
data: BossBattleCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
game = await session.get(Game, game_id)
|
||||
if game is None:
|
||||
raise HTTPException(status_code=404, detail="Game not found")
|
||||
|
||||
boss = BossBattle(game_id=game_id, **data.model_dump())
|
||||
session.add(boss)
|
||||
await session.commit()
|
||||
|
||||
# Re-fetch with eager loading
|
||||
result = await session.execute(
|
||||
select(BossBattle)
|
||||
.where(BossBattle.id == boss.id)
|
||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
@router.put("/games/{game_id}/bosses/{boss_id}", response_model=BossBattleResponse)
|
||||
async def update_boss(
|
||||
game_id: int,
|
||||
boss_id: int,
|
||||
data: BossBattleUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(
|
||||
select(BossBattle)
|
||||
.where(BossBattle.id == boss_id, BossBattle.game_id == game_id)
|
||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
||||
)
|
||||
boss = result.scalar_one_or_none()
|
||||
if boss is None:
|
||||
raise HTTPException(status_code=404, detail="Boss battle not found")
|
||||
|
||||
for field, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(boss, field, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(boss)
|
||||
|
||||
# Re-fetch with eager loading
|
||||
result = await session.execute(
|
||||
select(BossBattle)
|
||||
.where(BossBattle.id == boss.id)
|
||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
@router.delete("/games/{game_id}/bosses/{boss_id}", status_code=204)
|
||||
async def delete_boss(
|
||||
game_id: int,
|
||||
boss_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(
|
||||
select(BossBattle).where(BossBattle.id == boss_id, BossBattle.game_id == game_id)
|
||||
)
|
||||
boss = result.scalar_one_or_none()
|
||||
if boss is None:
|
||||
raise HTTPException(status_code=404, detail="Boss battle not found")
|
||||
|
||||
await session.delete(boss)
|
||||
await session.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/games/{game_id}/bosses/{boss_id}/pokemon",
|
||||
response_model=BossBattleResponse,
|
||||
)
|
||||
async def set_boss_team(
|
||||
game_id: int,
|
||||
boss_id: int,
|
||||
team: list[BossPokemonInput],
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(
|
||||
select(BossBattle)
|
||||
.where(BossBattle.id == boss_id, BossBattle.game_id == game_id)
|
||||
.options(selectinload(BossBattle.pokemon))
|
||||
)
|
||||
boss = result.scalar_one_or_none()
|
||||
if boss is None:
|
||||
raise HTTPException(status_code=404, detail="Boss battle not found")
|
||||
|
||||
# Remove existing team
|
||||
for p in boss.pokemon:
|
||||
await session.delete(p)
|
||||
|
||||
# Add new team
|
||||
for item in team:
|
||||
bp = BossPokemon(
|
||||
boss_battle_id=boss_id,
|
||||
pokemon_id=item.pokemon_id,
|
||||
level=item.level,
|
||||
order=item.order,
|
||||
)
|
||||
session.add(bp)
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Re-fetch with eager loading
|
||||
result = await session.execute(
|
||||
select(BossBattle)
|
||||
.where(BossBattle.id == boss.id)
|
||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
# --- Run-scoped endpoints ---
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}/boss-results", response_model=list[BossResultResponse])
|
||||
async def list_boss_results(
|
||||
run_id: int, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
run = await session.get(NuzlockeRun, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
|
||||
result = await session.execute(
|
||||
select(BossResult)
|
||||
.where(BossResult.run_id == run_id)
|
||||
.order_by(BossResult.id)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/runs/{run_id}/boss-results", response_model=BossResultResponse, status_code=201)
|
||||
async def create_boss_result(
|
||||
run_id: int,
|
||||
data: BossResultCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
run = await session.get(NuzlockeRun, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
|
||||
boss = await session.get(BossBattle, data.boss_battle_id)
|
||||
if boss is None:
|
||||
raise HTTPException(status_code=404, detail="Boss battle not found")
|
||||
|
||||
# Check for existing result (upsert)
|
||||
existing = await session.execute(
|
||||
select(BossResult).where(
|
||||
BossResult.run_id == run_id,
|
||||
BossResult.boss_battle_id == data.boss_battle_id,
|
||||
)
|
||||
)
|
||||
result = existing.scalar_one_or_none()
|
||||
|
||||
if result:
|
||||
result.result = data.result
|
||||
result.attempts = data.attempts
|
||||
result.completed_at = datetime.now(timezone.utc) if data.result == "won" else None
|
||||
else:
|
||||
result = BossResult(
|
||||
run_id=run_id,
|
||||
boss_battle_id=data.boss_battle_id,
|
||||
result=data.result,
|
||||
attempts=data.attempts,
|
||||
completed_at=datetime.now(timezone.utc) if data.result == "won" else None,
|
||||
)
|
||||
session.add(result)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(result)
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/runs/{run_id}/boss-results/{result_id}", status_code=204)
|
||||
async def delete_boss_result(
|
||||
run_id: int,
|
||||
result_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(
|
||||
select(BossResult).where(
|
||||
BossResult.id == result_id, BossResult.run_id == run_id
|
||||
)
|
||||
)
|
||||
boss_result = result.scalar_one_or_none()
|
||||
if boss_result is None:
|
||||
raise HTTPException(status_code=404, detail="Boss result not found")
|
||||
|
||||
await session.delete(boss_result)
|
||||
await session.commit()
|
||||
return Response(status_code=204)
|
||||
@@ -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"])
|
||||
|
||||
@@ -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",
|
||||
|
||||
31
backend/src/app/models/boss_battle.py
Normal file
31
backend/src/app/models/boss_battle.py
Normal 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}')>"
|
||||
22
backend/src/app/models/boss_pokemon.py
Normal file
22
backend/src/app/models/boss_pokemon.py
Normal 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})>"
|
||||
30
backend/src/app/models/boss_result.py
Normal file
30
backend/src/app/models/boss_result.py
Normal 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}')>"
|
||||
@@ -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}')>"
|
||||
|
||||
@@ -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}')>"
|
||||
|
||||
@@ -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",
|
||||
|
||||
80
backend/src/app/schemas/boss.py
Normal file
80
backend/src/app/schemas/boss.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from datetime import datetime
|
||||
|
||||
from app.schemas.base import CamelModel
|
||||
from app.schemas.pokemon import PokemonResponse
|
||||
|
||||
|
||||
class BossPokemonResponse(CamelModel):
|
||||
id: int
|
||||
pokemon_id: int
|
||||
level: int
|
||||
order: int
|
||||
pokemon: PokemonResponse
|
||||
|
||||
|
||||
class BossBattleResponse(CamelModel):
|
||||
id: int
|
||||
game_id: int
|
||||
name: str
|
||||
boss_type: str
|
||||
badge_name: str | None
|
||||
badge_image_url: str | None
|
||||
level_cap: int
|
||||
order: int
|
||||
after_route_id: int | None
|
||||
location: str
|
||||
sprite_url: str | None
|
||||
pokemon: list[BossPokemonResponse] = []
|
||||
|
||||
|
||||
class BossResultResponse(CamelModel):
|
||||
id: int
|
||||
run_id: int
|
||||
boss_battle_id: int
|
||||
result: str
|
||||
attempts: int
|
||||
completed_at: datetime | None
|
||||
|
||||
|
||||
# --- Input schemas ---
|
||||
|
||||
|
||||
class BossBattleCreate(CamelModel):
|
||||
name: str
|
||||
boss_type: str
|
||||
badge_name: str | None = None
|
||||
badge_image_url: str | None = None
|
||||
level_cap: int
|
||||
order: int
|
||||
after_route_id: int | None = None
|
||||
location: str
|
||||
sprite_url: str | None = None
|
||||
|
||||
|
||||
class BossBattleUpdate(CamelModel):
|
||||
name: str | None = None
|
||||
boss_type: str | None = None
|
||||
badge_name: str | None = None
|
||||
badge_image_url: str | None = None
|
||||
level_cap: int | None = None
|
||||
order: int | None = None
|
||||
after_route_id: int | None = None
|
||||
location: str | None = None
|
||||
sprite_url: str | None = None
|
||||
|
||||
|
||||
class BossPokemonInput(CamelModel):
|
||||
pokemon_id: int
|
||||
level: int
|
||||
order: int
|
||||
|
||||
|
||||
class BossResultCreate(CamelModel):
|
||||
boss_battle_id: int
|
||||
result: str
|
||||
attempts: int = 1
|
||||
|
||||
|
||||
class BossResultUpdate(CamelModel):
|
||||
result: str | None = None
|
||||
attempts: int | None = None
|
||||
@@ -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<BossBattle>(`/games/${gameId}/bosses`, data)
|
||||
|
||||
export const updateBossBattle = (gameId: number, bossId: number, data: UpdateBossBattleInput) =>
|
||||
api.put<BossBattle>(`/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<BossBattle>(`/games/${gameId}/bosses/${bossId}/pokemon`, team)
|
||||
|
||||
18
frontend/src/api/bosses.ts
Normal file
18
frontend/src/api/bosses.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { api } from './client'
|
||||
import type { BossBattle, BossResult, CreateBossResultInput } from '../types/game'
|
||||
|
||||
export function getGameBosses(gameId: number): Promise<BossBattle[]> {
|
||||
return api.get(`/games/${gameId}/bosses`)
|
||||
}
|
||||
|
||||
export function getBossResults(runId: number): Promise<BossResult[]> {
|
||||
return api.get(`/runs/${runId}/boss-results`)
|
||||
}
|
||||
|
||||
export function createBossResult(runId: number, data: CreateBossResultInput): Promise<BossResult> {
|
||||
return api.post(`/runs/${runId}/boss-results`, data)
|
||||
}
|
||||
|
||||
export function deleteBossResult(runId: number, resultId: number): Promise<void> {
|
||||
return api.del(`/runs/${runId}/boss-results/${resultId}`)
|
||||
}
|
||||
120
frontend/src/components/BossDefeatModal.tsx
Normal file
120
frontend/src/components/BossDefeatModal.tsx
Normal file
@@ -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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold">Battle: {boss.name}</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{boss.location}</p>
|
||||
</div>
|
||||
|
||||
{/* Boss team preview */}
|
||||
{boss.pokemon.length > 0 && (
|
||||
<div className="px-6 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{boss.pokemon
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((bp) => (
|
||||
<div key={bp.id} className="flex flex-col items-center">
|
||||
{bp.pokemon.spriteUrl ? (
|
||||
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-10 h-10" />
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||
)}
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 capitalize">
|
||||
{bp.pokemon.name}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
Lv.{bp.level}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Result</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setResult('won')}
|
||||
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md border transition-colors ${
|
||||
result === 'won'
|
||||
? 'bg-green-600 text-white border-green-600'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
Won
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setResult('lost')}
|
||||
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md border transition-colors ${
|
||||
result === 'lost'
|
||||
? 'bg-red-600 text-white border-red-600'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
Lost
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Attempts</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={attempts}
|
||||
onChange={(e) => setAttempts(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isPending ? 'Saving...' : 'Save Result'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
183
frontend/src/components/admin/BossBattleFormModal.tsx
Normal file
183
frontend/src/components/admin/BossBattleFormModal.tsx
Normal file
@@ -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 (
|
||||
<FormModal
|
||||
title={boss ? 'Edit Boss Battle' : 'Add Boss Battle'}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Type</label>
|
||||
<select
|
||||
value={bossType}
|
||||
onChange={(e) => setBossType(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
{BOSS_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Location</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={location}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Level Cap</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min={1}
|
||||
value={levelCap}
|
||||
onChange={(e) => setLevelCap(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Order</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min={1}
|
||||
value={order}
|
||||
onChange={(e) => setOrder(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Position After Route</label>
|
||||
<select
|
||||
value={afterRouteId}
|
||||
onChange={(e) => setAfterRouteId(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
<option value="">None</option>
|
||||
{sortedRoutes.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.order}. {r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Badge Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={badgeName}
|
||||
onChange={(e) => setBadgeName(e.target.value)}
|
||||
placeholder="Optional"
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Badge Image URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={badgeImageUrl}
|
||||
onChange={(e) => setBadgeImageUrl(e.target.value)}
|
||||
placeholder="Optional"
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Sprite URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={spriteUrl}
|
||||
onChange={(e) => setSpriteUrl(e.target.value)}
|
||||
placeholder="Optional"
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</FormModal>
|
||||
)
|
||||
}
|
||||
129
frontend/src/components/admin/BossTeamEditor.tsx
Normal file
129
frontend/src/components/admin/BossTeamEditor.tsx
Normal file
@@ -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<Array<{ pokemonId: number | null; pokemonName: string; level: string; order: number }>>(
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold">{boss.name}'s Team</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="px-6 py-4 space-y-3">
|
||||
{team.map((slot, index) => (
|
||||
<div key={index} className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<PokemonSelector
|
||||
label={`Pokemon ${index + 1}`}
|
||||
selectedId={slot.pokemonId}
|
||||
initialName={slot.pokemonName}
|
||||
onChange={(id) => updateSlot(index, 'pokemonId', id)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-20">
|
||||
<label className="block text-sm font-medium mb-1">Level</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={slot.level}
|
||||
onChange={(e) => updateSlot(index, 'level', e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSlot(index)}
|
||||
className="px-2 py-2 text-red-500 hover:text-red-700 text-sm"
|
||||
title="Remove"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{team.length < 6 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addSlot}
|
||||
className="w-full py-2 text-sm text-blue-600 dark:text-blue-400 border border-dashed border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
+ Add Pokemon
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Team'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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}`),
|
||||
})
|
||||
}
|
||||
|
||||
43
frontend/src/hooks/useBosses.ts
Normal file
43
frontend/src/hooks/useBosses.ts
Normal file
@@ -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}`),
|
||||
})
|
||||
}
|
||||
@@ -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<RunStatus, string> = {
|
||||
@@ -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<Route | null>(null)
|
||||
const [selectedBoss, setSelectedBoss] = useState<BossBattle | null>(null)
|
||||
const [editingEncounter, setEditingEncounter] =
|
||||
useState<EncounterDetail | null>(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<number>()
|
||||
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<number, BossBattle[]>()
|
||||
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() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Level Cap Bar */}
|
||||
{run.rules?.levelCaps && sortedBosses.length > 0 && (
|
||||
<div className="sticky top-0 z-10 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3 mb-6 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Level Cap: {currentLevelCap ?? '—'}
|
||||
</span>
|
||||
{nextBoss && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Next: {nextBoss.name}
|
||||
</span>
|
||||
)}
|
||||
{!nextBoss && (
|
||||
<span className="text-sm text-green-600 dark:text-green-400">
|
||||
All bosses defeated!
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{defeatedBossIds.size}/{sortedBosses.length} defeated
|
||||
</span>
|
||||
</div>
|
||||
{/* Badge row — gym leaders only */}
|
||||
{sortedBosses.some((b) => b.bossType === 'gym_leader') && (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{sortedBosses
|
||||
.filter((b) => b.bossType === 'gym_leader')
|
||||
.map((boss) => {
|
||||
const earned = defeatedBossIds.has(boss.id)
|
||||
return (
|
||||
<div
|
||||
key={boss.id}
|
||||
className={`flex flex-col items-center transition-opacity ${earned ? '' : 'opacity-30 grayscale'}`}
|
||||
title={`${boss.badgeName ?? boss.name}${earned ? ' (earned)' : ''}`}
|
||||
>
|
||||
{boss.badgeImageUrl ? (
|
||||
<img
|
||||
src={boss.badgeImageUrl}
|
||||
alt={boss.badgeName ?? boss.name}
|
||||
className="w-6 h-6"
|
||||
/>
|
||||
) : (
|
||||
<div className={`w-6 h-6 rounded-full border-2 flex items-center justify-center text-xs font-bold ${
|
||||
earned
|
||||
? 'border-yellow-500 bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-300'
|
||||
: 'border-gray-300 dark:border-gray-600 text-gray-400'
|
||||
}`}>
|
||||
{boss.order}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rules */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
|
||||
@@ -813,71 +923,168 @@ export function RunEncounters() {
|
||||
</p>
|
||||
)}
|
||||
{filteredRoutes.map((route) => {
|
||||
// Render as group if it has children
|
||||
if (route.children.length > 0) {
|
||||
return (
|
||||
<RouteGroup
|
||||
key={route.id}
|
||||
group={route}
|
||||
encounterByRoute={encounterByRoute}
|
||||
isExpanded={expandedGroups.has(route.id)}
|
||||
onToggleExpand={() => 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 ? (
|
||||
<RouteGroup
|
||||
key={route.id}
|
||||
group={route}
|
||||
encounterByRoute={encounterByRoute}
|
||||
isExpanded={expandedGroups.has(route.id)}
|
||||
onToggleExpand={() => toggleGroup(route.id)}
|
||||
onRouteClick={handleRouteClick}
|
||||
filter={filter}
|
||||
pinwheelClause={pinwheelClause}
|
||||
/>
|
||||
) : (() => {
|
||||
const encounter = encounterByRoute.get(route.id)
|
||||
const rs = getRouteStatus(encounter)
|
||||
const si = statusIndicator[rs]
|
||||
|
||||
return (
|
||||
<button
|
||||
key={route.id}
|
||||
type="button"
|
||||
onClick={() => handleRouteClick(route)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-700/50 ${si.bg}`}
|
||||
>
|
||||
<span
|
||||
className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{route.name}
|
||||
</div>
|
||||
{encounter ? (
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{encounter.pokemon.spriteUrl && (
|
||||
<img
|
||||
src={encounter.pokemon.spriteUrl}
|
||||
alt={encounter.pokemon.name}
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 capitalize">
|
||||
{encounter.nickname ?? encounter.pokemon.name}
|
||||
{encounter.status === 'caught' &&
|
||||
encounter.faintLevel !== null &&
|
||||
(encounter.deathCause
|
||||
? ` — ${encounter.deathCause}`
|
||||
: ' (dead)')}
|
||||
</span>
|
||||
</div>
|
||||
) : route.encounterMethods.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||
{route.encounterMethods.map((m) => (
|
||||
<EncounterMethodBadge key={m} method={m} size="xs" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
|
||||
{si.label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})()
|
||||
|
||||
return (
|
||||
<button
|
||||
key={route.id}
|
||||
type="button"
|
||||
onClick={() => handleRouteClick(route)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-700/50 ${si.bg}`}
|
||||
>
|
||||
<span
|
||||
className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{route.name}
|
||||
</div>
|
||||
{encounter ? (
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{encounter.pokemon.spriteUrl && (
|
||||
<img
|
||||
src={encounter.pokemon.spriteUrl}
|
||||
alt={encounter.pokemon.name}
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
<div key={route.id}>
|
||||
{routeElement}
|
||||
{/* Boss battle cards after this route */}
|
||||
{bossesHere.map((boss) => {
|
||||
const isDefeated = defeatedBossIds.has(boss.id)
|
||||
const bossTypeLabel: Record<string, string> = {
|
||||
gym_leader: 'Gym Leader',
|
||||
elite_four: 'Elite Four',
|
||||
champion: 'Champion',
|
||||
rival: 'Rival',
|
||||
evil_team: 'Evil Team',
|
||||
other: 'Boss',
|
||||
}
|
||||
const bossTypeColors: Record<string, string> = {
|
||||
gym_leader: 'border-yellow-400 dark:border-yellow-600',
|
||||
elite_four: 'border-purple-400 dark:border-purple-600',
|
||||
champion: 'border-red-400 dark:border-red-600',
|
||||
rival: 'border-blue-400 dark:border-blue-600',
|
||||
evil_team: 'border-gray-500 dark:border-gray-400',
|
||||
other: 'border-gray-400 dark:border-gray-500',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`boss-${boss.id}`}
|
||||
className={`my-2 rounded-lg border-2 ${bossTypeColors[boss.bossType] ?? bossTypeColors.other} ${
|
||||
isDefeated ? 'bg-green-50/50 dark:bg-green-900/10' : 'bg-white dark:bg-gray-800'
|
||||
} px-4 py-3`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{boss.spriteUrl && (
|
||||
<img src={boss.spriteUrl} alt={boss.name} className="w-10 h-10" />
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{boss.name}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
|
||||
{bossTypeLabel[boss.bossType] ?? boss.bossType}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{boss.location} · Level Cap: {boss.levelCap}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{isDefeated ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300">
|
||||
Defeated ✓
|
||||
</span>
|
||||
) : isActive ? (
|
||||
<button
|
||||
onClick={() => setSelectedBoss(boss)}
|
||||
className="px-3 py-1 text-xs font-medium rounded-full bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Battle
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* Boss pokemon team */}
|
||||
{boss.pokemon.length > 0 && (
|
||||
<div className="flex gap-2 mt-2 flex-wrap">
|
||||
{boss.pokemon
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((bp) => (
|
||||
<div key={bp.id} className="flex items-center gap-1">
|
||||
{bp.pokemon.spriteUrl ? (
|
||||
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-6 h-6" />
|
||||
) : (
|
||||
<div className="w-6 h-6 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||
)}
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{bp.level}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 capitalize">
|
||||
{encounter.nickname ?? encounter.pokemon.name}
|
||||
{encounter.status === 'caught' &&
|
||||
encounter.faintLevel !== null &&
|
||||
(encounter.deathCause
|
||||
? ` — ${encounter.deathCause}`
|
||||
: ' (dead)')}
|
||||
</span>
|
||||
</div>
|
||||
) : route.encounterMethods.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||
{route.encounterMethods.map((m) => (
|
||||
<EncounterMethodBadge key={m} method={m} size="xs" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
|
||||
{si.label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -923,6 +1130,20 @@ export function RunEncounters() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Boss Defeat Modal */}
|
||||
{selectedBoss && (
|
||||
<BossDefeatModal
|
||||
boss={selectedBoss}
|
||||
onSubmit={(data) => {
|
||||
createBossResult.mutate(data, {
|
||||
onSuccess: () => setSelectedBoss(null),
|
||||
})
|
||||
}}
|
||||
onClose={() => setSelectedBoss(null)}
|
||||
isPending={createBossResult.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* End Run Modal */}
|
||||
{showEndRun && (
|
||||
<EndRunModal
|
||||
|
||||
@@ -18,16 +18,24 @@ import {
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { RouteFormModal } from '../../components/admin/RouteFormModal'
|
||||
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
|
||||
import { BossBattleFormModal } from '../../components/admin/BossBattleFormModal'
|
||||
import { BossTeamEditor } from '../../components/admin/BossTeamEditor'
|
||||
import { useGame } from '../../hooks/useGames'
|
||||
import { useGameBosses } from '../../hooks/useBosses'
|
||||
import {
|
||||
useCreateRoute,
|
||||
useUpdateRoute,
|
||||
useDeleteRoute,
|
||||
useReorderRoutes,
|
||||
useCreateBossBattle,
|
||||
useUpdateBossBattle,
|
||||
useDeleteBossBattle,
|
||||
useSetBossTeam,
|
||||
} from '../../hooks/useAdmin'
|
||||
import { exportGameRoutes } from '../../api/admin'
|
||||
import { downloadJson } from '../../utils/download'
|
||||
import type { Route as GameRoute, CreateRouteInput, UpdateRouteInput } from '../../types'
|
||||
import type { Route as GameRoute, CreateRouteInput, UpdateRouteInput, BossBattle } from '../../types'
|
||||
import type { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/admin'
|
||||
|
||||
function SortableRouteRow({
|
||||
route,
|
||||
@@ -105,10 +113,18 @@ export function AdminGameDetail() {
|
||||
const updateRoute = useUpdateRoute(id)
|
||||
const deleteRoute = useDeleteRoute(id)
|
||||
const reorderRoutes = useReorderRoutes(id)
|
||||
const { data: bosses } = useGameBosses(id)
|
||||
const createBoss = useCreateBossBattle(id)
|
||||
const updateBoss = useUpdateBossBattle(id)
|
||||
const deleteBoss = useDeleteBossBattle(id)
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [editing, setEditing] = useState<GameRoute | null>(null)
|
||||
const [deleting, setDeleting] = useState<GameRoute | null>(null)
|
||||
const [showCreateBoss, setShowCreateBoss] = useState(false)
|
||||
const [editingBoss, setEditingBoss] = useState<BossBattle | null>(null)
|
||||
const [deletingBoss, setDeletingBoss] = useState<BossBattle | null>(null)
|
||||
const [editingTeam, setEditingTeam] = useState<BossBattle | null>(null)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
@@ -268,6 +284,168 @@ export function AdminGameDetail() {
|
||||
isDeleting={deleteRoute.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Boss Battles Section */}
|
||||
<div className="mt-10">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium">Boss Battles ({bosses?.length ?? 0})</h3>
|
||||
<button
|
||||
onClick={() => setShowCreateBoss(true)}
|
||||
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
|
||||
>
|
||||
Add Boss Battle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!bosses || bosses.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
No boss battles yet. Add one to get started.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-16">
|
||||
Order
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Location
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-20">
|
||||
Lv Cap
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-16">
|
||||
Team
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-40">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{bosses.map((boss) => (
|
||||
<tr key={boss.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.order}</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap font-medium">{boss.name}</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap capitalize">
|
||||
{boss.bossType.replace('_', ' ')}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.location}</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.levelCap}</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.pokemon.length}</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setEditingTeam(boss)}
|
||||
className="text-green-600 hover:text-green-800 dark:text-green-400 text-sm"
|
||||
>
|
||||
Team
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingBoss(boss)}
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 text-sm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingBoss(boss)}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 text-sm"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Boss Battle Modals */}
|
||||
{showCreateBoss && (
|
||||
<BossBattleFormModal
|
||||
routes={routes}
|
||||
nextOrder={bosses ? Math.max(0, ...bosses.map((b) => b.order)) + 1 : 1}
|
||||
onSubmit={(data) =>
|
||||
createBoss.mutate(data as CreateBossBattleInput, {
|
||||
onSuccess: () => setShowCreateBoss(false),
|
||||
})
|
||||
}
|
||||
onClose={() => setShowCreateBoss(false)}
|
||||
isSubmitting={createBoss.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingBoss && (
|
||||
<BossBattleFormModal
|
||||
boss={editingBoss}
|
||||
routes={routes}
|
||||
nextOrder={editingBoss.order}
|
||||
onSubmit={(data) =>
|
||||
updateBoss.mutate(
|
||||
{ bossId: editingBoss.id, data: data as UpdateBossBattleInput },
|
||||
{ onSuccess: () => setEditingBoss(null) },
|
||||
)
|
||||
}
|
||||
onClose={() => setEditingBoss(null)}
|
||||
isSubmitting={updateBoss.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deletingBoss && (
|
||||
<DeleteConfirmModal
|
||||
title={`Delete ${deletingBoss.name}?`}
|
||||
message="This will permanently delete this boss battle and its pokemon team."
|
||||
onConfirm={() =>
|
||||
deleteBoss.mutate(deletingBoss.id, {
|
||||
onSuccess: () => setDeletingBoss(null),
|
||||
})
|
||||
}
|
||||
onCancel={() => setDeletingBoss(null)}
|
||||
isDeleting={deleteBoss.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingTeam && (
|
||||
<BossTeamEditorWrapper
|
||||
gameId={id}
|
||||
boss={editingTeam}
|
||||
onClose={() => setEditingTeam(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BossTeamEditorWrapper({
|
||||
gameId,
|
||||
boss,
|
||||
onClose,
|
||||
}: {
|
||||
gameId: number
|
||||
boss: BossBattle
|
||||
onClose: () => void
|
||||
}) {
|
||||
const setBossTeam = useSetBossTeam(gameId, boss.id)
|
||||
return (
|
||||
<BossTeamEditor
|
||||
boss={boss}
|
||||
onSave={(team) =>
|
||||
setBossTeam.mutate(team, { onSuccess: onClose })
|
||||
}
|
||||
onClose={onClose}
|
||||
isSaving={setBossTeam.isPending}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user