diff --git a/.beans/nuzlocke-tracker-g57d--implement-conditional-boss-battle-teams.md b/.beans/nuzlocke-tracker-g57d--implement-conditional-boss-battle-teams.md new file mode 100644 index 0000000..c1c25eb --- /dev/null +++ b/.beans/nuzlocke-tracker-g57d--implement-conditional-boss-battle-teams.md @@ -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. \ No newline at end of file diff --git a/.beans/nuzlocke-tracker-x8ol--starter-dependent-boss-battle-teams.md b/.beans/nuzlocke-tracker-x8ol--starter-dependent-boss-battle-teams.md index 9264491..9fe6fd3 100644 --- a/.beans/nuzlocke-tracker-x8ol--starter-dependent-boss-battle-teams.md +++ b/.beans/nuzlocke-tracker-x8ol--starter-dependent-boss-battle-teams.md @@ -1,11 +1,11 @@ --- # nuzlocke-tracker-x8ol title: Conditional boss battle teams -status: draft +status: in-progress type: feature priority: normal 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. diff --git a/backend/src/app/alembic/versions/b7c8d9e0f1a2_add_condition_label_to_boss_pokemon.py b/backend/src/app/alembic/versions/b7c8d9e0f1a2_add_condition_label_to_boss_pokemon.py new file mode 100644 index 0000000..b5eafc6 --- /dev/null +++ b/backend/src/app/alembic/versions/b7c8d9e0f1a2_add_condition_label_to_boss_pokemon.py @@ -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') diff --git a/backend/src/app/api/bosses.py b/backend/src/app/api/bosses.py index 4ff9ee5..f598a0c 100644 --- a/backend/src/app/api/bosses.py +++ b/backend/src/app/api/bosses.py @@ -228,11 +228,17 @@ async def set_boss_team( pokemon_id=item.pokemon_id, level=item.level, order=item.order, + condition_label=item.condition_label, ) session.add(bp) 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 result = await session.execute( select(BossBattle) diff --git a/backend/src/app/api/export.py b/backend/src/app/api/export.py index 6fff59a..bc4b9b8 100644 --- a/backend/src/app/api/export.py +++ b/backend/src/app/api/export.py @@ -154,6 +154,7 @@ async def export_game_bosses( "pokemon_name": bp.pokemon.name, "level": bp.level, "order": bp.order, + **({"condition_label": bp.condition_label} if bp.condition_label else {}), } for bp in sorted(b.pokemon, key=lambda p: p.order) ], diff --git a/backend/src/app/models/boss_pokemon.py b/backend/src/app/models/boss_pokemon.py index 39014f5..56aa8cb 100644 --- a/backend/src/app/models/boss_pokemon.py +++ b/backend/src/app/models/boss_pokemon.py @@ -1,4 +1,4 @@ -from sqlalchemy import ForeignKey, SmallInteger +from sqlalchemy import ForeignKey, SmallInteger, String from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.database import Base @@ -14,6 +14,7 @@ class BossPokemon(Base): pokemon_id: Mapped[int] = mapped_column(ForeignKey("pokemon.id"), index=True) level: 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") pokemon: Mapped["Pokemon"] = relationship() diff --git a/backend/src/app/schemas/boss.py b/backend/src/app/schemas/boss.py index 7174128..61b2b17 100644 --- a/backend/src/app/schemas/boss.py +++ b/backend/src/app/schemas/boss.py @@ -9,6 +9,7 @@ class BossPokemonResponse(CamelModel): pokemon_id: int level: int order: int + condition_label: str | None pokemon: PokemonResponse @@ -73,6 +74,7 @@ class BossPokemonInput(CamelModel): pokemon_id: int level: int order: int + condition_label: str | None = None class BossResultCreate(CamelModel): diff --git a/backend/src/app/schemas/pokemon.py b/backend/src/app/schemas/pokemon.py index f99e24d..c6dce95 100644 --- a/backend/src/app/schemas/pokemon.py +++ b/backend/src/app/schemas/pokemon.py @@ -200,6 +200,7 @@ class BulkBossPokemonItem(BaseModel): pokeapi_id: int level: int order: int + condition_label: str | None = None class BulkBossItem(BaseModel): diff --git a/backend/src/app/seeds/loader.py b/backend/src/app/seeds/loader.py index 04d4f31..acfae14 100644 --- a/backend/src/app/seeds/loader.py +++ b/backend/src/app/seeds/loader.py @@ -270,6 +270,7 @@ async def upsert_bosses( pokemon_id=pokemon_id, level=bp["level"], order=bp["order"], + condition_label=bp.get("condition_label"), )) count += 1 diff --git a/frontend/src/components/BossDefeatModal.tsx b/frontend/src/components/BossDefeatModal.tsx index 324f387..1231ab3 100644 --- a/frontend/src/components/BossDefeatModal.tsx +++ b/frontend/src/components/BossDefeatModal.tsx @@ -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() + 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( + 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 && (
+ {hasVariants && ( +
+ {variantLabels.map((label) => ( + + ))} +
+ )}
- {boss.pokemon + {[...displayedPokemon] .sort((a, b) => a.order - b.order) .map((bp) => (
diff --git a/frontend/src/components/admin/BossTeamEditor.tsx b/frontend/src/components/admin/BossTeamEditor.tsx index f9a347e..427263d 100644 --- a/frontend/src/components/admin/BossTeamEditor.tsx +++ b/frontend/src/components/admin/BossTeamEditor.tsx @@ -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() + + 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>( - 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(() => 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

{boss.name}'s Team

+ {/* Variant tabs */} +
+ {variants.map((v, i) => ( + + ))} + {!showAddVariant ? ( + + ) : ( +
+ 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 + /> + + +
+ )} +
+
- {team.map((slot, index) => ( -
+ {activeVariant.pokemon.map((slot, index) => ( +
))} - {team.length < 6 && ( + {activeVariant.pokemon.length < 6 && ( + ))} +
+ )} +
+ {[...displayed] + .sort((a, b) => a.order - b.order) + .map((bp) => ( +
+ {bp.pokemon.spriteUrl ? ( + {bp.pokemon.name} + ) : ( +
+ )} + + Lvl {bp.level} + +
+ ))} +
+
+ ) +} + interface RouteGroupProps { group: RouteWithChildren encounterByRoute: Map @@ -1124,22 +1186,7 @@ export function RunEncounters() {
{/* Boss pokemon team */} {isBossExpanded && boss.pokemon.length > 0 && ( -
- {boss.pokemon - .sort((a, b) => a.order - b.order) - .map((bp) => ( -
- {bp.pokemon.spriteUrl ? ( - {bp.pokemon.name} - ) : ( -
- )} - - Lvl {bp.level} - -
- ))} -
+ )}
{sectionAfter && ( diff --git a/frontend/src/types/admin.ts b/frontend/src/types/admin.ts index f657dda..30769c4 100644 --- a/frontend/src/types/admin.ts +++ b/frontend/src/types/admin.ts @@ -176,4 +176,5 @@ export interface BossPokemonInput { pokemonId: number level: number order: number + conditionLabel?: string | null } diff --git a/frontend/src/types/game.ts b/frontend/src/types/game.ts index 3a53af9..b7ac039 100644 --- a/frontend/src/types/game.ts +++ b/frontend/src/types/game.ts @@ -137,6 +137,7 @@ export interface BossPokemon { pokemonId: number level: number order: number + conditionLabel: string | null pokemon: Pokemon }