diff --git a/.beans/nuzlocke-tracker-zmvy--add-game-id-field-to-bossbattle-for-version-exclus.md b/.beans/nuzlocke-tracker-zmvy--add-game-id-field-to-bossbattle-for-version-exclus.md new file mode 100644 index 0000000..3166750 --- /dev/null +++ b/.beans/nuzlocke-tracker-zmvy--add-game-id-field-to-bossbattle-for-version-exclus.md @@ -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. \ No newline at end of file diff --git a/backend/src/app/alembic/versions/f7a8b9c0d1e2_add_game_id_to_boss_battles.py b/backend/src/app/alembic/versions/f7a8b9c0d1e2_add_game_id_to_boss_battles.py new file mode 100644 index 0000000..488551c --- /dev/null +++ b/backend/src/app/alembic/versions/f7a8b9c0d1e2_add_game_id_to_boss_battles.py @@ -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") diff --git a/backend/src/app/api/bosses.py b/backend/src/app/api/bosses.py index 069f234..187203a 100644 --- a/backend/src/app/api/bosses.py +++ b/backend/src/app/api/bosses.py @@ -1,7 +1,7 @@ from datetime import UTC, datetime from fastapi import APIRouter, Depends, HTTPException, Response -from sqlalchemy import select +from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession 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]) -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) - result = await session.execute( + query = ( select(BossBattle) .where(BossBattle.version_group_id == vg_id) .options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon)) .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() @@ -106,6 +117,14 @@ async def create_boss( ): 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()) session.add(boss) await session.commit() @@ -128,6 +147,14 @@ async def update_boss( ): 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( select(BossBattle) .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} + # 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] try: 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: raise HTTPException( diff --git a/backend/src/app/api/export.py b/backend/src/app/api/export.py index ab14e1c..e620a22 100644 --- a/backend/src/app/api/export.py +++ b/backend/src/app/api/export.py @@ -126,6 +126,7 @@ async def export_game_bosses( .options( selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon), selectinload(BossBattle.after_route), + selectinload(BossBattle.game), ) .order_by(BossBattle.order) ) @@ -146,6 +147,7 @@ async def export_game_bosses( "location": b.location, "section": b.section, "sprite_url": b.sprite_url, + **({"game_slug": b.game.slug} if b.game_id else {}), "pokemon": [ { "pokeapi_id": bp.pokemon.pokeapi_id, diff --git a/backend/src/app/models/boss_battle.py b/backend/src/app/models/boss_battle.py index 5d4c405..4049079 100644 --- a/backend/src/app/models/boss_battle.py +++ b/backend/src/app/models/boss_battle.py @@ -33,9 +33,13 @@ class BossBattle(Base): location: Mapped[str] = mapped_column(String(200)) section: Mapped[str | None] = mapped_column(String(100), default=None) 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") after_route: Mapped["Route | None"] = relationship() + game: Mapped["Game | None"] = relationship() pokemon: Mapped[list["BossPokemon"]] = relationship( back_populates="boss_battle", cascade="all, delete-orphan" ) diff --git a/backend/src/app/schemas/boss.py b/backend/src/app/schemas/boss.py index 61b2b17..bc581e2 100644 --- a/backend/src/app/schemas/boss.py +++ b/backend/src/app/schemas/boss.py @@ -27,6 +27,7 @@ class BossBattleResponse(CamelModel): location: str section: str | None sprite_url: str | None + game_id: int | None pokemon: list[BossPokemonResponse] = [] @@ -54,6 +55,7 @@ class BossBattleCreate(CamelModel): location: str section: str | None = None sprite_url: str | None = None + game_id: int | None = None class BossBattleUpdate(CamelModel): @@ -68,6 +70,7 @@ class BossBattleUpdate(CamelModel): location: str | None = None section: str | None = None sprite_url: str | None = None + game_id: int | None = None class BossPokemonInput(CamelModel): diff --git a/backend/src/app/schemas/pokemon.py b/backend/src/app/schemas/pokemon.py index c6dce95..94ca898 100644 --- a/backend/src/app/schemas/pokemon.py +++ b/backend/src/app/schemas/pokemon.py @@ -215,4 +215,5 @@ class BulkBossItem(BaseModel): location: str section: str | None = None sprite_url: str | None = None + game_slug: str | None = None pokemon: list[BulkBossPokemonItem] = [] diff --git a/backend/src/app/seeds/data/black-bosses.json b/backend/src/app/seeds/data/black-bosses.json index 427a2c0..2fb81f6 100644 --- a/backend/src/app/seeds/data/black-bosses.json +++ b/backend/src/app/seeds/data/black-bosses.json @@ -107,8 +107,9 @@ "order": 8, "after_route_name": null, "location": "Opelucid Gym", - "section": "Black", + "section": null, "sprite_url": "/boss-sprites/black/drayden.png", + "game_slug": "black", "pokemon": [] }, { @@ -121,8 +122,9 @@ "order": 9, "after_route_name": null, "location": "Opelucid Gym", - "section": "White", + "section": null, "sprite_url": "/boss-sprites/black/iris.png", + "game_slug": "white", "pokemon": [] }, { diff --git a/backend/src/app/seeds/data/sword-bosses.json b/backend/src/app/seeds/data/sword-bosses.json index aab881c..b3ca2b6 100644 --- a/backend/src/app/seeds/data/sword-bosses.json +++ b/backend/src/app/seeds/data/sword-bosses.json @@ -51,8 +51,9 @@ "order": 4, "after_route_name": null, "location": "Stow-on-Side Stadium", - "section": "Sword", + "section": null, "sprite_url": "/boss-sprites/sword/bea.png", + "game_slug": "sword", "pokemon": [] }, { @@ -65,8 +66,9 @@ "order": 5, "after_route_name": null, "location": "Stow-on-Side Stadium", - "section": "Shield", + "section": null, "sprite_url": "/boss-sprites/sword/allister.png", + "game_slug": "shield", "pokemon": [] }, { @@ -93,8 +95,9 @@ "order": 7, "after_route_name": null, "location": "Circhester Stadium", - "section": "Sword", + "section": null, "sprite_url": "/boss-sprites/sword/gordie.png", + "game_slug": "sword", "pokemon": [] }, { @@ -107,8 +110,9 @@ "order": 8, "after_route_name": null, "location": "Circhester Stadium", - "section": "Shield", + "section": null, "sprite_url": "/boss-sprites/sword/melony.png", + "game_slug": "shield", "pokemon": [] }, { diff --git a/backend/src/app/seeds/loader.py b/backend/src/app/seeds/loader.py index 2b56e8d..bd334d1 100644 --- a/backend/src/app/seeds/loader.py +++ b/backend/src/app/seeds/loader.py @@ -239,6 +239,7 @@ async def upsert_bosses( bosses: list[dict], dex_to_id: dict[int, int], route_name_to_id: dict[str, int] | None = None, + slug_to_game_id: dict[str, int] | None = None, ) -> int: """Upsert boss battles for a version group, return count of bosses upserted.""" count = 0 @@ -253,6 +254,16 @@ async def upsert_bosses( 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 stmt = ( insert(BossBattle) @@ -269,6 +280,7 @@ async def upsert_bosses( location=boss["location"], section=boss.get("section"), sprite_url=boss.get("sprite_url"), + game_id=game_id, ) .on_conflict_do_update( constraint="uq_boss_battles_version_group_order", @@ -283,6 +295,7 @@ async def upsert_bosses( "location": boss["location"], "section": boss.get("section"), "sprite_url": boss.get("sprite_url"), + "game_id": game_id, }, ) .returning(BossBattle.id) diff --git a/backend/src/app/seeds/run.py b/backend/src/app/seeds/run.py index 9b96ac5..2ec8852 100644 --- a/backend/src/app/seeds/run.py +++ b/backend/src/app/seeds/run.py @@ -160,7 +160,7 @@ async def seed(): route_name_to_id = route_maps_by_vg.get(vg_id, {}) 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 print(f" {vg_slug}: {boss_count} bosses") @@ -491,6 +491,7 @@ async def _export_bosses(session: AsyncSession, vg_data: dict): .options( selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon), selectinload(BossBattle.after_route), + selectinload(BossBattle.game), ) .order_by(BossBattle.order) ) @@ -525,30 +526,31 @@ async def _export_bosses(session: AsyncSession, vg_data: dict): downloaded_sprites, ) - data.append( - { - "name": b.name, - "boss_type": b.boss_type, - "specialty_type": b.specialty_type, - "badge_name": b.badge_name, - "badge_image_url": badge_image_url, - "level_cap": b.level_cap, - "order": b.order, - "after_route_name": b.after_route.name if b.after_route else None, - "location": b.location, - "section": b.section, - "sprite_url": sprite_url, - "pokemon": [ - { - "pokeapi_id": bp.pokemon.pokeapi_id, - "pokemon_name": bp.pokemon.name, - "level": bp.level, - "order": bp.order, - } - for bp in sorted(b.pokemon, key=lambda p: p.order) - ], - } - ) + boss_dict: dict = { + "name": b.name, + "boss_type": b.boss_type, + "specialty_type": b.specialty_type, + "badge_name": b.badge_name, + "badge_image_url": badge_image_url, + "level_cap": b.level_cap, + "order": b.order, + "after_route_name": b.after_route.name if b.after_route else None, + "location": b.location, + "section": b.section, + "sprite_url": sprite_url, + "pokemon": [ + { + "pokeapi_id": bp.pokemon.pokeapi_id, + "pokemon_name": bp.pokemon.name, + "level": bp.level, + "order": bp.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) exported += 1 diff --git a/frontend/src/api/bosses.ts b/frontend/src/api/bosses.ts index 1f4f971..c212ea9 100644 --- a/frontend/src/api/bosses.ts +++ b/frontend/src/api/bosses.ts @@ -1,8 +1,9 @@ import { api } from './client' import type { BossBattle, BossResult, CreateBossResultInput } from '../types/game' -export function getGameBosses(gameId: number): Promise { - return api.get(`/games/${gameId}/bosses`) +export function getGameBosses(gameId: number, all?: boolean): Promise { + const params = all ? '?all=true' : '' + return api.get(`/games/${gameId}/bosses${params}`) } export function getBossResults(runId: number): Promise { diff --git a/frontend/src/components/admin/BossBattleFormModal.tsx b/frontend/src/components/admin/BossBattleFormModal.tsx index ca81465..399a259 100644 --- a/frontend/src/components/admin/BossBattleFormModal.tsx +++ b/frontend/src/components/admin/BossBattleFormModal.tsx @@ -1,11 +1,12 @@ import { type FormEvent, useState } from 'react' 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' interface BossBattleFormModalProps { boss?: BossBattle routes: Route[] + games?: Game[] nextOrder: number onSubmit: (data: CreateBossBattleInput | UpdateBossBattleInput) => void onClose: () => void @@ -35,6 +36,7 @@ const BOSS_TYPES = [ export function BossBattleFormModal({ boss, routes, + games, nextOrder, onSubmit, onClose, @@ -54,6 +56,7 @@ export function BossBattleFormModal({ const [location, setLocation] = useState(boss?.location ?? '') const [section, setSection] = useState(boss?.section ?? '') const [spriteUrl, setSpriteUrl] = useState(boss?.spriteUrl ?? '') + const [gameId, setGameId] = useState(String(boss?.gameId ?? '')) const handleSubmit = (e: FormEvent) => { e.preventDefault() @@ -69,6 +72,7 @@ export function BossBattleFormModal({ location, section: section || null, spriteUrl: spriteUrl || null, + gameId: gameId ? Number(gameId) : null, }) } @@ -173,15 +177,34 @@ export function BossBattleFormModal({ -
- - setSection(e.target.value)} - placeholder="e.g. Main Story, Endgame" - className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" - /> +
+
+ + setSection(e.target.value)} + placeholder="e.g. Main Story, Endgame" + className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" + /> +
+ {games && games.length > 1 && ( +
+ + +
+ )}
diff --git a/frontend/src/hooks/useBosses.ts b/frontend/src/hooks/useBosses.ts index 17f52c9..fe814e8 100644 --- a/frontend/src/hooks/useBosses.ts +++ b/frontend/src/hooks/useBosses.ts @@ -3,10 +3,10 @@ import { toast } from 'sonner' import { getGameBosses, getBossResults, createBossResult, deleteBossResult } from '../api/bosses' import type { CreateBossResultInput } from '../types/game' -export function useGameBosses(gameId: number | null) { +export function useGameBosses(gameId: number | null, all?: boolean) { return useQuery({ - queryKey: ['games', gameId, 'bosses'], - queryFn: () => getGameBosses(gameId!), + queryKey: ['games', gameId, 'bosses', { all }], + queryFn: () => getGameBosses(gameId!, all), enabled: gameId != null, }) } diff --git a/frontend/src/pages/admin/AdminGameDetail.tsx b/frontend/src/pages/admin/AdminGameDetail.tsx index 3fe58a5..18a8bad 100644 --- a/frontend/src/pages/admin/AdminGameDetail.tsx +++ b/frontend/src/pages/admin/AdminGameDetail.tsx @@ -21,7 +21,7 @@ import { RouteFormModal } from '../../components/admin/RouteFormModal' import { BossBattleFormModal } from '../../components/admin/BossBattleFormModal' import { BossTeamEditor } from '../../components/admin/BossTeamEditor' import { TypeBadge } from '../../components/TypeBadge' -import { useGame } from '../../hooks/useGames' +import { useGame, useGames } from '../../hooks/useGames' import { useGameBosses } from '../../hooks/useBosses' import { useCreateRoute, @@ -162,11 +162,13 @@ function SortableRouteGroup({ function SortableBossRow({ boss, routes, + games, onPositionChange, onClick, }: { boss: BossBattle routes: GameRoute[] + games: import('../../types/game').Game[] onPositionChange: (bossId: number, afterRouteId: number | null) => void onClick: (b: BossBattle) => void }) { @@ -204,7 +206,17 @@ function SortableBossRow({ {boss.order} - {boss.name} + + {boss.name} + {boss.gameId != null && (() => { + const g = games.find((g) => g.id === boss.gameId) + return g ? ( + + {g.name} + + ) : null + })()} + {boss.bossType.replace('_', ' ')} @@ -247,7 +259,8 @@ export function AdminGameDetail() { const deleteRoute = useDeleteRoute(id) const reorderRoutes = useReorderRoutes(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 updateBoss = useUpdateBossBattle(id) const deleteBoss = useDeleteBossBattle(id) @@ -273,6 +286,9 @@ export function AdminGameDetail() { const routes = game.routes ?? [] const routeGroups = organizeRoutes(routes) + const versionGroupGames = (allGames ?? []).filter( + (g) => g.versionGroupId === game.versionGroupId, + ) const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event @@ -573,6 +589,7 @@ export function AdminGameDetail() { key={boss.id} boss={boss} routes={routes} + games={versionGroupGames} onPositionChange={(bossId, afterRouteId) => updateBoss.mutate({ bossId, @@ -596,6 +613,7 @@ export function AdminGameDetail() { {showCreateBoss && ( b.order)) + 1 : 1} onSubmit={(data) => createBoss.mutate(data as CreateBossBattleInput, { @@ -611,6 +629,7 @@ export function AdminGameDetail() { updateBoss.mutate( diff --git a/frontend/src/types/admin.ts b/frontend/src/types/admin.ts index 4535c77..ada4707 100644 --- a/frontend/src/types/admin.ts +++ b/frontend/src/types/admin.ts @@ -151,6 +151,7 @@ export interface CreateBossBattleInput { location: string section?: string | null spriteUrl?: string | null + gameId?: number | null } export interface UpdateBossBattleInput { @@ -165,6 +166,7 @@ export interface UpdateBossBattleInput { location?: string section?: string | null spriteUrl?: string | null + gameId?: number | null } export interface BossReorderItem { diff --git a/frontend/src/types/game.ts b/frontend/src/types/game.ts index eb5ae41..07adfa0 100644 --- a/frontend/src/types/game.ts +++ b/frontend/src/types/game.ts @@ -188,6 +188,7 @@ export interface BossBattle { location: string section: string | null spriteUrl: string | null + gameId: number | null pokemon: BossPokemon[] }