From 3412d6c6fdbbd700884a3889043fefe531f9bb60 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Sat, 14 Feb 2026 10:00:36 +0100 Subject: [PATCH] Add naming scheme support for genlockes with lineage-aware suggestions (#20) Genlockes can now select a naming scheme at creation time, which is automatically applied to every leg's run. When catching a pokemon whose evolution family appeared in a previous leg, the system suggests the original nickname with a roman numeral suffix (e.g., "Heracles II"). Co-Authored-By: Claude Opus 4.6 Reviewed-on: https://gitea.nerdboden.de/TheFurya/nuzlocke-tracker/pulls/20 Co-authored-by: Julian Tabel Co-committed-by: Julian Tabel --- ...--enable-naming-generator-for-genlockes.md | 61 +++++++++- ...b9c0d1e2_add_naming_scheme_to_genlockes.py | 31 +++++ backend/src/app/api/genlockes.py | 3 + backend/src/app/api/runs.py | 107 +++++++++++++++++- backend/src/app/models/genlocke.py | 1 + backend/src/app/schemas/genlocke.py | 3 + backend/src/app/services/naming.py | 37 ++++++ frontend/src/api/runs.ts | 8 +- frontend/src/components/EncounterModal.tsx | 5 +- frontend/src/hooks/useRuns.ts | 6 +- frontend/src/pages/NewGenlocke.tsx | 38 +++++++ frontend/src/pages/RunEncounters.tsx | 1 + frontend/src/types/game.ts | 3 + 13 files changed, 293 insertions(+), 11 deletions(-) create mode 100644 backend/src/app/alembic/versions/f7a8b9c0d1e2_add_naming_scheme_to_genlockes.py diff --git a/.beans/nuzlocke-tracker-5tac--enable-naming-generator-for-genlockes.md b/.beans/nuzlocke-tracker-5tac--enable-naming-generator-for-genlockes.md index 209a38c..fff1144 100644 --- a/.beans/nuzlocke-tracker-5tac--enable-naming-generator-for-genlockes.md +++ b/.beans/nuzlocke-tracker-5tac--enable-naming-generator-for-genlockes.md @@ -1,11 +1,66 @@ --- # nuzlocke-tracker-5tac title: Enable naming generator for Genlockes -status: draft +status: completed type: task priority: normal created_at: 2026-02-11T21:14:21Z -updated_at: 2026-02-11T21:15:25Z +updated_at: 2026-02-14T08:52:16Z --- -Genlockes are just multiple runs in a row. Selecting a naming scheme works just the same and we do not need bigger dictionaries, as the names basically get reset after each team. Only 6 Pokemon at most can be transfered and would take up names, but that is OK. +## Overview + +Genlockes are multiple nuzlocke runs played back-to-back. Currently, naming scheme selection is only available per-run, meaning genlocke runs don't get naming schemes at all (they're created automatically during genlocke creation and leg advancement). This task adds genlocke-level naming scheme selection and lineage-aware name suggestions. + +## Key Behaviors + +### 1. Genlocke-Level Naming Scheme +- When creating a genlocke, the user selects a naming scheme (same categories as standalone runs) +- This scheme is stored on the `Genlocke` model and automatically applied to every leg's `NuzlockeRun` +- Both the initial run (created in `create_genlocke`) and subsequent runs (created in `advance_leg`) inherit the genlocke's naming scheme + +### 2. Name Suggestions (Current Leg Only) +- Duplicate name checking stays scoped to the current run (already the case) +- Transferred pokemon carry their nicknames forward, so they naturally occupy names in the current run's used-name set + +### 3. Lineage-Aware Name Suggestions (Roman Numerals) +- When catching a pokemon in a genlocke leg (leg 2+), the system checks if any pokemon from the same **evolution family** was caught in a previous leg +- If so, the original nickname is suggested with a roman numeral suffix (e.g., "Heracles II", "Heracles III") +- The numeral represents the Nth distinct leg where this evolution family was originally caught (not transferred) + - Leg 1: Magikarp → "Heracles" (no numeral, first appearance) + - Leg 2: Magikarp or Gyarados caught → suggest "Heracles II" + - Leg 3: Magikarp caught again → suggest "Heracles III" +- Transferred pokemon don't count as new appearances (they're the same individual) +- The "base name" is taken from the first original encounter of that family across all legs +- The lineage suggestion appears as a **priority suggestion** alongside regular naming scheme suggestions +- The user can always choose a different name + +### 4. How the API Changes +- `GET /runs/{run_id}/name-suggestions` gains an optional `pokemon_id` query param +- When `pokemon_id` is provided AND the run belongs to a genlocke: + - Determine the pokemon's evolution family + - Query previous legs' encounters (excluding transfer-target encounters) for matching family members + - If matches found: compute the roman numeral and prepend "{base_name} {numeral}" to the suggestions list +- Regular naming scheme suggestions are returned as before + +## Checklist + +### Backend +- [x] Add `naming_scheme` column to `genlockes` table (Alembic migration) +- [x] Update `Genlocke` model with `naming_scheme: Mapped[str | None]` +- [x] Update `GenlockeCreate` schema to accept optional `naming_scheme: str | None` +- [x] Update `GenlockeResponse` and `GenlockeDetailResponse` to include `naming_scheme` +- [x] Update `create_genlocke` endpoint: pass `naming_scheme` to the first leg's `NuzlockeRun` +- [x] Update `advance_leg` endpoint: pass the genlocke's `naming_scheme` to the new leg's `NuzlockeRun` +- [x] Add roman numeral helper function (e.g., in `services/naming.py`) +- [x] Update `get_name_suggestions` endpoint to accept optional `pokemon_id` param +- [x] Implement lineage lookup: when in genlocke context with `pokemon_id`, query prior legs for evolution family matches (excluding transfers) and compute suggestion with roman numeral +- [ ] Add tests for lineage-aware name suggestions + +### Frontend +- [x] Update `CreateGenlockeInput` type to include `namingScheme?: string | null` +- [x] Add naming scheme selector to genlocke creation wizard (in the Rules step or as a new step) +- [x] Update `GenlockeResponse` / `GenlockeDetailResponse` types to include `namingScheme` +- [x] Update `EncounterModal` to pass selected `pokemonId` to name suggestions API when in genlocke context +- [x] Update `getNameSuggestions` API client to accept optional `pokemonId` param +- [x] Display lineage suggestion prominently in the suggestions UI (e.g., first pill with distinct styling) \ No newline at end of file diff --git a/backend/src/app/alembic/versions/f7a8b9c0d1e2_add_naming_scheme_to_genlockes.py b/backend/src/app/alembic/versions/f7a8b9c0d1e2_add_naming_scheme_to_genlockes.py new file mode 100644 index 0000000..6287855 --- /dev/null +++ b/backend/src/app/alembic/versions/f7a8b9c0d1e2_add_naming_scheme_to_genlockes.py @@ -0,0 +1,31 @@ +"""add naming_scheme to genlockes + +Revision ID: f7a8b9c0d1e2 +Revises: e5f70a1ca323 +Create Date: 2026-02-14 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "f7a8b9c0d1e2" +down_revision: str | Sequence[str] | None = "e5f70a1ca323" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Add naming_scheme column to genlockes table.""" + op.add_column( + "genlockes", + sa.Column("naming_scheme", sa.String(50), nullable=True), + ) + + +def downgrade() -> None: + """Remove naming_scheme column from genlockes table.""" + op.drop_column("genlockes", "naming_scheme") diff --git a/backend/src/app/api/genlockes.py b/backend/src/app/api/genlockes.py index ace7172..0f00270 100644 --- a/backend/src/app/api/genlockes.py +++ b/backend/src/app/api/genlockes.py @@ -458,6 +458,7 @@ async def create_genlocke( status="active", genlocke_rules=data.genlocke_rules, nuzlocke_rules=data.nuzlocke_rules, + naming_scheme=data.naming_scheme, ) session.add(genlocke) await session.flush() # get genlocke.id @@ -480,6 +481,7 @@ async def create_genlocke( name=f"{data.name.strip()} \u2014 Leg 1", status="active", rules=data.nuzlocke_rules, + naming_scheme=data.naming_scheme, ) session.add(first_run) await session.flush() # get first_run.id @@ -653,6 +655,7 @@ async def advance_leg( name=f"{genlocke.name} \u2014 Leg {next_leg.leg_order}", status="active", rules=genlocke.nuzlocke_rules, + naming_scheme=genlocke.naming_scheme, ) session.add(new_run) await session.flush() diff --git a/backend/src/app/api/runs.py b/backend/src/app/api/runs.py index 69ccea0..2ea9dda 100644 --- a/backend/src/app/api/runs.py +++ b/backend/src/app/api/runs.py @@ -8,6 +8,7 @@ from sqlalchemy.orm import joinedload, selectinload from app.core.database import get_session 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 GenlockeLeg from app.models.genlocke_transfer import GenlockeTransfer @@ -19,7 +20,13 @@ from app.schemas.run import ( RunResponse, RunUpdate, ) -from app.services.naming import get_naming_categories, suggest_names +from app.services.families import build_families +from app.services.naming import ( + get_naming_categories, + strip_roman_suffix, + suggest_names, + to_roman, +) router = APIRouter() @@ -33,6 +40,7 @@ async def list_naming_categories(): async def get_name_suggestions( run_id: int, count: int = 10, + pokemon_id: int | None = None, session: AsyncSession = Depends(get_session), ): run = await session.get(NuzlockeRun, run_id) @@ -51,7 +59,102 @@ async def get_name_suggestions( ) used_names = {row[0] for row in result} - return suggest_names(run.naming_scheme, used_names, count) + lineage_suggestion: str | None = None + + # Lineage-aware suggestion: check if this run belongs to a genlocke + if pokemon_id is not None: + lineage_suggestion = await _compute_lineage_suggestion( + session, run_id, pokemon_id + ) + + suggestions = suggest_names(run.naming_scheme, used_names, count) + + if lineage_suggestion and lineage_suggestion not in suggestions: + suggestions.insert(0, lineage_suggestion) + + return suggestions + + +async def _compute_lineage_suggestion( + session: AsyncSession, + run_id: int, + pokemon_id: int, +) -> str | None: + """Check previous genlocke legs for the same evolution family and suggest a name with roman numeral.""" + # Find the genlocke leg for this run + leg_result = await session.execute( + select(GenlockeLeg).where(GenlockeLeg.run_id == run_id) + ) + current_leg = leg_result.scalar_one_or_none() + if current_leg is None or current_leg.leg_order <= 1: + return None + + # Build evolution family map + evo_result = await session.execute(select(Evolution)) + evolutions = evo_result.scalars().all() + pokemon_to_family = build_families(evolutions) + + family_ids = set(pokemon_to_family.get(pokemon_id, [pokemon_id])) + family_ids.add(pokemon_id) + + # Get run IDs for all previous legs + prev_legs_result = await session.execute( + select(GenlockeLeg.run_id).where( + GenlockeLeg.genlocke_id == current_leg.genlocke_id, + GenlockeLeg.leg_order < current_leg.leg_order, + GenlockeLeg.run_id.isnot(None), + ) + ) + prev_run_ids = [row[0] for row in prev_legs_result] + if not prev_run_ids: + return None + + # Get transfer target encounter IDs (these are not "original" catches) + transfer_targets_result = await session.execute( + select(GenlockeTransfer.target_encounter_id).where( + GenlockeTransfer.genlocke_id == current_leg.genlocke_id, + ) + ) + transfer_target_ids = {row[0] for row in transfer_targets_result} + + # Find original (non-transfer) encounters from previous legs matching this family + enc_result = await session.execute( + select(Encounter.id, Encounter.nickname, Encounter.run_id).where( + Encounter.run_id.in_(prev_run_ids), + Encounter.pokemon_id.in_(family_ids), + Encounter.status == "caught", + Encounter.nickname.isnot(None), + ) + ) + matches = [ + (row[0], row[1], row[2]) + for row in enc_result + if row[0] not in transfer_target_ids + ] + + if not matches: + return None + + # Use the nickname from the first encounter (earliest leg) + # Build run_id -> leg_order mapping for sorting + leg_order_result = await session.execute( + select(GenlockeLeg.run_id, GenlockeLeg.leg_order).where( + GenlockeLeg.genlocke_id == current_leg.genlocke_id, + GenlockeLeg.run_id.in_(prev_run_ids), + ) + ) + run_to_leg_order = {row[0]: row[1] for row in leg_order_result} + + # Sort by leg order to find the first appearance + matches.sort(key=lambda m: run_to_leg_order.get(m[2], 0)) + base_name = strip_roman_suffix(matches[0][1]) + + # Count distinct legs with original encounters for this family + legs_with_family = len({run_to_leg_order.get(m[2]) for m in matches}) + + # The new one would be the next numeral (legs_with_family + 1) + numeral = to_roman(legs_with_family + 1) + return f"{base_name} {numeral}" @router.post("", response_model=RunResponse, status_code=201) diff --git a/backend/src/app/models/genlocke.py b/backend/src/app/models/genlocke.py index 813d7d1..e6a6f54 100644 --- a/backend/src/app/models/genlocke.py +++ b/backend/src/app/models/genlocke.py @@ -18,6 +18,7 @@ class Genlocke(Base): ) # active, completed, failed genlocke_rules: Mapped[dict] = mapped_column(JSONB, default=dict) nuzlocke_rules: Mapped[dict] = mapped_column(JSONB, default=dict) + naming_scheme: Mapped[str | None] = mapped_column(String(50), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) diff --git a/backend/src/app/schemas/genlocke.py b/backend/src/app/schemas/genlocke.py index 8b52f26..54cdc58 100644 --- a/backend/src/app/schemas/genlocke.py +++ b/backend/src/app/schemas/genlocke.py @@ -10,6 +10,7 @@ class GenlockeCreate(CamelModel): game_ids: list[int] genlocke_rules: dict = {} nuzlocke_rules: dict = {} + naming_scheme: str | None = None class GenlockeUpdate(CamelModel): @@ -51,6 +52,7 @@ class GenlockeResponse(CamelModel): status: str genlocke_rules: dict nuzlocke_rules: dict + naming_scheme: str | None = None created_at: datetime legs: list[GenlockeLegResponse] = [] @@ -98,6 +100,7 @@ class GenlockeDetailResponse(CamelModel): status: str genlocke_rules: dict nuzlocke_rules: dict + naming_scheme: str | None = None created_at: datetime legs: list[GenlockeLegDetailResponse] = [] stats: GenlockeStatsResponse diff --git a/backend/src/app/services/naming.py b/backend/src/app/services/naming.py index 9c1819b..6372be8 100644 --- a/backend/src/app/services/naming.py +++ b/backend/src/app/services/naming.py @@ -1,5 +1,6 @@ import json import random +import re from functools import lru_cache from pathlib import Path @@ -26,6 +27,42 @@ def get_words_for_category(category: str) -> list[str]: return _load_dictionary().get(category, []) +_ROMAN_NUMERALS = [ + (1000, "M"), + (900, "CM"), + (500, "D"), + (400, "CD"), + (100, "C"), + (90, "XC"), + (50, "L"), + (40, "XL"), + (10, "X"), + (9, "IX"), + (5, "V"), + (4, "IV"), + (1, "I"), +] + +_ROMAN_SUFFIX_RE = re.compile( + r"\s+(M{0,3}(?:CM|CD|D?C{0,3})(?:XC|XL|L?X{0,3})(?:IX|IV|V?I{0,3}))$" +) + + +def to_roman(n: int) -> str: + """Convert a positive integer to a roman numeral string.""" + parts: list[str] = [] + for value, numeral in _ROMAN_NUMERALS: + while n >= value: + parts.append(numeral) + n -= value + return "".join(parts) + + +def strip_roman_suffix(name: str) -> str: + """Remove a trailing roman numeral suffix from a name (e.g., 'Heracles II' -> 'Heracles').""" + return _ROMAN_SUFFIX_RE.sub("", name).strip() + + def suggest_names( category: str, used_names: set[str], diff --git a/frontend/src/api/runs.ts b/frontend/src/api/runs.ts index 1757bcb..936279e 100644 --- a/frontend/src/api/runs.ts +++ b/frontend/src/api/runs.ts @@ -33,6 +33,10 @@ export function getNamingCategories(): Promise { return api.get('/runs/naming-categories') } -export function getNameSuggestions(runId: number, count = 10): Promise { - return api.get(`/runs/${runId}/name-suggestions?count=${count}`) +export function getNameSuggestions(runId: number, count = 10, pokemonId?: number): Promise { + let url = `/runs/${runId}/name-suggestions?count=${count}` + if (pokemonId != null) { + url += `&pokemon_id=${pokemonId}` + } + return api.get(url) } diff --git a/frontend/src/components/EncounterModal.tsx b/frontend/src/components/EncounterModal.tsx index 6c38032..2b1343b 100644 --- a/frontend/src/components/EncounterModal.tsx +++ b/frontend/src/components/EncounterModal.tsx @@ -18,6 +18,7 @@ interface EncounterModalProps { gameId: number runId: number namingScheme?: string | null + isGenlocke?: boolean existing?: EncounterDetail dupedPokemonIds?: Set retiredPokemonIds?: Set @@ -97,6 +98,7 @@ export function EncounterModal({ gameId, runId, namingScheme, + isGenlocke, existing, dupedPokemonIds, retiredPokemonIds, @@ -126,8 +128,9 @@ export function EncounterModal({ const isEditing = !!existing const showSuggestions = !!namingScheme && status === 'caught' && !isEditing + const lineagePokemonId = isGenlocke && selectedPokemon ? selectedPokemon.pokemonId : null const { data: suggestions, refetch: regenerate, isFetching: loadingSuggestions } = - useNameSuggestions(showSuggestions ? runId : null) + useNameSuggestions(showSuggestions ? runId : null, lineagePokemonId) // Pre-select pokemon when editing useEffect(() => { diff --git a/frontend/src/hooks/useRuns.ts b/frontend/src/hooks/useRuns.ts index 8e65126..edf2b62 100644 --- a/frontend/src/hooks/useRuns.ts +++ b/frontend/src/hooks/useRuns.ts @@ -60,10 +60,10 @@ export function useNamingCategories() { }) } -export function useNameSuggestions(runId: number | null) { +export function useNameSuggestions(runId: number | null, pokemonId?: number | null) { return useQuery({ - queryKey: ['name-suggestions', runId], - queryFn: () => getNameSuggestions(runId!), + queryKey: ['name-suggestions', runId, pokemonId ?? null], + queryFn: () => getNameSuggestions(runId!, 10, pokemonId ?? undefined), enabled: runId !== null, }) } diff --git a/frontend/src/pages/NewGenlocke.tsx b/frontend/src/pages/NewGenlocke.tsx index 0593853..79bd122 100644 --- a/frontend/src/pages/NewGenlocke.tsx +++ b/frontend/src/pages/NewGenlocke.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' import { RulesConfiguration, StepIndicator } from '../components' import { useRegions, useCreateGenlocke } from '../hooks/useGenlockes' +import { useNamingCategories } from '../hooks/useRuns' import type { Game, GenlockeRules, Region } from '../types' import { DEFAULT_RULES } from '../types' import type { NuzlockeRules } from '../types/rules' @@ -46,6 +47,8 @@ export function NewGenlocke() { const [preset, setPreset] = useState(null) const [nuzlockeRules, setNuzlockeRules] = useState(DEFAULT_RULES) const [genlockeRules, setGenlockeRules] = useState({ retireHoF: false }) + const [namingScheme, setNamingScheme] = useState(null) + const { data: namingCategories } = useNamingCategories() const handlePresetSelect = (type: PresetType) => { setPreset(type) @@ -91,6 +94,7 @@ export function NewGenlocke() { gameIds: legs.map((l) => l.game.id), genlockeRules, nuzlockeRules, + namingScheme, }, { onSuccess: (data) => { @@ -323,6 +327,32 @@ export function NewGenlocke() { + {/* Naming scheme */} +
+
+

+ Naming Scheme +

+

+ Get nickname suggestions from a themed word list when catching Pokemon. Applied to all legs. +

+
+
+ +
+
+
+
+
Naming Scheme
+
+ {namingScheme + ? namingScheme.charAt(0).toUpperCase() + namingScheme.slice(1) + : 'None'} +
+
diff --git a/frontend/src/pages/RunEncounters.tsx b/frontend/src/pages/RunEncounters.tsx index dda4cba..1585129 100644 --- a/frontend/src/pages/RunEncounters.tsx +++ b/frontend/src/pages/RunEncounters.tsx @@ -1437,6 +1437,7 @@ export function RunEncounters() { gameId={run!.gameId} runId={runIdNum} namingScheme={run!.namingScheme} + isGenlocke={!!run!.genlocke} existing={editingEncounter ?? undefined} dupedPokemonIds={dupedPokemonIds} retiredPokemonIds={retiredPokemonIds} diff --git a/frontend/src/types/game.ts b/frontend/src/types/game.ts index eb5ae41..7c4d6d5 100644 --- a/frontend/src/types/game.ts +++ b/frontend/src/types/game.ts @@ -230,6 +230,7 @@ export interface Genlocke { status: 'active' | 'completed' | 'failed' genlockeRules: GenlockeRules nuzlockeRules: NuzlockeRules + namingScheme: string | null createdAt: string legs: GenlockeLeg[] } @@ -239,6 +240,7 @@ export interface CreateGenlockeInput { gameIds: number[] genlockeRules: GenlockeRules nuzlockeRules: NuzlockeRules + namingScheme?: string | null } // Genlocke list / detail types @@ -283,6 +285,7 @@ export interface GenlockeDetail { status: 'active' | 'completed' | 'failed' genlockeRules: GenlockeRules nuzlockeRules: NuzlockeRules + namingScheme: string | null createdAt: string legs: GenlockeLegDetail[] stats: GenlockeStats