Add game_id FK to BossBattle for version-exclusive bosses
All checks were successful
CI / backend-lint (pull_request) Successful in 8s
CI / frontend-lint (pull_request) Successful in 30s

Version-exclusive bosses (e.g., Bea in Sword, Allister in Shield) were
using the section field to indicate which game they belong to. This adds
a proper game_id foreign key so the API can filter bosses per game,
keeping section free for visual grouping like "Main Story".

- Alembic migration adds nullable game_id column with FK and index
- API list_bosses filters by game_id unless ?all=true is passed
- Seed data updated to use game_slug instead of section overloading
- Admin form adds "Game (version exclusive)" dropdown
- Export endpoints include game_slug for exclusive bosses

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 11:00:48 +01:00
parent 3e7a6c9221
commit d8fb6c328c
17 changed files with 250 additions and 53 deletions

View File

@@ -0,0 +1,11 @@
---
# nuzlocke-tracker-zmvy
title: Add game_id field to BossBattle for version-exclusive bosses
status: completed
type: feature
priority: normal
created_at: 2026-02-14T09:47:40Z
updated_at: 2026-02-14T09:52:59Z
---
Add a proper game_id FK to BossBattle so version-exclusive bosses can be filtered per game instead of overloading the section field.

View File

@@ -0,0 +1,76 @@
"""add game_id to boss battles
Revision ID: f7a8b9c0d1e2
Revises: e5f70a1ca323
Create Date: 2026-02-14 12:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "f7a8b9c0d1e2"
down_revision: str | Sequence[str] | None = "e5f70a1ca323"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"boss_battles",
sa.Column("game_id", sa.Integer(), nullable=True),
)
op.create_foreign_key(
"fk_boss_battles_game_id",
"boss_battles",
"games",
["game_id"],
["id"],
)
op.create_index("ix_boss_battles_game_id", "boss_battles", ["game_id"])
# Data migration: for bosses where section is a game name,
# look up the game ID, set game_id, and reset section to null.
conn = op.get_bind()
rows = conn.execute(
sa.text(
"SELECT bb.id, g.id AS gid "
"FROM boss_battles bb "
"JOIN games g ON LOWER(bb.section) = LOWER(g.name) "
"WHERE bb.section IS NOT NULL"
)
).fetchall()
for row in rows:
conn.execute(
sa.text(
"UPDATE boss_battles SET game_id = :gid, section = NULL WHERE id = :bid"
),
{"gid": row.gid, "bid": row.id},
)
def downgrade() -> None:
# Reverse data migration: restore section from game name
conn = op.get_bind()
rows = conn.execute(
sa.text(
"SELECT bb.id, g.name "
"FROM boss_battles bb "
"JOIN games g ON bb.game_id = g.id "
"WHERE bb.game_id IS NOT NULL"
)
).fetchall()
for row in rows:
conn.execute(
sa.text(
"UPDATE boss_battles SET section = :name, game_id = NULL WHERE id = :bid"
),
{"name": row.name, "bid": row.id},
)
op.drop_index("ix_boss_battles_game_id", table_name="boss_battles")
op.drop_constraint("fk_boss_battles_game_id", "boss_battles", type_="foreignkey")
op.drop_column("boss_battles", "game_id")

View File

@@ -1,7 +1,7 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Response from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy import select from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -43,15 +43,26 @@ async def _get_version_group_id(session: AsyncSession, game_id: int) -> int:
@router.get("/games/{game_id}/bosses", response_model=list[BossBattleResponse]) @router.get("/games/{game_id}/bosses", response_model=list[BossBattleResponse])
async def list_bosses(game_id: int, session: AsyncSession = Depends(get_session)): async def list_bosses(
game_id: int,
all: bool = False,
session: AsyncSession = Depends(get_session),
):
vg_id = await _get_version_group_id(session, game_id) vg_id = await _get_version_group_id(session, game_id)
result = await session.execute( query = (
select(BossBattle) select(BossBattle)
.where(BossBattle.version_group_id == vg_id) .where(BossBattle.version_group_id == vg_id)
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon)) .options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
.order_by(BossBattle.order) .order_by(BossBattle.order)
) )
if not all:
query = query.where(
or_(BossBattle.game_id.is_(None), BossBattle.game_id == game_id)
)
result = await session.execute(query)
return result.scalars().all() return result.scalars().all()
@@ -106,6 +117,14 @@ async def create_boss(
): ):
vg_id = await _get_version_group_id(session, game_id) vg_id = await _get_version_group_id(session, game_id)
if data.game_id is not None:
game = await session.get(Game, data.game_id)
if game is None or game.version_group_id != vg_id:
raise HTTPException(
status_code=400,
detail="game_id does not belong to this version group",
)
boss = BossBattle(version_group_id=vg_id, **data.model_dump()) boss = BossBattle(version_group_id=vg_id, **data.model_dump())
session.add(boss) session.add(boss)
await session.commit() await session.commit()
@@ -128,6 +147,14 @@ async def update_boss(
): ):
vg_id = await _get_version_group_id(session, game_id) vg_id = await _get_version_group_id(session, game_id)
if data.game_id is not None:
game = await session.get(Game, data.game_id)
if game is None or game.version_group_id != vg_id:
raise HTTPException(
status_code=400,
detail="game_id does not belong to this version group",
)
result = await session.execute( result = await session.execute(
select(BossBattle) select(BossBattle)
.where(BossBattle.id == boss_id, BossBattle.version_group_id == vg_id) .where(BossBattle.id == boss_id, BossBattle.version_group_id == vg_id)
@@ -192,10 +219,16 @@ async def bulk_import_bosses(
) )
route_name_to_id = {row.name: row.id for row in result} route_name_to_id = {row.name: row.id for row in result}
# Build game slug -> id mapping for game_slug resolution
result = await session.execute(
select(Game.slug, Game.id).where(Game.version_group_id == vg_id)
)
slug_to_game_id = {row.slug: row.id for row in result}
bosses_data = [item.model_dump() for item in items] bosses_data = [item.model_dump() for item in items]
try: try:
count = await upsert_bosses( count = await upsert_bosses(
session, vg_id, bosses_data, dex_to_id, route_name_to_id session, vg_id, bosses_data, dex_to_id, route_name_to_id, slug_to_game_id
) )
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(

View File

@@ -126,6 +126,7 @@ async def export_game_bosses(
.options( .options(
selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon), selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon),
selectinload(BossBattle.after_route), selectinload(BossBattle.after_route),
selectinload(BossBattle.game),
) )
.order_by(BossBattle.order) .order_by(BossBattle.order)
) )
@@ -146,6 +147,7 @@ async def export_game_bosses(
"location": b.location, "location": b.location,
"section": b.section, "section": b.section,
"sprite_url": b.sprite_url, "sprite_url": b.sprite_url,
**({"game_slug": b.game.slug} if b.game_id else {}),
"pokemon": [ "pokemon": [
{ {
"pokeapi_id": bp.pokemon.pokeapi_id, "pokeapi_id": bp.pokemon.pokeapi_id,

View File

@@ -33,9 +33,13 @@ class BossBattle(Base):
location: Mapped[str] = mapped_column(String(200)) location: Mapped[str] = mapped_column(String(200))
section: Mapped[str | None] = mapped_column(String(100), default=None) section: Mapped[str | None] = mapped_column(String(100), default=None)
sprite_url: Mapped[str | None] = mapped_column(String(500)) sprite_url: Mapped[str | None] = mapped_column(String(500))
game_id: Mapped[int | None] = mapped_column(
ForeignKey("games.id"), index=True, default=None
)
version_group: Mapped["VersionGroup"] = relationship(back_populates="boss_battles") version_group: Mapped["VersionGroup"] = relationship(back_populates="boss_battles")
after_route: Mapped["Route | None"] = relationship() after_route: Mapped["Route | None"] = relationship()
game: Mapped["Game | None"] = relationship()
pokemon: Mapped[list["BossPokemon"]] = relationship( pokemon: Mapped[list["BossPokemon"]] = relationship(
back_populates="boss_battle", cascade="all, delete-orphan" back_populates="boss_battle", cascade="all, delete-orphan"
) )

View File

@@ -27,6 +27,7 @@ class BossBattleResponse(CamelModel):
location: str location: str
section: str | None section: str | None
sprite_url: str | None sprite_url: str | None
game_id: int | None
pokemon: list[BossPokemonResponse] = [] pokemon: list[BossPokemonResponse] = []
@@ -54,6 +55,7 @@ class BossBattleCreate(CamelModel):
location: str location: str
section: str | None = None section: str | None = None
sprite_url: str | None = None sprite_url: str | None = None
game_id: int | None = None
class BossBattleUpdate(CamelModel): class BossBattleUpdate(CamelModel):
@@ -68,6 +70,7 @@ class BossBattleUpdate(CamelModel):
location: str | None = None location: str | None = None
section: str | None = None section: str | None = None
sprite_url: str | None = None sprite_url: str | None = None
game_id: int | None = None
class BossPokemonInput(CamelModel): class BossPokemonInput(CamelModel):

View File

@@ -215,4 +215,5 @@ class BulkBossItem(BaseModel):
location: str location: str
section: str | None = None section: str | None = None
sprite_url: str | None = None sprite_url: str | None = None
game_slug: str | None = None
pokemon: list[BulkBossPokemonItem] = [] pokemon: list[BulkBossPokemonItem] = []

View File

@@ -107,8 +107,9 @@
"order": 8, "order": 8,
"after_route_name": null, "after_route_name": null,
"location": "Opelucid Gym", "location": "Opelucid Gym",
"section": "Black", "section": null,
"sprite_url": "/boss-sprites/black/drayden.png", "sprite_url": "/boss-sprites/black/drayden.png",
"game_slug": "black",
"pokemon": [] "pokemon": []
}, },
{ {
@@ -121,8 +122,9 @@
"order": 9, "order": 9,
"after_route_name": null, "after_route_name": null,
"location": "Opelucid Gym", "location": "Opelucid Gym",
"section": "White", "section": null,
"sprite_url": "/boss-sprites/black/iris.png", "sprite_url": "/boss-sprites/black/iris.png",
"game_slug": "white",
"pokemon": [] "pokemon": []
}, },
{ {

View File

@@ -51,8 +51,9 @@
"order": 4, "order": 4,
"after_route_name": null, "after_route_name": null,
"location": "Stow-on-Side Stadium", "location": "Stow-on-Side Stadium",
"section": "Sword", "section": null,
"sprite_url": "/boss-sprites/sword/bea.png", "sprite_url": "/boss-sprites/sword/bea.png",
"game_slug": "sword",
"pokemon": [] "pokemon": []
}, },
{ {
@@ -65,8 +66,9 @@
"order": 5, "order": 5,
"after_route_name": null, "after_route_name": null,
"location": "Stow-on-Side Stadium", "location": "Stow-on-Side Stadium",
"section": "Shield", "section": null,
"sprite_url": "/boss-sprites/sword/allister.png", "sprite_url": "/boss-sprites/sword/allister.png",
"game_slug": "shield",
"pokemon": [] "pokemon": []
}, },
{ {
@@ -93,8 +95,9 @@
"order": 7, "order": 7,
"after_route_name": null, "after_route_name": null,
"location": "Circhester Stadium", "location": "Circhester Stadium",
"section": "Sword", "section": null,
"sprite_url": "/boss-sprites/sword/gordie.png", "sprite_url": "/boss-sprites/sword/gordie.png",
"game_slug": "sword",
"pokemon": [] "pokemon": []
}, },
{ {
@@ -107,8 +110,9 @@
"order": 8, "order": 8,
"after_route_name": null, "after_route_name": null,
"location": "Circhester Stadium", "location": "Circhester Stadium",
"section": "Shield", "section": null,
"sprite_url": "/boss-sprites/sword/melony.png", "sprite_url": "/boss-sprites/sword/melony.png",
"game_slug": "shield",
"pokemon": [] "pokemon": []
}, },
{ {

View File

@@ -239,6 +239,7 @@ async def upsert_bosses(
bosses: list[dict], bosses: list[dict],
dex_to_id: dict[int, int], dex_to_id: dict[int, int],
route_name_to_id: dict[str, int] | None = None, route_name_to_id: dict[str, int] | None = None,
slug_to_game_id: dict[str, int] | None = None,
) -> int: ) -> int:
"""Upsert boss battles for a version group, return count of bosses upserted.""" """Upsert boss battles for a version group, return count of bosses upserted."""
count = 0 count = 0
@@ -253,6 +254,16 @@ async def upsert_bosses(
f" Warning: route '{after_route_name}' not found for boss '{boss['name']}'" f" Warning: route '{after_route_name}' not found for boss '{boss['name']}'"
) )
# Resolve game_slug to game_id
game_id = None
game_slug = boss.get("game_slug")
if game_slug and slug_to_game_id:
game_id = slug_to_game_id.get(game_slug)
if game_id is None:
print(
f" Warning: game '{game_slug}' not found for boss '{boss['name']}'"
)
# Upsert the boss battle on (version_group_id, order) conflict # Upsert the boss battle on (version_group_id, order) conflict
stmt = ( stmt = (
insert(BossBattle) insert(BossBattle)
@@ -269,6 +280,7 @@ async def upsert_bosses(
location=boss["location"], location=boss["location"],
section=boss.get("section"), section=boss.get("section"),
sprite_url=boss.get("sprite_url"), sprite_url=boss.get("sprite_url"),
game_id=game_id,
) )
.on_conflict_do_update( .on_conflict_do_update(
constraint="uq_boss_battles_version_group_order", constraint="uq_boss_battles_version_group_order",
@@ -283,6 +295,7 @@ async def upsert_bosses(
"location": boss["location"], "location": boss["location"],
"section": boss.get("section"), "section": boss.get("section"),
"sprite_url": boss.get("sprite_url"), "sprite_url": boss.get("sprite_url"),
"game_id": game_id,
}, },
) )
.returning(BossBattle.id) .returning(BossBattle.id)

View File

@@ -160,7 +160,7 @@ async def seed():
route_name_to_id = route_maps_by_vg.get(vg_id, {}) route_name_to_id = route_maps_by_vg.get(vg_id, {})
boss_count = await upsert_bosses( boss_count = await upsert_bosses(
session, vg_id, bosses_data, dex_to_id, route_name_to_id session, vg_id, bosses_data, dex_to_id, route_name_to_id, slug_to_id
) )
total_bosses += boss_count total_bosses += boss_count
print(f" {vg_slug}: {boss_count} bosses") print(f" {vg_slug}: {boss_count} bosses")
@@ -491,6 +491,7 @@ async def _export_bosses(session: AsyncSession, vg_data: dict):
.options( .options(
selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon), selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon),
selectinload(BossBattle.after_route), selectinload(BossBattle.after_route),
selectinload(BossBattle.game),
) )
.order_by(BossBattle.order) .order_by(BossBattle.order)
) )
@@ -525,30 +526,31 @@ async def _export_bosses(session: AsyncSession, vg_data: dict):
downloaded_sprites, downloaded_sprites,
) )
data.append( boss_dict: dict = {
{ "name": b.name,
"name": b.name, "boss_type": b.boss_type,
"boss_type": b.boss_type, "specialty_type": b.specialty_type,
"specialty_type": b.specialty_type, "badge_name": b.badge_name,
"badge_name": b.badge_name, "badge_image_url": badge_image_url,
"badge_image_url": badge_image_url, "level_cap": b.level_cap,
"level_cap": b.level_cap, "order": b.order,
"order": b.order, "after_route_name": b.after_route.name if b.after_route else None,
"after_route_name": b.after_route.name if b.after_route else None, "location": b.location,
"location": b.location, "section": b.section,
"section": b.section, "sprite_url": sprite_url,
"sprite_url": sprite_url, "pokemon": [
"pokemon": [ {
{ "pokeapi_id": bp.pokemon.pokeapi_id,
"pokeapi_id": bp.pokemon.pokeapi_id, "pokemon_name": bp.pokemon.name,
"pokemon_name": bp.pokemon.name, "level": bp.level,
"level": bp.level, "order": bp.order,
"order": bp.order, }
} for bp in sorted(b.pokemon, key=lambda p: p.order)
for bp in sorted(b.pokemon, key=lambda p: p.order) ],
], }
} if b.game_id:
) boss_dict["game_slug"] = b.game.slug
data.append(boss_dict)
_write_json(f"{first_game_slug}-bosses.json", data) _write_json(f"{first_game_slug}-bosses.json", data)
exported += 1 exported += 1

View File

@@ -1,8 +1,9 @@
import { api } from './client' import { api } from './client'
import type { BossBattle, BossResult, CreateBossResultInput } from '../types/game' import type { BossBattle, BossResult, CreateBossResultInput } from '../types/game'
export function getGameBosses(gameId: number): Promise<BossBattle[]> { export function getGameBosses(gameId: number, all?: boolean): Promise<BossBattle[]> {
return api.get(`/games/${gameId}/bosses`) const params = all ? '?all=true' : ''
return api.get(`/games/${gameId}/bosses${params}`)
} }
export function getBossResults(runId: number): Promise<BossResult[]> { export function getBossResults(runId: number): Promise<BossResult[]> {

View File

@@ -1,11 +1,12 @@
import { type FormEvent, useState } from 'react' import { type FormEvent, useState } from 'react'
import { FormModal } from './FormModal' import { FormModal } from './FormModal'
import type { BossBattle, Route } from '../../types/game' import type { BossBattle, Game, Route } from '../../types/game'
import type { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/admin' import type { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/admin'
interface BossBattleFormModalProps { interface BossBattleFormModalProps {
boss?: BossBattle boss?: BossBattle
routes: Route[] routes: Route[]
games?: Game[]
nextOrder: number nextOrder: number
onSubmit: (data: CreateBossBattleInput | UpdateBossBattleInput) => void onSubmit: (data: CreateBossBattleInput | UpdateBossBattleInput) => void
onClose: () => void onClose: () => void
@@ -35,6 +36,7 @@ const BOSS_TYPES = [
export function BossBattleFormModal({ export function BossBattleFormModal({
boss, boss,
routes, routes,
games,
nextOrder, nextOrder,
onSubmit, onSubmit,
onClose, onClose,
@@ -54,6 +56,7 @@ export function BossBattleFormModal({
const [location, setLocation] = useState(boss?.location ?? '') const [location, setLocation] = useState(boss?.location ?? '')
const [section, setSection] = useState(boss?.section ?? '') const [section, setSection] = useState(boss?.section ?? '')
const [spriteUrl, setSpriteUrl] = useState(boss?.spriteUrl ?? '') const [spriteUrl, setSpriteUrl] = useState(boss?.spriteUrl ?? '')
const [gameId, setGameId] = useState(String(boss?.gameId ?? ''))
const handleSubmit = (e: FormEvent) => { const handleSubmit = (e: FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -69,6 +72,7 @@ export function BossBattleFormModal({
location, location,
section: section || null, section: section || null,
spriteUrl: spriteUrl || null, spriteUrl: spriteUrl || null,
gameId: gameId ? Number(gameId) : null,
}) })
} }
@@ -173,15 +177,34 @@ export function BossBattleFormModal({
</div> </div>
</div> </div>
<div> <div className="grid grid-cols-2 gap-4">
<label className="block text-sm font-medium mb-1">Section</label> <div>
<input <label className="block text-sm font-medium mb-1">Section</label>
type="text" <input
value={section} type="text"
onChange={(e) => setSection(e.target.value)} value={section}
placeholder="e.g. Main Story, Endgame" onChange={(e) => setSection(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" placeholder="e.g. Main Story, Endgame"
/> className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
{games && games.length > 1 && (
<div>
<label className="block text-sm font-medium mb-1">Game (version exclusive)</label>
<select
value={gameId}
onChange={(e) => setGameId(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
>
<option value="">All games</option>
{games.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</div>
)}
</div> </div>
<div> <div>

View File

@@ -3,10 +3,10 @@ import { toast } from 'sonner'
import { getGameBosses, getBossResults, createBossResult, deleteBossResult } from '../api/bosses' import { getGameBosses, getBossResults, createBossResult, deleteBossResult } from '../api/bosses'
import type { CreateBossResultInput } from '../types/game' import type { CreateBossResultInput } from '../types/game'
export function useGameBosses(gameId: number | null) { export function useGameBosses(gameId: number | null, all?: boolean) {
return useQuery({ return useQuery({
queryKey: ['games', gameId, 'bosses'], queryKey: ['games', gameId, 'bosses', { all }],
queryFn: () => getGameBosses(gameId!), queryFn: () => getGameBosses(gameId!, all),
enabled: gameId != null, enabled: gameId != null,
}) })
} }

View File

@@ -21,7 +21,7 @@ import { RouteFormModal } from '../../components/admin/RouteFormModal'
import { BossBattleFormModal } from '../../components/admin/BossBattleFormModal' import { BossBattleFormModal } from '../../components/admin/BossBattleFormModal'
import { BossTeamEditor } from '../../components/admin/BossTeamEditor' import { BossTeamEditor } from '../../components/admin/BossTeamEditor'
import { TypeBadge } from '../../components/TypeBadge' import { TypeBadge } from '../../components/TypeBadge'
import { useGame } from '../../hooks/useGames' import { useGame, useGames } from '../../hooks/useGames'
import { useGameBosses } from '../../hooks/useBosses' import { useGameBosses } from '../../hooks/useBosses'
import { import {
useCreateRoute, useCreateRoute,
@@ -162,11 +162,13 @@ function SortableRouteGroup({
function SortableBossRow({ function SortableBossRow({
boss, boss,
routes, routes,
games,
onPositionChange, onPositionChange,
onClick, onClick,
}: { }: {
boss: BossBattle boss: BossBattle
routes: GameRoute[] routes: GameRoute[]
games: import('../../types/game').Game[]
onPositionChange: (bossId: number, afterRouteId: number | null) => void onPositionChange: (bossId: number, afterRouteId: number | null) => void
onClick: (b: BossBattle) => void onClick: (b: BossBattle) => void
}) { }) {
@@ -204,7 +206,17 @@ function SortableBossRow({
</button> </button>
</td> </td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.order}</td> <td className="px-4 py-3 text-sm whitespace-nowrap">{boss.order}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap font-medium">{boss.name}</td> <td className="px-4 py-3 text-sm whitespace-nowrap font-medium">
{boss.name}
{boss.gameId != null && (() => {
const g = games.find((g) => g.id === boss.gameId)
return g ? (
<span className="ml-2 text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">
{g.name}
</span>
) : null
})()}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap capitalize"> <td className="px-4 py-3 text-sm whitespace-nowrap capitalize">
{boss.bossType.replace('_', ' ')} {boss.bossType.replace('_', ' ')}
</td> </td>
@@ -247,7 +259,8 @@ export function AdminGameDetail() {
const deleteRoute = useDeleteRoute(id) const deleteRoute = useDeleteRoute(id)
const reorderRoutes = useReorderRoutes(id) const reorderRoutes = useReorderRoutes(id)
const bulkImportRoutes = useBulkImportRoutes(id) const bulkImportRoutes = useBulkImportRoutes(id)
const { data: bosses } = useGameBosses(id) const { data: bosses } = useGameBosses(id, true)
const { data: allGames } = useGames()
const createBoss = useCreateBossBattle(id) const createBoss = useCreateBossBattle(id)
const updateBoss = useUpdateBossBattle(id) const updateBoss = useUpdateBossBattle(id)
const deleteBoss = useDeleteBossBattle(id) const deleteBoss = useDeleteBossBattle(id)
@@ -273,6 +286,9 @@ export function AdminGameDetail() {
const routes = game.routes ?? [] const routes = game.routes ?? []
const routeGroups = organizeRoutes(routes) const routeGroups = organizeRoutes(routes)
const versionGroupGames = (allGames ?? []).filter(
(g) => g.versionGroupId === game.versionGroupId,
)
const handleDragEnd = (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event const { active, over } = event
@@ -573,6 +589,7 @@ export function AdminGameDetail() {
key={boss.id} key={boss.id}
boss={boss} boss={boss}
routes={routes} routes={routes}
games={versionGroupGames}
onPositionChange={(bossId, afterRouteId) => onPositionChange={(bossId, afterRouteId) =>
updateBoss.mutate({ updateBoss.mutate({
bossId, bossId,
@@ -596,6 +613,7 @@ export function AdminGameDetail() {
{showCreateBoss && ( {showCreateBoss && (
<BossBattleFormModal <BossBattleFormModal
routes={routes} routes={routes}
games={versionGroupGames}
nextOrder={bosses ? Math.max(0, ...bosses.map((b) => b.order)) + 1 : 1} nextOrder={bosses ? Math.max(0, ...bosses.map((b) => b.order)) + 1 : 1}
onSubmit={(data) => onSubmit={(data) =>
createBoss.mutate(data as CreateBossBattleInput, { createBoss.mutate(data as CreateBossBattleInput, {
@@ -611,6 +629,7 @@ export function AdminGameDetail() {
<BossBattleFormModal <BossBattleFormModal
boss={editingBoss} boss={editingBoss}
routes={routes} routes={routes}
games={versionGroupGames}
nextOrder={editingBoss.order} nextOrder={editingBoss.order}
onSubmit={(data) => onSubmit={(data) =>
updateBoss.mutate( updateBoss.mutate(

View File

@@ -151,6 +151,7 @@ export interface CreateBossBattleInput {
location: string location: string
section?: string | null section?: string | null
spriteUrl?: string | null spriteUrl?: string | null
gameId?: number | null
} }
export interface UpdateBossBattleInput { export interface UpdateBossBattleInput {
@@ -165,6 +166,7 @@ export interface UpdateBossBattleInput {
location?: string location?: string
section?: string | null section?: string | null
spriteUrl?: string | null spriteUrl?: string | null
gameId?: number | null
} }
export interface BossReorderItem { export interface BossReorderItem {

View File

@@ -188,6 +188,7 @@ export interface BossBattle {
location: string location: string
section: string | null section: string | null
spriteUrl: string | null spriteUrl: string | null
gameId: number | null
pokemon: BossPokemon[] pokemon: BossPokemon[]
} }