feature/genlocke-naming-scheme #23

Merged
TheFurya merged 2 commits from feature/genlocke-naming-scheme into develop 2026-02-14 11:04:46 +01:00
13 changed files with 293 additions and 11 deletions

View File

@@ -1,11 +1,66 @@
--- ---
# nuzlocke-tracker-5tac # nuzlocke-tracker-5tac
title: Enable naming generator for Genlockes title: Enable naming generator for Genlockes
status: draft status: completed
type: task type: task
priority: normal priority: normal
created_at: 2026-02-11T21:14:21Z 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)

View File

@@ -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")

View File

@@ -458,6 +458,7 @@ async def create_genlocke(
status="active", status="active",
genlocke_rules=data.genlocke_rules, genlocke_rules=data.genlocke_rules,
nuzlocke_rules=data.nuzlocke_rules, nuzlocke_rules=data.nuzlocke_rules,
naming_scheme=data.naming_scheme,
) )
session.add(genlocke) session.add(genlocke)
await session.flush() # get genlocke.id await session.flush() # get genlocke.id
@@ -480,6 +481,7 @@ async def create_genlocke(
name=f"{data.name.strip()} \u2014 Leg 1", name=f"{data.name.strip()} \u2014 Leg 1",
status="active", status="active",
rules=data.nuzlocke_rules, rules=data.nuzlocke_rules,
naming_scheme=data.naming_scheme,
) )
session.add(first_run) session.add(first_run)
await session.flush() # get first_run.id 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}", name=f"{genlocke.name} \u2014 Leg {next_leg.leg_order}",
status="active", status="active",
rules=genlocke.nuzlocke_rules, rules=genlocke.nuzlocke_rules,
naming_scheme=genlocke.naming_scheme,
) )
session.add(new_run) session.add(new_run)
await session.flush() await session.flush()

View File

@@ -8,6 +8,7 @@ from sqlalchemy.orm import joinedload, selectinload
from app.core.database import get_session from app.core.database import get_session
from app.models.boss_result import BossResult from app.models.boss_result import BossResult
from app.models.encounter import Encounter from app.models.encounter import Encounter
from app.models.evolution import Evolution
from app.models.game import Game from app.models.game import Game
from app.models.genlocke import GenlockeLeg from app.models.genlocke import GenlockeLeg
from app.models.genlocke_transfer import GenlockeTransfer from app.models.genlocke_transfer import GenlockeTransfer
@@ -19,7 +20,13 @@ from app.schemas.run import (
RunResponse, RunResponse,
RunUpdate, 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() router = APIRouter()
@@ -33,6 +40,7 @@ async def list_naming_categories():
async def get_name_suggestions( async def get_name_suggestions(
run_id: int, run_id: int,
count: int = 10, count: int = 10,
pokemon_id: int | None = None,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
run = await session.get(NuzlockeRun, run_id) run = await session.get(NuzlockeRun, run_id)
@@ -51,7 +59,102 @@ async def get_name_suggestions(
) )
used_names = {row[0] for row in result} 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) @router.post("", response_model=RunResponse, status_code=201)

View File

@@ -18,6 +18,7 @@ class Genlocke(Base):
) # active, completed, failed ) # active, completed, failed
genlocke_rules: Mapped[dict] = mapped_column(JSONB, default=dict) genlocke_rules: Mapped[dict] = mapped_column(JSONB, default=dict)
nuzlocke_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( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now() DateTime(timezone=True), server_default=func.now()
) )

View File

@@ -10,6 +10,7 @@ class GenlockeCreate(CamelModel):
game_ids: list[int] game_ids: list[int]
genlocke_rules: dict = {} genlocke_rules: dict = {}
nuzlocke_rules: dict = {} nuzlocke_rules: dict = {}
naming_scheme: str | None = None
class GenlockeUpdate(CamelModel): class GenlockeUpdate(CamelModel):
@@ -51,6 +52,7 @@ class GenlockeResponse(CamelModel):
status: str status: str
genlocke_rules: dict genlocke_rules: dict
nuzlocke_rules: dict nuzlocke_rules: dict
naming_scheme: str | None = None
created_at: datetime created_at: datetime
legs: list[GenlockeLegResponse] = [] legs: list[GenlockeLegResponse] = []
@@ -98,6 +100,7 @@ class GenlockeDetailResponse(CamelModel):
status: str status: str
genlocke_rules: dict genlocke_rules: dict
nuzlocke_rules: dict nuzlocke_rules: dict
naming_scheme: str | None = None
created_at: datetime created_at: datetime
legs: list[GenlockeLegDetailResponse] = [] legs: list[GenlockeLegDetailResponse] = []
stats: GenlockeStatsResponse stats: GenlockeStatsResponse

View File

@@ -1,5 +1,6 @@
import json import json
import random import random
import re
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
@@ -26,6 +27,42 @@ def get_words_for_category(category: str) -> list[str]:
return _load_dictionary().get(category, []) 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( def suggest_names(
category: str, category: str,
used_names: set[str], used_names: set[str],

View File

@@ -33,6 +33,10 @@ export function getNamingCategories(): Promise<string[]> {
return api.get('/runs/naming-categories') return api.get('/runs/naming-categories')
} }
export function getNameSuggestions(runId: number, count = 10): Promise<string[]> { export function getNameSuggestions(runId: number, count = 10, pokemonId?: number): Promise<string[]> {
return api.get(`/runs/${runId}/name-suggestions?count=${count}`) let url = `/runs/${runId}/name-suggestions?count=${count}`
if (pokemonId != null) {
url += `&pokemon_id=${pokemonId}`
}
return api.get(url)
} }

View File

@@ -18,6 +18,7 @@ interface EncounterModalProps {
gameId: number gameId: number
runId: number runId: number
namingScheme?: string | null namingScheme?: string | null
isGenlocke?: boolean
existing?: EncounterDetail existing?: EncounterDetail
dupedPokemonIds?: Set<number> dupedPokemonIds?: Set<number>
retiredPokemonIds?: Set<number> retiredPokemonIds?: Set<number>
@@ -97,6 +98,7 @@ export function EncounterModal({
gameId, gameId,
runId, runId,
namingScheme, namingScheme,
isGenlocke,
existing, existing,
dupedPokemonIds, dupedPokemonIds,
retiredPokemonIds, retiredPokemonIds,
@@ -126,8 +128,9 @@ export function EncounterModal({
const isEditing = !!existing const isEditing = !!existing
const showSuggestions = !!namingScheme && status === 'caught' && !isEditing const showSuggestions = !!namingScheme && status === 'caught' && !isEditing
const lineagePokemonId = isGenlocke && selectedPokemon ? selectedPokemon.pokemonId : null
const { data: suggestions, refetch: regenerate, isFetching: loadingSuggestions } = const { data: suggestions, refetch: regenerate, isFetching: loadingSuggestions } =
useNameSuggestions(showSuggestions ? runId : null) useNameSuggestions(showSuggestions ? runId : null, lineagePokemonId)
// Pre-select pokemon when editing // Pre-select pokemon when editing
useEffect(() => { useEffect(() => {

View File

@@ -60,10 +60,10 @@ export function useNamingCategories() {
}) })
} }
export function useNameSuggestions(runId: number | null) { export function useNameSuggestions(runId: number | null, pokemonId?: number | null) {
return useQuery({ return useQuery({
queryKey: ['name-suggestions', runId], queryKey: ['name-suggestions', runId, pokemonId ?? null],
queryFn: () => getNameSuggestions(runId!), queryFn: () => getNameSuggestions(runId!, 10, pokemonId ?? undefined),
enabled: runId !== null, enabled: runId !== null,
}) })
} }

View File

@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { RulesConfiguration, StepIndicator } from '../components' import { RulesConfiguration, StepIndicator } from '../components'
import { useRegions, useCreateGenlocke } from '../hooks/useGenlockes' import { useRegions, useCreateGenlocke } from '../hooks/useGenlockes'
import { useNamingCategories } from '../hooks/useRuns'
import type { Game, GenlockeRules, Region } from '../types' import type { Game, GenlockeRules, Region } from '../types'
import { DEFAULT_RULES } from '../types' import { DEFAULT_RULES } from '../types'
import type { NuzlockeRules } from '../types/rules' import type { NuzlockeRules } from '../types/rules'
@@ -46,6 +47,8 @@ export function NewGenlocke() {
const [preset, setPreset] = useState<PresetType>(null) const [preset, setPreset] = useState<PresetType>(null)
const [nuzlockeRules, setNuzlockeRules] = useState<NuzlockeRules>(DEFAULT_RULES) const [nuzlockeRules, setNuzlockeRules] = useState<NuzlockeRules>(DEFAULT_RULES)
const [genlockeRules, setGenlockeRules] = useState<GenlockeRules>({ retireHoF: false }) const [genlockeRules, setGenlockeRules] = useState<GenlockeRules>({ retireHoF: false })
const [namingScheme, setNamingScheme] = useState<string | null>(null)
const { data: namingCategories } = useNamingCategories()
const handlePresetSelect = (type: PresetType) => { const handlePresetSelect = (type: PresetType) => {
setPreset(type) setPreset(type)
@@ -91,6 +94,7 @@ export function NewGenlocke() {
gameIds: legs.map((l) => l.game.id), gameIds: legs.map((l) => l.game.id),
genlockeRules, genlockeRules,
nuzlockeRules, nuzlockeRules,
namingScheme,
}, },
{ {
onSuccess: (data) => { onSuccess: (data) => {
@@ -323,6 +327,32 @@ export function NewGenlocke() {
</div> </div>
</div> </div>
{/* Naming scheme */}
<div className="mt-6 bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
Naming Scheme
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Get nickname suggestions from a themed word list when catching Pokemon. Applied to all legs.
</p>
</div>
<div className="px-4 py-4">
<select
value={namingScheme ?? ''}
onChange={(e) => setNamingScheme(e.target.value || null)}
className="w-full max-w-xs px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">None (manual nicknames)</option>
{namingCategories?.map((cat) => (
<option key={cat} value={cat}>
{cat.charAt(0).toUpperCase() + cat.slice(1)}
</option>
))}
</select>
</div>
</div>
<div className="mt-6 flex justify-between"> <div className="mt-6 flex justify-between">
<button <button
type="button" type="button"
@@ -398,6 +428,14 @@ export function NewGenlocke() {
{genlockeRules.retireHoF ? 'Retire' : 'Keep'} {genlockeRules.retireHoF ? 'Retire' : 'Keep'}
</dd> </dd>
</div> </div>
<div className="flex justify-between">
<dt className="text-gray-600 dark:text-gray-400">Naming Scheme</dt>
<dd className="text-gray-900 dark:text-gray-100 font-medium">
{namingScheme
? namingScheme.charAt(0).toUpperCase() + namingScheme.slice(1)
: 'None'}
</dd>
</div>
</dl> </dl>
</div> </div>
</div> </div>

View File

@@ -1437,6 +1437,7 @@ export function RunEncounters() {
gameId={run!.gameId} gameId={run!.gameId}
runId={runIdNum} runId={runIdNum}
namingScheme={run!.namingScheme} namingScheme={run!.namingScheme}
isGenlocke={!!run!.genlocke}
existing={editingEncounter ?? undefined} existing={editingEncounter ?? undefined}
dupedPokemonIds={dupedPokemonIds} dupedPokemonIds={dupedPokemonIds}
retiredPokemonIds={retiredPokemonIds} retiredPokemonIds={retiredPokemonIds}

View File

@@ -230,6 +230,7 @@ export interface Genlocke {
status: 'active' | 'completed' | 'failed' status: 'active' | 'completed' | 'failed'
genlockeRules: GenlockeRules genlockeRules: GenlockeRules
nuzlockeRules: NuzlockeRules nuzlockeRules: NuzlockeRules
namingScheme: string | null
createdAt: string createdAt: string
legs: GenlockeLeg[] legs: GenlockeLeg[]
} }
@@ -239,6 +240,7 @@ export interface CreateGenlockeInput {
gameIds: number[] gameIds: number[]
genlockeRules: GenlockeRules genlockeRules: GenlockeRules
nuzlockeRules: NuzlockeRules nuzlockeRules: NuzlockeRules
namingScheme?: string | null
} }
// Genlocke list / detail types // Genlocke list / detail types
@@ -283,6 +285,7 @@ export interface GenlockeDetail {
status: 'active' | 'completed' | 'failed' status: 'active' | 'completed' | 'failed'
genlockeRules: GenlockeRules genlockeRules: GenlockeRules
nuzlockeRules: NuzlockeRules nuzlockeRules: NuzlockeRules
namingScheme: string | null
createdAt: string createdAt: string
legs: GenlockeLegDetail[] legs: GenlockeLegDetail[]
stats: GenlockeStats stats: GenlockeStats