Add game category and region metadata for genlocke presets

Add `category` field (original/remake/enhanced/sequel/spinoff) to the
Game model and tag all 38 games. Create regions.json with generation
mapping, ordering, and genlocke preset defaults per region. Add
GET /games/by-region endpoint returning games grouped by region.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Julian Tabel
2026-02-09 09:06:15 +01:00
parent dab0cf986f
commit aaaeb2146e
9 changed files with 237 additions and 9 deletions

View File

@@ -1,11 +1,11 @@
--- ---
# nuzlocke-tracker-glh8 # nuzlocke-tracker-glh8
title: Gather generation metadata (games, regions) title: Gather generation metadata (games, regions)
status: todo status: in-progress
type: task type: task
priority: normal priority: normal
created_at: 2026-02-08T19:20:49Z created_at: 2026-02-08T19:20:49Z
updated_at: 2026-02-09T07:45:21Z updated_at: 2026-02-09T08:05:52Z
parent: nuzlocke-tracker-25mh parent: nuzlocke-tracker-25mh
blocking: blocking:
- nuzlocke-tracker-kz5g - nuzlocke-tracker-kz5g
@@ -27,11 +27,17 @@ Collect and store metadata about each Pokemon generation to support genlocke fea
- However, a dedicated generations reference would be useful for UI purposes (showing all generations even if not all games are seeded yet) - However, a dedicated generations reference would be useful for UI purposes (showing all generations even if not all games are seeded yet)
- Check if `backend/src/app/seeds/data/generations.json` already exists or if this needs to be created from scratch - Check if `backend/src/app/seeds/data/generations.json` already exists or if this needs to be created from scratch
## Decisions
- Legends games (Hisui, Lumiose) are excluded from genlocke presets — available via Custom only
- Black 2/White 2 are grouped with Black/White in the same Unova slot (category: sequel)
- Normal Genlocke defaults use best mainline remake: FireRed, HeartGold, Alpha Sapphire, Platinum, Ultra Sun
- Metadata stored as `category` field on Game model + standalone `regions.json` seed file
## Checklist ## Checklist
- [ ] Define the generation-to-region mapping (Gen 1 = Kanto, Gen 2 = Johto, ..., Gen 9 = Paldea) - [x] Define the generation-to-region mapping (Gen 1 = Kanto, Gen 2 = Johto, ..., Gen 9 = Paldea)
- [ ] Determine how to group games by region (use `region` field on existing Game model, or create a dedicated lookup) - [x] Determine how to group games by region (use `region` field on existing Game model, or create a dedicated lookup)
- [ ] Create a `generations.json` seed file (or equivalent) with: generation number, region name, region order, and which games belong to each region - [x] Create a `regions.json` seed file (or equivalent) with: generation number, region name, region order, and which games belong to each region
- [ ] Categorize each game as "original", "remake", or "enhanced" so presets can filter appropriately - [x] Categorize each game as "original", "remake", "enhanced", "sequel", or "spinoff" so presets can filter appropriately
- [ ] Define which game is the "default" pick per region for the Normal Genlocke preset (e.g., FireRed for Kanto, HeartGold for Johto) - [x] Define which game is the "default" pick per region for the Normal Genlocke preset (e.g., FireRed for Kanto, HeartGold for Johto)
- [ ] Add an API endpoint or extend the games endpoint to return games grouped by region with generation metadata - [x] Add an API endpoint or extend the games endpoint to return games grouped by region with generation metadata
- [ ] Verify all seeded games are correctly tagged with their region - [x] Verify all seeded games are correctly tagged with their region

View File

@@ -0,0 +1,29 @@
"""add category to games
Revision ID: a1b2c3d4e5f8
Revises: f6a7b8c9d0e1
Create Date: 2026-02-09 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a1b2c3d4e5f8'
down_revision: Union[str, Sequence[str], None] = 'f6a7b8c9d0e1'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'games',
sa.Column('category', sa.String(20), nullable=True),
)
def downgrade() -> None:
op.drop_column('games', 'category')

View File

@@ -1,3 +1,6 @@
import json
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import delete, select from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -14,6 +17,7 @@ from app.schemas.game import (
GameDetailResponse, GameDetailResponse,
GameResponse, GameResponse,
GameUpdate, GameUpdate,
RegionResponse,
RouteCreate, RouteCreate,
RouteReorderRequest, RouteReorderRequest,
RouteResponse, RouteResponse,
@@ -46,6 +50,38 @@ async def list_games(session: AsyncSession = Depends(get_session)):
return result.scalars().all() return result.scalars().all()
@router.get("/by-region", response_model=list[RegionResponse])
async def list_games_by_region(session: AsyncSession = Depends(get_session)):
"""Return games grouped by region with generation metadata and genlocke preset defaults."""
regions_path = Path(__file__).parent.parent / "seeds" / "data" / "regions.json"
with open(regions_path) as f:
regions_data = json.load(f)
result = await session.execute(select(Game).order_by(Game.release_year, Game.name))
all_games = result.scalars().all()
games_by_region: dict[str, list[Game]] = {}
for game in all_games:
games_by_region.setdefault(game.region, []).append(game)
response = []
for region in regions_data:
region_games = games_by_region.get(region["name"], [])
defaults = region["genlocke_defaults"]
response.append({
"name": region["name"],
"generation": region["generation"],
"order": region["order"],
"genlocke_defaults": {
"true_genlocke": defaults["true"],
"normal_genlocke": defaults["normal"],
},
"games": region_games,
})
return response
@router.get("/{game_id}", response_model=GameDetailResponse) @router.get("/{game_id}", response_model=GameDetailResponse)
async def get_game(game_id: int, session: AsyncSession = Depends(get_session)): async def get_game(game_id: int, session: AsyncSession = Depends(get_session)):
game = await _get_game_or_404(session, game_id) game = await _get_game_or_404(session, game_id)
@@ -66,6 +102,7 @@ async def get_game(game_id: int, session: AsyncSession = Depends(get_session)):
"slug": game.slug, "slug": game.slug,
"generation": game.generation, "generation": game.generation,
"region": game.region, "region": game.region,
"category": game.category,
"box_art_url": game.box_art_url, "box_art_url": game.box_art_url,
"release_year": game.release_year, "release_year": game.release_year,
"color": game.color, "color": game.color,

View File

@@ -12,6 +12,7 @@ class Game(Base):
slug: Mapped[str] = mapped_column(String(100), unique=True) slug: Mapped[str] = mapped_column(String(100), unique=True)
generation: Mapped[int] = mapped_column(SmallInteger) generation: Mapped[int] = mapped_column(SmallInteger)
region: Mapped[str] = mapped_column(String(50)) region: Mapped[str] = mapped_column(String(50))
category: Mapped[str | None] = mapped_column(String(20)) # original, remake, enhanced, sequel, spinoff
box_art_url: Mapped[str | None] = mapped_column(String(500)) box_art_url: Mapped[str | None] = mapped_column(String(500))
release_year: Mapped[int | None] = mapped_column(SmallInteger) release_year: Mapped[int | None] = mapped_column(SmallInteger)
color: Mapped[str | None] = mapped_column(String(7)) # Hex color e.g. #FF0000 color: Mapped[str | None] = mapped_column(String(7)) # Hex color e.g. #FF0000

View File

@@ -17,6 +17,7 @@ class GameResponse(CamelModel):
slug: str slug: str
generation: int generation: int
region: str region: str
category: str | None = None
box_art_url: str | None box_art_url: str | None
release_year: int | None release_year: int | None
color: str | None color: str | None
@@ -39,6 +40,7 @@ class GameCreate(CamelModel):
slug: str slug: str
generation: int generation: int
region: str region: str
category: str | None = None
box_art_url: str | None = None box_art_url: str | None = None
release_year: int | None = None release_year: int | None = None
color: str | None = None color: str | None = None
@@ -49,11 +51,25 @@ class GameUpdate(CamelModel):
slug: str | None = None slug: str | None = None
generation: int | None = None generation: int | None = None
region: str | None = None region: str | None = None
category: str | None = None
box_art_url: str | None = None box_art_url: str | None = None
release_year: int | None = None release_year: int | None = None
color: str | None = None color: str | None = None
class GenlockeDefaultsResponse(CamelModel):
true_genlocke: str # game slug for true genlocke default
normal_genlocke: str # game slug for normal genlocke default
class RegionResponse(CamelModel):
name: str
generation: int
order: int
genlocke_defaults: GenlockeDefaultsResponse
games: list[GameResponse] = []
class RouteCreate(CamelModel): class RouteCreate(CamelModel):
name: str name: str
order: int order: int

View File

@@ -4,6 +4,7 @@
"slug": "alpha-sapphire", "slug": "alpha-sapphire",
"generation": 6, "generation": 6,
"region": "hoenn", "region": "hoenn",
"category": "remake",
"release_year": 2014, "release_year": 2014,
"color": "#26649C" "color": "#26649C"
}, },
@@ -12,6 +13,7 @@
"slug": "black", "slug": "black",
"generation": 5, "generation": 5,
"region": "unova", "region": "unova",
"category": "original",
"release_year": 2010, "release_year": 2010,
"color": "#444444" "color": "#444444"
}, },
@@ -20,6 +22,7 @@
"slug": "black-2", "slug": "black-2",
"generation": 5, "generation": 5,
"region": "unova", "region": "unova",
"category": "sequel",
"release_year": 2012, "release_year": 2012,
"color": "#424B50" "color": "#424B50"
}, },
@@ -28,6 +31,7 @@
"slug": "blue", "slug": "blue",
"generation": 1, "generation": 1,
"region": "kanto", "region": "kanto",
"category": "original",
"release_year": 1996, "release_year": 1996,
"color": "#1111FF" "color": "#1111FF"
}, },
@@ -36,6 +40,7 @@
"slug": "brilliant-diamond", "slug": "brilliant-diamond",
"generation": 8, "generation": 8,
"region": "sinnoh", "region": "sinnoh",
"category": "remake",
"release_year": 2021, "release_year": 2021,
"color": "#44BAE5" "color": "#44BAE5"
}, },
@@ -44,6 +49,7 @@
"slug": "crystal", "slug": "crystal",
"generation": 2, "generation": 2,
"region": "johto", "region": "johto",
"category": "enhanced",
"release_year": 2000, "release_year": 2000,
"color": "#4FD9FF" "color": "#4FD9FF"
}, },
@@ -52,6 +58,7 @@
"slug": "diamond", "slug": "diamond",
"generation": 4, "generation": 4,
"region": "sinnoh", "region": "sinnoh",
"category": "original",
"release_year": 2006, "release_year": 2006,
"color": "#AAAAFF" "color": "#AAAAFF"
}, },
@@ -60,6 +67,7 @@
"slug": "emerald", "slug": "emerald",
"generation": 3, "generation": 3,
"region": "hoenn", "region": "hoenn",
"category": "enhanced",
"release_year": 2005, "release_year": 2005,
"color": "#00A000" "color": "#00A000"
}, },
@@ -68,6 +76,7 @@
"slug": "firered", "slug": "firered",
"generation": 3, "generation": 3,
"region": "kanto", "region": "kanto",
"category": "remake",
"release_year": 2004, "release_year": 2004,
"color": "#FF7327" "color": "#FF7327"
}, },
@@ -76,6 +85,7 @@
"slug": "gold", "slug": "gold",
"generation": 2, "generation": 2,
"region": "johto", "region": "johto",
"category": "original",
"release_year": 1999, "release_year": 1999,
"color": "#DAA520" "color": "#DAA520"
}, },
@@ -84,6 +94,7 @@
"slug": "heartgold", "slug": "heartgold",
"generation": 4, "generation": 4,
"region": "johto", "region": "johto",
"category": "remake",
"release_year": 2010, "release_year": 2010,
"color": "#B69E00" "color": "#B69E00"
}, },
@@ -92,6 +103,7 @@
"slug": "leafgreen", "slug": "leafgreen",
"generation": 3, "generation": 3,
"region": "kanto", "region": "kanto",
"category": "remake",
"release_year": 2004, "release_year": 2004,
"color": "#00DD00" "color": "#00DD00"
}, },
@@ -100,6 +112,7 @@
"slug": "legends-arceus", "slug": "legends-arceus",
"generation": 8, "generation": 8,
"region": "hisui", "region": "hisui",
"category": "spinoff",
"release_year": 2022, "release_year": 2022,
"color": "#36597B" "color": "#36597B"
}, },
@@ -108,6 +121,7 @@
"slug": "legends-z-a", "slug": "legends-z-a",
"generation": 9, "generation": 9,
"region": "lumiose", "region": "lumiose",
"category": "spinoff",
"release_year": 2025, "release_year": 2025,
"color": "#3A7BDB" "color": "#3A7BDB"
}, },
@@ -116,6 +130,7 @@
"slug": "lets-go-eevee", "slug": "lets-go-eevee",
"generation": 7, "generation": 7,
"region": "kanto", "region": "kanto",
"category": "remake",
"release_year": 2018, "release_year": 2018,
"color": "#D4924B" "color": "#D4924B"
}, },
@@ -124,6 +139,7 @@
"slug": "lets-go-pikachu", "slug": "lets-go-pikachu",
"generation": 7, "generation": 7,
"region": "kanto", "region": "kanto",
"category": "remake",
"release_year": 2018, "release_year": 2018,
"color": "#F5DA00" "color": "#F5DA00"
}, },
@@ -132,6 +148,7 @@
"slug": "moon", "slug": "moon",
"generation": 7, "generation": 7,
"region": "alola", "region": "alola",
"category": "original",
"release_year": 2016, "release_year": 2016,
"color": "#5599CA" "color": "#5599CA"
}, },
@@ -140,6 +157,7 @@
"slug": "omega-ruby", "slug": "omega-ruby",
"generation": 6, "generation": 6,
"region": "hoenn", "region": "hoenn",
"category": "remake",
"release_year": 2014, "release_year": 2014,
"color": "#CF3025" "color": "#CF3025"
}, },
@@ -148,6 +166,7 @@
"slug": "pearl", "slug": "pearl",
"generation": 4, "generation": 4,
"region": "sinnoh", "region": "sinnoh",
"category": "original",
"release_year": 2006, "release_year": 2006,
"color": "#FFAAAA" "color": "#FFAAAA"
}, },
@@ -156,6 +175,7 @@
"slug": "platinum", "slug": "platinum",
"generation": 4, "generation": 4,
"region": "sinnoh", "region": "sinnoh",
"category": "enhanced",
"release_year": 2008, "release_year": 2008,
"color": "#999999" "color": "#999999"
}, },
@@ -164,6 +184,7 @@
"slug": "red", "slug": "red",
"generation": 1, "generation": 1,
"region": "kanto", "region": "kanto",
"category": "original",
"release_year": 1996, "release_year": 1996,
"color": "#FF1111" "color": "#FF1111"
}, },
@@ -172,6 +193,7 @@
"slug": "ruby", "slug": "ruby",
"generation": 3, "generation": 3,
"region": "hoenn", "region": "hoenn",
"category": "original",
"release_year": 2002, "release_year": 2002,
"color": "#A00000" "color": "#A00000"
}, },
@@ -180,6 +202,7 @@
"slug": "sapphire", "slug": "sapphire",
"generation": 3, "generation": 3,
"region": "hoenn", "region": "hoenn",
"category": "original",
"release_year": 2002, "release_year": 2002,
"color": "#0000A0" "color": "#0000A0"
}, },
@@ -188,6 +211,7 @@
"slug": "scarlet", "slug": "scarlet",
"generation": 9, "generation": 9,
"region": "paldea", "region": "paldea",
"category": "original",
"release_year": 2022, "release_year": 2022,
"color": "#F93C3C" "color": "#F93C3C"
}, },
@@ -196,6 +220,7 @@
"slug": "shield", "slug": "shield",
"generation": 8, "generation": 8,
"region": "galar", "region": "galar",
"category": "original",
"release_year": 2019, "release_year": 2019,
"color": "#EF3B6E" "color": "#EF3B6E"
}, },
@@ -204,6 +229,7 @@
"slug": "shining-pearl", "slug": "shining-pearl",
"generation": 8, "generation": 8,
"region": "sinnoh", "region": "sinnoh",
"category": "remake",
"release_year": 2021, "release_year": 2021,
"color": "#E18AAA" "color": "#E18AAA"
}, },
@@ -212,6 +238,7 @@
"slug": "silver", "slug": "silver",
"generation": 2, "generation": 2,
"region": "johto", "region": "johto",
"category": "original",
"release_year": 1999, "release_year": 1999,
"color": "#C0C0C0" "color": "#C0C0C0"
}, },
@@ -220,6 +247,7 @@
"slug": "soulsilver", "slug": "soulsilver",
"generation": 4, "generation": 4,
"region": "johto", "region": "johto",
"category": "remake",
"release_year": 2010, "release_year": 2010,
"color": "#C0C0E0" "color": "#C0C0E0"
}, },
@@ -228,6 +256,7 @@
"slug": "sun", "slug": "sun",
"generation": 7, "generation": 7,
"region": "alola", "region": "alola",
"category": "original",
"release_year": 2016, "release_year": 2016,
"color": "#F1912B" "color": "#F1912B"
}, },
@@ -236,6 +265,7 @@
"slug": "sword", "slug": "sword",
"generation": 8, "generation": 8,
"region": "galar", "region": "galar",
"category": "original",
"release_year": 2019, "release_year": 2019,
"color": "#00D4E7" "color": "#00D4E7"
}, },
@@ -244,6 +274,7 @@
"slug": "ultra-moon", "slug": "ultra-moon",
"generation": 7, "generation": 7,
"region": "alola", "region": "alola",
"category": "enhanced",
"release_year": 2017, "release_year": 2017,
"color": "#204E8C" "color": "#204E8C"
}, },
@@ -252,6 +283,7 @@
"slug": "ultra-sun", "slug": "ultra-sun",
"generation": 7, "generation": 7,
"region": "alola", "region": "alola",
"category": "enhanced",
"release_year": 2017, "release_year": 2017,
"color": "#E95B2B" "color": "#E95B2B"
}, },
@@ -260,6 +292,7 @@
"slug": "violet", "slug": "violet",
"generation": 9, "generation": 9,
"region": "paldea", "region": "paldea",
"category": "original",
"release_year": 2022, "release_year": 2022,
"color": "#A96EEC" "color": "#A96EEC"
}, },
@@ -268,6 +301,7 @@
"slug": "white", "slug": "white",
"generation": 5, "generation": 5,
"region": "unova", "region": "unova",
"category": "original",
"release_year": 2010, "release_year": 2010,
"color": "#E1E1E1" "color": "#E1E1E1"
}, },
@@ -276,6 +310,7 @@
"slug": "white-2", "slug": "white-2",
"generation": 5, "generation": 5,
"region": "unova", "region": "unova",
"category": "sequel",
"release_year": 2012, "release_year": 2012,
"color": "#E3CED0" "color": "#E3CED0"
}, },
@@ -284,6 +319,7 @@
"slug": "x", "slug": "x",
"generation": 6, "generation": 6,
"region": "kalos", "region": "kalos",
"category": "original",
"release_year": 2013, "release_year": 2013,
"color": "#025DA6" "color": "#025DA6"
}, },
@@ -292,6 +328,7 @@
"slug": "y", "slug": "y",
"generation": 6, "generation": 6,
"region": "kalos", "region": "kalos",
"category": "original",
"release_year": 2013, "release_year": 2013,
"color": "#EA1A3E" "color": "#EA1A3E"
}, },
@@ -300,6 +337,7 @@
"slug": "yellow", "slug": "yellow",
"generation": 1, "generation": 1,
"region": "kanto", "region": "kanto",
"category": "enhanced",
"release_year": 1998, "release_year": 1998,
"color": "#FFD733" "color": "#FFD733"
} }

View File

@@ -0,0 +1,83 @@
[
{
"name": "kanto",
"generation": 1,
"order": 1,
"genlocke_defaults": {
"true": "red",
"normal": "firered"
}
},
{
"name": "johto",
"generation": 2,
"order": 2,
"genlocke_defaults": {
"true": "gold",
"normal": "heartgold"
}
},
{
"name": "hoenn",
"generation": 3,
"order": 3,
"genlocke_defaults": {
"true": "ruby",
"normal": "alpha-sapphire"
}
},
{
"name": "sinnoh",
"generation": 4,
"order": 4,
"genlocke_defaults": {
"true": "diamond",
"normal": "platinum"
}
},
{
"name": "unova",
"generation": 5,
"order": 5,
"genlocke_defaults": {
"true": "black",
"normal": "black"
}
},
{
"name": "kalos",
"generation": 6,
"order": 6,
"genlocke_defaults": {
"true": "x",
"normal": "x"
}
},
{
"name": "alola",
"generation": 7,
"order": 7,
"genlocke_defaults": {
"true": "sun",
"normal": "ultra-sun"
}
},
{
"name": "galar",
"generation": 8,
"order": 8,
"genlocke_defaults": {
"true": "sword",
"normal": "sword"
}
},
{
"name": "paldea",
"generation": 9,
"order": 9,
"genlocke_defaults": {
"true": "scarlet",
"normal": "scarlet"
}
}
]

View File

@@ -51,6 +51,7 @@ async def upsert_games(
"slug": game["slug"], "slug": game["slug"],
"generation": game["generation"], "generation": game["generation"],
"region": game["region"], "region": game["region"],
"category": game.get("category"),
"release_year": game.get("release_year"), "release_year": game.get("release_year"),
"color": game.get("color"), "color": game.get("color"),
} }
@@ -58,6 +59,7 @@ async def upsert_games(
"name": game["name"], "name": game["name"],
"generation": game["generation"], "generation": game["generation"],
"region": game["region"], "region": game["region"],
"category": game.get("category"),
"release_year": game.get("release_year"), "release_year": game.get("release_year"),
"color": game.get("color"), "color": game.get("color"),
} }

View File

@@ -1,15 +1,31 @@
export type GameCategory = 'original' | 'remake' | 'enhanced' | 'sequel' | 'spinoff'
export interface Game { export interface Game {
id: number id: number
name: string name: string
slug: string slug: string
generation: number generation: number
region: string region: string
category: GameCategory | null
boxArtUrl: string | null boxArtUrl: string | null
releaseYear: number | null releaseYear: number | null
color: string | null color: string | null
versionGroupId: number | null versionGroupId: number | null
} }
export interface GenlockeDefaults {
trueGenlocke: string // game slug
normalGenlocke: string // game slug
}
export interface Region {
name: string
generation: number
order: number
genlockeDefaults: GenlockeDefaults
games: Game[]
}
export interface Route { export interface Route {
id: number id: number
name: string name: string