Add boss battles, level caps, and badge tracking

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

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

View File

@@ -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}
/>
)
}