Add conditional boss battle teams (variant teams by condition)

Wire up the existing condition_label column on boss_pokemon to support
variant teams throughout the UI. Boss battles can now have multiple team
configurations based on conditions (e.g., starter choice in Gen 1).

- Add condition_label to BossPokemonInput schema (frontend + backend bulk import)
- Rewrite BossTeamEditor with variant tabs (Default + named conditions)
- Add variant pill selector to BossDefeatModal team preview
- Add BossTeamPreview component to RunEncounters boss cards
- Fix MissingGreenlet error in set_boss_team via session.expunge_all()
- Fix PokemonSelector state bleed between tabs via composite React key
- Add Alembic migration for condition_label column

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 21:20:30 +01:00
parent 8931424ef4
commit a6bf8b4af2
14 changed files with 309 additions and 52 deletions

View File

@@ -1,4 +1,4 @@
import { type FormEvent, useState } from 'react'
import { type FormEvent, useState, useMemo } from 'react'
import type { BossBattle, CreateBossResultInput } from '../types/game'
interface BossDefeatModalProps {
@@ -13,6 +13,26 @@ export function BossDefeatModal({ boss, onSubmit, onClose, isPending, hardcoreMo
const [result, setResult] = useState<'won' | 'lost'>('won')
const [attempts, setAttempts] = useState('1')
const variantLabels = useMemo(() => {
const labels = new Set<string>()
for (const bp of boss.pokemon) {
if (bp.conditionLabel) labels.add(bp.conditionLabel)
}
return [...labels].sort()
}, [boss.pokemon])
const hasVariants = variantLabels.length > 0
const [selectedVariant, setSelectedVariant] = useState<string | null>(
hasVariants ? variantLabels[0] : null,
)
const displayedPokemon = useMemo(() => {
if (!hasVariants) return boss.pokemon
return boss.pokemon.filter(
(bp) => bp.conditionLabel === selectedVariant || bp.conditionLabel === null,
)
}, [boss.pokemon, hasVariants, selectedVariant])
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
onSubmit({
@@ -34,8 +54,26 @@ export function BossDefeatModal({ boss, onSubmit, onClose, isPending, hardcoreMo
{/* Boss team preview */}
{boss.pokemon.length > 0 && (
<div className="px-6 py-3 border-b border-gray-200 dark:border-gray-700">
{hasVariants && (
<div className="flex gap-1 mb-2 flex-wrap">
{variantLabels.map((label) => (
<button
key={label}
type="button"
onClick={() => setSelectedVariant(label)}
className={`px-2 py-0.5 text-xs font-medium rounded-full transition-colors ${
selectedVariant === label
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
{label}
</button>
))}
</div>
)}
<div className="flex flex-wrap gap-3">
{boss.pokemon
{[...displayedPokemon]
.sort((a, b) => a.order - b.order)
.map((bp) => (
<div key={bp.id} className="flex flex-col items-center">

View File

@@ -10,47 +10,117 @@ interface BossTeamEditorProps {
isSaving?: boolean
}
interface PokemonSlot {
pokemonId: number | null
pokemonName: string
level: string
order: number
}
interface Variant {
label: string | null
pokemon: PokemonSlot[]
}
function groupByVariant(boss: BossBattle): Variant[] {
const sorted = [...boss.pokemon].sort((a, b) => a.order - b.order)
const map = new Map<string | null, PokemonSlot[]>()
for (const bp of sorted) {
const key = bp.conditionLabel
if (!map.has(key)) map.set(key, [])
map.get(key)!.push({
pokemonId: bp.pokemonId,
pokemonName: bp.pokemon.name,
level: String(bp.level),
order: bp.order,
})
}
if (map.size === 0) {
return [{ label: null, pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }] }]
}
const variants: Variant[] = []
// null (default) first
if (map.has(null)) {
variants.push({ label: null, pokemon: map.get(null)! })
map.delete(null)
}
// Then alphabetical
const remaining = [...map.entries()].sort((a, b) => (a[0] ?? '').localeCompare(b[0] ?? ''))
for (const [label, pokemon] of remaining) {
variants.push({ label, pokemon })
}
return variants
}
export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEditorProps) {
const [team, setTeam] = useState<Array<{ pokemonId: number | null; pokemonName: string; level: string; order: number }>>(
boss.pokemon.length > 0
? boss.pokemon
.sort((a, b) => a.order - b.order)
.map((bp) => ({
pokemonId: bp.pokemonId,
pokemonName: bp.pokemon.name,
level: String(bp.level),
order: bp.order,
}))
: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }],
)
const [variants, setVariants] = useState<Variant[]>(() => groupByVariant(boss))
const [activeTab, setActiveTab] = useState(0)
const [newVariantName, setNewVariantName] = useState('')
const [showAddVariant, setShowAddVariant] = useState(false)
const activeVariant = variants[activeTab] ?? variants[0]
const updateVariant = (tabIndex: number, updater: (v: Variant) => Variant) => {
setVariants((prev) => prev.map((v, i) => (i === tabIndex ? updater(v) : v)))
}
const addSlot = () => {
setTeam((prev) => [
...prev,
{ pokemonId: null, pokemonName: '', level: '', order: prev.length + 1 },
])
updateVariant(activeTab, (v) => ({
...v,
pokemon: [...v.pokemon, { pokemonId: null, pokemonName: '', level: '', order: v.pokemon.length + 1 }],
}))
}
const removeSlot = (index: number) => {
setTeam((prev) => prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, order: i + 1 })))
updateVariant(activeTab, (v) => ({
...v,
pokemon: v.pokemon.filter((_, i) => i !== index).map((item, i) => ({ ...item, order: i + 1 })),
}))
}
const updateSlot = (index: number, field: string, value: number | string | null) => {
setTeam((prev) =>
prev.map((item, i) => (i === index ? { ...item, [field]: value } : item)),
)
updateVariant(activeTab, (v) => ({
...v,
pokemon: v.pokemon.map((item, i) => (i === index ? { ...item, [field]: value } : item)),
}))
}
const addVariant = () => {
const name = newVariantName.trim()
if (!name) return
if (variants.some((v) => v.label === name)) return
setVariants((prev) => [...prev, { label: name, pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }] }])
setActiveTab(variants.length)
setNewVariantName('')
setShowAddVariant(false)
}
const removeVariant = (tabIndex: number) => {
if (variants[tabIndex].label === null) return
if (!window.confirm(`Remove variant "${variants[tabIndex].label}"?`)) return
setVariants((prev) => prev.filter((_, i) => i !== tabIndex))
setActiveTab((prev) => Math.min(prev, variants.length - 2))
}
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
const validTeam: BossPokemonInput[] = team
.filter((t) => t.pokemonId != null && t.level)
.map((t, i) => ({
pokemonId: t.pokemonId!,
level: Number(t.level),
order: i + 1,
}))
onSave(validTeam)
const allPokemon: BossPokemonInput[] = []
for (const variant of variants) {
const conditionLabel = variants.length === 1 && variant.label === null ? null : variant.label
const validPokemon = variant.pokemon.filter((t) => t.pokemonId != null && t.level)
for (let i = 0; i < validPokemon.length; i++) {
allPokemon.push({
pokemonId: validPokemon[i].pokemonId!,
level: Number(validPokemon[i].level),
order: i + 1,
conditionLabel,
})
}
}
onSave(allPokemon)
}
return (
@@ -61,10 +131,61 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
<h2 className="text-lg font-semibold">{boss.name}'s Team</h2>
</div>
{/* Variant tabs */}
<div className="px-6 pt-3 flex items-center gap-1 flex-wrap border-b border-gray-200 dark:border-gray-700">
{variants.map((v, i) => (
<button
key={v.label ?? '__default'}
type="button"
onClick={() => setActiveTab(i)}
className={`px-3 py-1.5 text-sm font-medium rounded-t-md border border-b-0 transition-colors ${
activeTab === i
? 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-900 dark:text-gray-100'
: 'bg-gray-50 dark:bg-gray-700 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
{v.label ?? 'Default'}
{v.label !== null && (
<span
onClick={(e) => { e.stopPropagation(); removeVariant(i) }}
className="ml-1.5 text-gray-400 hover:text-red-500 cursor-pointer"
title="Remove variant"
>
&#10005;
</span>
)}
</button>
))}
{!showAddVariant ? (
<button
type="button"
onClick={() => setShowAddVariant(true)}
className="px-2 py-1.5 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
title="Add variant"
>
+
</button>
) : (
<div className="flex items-center gap-1 pb-1">
<input
type="text"
value={newVariantName}
onChange={(e) => setNewVariantName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addVariant() } if (e.key === 'Escape') setShowAddVariant(false) }}
placeholder="Variant name..."
className="px-2 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600 w-40"
autoFocus
/>
<button type="button" onClick={addVariant} className="px-2 py-1 text-sm text-blue-600 dark:text-blue-400">Add</button>
<button type="button" onClick={() => setShowAddVariant(false)} className="px-1 py-1 text-sm text-gray-400">&#10005;</button>
</div>
)}
</div>
<form onSubmit={handleSubmit}>
<div className="px-6 py-4 space-y-3">
{team.map((slot, index) => (
<div key={index} className="flex items-end gap-2">
{activeVariant.pokemon.map((slot, index) => (
<div key={`${activeTab}-${index}`} className="flex items-end gap-2">
<div className="flex-1">
<PokemonSelector
label={`Pokemon ${index + 1}`}
@@ -95,7 +216,7 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
</div>
))}
{team.length < 6 && (
{activeVariant.pokemon.length < 6 && (
<button
type="button"
onClick={addSlot}

View File

@@ -26,6 +26,7 @@ import type {
EncounterStatus,
CreateEncounterInput,
BossBattle,
BossPokemon,
} from '../types'
const statusStyles: Record<RunStatus, string> = {
@@ -145,6 +146,67 @@ function countDistinctZones(group: RouteWithChildren): number {
return zones.size
}
function BossTeamPreview({ pokemon }: { pokemon: BossPokemon[] }) {
const variantLabels = useMemo(() => {
const labels = new Set<string>()
for (const bp of pokemon) {
if (bp.conditionLabel) labels.add(bp.conditionLabel)
}
return [...labels].sort()
}, [pokemon])
const hasVariants = variantLabels.length > 0
const [selectedVariant, setSelectedVariant] = useState<string | null>(
hasVariants ? variantLabels[0] : null,
)
const displayed = useMemo(() => {
if (!hasVariants) return pokemon
return pokemon.filter(
(bp) => bp.conditionLabel === selectedVariant || bp.conditionLabel === null,
)
}, [pokemon, hasVariants, selectedVariant])
return (
<div className="mt-2">
{hasVariants && (
<div className="flex gap-1 mb-2 flex-wrap">
{variantLabels.map((label) => (
<button
key={label}
type="button"
onClick={() => setSelectedVariant(label)}
className={`px-2 py-0.5 text-xs font-medium rounded-full transition-colors ${
selectedVariant === label
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
{label}
</button>
))}
</div>
)}
<div className="flex gap-2 flex-wrap">
{[...displayed]
.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-20 h-20" />
) : (
<div className="w-20 h-20 bg-gray-200 dark:bg-gray-700 rounded-full" />
)}
<span className="text-xs text-gray-500 dark:text-gray-400">
Lvl {bp.level}
</span>
</div>
))}
</div>
</div>
)
}
interface RouteGroupProps {
group: RouteWithChildren
encounterByRoute: Map<number, EncounterDetail>
@@ -1124,22 +1186,7 @@ export function RunEncounters() {
</div>
{/* Boss pokemon team */}
{isBossExpanded && 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-20 h-20" />
) : (
<div className="w-20 h-20 bg-gray-200 dark:bg-gray-700 rounded-full" />
)}
<span className="text-xs text-gray-500 dark:text-gray-400">
Lvl {bp.level}
</span>
</div>
))}
</div>
<BossTeamPreview pokemon={boss.pokemon} />
)}
</div>
{sectionAfter && (

View File

@@ -176,4 +176,5 @@ export interface BossPokemonInput {
pokemonId: number
level: number
order: number
conditionLabel?: string | null
}

View File

@@ -137,6 +137,7 @@ export interface BossPokemon {
pokemonId: number
level: number
order: number
conditionLabel: string | null
pokemon: Pokemon
}