diff --git a/.beans/nuzlocke-tracker-8w9s--gauntlet-rule-option-for-genlockes.md b/.beans/nuzlocke-tracker-8w9s--gauntlet-rule-option-for-genlockes.md index feb6642..e3d3ecd 100644 --- a/.beans/nuzlocke-tracker-8w9s--gauntlet-rule-option-for-genlockes.md +++ b/.beans/nuzlocke-tracker-8w9s--gauntlet-rule-option-for-genlockes.md @@ -1,11 +1,11 @@ --- # nuzlocke-tracker-8w9s title: Gauntlet rule option for genlockes -status: todo +status: in-progress type: feature priority: normal created_at: 2026-02-08T19:15:43Z -updated_at: 2026-02-09T07:46:32Z +updated_at: 2026-02-09T08:56:55Z parent: nuzlocke-tracker-25mh --- @@ -31,10 +31,10 @@ Add the **Retire HoF** (aka Gauntlet) rule as a genlocke-specific rule option. W - The dupe list should be visible somewhere in the genlocke dashboard so the player knows which families are off-limits ## Checklist -- [ ] Add a `retireHoF` boolean (or equivalent) to the genlocke rules JSONB schema -- [ ] On leg completion with Retire HoF enabled: resolve the full evolutionary families of all surviving HoF Pokemon -- [ ] Store the cumulative retired families list (could be a JSONB field on the Genlocke, or derived from completed legs) -- [ ] Implement `GET /api/v1/genlockes/{id}/retired-families` — return the list of retired evolutionary families with which leg they were retired in -- [ ] Integrate with the encounter system's duplicates clause: when logging an encounter in a genlocke leg, check the cumulative retired list and flag duplicates +- [x] Add a `retireHoF` boolean (or equivalent) to the genlocke rules JSONB schema +- [x] On leg completion with Retire HoF enabled: resolve the full evolutionary families of all surviving HoF Pokemon +- [x] Store the cumulative retired families list (could be a JSONB field on the Genlocke, or derived from completed legs) +- [x] Implement `GET /api/v1/genlockes/{id}/retired-families` — return the list of retired evolutionary families with which leg they were retired in +- [x] Integrate with the encounter system's duplicates clause: when logging an encounter in a genlocke leg, check the cumulative retired list and flag duplicates - [ ] Build a "Retired Families" display on the genlocke overview page showing all off-limits Pokemon with their sprites -- [ ] Ensure the creation wizard's genlocke rules step correctly toggles between Keep HoF and Retire HoF \ No newline at end of file +- [x] Ensure the creation wizard's genlocke rules step correctly toggles between Keep HoF and Retire HoF \ No newline at end of file diff --git a/.beans/nuzlocke-tracker-x4p6--genlocke-overview-page.md b/.beans/nuzlocke-tracker-x4p6--genlocke-overview-page.md index 9abf7f5..906dc88 100644 --- a/.beans/nuzlocke-tracker-x4p6--genlocke-overview-page.md +++ b/.beans/nuzlocke-tracker-x4p6--genlocke-overview-page.md @@ -55,5 +55,6 @@ A dedicated dashboard page for a genlocke, showing progress, configuration, and - [ ] Build the configuration section: display genlocke rules and nuzlocke rules - [ ] Build the cumulative stats section: aggregate encounters, deaths, and completions across legs - [ ] Add quick action links (active leg, graveyard, lineage — placeholder links for unimplemented features) +- [ ] Build "Retired Families" section: when retireHoF is enabled, display cumulative retired Pokemon with sprites grouped by leg (data available via `GET /api/v1/genlockes/{id}/retired-families`) - [ ] Add "Genlockes" navigation item to the app nav bar - [ ] Add `/genlockes` and `/genlockes/:genlockeId` routes to the React Router config \ No newline at end of file diff --git a/backend/src/app/alembic/versions/c3d4e5f6a7b9_add_retired_pokemon_ids_to_genlocke_legs.py b/backend/src/app/alembic/versions/c3d4e5f6a7b9_add_retired_pokemon_ids_to_genlocke_legs.py new file mode 100644 index 0000000..672f330 --- /dev/null +++ b/backend/src/app/alembic/versions/c3d4e5f6a7b9_add_retired_pokemon_ids_to_genlocke_legs.py @@ -0,0 +1,30 @@ +"""add retired_pokemon_ids to genlocke_legs + +Revision ID: c3d4e5f6a7b9 +Revises: b2c3d4e5f6a8 +Create Date: 2026-02-09 18: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 = 'c3d4e5f6a7b9' +down_revision: Union[str, Sequence[str], None] = 'b2c3d4e5f6a8' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + 'genlocke_legs', + sa.Column('retired_pokemon_ids', JSONB(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column('genlocke_legs', 'retired_pokemon_ids') diff --git a/backend/src/app/api/encounters.py b/backend/src/app/api/encounters.py index 485ca6c..765fe26 100644 --- a/backend/src/app/api/encounters.py +++ b/backend/src/app/api/encounters.py @@ -1,5 +1,4 @@ import random -from collections import deque from fastapi import APIRouter, Depends, HTTPException, Response from sqlalchemy import select @@ -9,6 +8,7 @@ from sqlalchemy.orm import joinedload, selectinload from app.core.database import get_session from app.models.encounter import Encounter from app.models.evolution import Evolution +from app.models.genlocke import GenlockeLeg from app.models.nuzlocke_run import NuzlockeRun from app.models.pokemon import Pokemon from app.models.route import Route @@ -20,6 +20,7 @@ from app.schemas.encounter import ( EncounterResponse, EncounterUpdate, ) +from app.services.families import build_families router = APIRouter() @@ -159,34 +160,6 @@ async def delete_encounter( return Response(status_code=204) -def _build_families(evolutions: list[Evolution]) -> dict[int, list[int]]: - """Build pokemon_id → family members mapping using BFS on evolution graph.""" - adj: dict[int, set[int]] = {} - for evo in evolutions: - adj.setdefault(evo.from_pokemon_id, set()).add(evo.to_pokemon_id) - adj.setdefault(evo.to_pokemon_id, set()).add(evo.from_pokemon_id) - - visited: set[int] = set() - pokemon_to_family: dict[int, list[int]] = {} - for node in adj: - if node in visited: - continue - component: list[int] = [] - queue = deque([node]) - while queue: - current = queue.popleft() - if current in visited: - continue - visited.add(current) - component.append(current) - for neighbor in adj.get(current, set()): - if neighbor not in visited: - queue.append(neighbor) - for member in component: - pokemon_to_family[member] = component - return pokemon_to_family - - @router.post( "/runs/{run_id}/encounters/bulk-randomize", response_model=BulkRandomizeResponse, @@ -247,7 +220,7 @@ async def bulk_randomize_encounters( if dupes_clause_on: evo_result = await session.execute(select(Evolution)) evolutions = evo_result.scalars().all() - pokemon_to_family = _build_families(evolutions) + pokemon_to_family = build_families(evolutions) # 7. Build initial duped set from existing caught encounters duped: set[int] = set() @@ -260,6 +233,23 @@ async def bulk_randomize_encounters( for member in family: duped.add(member) + # Seed duped set with retired Pokemon IDs from prior genlocke legs + leg_result = await session.execute( + select(GenlockeLeg).where(GenlockeLeg.run_id == run_id) + ) + leg = leg_result.scalar_one_or_none() + if leg: + genlocke_result = await session.execute( + select(GenlockeLeg.retired_pokemon_ids) + .where( + GenlockeLeg.genlocke_id == leg.genlocke_id, + GenlockeLeg.leg_order < leg.leg_order, + GenlockeLeg.retired_pokemon_ids.isnot(None), + ) + ) + for (retired_ids,) in genlocke_result: + duped.update(retired_ids) + # 8. Organize routes: identify top-level and children routes_by_id = {r.id: r for r in all_routes} top_level = [r for r in all_routes if r.parent_route_id is None] diff --git a/backend/src/app/api/genlockes.py b/backend/src/app/api/genlockes.py index 314e392..23ca871 100644 --- a/backend/src/app/api/genlockes.py +++ b/backend/src/app/api/genlockes.py @@ -1,13 +1,17 @@ from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel 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.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.schemas.genlocke import GenlockeCreate, GenlockeResponse +from app.services.families import build_families router = APIRouter() @@ -140,6 +144,37 @@ async def advance_leg( status_code=400, detail="Next leg already has a run" ) + # Compute retired Pokemon families if retireHoF is enabled + if genlocke.genlocke_rules.get("retireHoF", False): + # Query surviving caught Pokemon from the completed run + # "Surviving HoF" = caught, not fainted, not shiny + survivors_result = await session.execute( + select(Encounter.pokemon_id).where( + Encounter.run_id == current_leg.run_id, + Encounter.status == "caught", + Encounter.faint_level.is_(None), + Encounter.is_shiny.is_(False), + ) + ) + survivor_ids = [row[0] for row in survivors_result] + + if survivor_ids: + # Build family map from evolution data + evo_result = await session.execute(select(Evolution)) + evolutions = evo_result.scalars().all() + pokemon_to_family = build_families(evolutions) + + # Collect all family members of surviving Pokemon + retired: set[int] = set() + for pid in survivor_ids: + retired.add(pid) + for member in pokemon_to_family.get(pid, []): + retired.add(member) + + current_leg.retired_pokemon_ids = sorted(retired) + else: + current_leg.retired_pokemon_ids = [] + # Create a new run for the next leg new_run = NuzlockeRun( game_id=next_leg.game_id, @@ -162,3 +197,56 @@ async def advance_leg( ) ) return result.scalar_one() + + +class RetiredLegResponse(BaseModel): + leg_order: int + retired_pokemon_ids: list[int] + + class Config: + from_attributes = True + + +class RetiredFamiliesResponse(BaseModel): + retired_pokemon_ids: list[int] + by_leg: list[RetiredLegResponse] + + +@router.get( + "/{genlocke_id}/retired-families", + response_model=RetiredFamiliesResponse, +) +async def get_retired_families( + genlocke_id: int, + session: AsyncSession = Depends(get_session), +): + # Verify genlocke exists + genlocke = await session.get(Genlocke, genlocke_id) + if genlocke is None: + raise HTTPException(status_code=404, detail="Genlocke not found") + + # Query all legs with retired_pokemon_ids + result = await session.execute( + select(GenlockeLeg) + .where( + GenlockeLeg.genlocke_id == genlocke_id, + GenlockeLeg.retired_pokemon_ids.isnot(None), + ) + .order_by(GenlockeLeg.leg_order) + ) + legs = result.scalars().all() + + cumulative: set[int] = set() + by_leg: list[RetiredLegResponse] = [] + for leg in legs: + ids = leg.retired_pokemon_ids or [] + cumulative.update(ids) + by_leg.append(RetiredLegResponse( + leg_order=leg.leg_order, + retired_pokemon_ids=ids, + )) + + return RetiredFamiliesResponse( + retired_pokemon_ids=sorted(cumulative), + by_leg=by_leg, + ) diff --git a/backend/src/app/api/runs.py b/backend/src/app/api/runs.py index a0989ee..7bc9f4b 100644 --- a/backend/src/app/api/runs.py +++ b/backend/src/app/api/runs.py @@ -78,12 +78,29 @@ async def get_run(run_id: int, session: AsyncSession = Depends(get_session)): .where(GenlockeLeg.genlocke_id == leg.genlocke_id) ) total_legs = total_legs_result.scalar_one() + + # Aggregate retired Pokemon IDs from prior legs (retireHoF rule) + retired_pokemon_ids: list[int] = [] + if leg.genlocke.genlocke_rules.get("retireHoF", False) and leg.leg_order > 1: + prior_result = await session.execute( + select(GenlockeLeg.retired_pokemon_ids).where( + GenlockeLeg.genlocke_id == leg.genlocke_id, + GenlockeLeg.leg_order < leg.leg_order, + GenlockeLeg.retired_pokemon_ids.isnot(None), + ) + ) + cumulative: set[int] = set() + for (ids,) in prior_result: + cumulative.update(ids) + retired_pokemon_ids = sorted(cumulative) + genlocke_context = RunGenlockeContext( genlocke_id=leg.genlocke_id, genlocke_name=leg.genlocke.name, leg_order=leg.leg_order, total_legs=total_legs, is_final_leg=leg.leg_order == total_legs, + retired_pokemon_ids=retired_pokemon_ids, ) response = RunDetailResponse.model_validate(run) diff --git a/backend/src/app/models/genlocke.py b/backend/src/app/models/genlocke.py index 8f003c4..f2c3633 100644 --- a/backend/src/app/models/genlocke.py +++ b/backend/src/app/models/genlocke.py @@ -40,6 +40,7 @@ class GenlockeLeg(Base): ForeignKey("nuzlocke_runs.id"), index=True ) leg_order: Mapped[int] = mapped_column(SmallInteger) + retired_pokemon_ids: Mapped[list[int] | None] = mapped_column(JSONB, default=None) genlocke: Mapped["Genlocke"] = relationship(back_populates="legs") game: Mapped["Game"] = relationship() diff --git a/backend/src/app/schemas/run.py b/backend/src/app/schemas/run.py index 1f1c842..b0bc3d2 100644 --- a/backend/src/app/schemas/run.py +++ b/backend/src/app/schemas/run.py @@ -33,6 +33,7 @@ class RunGenlockeContext(CamelModel): leg_order: int total_legs: int is_final_leg: bool + retired_pokemon_ids: list[int] = [] class RunDetailResponse(RunResponse): diff --git a/backend/src/app/services/families.py b/backend/src/app/services/families.py new file mode 100644 index 0000000..510f485 --- /dev/null +++ b/backend/src/app/services/families.py @@ -0,0 +1,31 @@ +from collections import deque + +from app.models.evolution import Evolution + + +def build_families(evolutions: list[Evolution]) -> dict[int, list[int]]: + """Build pokemon_id -> family members mapping using BFS on evolution graph.""" + adj: dict[int, set[int]] = {} + for evo in evolutions: + adj.setdefault(evo.from_pokemon_id, set()).add(evo.to_pokemon_id) + adj.setdefault(evo.to_pokemon_id, set()).add(evo.from_pokemon_id) + + visited: set[int] = set() + pokemon_to_family: dict[int, list[int]] = {} + for node in adj: + if node in visited: + continue + component: list[int] = [] + queue = deque([node]) + while queue: + current = queue.popleft() + if current in visited: + continue + visited.add(current) + component.append(current) + for neighbor in adj.get(current, set()): + if neighbor not in visited: + queue.append(neighbor) + for member in component: + pokemon_to_family[member] = component + return pokemon_to_family diff --git a/frontend/src/components/EncounterModal.tsx b/frontend/src/components/EncounterModal.tsx index 6f5d15b..cd8d3a8 100644 --- a/frontend/src/components/EncounterModal.tsx +++ b/frontend/src/components/EncounterModal.tsx @@ -17,6 +17,7 @@ interface EncounterModalProps { gameId: number existing?: EncounterDetail dupedPokemonIds?: Set + retiredPokemonIds?: Set onSubmit: (data: { routeId: number pokemonId: number @@ -93,6 +94,7 @@ export function EncounterModal({ gameId, existing, dupedPokemonIds, + retiredPokemonIds, onSubmit, onUpdate, onClose, @@ -285,7 +287,7 @@ export function EncounterModal({ {isDuped && ( - already caught + {retiredPokemonIds?.has(rp.pokemonId) ? 'retired (HoF)' : 'already caught'} )} {!isDuped && SPECIAL_METHODS.includes(rp.encounterMethod) && ( diff --git a/frontend/src/pages/RunEncounters.tsx b/frontend/src/pages/RunEncounters.tsx index 8b0c6ef..b6d8153 100644 --- a/frontend/src/pages/RunEncounters.tsx +++ b/frontend/src/pages/RunEncounters.tsx @@ -469,6 +469,13 @@ export function RunEncounters() { return map }, [normalEncounters]) + // Build set of retired Pokemon IDs from genlocke context + const retiredPokemonIds = useMemo(() => { + const ids = run?.genlocke?.retiredPokemonIds + if (!ids || ids.length === 0) return undefined + return new Set(ids) + }, [run]) + // Build set of duped Pokemon IDs (for duplicates clause) const dupedPokemonIds = useMemo(() => { const dupesClauseOn = run?.rules?.duplicatesClause ?? true @@ -483,6 +490,14 @@ export function RunEncounters() { } const duped = new Set() + + // Seed with retired Pokemon IDs from prior genlocke legs + if (retiredPokemonIds) { + for (const id of retiredPokemonIds) { + duped.add(id) + } + } + for (const enc of normalEncounters) { if (enc.status !== 'caught') continue const pokemonId = enc.currentPokemonId ?? enc.pokemonId @@ -504,7 +519,7 @@ export function RunEncounters() { } } return duped.size > 0 ? duped : undefined - }, [run, normalEncounters, familiesData]) + }, [run, normalEncounters, familiesData, retiredPokemonIds]) // Find starter Pokemon name for auto-matching variant boss teams // Note: enc.route from the run detail doesn't include encounterMethods @@ -1286,6 +1301,7 @@ export function RunEncounters() { gameId={run!.gameId} existing={editingEncounter ?? undefined} dupedPokemonIds={dupedPokemonIds} + retiredPokemonIds={retiredPokemonIds} onSubmit={handleCreate} onUpdate={handleUpdate} onClose={() => { diff --git a/frontend/src/types/game.ts b/frontend/src/types/game.ts index 9ec96a4..d99bc55 100644 --- a/frontend/src/types/game.ts +++ b/frontend/src/types/game.ts @@ -99,6 +99,7 @@ export interface RunGenlockeContext { legOrder: number totalLegs: number isFinalLeg: boolean + retiredPokemonIds: number[] } export interface RunDetail extends NuzlockeRun {