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:
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-g57d
|
||||||
|
title: Implement conditional boss battle teams
|
||||||
|
status: completed
|
||||||
|
type: feature
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-02-08T19:51:02Z
|
||||||
|
updated_at: 2026-02-08T19:54:52Z
|
||||||
|
---
|
||||||
|
|
||||||
|
Wire up the existing condition_label column on boss_pokemon to support variant teams in the UI. This includes: admin BossTeamEditor with variant tabs, BossDefeatModal with variant selector, RunEncounters boss card variant display, and schema additions for BulkBossPokemonItem and frontend BossPokemonInput.
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-x8ol
|
# nuzlocke-tracker-x8ol
|
||||||
title: Conditional boss battle teams
|
title: Conditional boss battle teams
|
||||||
status: draft
|
status: in-progress
|
||||||
type: feature
|
type: feature
|
||||||
priority: normal
|
priority: normal
|
||||||
created_at: 2026-02-08T13:23:00Z
|
created_at: 2026-02-08T13:23:00Z
|
||||||
updated_at: 2026-02-08T13:29:26Z
|
updated_at: 2026-02-08T19:34:27Z
|
||||||
---
|
---
|
||||||
|
|
||||||
Some boss battles have teams that vary based on conditions in the player's run. The most common case is starter choice (e.g., Blue's team in Gen 1 depends on whether you picked Bulbasaur, Charmander, or Squirtle), but other conditions exist too — in Pokemon Yellow, the rival's team changes based on the outcomes of two early-game fights, not the starter. This feature adds support for defining multiple team variants per boss battle, each associated with a named condition.
|
Some boss battles have teams that vary based on conditions in the player's run. The most common case is starter choice (e.g., Blue's team in Gen 1 depends on whether you picked Bulbasaur, Charmander, or Squirtle), but other conditions exist too — in Pokemon Yellow, the rival's team changes based on the outcomes of two early-game fights, not the starter. This feature adds support for defining multiple team variants per boss battle, each associated with a named condition.
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""add condition_label to boss_pokemon
|
||||||
|
|
||||||
|
Revision ID: b7c8d9e0f1a2
|
||||||
|
Revises: a6b7c8d9e0f1
|
||||||
|
Create Date: 2026-02-08 22:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'b7c8d9e0f1a2'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'a6b7c8d9e0f1'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('boss_pokemon', sa.Column('condition_label', sa.String(100), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('boss_pokemon', 'condition_label')
|
||||||
@@ -228,11 +228,17 @@ async def set_boss_team(
|
|||||||
pokemon_id=item.pokemon_id,
|
pokemon_id=item.pokemon_id,
|
||||||
level=item.level,
|
level=item.level,
|
||||||
order=item.order,
|
order=item.order,
|
||||||
|
condition_label=item.condition_label,
|
||||||
)
|
)
|
||||||
session.add(bp)
|
session.add(bp)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
# Clear identity map so selectinload fetches everything fresh
|
||||||
|
# (expired Pokemon from deleted BossPokemon would otherwise cause
|
||||||
|
# MissingGreenlet errors during response serialization)
|
||||||
|
session.expunge_all()
|
||||||
|
|
||||||
# Re-fetch with eager loading
|
# Re-fetch with eager loading
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(BossBattle)
|
select(BossBattle)
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ async def export_game_bosses(
|
|||||||
"pokemon_name": bp.pokemon.name,
|
"pokemon_name": bp.pokemon.name,
|
||||||
"level": bp.level,
|
"level": bp.level,
|
||||||
"order": bp.order,
|
"order": bp.order,
|
||||||
|
**({"condition_label": bp.condition_label} if bp.condition_label else {}),
|
||||||
}
|
}
|
||||||
for bp in sorted(b.pokemon, key=lambda p: p.order)
|
for bp in sorted(b.pokemon, key=lambda p: p.order)
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import ForeignKey, SmallInteger
|
from sqlalchemy import ForeignKey, SmallInteger, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@@ -14,6 +14,7 @@ class BossPokemon(Base):
|
|||||||
pokemon_id: Mapped[int] = mapped_column(ForeignKey("pokemon.id"), index=True)
|
pokemon_id: Mapped[int] = mapped_column(ForeignKey("pokemon.id"), index=True)
|
||||||
level: Mapped[int] = mapped_column(SmallInteger)
|
level: Mapped[int] = mapped_column(SmallInteger)
|
||||||
order: Mapped[int] = mapped_column(SmallInteger)
|
order: Mapped[int] = mapped_column(SmallInteger)
|
||||||
|
condition_label: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
|
||||||
boss_battle: Mapped["BossBattle"] = relationship(back_populates="pokemon")
|
boss_battle: Mapped["BossBattle"] = relationship(back_populates="pokemon")
|
||||||
pokemon: Mapped["Pokemon"] = relationship()
|
pokemon: Mapped["Pokemon"] = relationship()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class BossPokemonResponse(CamelModel):
|
|||||||
pokemon_id: int
|
pokemon_id: int
|
||||||
level: int
|
level: int
|
||||||
order: int
|
order: int
|
||||||
|
condition_label: str | None
|
||||||
pokemon: PokemonResponse
|
pokemon: PokemonResponse
|
||||||
|
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ class BossPokemonInput(CamelModel):
|
|||||||
pokemon_id: int
|
pokemon_id: int
|
||||||
level: int
|
level: int
|
||||||
order: int
|
order: int
|
||||||
|
condition_label: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class BossResultCreate(CamelModel):
|
class BossResultCreate(CamelModel):
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ class BulkBossPokemonItem(BaseModel):
|
|||||||
pokeapi_id: int
|
pokeapi_id: int
|
||||||
level: int
|
level: int
|
||||||
order: int
|
order: int
|
||||||
|
condition_label: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class BulkBossItem(BaseModel):
|
class BulkBossItem(BaseModel):
|
||||||
|
|||||||
@@ -270,6 +270,7 @@ async def upsert_bosses(
|
|||||||
pokemon_id=pokemon_id,
|
pokemon_id=pokemon_id,
|
||||||
level=bp["level"],
|
level=bp["level"],
|
||||||
order=bp["order"],
|
order=bp["order"],
|
||||||
|
condition_label=bp.get("condition_label"),
|
||||||
))
|
))
|
||||||
|
|
||||||
count += 1
|
count += 1
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type FormEvent, useState } from 'react'
|
import { type FormEvent, useState, useMemo } from 'react'
|
||||||
import type { BossBattle, CreateBossResultInput } from '../types/game'
|
import type { BossBattle, CreateBossResultInput } from '../types/game'
|
||||||
|
|
||||||
interface BossDefeatModalProps {
|
interface BossDefeatModalProps {
|
||||||
@@ -13,6 +13,26 @@ export function BossDefeatModal({ boss, onSubmit, onClose, isPending, hardcoreMo
|
|||||||
const [result, setResult] = useState<'won' | 'lost'>('won')
|
const [result, setResult] = useState<'won' | 'lost'>('won')
|
||||||
const [attempts, setAttempts] = useState('1')
|
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) => {
|
const handleSubmit = (e: FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onSubmit({
|
onSubmit({
|
||||||
@@ -34,8 +54,26 @@ export function BossDefeatModal({ boss, onSubmit, onClose, isPending, hardcoreMo
|
|||||||
{/* Boss team preview */}
|
{/* Boss team preview */}
|
||||||
{boss.pokemon.length > 0 && (
|
{boss.pokemon.length > 0 && (
|
||||||
<div className="px-6 py-3 border-b border-gray-200 dark:border-gray-700">
|
<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">
|
<div className="flex flex-wrap gap-3">
|
||||||
{boss.pokemon
|
{[...displayedPokemon]
|
||||||
.sort((a, b) => a.order - b.order)
|
.sort((a, b) => a.order - b.order)
|
||||||
.map((bp) => (
|
.map((bp) => (
|
||||||
<div key={bp.id} className="flex flex-col items-center">
|
<div key={bp.id} className="flex flex-col items-center">
|
||||||
|
|||||||
@@ -10,47 +10,117 @@ interface BossTeamEditorProps {
|
|||||||
isSaving?: boolean
|
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) {
|
export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEditorProps) {
|
||||||
const [team, setTeam] = useState<Array<{ pokemonId: number | null; pokemonName: string; level: string; order: number }>>(
|
const [variants, setVariants] = useState<Variant[]>(() => groupByVariant(boss))
|
||||||
boss.pokemon.length > 0
|
const [activeTab, setActiveTab] = useState(0)
|
||||||
? boss.pokemon
|
const [newVariantName, setNewVariantName] = useState('')
|
||||||
.sort((a, b) => a.order - b.order)
|
const [showAddVariant, setShowAddVariant] = useState(false)
|
||||||
.map((bp) => ({
|
|
||||||
pokemonId: bp.pokemonId,
|
const activeVariant = variants[activeTab] ?? variants[0]
|
||||||
pokemonName: bp.pokemon.name,
|
|
||||||
level: String(bp.level),
|
const updateVariant = (tabIndex: number, updater: (v: Variant) => Variant) => {
|
||||||
order: bp.order,
|
setVariants((prev) => prev.map((v, i) => (i === tabIndex ? updater(v) : v)))
|
||||||
}))
|
}
|
||||||
: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }],
|
|
||||||
)
|
|
||||||
|
|
||||||
const addSlot = () => {
|
const addSlot = () => {
|
||||||
setTeam((prev) => [
|
updateVariant(activeTab, (v) => ({
|
||||||
...prev,
|
...v,
|
||||||
{ pokemonId: null, pokemonName: '', level: '', order: prev.length + 1 },
|
pokemon: [...v.pokemon, { pokemonId: null, pokemonName: '', level: '', order: v.pokemon.length + 1 }],
|
||||||
])
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeSlot = (index: number) => {
|
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) => {
|
const updateSlot = (index: number, field: string, value: number | string | null) => {
|
||||||
setTeam((prev) =>
|
updateVariant(activeTab, (v) => ({
|
||||||
prev.map((item, i) => (i === index ? { ...item, [field]: value } : item)),
|
...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) => {
|
const handleSubmit = (e: FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const validTeam: BossPokemonInput[] = team
|
const allPokemon: BossPokemonInput[] = []
|
||||||
.filter((t) => t.pokemonId != null && t.level)
|
for (const variant of variants) {
|
||||||
.map((t, i) => ({
|
const conditionLabel = variants.length === 1 && variant.label === null ? null : variant.label
|
||||||
pokemonId: t.pokemonId!,
|
const validPokemon = variant.pokemon.filter((t) => t.pokemonId != null && t.level)
|
||||||
level: Number(t.level),
|
for (let i = 0; i < validPokemon.length; i++) {
|
||||||
order: i + 1,
|
allPokemon.push({
|
||||||
}))
|
pokemonId: validPokemon[i].pokemonId!,
|
||||||
onSave(validTeam)
|
level: Number(validPokemon[i].level),
|
||||||
|
order: i + 1,
|
||||||
|
conditionLabel,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onSave(allPokemon)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -61,10 +131,61 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
|||||||
<h2 className="text-lg font-semibold">{boss.name}'s Team</h2>
|
<h2 className="text-lg font-semibold">{boss.name}'s Team</h2>
|
||||||
</div>
|
</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"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</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">✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="px-6 py-4 space-y-3">
|
<div className="px-6 py-4 space-y-3">
|
||||||
{team.map((slot, index) => (
|
{activeVariant.pokemon.map((slot, index) => (
|
||||||
<div key={index} className="flex items-end gap-2">
|
<div key={`${activeTab}-${index}`} className="flex items-end gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<PokemonSelector
|
<PokemonSelector
|
||||||
label={`Pokemon ${index + 1}`}
|
label={`Pokemon ${index + 1}`}
|
||||||
@@ -95,7 +216,7 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{team.length < 6 && (
|
{activeVariant.pokemon.length < 6 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addSlot}
|
onClick={addSlot}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type {
|
|||||||
EncounterStatus,
|
EncounterStatus,
|
||||||
CreateEncounterInput,
|
CreateEncounterInput,
|
||||||
BossBattle,
|
BossBattle,
|
||||||
|
BossPokemon,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
|
||||||
const statusStyles: Record<RunStatus, string> = {
|
const statusStyles: Record<RunStatus, string> = {
|
||||||
@@ -145,6 +146,67 @@ function countDistinctZones(group: RouteWithChildren): number {
|
|||||||
return zones.size
|
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 {
|
interface RouteGroupProps {
|
||||||
group: RouteWithChildren
|
group: RouteWithChildren
|
||||||
encounterByRoute: Map<number, EncounterDetail>
|
encounterByRoute: Map<number, EncounterDetail>
|
||||||
@@ -1124,22 +1186,7 @@ export function RunEncounters() {
|
|||||||
</div>
|
</div>
|
||||||
{/* Boss pokemon team */}
|
{/* Boss pokemon team */}
|
||||||
{isBossExpanded && boss.pokemon.length > 0 && (
|
{isBossExpanded && boss.pokemon.length > 0 && (
|
||||||
<div className="flex gap-2 mt-2 flex-wrap">
|
<BossTeamPreview pokemon={boss.pokemon} />
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{sectionAfter && (
|
{sectionAfter && (
|
||||||
|
|||||||
@@ -176,4 +176,5 @@ export interface BossPokemonInput {
|
|||||||
pokemonId: number
|
pokemonId: number
|
||||||
level: number
|
level: number
|
||||||
order: number
|
order: number
|
||||||
|
conditionLabel?: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export interface BossPokemon {
|
|||||||
pokemonId: number
|
pokemonId: number
|
||||||
level: number
|
level: number
|
||||||
order: number
|
order: number
|
||||||
|
conditionLabel: string | null
|
||||||
pokemon: Pokemon
|
pokemon: Pokemon
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user