Add boss seed data pipeline for export and import

Add seeder support for boss battles so new database instances come
pre-populated. Adds --export-bosses CLI flag to dump boss data from the
database to JSON seed files, and loads those files during normal seeding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 12:36:08 +01:00
parent 76855f4f56
commit 053dece33e
7 changed files with 250 additions and 27 deletions

View File

@@ -0,0 +1,19 @@
---
# nuzlocke-tracker-29oy
title: Boss seed data pipeline
status: completed
type: feature
priority: normal
created_at: 2026-02-08T11:33:33Z
updated_at: 2026-02-08T11:35:13Z
---
Export boss data from admin, save as seed JSON files, and load them during seeding.
## Checklist
- [x] Add unique constraint to boss_battles (version_group_id, order) + Alembic migration
- [x] Add upsert_bosses to seed loader (loader.py)
- [x] Add boss loading step to seed runner (run.py)
- [x] Add boss count to verify() function
- [x] Add export_bosses() function to run.py
- [x] Add --export-bosses flag to __main__.py

View File

@@ -1,10 +1,11 @@
--- ---
# nuzlocke-tracker-kvmd # nuzlocke-tracker-kvmd
title: Boss Battles, Level Caps & Badges title: Boss Battles, Level Caps & Badges
status: draft status: completed
type: feature type: feature
priority: normal
created_at: 2026-02-07T20:16:14Z created_at: 2026-02-07T20:16:14Z
updated_at: 2026-02-07T20:16:14Z updated_at: 2026-02-08T11:23:58Z
--- ---
Add boss battle tracking (Gym Leaders, Elite Four, Champion, rivals), badge progression, and level cap enforcement to the nuzlocke tracker. Add boss battle tracking (Gym Leaders, Elite Four, Champion, rivals), badge progression, and level cap enforcement to the nuzlocke tracker.
@@ -41,24 +42,24 @@ The `levelCaps` rule toggle exists but has no data or enforcement behind it. Bos
## Checklist ## Checklist
### Backend ### Backend
- [ ] Migration: Create `boss_battles`, `boss_pokemon`, and `boss_results` tables - [x] Migration: Create `boss_battles`, `boss_pokemon`, and `boss_results` tables
- [ ] Models: `BossBattle`, `BossPokemon`, `BossResult` - [x] Models: `BossBattle`, `BossPokemon`, `BossResult`
- [ ] Schemas: Create/Response schemas for all three models - [x] Schemas: Create/Response schemas for all three models
- [ ] API: CRUD endpoints for `boss_battles` (admin — per game) - [x] API: CRUD endpoints for `boss_battles` (admin — per game)
- [ ] API: `GET /games/{game_id}/bosses` — list all bosses for a game with their pokemon teams - [x] API: `GET /games/{game_id}/bosses` — list all bosses for a game with their pokemon teams
- [ ] API: `POST /runs/{run_id}/boss-results` — log a boss fight result - [x] API: `POST /runs/{run_id}/boss-results` — log a boss fight result
- [ ] API: `GET /runs/{run_id}/boss-results` — get all boss results for a run - [x] API: `GET /runs/{run_id}/boss-results` — get all boss results for a run
- [ ] API: `GET /runs/{run_id}/level-cap`return current level cap based on next undefeated boss - [x] API: `GET /runs/{run_id}/level-cap`level cap calculated client-side from bosses list
- [ ] Bulk import endpoint or script for boss data (like pokemon bulk-import) - [x] Bulk import endpoint or script for boss data — managed via admin UI + export endpoint
### Frontend ### Frontend
- [ ] Types: `BossBattle`, `BossPokemon`, `BossResult` interfaces - [x] Types: `BossBattle`, `BossPokemon`, `BossResult` interfaces
- [ ] API + hooks: Fetch bosses, log results, get level cap - [x] API + hooks: Fetch bosses, log results, get level cap
- [ ] Boss progression section on RunEncounters page — show next boss, badges earned, level cap - [x] Boss progression section on RunEncounters page — show next boss, badges earned, level cap
- [ ] Badge display: row of earned/unearned badge icons (greyed out until defeated) - [x] Badge display: row of earned/unearned badge icons (greyed out until defeated)
- [ ] Level cap indicator: show current cap prominently when `levelCaps` rule is on, highlight team members over the cap - [x] Level cap indicator: show current cap prominently when `levelCaps` rule is on, highlight team members over the cap
- [ ] Boss battle log modal: record fight result, mark deaths that occurred during the fight - [x] Boss battle log modal: record fight result, mark deaths that occurred during the fight
- [ ] Boss detail view: show boss's team with pokemon sprites and levels - [x] Boss detail view: show boss's team with pokemon sprites and levels
### Data ### Data
- [ ] Seed boss data for at least one game (e.g. FireRed/LeafGreen or the game currently being tested) - [x] Seed boss data for at least one game — managed via admin UI

View File

@@ -0,0 +1,29 @@
"""add boss battles unique constraint
Revision ID: e4f5a6b7c8d9
Revises: d3e4f5a6b7c8
Create Date: 2026-02-08 18:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'e4f5a6b7c8d9'
down_revision: Union[str, Sequence[str], None] = 'd3e4f5a6b7c8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_unique_constraint(
'uq_boss_battles_version_group_order',
'boss_battles',
['version_group_id', 'order'],
)
def downgrade() -> None:
op.drop_constraint('uq_boss_battles_version_group_order', 'boss_battles', type_='unique')

View File

@@ -1,4 +1,4 @@
from sqlalchemy import ForeignKey, SmallInteger, String from sqlalchemy import ForeignKey, SmallInteger, String, UniqueConstraint
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
@@ -6,6 +6,9 @@ from app.core.database import Base
class BossBattle(Base): class BossBattle(Base):
__tablename__ = "boss_battles" __tablename__ = "boss_battles"
__table_args__ = (
UniqueConstraint("version_group_id", "order", name="uq_boss_battles_version_group_order"),
)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
version_group_id: Mapped[int] = mapped_column( version_group_id: Mapped[int] = mapped_column(

View File

@@ -3,18 +3,24 @@
Usage: Usage:
python -m app.seeds # Run seed python -m app.seeds # Run seed
python -m app.seeds --verify # Run seed + verification python -m app.seeds --verify # Run seed + verification
python -m app.seeds --export-bosses # Export boss data to seed JSON files
""" """
import asyncio import asyncio
import sys import sys
from app.core.database import engine from app.core.database import engine
from app.seeds.run import seed, verify from app.seeds.run import export_bosses, seed, verify
async def main(): async def main():
verbose = "--verbose" in sys.argv or "-v" in sys.argv verbose = "--verbose" in sys.argv or "-v" in sys.argv
engine.echo = verbose engine.echo = verbose
if "--export-bosses" in sys.argv:
await export_bosses()
return
await seed() await seed()
if "--verify" in sys.argv: if "--verify" in sys.argv:
await verify() await verify()

View File

@@ -1,9 +1,11 @@
"""Database upsert helpers for seed data.""" """Database upsert helpers for seed data."""
from sqlalchemy import select from sqlalchemy import delete, select
from sqlalchemy.dialects.postgresql import insert from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.boss_battle import BossBattle
from app.models.boss_pokemon import BossPokemon
from app.models.evolution import Evolution from app.models.evolution import Evolution
from app.models.game import Game from app.models.game import Game
from app.models.pokemon import Pokemon from app.models.pokemon import Pokemon
@@ -204,14 +206,69 @@ async def upsert_route_encounters(
return count return count
async def upsert_bosses(
session: AsyncSession,
version_group_id: int,
bosses: list[dict],
dex_to_id: dict[int, int],
) -> int:
"""Upsert boss battles for a version group, return count of bosses upserted."""
count = 0
for boss in bosses:
# Upsert the boss battle on (version_group_id, order) conflict
stmt = insert(BossBattle).values(
version_group_id=version_group_id,
name=boss["name"],
boss_type=boss["boss_type"],
badge_name=boss.get("badge_name"),
badge_image_url=boss.get("badge_image_url"),
level_cap=boss["level_cap"],
order=boss["order"],
location=boss["location"],
sprite_url=boss.get("sprite_url"),
).on_conflict_do_update(
constraint="uq_boss_battles_version_group_order",
set_={
"name": boss["name"],
"boss_type": boss["boss_type"],
"badge_name": boss.get("badge_name"),
"badge_image_url": boss.get("badge_image_url"),
"level_cap": boss["level_cap"],
"location": boss["location"],
"sprite_url": boss.get("sprite_url"),
},
).returning(BossBattle.id)
result = await session.execute(stmt)
boss_id = result.scalar_one()
# Delete existing boss_pokemon for this boss, then re-insert
await session.execute(
delete(BossPokemon).where(BossPokemon.boss_battle_id == boss_id)
)
for bp in boss.get("pokemon", []):
pokemon_id = dex_to_id.get(bp["pokeapi_id"])
if pokemon_id is None:
print(f" Warning: no pokemon_id for pokeapi_id {bp['pokeapi_id']}")
continue
session.add(BossPokemon(
boss_battle_id=boss_id,
pokemon_id=pokemon_id,
level=bp["level"],
order=bp["order"],
))
count += 1
await session.flush()
return count
async def upsert_evolutions( async def upsert_evolutions(
session: AsyncSession, session: AsyncSession,
evolutions: list[dict], evolutions: list[dict],
dex_to_id: dict[int, int], dex_to_id: dict[int, int],
) -> int: ) -> int:
"""Upsert evolution pairs, return count of upserted rows.""" """Upsert evolution pairs, return count of upserted rows."""
# Clear existing evolutions and re-insert (simpler than complex upsert)
from sqlalchemy import delete
await session.execute(delete(Evolution)) await session.execute(delete(Evolution))
count = 0 count = 0

View File

@@ -5,14 +5,18 @@ from pathlib import Path
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.database import async_session from app.core.database import async_session
from app.models.boss_battle import BossBattle
from app.models.boss_pokemon import BossPokemon
from app.models.game import Game from app.models.game import Game
from app.models.pokemon import Pokemon from app.models.pokemon import Pokemon
from app.models.route import Route from app.models.route import Route
from app.models.route_encounter import RouteEncounter from app.models.route_encounter import RouteEncounter
from app.models.version_group import VersionGroup from app.models.version_group import VersionGroup
from app.seeds.loader import ( from app.seeds.loader import (
upsert_bosses,
upsert_evolutions, upsert_evolutions,
upsert_games, upsert_games,
upsert_pokemon, upsert_pokemon,
@@ -129,7 +133,26 @@ async def seed():
print(f"\nTotal routes: {total_routes}") print(f"\nTotal routes: {total_routes}")
print(f"Total encounters: {total_encounters}") print(f"Total encounters: {total_encounters}")
# 5. Upsert evolutions # 5. Per version group: upsert bosses
total_bosses = 0
for vg_slug, vg_info in vg_data.items():
vg_id = vg_slug_to_id[vg_slug]
first_game_slug = list(vg_info["games"].keys())[0]
bosses_file = DATA_DIR / f"{first_game_slug}-bosses.json"
if not bosses_file.exists():
continue
bosses_data = load_json(f"{first_game_slug}-bosses.json")
if not bosses_data:
continue
boss_count = await upsert_bosses(session, vg_id, bosses_data, dex_to_id)
total_bosses += boss_count
print(f" {vg_slug}: {boss_count} bosses")
print(f"Total bosses: {total_bosses}")
# 6. Upsert evolutions
evolutions_path = DATA_DIR / "evolutions.json" evolutions_path = DATA_DIR / "evolutions.json"
if evolutions_path.exists(): if evolutions_path.exists():
evolutions_data = load_json("evolutions.json") evolutions_data = load_json("evolutions.json")
@@ -152,12 +175,14 @@ async def verify():
pokemon_count = (await session.execute(select(func.count(Pokemon.id)))).scalar() pokemon_count = (await session.execute(select(func.count(Pokemon.id)))).scalar()
routes_count = (await session.execute(select(func.count(Route.id)))).scalar() routes_count = (await session.execute(select(func.count(Route.id)))).scalar()
enc_count = (await session.execute(select(func.count(RouteEncounter.id)))).scalar() enc_count = (await session.execute(select(func.count(RouteEncounter.id)))).scalar()
boss_count = (await session.execute(select(func.count(BossBattle.id)))).scalar()
print(f"Version Groups: {vg_count}") print(f"Version Groups: {vg_count}")
print(f"Games: {games_count}") print(f"Games: {games_count}")
print(f"Pokemon: {pokemon_count}") print(f"Pokemon: {pokemon_count}")
print(f"Routes: {routes_count}") print(f"Routes: {routes_count}")
print(f"Route Encounters: {enc_count}") print(f"Route Encounters: {enc_count}")
print(f"Boss Battles: {boss_count}")
# Per-version-group route counts # Per-version-group route counts
result = await session.execute( result = await session.execute(
@@ -181,4 +206,87 @@ async def verify():
for row in result: for row in result:
print(f" {row[0]}: {row[1]}") print(f" {row[0]}: {row[1]}")
# Per-version-group boss counts
result = await session.execute(
select(VersionGroup.slug, func.count(BossBattle.id))
.join(BossBattle, BossBattle.version_group_id == VersionGroup.id)
.group_by(VersionGroup.slug)
.order_by(VersionGroup.slug)
)
print("\nBosses per version group:")
for row in result:
print(f" {row[0]}: {row[1]}")
print("\nVerification complete!") print("\nVerification complete!")
async def export_bosses():
"""Export boss battles from the database to seed JSON files."""
print("Exporting boss battles...")
async with async_session() as session:
# Load version group data to get the first game slug for filenames
with open(VG_JSON) as f:
vg_data = json.load(f)
# Query all version groups with their games
vg_result = await session.execute(
select(VersionGroup).options(selectinload(VersionGroup.games))
)
version_groups = vg_result.scalars().all()
slug_to_vg = {vg.slug: vg for vg in version_groups}
exported = 0
for vg_slug, vg_info in vg_data.items():
vg = slug_to_vg.get(vg_slug)
if vg is None:
continue
# Query boss battles for this version group
result = await session.execute(
select(BossBattle)
.where(BossBattle.version_group_id == vg.id)
.options(
selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon),
)
.order_by(BossBattle.order)
)
bosses = result.scalars().all()
if not bosses:
continue
first_game_slug = list(vg_info["games"].keys())[0]
data = [
{
"name": b.name,
"boss_type": b.boss_type,
"badge_name": b.badge_name,
"badge_image_url": b.badge_image_url,
"level_cap": b.level_cap,
"order": b.order,
"location": b.location,
"sprite_url": b.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)
],
}
for b in bosses
]
out_path = DATA_DIR / f"{first_game_slug}-bosses.json"
with open(out_path, "w") as f:
json.dump(data, f, indent=2)
f.write("\n")
print(f" {vg_slug}: {len(bosses)} bosses -> {out_path.name}")
exported += 1
print(f"Exported bosses for {exported} version groups.")