Add encounter condition support with rate display
Add a `condition` column to RouteEncounter for time-of-day, weather, and season variants. Seed loader expands `conditions` dict into per-condition rows. EncounterModal shows condition filter tabs with per-condition encounter rates, and displays rates for all standard encounter methods (walk, surf, fishing, etc.). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-oqfo
|
# nuzlocke-tracker-oqfo
|
||||||
title: Improve encounter rate display for time/weather variants
|
title: Improve encounter rate display for time/weather variants
|
||||||
status: todo
|
status: in-progress
|
||||||
type: feature
|
type: feature
|
||||||
priority: normal
|
priority: normal
|
||||||
created_at: 2026-02-10T14:04:27Z
|
created_at: 2026-02-10T14:04:27Z
|
||||||
updated_at: 2026-02-14T21:17:00Z
|
updated_at: 2026-02-14T21:39:34Z
|
||||||
---
|
---
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
@@ -25,59 +25,18 @@ Extend the data model and UI to support conditional encounter rates, so users ca
|
|||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
### Seed data format
|
Added a nullable `condition` column to `RouteEncounter` with a functional unique index using `COALESCE(condition, '')` to handle NULL uniqueness. Seed JSON supports an optional `conditions` dict that expands into per-condition DB rows.
|
||||||
|
|
||||||
Add an optional `conditions` field to encounter entries. When absent, the encounter has a flat rate (Gen 1/3/6 — no change needed). When present, it replaces `encounter_rate` with per-condition rates:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"pokeapi_id": 163,
|
|
||||||
"pokemon_name": "Hoothoot",
|
|
||||||
"method": "walk",
|
|
||||||
"encounter_rate": null,
|
|
||||||
"conditions": {
|
|
||||||
"night": 50,
|
|
||||||
"morning": 10,
|
|
||||||
"day": 0
|
|
||||||
},
|
|
||||||
"min_level": 2,
|
|
||||||
"max_level": 5
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
For games without variant rates, the existing flat `encounter_rate` field is used unchanged.
|
|
||||||
|
|
||||||
### Backend changes
|
|
||||||
|
|
||||||
1. **New model `RouteEncounterCondition`** (one-to-many from `RouteEncounter`):
|
|
||||||
- `id`, `route_encounter_id` (FK), `condition` (string), `encounter_rate` (int)
|
|
||||||
- Conditions are free-form strings: `"morning"`, `"day"`, `"night"`, `"rain"`, `"spring"`, etc.
|
|
||||||
2. **`RouteEncounter` model**: keep `encounter_rate` as nullable — null when conditions exist, populated when flat.
|
|
||||||
3. **Seed loader**: detect `conditions` key in JSON, create `RouteEncounterCondition` rows accordingly.
|
|
||||||
4. **API**: include conditions in route encounter responses (nested array under each encounter).
|
|
||||||
|
|
||||||
### Frontend changes
|
|
||||||
|
|
||||||
1. **AdminRouteDetail**: show conditions as sub-rows or a tooltip when hovering the rate column.
|
|
||||||
2. **EncounterModal**: group by condition context when relevant (e.g. tabs for morning/day/night).
|
|
||||||
3. **Type updates**: extend `RouteEncounter` type with optional `conditions: { condition: string, encounterRate: number }[]`.
|
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
- [ ] Update seed JSON schema: add optional `conditions` field to encounter entries
|
- [x] Update seed JSON schema: add optional `conditions` field to encounter entries
|
||||||
- [ ] Create `RouteEncounterCondition` model with migration
|
- [x] Add `condition` column to `RouteEncounter` model with migration
|
||||||
- [ ] Make `RouteEncounter.encounter_rate` nullable
|
- [x] Update seed loader to handle `conditions` entries (expands dict into rows)
|
||||||
- [ ] Update seed loader to handle `conditions` entries
|
- [x] Update API serialization to include `condition` field
|
||||||
- [ ] Update API serialization to include conditions
|
- [x] Update export endpoint to include `condition` field
|
||||||
- [ ] Update frontend types (`RouteEncounter`)
|
- [x] Update frontend types (`RouteEncounter`, admin input types)
|
||||||
- [ ] Update AdminRouteDetail to display condition-based rates
|
- [x] Update AdminRouteDetail to display condition column (shown only when conditions exist)
|
||||||
- [ ] Update EncounterModal to show conditions contextually
|
- [x] Update EncounterModal to show conditions contextually
|
||||||
- [ ] Update seed data for at least one game per variant type (HG/SS, B/W, Sw/Sh) as proof of concept
|
- [x] Update seed data for HeartGold Route 29 as proof of concept (morning/day/night)
|
||||||
- [ ] Keep simple display for games with flat rates (no regression)
|
- [x] Keep simple display for games with flat rates (no regression)
|
||||||
|
- [ ] Update seed data for remaining games with variant encounters (incremental)
|
||||||
## Considerations
|
|
||||||
|
|
||||||
- For Nuzlocke play, availability ("appears during rain") matters more than exact percentages — consider a simplified view option
|
|
||||||
- Keep UI uncluttered for simple cases (Gen 1/3/6)
|
|
||||||
- Condition strings should use a consistent vocabulary (define an enum or reference list)
|
|
||||||
- Seed data updates for all games can be done incrementally after the infrastructure is in place
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""add condition to route encounters
|
||||||
|
|
||||||
|
Revision ID: h9c0d1e2f3a4
|
||||||
|
Revises: g8b9c0d1e2f3
|
||||||
|
Create Date: 2026-02-14 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "h9c0d1e2f3a4"
|
||||||
|
down_revision: str | Sequence[str] | None = "g8b9c0d1e2f3"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"route_encounters",
|
||||||
|
sa.Column("condition", sa.String(30), nullable=False, server_default=""),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_constraint(
|
||||||
|
"uq_route_pokemon_method_game", "route_encounters", type_="unique"
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_route_pokemon_method_game_condition",
|
||||||
|
"route_encounters",
|
||||||
|
["route_id", "pokemon_id", "encounter_method", "game_id", "condition"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint(
|
||||||
|
"uq_route_pokemon_method_game_condition",
|
||||||
|
"route_encounters",
|
||||||
|
type_="unique",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_route_pokemon_method_game",
|
||||||
|
"route_encounters",
|
||||||
|
["route_id", "pokemon_id", "encounter_method", "game_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_column("route_encounters", "condition")
|
||||||
@@ -69,8 +69,9 @@ async def export_game_routes(
|
|||||||
game_encounters = [
|
game_encounters = [
|
||||||
enc for enc in route.route_encounters if enc.game_id == game_id
|
enc for enc in route.route_encounters if enc.game_id == game_id
|
||||||
]
|
]
|
||||||
return [
|
result = []
|
||||||
{
|
for enc in sorted(game_encounters, key=lambda e: -e.encounter_rate):
|
||||||
|
entry: dict = {
|
||||||
"pokeapi_id": enc.pokemon.pokeapi_id,
|
"pokeapi_id": enc.pokemon.pokeapi_id,
|
||||||
"pokemon_name": enc.pokemon.name,
|
"pokemon_name": enc.pokemon.name,
|
||||||
"method": enc.encounter_method,
|
"method": enc.encounter_method,
|
||||||
@@ -78,8 +79,10 @@ async def export_game_routes(
|
|||||||
"min_level": enc.min_level,
|
"min_level": enc.min_level,
|
||||||
"max_level": enc.max_level,
|
"max_level": enc.max_level,
|
||||||
}
|
}
|
||||||
for enc in sorted(game_encounters, key=lambda e: -e.encounter_rate)
|
if enc.condition:
|
||||||
]
|
entry["condition"] = enc.condition
|
||||||
|
result.append(entry)
|
||||||
|
return result
|
||||||
|
|
||||||
def format_route(route: Route) -> dict:
|
def format_route(route: Route) -> dict:
|
||||||
data: dict = {
|
data: dict = {
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ async def get_pokemon_encounter_locations(
|
|||||||
route_name=enc.route.name,
|
route_name=enc.route.name,
|
||||||
encounter_method=enc.encounter_method,
|
encounter_method=enc.encounter_method,
|
||||||
encounter_rate=enc.encounter_rate,
|
encounter_rate=enc.encounter_rate,
|
||||||
|
condition=enc.condition,
|
||||||
min_level=enc.min_level,
|
min_level=enc.min_level,
|
||||||
max_level=enc.max_level,
|
max_level=enc.max_level,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ class RouteEncounter(Base):
|
|||||||
"pokemon_id",
|
"pokemon_id",
|
||||||
"encounter_method",
|
"encounter_method",
|
||||||
"game_id",
|
"game_id",
|
||||||
name="uq_route_pokemon_method_game",
|
"condition",
|
||||||
|
name="uq_route_pokemon_method_game_condition",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ class RouteEncounter(Base):
|
|||||||
game_id: Mapped[int] = mapped_column(ForeignKey("games.id"), index=True)
|
game_id: Mapped[int] = mapped_column(ForeignKey("games.id"), index=True)
|
||||||
encounter_method: Mapped[str] = mapped_column(String(30))
|
encounter_method: Mapped[str] = mapped_column(String(30))
|
||||||
encounter_rate: Mapped[int] = mapped_column(SmallInteger)
|
encounter_rate: Mapped[int] = mapped_column(SmallInteger)
|
||||||
|
condition: Mapped[str] = mapped_column(String(30), default="", server_default="")
|
||||||
min_level: Mapped[int] = mapped_column(SmallInteger)
|
min_level: Mapped[int] = mapped_column(SmallInteger)
|
||||||
max_level: Mapped[int] = mapped_column(SmallInteger)
|
max_level: Mapped[int] = mapped_column(SmallInteger)
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class RouteEncounterResponse(CamelModel):
|
|||||||
game_id: int
|
game_id: int
|
||||||
encounter_method: str
|
encounter_method: str
|
||||||
encounter_rate: int
|
encounter_rate: int
|
||||||
|
condition: str = ""
|
||||||
min_level: int
|
min_level: int
|
||||||
max_level: int
|
max_level: int
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ class PokemonEncounterLocationItem(CamelModel):
|
|||||||
route_name: str
|
route_name: str
|
||||||
encounter_method: str
|
encounter_method: str
|
||||||
encounter_rate: int
|
encounter_rate: int
|
||||||
|
condition: str = ""
|
||||||
min_level: int
|
min_level: int
|
||||||
max_level: int
|
max_level: int
|
||||||
|
|
||||||
@@ -89,6 +91,7 @@ class RouteEncounterCreate(CamelModel):
|
|||||||
game_id: int
|
game_id: int
|
||||||
encounter_method: str
|
encounter_method: str
|
||||||
encounter_rate: int
|
encounter_rate: int
|
||||||
|
condition: str = ""
|
||||||
min_level: int
|
min_level: int
|
||||||
max_level: int
|
max_level: int
|
||||||
|
|
||||||
@@ -96,6 +99,7 @@ class RouteEncounterCreate(CamelModel):
|
|||||||
class RouteEncounterUpdate(CamelModel):
|
class RouteEncounterUpdate(CamelModel):
|
||||||
encounter_method: str | None = None
|
encounter_method: str | None = None
|
||||||
encounter_rate: int | None = None
|
encounter_rate: int | None = None
|
||||||
|
condition: str | None = None
|
||||||
min_level: int | None = None
|
min_level: int | None = None
|
||||||
max_level: int | None = None
|
max_level: int | None = None
|
||||||
|
|
||||||
@@ -178,6 +182,7 @@ class BulkRouteEncounterItem(BaseModel):
|
|||||||
pokeapi_id: int
|
pokeapi_id: int
|
||||||
method: str
|
method: str
|
||||||
encounter_rate: int
|
encounter_rate: int
|
||||||
|
condition: str = ""
|
||||||
min_level: int
|
min_level: int
|
||||||
max_level: int
|
max_level: int
|
||||||
|
|
||||||
|
|||||||
@@ -169,11 +169,19 @@
|
|||||||
"min_level": 2,
|
"min_level": 2,
|
||||||
"max_level": 5
|
"max_level": 5
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"pokeapi_id": 16,
|
||||||
|
"pokemon_name": "Pidgey",
|
||||||
|
"method": "walk",
|
||||||
|
"encounter_rate": 55,
|
||||||
|
"min_level": 2,
|
||||||
|
"max_level": 4
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"pokeapi_id": 163,
|
"pokeapi_id": 163,
|
||||||
"pokemon_name": "Hoothoot",
|
"pokemon_name": "Hoothoot",
|
||||||
"method": "walk",
|
"method": "walk",
|
||||||
"encounter_rate": 85,
|
"encounter_rate": 50,
|
||||||
"min_level": 2,
|
"min_level": 2,
|
||||||
"max_level": 4
|
"max_level": 4
|
||||||
},
|
},
|
||||||
@@ -181,10 +189,18 @@
|
|||||||
"pokeapi_id": 16,
|
"pokeapi_id": 16,
|
||||||
"pokemon_name": "Pidgey",
|
"pokemon_name": "Pidgey",
|
||||||
"method": "walk",
|
"method": "walk",
|
||||||
"encounter_rate": 55,
|
"encounter_rate": 50,
|
||||||
"min_level": 2,
|
"min_level": 2,
|
||||||
"max_level": 4
|
"max_level": 4
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"pokeapi_id": 161,
|
||||||
|
"pokemon_name": "Sentret",
|
||||||
|
"method": "walk",
|
||||||
|
"encounter_rate": 50,
|
||||||
|
"min_level": 2,
|
||||||
|
"max_level": 3
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"pokeapi_id": 102,
|
"pokeapi_id": 102,
|
||||||
"pokemon_name": "Exeggcute",
|
"pokemon_name": "Exeggcute",
|
||||||
@@ -209,6 +225,14 @@
|
|||||||
"min_level": 2,
|
"min_level": 2,
|
||||||
"max_level": 3
|
"max_level": 3
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"pokeapi_id": 19,
|
||||||
|
"pokemon_name": "Rattata",
|
||||||
|
"method": "walk",
|
||||||
|
"encounter_rate": 40,
|
||||||
|
"min_level": 2,
|
||||||
|
"max_level": 4
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"pokeapi_id": 204,
|
"pokeapi_id": 204,
|
||||||
"pokemon_name": "Pineco",
|
"pokemon_name": "Pineco",
|
||||||
@@ -241,11 +265,51 @@
|
|||||||
"min_level": 3,
|
"min_level": 3,
|
||||||
"max_level": 3
|
"max_level": 3
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"pokeapi_id": 163,
|
||||||
|
"pokemon_name": "Hoothoot",
|
||||||
|
"method": "walk",
|
||||||
|
"encounter_rate": 10,
|
||||||
|
"min_level": 2,
|
||||||
|
"max_level": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pokeapi_id": 163,
|
||||||
|
"pokemon_name": "Hoothoot",
|
||||||
|
"method": "walk",
|
||||||
|
"encounter_rate": 0,
|
||||||
|
"min_level": 2,
|
||||||
|
"max_level": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pokeapi_id": 16,
|
||||||
|
"pokemon_name": "Pidgey",
|
||||||
|
"method": "walk",
|
||||||
|
"encounter_rate": 0,
|
||||||
|
"min_level": 2,
|
||||||
|
"max_level": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pokeapi_id": 161,
|
||||||
|
"pokemon_name": "Sentret",
|
||||||
|
"method": "walk",
|
||||||
|
"encounter_rate": 0,
|
||||||
|
"min_level": 2,
|
||||||
|
"max_level": 3
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"pokeapi_id": 19,
|
"pokeapi_id": 19,
|
||||||
"pokemon_name": "Rattata",
|
"pokemon_name": "Rattata",
|
||||||
"method": "walk",
|
"method": "walk",
|
||||||
"encounter_rate": 20,
|
"encounter_rate": 0,
|
||||||
|
"min_level": 2,
|
||||||
|
"max_level": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pokeapi_id": 19,
|
||||||
|
"pokemon_name": "Rattata",
|
||||||
|
"method": "walk",
|
||||||
|
"encounter_rate": 0,
|
||||||
"min_level": 2,
|
"min_level": 2,
|
||||||
"max_level": 4
|
"max_level": 4
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,6 +192,41 @@ async def upsert_routes(
|
|||||||
return {row.name: row.id for row in result}
|
return {row.name: row.id for row in result}
|
||||||
|
|
||||||
|
|
||||||
|
async def _upsert_single_encounter(
|
||||||
|
session: AsyncSession,
|
||||||
|
route_id: int,
|
||||||
|
pokemon_id: int,
|
||||||
|
game_id: int,
|
||||||
|
method: str,
|
||||||
|
encounter_rate: int,
|
||||||
|
min_level: int,
|
||||||
|
max_level: int,
|
||||||
|
condition: str = "",
|
||||||
|
) -> None:
|
||||||
|
stmt = (
|
||||||
|
insert(RouteEncounter)
|
||||||
|
.values(
|
||||||
|
route_id=route_id,
|
||||||
|
pokemon_id=pokemon_id,
|
||||||
|
game_id=game_id,
|
||||||
|
encounter_method=method,
|
||||||
|
encounter_rate=encounter_rate,
|
||||||
|
condition=condition,
|
||||||
|
min_level=min_level,
|
||||||
|
max_level=max_level,
|
||||||
|
)
|
||||||
|
.on_conflict_do_update(
|
||||||
|
constraint="uq_route_pokemon_method_game_condition",
|
||||||
|
set_={
|
||||||
|
"encounter_rate": encounter_rate,
|
||||||
|
"min_level": min_level,
|
||||||
|
"max_level": max_level,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
|
||||||
|
|
||||||
async def upsert_route_encounters(
|
async def upsert_route_encounters(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
route_id: int,
|
route_id: int,
|
||||||
@@ -207,27 +242,32 @@ async def upsert_route_encounters(
|
|||||||
print(f" Warning: no pokemon_id for pokeapi_id {enc['pokeapi_id']}")
|
print(f" Warning: no pokemon_id for pokeapi_id {enc['pokeapi_id']}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
stmt = (
|
conditions = enc.get("conditions")
|
||||||
insert(RouteEncounter)
|
if conditions:
|
||||||
.values(
|
for condition_name, rate in conditions.items():
|
||||||
route_id=route_id,
|
await _upsert_single_encounter(
|
||||||
pokemon_id=pokemon_id,
|
session,
|
||||||
game_id=game_id,
|
route_id,
|
||||||
encounter_method=enc["method"],
|
pokemon_id,
|
||||||
encounter_rate=enc["encounter_rate"],
|
game_id,
|
||||||
min_level=enc["min_level"],
|
enc["method"],
|
||||||
max_level=enc["max_level"],
|
rate,
|
||||||
|
enc["min_level"],
|
||||||
|
enc["max_level"],
|
||||||
|
condition=condition_name,
|
||||||
)
|
)
|
||||||
.on_conflict_do_update(
|
count += 1
|
||||||
constraint="uq_route_pokemon_method_game",
|
else:
|
||||||
set_={
|
await _upsert_single_encounter(
|
||||||
"encounter_rate": enc["encounter_rate"],
|
session,
|
||||||
"min_level": enc["min_level"],
|
route_id,
|
||||||
"max_level": enc["max_level"],
|
pokemon_id,
|
||||||
},
|
game_id,
|
||||||
|
enc["method"],
|
||||||
|
enc["encounter_rate"],
|
||||||
|
enc["min_level"],
|
||||||
|
enc["max_level"],
|
||||||
)
|
)
|
||||||
)
|
|
||||||
await session.execute(stmt)
|
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
return count
|
return count
|
||||||
|
|||||||
@@ -69,14 +69,90 @@ const statusOptions: {
|
|||||||
|
|
||||||
const SPECIAL_METHODS = ['starter', 'gift', 'fossil', 'trade']
|
const SPECIAL_METHODS = ['starter', 'gift', 'fossil', 'trade']
|
||||||
|
|
||||||
function groupByMethod(
|
interface GroupedEncounter {
|
||||||
pokemon: RouteEncounterDetail[]
|
encounter: RouteEncounterDetail
|
||||||
): { method: string; pokemon: RouteEncounterDetail[] }[] {
|
conditions: string[]
|
||||||
const groups = new Map<string, RouteEncounterDetail[]>()
|
displayRate: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUniqueConditions(pokemon: RouteEncounterDetail[]): string[] {
|
||||||
|
const conditions = new Set<string>()
|
||||||
for (const rp of pokemon) {
|
for (const rp of pokemon) {
|
||||||
const list = groups.get(rp.encounterMethod) ?? []
|
if (rp.condition) conditions.add(rp.condition)
|
||||||
list.push(rp)
|
}
|
||||||
groups.set(rp.encounterMethod, list)
|
return [...conditions].sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByMethod(
|
||||||
|
pokemon: RouteEncounterDetail[],
|
||||||
|
selectedCondition: string | null
|
||||||
|
): { method: string; pokemon: GroupedEncounter[] }[] {
|
||||||
|
const groups = new Map<string, Map<number, GroupedEncounter>>()
|
||||||
|
|
||||||
|
// Build a lookup: pokemonId+method -> condition -> rate
|
||||||
|
const rateByCondition = new Map<string, Map<string, number>>()
|
||||||
|
for (const rp of pokemon) {
|
||||||
|
if (rp.condition) {
|
||||||
|
const key = `${rp.pokemonId}:${rp.encounterMethod}`
|
||||||
|
let condMap = rateByCondition.get(key)
|
||||||
|
if (!condMap) {
|
||||||
|
condMap = new Map()
|
||||||
|
rateByCondition.set(key, condMap)
|
||||||
|
}
|
||||||
|
condMap.set(rp.condition, rp.encounterRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rp of pokemon) {
|
||||||
|
// When a specific condition is selected, skip pokemon with 0% under that condition
|
||||||
|
if (selectedCondition) {
|
||||||
|
const key = `${rp.pokemonId}:${rp.encounterMethod}`
|
||||||
|
const condMap = rateByCondition.get(key)
|
||||||
|
if (condMap) {
|
||||||
|
const rate = condMap.get(selectedCondition)
|
||||||
|
if (rate === 0) continue
|
||||||
|
// Skip entries for other conditions (we only want one entry per pokemon)
|
||||||
|
if (rp.condition && rp.condition !== selectedCondition) continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// "All" mode: skip 0% entries
|
||||||
|
if (rp.encounterRate === 0 && rp.condition) continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let methodGroup = groups.get(rp.encounterMethod)
|
||||||
|
if (!methodGroup) {
|
||||||
|
methodGroup = new Map()
|
||||||
|
groups.set(rp.encounterMethod, methodGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = methodGroup.get(rp.pokemonId)
|
||||||
|
if (existing) {
|
||||||
|
if (rp.condition) existing.conditions.push(rp.condition)
|
||||||
|
} else {
|
||||||
|
// Determine the display rate
|
||||||
|
let displayRate: number | null = null
|
||||||
|
const isSpecial = SPECIAL_METHODS.includes(rp.encounterMethod)
|
||||||
|
if (!isSpecial) {
|
||||||
|
if (selectedCondition) {
|
||||||
|
const key = `${rp.pokemonId}:${rp.encounterMethod}`
|
||||||
|
const condMap = rateByCondition.get(key)
|
||||||
|
if (condMap) {
|
||||||
|
displayRate = condMap.get(selectedCondition) ?? null
|
||||||
|
} else {
|
||||||
|
displayRate = rp.encounterRate
|
||||||
|
}
|
||||||
|
} else if (!rp.condition) {
|
||||||
|
// "All" mode: show the base rate for non-condition entries
|
||||||
|
displayRate = rp.encounterRate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
methodGroup.set(rp.pokemonId, {
|
||||||
|
encounter: rp,
|
||||||
|
conditions: rp.condition ? [rp.condition] : [],
|
||||||
|
displayRate,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return [...groups.entries()]
|
return [...groups.entries()]
|
||||||
.sort(([a], [b]) => {
|
.sort(([a], [b]) => {
|
||||||
@@ -84,16 +160,29 @@ function groupByMethod(
|
|||||||
const bi = METHOD_ORDER.indexOf(b)
|
const bi = METHOD_ORDER.indexOf(b)
|
||||||
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi)
|
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi)
|
||||||
})
|
})
|
||||||
.map(([method, pokemon]) => ({ method, pokemon }))
|
.map(([method, pokemonMap]) => ({
|
||||||
|
method,
|
||||||
|
pokemon: [...pokemonMap.values()].sort(
|
||||||
|
(a, b) => (b.displayRate ?? 0) - (a.displayRate ?? 0)
|
||||||
|
),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickRandomPokemon(
|
function pickRandomPokemon(
|
||||||
pokemon: RouteEncounterDetail[],
|
pokemon: RouteEncounterDetail[],
|
||||||
dupedIds?: Set<number>
|
dupedIds?: Set<number>
|
||||||
): RouteEncounterDetail | null {
|
): RouteEncounterDetail | null {
|
||||||
|
// Deduplicate by pokemonId (conditions may create multiple entries)
|
||||||
|
const seen = new Set<number>()
|
||||||
|
const unique = pokemon.filter((rp) => {
|
||||||
|
if (rp.encounterRate === 0) return false
|
||||||
|
if (seen.has(rp.pokemonId)) return false
|
||||||
|
seen.add(rp.pokemonId)
|
||||||
|
return true
|
||||||
|
})
|
||||||
const eligible = dupedIds
|
const eligible = dupedIds
|
||||||
? pokemon.filter((rp) => !dupedIds.has(rp.pokemonId))
|
? unique.filter((rp) => !dupedIds.has(rp.pokemonId))
|
||||||
: pokemon
|
: unique
|
||||||
if (eligible.length === 0) return null
|
if (eligible.length === 0) return null
|
||||||
return eligible[Math.floor(Math.random() * eligible.length)]
|
return eligible[Math.floor(Math.random() * eligible.length)]
|
||||||
}
|
}
|
||||||
@@ -129,6 +218,9 @@ export function EncounterModal({
|
|||||||
const [faintLevel, setFaintLevel] = useState<string>('')
|
const [faintLevel, setFaintLevel] = useState<string>('')
|
||||||
const [deathCause, setDeathCause] = useState('')
|
const [deathCause, setDeathCause] = useState('')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
|
const [selectedCondition, setSelectedCondition] = useState<string | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
const isEditing = !!existing
|
const isEditing = !!existing
|
||||||
|
|
||||||
@@ -151,13 +243,19 @@ export function EncounterModal({
|
|||||||
}
|
}
|
||||||
}, [existing, routePokemon])
|
}, [existing, routePokemon])
|
||||||
|
|
||||||
|
const availableConditions = useMemo(
|
||||||
|
() => (routePokemon ? getUniqueConditions(routePokemon) : []),
|
||||||
|
[routePokemon]
|
||||||
|
)
|
||||||
|
|
||||||
const filteredPokemon = routePokemon?.filter((rp) =>
|
const filteredPokemon = routePokemon?.filter((rp) =>
|
||||||
rp.pokemon.name.toLowerCase().includes(search.toLowerCase())
|
rp.pokemon.name.toLowerCase().includes(search.toLowerCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
const groupedPokemon = useMemo(
|
const groupedPokemon = useMemo(
|
||||||
() => (filteredPokemon ? groupByMethod(filteredPokemon) : []),
|
() =>
|
||||||
[filteredPokemon]
|
filteredPokemon ? groupByMethod(filteredPokemon, selectedCondition) : [],
|
||||||
|
[filteredPokemon, selectedCondition]
|
||||||
)
|
)
|
||||||
const hasMultipleGroups = groupedPokemon.length > 1
|
const hasMultipleGroups = groupedPokemon.length > 1
|
||||||
|
|
||||||
@@ -266,6 +364,35 @@ export function EncounterModal({
|
|||||||
className="w-full px-3 py-1.5 mb-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full px-3 py-1.5 mb-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{availableConditions.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mb-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedCondition(null)}
|
||||||
|
className={`px-2.5 py-1 text-xs font-medium rounded-full border transition-colors ${
|
||||||
|
selectedCondition === null
|
||||||
|
? 'bg-purple-100 border-purple-300 text-purple-800 dark:bg-purple-900/40 dark:border-purple-600 dark:text-purple-300'
|
||||||
|
: 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-purple-300 dark:hover:border-purple-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{availableConditions.map((cond) => (
|
||||||
|
<button
|
||||||
|
key={cond}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedCondition(cond)}
|
||||||
|
className={`px-2.5 py-1 text-xs font-medium rounded-full border transition-colors capitalize ${
|
||||||
|
selectedCondition === cond
|
||||||
|
? 'bg-purple-100 border-purple-300 text-purple-800 dark:bg-purple-900/40 dark:border-purple-600 dark:text-purple-300'
|
||||||
|
: 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-purple-300 dark:hover:border-purple-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cond}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="max-h-64 overflow-y-auto space-y-3">
|
<div className="max-h-64 overflow-y-auto space-y-3">
|
||||||
{groupedPokemon.map(({ method, pokemon }, groupIdx) => (
|
{groupedPokemon.map(({ method, pokemon }, groupIdx) => (
|
||||||
<div key={method}>
|
<div key={method}>
|
||||||
@@ -278,12 +405,17 @@ export function EncounterModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{pokemon.map((rp) => {
|
{pokemon.map(
|
||||||
|
({ encounter: rp, conditions, displayRate }) => {
|
||||||
const isDuped =
|
const isDuped =
|
||||||
dupedPokemonIds?.has(rp.pokemonId) ?? false
|
dupedPokemonIds?.has(rp.pokemonId) ?? false
|
||||||
|
const isSelected =
|
||||||
|
selectedPokemon?.pokemonId === rp.pokemonId &&
|
||||||
|
selectedPokemon?.encounterMethod ===
|
||||||
|
rp.encounterMethod
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={rp.id}
|
key={`${rp.encounterMethod}-${rp.pokemonId}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
!isDuped && setSelectedPokemon(rp)
|
!isDuped && setSelectedPokemon(rp)
|
||||||
@@ -292,7 +424,7 @@ export function EncounterModal({
|
|||||||
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
|
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
|
||||||
isDuped
|
isDuped
|
||||||
? 'opacity-40 cursor-not-allowed border-gray-200 dark:border-gray-700'
|
? 'opacity-40 cursor-not-allowed border-gray-200 dark:border-gray-700'
|
||||||
: selectedPokemon?.id === rp.id
|
: isSelected
|
||||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30'
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30'
|
||||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||||
}`}
|
}`}
|
||||||
@@ -326,6 +458,20 @@ export function EncounterModal({
|
|||||||
method={rp.encounterMethod}
|
method={rp.encounterMethod}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{!isDuped &&
|
||||||
|
displayRate !== null &&
|
||||||
|
displayRate !== undefined && (
|
||||||
|
<span className="text-[10px] text-purple-500 dark:text-purple-400 font-medium">
|
||||||
|
{displayRate}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!isDuped &&
|
||||||
|
selectedCondition === null &&
|
||||||
|
conditions.length > 0 && (
|
||||||
|
<span className="text-[10px] text-purple-500 dark:text-purple-400">
|
||||||
|
{conditions.join(', ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{!isDuped && (
|
{!isDuped && (
|
||||||
<span className="text-[10px] text-gray-400">
|
<span className="text-[10px] text-gray-400">
|
||||||
Lv. {rp.minLevel}
|
Lv. {rp.minLevel}
|
||||||
@@ -335,7 +481,8 @@ export function EncounterModal({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
}
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export function AdminRouteDetail() {
|
|||||||
? Math.max(...childRoutes.map((r) => r.order)) + 1
|
? Math.max(...childRoutes.map((r) => r.order)) + 1
|
||||||
: (route?.order ?? 0) * 10 + 1
|
: (route?.order ?? 0) * 10 + 1
|
||||||
|
|
||||||
|
const hasConditions = encounters.some((e) => e.condition !== '')
|
||||||
|
|
||||||
const columns: Column<RouteEncounterDetail>[] = [
|
const columns: Column<RouteEncounterDetail>[] = [
|
||||||
{
|
{
|
||||||
header: 'Pokemon',
|
header: 'Pokemon',
|
||||||
@@ -86,6 +88,14 @@ export function AdminRouteDetail() {
|
|||||||
},
|
},
|
||||||
{ header: 'Method', accessor: (e) => e.encounterMethod },
|
{ header: 'Method', accessor: (e) => e.encounterMethod },
|
||||||
{ header: 'Rate', accessor: (e) => `${e.encounterRate}%` },
|
{ header: 'Rate', accessor: (e) => `${e.encounterRate}%` },
|
||||||
|
...(hasConditions
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
header: 'Condition',
|
||||||
|
accessor: (e: RouteEncounterDetail) => e.condition || '\u2014',
|
||||||
|
} as Column<RouteEncounterDetail>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
header: 'Levels',
|
header: 'Levels',
|
||||||
accessor: (e) =>
|
accessor: (e) =>
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export interface CreateRouteEncounterInput {
|
|||||||
gameId: number
|
gameId: number
|
||||||
encounterMethod: string
|
encounterMethod: string
|
||||||
encounterRate: number
|
encounterRate: number
|
||||||
|
condition?: string
|
||||||
minLevel: number
|
minLevel: number
|
||||||
maxLevel: number
|
maxLevel: number
|
||||||
}
|
}
|
||||||
@@ -75,6 +76,7 @@ export interface CreateRouteEncounterInput {
|
|||||||
export interface UpdateRouteEncounterInput {
|
export interface UpdateRouteEncounterInput {
|
||||||
encounterMethod?: string
|
encounterMethod?: string
|
||||||
encounterRate?: number
|
encounterRate?: number
|
||||||
|
condition?: string
|
||||||
minLevel?: number
|
minLevel?: number
|
||||||
maxLevel?: number
|
maxLevel?: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export interface RouteEncounter {
|
|||||||
gameId: number
|
gameId: number
|
||||||
encounterMethod: string
|
encounterMethod: string
|
||||||
encounterRate: number
|
encounterRate: number
|
||||||
|
condition: string
|
||||||
minLevel: number
|
minLevel: number
|
||||||
maxLevel: number
|
maxLevel: number
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user