Add hierarchical route grouping for multi-area locations
Locations like Mt. Moon (with 1F, B1F, B2F floors) are now grouped so only one encounter can be logged per location group, enforcing Nuzlocke first-encounter rules correctly. - Add parent_route_id column with self-referential FK to routes table - Add parent/children relationships on Route model - Update games API to return hierarchical route structure - Add validation in encounters API to prevent parent route encounters and duplicate encounters within sibling routes (409 conflict) - Update frontend with collapsible RouteGroup component - Auto-derive route groups from PokeAPI location/location-area structure - Regenerate seed data with 70 parent routes and 315 child routes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,64 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-u7i9
|
# nuzlocke-tracker-u7i9
|
||||||
title: Combine sub-areas into single locations
|
title: Combine sub-areas into single locations
|
||||||
status: todo
|
status: completed
|
||||||
type: feature
|
type: feature
|
||||||
|
priority: normal
|
||||||
created_at: 2026-02-05T14:27:13Z
|
created_at: 2026-02-05T14:27:13Z
|
||||||
updated_at: 2026-02-05T14:27:13Z
|
updated_at: 2026-02-06T09:56:11Z
|
||||||
parent: nuzlocke-tracker-f5ob
|
parent: nuzlocke-tracker-f5ob
|
||||||
---
|
---
|
||||||
|
|
||||||
Some game locations have multiple encounter tables (e.g. Mount Moon 1F, Mount Moon B1F, Mount Moon B2F) but are treated as a single location for Nuzlocke first-encounter rules.
|
Some game locations have multiple encounter tables (e.g. Mount Moon 1F, Mount Moon B1F, Mount Moon B2F) but are treated as a single location for Nuzlocke first-encounter rules.
|
||||||
|
|
||||||
Needs a concept of 'location groups' — a parent location that contains multiple sub-areas, each with their own encounter table. For Nuzlocke purposes, the first encounter in *any* sub-area of the group counts as that location's encounter.
|
Needs a concept of 'location groups' — a parent location that contains multiple sub-areas, each with their own encounter table. For Nuzlocke purposes, the first encounter in *any* sub-area of the group counts as that location's encounter.
|
||||||
|
|
||||||
|
## Implementation Summary
|
||||||
|
|
||||||
|
Added hierarchical route grouping so locations like Mt. Moon (with floors 1F, B1F, B2F) are treated as a single location for Nuzlocke first-encounter rules. The first encounter in ANY sub-area counts as that location's encounter.
|
||||||
|
|
||||||
|
### Changes Made
|
||||||
|
|
||||||
|
1. **Database Migration** (`c3d4e5f6a7b8_add_route_grouping.py`)
|
||||||
|
- Added nullable `parent_route_id` column with self-referential FK (CASCADE delete)
|
||||||
|
|
||||||
|
2. **Backend Model** (`backend/src/app/models/route.py`)
|
||||||
|
- Added `parent_route_id` field
|
||||||
|
- Added self-referential `parent` and `children` relationships
|
||||||
|
|
||||||
|
3. **Backend Schemas** (`backend/src/app/schemas/game.py`)
|
||||||
|
- Added `parent_route_id` to `RouteResponse`, `RouteCreate`, `RouteUpdate`
|
||||||
|
- Added `RouteWithChildrenResponse` with nested children
|
||||||
|
|
||||||
|
4. **Backend API - Games** (`backend/src/app/api/games.py`)
|
||||||
|
- Updated `list_game_routes` to support hierarchical response (default) or flat list (`?flat=true`)
|
||||||
|
|
||||||
|
5. **Backend API - Encounters** (`backend/src/app/api/encounters.py`)
|
||||||
|
- Added validation: cannot create encounter on parent route (400 error)
|
||||||
|
- Added validation: cannot create encounter if sibling already has one (409 error)
|
||||||
|
|
||||||
|
6. **Frontend Types** (`frontend/src/types/game.ts`)
|
||||||
|
- Added `parentRouteId` to `Route` interface
|
||||||
|
- Added `RouteWithChildren` interface
|
||||||
|
|
||||||
|
7. **Frontend Page** (`frontend/src/pages/RunEncounters.tsx`)
|
||||||
|
- Added `organizeRoutes()` helper to build hierarchy from flat list
|
||||||
|
- Added `getGroupEncounter()` to check if any child has an encounter
|
||||||
|
- Updated progress counter to count groups (not individual sub-routes)
|
||||||
|
- Added collapsible `RouteGroup` component
|
||||||
|
- Sibling routes are disabled after one has an encounter
|
||||||
|
|
||||||
|
8. **Seed Data** (`backend/src/app/seeds/fetch_pokeapi.py`)
|
||||||
|
- Updated to automatically detect grouped locations from PokeAPI's location/location-area structure
|
||||||
|
- Parent routes have empty encounters; children have actual encounters
|
||||||
|
- 70 parent routes with 315 child routes across all games
|
||||||
|
|
||||||
|
9. **Seed Loader** (`backend/src/app/seeds/loader.py`, `run.py`)
|
||||||
|
- Updated `upsert_routes` to handle hierarchical structure with parent_route_id
|
||||||
|
- Updated seed runner to process child route encounters
|
||||||
|
|
||||||
## Considerations
|
## Considerations
|
||||||
- Data model: add a parent_route_id or location_group concept to the Route model
|
- [x] Data model: add a parent_route_id or location_group concept to the Route model
|
||||||
- Seed data: identify which routes should be grouped (may need manual curation per game)
|
- [x] Seed data: identify which routes should be grouped (automatically derived from PokeAPI location/area structure)
|
||||||
- Encounter tracking: when logging an encounter in a sub-area, mark the whole group as visited
|
- [x] Encounter tracking: when logging an encounter in a sub-area, mark the whole group as visited
|
||||||
- Route list UI: show grouped locations as collapsible sections
|
- [x] Route list UI: show grouped locations as collapsible sections
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""add parent_route_id for route grouping
|
||||||
|
|
||||||
|
Revision ID: c3d4e5f6a7b8
|
||||||
|
Revises: b2c3d4e5f6a7
|
||||||
|
Create Date: 2026-02-06 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'c3d4e5f6a7b8'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'b2c3d4e5f6a7'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
'routes',
|
||||||
|
sa.Column(
|
||||||
|
'parent_route_id',
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey('routes.id', ondelete='CASCADE'),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('routes', 'parent_route_id')
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload, selectinload
|
||||||
|
|
||||||
from app.core.database import get_session
|
from app.core.database import get_session
|
||||||
from app.models.encounter import Encounter
|
from app.models.encounter import Encounter
|
||||||
@@ -33,11 +33,45 @@ async def create_encounter(
|
|||||||
if run is None:
|
if run is None:
|
||||||
raise HTTPException(status_code=404, detail="Run not found")
|
raise HTTPException(status_code=404, detail="Run not found")
|
||||||
|
|
||||||
# Validate route exists
|
# Validate route exists and load its children
|
||||||
route = await session.get(Route, data.route_id)
|
result = await session.execute(
|
||||||
|
select(Route)
|
||||||
|
.where(Route.id == data.route_id)
|
||||||
|
.options(selectinload(Route.children))
|
||||||
|
)
|
||||||
|
route = result.scalar_one_or_none()
|
||||||
if route is None:
|
if route is None:
|
||||||
raise HTTPException(status_code=404, detail="Route not found")
|
raise HTTPException(status_code=404, detail="Route not found")
|
||||||
|
|
||||||
|
# Cannot create encounter on a parent route (routes with children)
|
||||||
|
if route.children:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Cannot create encounter on a parent route. Use a child route instead.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# If this route has a parent, check if any sibling already has an encounter
|
||||||
|
if route.parent_route_id is not None:
|
||||||
|
# Get all sibling route IDs (routes with same parent, including this one)
|
||||||
|
siblings_result = await session.execute(
|
||||||
|
select(Route.id).where(Route.parent_route_id == route.parent_route_id)
|
||||||
|
)
|
||||||
|
sibling_ids = [r for r in siblings_result.scalars().all()]
|
||||||
|
|
||||||
|
# Check if any sibling already has an encounter in this run
|
||||||
|
existing_encounter = await session.execute(
|
||||||
|
select(Encounter)
|
||||||
|
.where(
|
||||||
|
Encounter.run_id == run_id,
|
||||||
|
Encounter.route_id.in_(sibling_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing_encounter.scalar_one_or_none() is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="This location group already has an encounter. Only one encounter per location group is allowed.",
|
||||||
|
)
|
||||||
|
|
||||||
# Validate pokemon exists
|
# Validate pokemon exists
|
||||||
pokemon = await session.get(Pokemon, data.pokemon_id)
|
pokemon = await session.get(Pokemon, data.pokemon_id)
|
||||||
if pokemon is None:
|
if pokemon is None:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from app.schemas.game import (
|
|||||||
RouteReorderRequest,
|
RouteReorderRequest,
|
||||||
RouteResponse,
|
RouteResponse,
|
||||||
RouteUpdate,
|
RouteUpdate,
|
||||||
|
RouteWithChildrenResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -42,10 +43,21 @@ async def get_game(game_id: int, session: AsyncSession = Depends(get_session)):
|
|||||||
return game
|
return game
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{game_id}/routes", response_model=list[RouteResponse])
|
@router.get(
|
||||||
|
"/{game_id}/routes",
|
||||||
|
response_model=list[RouteWithChildrenResponse] | list[RouteResponse],
|
||||||
|
)
|
||||||
async def list_game_routes(
|
async def list_game_routes(
|
||||||
game_id: int, session: AsyncSession = Depends(get_session)
|
game_id: int,
|
||||||
|
flat: bool = False,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
List routes for a game.
|
||||||
|
|
||||||
|
By default, returns a hierarchical structure with top-level routes containing
|
||||||
|
nested children. Use `flat=True` to get a flat list of all routes.
|
||||||
|
"""
|
||||||
# Verify game exists
|
# Verify game exists
|
||||||
game = await session.get(Game, game_id)
|
game = await session.get(Game, game_id)
|
||||||
if game is None:
|
if game is None:
|
||||||
@@ -56,7 +68,36 @@ async def list_game_routes(
|
|||||||
.where(Route.game_id == game_id)
|
.where(Route.game_id == game_id)
|
||||||
.order_by(Route.order)
|
.order_by(Route.order)
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
all_routes = result.scalars().all()
|
||||||
|
|
||||||
|
if flat:
|
||||||
|
return all_routes
|
||||||
|
|
||||||
|
# Build hierarchical structure
|
||||||
|
# Group children by parent_route_id
|
||||||
|
children_by_parent: dict[int, list[Route]] = {}
|
||||||
|
top_level_routes: list[Route] = []
|
||||||
|
|
||||||
|
for route in all_routes:
|
||||||
|
if route.parent_route_id is None:
|
||||||
|
top_level_routes.append(route)
|
||||||
|
else:
|
||||||
|
children_by_parent.setdefault(route.parent_route_id, []).append(route)
|
||||||
|
|
||||||
|
# Build response with nested children
|
||||||
|
response = []
|
||||||
|
for route in top_level_routes:
|
||||||
|
route_dict = {
|
||||||
|
"id": route.id,
|
||||||
|
"name": route.name,
|
||||||
|
"game_id": route.game_id,
|
||||||
|
"order": route.order,
|
||||||
|
"parent_route_id": route.parent_route_id,
|
||||||
|
"children": children_by_parent.get(route.id, []),
|
||||||
|
}
|
||||||
|
response.append(route_dict)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
# --- Admin endpoints ---
|
# --- Admin endpoints ---
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ class Route(Base):
|
|||||||
name: Mapped[str] = mapped_column(String(100))
|
name: Mapped[str] = mapped_column(String(100))
|
||||||
game_id: Mapped[int] = mapped_column(ForeignKey("games.id"), index=True)
|
game_id: Mapped[int] = mapped_column(ForeignKey("games.id"), index=True)
|
||||||
order: Mapped[int] = mapped_column(SmallInteger)
|
order: Mapped[int] = mapped_column(SmallInteger)
|
||||||
|
parent_route_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("routes.id", ondelete="CASCADE"), index=True, default=None
|
||||||
|
)
|
||||||
|
|
||||||
game: Mapped["Game"] = relationship(back_populates="routes")
|
game: Mapped["Game"] = relationship(back_populates="routes")
|
||||||
route_encounters: Mapped[list["RouteEncounter"]] = relationship(
|
route_encounters: Mapped[list["RouteEncounter"]] = relationship(
|
||||||
@@ -21,5 +24,13 @@ class Route(Base):
|
|||||||
)
|
)
|
||||||
encounters: Mapped[list["Encounter"]] = relationship(back_populates="route")
|
encounters: Mapped[list["Encounter"]] = relationship(back_populates="route")
|
||||||
|
|
||||||
|
# Self-referential relationships for route grouping
|
||||||
|
parent: Mapped["Route | None"] = relationship(
|
||||||
|
back_populates="children", remote_side=[id]
|
||||||
|
)
|
||||||
|
children: Mapped[list["Route"]] = relationship(
|
||||||
|
back_populates="parent", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<Route(id={self.id}, name='{self.name}')>"
|
return f"<Route(id={self.id}, name='{self.name}')>"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ class RouteResponse(CamelModel):
|
|||||||
name: str
|
name: str
|
||||||
game_id: int
|
game_id: int
|
||||||
order: int
|
order: int
|
||||||
|
parent_route_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class GameResponse(CamelModel):
|
class GameResponse(CamelModel):
|
||||||
@@ -18,6 +19,10 @@ class GameResponse(CamelModel):
|
|||||||
release_year: int | None
|
release_year: int | None
|
||||||
|
|
||||||
|
|
||||||
|
class RouteWithChildrenResponse(RouteResponse):
|
||||||
|
children: list[RouteResponse] = []
|
||||||
|
|
||||||
|
|
||||||
class GameDetailResponse(GameResponse):
|
class GameDetailResponse(GameResponse):
|
||||||
routes: list[RouteResponse] = []
|
routes: list[RouteResponse] = []
|
||||||
|
|
||||||
@@ -46,11 +51,13 @@ class GameUpdate(CamelModel):
|
|||||||
class RouteCreate(CamelModel):
|
class RouteCreate(CamelModel):
|
||||||
name: str
|
name: str
|
||||||
order: int
|
order: int
|
||||||
|
parent_route_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class RouteUpdate(CamelModel):
|
class RouteUpdate(CamelModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
order: int | None = None
|
order: int | None = None
|
||||||
|
parent_route_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class RouteReorderItem(CamelModel):
|
class RouteReorderItem(CamelModel):
|
||||||
|
|||||||
+1861
-1805
File diff suppressed because it is too large
Load Diff
+3616
-3532
File diff suppressed because it is too large
Load Diff
+7634
-7501
File diff suppressed because it is too large
Load Diff
+3616
-3532
File diff suppressed because it is too large
Load Diff
+7618
-7485
File diff suppressed because it is too large
Load Diff
@@ -187,7 +187,11 @@ def aggregate_encounters(raw_encounters: list[dict]) -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def process_version(version_name: str, vg_info: dict) -> list[dict]:
|
def process_version(version_name: str, vg_info: dict) -> 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
|
||||||
|
become parent routes with child routes for each area.
|
||||||
|
"""
|
||||||
print(f"\n--- Processing {version_name} ---")
|
print(f"\n--- Processing {version_name} ---")
|
||||||
|
|
||||||
region = load_resource("region", vg_info["region_id"])
|
region = load_resource("region", vg_info["region_id"])
|
||||||
@@ -230,8 +234,38 @@ def process_version(version_name: str, vg_info: dict) -> list[dict]:
|
|||||||
else:
|
else:
|
||||||
all_encounters.extend(encounters)
|
all_encounters.extend(encounters)
|
||||||
|
|
||||||
# Area-specific encounters become separate routes
|
# If we have multiple area-specific encounters, create a parent route
|
||||||
if area_specific:
|
# with child routes for each area (hierarchical grouping)
|
||||||
|
if area_specific and len(area_specific) > 1:
|
||||||
|
# Create parent route (no encounters - just a logical grouping)
|
||||||
|
parent_order = order
|
||||||
|
order += 1
|
||||||
|
|
||||||
|
child_routes = []
|
||||||
|
for area_suffix, area_encs in area_specific.items():
|
||||||
|
aggregated = aggregate_encounters(area_encs)
|
||||||
|
if aggregated:
|
||||||
|
route_name = f"{display_name} ({area_suffix})"
|
||||||
|
for enc in aggregated:
|
||||||
|
all_pokemon_dex.add(enc["national_dex"])
|
||||||
|
child_routes.append({
|
||||||
|
"name": route_name,
|
||||||
|
"order": order,
|
||||||
|
"encounters": aggregated,
|
||||||
|
})
|
||||||
|
order += 1
|
||||||
|
|
||||||
|
# Only add parent if we have child routes
|
||||||
|
if child_routes:
|
||||||
|
routes.append({
|
||||||
|
"name": display_name,
|
||||||
|
"order": parent_order,
|
||||||
|
"encounters": [], # Parent routes have no encounters
|
||||||
|
"children": child_routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
elif area_specific:
|
||||||
|
# Only one area-specific route - don't create parent/child, just use suffix
|
||||||
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)
|
||||||
if aggregated:
|
if aggregated:
|
||||||
@@ -245,6 +279,7 @@ def process_version(version_name: str, vg_info: dict) -> list[dict]:
|
|||||||
})
|
})
|
||||||
order += 1
|
order += 1
|
||||||
|
|
||||||
|
# Non-area-specific encounters (or single area without suffix)
|
||||||
if all_encounters:
|
if all_encounters:
|
||||||
aggregated = aggregate_encounters(all_encounters)
|
aggregated = aggregate_encounters(all_encounters)
|
||||||
if aggregated:
|
if aggregated:
|
||||||
@@ -257,8 +292,13 @@ def process_version(version_name: str, vg_info: dict) -> list[dict]:
|
|||||||
})
|
})
|
||||||
order += 1
|
order += 1
|
||||||
|
|
||||||
print(f" Routes with encounters: {len(routes)}")
|
# Count routes including children
|
||||||
total_enc = sum(len(r["encounters"]) for r in routes)
|
total_routes = sum(1 + len(r.get("children", [])) for r in routes)
|
||||||
|
print(f" Routes with encounters: {total_routes}")
|
||||||
|
total_enc = sum(
|
||||||
|
len(r["encounters"]) + sum(len(c["encounters"]) for c in r.get("children", []))
|
||||||
|
for r in routes
|
||||||
|
)
|
||||||
print(f" Total encounter entries: {total_enc}")
|
print(f" Total encounter entries: {total_enc}")
|
||||||
|
|
||||||
return routes
|
return routes
|
||||||
|
|||||||
@@ -66,20 +66,54 @@ async def upsert_routes(
|
|||||||
game_id: int,
|
game_id: int,
|
||||||
routes: list[dict],
|
routes: list[dict],
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
"""Upsert route records for a game, return {name: id} mapping."""
|
"""Upsert route records for a game, return {name: id} mapping.
|
||||||
|
|
||||||
|
Handles hierarchical routes: routes with 'children' are parent routes,
|
||||||
|
and their children get parent_route_id set accordingly.
|
||||||
|
"""
|
||||||
|
# First pass: upsert all parent routes (without parent_route_id)
|
||||||
for route in routes:
|
for route in routes:
|
||||||
stmt = insert(Route).values(
|
stmt = insert(Route).values(
|
||||||
name=route["name"],
|
name=route["name"],
|
||||||
game_id=game_id,
|
game_id=game_id,
|
||||||
order=route["order"],
|
order=route["order"],
|
||||||
|
parent_route_id=None, # Parent routes have no parent
|
||||||
).on_conflict_do_update(
|
).on_conflict_do_update(
|
||||||
constraint="uq_routes_game_name",
|
constraint="uq_routes_game_name",
|
||||||
set_={"order": route["order"]},
|
set_={"order": route["order"], "parent_route_id": None},
|
||||||
)
|
)
|
||||||
await session.execute(stmt)
|
await session.execute(stmt)
|
||||||
|
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
|
# Get mapping of parent routes
|
||||||
|
result = await session.execute(
|
||||||
|
select(Route.name, Route.id).where(Route.game_id == game_id)
|
||||||
|
)
|
||||||
|
name_to_id = {row.name: row.id for row in result}
|
||||||
|
|
||||||
|
# Second pass: upsert child routes with parent_route_id
|
||||||
|
for route in routes:
|
||||||
|
children = route.get("children", [])
|
||||||
|
if not children:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parent_id = name_to_id[route["name"]]
|
||||||
|
for child in children:
|
||||||
|
stmt = insert(Route).values(
|
||||||
|
name=child["name"],
|
||||||
|
game_id=game_id,
|
||||||
|
order=child["order"],
|
||||||
|
parent_route_id=parent_id,
|
||||||
|
).on_conflict_do_update(
|
||||||
|
constraint="uq_routes_game_name",
|
||||||
|
set_={"order": child["order"], "parent_route_id": parent_id},
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
# Return full mapping including children
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Route.name, Route.id).where(Route.game_id == game_id)
|
select(Route.name, Route.id).where(Route.game_id == game_id)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -66,10 +66,24 @@ async def seed():
|
|||||||
print(f" Warning: route '{route['name']}' not found")
|
print(f" Warning: route '{route['name']}' not found")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
enc_count = await upsert_route_encounters(
|
# Parent routes may have empty encounters
|
||||||
session, route_id, route["encounters"], dex_to_id
|
if route["encounters"]:
|
||||||
)
|
enc_count = await upsert_route_encounters(
|
||||||
total_encounters += enc_count
|
session, route_id, route["encounters"], dex_to_id
|
||||||
|
)
|
||||||
|
total_encounters += enc_count
|
||||||
|
|
||||||
|
# Handle child routes
|
||||||
|
for child in route.get("children", []):
|
||||||
|
child_id = route_map.get(child["name"])
|
||||||
|
if child_id is None:
|
||||||
|
print(f" Warning: child route '{child['name']}' not found")
|
||||||
|
continue
|
||||||
|
|
||||||
|
enc_count = await upsert_route_encounters(
|
||||||
|
session, child_id, child["encounters"], dex_to_id
|
||||||
|
)
|
||||||
|
total_encounters += enc_count
|
||||||
|
|
||||||
print(f" {game_slug}: {len(route_map)} routes")
|
print(f" {game_slug}: {len(route_map)} routes")
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ export function getGame(id: number): Promise<GameDetail> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getGameRoutes(gameId: number): Promise<Route[]> {
|
export function getGameRoutes(gameId: number): Promise<Route[]> {
|
||||||
return api.get(`/games/${gameId}/routes`)
|
// Use flat=true to get all routes in a flat list
|
||||||
|
// The frontend organizes them into hierarchy based on parentRouteId
|
||||||
|
return api.get(`/games/${gameId}/routes?flat=true`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRoutePokemon(routeId: number): Promise<RouteEncounterDetail[]> {
|
export function getRoutePokemon(routeId: number): Promise<RouteEncounterDetail[]> {
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { useRun } from '../hooks/useRuns'
|
import { useRun } 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 { EncounterModal } from '../components'
|
import { EncounterModal } from '../components'
|
||||||
import type { Route, EncounterDetail, EncounterStatus } from '../types'
|
import type {
|
||||||
|
Route,
|
||||||
|
RouteWithChildren,
|
||||||
|
EncounterDetail,
|
||||||
|
EncounterStatus,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
type RouteStatus = 'caught' | 'fainted' | 'missed' | 'none'
|
type RouteStatus = 'caught' | 'fainted' | 'missed' | 'none'
|
||||||
|
|
||||||
@@ -35,6 +40,175 @@ const statusIndicator: Record<
|
|||||||
none: { dot: 'bg-gray-300 dark:bg-gray-600', label: '', bg: '' },
|
none: { dot: 'bg-gray-300 dark:bg-gray-600', label: '', bg: '' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organize flat routes into hierarchical structure.
|
||||||
|
* Routes with parentRouteId are grouped under their parent.
|
||||||
|
*/
|
||||||
|
function organizeRoutes(routes: Route[]): RouteWithChildren[] {
|
||||||
|
const childrenByParent = new Map<number, Route[]>()
|
||||||
|
const topLevel: Route[] = []
|
||||||
|
|
||||||
|
for (const route of routes) {
|
||||||
|
if (route.parentRouteId === null) {
|
||||||
|
topLevel.push(route)
|
||||||
|
} else {
|
||||||
|
const children = childrenByParent.get(route.parentRouteId) ?? []
|
||||||
|
children.push(route)
|
||||||
|
childrenByParent.set(route.parentRouteId, children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return topLevel.map((route) => ({
|
||||||
|
...route,
|
||||||
|
children: childrenByParent.get(route.id) ?? [],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if any child route in a group has an encounter.
|
||||||
|
* Returns the encounter if found, null otherwise.
|
||||||
|
*/
|
||||||
|
function getGroupEncounter(
|
||||||
|
group: RouteWithChildren,
|
||||||
|
encounterByRoute: Map<number, EncounterDetail>,
|
||||||
|
): EncounterDetail | null {
|
||||||
|
for (const child of group.children) {
|
||||||
|
const enc = encounterByRoute.get(child.id)
|
||||||
|
if (enc) return enc
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteGroupProps {
|
||||||
|
group: RouteWithChildren
|
||||||
|
encounterByRoute: Map<number, EncounterDetail>
|
||||||
|
isExpanded: boolean
|
||||||
|
onToggleExpand: () => void
|
||||||
|
onRouteClick: (route: Route) => void
|
||||||
|
filter: 'all' | RouteStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteGroup({
|
||||||
|
group,
|
||||||
|
encounterByRoute,
|
||||||
|
isExpanded,
|
||||||
|
onToggleExpand,
|
||||||
|
onRouteClick,
|
||||||
|
filter,
|
||||||
|
}: RouteGroupProps) {
|
||||||
|
const groupEncounter = getGroupEncounter(group, encounterByRoute)
|
||||||
|
const groupStatus = groupEncounter ? groupEncounter.status : 'none'
|
||||||
|
const si = statusIndicator[groupStatus]
|
||||||
|
|
||||||
|
// For groups, check if it matches the filter
|
||||||
|
if (filter !== 'all' && groupStatus !== filter) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasGroupEncounter = groupEncounter !== null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
|
{/* Group header */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleExpand}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-700/50 ${si.bg}`}
|
||||||
|
>
|
||||||
|
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||||
|
{group.name}
|
||||||
|
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
({group.children.length} areas)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{groupEncounter && (
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
{groupEncounter.pokemon.spriteUrl && (
|
||||||
|
<img
|
||||||
|
src={groupEncounter.pokemon.spriteUrl}
|
||||||
|
alt={groupEncounter.pokemon.name}
|
||||||
|
className="w-5 h-5"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 capitalize">
|
||||||
|
{groupEncounter.nickname ?? groupEncounter.pokemon.name}
|
||||||
|
{groupEncounter.status === 'caught' &&
|
||||||
|
groupEncounter.faintLevel !== null &&
|
||||||
|
(groupEncounter.deathCause
|
||||||
|
? ` — ${groupEncounter.deathCause}`
|
||||||
|
: ' (dead)')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
|
||||||
|
{si.label}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 9l-7 7-7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expanded children */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/50">
|
||||||
|
{group.children.map((child) => {
|
||||||
|
const childEncounter = encounterByRoute.get(child.id)
|
||||||
|
const childStatus = getRouteStatus(childEncounter)
|
||||||
|
const childSi = statusIndicator[childStatus]
|
||||||
|
const isDisabled = hasGroupEncounter && !childEncounter
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={child.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => !isDisabled && onRouteClick(child)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-2 pl-8 text-left transition-colors ${
|
||||||
|
isDisabled
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: 'hover:bg-gray-100 dark:hover:bg-gray-700/50'
|
||||||
|
} ${childSi.bg}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-2 h-2 rounded-full shrink-0 ${childSi.dot}`}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{child.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{childEncounter && (
|
||||||
|
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{childSi.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isDisabled && (
|
||||||
|
<span className="text-xs text-gray-400 dark:text-gray-500 italic">
|
||||||
|
(locked)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function RunEncounters() {
|
export function RunEncounters() {
|
||||||
const { runId } = useParams<{ runId: string }>()
|
const { runId } = useParams<{ runId: string }>()
|
||||||
const runIdNum = Number(runId)
|
const runIdNum = Number(runId)
|
||||||
@@ -49,6 +223,13 @@ export function RunEncounters() {
|
|||||||
const [editingEncounter, setEditingEncounter] =
|
const [editingEncounter, setEditingEncounter] =
|
||||||
useState<EncounterDetail | null>(null)
|
useState<EncounterDetail | null>(null)
|
||||||
const [filter, setFilter] = useState<'all' | RouteStatus>('all')
|
const [filter, setFilter] = useState<'all' | RouteStatus>('all')
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set())
|
||||||
|
|
||||||
|
// Organize routes into hierarchical structure
|
||||||
|
const organizedRoutes = useMemo(() => {
|
||||||
|
if (!routes) return []
|
||||||
|
return organizeRoutes(routes)
|
||||||
|
}, [routes])
|
||||||
|
|
||||||
if (isLoading || loadingRoutes) {
|
if (isLoading || loadingRoutes) {
|
||||||
return (
|
return (
|
||||||
@@ -80,19 +261,29 @@ export function RunEncounters() {
|
|||||||
encounterByRoute.set(enc.routeId, enc)
|
encounterByRoute.set(enc.routeId, enc)
|
||||||
}
|
}
|
||||||
|
|
||||||
const allRoutes = routes ?? []
|
// Count completed locations (groups count as 1, standalone routes count as 1)
|
||||||
const completedCount = allRoutes.filter((r) =>
|
const completedCount = organizedRoutes.filter((r) => {
|
||||||
encounterByRoute.has(r.id),
|
if (r.children.length > 0) {
|
||||||
).length
|
// It's a group - check if any child has an encounter
|
||||||
|
return getGroupEncounter(r, encounterByRoute) !== null
|
||||||
|
}
|
||||||
|
// Standalone route
|
||||||
|
return encounterByRoute.has(r.id)
|
||||||
|
}).length
|
||||||
|
|
||||||
// Filter routes
|
const totalLocations = organizedRoutes.length
|
||||||
const filteredRoutes =
|
|
||||||
filter === 'all'
|
const toggleGroup = (groupId: number) => {
|
||||||
? allRoutes
|
setExpandedGroups((prev) => {
|
||||||
: allRoutes.filter((r) => {
|
const next = new Set(prev)
|
||||||
const enc = encounterByRoute.get(r.id)
|
if (next.has(groupId)) {
|
||||||
return getRouteStatus(enc) === filter
|
next.delete(groupId)
|
||||||
})
|
} else {
|
||||||
|
next.add(groupId)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleRouteClick = (route: Route) => {
|
const handleRouteClick = (route: Route) => {
|
||||||
const existing = encounterByRoute.get(route.id)
|
const existing = encounterByRoute.get(route.id)
|
||||||
@@ -136,6 +327,21 @@ export function RunEncounters() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter routes
|
||||||
|
const filteredRoutes = organizedRoutes.filter((r) => {
|
||||||
|
if (filter === 'all') return true
|
||||||
|
|
||||||
|
if (r.children.length > 0) {
|
||||||
|
// It's a group
|
||||||
|
const groupEnc = getGroupEncounter(r, encounterByRoute)
|
||||||
|
return getRouteStatus(groupEnc ?? undefined) === filter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standalone route
|
||||||
|
const enc = encounterByRoute.get(r.id)
|
||||||
|
return getRouteStatus(enc) === filter
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto p-8">
|
<div className="max-w-4xl mx-auto p-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -150,7 +356,7 @@ export function RunEncounters() {
|
|||||||
Encounters
|
Encounters
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
{run.game.name} · {completedCount} / {allRoutes.length} routes
|
{run.game.name} · {completedCount} / {totalLocations} locations
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -160,7 +366,7 @@ export function RunEncounters() {
|
|||||||
<div
|
<div
|
||||||
className="h-full bg-blue-500 rounded-full transition-all"
|
className="h-full bg-blue-500 rounded-full transition-all"
|
||||||
style={{
|
style={{
|
||||||
width: `${allRoutes.length > 0 ? (completedCount / allRoutes.length) * 100 : 0}%`,
|
width: `${totalLocations > 0 ? (completedCount / totalLocations) * 100 : 0}%`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,6 +405,22 @@ export function RunEncounters() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{filteredRoutes.map((route) => {
|
{filteredRoutes.map((route) => {
|
||||||
|
// Render as group if it has children
|
||||||
|
if (route.children.length > 0) {
|
||||||
|
return (
|
||||||
|
<RouteGroup
|
||||||
|
key={route.id}
|
||||||
|
group={route}
|
||||||
|
encounterByRoute={encounterByRoute}
|
||||||
|
isExpanded={expandedGroups.has(route.id)}
|
||||||
|
onToggleExpand={() => toggleGroup(route.id)}
|
||||||
|
onRouteClick={handleRouteClick}
|
||||||
|
filter={filter}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standalone route (no children)
|
||||||
const encounter = encounterByRoute.get(route.id)
|
const encounter = encounterByRoute.get(route.id)
|
||||||
const rs = getRouteStatus(encounter)
|
const rs = getRouteStatus(encounter)
|
||||||
const si = statusIndicator[rs]
|
const si = statusIndicator[rs]
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ export interface Route {
|
|||||||
name: string
|
name: string
|
||||||
gameId: number
|
gameId: number
|
||||||
order: number
|
order: number
|
||||||
|
parentRouteId: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteWithChildren extends Route {
|
||||||
|
children: Route[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Pokemon {
|
export interface Pokemon {
|
||||||
|
|||||||
Reference in New Issue
Block a user