feature/boss-sprites-and-badges #22
@@ -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.
|
||||||
@@ -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")
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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] = []
|
||||||
|
|||||||
@@ -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": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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[]> {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user