diff --git a/.beans/nuzlocke-tracker-25mh--genlocke-tracking.md b/.beans/nuzlocke-tracker-25mh--genlocke-tracking.md index 07a3824..f9eac27 100644 --- a/.beans/nuzlocke-tracker-25mh--genlocke-tracking.md +++ b/.beans/nuzlocke-tracker-25mh--genlocke-tracking.md @@ -73,11 +73,11 @@ A dedicated page showing: 7. **Gauntlet/Retire HoF rule** — Enforce the "retire" mechanic with cumulative dupe list ## Success Criteria -- [ ] A user can create a new genlocke via a multi-step wizard (name, game selection with presets, rules) -- [ ] Games can be selected using True Genlocke, Normal Genlocke, or Custom presets, grouped by region -- [ ] Nuzlocke rules are configured once and applied uniformly to all legs -- [ ] Genlocke-specific rules (Keep HoF / Retire HoF) can be selected -- [ ] The first leg starts automatically upon genlocke creation +- [x] A user can create a new genlocke via a multi-step wizard (name, game selection with presets, rules) +- [x] Games can be selected using True Genlocke, Normal Genlocke, or Custom presets, grouped by region +- [x] Nuzlocke rules are configured once and applied uniformly to all legs +- [x] Genlocke-specific rules (Keep HoF / Retire HoF) can be selected +- [x] The first leg starts automatically upon genlocke creation - [ ] Each leg is a full nuzlocke run, tracked identically to standalone runs - [ ] Completing a leg triggers a transfer step where surviving Pokemon can be carried forward - [ ] Failing a leg marks the entire genlocke as failed diff --git a/.beans/nuzlocke-tracker-glh8--gather-generation-metadata-games-regions.md b/.beans/nuzlocke-tracker-glh8--gather-generation-metadata-games-regions.md index 80227af..6316eb5 100644 --- a/.beans/nuzlocke-tracker-glh8--gather-generation-metadata-games-regions.md +++ b/.beans/nuzlocke-tracker-glh8--gather-generation-metadata-games-regions.md @@ -1,11 +1,11 @@ --- # nuzlocke-tracker-glh8 title: Gather generation metadata (games, regions) -status: in-progress +status: completed type: task priority: normal created_at: 2026-02-08T19:20:49Z -updated_at: 2026-02-09T08:05:52Z +updated_at: 2026-02-09T08:06:19Z parent: nuzlocke-tracker-25mh blocking: - nuzlocke-tracker-kz5g diff --git a/.beans/nuzlocke-tracker-kz5g--genlocke-creation-wizard.md b/.beans/nuzlocke-tracker-kz5g--genlocke-creation-wizard.md index a244338..d37c28e 100644 --- a/.beans/nuzlocke-tracker-kz5g--genlocke-creation-wizard.md +++ b/.beans/nuzlocke-tracker-kz5g--genlocke-creation-wizard.md @@ -1,11 +1,11 @@ --- # nuzlocke-tracker-kz5g title: Genlocke creation wizard -status: todo +status: in-progress type: feature priority: normal created_at: 2026-02-09T07:42:10Z -updated_at: 2026-02-09T07:45:34Z +updated_at: 2026-02-09T08:10:10Z parent: nuzlocke-tracker-25mh blocking: - nuzlocke-tracker-x4p6 @@ -50,16 +50,16 @@ Multi-step wizard UI for creating a new genlocke. This is the entry point for th - Needs generation/region metadata to power the preset templates (see nuzlocke-tracker-glh8) ## Checklist -- [ ] Create `Genlocke` SQLAlchemy model (name, status, genlocke_rules JSONB, nuzlocke_rules JSONB, created_at) -- [ ] Create `GenlockeLeg` SQLAlchemy model (genlocke_id FK, run_id FK nullable, leg_order, game_id FK) -- [ ] Create Alembic migration for both new tables -- [ ] Create Pydantic schemas for genlocke creation request/response -- [ ] Implement `POST /api/v1/genlockes` endpoint (creates genlocke, legs, and first run) -- [ ] Build the multi-step wizard shell component with back/next navigation and step indicator -- [ ] Build Step 1: Name input -- [ ] Build Step 2: Preset template selector (True / Normal / Custom) with region-grouped game picker -- [ ] Build Step 3: Rules configuration (reuse `RulesConfiguration` + genlocke rules radio) -- [ ] Build Step 4: Confirmation summary with "Start Genlocke" action -- [ ] Add `/genlockes/new` route to the React Router config -- [ ] Add TypeScript types for genlocke API responses -- [ ] Wire up the wizard to call the create endpoint and redirect to the genlocke overview on success \ No newline at end of file +- [x] Create `Genlocke` SQLAlchemy model (name, status, genlocke_rules JSONB, nuzlocke_rules JSONB, created_at) +- [x] Create `GenlockeLeg` SQLAlchemy model (genlocke_id FK, run_id FK nullable, leg_order, game_id FK) +- [x] Create Alembic migration for both new tables +- [x] Create Pydantic schemas for genlocke creation request/response +- [x] Implement `POST /api/v1/genlockes` endpoint (creates genlocke, legs, and first run) +- [x] Build the multi-step wizard shell component with back/next navigation and step indicator +- [x] Build Step 1: Name input +- [x] Build Step 2: Preset template selector (True / Normal / Custom) with region-grouped game picker +- [x] Build Step 3: Rules configuration (reuse `RulesConfiguration` + genlocke rules radio) +- [x] Build Step 4: Confirmation summary with "Start Genlocke" action +- [x] Add `/genlockes/new` route to the React Router config +- [x] Add TypeScript types for genlocke API responses +- [x] Wire up the wizard to call the create endpoint and redirect to the genlocke overview on success \ No newline at end of file diff --git a/backend/src/app/alembic/versions/b2c3d4e5f6a8_add_genlocke_tables.py b/backend/src/app/alembic/versions/b2c3d4e5f6a8_add_genlocke_tables.py new file mode 100644 index 0000000..0cf040f --- /dev/null +++ b/backend/src/app/alembic/versions/b2c3d4e5f6a8_add_genlocke_tables.py @@ -0,0 +1,46 @@ +"""add genlocke tables + +Revision ID: b2c3d4e5f6a8 +Revises: a1b2c3d4e5f8, b7c8d9e0f1a2 +Create Date: 2026-02-09 14:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + + +# revision identifiers, used by Alembic. +revision: str = 'b2c3d4e5f6a8' +down_revision: Union[str, Sequence[str], None] = ('a1b2c3d4e5f8', 'b7c8d9e0f1a2') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'genlockes', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('name', sa.String(100), nullable=False), + sa.Column('status', sa.String(20), nullable=False, index=True), + sa.Column('genlocke_rules', JSONB(), nullable=False, server_default='{}'), + sa.Column('nuzlocke_rules', JSONB(), nullable=False, server_default='{}'), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + + op.create_table( + 'genlocke_legs', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('genlocke_id', sa.Integer(), sa.ForeignKey('genlockes.id', ondelete='CASCADE'), nullable=False, index=True), + sa.Column('game_id', sa.Integer(), sa.ForeignKey('games.id'), nullable=False, index=True), + sa.Column('run_id', sa.Integer(), sa.ForeignKey('nuzlocke_runs.id'), nullable=True, index=True), + sa.Column('leg_order', sa.SmallInteger(), nullable=False), + sa.UniqueConstraint('genlocke_id', 'leg_order', name='uq_genlocke_legs_order'), + ) + + +def downgrade() -> None: + op.drop_table('genlocke_legs') + op.drop_table('genlockes') diff --git a/backend/src/app/api/genlockes.py b/backend/src/app/api/genlockes.py new file mode 100644 index 0000000..18e06d7 --- /dev/null +++ b/backend/src/app/api/genlockes.py @@ -0,0 +1,81 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.database import get_session +from app.models.game import Game +from app.models.genlocke import Genlocke, GenlockeLeg +from app.models.nuzlocke_run import NuzlockeRun +from app.schemas.genlocke import GenlockeCreate, GenlockeResponse + +router = APIRouter() + + +@router.post("", response_model=GenlockeResponse, status_code=201) +async def create_genlocke( + data: GenlockeCreate, session: AsyncSession = Depends(get_session) +): + if not data.game_ids: + raise HTTPException(status_code=400, detail="At least one game is required") + + if not data.name.strip(): + raise HTTPException(status_code=400, detail="Name is required") + + # Validate all game_ids exist + result = await session.execute( + select(Game).where(Game.id.in_(data.game_ids)) + ) + found_games = {g.id: g for g in result.scalars().all()} + missing = [gid for gid in data.game_ids if gid not in found_games] + if missing: + raise HTTPException( + status_code=404, detail=f"Games not found: {missing}" + ) + + # Create genlocke + genlocke = Genlocke( + name=data.name.strip(), + status="active", + genlocke_rules=data.genlocke_rules, + nuzlocke_rules=data.nuzlocke_rules, + ) + session.add(genlocke) + await session.flush() # get genlocke.id + + # Create legs + legs = [] + for i, game_id in enumerate(data.game_ids, start=1): + leg = GenlockeLeg( + genlocke_id=genlocke.id, + game_id=game_id, + leg_order=i, + ) + session.add(leg) + legs.append(leg) + + # Create the first run + first_game = found_games[data.game_ids[0]] + first_run = NuzlockeRun( + game_id=first_game.id, + name=f"{data.name.strip()} \u2014 Leg 1", + status="active", + rules=data.nuzlocke_rules, + ) + session.add(first_run) + await session.flush() # get first_run.id + + # Link first leg to the run + legs[0].run_id = first_run.id + + await session.commit() + + # Reload with relationships + result = await session.execute( + select(Genlocke) + .where(Genlocke.id == genlocke.id) + .options( + selectinload(Genlocke.legs).selectinload(GenlockeLeg.game), + ) + ) + return result.scalar_one() diff --git a/backend/src/app/api/routes.py b/backend/src/app/api/routes.py index 1678e94..4f33dee 100644 --- a/backend/src/app/api/routes.py +++ b/backend/src/app/api/routes.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api import bosses, encounters, evolutions, export, games, health, pokemon, runs, stats +from app.api import bosses, encounters, evolutions, export, games, genlockes, health, pokemon, runs, stats api_router = APIRouter() api_router.include_router(health.router) @@ -8,6 +8,7 @@ api_router.include_router(games.router, prefix="/games", tags=["games"]) api_router.include_router(pokemon.router, tags=["pokemon"]) api_router.include_router(evolutions.router, tags=["evolutions"]) api_router.include_router(runs.router, prefix="/runs", tags=["runs"]) +api_router.include_router(genlockes.router, prefix="/genlockes", tags=["genlockes"]) api_router.include_router(encounters.router, tags=["encounters"]) api_router.include_router(stats.router, prefix="/stats", tags=["stats"]) api_router.include_router(bosses.router, tags=["bosses"]) diff --git a/backend/src/app/models/__init__.py b/backend/src/app/models/__init__.py index 3bc4c51..eb3cdfc 100644 --- a/backend/src/app/models/__init__.py +++ b/backend/src/app/models/__init__.py @@ -4,6 +4,7 @@ from app.models.boss_result import BossResult from app.models.encounter import Encounter from app.models.evolution import Evolution from app.models.game import Game +from app.models.genlocke import Genlocke, GenlockeLeg from app.models.nuzlocke_run import NuzlockeRun from app.models.pokemon import Pokemon from app.models.route import Route @@ -17,6 +18,8 @@ __all__ = [ "Encounter", "Evolution", "Game", + "Genlocke", + "GenlockeLeg", "NuzlockeRun", "Pokemon", "Route", diff --git a/backend/src/app/models/genlocke.py b/backend/src/app/models/genlocke.py new file mode 100644 index 0000000..8f003c4 --- /dev/null +++ b/backend/src/app/models/genlocke.py @@ -0,0 +1,46 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, SmallInteger, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func + +from app.core.database import Base + + +class Genlocke(Base): + __tablename__ = "genlockes" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + status: Mapped[str] = mapped_column(String(20), index=True) # active, completed, failed + genlocke_rules: Mapped[dict] = mapped_column(JSONB, default=dict) + nuzlocke_rules: Mapped[dict] = mapped_column(JSONB, default=dict) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + legs: Mapped[list["GenlockeLeg"]] = relationship( + back_populates="genlocke", order_by="GenlockeLeg.leg_order" + ) + + +class GenlockeLeg(Base): + __tablename__ = "genlocke_legs" + __table_args__ = ( + UniqueConstraint("genlocke_id", "leg_order", name="uq_genlocke_legs_order"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + genlocke_id: Mapped[int] = mapped_column( + ForeignKey("genlockes.id", ondelete="CASCADE"), index=True + ) + game_id: Mapped[int] = mapped_column(ForeignKey("games.id"), index=True) + run_id: Mapped[int | None] = mapped_column( + ForeignKey("nuzlocke_runs.id"), index=True + ) + leg_order: Mapped[int] = mapped_column(SmallInteger) + + genlocke: Mapped["Genlocke"] = relationship(back_populates="legs") + game: Mapped["Game"] = relationship() + run: Mapped["NuzlockeRun | None"] = relationship() diff --git a/backend/src/app/schemas/__init__.py b/backend/src/app/schemas/__init__.py index 6547db7..263bd7c 100644 --- a/backend/src/app/schemas/__init__.py +++ b/backend/src/app/schemas/__init__.py @@ -14,6 +14,7 @@ from app.schemas.encounter import ( EncounterResponse, EncounterUpdate, ) +from app.schemas.genlocke import GenlockeCreate, GenlockeResponse, GenlockeLegResponse from app.schemas.game import ( GameCreate, GameDetailResponse, @@ -54,6 +55,9 @@ __all__ = [ "EncounterResponse", "EncounterUpdate", "EvolutionResponse", + "GenlockeCreate", + "GenlockeLegResponse", + "GenlockeResponse", "GameCreate", "GameDetailResponse", "GameResponse", diff --git a/backend/src/app/schemas/genlocke.py b/backend/src/app/schemas/genlocke.py new file mode 100644 index 0000000..56d704c --- /dev/null +++ b/backend/src/app/schemas/genlocke.py @@ -0,0 +1,30 @@ +from datetime import datetime + +from app.schemas.base import CamelModel +from app.schemas.game import GameResponse + + +class GenlockeCreate(CamelModel): + name: str + game_ids: list[int] + genlocke_rules: dict = {} + nuzlocke_rules: dict = {} + + +class GenlockeLegResponse(CamelModel): + id: int + genlocke_id: int + game_id: int + run_id: int | None = None + leg_order: int + game: GameResponse + + +class GenlockeResponse(CamelModel): + id: int + name: str + status: str + genlocke_rules: dict + nuzlocke_rules: dict + created_at: datetime + legs: list[GenlockeLegResponse] = [] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bb0c4bc..62c24b6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { Routes, Route, Navigate } from 'react-router-dom' import { Layout } from './components' import { AdminLayout } from './components/admin' -import { Home, NewRun, RunList, RunEncounters, Stats } from './pages' +import { Home, NewGenlocke, NewRun, RunList, RunEncounters, Stats } from './pages' import { AdminGames, AdminGameDetail, @@ -19,6 +19,7 @@ function App() { } /> } /> } /> + } /> } /> } /> }> diff --git a/frontend/src/api/genlockes.ts b/frontend/src/api/genlockes.ts new file mode 100644 index 0000000..e9c9b30 --- /dev/null +++ b/frontend/src/api/genlockes.ts @@ -0,0 +1,10 @@ +import { api } from './client' +import type { Genlocke, CreateGenlockeInput, Region } from '../types/game' + +export function createGenlocke(data: CreateGenlockeInput): Promise { + return api.post('/genlockes', data) +} + +export function getGamesByRegion(): Promise { + return api.get('/games/by-region') +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 200a7ce..a78cd87 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -28,6 +28,12 @@ export function Layout() { > My Runs + + Genlockes + My Runs + setMenuOpen(false)} + className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700" + > + Genlockes + setMenuOpen(false)} diff --git a/frontend/src/components/StepIndicator.tsx b/frontend/src/components/StepIndicator.tsx index fd0a68e..38fb6cd 100644 --- a/frontend/src/components/StepIndicator.tsx +++ b/frontend/src/components/StepIndicator.tsx @@ -1,15 +1,16 @@ -const STEPS = ['Select Game', 'Configure Rules', 'Create Run'] +const DEFAULT_STEPS = ['Select Game', 'Configure Rules', 'Create Run'] interface StepIndicatorProps { currentStep: number onStepClick: (step: number) => void + steps?: string[] } -export function StepIndicator({ currentStep, onStepClick }: StepIndicatorProps) { +export function StepIndicator({ currentStep, onStepClick, steps = DEFAULT_STEPS }: StepIndicatorProps) { return (