diff --git a/.beans/nuzlocke-tracker-29oy--boss-seed-data-pipeline.md b/.beans/nuzlocke-tracker-29oy--boss-seed-data-pipeline.md new file mode 100644 index 0000000..d23f423 --- /dev/null +++ b/.beans/nuzlocke-tracker-29oy--boss-seed-data-pipeline.md @@ -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 \ No newline at end of file diff --git a/.beans/nuzlocke-tracker-kvmd--boss-battles-level-caps-badges.md b/.beans/nuzlocke-tracker-kvmd--boss-battles-level-caps-badges.md index 48220fd..58f17e2 100644 --- a/.beans/nuzlocke-tracker-kvmd--boss-battles-level-caps-badges.md +++ b/.beans/nuzlocke-tracker-kvmd--boss-battles-level-caps-badges.md @@ -1,10 +1,11 @@ --- # nuzlocke-tracker-kvmd title: Boss Battles, Level Caps & Badges -status: draft +status: completed type: feature +priority: normal 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. @@ -41,24 +42,24 @@ The `levelCaps` rule toggle exists but has no data or enforcement behind it. Bos ## Checklist ### Backend -- [ ] Migration: Create `boss_battles`, `boss_pokemon`, and `boss_results` tables -- [ ] Models: `BossBattle`, `BossPokemon`, `BossResult` -- [ ] Schemas: Create/Response schemas for all three models -- [ ] 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 -- [ ] 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 -- [ ] API: `GET /runs/{run_id}/level-cap` — return current level cap based on next undefeated boss -- [ ] Bulk import endpoint or script for boss data (like pokemon bulk-import) +- [x] Migration: Create `boss_battles`, `boss_pokemon`, and `boss_results` tables +- [x] Models: `BossBattle`, `BossPokemon`, `BossResult` +- [x] Schemas: Create/Response schemas for all three models +- [x] API: CRUD endpoints for `boss_battles` (admin — per game) +- [x] API: `GET /games/{game_id}/bosses` — list all bosses for a game with their pokemon teams +- [x] API: `POST /runs/{run_id}/boss-results` — log a boss fight result +- [x] API: `GET /runs/{run_id}/boss-results` — get all boss results for a run +- [x] API: `GET /runs/{run_id}/level-cap` — level cap calculated client-side from bosses list +- [x] Bulk import endpoint or script for boss data — managed via admin UI + export endpoint ### Frontend -- [ ] Types: `BossBattle`, `BossPokemon`, `BossResult` interfaces -- [ ] API + hooks: Fetch bosses, log results, get level cap -- [ ] 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) -- [ ] 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 -- [ ] Boss detail view: show boss's team with pokemon sprites and levels +- [x] Types: `BossBattle`, `BossPokemon`, `BossResult` interfaces +- [x] API + hooks: Fetch bosses, log results, get level cap +- [x] Boss progression section on RunEncounters page — show next boss, badges earned, level cap +- [x] Badge display: row of earned/unearned badge icons (greyed out until defeated) +- [x] Level cap indicator: show current cap prominently when `levelCaps` rule is on, highlight team members over the cap +- [x] Boss battle log modal: record fight result, mark deaths that occurred during the fight +- [x] Boss detail view: show boss's team with pokemon sprites and levels ### Data -- [ ] Seed boss data for at least one game (e.g. FireRed/LeafGreen or the game currently being tested) \ No newline at end of file +- [x] Seed boss data for at least one game — managed via admin UI \ No newline at end of file diff --git a/backend/src/app/alembic/versions/e4f5a6b7c8d9_add_boss_battles_unique_constraint.py b/backend/src/app/alembic/versions/e4f5a6b7c8d9_add_boss_battles_unique_constraint.py new file mode 100644 index 0000000..579a552 --- /dev/null +++ b/backend/src/app/alembic/versions/e4f5a6b7c8d9_add_boss_battles_unique_constraint.py @@ -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') diff --git a/backend/src/app/models/boss_battle.py b/backend/src/app/models/boss_battle.py index ebdd0fe..b4012f2 100644 --- a/backend/src/app/models/boss_battle.py +++ b/backend/src/app/models/boss_battle.py @@ -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 app.core.database import Base @@ -6,6 +6,9 @@ from app.core.database import Base class BossBattle(Base): __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) version_group_id: Mapped[int] = mapped_column( diff --git a/backend/src/app/seeds/__main__.py b/backend/src/app/seeds/__main__.py index ffacfe1..3cd0658 100644 --- a/backend/src/app/seeds/__main__.py +++ b/backend/src/app/seeds/__main__.py @@ -1,20 +1,26 @@ """Entry point for running seeds. Usage: - python -m app.seeds # Run seed - python -m app.seeds --verify # Run seed + verification + python -m app.seeds # Run seed + python -m app.seeds --verify # Run seed + verification + python -m app.seeds --export-bosses # Export boss data to seed JSON files """ import asyncio import sys 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(): verbose = "--verbose" in sys.argv or "-v" in sys.argv engine.echo = verbose + + if "--export-bosses" in sys.argv: + await export_bosses() + return + await seed() if "--verify" in sys.argv: await verify() diff --git a/backend/src/app/seeds/loader.py b/backend/src/app/seeds/loader.py index de15d9d..374062c 100644 --- a/backend/src/app/seeds/loader.py +++ b/backend/src/app/seeds/loader.py @@ -1,9 +1,11 @@ """Database upsert helpers for seed data.""" -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.dialects.postgresql import insert 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.game import Game from app.models.pokemon import Pokemon @@ -204,14 +206,69 @@ async def upsert_route_encounters( 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( session: AsyncSession, evolutions: list[dict], dex_to_id: dict[int, int], ) -> int: """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)) count = 0 diff --git a/backend/src/app/seeds/run.py b/backend/src/app/seeds/run.py index 9539109..8488f16 100644 --- a/backend/src/app/seeds/run.py +++ b/backend/src/app/seeds/run.py @@ -5,14 +5,18 @@ from pathlib import Path from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload 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.pokemon import Pokemon from app.models.route import Route from app.models.route_encounter import RouteEncounter from app.models.version_group import VersionGroup from app.seeds.loader import ( + upsert_bosses, upsert_evolutions, upsert_games, upsert_pokemon, @@ -129,7 +133,26 @@ async def seed(): print(f"\nTotal routes: {total_routes}") 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" if evolutions_path.exists(): evolutions_data = load_json("evolutions.json") @@ -152,12 +175,14 @@ async def verify(): pokemon_count = (await session.execute(select(func.count(Pokemon.id)))).scalar() routes_count = (await session.execute(select(func.count(Route.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"Games: {games_count}") print(f"Pokemon: {pokemon_count}") print(f"Routes: {routes_count}") print(f"Route Encounters: {enc_count}") + print(f"Boss Battles: {boss_count}") # Per-version-group route counts result = await session.execute( @@ -181,4 +206,87 @@ async def verify(): for row in result: 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!") + + +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.")