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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user