feat: add auth system, boss pokemon details, moves/abilities API, and run ownership
Some checks failed
CI / backend-tests (push) Failing after 1m16s
CI / frontend-tests (push) Successful in 57s

Add user authentication with login/signup/protected routes, boss pokemon
detail fields and result team tracking, moves and abilities selector
components and API, run ownership and visibility controls, and various
UI improvements across encounters, run list, and journal pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 21:41:38 +01:00
parent a6cb309b8b
commit 0a519e356e
69 changed files with 3574 additions and 693 deletions

View File

@@ -5,10 +5,13 @@ from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.auth import AuthUser, require_auth
from app.core.database import get_session
from app.models.boss_battle import BossBattle
from app.models.boss_pokemon import BossPokemon
from app.models.boss_result import BossResult
from app.models.boss_result_team import BossResultTeam
from app.models.encounter import Encounter
from app.models.game import Game
from app.models.nuzlocke_run import NuzlockeRun
from app.models.pokemon import Pokemon
@@ -28,6 +31,18 @@ from app.seeds.loader import upsert_bosses
router = APIRouter()
def _boss_pokemon_load_options():
"""Standard eager-loading options for BossPokemon relationships."""
return (
selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon),
selectinload(BossBattle.pokemon).selectinload(BossPokemon.ability),
selectinload(BossBattle.pokemon).selectinload(BossPokemon.move1),
selectinload(BossBattle.pokemon).selectinload(BossPokemon.move2),
selectinload(BossBattle.pokemon).selectinload(BossPokemon.move3),
selectinload(BossBattle.pokemon).selectinload(BossPokemon.move4),
)
async def _get_version_group_id(session: AsyncSession, game_id: int) -> int:
game = await session.get(Game, game_id)
if game is None:
@@ -53,7 +68,7 @@ async def list_bosses(
query = (
select(BossBattle)
.where(BossBattle.version_group_id == vg_id)
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
.options(*_boss_pokemon_load_options())
.order_by(BossBattle.order)
)
@@ -71,6 +86,7 @@ async def reorder_bosses(
game_id: int,
data: BossReorderRequest,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
vg_id = await _get_version_group_id(session, game_id)
@@ -101,7 +117,7 @@ async def reorder_bosses(
result = await session.execute(
select(BossBattle)
.where(BossBattle.version_group_id == vg_id)
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
.options(*_boss_pokemon_load_options())
.order_by(BossBattle.order)
)
return result.scalars().all()
@@ -114,6 +130,7 @@ async def create_boss(
game_id: int,
data: BossBattleCreate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
vg_id = await _get_version_group_id(session, game_id)
@@ -133,7 +150,7 @@ async def create_boss(
result = await session.execute(
select(BossBattle)
.where(BossBattle.id == boss.id)
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
.options(*_boss_pokemon_load_options())
)
return result.scalar_one()
@@ -144,6 +161,7 @@ async def update_boss(
boss_id: int,
data: BossBattleUpdate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
vg_id = await _get_version_group_id(session, game_id)
@@ -158,7 +176,7 @@ async def update_boss(
result = await session.execute(
select(BossBattle)
.where(BossBattle.id == boss_id, BossBattle.version_group_id == vg_id)
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
.options(*_boss_pokemon_load_options())
)
boss = result.scalar_one_or_none()
if boss is None:
@@ -174,7 +192,7 @@ async def update_boss(
result = await session.execute(
select(BossBattle)
.where(BossBattle.id == boss.id)
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
.options(*_boss_pokemon_load_options())
)
return result.scalar_one()
@@ -184,6 +202,7 @@ async def delete_boss(
game_id: int,
boss_id: int,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
vg_id = await _get_version_group_id(session, game_id)
@@ -206,6 +225,7 @@ async def bulk_import_bosses(
game_id: int,
items: list[BulkBossItem],
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
vg_id = await _get_version_group_id(session, game_id)
@@ -248,6 +268,7 @@ async def set_boss_team(
boss_id: int,
team: list[BossPokemonInput],
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
vg_id = await _get_version_group_id(session, game_id)
@@ -272,6 +293,13 @@ async def set_boss_team(
level=item.level,
order=item.order,
condition_label=item.condition_label,
ability_id=item.ability_id,
held_item=item.held_item,
nature=item.nature,
move1_id=item.move1_id,
move2_id=item.move2_id,
move3_id=item.move3_id,
move4_id=item.move4_id,
)
session.add(bp)
@@ -286,7 +314,7 @@ async def set_boss_team(
result = await session.execute(
select(BossBattle)
.where(BossBattle.id == boss.id)
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
.options(*_boss_pokemon_load_options())
)
return result.scalar_one()
@@ -301,7 +329,10 @@ async def list_boss_results(run_id: int, session: AsyncSession = Depends(get_ses
raise HTTPException(status_code=404, detail="Run not found")
result = await session.execute(
select(BossResult).where(BossResult.run_id == run_id).order_by(BossResult.id)
select(BossResult)
.where(BossResult.run_id == run_id)
.options(selectinload(BossResult.team))
.order_by(BossResult.id)
)
return result.scalars().all()
@@ -313,6 +344,7 @@ async def create_boss_result(
run_id: int,
data: BossResultCreate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
run = await session.get(NuzlockeRun, run_id)
if run is None:
@@ -322,12 +354,30 @@ async def create_boss_result(
if boss is None:
raise HTTPException(status_code=404, detail="Boss battle not found")
# Validate team encounter IDs belong to this run
if data.team:
encounter_ids = [t.encounter_id for t in data.team]
enc_result = await session.execute(
select(Encounter).where(
Encounter.id.in_(encounter_ids), Encounter.run_id == run_id
)
)
found_encounters = {e.id for e in enc_result.scalars().all()}
missing = [eid for eid in encounter_ids if eid not in found_encounters]
if missing:
raise HTTPException(
status_code=400,
detail=f"Encounters not found in this run: {missing}",
)
# Check for existing result (upsert)
existing = await session.execute(
select(BossResult).where(
select(BossResult)
.where(
BossResult.run_id == run_id,
BossResult.boss_battle_id == data.boss_battle_id,
)
.options(selectinload(BossResult.team))
)
result = existing.scalar_one_or_none()
@@ -335,6 +385,10 @@ async def create_boss_result(
result.result = data.result
result.attempts = data.attempts
result.completed_at = datetime.now(UTC) if data.result == "won" else None
# Clear existing team and add new
for tm in result.team:
await session.delete(tm)
await session.flush()
else:
result = BossResult(
run_id=run_id,
@@ -344,10 +398,26 @@ async def create_boss_result(
completed_at=datetime.now(UTC) if data.result == "won" else None,
)
session.add(result)
await session.flush()
# Add team members
for tm in data.team:
team_member = BossResultTeam(
boss_result_id=result.id,
encounter_id=tm.encounter_id,
level=tm.level,
)
session.add(team_member)
await session.commit()
await session.refresh(result)
return result
# Re-fetch with team loaded
fresh = await session.execute(
select(BossResult)
.where(BossResult.id == result.id)
.options(selectinload(BossResult.team))
)
return fresh.scalar_one()
@router.delete("/runs/{run_id}/boss-results/{result_id}", status_code=204)
@@ -355,6 +425,7 @@ async def delete_boss_result(
run_id: int,
result_id: int,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
result = await session.execute(
select(BossResult).where(