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:
2026-02-14 22:42:49 +01:00
parent a482b27bca
commit 9029f1632a
12 changed files with 436 additions and 151 deletions

View File

@@ -69,14 +69,90 @@ const statusOptions: {
const SPECIAL_METHODS = ['starter', 'gift', 'fossil', 'trade']
function groupByMethod(
pokemon: RouteEncounterDetail[]
): { method: string; pokemon: RouteEncounterDetail[] }[] {
const groups = new Map<string, RouteEncounterDetail[]>()
interface GroupedEncounter {
encounter: RouteEncounterDetail
conditions: string[]
displayRate: number | null
}
function getUniqueConditions(pokemon: RouteEncounterDetail[]): string[] {
const conditions = new Set<string>()
for (const rp of pokemon) {
const list = groups.get(rp.encounterMethod) ?? []
list.push(rp)
groups.set(rp.encounterMethod, list)
if (rp.condition) conditions.add(rp.condition)
}
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()]
.sort(([a], [b]) => {
@@ -84,16 +160,29 @@ function groupByMethod(
const bi = METHOD_ORDER.indexOf(b)
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(
pokemon: RouteEncounterDetail[],
dupedIds?: Set<number>
): 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
? pokemon.filter((rp) => !dupedIds.has(rp.pokemonId))
: pokemon
? unique.filter((rp) => !dupedIds.has(rp.pokemonId))
: unique
if (eligible.length === 0) return null
return eligible[Math.floor(Math.random() * eligible.length)]
}
@@ -129,6 +218,9 @@ export function EncounterModal({
const [faintLevel, setFaintLevel] = useState<string>('')
const [deathCause, setDeathCause] = useState('')
const [search, setSearch] = useState('')
const [selectedCondition, setSelectedCondition] = useState<string | null>(
null
)
const isEditing = !!existing
@@ -151,13 +243,19 @@ export function EncounterModal({
}
}, [existing, routePokemon])
const availableConditions = useMemo(
() => (routePokemon ? getUniqueConditions(routePokemon) : []),
[routePokemon]
)
const filteredPokemon = routePokemon?.filter((rp) =>
rp.pokemon.name.toLowerCase().includes(search.toLowerCase())
)
const groupedPokemon = useMemo(
() => (filteredPokemon ? groupByMethod(filteredPokemon) : []),
[filteredPokemon]
() =>
filteredPokemon ? groupByMethod(filteredPokemon, selectedCondition) : [],
[filteredPokemon, selectedCondition]
)
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"
/>
)}
{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">
{groupedPokemon.map(({ method, pokemon }, groupIdx) => (
<div key={method}>
@@ -278,64 +405,84 @@ export function EncounterModal({
</div>
)}
<div className="grid grid-cols-3 gap-2">
{pokemon.map((rp) => {
const isDuped =
dupedPokemonIds?.has(rp.pokemonId) ?? false
return (
<button
key={rp.id}
type="button"
onClick={() =>
!isDuped && setSelectedPokemon(rp)
}
disabled={isDuped}
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
isDuped
? 'opacity-40 cursor-not-allowed border-gray-200 dark:border-gray-700'
: selectedPokemon?.id === rp.id
? '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'
}`}
>
{rp.pokemon.spriteUrl ? (
<img
src={rp.pokemon.spriteUrl}
alt={rp.pokemon.name}
className="w-10 h-10"
/>
) : (
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
{rp.pokemon.name[0].toUpperCase()}
</div>
)}
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
{rp.pokemon.name}
</span>
{isDuped && (
<span className="text-[10px] text-gray-400 italic">
{retiredPokemonIds?.has(rp.pokemonId)
? 'retired (HoF)'
: 'already caught'}
</span>
)}
{!isDuped &&
SPECIAL_METHODS.includes(
rp.encounterMethod
) && (
<EncounterMethodBadge
method={rp.encounterMethod}
{pokemon.map(
({ encounter: rp, conditions, displayRate }) => {
const isDuped =
dupedPokemonIds?.has(rp.pokemonId) ?? false
const isSelected =
selectedPokemon?.pokemonId === rp.pokemonId &&
selectedPokemon?.encounterMethod ===
rp.encounterMethod
return (
<button
key={`${rp.encounterMethod}-${rp.pokemonId}`}
type="button"
onClick={() =>
!isDuped && setSelectedPokemon(rp)
}
disabled={isDuped}
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
isDuped
? 'opacity-40 cursor-not-allowed border-gray-200 dark:border-gray-700'
: isSelected
? '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'
}`}
>
{rp.pokemon.spriteUrl ? (
<img
src={rp.pokemon.spriteUrl}
alt={rp.pokemon.name}
className="w-10 h-10"
/>
) : (
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
{rp.pokemon.name[0].toUpperCase()}
</div>
)}
{!isDuped && (
<span className="text-[10px] text-gray-400">
Lv. {rp.minLevel}
{rp.maxLevel !== rp.minLevel &&
`${rp.maxLevel}`}
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
{rp.pokemon.name}
</span>
)}
</button>
)
})}
{isDuped && (
<span className="text-[10px] text-gray-400 italic">
{retiredPokemonIds?.has(rp.pokemonId)
? 'retired (HoF)'
: 'already caught'}
</span>
)}
{!isDuped &&
SPECIAL_METHODS.includes(
rp.encounterMethod
) && (
<EncounterMethodBadge
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 && (
<span className="text-[10px] text-gray-400">
Lv. {rp.minLevel}
{rp.maxLevel !== rp.minLevel &&
`${rp.maxLevel}`}
</span>
)}
</button>
)
}
)}
</div>
</div>
))}

View File

@@ -66,6 +66,8 @@ export function AdminRouteDetail() {
? Math.max(...childRoutes.map((r) => r.order)) + 1
: (route?.order ?? 0) * 10 + 1
const hasConditions = encounters.some((e) => e.condition !== '')
const columns: Column<RouteEncounterDetail>[] = [
{
header: 'Pokemon',
@@ -86,6 +88,14 @@ export function AdminRouteDetail() {
},
{ header: 'Method', accessor: (e) => e.encounterMethod },
{ header: 'Rate', accessor: (e) => `${e.encounterRate}%` },
...(hasConditions
? [
{
header: 'Condition',
accessor: (e: RouteEncounterDetail) => e.condition || '\u2014',
} as Column<RouteEncounterDetail>,
]
: []),
{
header: 'Levels',
accessor: (e) =>

View File

@@ -68,6 +68,7 @@ export interface CreateRouteEncounterInput {
gameId: number
encounterMethod: string
encounterRate: number
condition?: string
minLevel: number
maxLevel: number
}
@@ -75,6 +76,7 @@ export interface CreateRouteEncounterInput {
export interface UpdateRouteEncounterInput {
encounterMethod?: string
encounterRate?: number
condition?: string
minLevel?: number
maxLevel?: number
}

View File

@@ -61,6 +61,7 @@ export interface RouteEncounter {
gameId: number
encounterMethod: string
encounterRate: number
condition: string
minLevel: number
maxLevel: number
}