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 <noreply@anthropic.com> Reviewed-on: TheFurya/nuzlocke-tracker#20 Co-authored-by: Julian Tabel <juliantabel.jt@gmail.com> Co-committed-by: Julian Tabel <juliantabel.jt@gmail.com>
This commit was merged in pull request #20.
This commit is contained in:
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user