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:
@@ -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')
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} · Level Cap: {boss.levelCap}
|
{boss.location} · Level Cap: {boss.levelCap}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user