Merge branch 'develop' into feature/add-boss-data
Some checks failed
CI / backend-lint (pull_request) Failing after 8s
CI / frontend-lint (pull_request) Successful in 31s

This commit is contained in:
2026-02-11 21:52:41 +01:00
21 changed files with 833 additions and 49 deletions

View File

@@ -1,10 +1,11 @@
--- ---
# nuzlocke-tracker-bi4e # nuzlocke-tracker-bi4e
title: Integrate name suggestions into encounter registration UI title: Integrate name suggestions into encounter registration UI
status: todo status: completed
type: task type: task
priority: normal
created_at: 2026-02-11T15:56:44Z created_at: 2026-02-11T15:56:44Z
updated_at: 2026-02-11T15:56:44Z updated_at: 2026-02-11T20:48:02Z
parent: nuzlocke-tracker-igl3 parent: nuzlocke-tracker-igl3
--- ---
@@ -12,17 +13,23 @@ Show name suggestions in the encounter registration flow so users can pick a nic
## Requirements ## Requirements
- When a user clicks an encounter slot and registers a new Pokemon, display 5-10 name suggestions below/near the nickname input - When a user registers a new Pokemon encounter, display 5-10 name suggestions below/near the nickname input
- Each suggestion is a clickable chip/button that fills in the nickname field - Each suggestion is a clickable chip/button that fills in the nickname field
- Include a "regenerate" button to get a fresh batch of suggestions - Include a "regenerate" button to get a fresh batch of suggestions
- Only show suggestions if the run has a naming scheme selected - Only show suggestions if the run has a naming scheme selected
- The nickname input should still be editable for manual entry - The nickname input should still be editable for manual entry
## Implementation Notes
- **Data fetching**: Call `GET /api/v1/runs/{run_id}/name-suggestions?count=10` to get suggestions from the backend.
- **Regeneration**: Each call to the endpoint returns a fresh random batch (backend handles exclusion of used names).
- **No dictionary data in frontend**: All suggestion logic lives in the backend.
## Checklist ## Checklist
- [ ] Add a name suggestions component (chips/buttons with regenerate) - [x] Add a name suggestions component (chips/buttons with regenerate)
- [ ] Integrate the component into the encounter registration modal/form - [x] Integrate the component into the encounter registration modal/form
- [ ] Wire up the name suggestion engine to the component - [x] Wire up the backend API endpoint to the component via React Query
- [ ] Ensure clicking a suggestion populates the nickname field - [x] Ensure clicking a suggestion populates the nickname field
- [ ] Ensure regenerate fetches a new batch without repeating prior suggestions - [x] Ensure regenerate fetches a new batch from the API
- [ ] Hide suggestions gracefully if no naming scheme is set on the run - [x] Hide suggestions gracefully if no naming scheme is set on the run

View File

@@ -1,28 +1,36 @@
--- ---
# nuzlocke-tracker-c6ly # nuzlocke-tracker-c6ly
title: Build name suggestion engine title: Build name suggestion engine
status: todo status: completed
type: task type: task
priority: normal priority: normal
created_at: 2026-02-11T15:56:44Z created_at: 2026-02-11T15:56:44Z
updated_at: 2026-02-11T15:56:48Z updated_at: 2026-02-11T20:44:27Z
parent: nuzlocke-tracker-igl3 parent: nuzlocke-tracker-igl3
blocking: blocking:
- nuzlocke-tracker-bi4e - nuzlocke-tracker-bi4e
--- ---
Build the core logic that picks random name suggestions from the dictionary based on a selected naming scheme (category). Build the backend service and API endpoint that picks random name suggestions from the dictionary based on a selected naming scheme.
## Requirements ## Requirements
- Given a category and a list of already-used names in the run, return 5-10 unique suggestions - Given a category and a run ID, return 5-10 unique suggestions
- Suggestions must not include names already assigned to other Pokemon in the same run - The engine queries the run's existing encounter nicknames and excludes them from suggestions
- Support regeneration (return a fresh batch, avoiding previously shown suggestions where possible) - Support regeneration (return a fresh batch, avoiding previously shown suggestions where possible)
- Handle edge case where category is nearly exhausted gracefully (return fewer suggestions) - Handle edge case where category is nearly exhausted gracefully (return fewer suggestions)
## Implementation Notes
- **Backend service**: A Python module that loads the dictionary JSON, filters by category, excludes used names, and picks random suggestions.
- **API endpoint**: `GET /api/v1/runs/{run_id}/name-suggestions?count=10` — reads the run's `naming_scheme`, fetches encounter nicknames, returns suggestions.
- **No new DB tables needed**: Used names come from `Encounter.nickname` on the run's encounters.
- **Caching**: Load the dictionary once and cache in memory (it's static data).
## Checklist ## Checklist
- [ ] Create a service/utility module for name suggestion logic - [x] Create a service module for name suggestion logic (e.g. `services/name_suggestions.py`)
- [ ] Implement random selection from a category with exclusion of used names - [x] Implement dictionary loading with in-memory caching
- [ ] Implement regeneration that avoids repeating previous suggestions - [x] Implement random selection from a category with exclusion of used names
- [ ] Add unit tests for the suggestion logic - [x] Add API endpoint for fetching suggestions
- [x] Add unit tests for the suggestion logic

View File

@@ -1,26 +1,34 @@
--- ---
# nuzlocke-tracker-igl3 # nuzlocke-tracker-igl3
title: Name Generation title: Name Generation
status: todo status: completed
type: epic type: epic
priority: normal priority: normal
created_at: 2026-02-05T13:45:15Z created_at: 2026-02-05T13:45:15Z
updated_at: 2026-02-11T15:57:27Z updated_at: 2026-02-11T20:48:02Z
--- ---
Implement a dictionary-based nickname generation system for Nuzlocke runs. Instead of using an LLM API to generate names on the fly, provide a static dictionary of words categorised by theme. A word can belong to multiple categories, making it usable across different naming schemes. Implement a dictionary-based nickname generation system for Nuzlocke runs. Instead of using an LLM API to generate names on the fly, provide a static dictionary of words categorised by theme. A word can belong to multiple categories, making it usable across different naming schemes.
## Architecture Decisions
- **Dictionary storage**: Static JSON file in `backend/src/app/seeds/data/`, alongside other seed data. Not exposed to frontend directly.
- **Dictionary format**: Category-keyed structure (`{ "mythology": ["Apollo", ...], "space": ["Nova", ...] }`) for fast lookup by naming scheme. Words may appear in multiple categories.
- **Suggestion logic**: Backend service with API endpoint. Frontend calls the backend to get suggestions.
- **Used-name tracking**: No new storage needed. The existing `Encounter.nickname` field already tracks assigned names. The suggestion engine queries encounter nicknames for the current run and excludes them.
- **Naming scheme per run**: Dedicated nullable `naming_scheme` column on `NuzlockeRun` (not in the `rules` JSONB).
## Approach ## Approach
- **Static dictionary**: A local data file (JSON) containing words tagged with categories (e.g. mythology, food, space, nature, warriors, music, etc.) - **Static dictionary**: A local data file (JSON) containing words organised by category (e.g. mythology, food, space, nature, warriors, music, etc.)
- **~150-200 words per category**: A typical Nuzlocke has ~100 encounters, so this provides ample variety without repetition. - **~150-200 words per category**: A typical Nuzlocke has ~100 encounters, so this provides ample variety without repetition.
- **Name suggestion UX**: When registering a new encounter, the user is shown 5-10 suggested names from their chosen naming scheme. They can click one to select it, or regenerate for a fresh batch. - **Name suggestion UX**: When registering a new encounter, the user is shown 5-10 suggested names from their chosen naming scheme. They can click one to select it, or regenerate for a fresh batch.
- **Naming scheme selection**: Users pick a naming scheme (category) per run, either at run creation or in run settings. - **Naming scheme selection**: Users pick a naming scheme (category) per run, either at run creation or in run settings.
## Success Criteria ## Success Criteria
- [ ] Word dictionary data file exists with multiple categories, each containing 150-200 words - [x] Word dictionary data file exists with multiple categories, each containing 150-200 words
- [ ] Name suggestion engine picks random names from the selected category, avoiding duplicates already used in the run - [x] Name suggestion engine picks random names from the selected category, avoiding duplicates already used in the run
- [ ] Encounter registration UI shows 5-10 clickable name suggestions - [x] Encounter registration UI shows 5-10 clickable name suggestions
- [ ] User can regenerate suggestions if none fit - [x] User can regenerate suggestions if none fit
- [ ] User can select a naming scheme per run - [x] User can select a naming scheme per run

View File

@@ -0,0 +1,11 @@
---
# nuzlocke-tracker-l272
title: Add boss data to seed files for all games
status: completed
type: feature
priority: normal
created_at: 2026-02-11T20:23:20Z
updated_at: 2026-02-11T20:31:27Z
---
Add gym leaders, Elite Four, champions and equivalents for all remaining games. Add kahuna and totem boss types for Alola games. Create 11 new seed files, complete 2 existing ones, and update frontend types/UI for new boss types.

View File

@@ -1,11 +1,11 @@
--- ---
# nuzlocke-tracker-m86o # nuzlocke-tracker-m86o
title: Add naming scheme selection to run configuration title: Add naming scheme selection to run configuration
status: todo status: completed
type: task type: task
priority: normal priority: normal
created_at: 2026-02-11T15:56:44Z created_at: 2026-02-11T15:56:44Z
updated_at: 2026-02-11T15:56:48Z updated_at: 2026-02-11T20:37:00Z
parent: nuzlocke-tracker-igl3 parent: nuzlocke-tracker-igl3
blocking: blocking:
- nuzlocke-tracker-bi4e - nuzlocke-tracker-bi4e
@@ -15,14 +15,24 @@ Allow users to select a naming scheme (category) for their Nuzlocke run.
## Requirements ## Requirements
- Add a `namingScheme` field to the run model/settings (optional, nullable — user may not want auto-naming) - Add a `naming_scheme` column to the NuzlockeRun model (nullable string — user may not want auto-naming)
- Provide a dropdown/selector in run creation and run settings where the user can pick a category - Provide a dropdown/selector in run creation and run settings where the user can pick a category
- List available categories dynamically from the dictionary data - List available categories dynamically by querying a backend endpoint that reads the dictionary file
- Allow changing the naming scheme mid-run - Allow changing the naming scheme mid-run
## Implementation Notes
- **Storage**: Dedicated nullable `naming_scheme` column on `NuzlockeRun` (not in the `rules` JSONB). This is a first-class run setting.
- **Migration**: Add an Alembic migration for the new column.
- **API**: Add/update the run creation and update endpoints to accept `namingScheme`.
- **Categories endpoint**: Add a GET endpoint that returns the list of available category names from the dictionary file, so the frontend can populate the dropdown.
## Checklist ## Checklist
- [ ] Add `namingScheme` field to the NuzlockeRun type/model - [x] Add `naming_scheme` nullable column to NuzlockeRun model
- [ ] Add naming scheme selector to run creation UI - [x] Create Alembic migration for the new column
- [ ] Add naming scheme selector to run settings UI - [x] Update run Pydantic schemas to include `namingScheme`
- [ ] Persist the selected naming scheme with the run data - [x] Update run creation and update endpoints to persist the naming scheme
- [x] Add GET endpoint to list available naming categories from the dictionary
- [x] Add naming scheme selector to run creation UI
- [x] Add naming scheme selector to run settings UI

View File

@@ -1,11 +1,11 @@
--- ---
# nuzlocke-tracker-spx3 # nuzlocke-tracker-spx3
title: Evaluate separate seed/init container after PokeDB import title: Evaluate separate seed/init container after PokeDB import
status: draft status: scrapped
type: task type: task
priority: low priority: low
created_at: 2026-02-10T14:30:57Z created_at: 2026-02-10T14:30:57Z
updated_at: 2026-02-10T14:30:57Z updated_at: 2026-02-11T20:15:43Z
--- ---
After the PokeDB.org data import (beans-bs05) is complete, evaluate whether the seed data has grown enough to justify splitting seeding into a separate init container. After the PokeDB.org data import (beans-bs05) is complete, evaluate whether the seed data has grown enough to justify splitting seeding into a separate init container.

View File

@@ -1,29 +1,38 @@
--- ---
# nuzlocke-tracker-ueyy # nuzlocke-tracker-ueyy
title: Create name dictionary data file title: Create name dictionary data file
status: todo status: completed
type: task type: task
priority: normal priority: normal
created_at: 2026-02-11T15:56:26Z created_at: 2026-02-11T15:56:26Z
updated_at: 2026-02-11T15:56:48Z updated_at: 2026-02-11T20:42:16Z
parent: nuzlocke-tracker-igl3 parent: nuzlocke-tracker-igl3
blocking: blocking:
- nuzlocke-tracker-c6ly - nuzlocke-tracker-c6ly
--- ---
Create a JSON data file containing themed words for nickname generation. Create a JSON data file containing themed words for nickname generation, stored in the backend alongside other seed data.
## Requirements ## Requirements
- Each word entry has: `word` (string) and `categories` (string array) - Store at `backend/src/app/seeds/data/name_dictionary.json`
- Category-keyed structure for fast lookup:
```json
{
"mythology": ["Apollo", "Athena", "Loki", ...],
"space": ["Apollo", "Nova", "Nebula", ...],
"food": ["Basil", "Sage", "Pepper", ...]
}
```
- Words may appear in multiple categories
- Categories should include themes like: mythology, food, space, nature, warriors, music, literature, gems, ocean, weather, etc. - Categories should include themes like: mythology, food, space, nature, warriors, music, literature, gems, ocean, weather, etc.
- Target 150-200 words per category - Target 150-200 words per category
- Words can belong to multiple categories
- Words should be short, punchy, and suitable as Pokemon nicknames (ideally 1-2 words, max ~12 characters) - Words should be short, punchy, and suitable as Pokemon nicknames (ideally 1-2 words, max ~12 characters)
- This file is NOT seeded into the database — it is read directly by the backend service at runtime
## Checklist ## Checklist
- [ ] Define the data schema / TypeScript type for dictionary entries - [x] Create `backend/src/app/seeds/data/name_dictionary.json` with the category-keyed structure
- [ ] Create the JSON data file with initial categories - [x] Populate each category with 150-200 words
- [ ] Populate each category with 150-200 words - [x] Validate no duplicates exist within a single category
- [ ] Validate no duplicates exist within the file - [x] Add a utility function to load the dictionary from disk (with caching)

View File

@@ -0,0 +1,29 @@
"""add naming_scheme to nuzlocke_runs
Revision ID: e5f6a7b8c9d1
Revises: d4e5f6a7b9c0
Create Date: 2026-02-11 12:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "e5f6a7b8c9d1"
down_revision: str | Sequence[str] | None = "d4e5f6a7b9c0"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"nuzlocke_runs",
sa.Column("naming_scheme", sa.String(50), nullable=True),
)
def downgrade() -> None:
op.drop_column("nuzlocke_runs", "naming_scheme")

View File

@@ -0,0 +1,28 @@
"""merge naming_scheme and genlocke_transfers
Revision ID: e5f70a1ca323
Revises: e5f6a7b8c9d1, e5f6a7b9c0d1
Create Date: 2026-02-11 21:49:29.942841
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e5f70a1ca323'
down_revision: Union[str, Sequence[str], None] = ('e5f6a7b8c9d1', 'e5f6a7b9c0d1')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
pass
def downgrade() -> None:
"""Downgrade schema."""
pass

View File

@@ -19,10 +19,41 @@ from app.schemas.run import (
RunResponse, RunResponse,
RunUpdate, RunUpdate,
) )
from app.services.naming import get_naming_categories, suggest_names
router = APIRouter() router = APIRouter()
@router.get("/naming-categories", response_model=list[str])
async def list_naming_categories():
return get_naming_categories()
@router.get("/{run_id}/name-suggestions", response_model=list[str])
async def get_name_suggestions(
run_id: int,
count: int = 10,
session: AsyncSession = Depends(get_session),
):
run = await session.get(NuzlockeRun, run_id)
if run is None:
raise HTTPException(status_code=404, detail="Run not found")
if not run.naming_scheme:
return []
# Collect nicknames already used in this run
result = await session.execute(
select(Encounter.nickname).where(
Encounter.run_id == run_id,
Encounter.nickname.isnot(None),
)
)
used_names = {row[0] for row in result}
return suggest_names(run.naming_scheme, used_names, count)
@router.post("", response_model=RunResponse, status_code=201) @router.post("", response_model=RunResponse, status_code=201)
async def create_run(data: RunCreate, session: AsyncSession = Depends(get_session)): async def create_run(data: RunCreate, session: AsyncSession = Depends(get_session)):
# Validate game exists # Validate game exists
@@ -35,6 +66,7 @@ async def create_run(data: RunCreate, session: AsyncSession = Depends(get_sessio
name=data.name, name=data.name,
status="active", status="active",
rules=data.rules, rules=data.rules,
naming_scheme=data.naming_scheme,
) )
session.add(run) session.add(run)
await session.commit() await session.commit()

View File

@@ -22,6 +22,7 @@ class NuzlockeRun(Base):
) )
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
hof_encounter_ids: Mapped[list[int] | None] = mapped_column(JSONB, default=None) hof_encounter_ids: Mapped[list[int] | None] = mapped_column(JSONB, default=None)
naming_scheme: Mapped[str | None] = mapped_column(String(50), nullable=True)
game: Mapped["Game"] = relationship(back_populates="runs") game: Mapped["Game"] = relationship(back_populates="runs")
encounters: Mapped[list["Encounter"]] = relationship(back_populates="run") encounters: Mapped[list["Encounter"]] = relationship(back_populates="run")

View File

@@ -9,6 +9,7 @@ class RunCreate(CamelModel):
game_id: int game_id: int
name: str name: str
rules: dict = {} rules: dict = {}
naming_scheme: str | None = None
class RunUpdate(CamelModel): class RunUpdate(CamelModel):
@@ -16,6 +17,7 @@ class RunUpdate(CamelModel):
status: str | None = None status: str | None = None
rules: dict | None = None rules: dict | None = None
hof_encounter_ids: list[int] | None = None hof_encounter_ids: list[int] | None = None
naming_scheme: str | None = None
class RunResponse(CamelModel): class RunResponse(CamelModel):
@@ -25,6 +27,7 @@ class RunResponse(CamelModel):
status: str status: str
rules: dict rules: dict
hof_encounter_ids: list[int] | None = None hof_encounter_ids: list[int] | None = None
naming_scheme: str | None = None
started_at: datetime started_at: datetime
completed_at: datetime | None completed_at: datetime | None

View File

@@ -0,0 +1,372 @@
{
"mythology": [
"Zeus", "Hera", "Apollo", "Athena", "Ares",
"Hermes", "Artemis", "Hades", "Poseidon", "Demeter",
"Hephaestus", "Aphrodite", "Dionysus", "Persephone", "Eros",
"Nike", "Helios", "Selene", "Nyx", "Chronos",
"Atlas", "Titan", "Hydra", "Cerberus", "Minotaur",
"Medusa", "Pegasus", "Phoenix", "Chimera", "Cyclops",
"Icarus", "Orpheus", "Perseus", "Achilles", "Odysseus",
"Ajax", "Hector", "Paris", "Helen", "Circe",
"Calypso", "Siren", "Muse", "Oracle", "Sphinx",
"Styx", "Elysium", "Olympus", "Tartarus", "Nemesis",
"Odin", "Thor", "Loki", "Freya", "Tyr",
"Baldur", "Fenrir", "Jormungandr", "Ragnarok", "Valkyrie",
"Mjolnir", "Yggdrasil", "Bifrost", "Valhalla", "Asgard",
"Midgard", "Norns", "Huginn", "Muninn", "Sleipnir",
"Ra", "Osiris", "Isis", "Anubis", "Thoth",
"Bastet", "Sekhmet", "Sobek", "Horus", "Seth",
"Nephthys", "Ammit", "Scarab", "Apophis", "Khepri",
"Amaterasu", "Susanoo", "Tsukuyomi", "Izanagi", "Izanami",
"Raijin", "Fujin", "Inari", "Kitsune", "Tengu",
"Oni", "Tanuki", "Yokai", "Kami", "Byakko",
"Vishnu", "Shiva", "Brahma", "Kali", "Ganesh",
"Lakshmi", "Hanuman", "Indra", "Agni", "Surya",
"Garuda", "Naga", "Deva", "Asura", "Dharma",
"Karma", "Mantra", "Lotus", "Chakra", "Maya",
"Brigid", "Morrigan", "Dagda", "Lugh", "Danu",
"Cernunnos", "Oberon", "Titania", "Merlin", "Avalon",
"Excalibur", "Druid", "Banshee", "Sidhe", "Fomorian",
"Leviathan", "Golem", "Djinn", "Ifrit", "Typhon",
"Griffin", "Kraken", "Wyrm", "Drake", "Wyvern",
"Basilisk", "Manticore", "Harpy", "Gorgon", "Triton",
"Prometheus", "Pandora", "Daphne", "Echo", "Narcissus",
"Midas", "Theseus", "Orion", "Castor", "Pollux",
"Zephyr", "Boreas", "Aether", "Chaos", "Gaia",
"Uranus", "Rhea", "Hyperion", "Themis", "Mnemosyne",
"Clio", "Fury", "Fate", "Moira", "Clotho"
],
"food": [
"Basil", "Sage", "Thyme", "Rosemary", "Saffron",
"Ginger", "Pepper", "Cinnamon", "Nutmeg", "Clove",
"Paprika", "Wasabi", "Cumin", "Fennel", "Dill",
"Anise", "Cardamom", "Turmeric", "Coriander", "Oregano",
"Tarragon", "Chive", "Parsley", "Mint", "Vanilla",
"Mango", "Kiwi", "Peach", "Cherry", "Plum",
"Fig", "Lemon", "Lime", "Melon", "Berry",
"Olive", "Cocoa", "Mocha", "Latte", "Chai",
"Matcha", "Espresso", "Cider", "Mead", "Porter",
"Stout", "Sake", "Roux", "Bisque", "Broth",
"Frappe", "Sorbet", "Gelato", "Truffle", "Praline",
"Nougat", "Toffee", "Caramel", "Fudge", "Brioche",
"Croissant", "Baguette", "Pretzel", "Dumpling", "Gyoza",
"Mochi", "Tempura", "Ramen", "Udon", "Soba",
"Pesto", "Salsa", "Kimchi", "Naan", "Tofu",
"Panko", "Miso", "Tahini", "Hummus", "Falafel",
"Burrito", "Taco", "Nacho", "Churro", "Crepe",
"Waffle", "Scone", "Crumpet", "Biscuit", "Cobbler",
"Crisp", "Tart", "Torte", "Mousse", "Souffle",
"Quiche", "Gratin", "Fondue", "Risotto", "Gnocchi",
"Ravioli", "Penne", "Orzo", "Pilaf", "Paella",
"Tagine", "Curry", "Satay", "Bulgogi", "Teriyaki",
"Ponzu", "Sriracha", "Harissa", "Chutney", "Relish",
"Pickle", "Caper", "Anchovy", "Prawn", "Scallop",
"Brisket", "Bison", "Quinoa", "Barley", "Millet",
"Almond", "Pecan", "Walnut", "Cashew", "Pistachio",
"Hazel", "Acai", "Guava", "Lychee", "Papaya",
"Tamarind", "Kumquat", "Clementine", "Nectar", "Honey",
"Maple", "Agave", "Molasses", "Butter", "Cream",
"Brie", "Gouda", "Cheddar", "Ricotta", "Paneer",
"Toasted", "Smoked", "Braised", "Seared", "Glazed",
"Roasted", "Grilled", "Poached", "Zest", "Tang",
"Crunch", "Morsel", "Nibble", "Drizzle", "Dash",
"Pinch", "Sprig", "Garnish", "Feast", "Brunch",
"Savory", "Umami", "Tangy", "Citrus", "Jambon"
],
"space": [
"Nova", "Nebula", "Quasar", "Pulsar", "Cosmos",
"Comet", "Meteor", "Astral", "Stellar", "Solar",
"Lunar", "Orbit", "Eclipse", "Galaxy", "Vortex",
"Zenith", "Nadir", "Apogee", "Perigee", "Epoch",
"Apollo", "Gemini", "Mercury", "Venus", "Mars",
"Jupiter", "Saturn", "Neptune", "Uranus", "Pluto",
"Titan", "Europa", "Io", "Callisto", "Ganymede",
"Triton", "Charon", "Phobos", "Deimos", "Ceres",
"Juno", "Vesta", "Pallas", "Eris", "Sedna",
"Sirius", "Rigel", "Vega", "Altair", "Deneb",
"Polaris", "Canopus", "Betelgeuse", "Antares", "Aldebaran",
"Spica", "Arcturus", "Capella", "Procyon", "Achernar",
"Castor", "Pollux", "Regulus", "Fomalhaut", "Mira",
"Bellatrix", "Mintaka", "Alnilam", "Alnitak", "Saiph",
"Orion", "Lyra", "Draco", "Hydra", "Corvus",
"Aquila", "Cygnus", "Phoenix", "Andromeda", "Cassiopeia",
"Perseus", "Centauri", "Pegasus", "Scorpius", "Leo",
"Aries", "Virgo", "Libra", "Pisces", "Taurus",
"Photon", "Neutron", "Proton", "Plasma", "Flux",
"Prism", "Spectrum", "Horizon", "Corona", "Halo",
"Aurora", "Solstice", "Equinox", "Parallax", "Parsec",
"Lightyear", "Warp", "Void", "Rift", "Abyss",
"Singularity", "Binary", "Cluster", "Remnant", "Dwarf",
"Giant", "Supernova", "Stardust", "Starfall", "Skyfire",
"Sunspot", "Flare", "Radiance", "Luminous", "Beacon",
"Voyager", "Pioneer", "Sputnik", "Hubble", "Kepler",
"Galileo", "Cassini", "Rosetta", "Artemis", "Chandra",
"Solaris", "Nebular", "Asteria", "Celestia", "Astra",
"Solis", "Lumen", "Ignis", "Ember", "Blaze",
"Spark", "Glimmer", "Shimmer", "Twilight", "Dusk",
"Dawn", "Crescent", "Waning", "Waxing", "Umbra",
"Penumbra", "Perihelion", "Aphelion", "Azimuth", "Meridian",
"Helix", "Spiral", "Ring", "Arc", "Bolt",
"Surge", "Pulse", "Wave", "Drift", "Phase",
"Xenon", "Neon", "Argon", "Helium", "Lithium"
],
"nature": [
"Willow", "Cedar", "Birch", "Aspen", "Maple",
"Rowan", "Alder", "Elm", "Oak", "Pine",
"Spruce", "Juniper", "Laurel", "Ivy", "Fern",
"Moss", "Lichen", "Thistle", "Clover", "Briar",
"Thorn", "Reed", "Sage", "Basil", "Lotus",
"Orchid", "Dahlia", "Iris", "Lily", "Violet",
"Jasmine", "Heather", "Daisy", "Poppy", "Aster",
"Azalea", "Magnolia", "Wisteria", "Camellia", "Marigold",
"Hawk", "Falcon", "Eagle", "Osprey", "Heron",
"Crane", "Wren", "Finch", "Lark", "Robin",
"Sparrow", "Raven", "Crow", "Jay", "Dove",
"Swift", "Owl", "Kestrel", "Harrier", "Condor",
"Wolf", "Fox", "Bear", "Lynx", "Puma",
"Jaguar", "Panther", "Stag", "Elk", "Moose",
"Bison", "Badger", "Otter", "Mink", "Ermine",
"Viper", "Cobra", "Adder", "Gecko", "Newt",
"Coral", "Pearl", "Amber", "Flint", "Slate",
"Granite", "Basalt", "Quartz", "Mica", "Clay",
"Dune", "Mesa", "Butte", "Gorge", "Canyon",
"Ridge", "Summit", "Crest", "Peak", "Cliff",
"Bluff", "Dale", "Glen", "Vale", "Hollow",
"Meadow", "Prairie", "Tundra", "Taiga", "Savanna",
"Grove", "Copse", "Thicket", "Canopy", "Glade",
"Brook", "Creek", "Rivulet", "Spring", "Rapids",
"Cascade", "Torrent", "Eddy", "Delta", "Marsh",
"Bog", "Fen", "Mire", "Lagoon", "Oasis",
"Frost", "Dew", "Mist", "Haze", "Fog",
"Breeze", "Gust", "Gale", "Squall", "Tempest",
"Aurora", "Solstice", "Equinox", "Eclipse", "Dawn",
"Dusk", "Twilight", "Ember", "Spark", "Flame",
"Petal", "Bloom", "Blossom", "Sprout", "Seedling",
"Sapling", "Root", "Bark", "Sap", "Nectar",
"Pollen", "Spore", "Frond", "Tendril", "Vine",
"Bramble", "Nettle", "Sorrel", "Yarrow", "Tansy",
"Mushroom", "Fungi", "Truffle", "Morel", "Chanterelle"
],
"warriors": [
"Spartan", "Samurai", "Viking", "Shogun", "Ronin",
"Gladiator", "Centurion", "Legionary", "Tribune", "Praetor",
"Knight", "Paladin", "Templar", "Crusader", "Sentinel",
"Warden", "Marshal", "Captain", "General", "Admiral",
"Vanguard", "Raider", "Berserker", "Valkyrie", "Einherjar",
"Ninja", "Shinobi", "Kenshi", "Bushido", "Daimyo",
"Mongol", "Khan", "Sultan", "Shah", "Pharaoh",
"Legatus", "Consul", "Imperator", "Caesar", "Augustus",
"Hussar", "Cossack", "Janissary", "Mamluk", "Saracen",
"Apache", "Comanche", "Mohawk", "Zulu", "Aztec",
"Mayan", "Incan", "Sparrow", "Hawk", "Falcon",
"Talon", "Fang", "Claw", "Tusk", "Horn",
"Blade", "Saber", "Rapier", "Cutlass", "Katana",
"Claymore", "Scimitar", "Halberd", "Glaive", "Pike",
"Lance", "Javelin", "Spear", "Trident", "Mace",
"Flail", "Hammer", "Maul", "Axe", "Tomahawk",
"Dagger", "Stiletto", "Dirk", "Kunai", "Shuriken",
"Bow", "Longbow", "Crossbow", "Bolt", "Arrow",
"Quiver", "Shield", "Buckler", "Aegis", "Bulwark",
"Rampart", "Bastion", "Citadel", "Fortress", "Garrison",
"Helm", "Visor", "Gauntlet", "Bracer", "Greave",
"Cuirass", "Brigand", "Chainmail", "Plate", "Scale",
"Banner", "Standard", "Crest", "Sigil", "Herald",
"Siege", "Assault", "Charge", "Rally", "Flanker",
"Scout", "Ranger", "Sniper", "Archer", "Lancer",
"Dragoon", "Cavalry", "Infantry", "Legion", "Phalanx",
"Cohort", "Regiment", "Battalion", "Brigade", "Platoon",
"Striker", "Brawler", "Duelist", "Champion", "Conqueror",
"Warlord", "Overlord", "Chieftain", "Thane", "Jarl",
"Reaver", "Marauder", "Pillager", "Ravager", "Slayer",
"Titan", "Colossus", "Juggernaut", "Goliath", "Ajax",
"Hector", "Achilles", "Leonidas", "Hannibal", "Attila",
"Genghis", "Alexander", "Boudicca", "Shaka", "Saladin",
"Agincourt", "Thermopylae", "Troy", "Carthage", "Masada",
"Valor", "Honor", "Glory", "Fury", "Wrath"
],
"music": [
"Tempo", "Rhythm", "Melody", "Harmony", "Chord",
"Riff", "Solo", "Verse", "Chorus", "Bridge",
"Cadence", "Crescendo", "Forte", "Piano", "Allegro",
"Adagio", "Vivace", "Presto", "Staccato", "Legato",
"Vibrato", "Tremolo", "Glissando", "Arpeggio", "Pizzicato",
"Fermata", "Sforzando", "Rubato", "Ostinato", "Coda",
"Treble", "Bass", "Alto", "Tenor", "Soprano",
"Baritone", "Falsetto", "Contralto", "Aria", "Duet",
"Sonata", "Fugue", "Prelude", "Nocturne", "Etude",
"Opus", "Requiem", "Ballad", "Anthem", "Hymn",
"Lyric", "Serenade", "Caprice", "Fantasia", "Overture",
"Minuet", "Waltz", "Bolero", "Tango", "Mambo",
"Samba", "Rumba", "Salsa", "Swing", "Bebop",
"Funk", "Soul", "Blues", "Jazz", "Gospel",
"Grunge", "Punk", "Metal", "Rock", "Indie",
"Techno", "Trance", "House", "Dubstep", "Ambient",
"Synth", "Drone", "Fuzz", "Wah", "Reverb",
"Echo", "Loop", "Beat", "Drop", "Groove",
"Fiddle", "Banjo", "Lute", "Harp", "Lyre",
"Viola", "Cello", "Oboe", "Flute", "Fife",
"Bugle", "Cornet", "Trumpet", "Trombone", "Tuba",
"Sax", "Clarinet", "Bassoon", "Piccolo", "Organ",
"Cymbal", "Gong", "Chime", "Bell", "Snare",
"Tambourine", "Bongo", "Conga", "Djembe", "Tabla",
"Sitar", "Shamisen", "Koto", "Guzheng", "Erhu",
"Raga", "Mantra", "Chant", "Dirge", "Elegy",
"Refrain", "Motif", "Theme", "Encore", "Finale",
"Pitch", "Tone", "Note", "Scale", "Octave",
"Sharp", "Flat", "Major", "Minor", "Modal",
"Acoustic", "Electric", "Muted", "Resonant", "Harmonic",
"Sonic", "Stereo", "Mono", "Vinyl", "Track",
"Album", "Mixtape", "Jam", "Gig", "Venue",
"Stage", "Amp", "Speaker", "Mic", "Reed",
"Bow", "Pick", "Slide", "Hammer", "Mallet",
"Timpani", "Marimba", "Celesta", "Dulcimer", "Zither"
],
"literature": [
"Prose", "Verse", "Stanza", "Sonnet", "Haiku",
"Limerick", "Ballad", "Ode", "Epic", "Saga",
"Fable", "Myth", "Legend", "Tale", "Lore",
"Tome", "Codex", "Scroll", "Grimoire", "Almanac",
"Quill", "Ink", "Vellum", "Parchment", "Folio",
"Preface", "Prologue", "Epilogue", "Chapter", "Canto",
"Epitaph", "Elegy", "Dirge", "Psalm", "Hymn",
"Allegory", "Parable", "Satire", "Parody", "Irony",
"Motif", "Theme", "Trope", "Genre", "Canon",
"Muse", "Oracle", "Bard", "Scribe", "Sage",
"Hamlet", "Othello", "Prospero", "Oberon", "Titania",
"Puck", "Ariel", "Caliban", "Portia", "Cordelia",
"Macbeth", "Banquo", "Lear", "Juliet", "Romeo",
"Brutus", "Cassius", "Shylock", "Mercutio", "Falstaff",
"Gatsby", "Atticus", "Scout", "Holden", "Pip",
"Huck", "Ishmael", "Ahab", "Queequeg", "Dorian",
"Darcy", "Heathcliff", "Rochester", "Eyre", "Estella",
"Cosette", "Valjean", "Quixote", "Sancho", "Dulcinea",
"Odysseus", "Aeneas", "Beowulf", "Gilgamesh", "Roland",
"Percival", "Galahad", "Lancelot", "Gawain", "Merlin",
"Scheherazade", "Sinbad", "Aladdin", "Mowgli", "Baloo",
"Nemo", "Moreau", "Jekyll", "Hyde", "Dracula",
"Renfield", "Harker", "Shelley", "Bronte", "Austen",
"Poe", "Frost", "Wilde", "Twain", "Dickens",
"Tolstoy", "Kafka", "Orwell", "Hemingway", "Faulkner",
"Woolf", "Plath", "Dumas", "Hugo", "Verne",
"Cipher", "Riddle", "Enigma", "Rune", "Glyph",
"Sigil", "Symbol", "Token", "Emblem", "Crest",
"Aria", "Lyric", "Rhyme", "Meter", "Cadence",
"Pathos", "Ethos", "Logos", "Hubris", "Nemesis",
"Catharsis", "Climax", "Zenith", "Nadir", "Pivot",
"Twist", "Reverie", "Whimsy", "Satyr", "Nymph",
"Sprite", "Wraith", "Phantom", "Specter", "Shade",
"Vestige", "Relic", "Echo", "Mirage", "Revenant",
"Requiem", "Opus", "Magnum", "Novella", "Lexicon"
],
"gems": [
"Ruby", "Sapphire", "Emerald", "Diamond", "Topaz",
"Amethyst", "Opal", "Pearl", "Garnet", "Peridot",
"Onyx", "Jade", "Agate", "Jasper", "Quartz",
"Citrine", "Zircon", "Beryl", "Spinel", "Tanzanite",
"Tourmaline", "Morganite", "Kunzite", "Iolite", "Larimar",
"Sunstone", "Moonstone", "Bloodstone", "Lodestone", "Heliodor",
"Chrysolite", "Alexandrite", "Andalusite", "Labradorite", "Amazonite",
"Rhodonite", "Sodalite", "Fluorite", "Calcite", "Pyrite",
"Galena", "Bauxite", "Magnetite", "Hematite", "Malachite",
"Azurite", "Turquoise", "Lapis", "Lazuli", "Carnelian",
"Sardonyx", "Chalcedony", "Aventurine", "Obsidian", "Granite",
"Marble", "Basalt", "Pumice", "Slate", "Flint",
"Amber", "Coral", "Ivory", "Jet", "Nacre",
"Gold", "Silver", "Platinum", "Copper", "Bronze",
"Iron", "Steel", "Titanium", "Cobalt", "Nickel",
"Chrome", "Zinc", "Tin", "Lead", "Brass",
"Pewter", "Electrum", "Palladium", "Rhodium", "Iridium",
"Osmium", "Ruthenium", "Tungsten", "Bismuth", "Lithium",
"Cesium", "Radium", "Gallium", "Indium", "Thallium",
"Strontium", "Barium", "Cadmium", "Antimony", "Tellurium",
"Selenite", "Celestite", "Sphalerite", "Kyanite", "Prehnite",
"Apatite", "Danburite", "Howlite", "Lepidolite", "Sugilite",
"Charoite", "Seraphinite", "Moldavite", "Tektite", "Shungite",
"Crystal", "Prism", "Facet", "Carat", "Luster",
"Brilliance", "Clarity", "Sparkle", "Shimmer", "Glimmer",
"Gleam", "Sheen", "Polish", "Ore", "Vein",
"Nugget", "Ingot", "Alloy", "Forge", "Smelt",
"Temper", "Anneal", "Refine", "Crucible", "Slag",
"Geode", "Druzy", "Cabochon", "Cameo", "Inlay",
"Filigree", "Gilt", "Patina", "Verdigris", "Tarnish",
"Shard", "Sliver", "Splinter", "Fragment", "Chip",
"Matrix", "Stratum", "Seam", "Deposit", "Quarry",
"Cavern", "Grotto", "Trove", "Cache", "Vault",
"Crown", "Diadem", "Tiara", "Circlet", "Coronet",
"Amulet", "Talisman", "Pendant", "Brooch", "Signet"
],
"ocean": [
"Tide", "Wave", "Surf", "Swell", "Crest",
"Trough", "Breaker", "Ripple", "Current", "Drift",
"Riptide", "Undertow", "Maelstrom", "Whirlpool", "Eddy",
"Tsunami", "Surge", "Deluge", "Torrent", "Cascade",
"Coral", "Reef", "Atoll", "Lagoon", "Shoal",
"Sandbar", "Islet", "Archipelago", "Fjord", "Inlet",
"Cove", "Bay", "Gulf", "Sound", "Strait",
"Channel", "Harbor", "Marina", "Pier", "Wharf",
"Dock", "Jetty", "Quay", "Beacon", "Lighthouse",
"Anchor", "Helm", "Rudder", "Keel", "Hull",
"Bow", "Stern", "Port", "Mast", "Sail",
"Rigging", "Galley", "Frigate", "Sloop", "Schooner",
"Clipper", "Brigantine", "Corsair", "Buccaneer", "Mariner",
"Skipper", "Bosun", "Navigator", "Helmsman", "Admiral",
"Commodore", "Captain", "Privateer", "Seafarer", "Voyager",
"Kraken", "Leviathan", "Siren", "Selkie", "Nereid",
"Triton", "Poseidon", "Neptune", "Calypso", "Charybdis",
"Scylla", "Davy", "Locker", "Nautilus", "Nemo",
"Whale", "Orca", "Dolphin", "Narwhal", "Porpoise",
"Shark", "Barracuda", "Marlin", "Sailfish", "Swordfish",
"Manta", "Stingray", "Moray", "Grouper", "Snapper",
"Tuna", "Mackerel", "Herring", "Sardine", "Anchovy",
"Squid", "Octopus", "Ammonite", "Cuttlefish", "Jellyfish",
"Anemone", "Urchin", "Starfish", "Seahorse", "Conch",
"Abalone", "Clam", "Oyster", "Mussel", "Scallop",
"Lobster", "Crab", "Shrimp", "Barnacle", "Kelp",
"Seaweed", "Plankton", "Algae", "Brine", "Foam",
"Spray", "Spume", "Mist", "Salt", "Pearl",
"Trench", "Abyss", "Fathom", "Depth", "Benthic",
"Pelagic", "Abyssal", "Hadal", "Tidal", "Littoral",
"Estuary", "Delta", "Mangrove", "Marsh", "Wetland",
"Driftwood", "Flotsam", "Jetsam", "Wreck", "Salvage",
"Bounty", "Plunder", "Treasure", "Doubloon", "Cutlass",
"Cannon", "Broadside", "Mainsail", "Spinnaker", "Jib",
"Bowsprit", "Gangway", "Porthole", "Bulkhead", "Bilge"
],
"weather": [
"Storm", "Tempest", "Squall", "Gale", "Gust",
"Breeze", "Zephyr", "Draft", "Whirlwind", "Tornado",
"Cyclone", "Typhoon", "Hurricane", "Monsoon", "Sirocco",
"Mistral", "Chinook", "Foehn", "Harmattan", "Tramontane",
"Thunder", "Lightning", "Bolt", "Flash", "Strike",
"Rumble", "Crack", "Boom", "Clap", "Roar",
"Rain", "Drizzle", "Shower", "Downpour", "Deluge",
"Torrent", "Cloudburst", "Sprinkle", "Mizzle", "Sleet",
"Snow", "Blizzard", "Flurry", "Frost", "Ice",
"Hail", "Rime", "Hoarfrost", "Glaze", "Verglas",
"Fog", "Mist", "Haze", "Smog", "Murk",
"Overcast", "Gloom", "Shadow", "Shade", "Dusk",
"Cloud", "Cumulus", "Stratus", "Cirrus", "Nimbus",
"Cumulonimbus", "Alto", "Wisp", "Veil", "Canopy",
"Anvil", "Billow", "Plume", "Column", "Front",
"Ridge", "Trough", "Vortex", "Eye", "Funnel",
"Twister", "Spout", "Surge", "Flood", "Flicker",
"Cascade", "Gush", "Swell", "Crest", "Tide",
"Drought", "Arid", "Parch", "Scorch", "Blaze",
"Ember", "Kindle", "Sear", "Wither", "Dustbowl",
"Heat", "Warmth", "Balmy", "Swelter", "Muggy",
"Humid", "Sultry", "Tropic", "Equator", "Solstice",
"Equinox", "Aurora", "Borealis", "Mirage", "Shimmer",
"Glint", "Gleam", "Radiance", "Glow", "Halo",
"Rainbow", "Prism", "Spectrum", "Arc", "Bow",
"Dew", "Droplet", "Puddle", "Rivulet", "Stream",
"Thaw", "Melt", "Freeze", "Chill", "Nip",
"Bite", "Brisk", "Crisp", "Cool", "Cold",
"Frigid", "Glacial", "Polar", "Arctic", "Tundra",
"Permafrost", "Icecap", "Avalanche", "Snowdrift", "Whiteout",
"Barometer", "Pressure", "Isobar", "Celsius", "Kelvin",
"Climate", "Season", "Forecast", "Outlook", "Pattern",
"Windchill", "Dewpoint", "Updraft", "Downdraft", "Jetstream",
"Tradewind", "Doldrums", "Lull", "Calm", "Serene",
"Clear", "Sunny", "Bright", "Radiant", "Blazing"
]
}

View File

@@ -0,0 +1,45 @@
import json
import random
from functools import lru_cache
from pathlib import Path
DICTIONARY_PATH = Path(__file__).resolve().parents[1] / "seeds" / "data" / "name_dictionary.json"
@lru_cache(maxsize=1)
def _load_dictionary() -> dict[str, list[str]]:
if not DICTIONARY_PATH.exists():
return {}
with open(DICTIONARY_PATH) as f:
return json.load(f)
def get_naming_categories() -> list[str]:
"""Return sorted list of available naming category names."""
return sorted(_load_dictionary().keys())
def get_words_for_category(category: str) -> list[str]:
"""Return the word list for a category, or empty list if not found."""
return _load_dictionary().get(category, [])
def suggest_names(
category: str,
used_names: set[str],
count: int = 10,
) -> list[str]:
"""Pick random name suggestions from a category, excluding used names.
Returns up to `count` names. If the category is nearly exhausted,
returns fewer.
"""
words = get_words_for_category(category)
if not words:
return []
available = [w for w in words if w not in used_names]
if not available:
return []
return random.sample(available, min(count, len(available)))

View File

@@ -0,0 +1,84 @@
from unittest.mock import patch
import pytest
from app.services.naming import (
get_naming_categories,
get_words_for_category,
suggest_names,
)
MOCK_DICTIONARY = {
"mythology": ["Apollo", "Athena", "Loki", "Thor", "Zeus"],
"food": ["Basil", "Sage", "Pepper", "Saffron", "Mango"],
"space": ["Apollo", "Nova", "Nebula", "Comet", "Vega"],
}
@pytest.fixture(autouse=True)
def _mock_dictionary():
with patch("app.services.naming._load_dictionary", return_value=MOCK_DICTIONARY):
yield
class TestGetNamingCategories:
def test_returns_sorted_categories(self):
result = get_naming_categories()
assert result == ["food", "mythology", "space"]
def test_returns_empty_for_empty_dictionary(self):
with patch("app.services.naming._load_dictionary", return_value={}):
assert get_naming_categories() == []
class TestGetWordsForCategory:
def test_returns_words_for_valid_category(self):
result = get_words_for_category("mythology")
assert result == ["Apollo", "Athena", "Loki", "Thor", "Zeus"]
def test_returns_empty_for_unknown_category(self):
assert get_words_for_category("nonexistent") == []
class TestSuggestNames:
def test_returns_requested_count(self):
result = suggest_names("mythology", set(), count=3)
assert len(result) == 3
assert all(name in MOCK_DICTIONARY["mythology"] for name in result)
def test_excludes_used_names(self):
used = {"Apollo", "Athena", "Loki"}
result = suggest_names("mythology", used, count=10)
assert set(result) <= {"Thor", "Zeus"}
assert not set(result) & used
def test_returns_fewer_when_category_nearly_exhausted(self):
used = {"Apollo", "Athena", "Loki", "Thor"}
result = suggest_names("mythology", used, count=10)
assert result == ["Zeus"]
def test_returns_empty_when_category_fully_exhausted(self):
used = {"Apollo", "Athena", "Loki", "Thor", "Zeus"}
result = suggest_names("mythology", used, count=10)
assert result == []
def test_returns_empty_for_unknown_category(self):
result = suggest_names("nonexistent", set(), count=10)
assert result == []
def test_no_duplicates_in_suggestions(self):
result = suggest_names("mythology", set(), count=5)
assert len(result) == len(set(result))
def test_default_count_is_ten(self):
# food has 5 words, so we should get all 5
result = suggest_names("food", set())
assert len(result) == 5
def test_cross_category_names_handled_independently(self):
# "Apollo" used in mythology shouldn't affect space
used = {"Apollo"}
mythology_result = suggest_names("mythology", used, count=10)
space_result = suggest_names("space", used, count=10)
assert "Apollo" not in mythology_result
assert "Apollo" not in space_result

View File

@@ -28,3 +28,11 @@ export function updateRun(
export function deleteRun(id: number): Promise<void> { export function deleteRun(id: number): Promise<void> {
return api.del(`/runs/${id}`) return api.del(`/runs/${id}`)
} }
export function getNamingCategories(): Promise<string[]> {
return api.get('/runs/naming-categories')
}
export function getNameSuggestions(runId: number, count = 10): Promise<string[]> {
return api.get(`/runs/${runId}/name-suggestions?count=${count}`)
}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import { useRoutePokemon } from '../hooks/useGames' import { useRoutePokemon } from '../hooks/useGames'
import { useNameSuggestions } from '../hooks/useRuns'
import { import {
EncounterMethodBadge, EncounterMethodBadge,
getMethodLabel, getMethodLabel,
@@ -15,6 +16,8 @@ import type {
interface EncounterModalProps { interface EncounterModalProps {
route: Route route: Route
gameId: number gameId: number
runId: number
namingScheme?: string | null
existing?: EncounterDetail existing?: EncounterDetail
dupedPokemonIds?: Set<number> dupedPokemonIds?: Set<number>
retiredPokemonIds?: Set<number> retiredPokemonIds?: Set<number>
@@ -92,6 +95,8 @@ function pickRandomPokemon(
export function EncounterModal({ export function EncounterModal({
route, route,
gameId, gameId,
runId,
namingScheme,
existing, existing,
dupedPokemonIds, dupedPokemonIds,
retiredPokemonIds, retiredPokemonIds,
@@ -120,6 +125,10 @@ export function EncounterModal({
const isEditing = !!existing const isEditing = !!existing
const showSuggestions = !!namingScheme && status === 'caught' && !isEditing
const { data: suggestions, refetch: regenerate, isFetching: loadingSuggestions } =
useNameSuggestions(showSuggestions ? runId : null)
// Pre-select pokemon when editing // Pre-select pokemon when editing
useEffect(() => { useEffect(() => {
if (existing && routePokemon) { if (existing && routePokemon) {
@@ -380,6 +389,39 @@ export function EncounterModal({
placeholder="Give it a name..." placeholder="Give it a name..."
className="w-full 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" className="w-full 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"
/> />
{showSuggestions && suggestions && suggestions.length > 0 && (
<div className="mt-2">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-gray-500 dark:text-gray-400">
Suggestions ({namingScheme})
</span>
<button
type="button"
onClick={() => regenerate()}
disabled={loadingSuggestions}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 disabled:opacity-50 transition-colors"
>
{loadingSuggestions ? 'Loading...' : 'Regenerate'}
</button>
</div>
<div className="flex flex-wrap gap-1.5">
{suggestions.map((name) => (
<button
key={name}
type="button"
onClick={() => setNickname(name)}
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
nickname === name
? 'bg-blue-100 border-blue-300 text-blue-800 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-300'
: 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:border-blue-300 dark:hover:border-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20'
}`}
>
{name}
</button>
))}
</div>
</div>
)}
</div> </div>
)} )}

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
import { getRuns, getRun, createRun, updateRun, deleteRun } from '../api/runs' import { getRuns, getRun, createRun, updateRun, deleteRun, getNamingCategories, getNameSuggestions } from '../api/runs'
import type { CreateRunInput, UpdateRunInput } from '../types/game' import type { CreateRunInput, UpdateRunInput } from '../types/game'
export function useRuns() { export function useRuns() {
@@ -51,3 +51,19 @@ export function useDeleteRun() {
}, },
}) })
} }
export function useNamingCategories() {
return useQuery({
queryKey: ['naming-categories'],
queryFn: getNamingCategories,
staleTime: Infinity,
})
}
export function useNameSuggestions(runId: number | null) {
return useQuery({
queryKey: ['name-suggestions', runId],
queryFn: () => getNameSuggestions(runId!),
enabled: runId !== null,
})
}

View File

@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { GameGrid, RulesConfiguration, StepIndicator } from '../components' import { GameGrid, RulesConfiguration, StepIndicator } from '../components'
import { useGames, useGameRoutes } from '../hooks/useGames' import { useGames, useGameRoutes } from '../hooks/useGames'
import { useCreateRun, useRuns } from '../hooks/useRuns' import { useCreateRun, useRuns, useNamingCategories } from '../hooks/useRuns'
import type { Game, NuzlockeRules } from '../types' import type { Game, NuzlockeRules } from '../types'
import { DEFAULT_RULES } from '../types' import { DEFAULT_RULES } from '../types'
import { RULE_DEFINITIONS } from '../types/rules' import { RULE_DEFINITIONS } from '../types/rules'
@@ -14,11 +14,13 @@ export function NewRun() {
const { data: games, isLoading, error } = useGames() const { data: games, isLoading, error } = useGames()
const { data: runs } = useRuns() const { data: runs } = useRuns()
const createRun = useCreateRun() const createRun = useCreateRun()
const { data: namingCategories } = useNamingCategories()
const [step, setStep] = useState(1) const [step, setStep] = useState(1)
const [selectedGame, setSelectedGame] = useState<Game | null>(null) const [selectedGame, setSelectedGame] = useState<Game | null>(null)
const [rules, setRules] = useState<NuzlockeRules>(DEFAULT_RULES) const [rules, setRules] = useState<NuzlockeRules>(DEFAULT_RULES)
const [runName, setRunName] = useState('') const [runName, setRunName] = useState('')
const [namingScheme, setNamingScheme] = useState<string | null>(null)
const { data: routes } = useGameRoutes(selectedGame?.id ?? null) const { data: routes } = useGameRoutes(selectedGame?.id ?? null)
const hiddenRules = useMemo(() => { const hiddenRules = useMemo(() => {
@@ -44,7 +46,7 @@ export function NewRun() {
const handleCreate = () => { const handleCreate = () => {
if (!selectedGame) return if (!selectedGame) return
createRun.mutate( createRun.mutate(
{ gameId: selectedGame.id, name: runName, rules }, { gameId: selectedGame.id, name: runName, rules, namingScheme },
{ onSuccess: (data) => navigate(`/runs/${data.id}`) }, { onSuccess: (data) => navigate(`/runs/${data.id}`) },
) )
} }
@@ -180,6 +182,33 @@ export function NewRun() {
/> />
</div> </div>
{namingCategories && namingCategories.length > 0 && (
<div>
<label
htmlFor="naming-scheme"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Naming Scheme
</label>
<select
id="naming-scheme"
value={namingScheme ?? ''}
onChange={(e) => setNamingScheme(e.target.value || null)}
className="w-full 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 focus:border-transparent"
>
<option value="">None (manual nicknames)</option>
{namingCategories.map((cat) => (
<option key={cat} value={cat}>
{cat.charAt(0).toUpperCase() + cat.slice(1)}
</option>
))}
</select>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Get nickname suggestions from a themed word list when catching Pokemon.
</p>
</div>
)}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4"> <div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2"> <h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
Summary Summary
@@ -203,6 +232,14 @@ export function NewRun() {
{enabledRuleCount} of {totalRuleCount} enabled {enabledRuleCount} of {totalRuleCount} enabled
</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

@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { useRun, useUpdateRun } from '../hooks/useRuns' import { useRun, useUpdateRun, useNamingCategories } from '../hooks/useRuns'
import { useGameRoutes } from '../hooks/useGames' import { useGameRoutes } from '../hooks/useGames'
import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters' import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters'
import { StatCard, PokemonCard, RuleBadges, StatusChangeModal, EndRunModal } from '../components' import { StatCard, PokemonCard, RuleBadges, StatusChangeModal, EndRunModal } from '../components'
@@ -51,6 +51,7 @@ export function RunDashboard() {
const createEncounter = useCreateEncounter(runIdNum) const createEncounter = useCreateEncounter(runIdNum)
const updateEncounter = useUpdateEncounter(runIdNum) const updateEncounter = useUpdateEncounter(runIdNum)
const updateRun = useUpdateRun(runIdNum) const updateRun = useUpdateRun(runIdNum)
const { data: namingCategories } = useNamingCategories()
const [selectedEncounter, setSelectedEncounter] = const [selectedEncounter, setSelectedEncounter] =
useState<EncounterDetail | null>(null) useState<EncounterDetail | null>(null)
const [showEndRun, setShowEndRun] = useState(false) const [showEndRun, setShowEndRun] = useState(false)
@@ -197,6 +198,37 @@ export function RunDashboard() {
<RuleBadges rules={run.rules} /> <RuleBadges rules={run.rules} />
</div> </div>
{/* Naming Scheme */}
{namingCategories && namingCategories.length > 0 && (
<div className="mb-6">
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
Naming Scheme
</h2>
{isActive ? (
<select
value={run.namingScheme ?? ''}
onChange={(e) =>
updateRun.mutate({ namingScheme: e.target.value || null })
}
className="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="">None</option>
{namingCategories.map((cat) => (
<option key={cat} value={cat}>
{cat.charAt(0).toUpperCase() + cat.slice(1)}
</option>
))}
</select>
) : (
<span className="text-sm text-gray-900 dark:text-gray-100">
{run.namingScheme
? run.namingScheme.charAt(0).toUpperCase() + run.namingScheme.slice(1)
: 'None'}
</span>
)}
</div>
)}
{/* Active Team */} {/* Active Team */}
<div className="mb-6"> <div className="mb-6">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">

View File

@@ -1435,6 +1435,8 @@ export function RunEncounters() {
<EncounterModal <EncounterModal
route={selectedRoute} route={selectedRoute}
gameId={run!.gameId} gameId={run!.gameId}
runId={runIdNum}
namingScheme={run!.namingScheme}
existing={editingEncounter ?? undefined} existing={editingEncounter ?? undefined}
dupedPokemonIds={dupedPokemonIds} dupedPokemonIds={dupedPokemonIds}
retiredPokemonIds={retiredPokemonIds} retiredPokemonIds={retiredPokemonIds}