From 08a5e5c621f92a9c637281311eb123709cb141c2 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Mon, 9 Feb 2026 10:19:56 +0100 Subject: [PATCH] Add Hall of Fame team selection for completed runs After marking a run as completed, a modal prompts the player to select which Pokemon (up to 6) entered the Hall of Fame. The selection is stored as hof_encounter_ids on the run, displayed in the victory banner, and can be edited later. This lays the foundation for scoping genlocke retireHoF to only the actual HoF team. Co-Authored-By: Claude Opus 4.6 --- ...acker-xbxv--hall-of-fame-team-selection.md | 18 +-- ..._add_hof_encounter_ids_to_nuzlocke_runs.py | 30 +++++ backend/src/app/api/runs.py | 32 +++++ backend/src/app/models/nuzlocke_run.py | 1 + backend/src/app/schemas/run.py | 2 + frontend/src/components/HofTeamModal.tsx | 115 ++++++++++++++++++ frontend/src/components/index.ts | 1 + frontend/src/pages/RunEncounters.tsx | 75 +++++++++++- frontend/src/types/game.ts | 2 + 9 files changed, 266 insertions(+), 10 deletions(-) create mode 100644 backend/src/app/alembic/versions/d4e5f6a7b9c0_add_hof_encounter_ids_to_nuzlocke_runs.py create mode 100644 frontend/src/components/HofTeamModal.tsx diff --git a/.beans/nuzlocke-tracker-xbxv--hall-of-fame-team-selection.md b/.beans/nuzlocke-tracker-xbxv--hall-of-fame-team-selection.md index 5094a31..25d0ecb 100644 --- a/.beans/nuzlocke-tracker-xbxv--hall-of-fame-team-selection.md +++ b/.beans/nuzlocke-tracker-xbxv--hall-of-fame-team-selection.md @@ -1,11 +1,11 @@ --- # nuzlocke-tracker-xbxv title: Hall of Fame team selection -status: todo +status: in-progress type: feature priority: normal created_at: 2026-02-09T09:14:44Z -updated_at: 2026-02-09T09:15:08Z +updated_at: 2026-02-09T09:16:34Z blocking: - nuzlocke-tracker-25mh - nuzlocke-tracker-h3fw @@ -37,10 +37,10 @@ This is a general nuzlocke feature (not genlocke-specific) and provides value on - Allow editing the HoF team after the fact (in case the player forgot or made a mistake) ## Checklist -- [ ] Add `hof_encounter_ids` JSONB column to `nuzlocke_runs` (nullable array of ints) -- [ ] Migration for the new column -- [ ] Update `RunResponse` / `RunDetailResponse` schemas to include `hofEncounterIds` -- [ ] Extend `PATCH /runs/{id}` to accept `hofEncounterIds` (validate they belong to the run and are alive) -- [ ] Build HoF team selection modal (shown after completing a run) -- [ ] Display HoF team in the victory banner on completed run pages -- [ ] Allow editing HoF team selection on completed runs \ No newline at end of file +- [x] Add `hof_encounter_ids` JSONB column to `nuzlocke_runs` (nullable array of ints) +- [x] Migration for the new column +- [x] Update `RunResponse` / `RunDetailResponse` schemas to include `hofEncounterIds` +- [x] Extend `PATCH /runs/{id}` to accept `hofEncounterIds` (validate they belong to the run and are alive) +- [x] Build HoF team selection modal (shown after completing a run) +- [x] Display HoF team in the victory banner on completed run pages +- [x] Allow editing HoF team selection on completed runs \ No newline at end of file diff --git a/backend/src/app/alembic/versions/d4e5f6a7b9c0_add_hof_encounter_ids_to_nuzlocke_runs.py b/backend/src/app/alembic/versions/d4e5f6a7b9c0_add_hof_encounter_ids_to_nuzlocke_runs.py new file mode 100644 index 0000000..89f19bc --- /dev/null +++ b/backend/src/app/alembic/versions/d4e5f6a7b9c0_add_hof_encounter_ids_to_nuzlocke_runs.py @@ -0,0 +1,30 @@ +"""add hof_encounter_ids to nuzlocke_runs + +Revision ID: d4e5f6a7b9c0 +Revises: c3d4e5f6a7b9 +Create Date: 2026-02-09 20: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 = 'd4e5f6a7b9c0' +down_revision: Union[str, Sequence[str], None] = 'c3d4e5f6a7b9' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + 'nuzlocke_runs', + sa.Column('hof_encounter_ids', JSONB(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column('nuzlocke_runs', 'hof_encounter_ids') diff --git a/backend/src/app/api/runs.py b/backend/src/app/api/runs.py index 7bc9f4b..4d0816f 100644 --- a/backend/src/app/api/runs.py +++ b/backend/src/app/api/runs.py @@ -120,6 +120,38 @@ async def update_run( update_data = data.model_dump(exclude_unset=True) + # Validate hof_encounter_ids if provided + if "hof_encounter_ids" in update_data and update_data["hof_encounter_ids"] is not None: + hof_ids = update_data["hof_encounter_ids"] + if len(hof_ids) > 6: + raise HTTPException( + status_code=400, detail="HoF team cannot have more than 6 Pokemon" + ) + if hof_ids: + # Validate all encounter IDs belong to this run and are alive + enc_result = await session.execute( + select(Encounter).where( + Encounter.id.in_(hof_ids), + Encounter.run_id == run_id, + ) + ) + found = {e.id: e for e in enc_result.scalars().all()} + missing = [eid for eid in hof_ids if eid not in found] + if missing: + raise HTTPException( + status_code=400, + detail=f"Encounters not found in this run: {missing}", + ) + not_alive = [ + eid for eid, e in found.items() + if e.status != "caught" or e.faint_level is not None + ] + if not_alive: + raise HTTPException( + status_code=400, + detail=f"Encounters are not alive: {not_alive}", + ) + # Auto-set completed_at when ending a run if "status" in update_data and update_data["status"] in ("completed", "failed"): if run.status != "active": diff --git a/backend/src/app/models/nuzlocke_run.py b/backend/src/app/models/nuzlocke_run.py index 69d4f15..9e133c9 100644 --- a/backend/src/app/models/nuzlocke_run.py +++ b/backend/src/app/models/nuzlocke_run.py @@ -19,6 +19,7 @@ class NuzlockeRun(Base): DateTime(timezone=True), server_default=func.now() ) completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + hof_encounter_ids: Mapped[list[int] | None] = mapped_column(JSONB, default=None) game: Mapped["Game"] = relationship(back_populates="runs") encounters: Mapped[list["Encounter"]] = relationship(back_populates="run") diff --git a/backend/src/app/schemas/run.py b/backend/src/app/schemas/run.py index b0bc3d2..13b7c64 100644 --- a/backend/src/app/schemas/run.py +++ b/backend/src/app/schemas/run.py @@ -15,6 +15,7 @@ class RunUpdate(CamelModel): name: str | None = None status: str | None = None rules: dict | None = None + hof_encounter_ids: list[int] | None = None class RunResponse(CamelModel): @@ -23,6 +24,7 @@ class RunResponse(CamelModel): name: str status: str rules: dict + hof_encounter_ids: list[int] | None = None started_at: datetime completed_at: datetime | None diff --git a/frontend/src/components/HofTeamModal.tsx b/frontend/src/components/HofTeamModal.tsx new file mode 100644 index 0000000..43e859c --- /dev/null +++ b/frontend/src/components/HofTeamModal.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react' +import type { EncounterDetail } from '../types' + +interface HofTeamModalProps { + alive: EncounterDetail[] + onSubmit: (encounterIds: number[]) => void + onSkip: () => void + isPending: boolean +} + +export function HofTeamModal({ alive, onSubmit, onSkip, isPending }: HofTeamModalProps) { + const [selected, setSelected] = useState>(() => { + // Pre-select all if 6 or fewer + if (alive.length <= 6) return new Set(alive.map((e) => e.id)) + return new Set() + }) + + const toggle = (id: number) => { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else if (next.size < 6) { + next.add(id) + } + return next + }) + } + + return ( +
+
+
+
+

+ Hall of Fame Team +

+

+ Select the Pokemon that entered the Hall of Fame (max 6) +

+
+ +
+
+ {alive.map((enc) => { + const displayPokemon = enc.currentPokemon ?? enc.pokemon + const isSelected = selected.has(enc.id) + const atMax = selected.size >= 6 && !isSelected + + return ( + + ) + })} +
+
+ +
+ +
+ + {selected.size}/6 selected + + +
+
+
+
+ ) +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 192067c..41c66bb 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -3,6 +3,7 @@ export { EncounterMethodBadge } from './EncounterMethodBadge' export { EncounterModal } from './EncounterModal' export { EndRunModal } from './EndRunModal' export { GameCard } from './GameCard' +export { HofTeamModal } from './HofTeamModal' export { GameGrid } from './GameGrid' export { Layout } from './Layout' export { PokemonCard } from './PokemonCard' diff --git a/frontend/src/pages/RunEncounters.tsx b/frontend/src/pages/RunEncounters.tsx index b6d8153..45577a2 100644 --- a/frontend/src/pages/RunEncounters.tsx +++ b/frontend/src/pages/RunEncounters.tsx @@ -10,6 +10,7 @@ import { EggEncounterModal, EncounterModal, EncounterMethodBadge, + HofTeamModal, StatCard, PokemonCard, StatusChangeModal, @@ -413,6 +414,7 @@ export function RunEncounters() { const [selectedTeamEncounter, setSelectedTeamEncounter] = useState(null) const [showEndRun, setShowEndRun] = useState(false) + const [showHofModal, setShowHofModal] = useState(false) const [showShinyModal, setShowShinyModal] = useState(false) const [showEggModal, setShowEggModal] = useState(false) const [expandedBosses, setExpandedBosses] = useState>(new Set()) @@ -667,6 +669,13 @@ export function RunEncounters() { (e) => e.status === 'caught' && e.faintLevel !== null, ) + // Resolve HoF team encounters from IDs + const hofTeam = useMemo(() => { + if (!run.hofEncounterIds || run.hofEncounterIds.length === 0) return null + const idSet = new Set(run.hofEncounterIds) + return normalEncounters.filter((e) => idSet.has(e.id)) + }, [run.hofEncounterIds, normalEncounters]) + const toggleGroup = (groupId: number) => { updateExpandedGroups((prev) => { const next = new Set(prev) @@ -877,6 +886,48 @@ export function RunEncounters() { )}
+ {/* HoF Team Display */} + {run.status === 'completed' && ( +
+
+ + Hall of Fame + + +
+ {hofTeam ? ( +
+ {hofTeam.map((enc) => { + const dp = enc.currentPokemon ?? enc.pokemon + return ( +
+ {dp.spriteUrl ? ( + {dp.name} + ) : ( +
+ {dp.name[0].toUpperCase()} +
+ )} + + {enc.nickname || dp.name} + +
+ ) + })} +
+ ) : ( +

+ No HoF team selected yet +

+ )} +
+ )} )} @@ -1373,7 +1424,14 @@ export function RunEncounters() { onConfirm={(status) => { updateRun.mutate( { status }, - { onSuccess: () => setShowEndRun(false) }, + { + onSuccess: () => { + setShowEndRun(false) + if (status === 'completed') { + setShowHofModal(true) + } + }, + }, ) }} onClose={() => setShowEndRun(false)} @@ -1381,6 +1439,21 @@ export function RunEncounters() { genlockeContext={run.genlocke} /> )} + + {/* HoF Team Selection Modal */} + {showHofModal && ( + { + updateRun.mutate( + { hofEncounterIds: encounterIds }, + { onSuccess: () => setShowHofModal(false) }, + ) + }} + onSkip={() => setShowHofModal(false)} + isPending={updateRun.isPending} + /> + )} ) } diff --git a/frontend/src/types/game.ts b/frontend/src/types/game.ts index d99bc55..94ee152 100644 --- a/frontend/src/types/game.ts +++ b/frontend/src/types/game.ts @@ -89,6 +89,7 @@ export interface NuzlockeRun { name: string status: RunStatus rules: NuzlockeRules + hofEncounterIds: number[] | null startedAt: string completedAt: string | null } @@ -136,6 +137,7 @@ export interface UpdateRunInput { name?: string status?: RunStatus rules?: NuzlockeRules + hofEncounterIds?: number[] } export interface CreateEncounterInput {