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,10 +1,11 @@
|
|||||||
---
|
---
|
||||||
# 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
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -12,8 +13,52 @@ Some game locations have multiple encounter tables (e.g. Mount Moon 1F, Mount Mo
|
|||||||
|
|
||||||
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):
|
||||||
|
|||||||
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
@@ -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