Add optional specialty type field to boss battles

Gym leaders, Elite Four, and champions can now have a Pokemon type
specialty (e.g. Rock, Water). Shown as a type image badge on boss
cards in the run view, and editable via dropdown in the admin form.
Includes migration, export, and seed pipeline support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 15:23:59 +01:00
parent 1a7476f811
commit 0e4fac8790
10 changed files with 65 additions and 1 deletions

View File

@@ -0,0 +1,26 @@
"""add specialty_type to boss battles
Revision ID: a6b7c8d9e0f1
Revises: f5a6b7c8d9e0
Create Date: 2026-02-08 21:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'a6b7c8d9e0f1'
down_revision: Union[str, Sequence[str], None] = 'f5a6b7c8d9e0'
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('specialty_type', sa.String(20), nullable=True))
def downgrade() -> None:
op.drop_column('boss_battles', 'specialty_type')

View File

@@ -138,6 +138,7 @@ async def export_game_bosses(
{ {
"name": b.name, "name": b.name,
"boss_type": b.boss_type, "boss_type": b.boss_type,
"specialty_type": b.specialty_type,
"badge_name": b.badge_name, "badge_name": b.badge_name,
"badge_image_url": b.badge_image_url, "badge_image_url": b.badge_image_url,
"level_cap": b.level_cap, "level_cap": b.level_cap,

View File

@@ -16,6 +16,7 @@ class BossBattle(Base):
) )
name: Mapped[str] = mapped_column(String(100)) name: Mapped[str] = mapped_column(String(100))
boss_type: Mapped[str] = mapped_column(String(20)) # gym_leader, elite_four, champion, rival, evil_team, other boss_type: Mapped[str] = mapped_column(String(20)) # gym_leader, elite_four, champion, rival, evil_team, other
specialty_type: Mapped[str | None] = mapped_column(String(20), default=None) # pokemon type specialty (e.g. rock, water)
badge_name: Mapped[str | None] = mapped_column(String(100)) badge_name: Mapped[str | None] = mapped_column(String(100))
badge_image_url: Mapped[str | None] = mapped_column(String(500)) badge_image_url: Mapped[str | None] = mapped_column(String(500))
level_cap: Mapped[int] = mapped_column(SmallInteger) level_cap: Mapped[int] = mapped_column(SmallInteger)

View File

@@ -17,6 +17,7 @@ class BossBattleResponse(CamelModel):
version_group_id: int version_group_id: int
name: str name: str
boss_type: str boss_type: str
specialty_type: str | None
badge_name: str | None badge_name: str | None
badge_image_url: str | None badge_image_url: str | None
level_cap: int level_cap: int
@@ -43,6 +44,7 @@ class BossResultResponse(CamelModel):
class BossBattleCreate(CamelModel): class BossBattleCreate(CamelModel):
name: str name: str
boss_type: str boss_type: str
specialty_type: str | None = None
badge_name: str | None = None badge_name: str | None = None
badge_image_url: str | None = None badge_image_url: str | None = None
level_cap: int level_cap: int
@@ -56,6 +58,7 @@ class BossBattleCreate(CamelModel):
class BossBattleUpdate(CamelModel): class BossBattleUpdate(CamelModel):
name: str | None = None name: str | None = None
boss_type: str | None = None boss_type: str | None = None
specialty_type: str | None = None
badge_name: str | None = None badge_name: str | None = None
badge_image_url: str | None = None badge_image_url: str | None = None
level_cap: int | None = None level_cap: int | None = None

View File

@@ -220,6 +220,7 @@ async def upsert_bosses(
version_group_id=version_group_id, version_group_id=version_group_id,
name=boss["name"], name=boss["name"],
boss_type=boss["boss_type"], boss_type=boss["boss_type"],
specialty_type=boss.get("specialty_type"),
badge_name=boss.get("badge_name"), badge_name=boss.get("badge_name"),
badge_image_url=boss.get("badge_image_url"), badge_image_url=boss.get("badge_image_url"),
level_cap=boss["level_cap"], level_cap=boss["level_cap"],
@@ -232,6 +233,7 @@ async def upsert_bosses(
set_={ set_={
"name": boss["name"], "name": boss["name"],
"boss_type": boss["boss_type"], "boss_type": boss["boss_type"],
"specialty_type": boss.get("specialty_type"),
"badge_name": boss.get("badge_name"), "badge_name": boss.get("badge_name"),
"badge_image_url": boss.get("badge_image_url"), "badge_image_url": boss.get("badge_image_url"),
"level_cap": boss["level_cap"], "level_cap": boss["level_cap"],

View File

@@ -429,6 +429,7 @@ async def _export_bosses(session: AsyncSession, vg_data: dict):
{ {
"name": b.name, "name": b.name,
"boss_type": b.boss_type, "boss_type": b.boss_type,
"specialty_type": b.specialty_type,
"badge_name": b.badge_name, "badge_name": b.badge_name,
"badge_image_url": b.badge_image_url, "badge_image_url": b.badge_image_url,
"level_cap": b.level_cap, "level_cap": b.level_cap,

View File

@@ -15,6 +15,12 @@ interface BossBattleFormModalProps {
onEditTeam?: () => void onEditTeam?: () => void
} }
const POKEMON_TYPES = [
'normal', 'fire', 'water', 'electric', 'grass', 'ice',
'fighting', 'poison', 'ground', 'flying', 'psychic', 'bug',
'rock', 'ghost', 'dragon', 'dark', 'steel', 'fairy',
]
const BOSS_TYPES = [ const BOSS_TYPES = [
{ value: 'gym_leader', label: 'Gym Leader' }, { value: 'gym_leader', label: 'Gym Leader' },
{ value: 'elite_four', label: 'Elite Four' }, { value: 'elite_four', label: 'Elite Four' },
@@ -37,6 +43,7 @@ export function BossBattleFormModal({
}: BossBattleFormModalProps) { }: BossBattleFormModalProps) {
const [name, setName] = useState(boss?.name ?? '') const [name, setName] = useState(boss?.name ?? '')
const [bossType, setBossType] = useState(boss?.bossType ?? 'gym_leader') const [bossType, setBossType] = useState(boss?.bossType ?? 'gym_leader')
const [specialtyType, setSpecialtyType] = useState(boss?.specialtyType ?? '')
const [badgeName, setBadgeName] = useState(boss?.badgeName ?? '') const [badgeName, setBadgeName] = useState(boss?.badgeName ?? '')
const [badgeImageUrl, setBadgeImageUrl] = useState(boss?.badgeImageUrl ?? '') const [badgeImageUrl, setBadgeImageUrl] = useState(boss?.badgeImageUrl ?? '')
const [levelCap, setLevelCap] = useState(String(boss?.levelCap ?? '')) const [levelCap, setLevelCap] = useState(String(boss?.levelCap ?? ''))
@@ -51,6 +58,7 @@ export function BossBattleFormModal({
onSubmit({ onSubmit({
name, name,
bossType, bossType,
specialtyType: specialtyType || null,
badgeName: badgeName || null, badgeName: badgeName || null,
badgeImageUrl: badgeImageUrl || null, badgeImageUrl: badgeImageUrl || null,
levelCap: Number(levelCap), levelCap: Number(levelCap),
@@ -83,7 +91,7 @@ export function BossBattleFormModal({
</button> </button>
) : undefined} ) : undefined}
> >
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-3 gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1">Name</label> <label className="block text-sm font-medium mb-1">Name</label>
<input <input
@@ -109,6 +117,21 @@ export function BossBattleFormModal({
))} ))}
</select> </select>
</div> </div>
<div>
<label className="block text-sm font-medium mb-1">Specialty</label>
<select
value={specialtyType}
onChange={(e) => setSpecialtyType(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600 capitalize"
>
<option value="">None</option>
{POKEMON_TYPES.map((t) => (
<option key={t} value={t} className="capitalize">
{t.charAt(0).toUpperCase() + t.slice(1)}
</option>
))}
</select>
</div>
</div> </div>
<div> <div>

View File

@@ -15,6 +15,7 @@ import {
RuleBadges, RuleBadges,
ShinyBox, ShinyBox,
ShinyEncounterModal, ShinyEncounterModal,
TypeBadge,
} from '../components' } from '../components'
import { BossDefeatModal } from '../components/BossDefeatModal' import { BossDefeatModal } from '../components/BossDefeatModal'
import type { import type {
@@ -1097,6 +1098,9 @@ export function RunEncounters() {
<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"> <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} {bossTypeLabel[boss.bossType] ?? boss.bossType}
</span> </span>
{boss.specialtyType && (
<TypeBadge type={boss.specialtyType} />
)}
</div> </div>
<p className="text-xs text-gray-500 dark:text-gray-400"> <p className="text-xs text-gray-500 dark:text-gray-400">
{boss.location} &middot; Level Cap: {boss.levelCap} {boss.location} &middot; Level Cap: {boss.levelCap}

View File

@@ -141,6 +141,7 @@ export interface PokemonEncounterLocation {
export interface CreateBossBattleInput { export interface CreateBossBattleInput {
name: string name: string
bossType: string bossType: string
specialtyType?: string | null
badgeName?: string | null badgeName?: string | null
badgeImageUrl?: string | null badgeImageUrl?: string | null
levelCap: number levelCap: number
@@ -154,6 +155,7 @@ export interface CreateBossBattleInput {
export interface UpdateBossBattleInput { export interface UpdateBossBattleInput {
name?: string name?: string
bossType?: string bossType?: string
specialtyType?: string | null
badgeName?: string | null badgeName?: string | null
badgeImageUrl?: string | null badgeImageUrl?: string | null
levelCap?: number levelCap?: number

View File

@@ -145,6 +145,7 @@ export interface BossBattle {
versionGroupId: number versionGroupId: number
name: string name: string
bossType: BossType bossType: BossType
specialtyType: string | null
badgeName: string | null badgeName: string | null
badgeImageUrl: string | null badgeImageUrl: string | null
levelCap: number levelCap: number