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

@@ -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} &middot; 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 &#10003;
</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