Sort seed data routes by game progression instead of alphabetically

Add ROUTE_ORDER maps for Kanto (FRLG), Johto (HGSS), and Hoenn (Emerald)
progressions with aliases for related version groups. Add Export Order
button to admin game detail page for iterating on route orderings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 13:27:51 +01:00
parent 110b864e95
commit 73d4a1831c
16 changed files with 66639 additions and 66337 deletions

View File

@@ -0,0 +1,11 @@
---
# nuzlocke-tracker-37gk
title: Curate route ordering to match game progression
status: completed
type: feature
priority: normal
created_at: 2026-02-07T12:25:05Z
updated_at: 2026-02-07T12:27:11Z
---
Implement ROUTE_ORDER in fetch_pokeapi.py for progression-based sorting, and add Export Order button to AdminGameDetail. See bean j28y for full plan.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -449,6 +449,260 @@ INCLUDED_METHODS = {
"headbutt", "headbutt",
} }
# Route progression order by version group.
# Keys are version group names from VERSION_GROUPS. Names must match
# clean_location_name() output. Routes not listed fall to the end alphabetically.
ROUTE_ORDER: dict[str, list[str]] = {
"firered-leafgreen": [
# Main Kanto progression
"Pallet Town",
"Route 1",
"Viridian City",
"Route 22",
"Route 2",
"Viridian Forest",
"Route 3",
"Mt Moon",
"Route 4",
"Cerulean City",
"Route 24",
"Route 25",
"Route 5",
"Route 6",
"Vermilion City",
"Ss Anne",
"Route 11",
"Digletts Cave",
"Route 9",
"Route 10",
"Rock Tunnel",
"Power Plant",
"Route 8",
"Route 7",
"Celadon City",
"Route 16",
"Route 17",
"Route 18",
"Fuchsia City",
"Safari Zone",
"Route 15",
"Route 14",
"Route 13",
"Route 12",
"Pokemon Tower",
"Sea Route 19",
"Sea Route 20",
"Seafoam Islands",
"Cinnabar Island",
"Pokemon Mansion",
"Sea Route 21",
"Route 23",
"Victory Road 2",
"Cerulean Cave",
# Sevii Islands
"One Island",
"Kindle Road",
"Treasure Beach",
"Mt Ember",
"Cape Brink",
"Berry Forest",
"Bond Bridge",
"Three Isle Port",
"Four Island",
"Icefall Cave",
"Resort Gorgeous",
"Water Labyrinth",
"Five Island",
"Five Isle Meadow",
"Memorial Pillar",
"Outcast Island",
"Green Path",
"Water Path",
"Ruin Valley",
"Lost Cave",
"Pattern Bush",
"Trainer Tower",
"Canyon Entrance",
"Sevault Canyon",
"Tanoby Ruins",
"Monean Chamber",
"Liptoo Chamber",
"Weepth Chamber",
"Dilford Chamber",
"Scufib Chamber",
"Rixy Chamber",
"Viapos Chamber",
"Altering Cave",
],
"heartgold-soulsilver": [
# Johto
"New Bark Town",
"Route 29",
"Cherrygrove City",
"Route 30",
"Route 31",
"Dark Cave",
"Violet City",
"Sprout Tower",
"Route 32",
"Ruins Of Alph",
"Union Cave",
"Route 33",
"Slowpoke Well",
"Route 34",
"Ilex Forest",
"National Park",
"Route 35",
"Route 36",
"Route 37",
"Ecruteak City",
"Burned Tower",
"Bell Tower",
"Route 38",
"Route 39",
"Olivine City",
"Sea Route 40",
"Sea Route 41",
"Cianwood City",
"Route 42",
"Mt Mortar",
"Lake Of Rage",
"Route 43",
"Route 44",
"Ice Path",
"Blackthorn City",
"Dragons Den",
"Route 45",
"Route 46",
"Route 47",
"Route 48",
"Safari Zone",
"Whirl Islands",
"Tohjo Falls",
# Kanto post-game
"Route 27",
"Route 26",
"Victory Road 1",
"Route 28",
"Mt Silver",
"Pallet Town",
"Route 1",
"Viridian City",
"Viridian Forest",
"Route 2",
"Route 3",
"Mt Moon",
"Route 4",
"Cerulean City",
"Route 24",
"Route 25",
"Cerulean Cave",
"Route 5",
"Route 6",
"Vermilion City",
"Route 7",
"Route 8",
"Route 9",
"Route 10",
"Rock Tunnel",
"Route 11",
"Digletts Cave",
"Route 12",
"Route 13",
"Route 14",
"Route 15",
"Fuchsia City",
"Route 16",
"Route 17",
"Route 18",
"Celadon City",
"Sea Route 19",
"Sea Route 20",
"Seafoam Islands",
"Cinnabar Island",
"Sea Route 21",
"Route 22",
# Misc
"Unknown All Poliwag",
"Unknown All Rattata",
"Unknown All Bugs",
],
"emerald": [
"Route 101",
"Oldale Town",
"Route 103",
"Route 102",
"Petalburg City",
"Route 104",
"Petalburg Woods",
"Rusturf Tunnel",
"Route 116",
"Route 105",
"Route 106",
"Dewford Town",
"Granite Cave",
"Route 107",
"Route 108",
"Route 109",
"Slateport City",
"Route 110",
"New Mauville",
"Route 117",
"Route 111",
"Mirage Tower",
"Route 112",
"Fiery Path",
"Jagged Pass",
"Route 113",
"Route 114",
"Meteor Falls",
"Route 115",
"Route 118",
"Route 119",
"Route 120",
"Route 121",
"Safari Zone",
"Lilycove City",
"Route 122",
"Mt Pyre",
"Route 123",
"Magma Hideout",
"Route 124",
"Mossdeep City",
"Route 125",
"Shoal Cave",
"Route 126",
"Sootopolis City",
"Cave Of Origin",
"Route 127",
"Route 128",
"Seafloor Cavern",
"Route 129",
"Route 130",
"Route 131",
"Pacifidlog Town",
"Route 132",
"Route 133",
"Route 134",
"Ever Grande City",
"Victory Road",
"Sky Pillar",
"Abandoned Ship",
"Desert Underpass",
"Artisan Cave",
"Altering Cave",
],
}
# Aliases — version groups sharing same route progression
ROUTE_ORDER["red-blue"] = ROUTE_ORDER["firered-leafgreen"]
ROUTE_ORDER["yellow"] = ROUTE_ORDER["firered-leafgreen"]
ROUTE_ORDER["lets-go"] = ROUTE_ORDER["firered-leafgreen"]
ROUTE_ORDER["gold-silver"] = ROUTE_ORDER["heartgold-soulsilver"]
ROUTE_ORDER["crystal"] = ROUTE_ORDER["heartgold-soulsilver"]
ROUTE_ORDER["ruby-sapphire"] = ROUTE_ORDER["emerald"]
ROUTE_ORDER["omega-ruby-alpha-sapphire"] = ROUTE_ORDER["emerald"]
# Collect all pokemon dex numbers across games # Collect all pokemon dex numbers across games
all_pokemon_dex: set[int] = set() all_pokemon_dex: set[int] = set()
@@ -482,6 +736,26 @@ def clean_area_name(area_name: str, location_name: str) -> str | None:
return area_name.replace("-", " ").title() return area_name.replace("-", " ").title()
def sort_routes_by_progression(routes: list[dict], vg_key: str) -> list[dict]:
"""Sort routes by game progression order for the given version group.
Routes not in the ordering list fall to the end, sorted alphabetically.
Uses starts-with matching so 'Route 2' matches 'Route 2 (South Towards ...)'.
"""
order_list = ROUTE_ORDER.get(vg_key)
if not order_list:
return routes
def sort_key(route: dict) -> tuple[int, str]:
name = route["name"]
for i, ordered_name in enumerate(order_list):
if name == ordered_name or name.startswith(ordered_name + " ("):
return (i, name)
return (len(order_list), name) # Unmatched → end, alphabetical
return sorted(routes, key=sort_key)
def get_encounters_for_area(area_id: int, version_name: str) -> list[dict]: def get_encounters_for_area(area_id: int, version_name: str) -> list[dict]:
"""Get encounter data for a location area, filtered by version.""" """Get encounter data for a location area, filtered by version."""
area = load_resource("location-area", area_id) area = load_resource("location-area", area_id)
@@ -539,7 +813,7 @@ def aggregate_encounters(raw_encounters: list[dict]) -> list[dict]:
return sorted(result, key=lambda x: (-x["encounter_rate"], x["pokemon_name"])) return sorted(result, key=lambda x: (-x["encounter_rate"], x["pokemon_name"]))
def process_version(version_name: str, vg_info: dict) -> list[dict]: def process_version(version_name: str, vg_info: dict, vg_key: str) -> list[dict]:
"""Process all locations for a specific game version. """Process all locations for a specific game version.
Creates hierarchical route structure where locations with multiple areas Creates hierarchical route structure where locations with multiple areas
@@ -558,7 +832,6 @@ def process_version(version_name: str, vg_info: dict) -> list[dict]:
print(f" Found {len(location_refs)} locations") print(f" Found {len(location_refs)} locations")
routes = [] routes = []
order = 1
for loc_ref in location_refs: for loc_ref in location_refs:
loc_name = loc_ref["name"] loc_name = loc_ref["name"]
@@ -590,10 +863,6 @@ def process_version(version_name: str, vg_info: dict) -> list[dict]:
# If we have multiple area-specific encounters, create a parent route # If we have multiple area-specific encounters, create a parent route
# with child routes for each area (hierarchical grouping) # with child routes for each area (hierarchical grouping)
if area_specific and len(area_specific) > 1: if area_specific and len(area_specific) > 1:
# Create parent route (no encounters - just a logical grouping)
parent_order = order
order += 1
child_routes = [] child_routes = []
for area_suffix, area_encs in area_specific.items(): for area_suffix, area_encs in area_specific.items():
aggregated = aggregate_encounters(area_encs) aggregated = aggregate_encounters(area_encs)
@@ -603,16 +872,15 @@ def process_version(version_name: str, vg_info: dict) -> list[dict]:
all_pokemon_dex.add(enc["national_dex"]) all_pokemon_dex.add(enc["national_dex"])
child_routes.append({ child_routes.append({
"name": route_name, "name": route_name,
"order": order, "order": 0,
"encounters": aggregated, "encounters": aggregated,
}) })
order += 1
# Only add parent if we have child routes # Only add parent if we have child routes
if child_routes: if child_routes:
routes.append({ routes.append({
"name": display_name, "name": display_name,
"order": parent_order, "order": 0,
"encounters": [], # Parent routes have no encounters "encounters": [], # Parent routes have no encounters
"children": child_routes, "children": child_routes,
}) })
@@ -627,10 +895,9 @@ def process_version(version_name: str, vg_info: dict) -> list[dict]:
all_pokemon_dex.add(enc["national_dex"]) all_pokemon_dex.add(enc["national_dex"])
routes.append({ routes.append({
"name": route_name, "name": route_name,
"order": order, "order": 0,
"encounters": aggregated, "encounters": aggregated,
}) })
order += 1
# Non-area-specific encounters (or single area without suffix) # Non-area-specific encounters (or single area without suffix)
if all_encounters: if all_encounters:
@@ -640,9 +907,20 @@ def process_version(version_name: str, vg_info: dict) -> list[dict]:
all_pokemon_dex.add(enc["national_dex"]) all_pokemon_dex.add(enc["national_dex"])
routes.append({ routes.append({
"name": display_name, "name": display_name,
"order": order, "order": 0,
"encounters": aggregated, "encounters": aggregated,
}) })
# Sort routes by game progression order
routes = sort_routes_by_progression(routes, vg_key)
# Reassign sequential order values
order = 1
for route in routes:
route["order"] = order
order += 1
for child in route.get("children", []):
child["order"] = order
order += 1 order += 1
# Count routes including children # Count routes including children
@@ -870,9 +1148,9 @@ def main():
print(f"Wrote {len(games)} games to games.json") print(f"Wrote {len(games)} games to games.json")
# Process each version # Process each version
for vg_info in VERSION_GROUPS.values(): for vg_key, vg_info in VERSION_GROUPS.items():
for ver_name in vg_info["versions"]: for ver_name in vg_info["versions"]:
routes = process_version(ver_name, vg_info) routes = process_version(ver_name, vg_info, vg_key)
write_json(f"{ver_name}.json", routes) write_json(f"{ver_name}.json", routes)
# Fetch all Pokemon species # Fetch all Pokemon species

View File

@@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { toast } from 'sonner'
import { useParams, useNavigate, Link } from 'react-router-dom' import { useParams, useNavigate, Link } from 'react-router-dom'
import { import {
DndContext, DndContext,
@@ -158,6 +159,17 @@ export function AdminGameDetail() {
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium">Routes ({routes.length})</h3> <h3 className="text-lg font-medium">Routes ({routes.length})</h3>
<div className="flex gap-2">
<button
onClick={() => {
const names = routes.map((r) => r.name)
navigator.clipboard.writeText(JSON.stringify(names, null, 2))
toast.success('Route order copied to clipboard')
}}
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800"
>
Export Order
</button>
<button <button
onClick={() => setShowCreate(true)} onClick={() => setShowCreate(true)}
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700" className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
@@ -165,6 +177,7 @@ export function AdminGameDetail() {
Add Route Add Route
</button> </button>
</div> </div>
</div>
{routes.length === 0 ? ( {routes.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700 rounded-lg"> <div className="text-center py-8 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700 rounded-lg">