Add section field to boss battles for run progression dividers
Adds a nullable `section` column to boss battles (e.g. "Main Story", "Endgame") with dividers rendered in the run view between sections. Admin UI gets a Section column in the table and a text input in the form. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-704x
|
||||||
|
title: Run progression dividers (main story / endgame)
|
||||||
|
status: completed
|
||||||
|
type: feature
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-02-08T13:46:12Z
|
||||||
|
updated_at: 2026-02-08T13:48:28Z
|
||||||
|
---
|
||||||
|
|
||||||
|
Add section field to boss battles to enable visual dividers between game progression phases (Main Story, Endgame, etc.) in both admin and run views.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""add section to boss battles
|
||||||
|
|
||||||
|
Revision ID: f5a6b7c8d9e0
|
||||||
|
Revises: e4f5a6b7c8d9
|
||||||
|
Create Date: 2026-02-08 20:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'f5a6b7c8d9e0'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'e4f5a6b7c8d9'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('boss_battles', sa.Column('section', sa.String(100), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('boss_battles', 'section')
|
||||||
@@ -24,6 +24,7 @@ class BossBattle(Base):
|
|||||||
ForeignKey("routes.id"), index=True, default=None
|
ForeignKey("routes.id"), index=True, default=None
|
||||||
)
|
)
|
||||||
location: Mapped[str] = mapped_column(String(200))
|
location: Mapped[str] = mapped_column(String(200))
|
||||||
|
section: Mapped[str | None] = mapped_column(String(100), default=None)
|
||||||
sprite_url: Mapped[str | None] = mapped_column(String(500))
|
sprite_url: Mapped[str | None] = mapped_column(String(500))
|
||||||
|
|
||||||
version_group: Mapped["VersionGroup"] = relationship(
|
version_group: Mapped["VersionGroup"] = relationship(
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class BossBattleResponse(CamelModel):
|
|||||||
order: int
|
order: int
|
||||||
after_route_id: int | None
|
after_route_id: int | None
|
||||||
location: str
|
location: str
|
||||||
|
section: str | None
|
||||||
sprite_url: str | None
|
sprite_url: str | None
|
||||||
pokemon: list[BossPokemonResponse] = []
|
pokemon: list[BossPokemonResponse] = []
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ class BossBattleCreate(CamelModel):
|
|||||||
order: int
|
order: int
|
||||||
after_route_id: int | None = None
|
after_route_id: int | None = None
|
||||||
location: str
|
location: str
|
||||||
|
section: str | None = None
|
||||||
sprite_url: str | None = None
|
sprite_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -60,6 +62,7 @@ class BossBattleUpdate(CamelModel):
|
|||||||
order: int | None = None
|
order: int | None = None
|
||||||
after_route_id: int | None = None
|
after_route_id: int | None = None
|
||||||
location: str | None = None
|
location: str | None = None
|
||||||
|
section: str | None = None
|
||||||
sprite_url: str | None = None
|
sprite_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -75,6 +78,15 @@ class BossResultCreate(CamelModel):
|
|||||||
attempts: int = 1
|
attempts: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
class BossReorderItem(CamelModel):
|
||||||
|
id: int
|
||||||
|
order: int
|
||||||
|
|
||||||
|
|
||||||
|
class BossReorderRequest(CamelModel):
|
||||||
|
bosses: list[BossReorderItem]
|
||||||
|
|
||||||
|
|
||||||
class BossResultUpdate(CamelModel):
|
class BossResultUpdate(CamelModel):
|
||||||
result: str | None = None
|
result: str | None = None
|
||||||
attempts: int | None = None
|
attempts: int | None = None
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function BossBattleFormModal({
|
|||||||
const [order, setOrder] = useState(String(boss?.order ?? nextOrder))
|
const [order, setOrder] = useState(String(boss?.order ?? nextOrder))
|
||||||
const [afterRouteId, setAfterRouteId] = useState(String(boss?.afterRouteId ?? ''))
|
const [afterRouteId, setAfterRouteId] = useState(String(boss?.afterRouteId ?? ''))
|
||||||
const [location, setLocation] = useState(boss?.location ?? '')
|
const [location, setLocation] = useState(boss?.location ?? '')
|
||||||
|
const [section, setSection] = useState(boss?.section ?? '')
|
||||||
const [spriteUrl, setSpriteUrl] = useState(boss?.spriteUrl ?? '')
|
const [spriteUrl, setSpriteUrl] = useState(boss?.spriteUrl ?? '')
|
||||||
|
|
||||||
const handleSubmit = (e: FormEvent) => {
|
const handleSubmit = (e: FormEvent) => {
|
||||||
@@ -56,6 +57,7 @@ export function BossBattleFormModal({
|
|||||||
order: Number(order),
|
order: Number(order),
|
||||||
afterRouteId: afterRouteId ? Number(afterRouteId) : null,
|
afterRouteId: afterRouteId ? Number(afterRouteId) : null,
|
||||||
location,
|
location,
|
||||||
|
section: section || null,
|
||||||
spriteUrl: spriteUrl || null,
|
spriteUrl: spriteUrl || null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -146,6 +148,17 @@ export function BossBattleFormModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Section</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={section}
|
||||||
|
onChange={(e) => setSection(e.target.value)}
|
||||||
|
placeholder="e.g. Main Story, Endgame"
|
||||||
|
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>
|
<label className="block text-sm font-medium mb-1">Position After Route</label>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -459,6 +459,20 @@ export function RunEncounters() {
|
|||||||
return nextBoss.levelCap
|
return nextBoss.levelCap
|
||||||
}, [nextBoss, sortedBosses])
|
}, [nextBoss, sortedBosses])
|
||||||
|
|
||||||
|
// Pre-compute which bosses get a section divider rendered AFTER them
|
||||||
|
// (when the next boss in order has a different section)
|
||||||
|
const sectionDividerAfterBoss = useMemo(() => {
|
||||||
|
const map = new Map<number, string>()
|
||||||
|
for (let i = 0; i < sortedBosses.length - 1; i++) {
|
||||||
|
const current = sortedBosses[i]
|
||||||
|
const next = sortedBosses[i + 1]
|
||||||
|
if (next.section != null && current.section !== next.section) {
|
||||||
|
map.set(current.id, next.section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [sortedBosses])
|
||||||
|
|
||||||
// Map afterRouteId → BossBattle[] for interleaving
|
// Map afterRouteId → BossBattle[] for interleaving
|
||||||
const bossesAfterRoute = useMemo(() => {
|
const bossesAfterRoute = useMemo(() => {
|
||||||
const map = new Map<number, BossBattle[]>()
|
const map = new Map<number, BossBattle[]>()
|
||||||
@@ -1023,6 +1037,7 @@ export function RunEncounters() {
|
|||||||
{/* Boss battle cards after this route */}
|
{/* Boss battle cards after this route */}
|
||||||
{bossesHere.map((boss) => {
|
{bossesHere.map((boss) => {
|
||||||
const isDefeated = defeatedBossIds.has(boss.id)
|
const isDefeated = defeatedBossIds.has(boss.id)
|
||||||
|
const sectionAfter = sectionDividerAfterBoss.get(boss.id)
|
||||||
const bossTypeLabel: Record<string, string> = {
|
const bossTypeLabel: Record<string, string> = {
|
||||||
gym_leader: 'Gym Leader',
|
gym_leader: 'Gym Leader',
|
||||||
elite_four: 'Elite Four',
|
elite_four: 'Elite Four',
|
||||||
@@ -1051,12 +1066,12 @@ export function RunEncounters() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={`boss-${boss.id}`}>
|
||||||
key={`boss-${boss.id}`}
|
<div
|
||||||
className={`my-2 rounded-lg border-2 ${bossTypeColors[boss.bossType] ?? bossTypeColors.other} ${
|
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'
|
isDefeated ? 'bg-green-50/50 dark:bg-green-900/10' : 'bg-white dark:bg-gray-800'
|
||||||
} px-4 py-3`}
|
} px-4 py-3`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex items-start justify-between cursor-pointer select-none"
|
className="flex items-start justify-between cursor-pointer select-none"
|
||||||
onClick={toggleBoss}
|
onClick={toggleBoss}
|
||||||
@@ -1122,6 +1137,14 @@ export function RunEncounters() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
{sectionAfter && (
|
||||||
|
<div className="flex items-center gap-3 my-4">
|
||||||
|
<div className="flex-1 h-px bg-gray-300 dark:bg-gray-600" />
|
||||||
|
<span className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">{sectionAfter}</span>
|
||||||
|
<div className="flex-1 h-px bg-gray-300 dark:bg-gray-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
useUpdateRoute,
|
useUpdateRoute,
|
||||||
useDeleteRoute,
|
useDeleteRoute,
|
||||||
useReorderRoutes,
|
useReorderRoutes,
|
||||||
|
useReorderBosses,
|
||||||
useCreateBossBattle,
|
useCreateBossBattle,
|
||||||
useUpdateBossBattle,
|
useUpdateBossBattle,
|
||||||
useDeleteBossBattle,
|
useDeleteBossBattle,
|
||||||
@@ -82,6 +83,59 @@ function SortableRouteRow({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SortableBossRow({
|
||||||
|
boss,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
boss: BossBattle
|
||||||
|
onClick: (b: BossBattle) => void
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||||
|
useSortable({ id: boss.id })
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`${isDragging ? 'opacity-50 bg-blue-50 dark:bg-blue-900/20' : ''} hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer`}
|
||||||
|
onClick={() => onClick(boss)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 text-sm w-12">
|
||||||
|
<button
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 touch-none"
|
||||||
|
title="Drag to reorder"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<circle cx="5" cy="3" r="1.5" />
|
||||||
|
<circle cx="11" cy="3" r="1.5" />
|
||||||
|
<circle cx="5" cy="8" r="1.5" />
|
||||||
|
<circle cx="11" cy="8" r="1.5" />
|
||||||
|
<circle cx="5" cy="13" r="1.5" />
|
||||||
|
<circle cx="11" cy="13" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<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.section ?? '\u2014'}</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>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function AdminGameDetail() {
|
export function AdminGameDetail() {
|
||||||
const { gameId } = useParams<{ gameId: string }>()
|
const { gameId } = useParams<{ gameId: string }>()
|
||||||
const id = Number(gameId)
|
const id = Number(gameId)
|
||||||
@@ -95,6 +149,7 @@ export function AdminGameDetail() {
|
|||||||
const createBoss = useCreateBossBattle(id)
|
const createBoss = useCreateBossBattle(id)
|
||||||
const updateBoss = useUpdateBossBattle(id)
|
const updateBoss = useUpdateBossBattle(id)
|
||||||
const deleteBoss = useDeleteBossBattle(id)
|
const deleteBoss = useDeleteBossBattle(id)
|
||||||
|
const reorderBosses = useReorderBosses(id)
|
||||||
|
|
||||||
const [tab, setTab] = useState<'routes' | 'bosses'>('routes')
|
const [tab, setTab] = useState<'routes' | 'bosses'>('routes')
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
@@ -133,6 +188,26 @@ export function AdminGameDetail() {
|
|||||||
reorderRoutes.mutate(newOrders)
|
reorderRoutes.mutate(newOrders)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleBossDragEnd = (event: DragEndEvent) => {
|
||||||
|
if (!bosses) return
|
||||||
|
const { active, over } = event
|
||||||
|
if (!over || active.id === over.id) return
|
||||||
|
|
||||||
|
const oldIndex = bosses.findIndex((b) => b.id === active.id)
|
||||||
|
const newIndex = bosses.findIndex((b) => b.id === over.id)
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return
|
||||||
|
|
||||||
|
const reordered = [...bosses]
|
||||||
|
const [moved] = reordered.splice(oldIndex, 1)
|
||||||
|
reordered.splice(newIndex, 0, moved)
|
||||||
|
|
||||||
|
const newOrders = reordered.map((b, i) => ({
|
||||||
|
id: b.id,
|
||||||
|
order: i + 1,
|
||||||
|
}))
|
||||||
|
reorderBosses.mutate(newOrders)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<nav className="text-sm mb-4 text-gray-500 dark:text-gray-400">
|
<nav className="text-sm mb-4 text-gray-500 dark:text-gray-400">
|
||||||
@@ -304,6 +379,7 @@ export function AdminGameDetail() {
|
|||||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-12" />
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-16">
|
<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
|
Order
|
||||||
</th>
|
</th>
|
||||||
@@ -313,6 +389,9 @@ export function AdminGameDetail() {
|
|||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
Type
|
Type
|
||||||
</th>
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Section
|
||||||
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
Location
|
Location
|
||||||
</th>
|
</th>
|
||||||
@@ -324,24 +403,26 @@ export function AdminGameDetail() {
|
|||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
<DndContext
|
||||||
{bosses.map((boss) => (
|
sensors={sensors}
|
||||||
<tr
|
collisionDetection={closestCenter}
|
||||||
key={boss.id}
|
onDragEnd={handleBossDragEnd}
|
||||||
className="hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer"
|
>
|
||||||
onClick={() => setEditingBoss(boss)}
|
<SortableContext
|
||||||
>
|
items={bosses.map((b) => b.id)}
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.order}</td>
|
strategy={verticalListSortingStrategy}
|
||||||
<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">
|
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{boss.bossType.replace('_', ' ')}
|
{bosses.map((boss) => (
|
||||||
</td>
|
<SortableBossRow
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.location}</td>
|
key={boss.id}
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.levelCap}</td>
|
boss={boss}
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.pokemon.length}</td>
|
onClick={(b) => setEditingBoss(b)}
|
||||||
</tr>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ export interface CreateBossBattleInput {
|
|||||||
order: number
|
order: number
|
||||||
afterRouteId?: number | null
|
afterRouteId?: number | null
|
||||||
location: string
|
location: string
|
||||||
|
section?: string | null
|
||||||
spriteUrl?: string | null
|
spriteUrl?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,9 +160,15 @@ export interface UpdateBossBattleInput {
|
|||||||
order?: number
|
order?: number
|
||||||
afterRouteId?: number | null
|
afterRouteId?: number | null
|
||||||
location?: string
|
location?: string
|
||||||
|
section?: string | null
|
||||||
spriteUrl?: string | null
|
spriteUrl?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BossReorderItem {
|
||||||
|
id: number
|
||||||
|
order: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface BossPokemonInput {
|
export interface BossPokemonInput {
|
||||||
pokemonId: number
|
pokemonId: number
|
||||||
level: number
|
level: number
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ export interface BossBattle {
|
|||||||
order: number
|
order: number
|
||||||
afterRouteId: number | null
|
afterRouteId: number | null
|
||||||
location: string
|
location: string
|
||||||
|
section: string | null
|
||||||
spriteUrl: string | null
|
spriteUrl: string | null
|
||||||
pokemon: BossPokemon[]
|
pokemon: BossPokemon[]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user