Add naming scheme support for genlockes with lineage-aware suggestions
Some checks failed
CI / backend-lint (pull_request) Failing after 8s
CI / frontend-lint (pull_request) Successful in 31s

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 09:52:13 +01:00
parent c01c504519
commit 57023fc52a
13 changed files with 278 additions and 11 deletions

View File

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

View File

@@ -19,7 +19,10 @@ from app.schemas.run import (
RunResponse,
RunUpdate,
)
from app.services.naming import get_naming_categories, suggest_names
from app.models.evolution import Evolution
from app.models.genlocke import Genlocke
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 +36,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 +55,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)