Files
nuzlocke-tracker/frontend/src/components/EncounterModal.tsx
Julian Tabel 2298c32691
All checks were successful
CI / backend-lint (push) Successful in 9s
CI / actions-lint (push) Successful in 14s
CI / frontend-lint (push) Successful in 21s
Add egglocke, wonderlocke, and randomizer variant rules
When any variant rule is enabled, the encounter modal switches from
the game's regional dex to an all-Pokemon search (same debounced
API pattern as EggEncounterModal). A new "Run Variant" section in
rules configuration groups these rules, and badges render in amber.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 21:33:01 +01:00

768 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useMemo } from 'react'
import { api } from '../api/client'
import { useRoutePokemon } from '../hooks/useGames'
import { useNameSuggestions } from '../hooks/useRuns'
import { EncounterMethodBadge, getMethodLabel, METHOD_ORDER } from './EncounterMethodBadge'
import type {
Route,
EncounterDetail,
EncounterStatus,
RouteEncounterDetail,
Pokemon,
} from '../types'
interface EncounterModalProps {
route: Route
gameId: number
runId: number
namingScheme?: string | null | undefined
isGenlocke?: boolean | undefined
existing?: EncounterDetail | undefined
dupedPokemonIds?: Set<number> | undefined
retiredPokemonIds?: Set<number> | undefined
onSubmit: (data: {
routeId: number
pokemonId: number
nickname?: string | undefined
status: EncounterStatus
catchLevel?: number | undefined
}) => void
onUpdate?:
| ((data: {
id: number
data: {
nickname?: string | undefined
status?: EncounterStatus | undefined
faintLevel?: number | undefined
deathCause?: string | undefined
}
}) => void)
| undefined
onClose: () => void
isPending: boolean
useAllPokemon?: boolean | undefined
}
const statusOptions: {
value: EncounterStatus
label: string
color: string
}[] = [
{
value: 'caught',
label: 'Caught',
color:
'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800 border-green-700 light:border-green-300',
},
{
value: 'fainted',
label: 'Fainted',
color:
'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800 border-red-700 light:border-red-300',
},
{
value: 'missed',
label: 'Missed / Ran',
color: 'bg-surface-2 text-text-primary border-border-default',
},
]
const SPECIAL_METHODS = ['starter', 'gift', 'fossil', 'trade']
interface GroupedEncounter {
encounter: RouteEncounterDetail
conditions: string[]
displayRate: number | null
}
function getUniqueConditions(pokemon: RouteEncounterDetail[]): string[] {
const conditions = new Set<string>()
for (const rp of pokemon) {
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]) => {
const ai = METHOD_ORDER.indexOf(a)
const bi = METHOD_ORDER.indexOf(b)
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi)
})
.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 ? unique.filter((rp) => !dupedIds.has(rp.pokemonId)) : unique
if (eligible.length === 0) return null
return eligible[Math.floor(Math.random() * eligible.length)] ?? null
}
export function EncounterModal({
route,
gameId,
runId,
namingScheme,
isGenlocke,
existing,
dupedPokemonIds,
retiredPokemonIds,
onSubmit,
onUpdate,
onClose,
isPending,
useAllPokemon,
}: EncounterModalProps) {
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
useAllPokemon ? null : route.id,
useAllPokemon ? undefined : gameId
)
const [selectedPokemon, setSelectedPokemon] = useState<RouteEncounterDetail | null>(null)
const [status, setStatus] = useState<EncounterStatus>(existing?.status ?? 'caught')
const [nickname, setNickname] = useState(existing?.nickname ?? '')
const [catchLevel, setCatchLevel] = useState<string>(existing?.catchLevel?.toString() ?? '')
const [faintLevel, setFaintLevel] = useState<string>('')
const [deathCause, setDeathCause] = useState('')
const [search, setSearch] = useState('')
const [selectedCondition, setSelectedCondition] = useState<string | null>(null)
const [allPokemonResults, setAllPokemonResults] = useState<Pokemon[]>([])
const [isSearchingAll, setIsSearchingAll] = useState(false)
const isEditing = !!existing
const showSuggestions = !!namingScheme && status === 'caught' && !isEditing
const lineagePokemonId = isGenlocke && selectedPokemon ? selectedPokemon.pokemonId : null
const {
data: suggestions,
refetch: regenerate,
isFetching: loadingSuggestions,
} = useNameSuggestions(showSuggestions ? runId : null, lineagePokemonId)
// Pre-select pokemon when editing
useEffect(() => {
if (existing && routePokemon) {
const match = routePokemon.find((rp) => rp.pokemonId === existing.pokemonId)
if (match) setSelectedPokemon(match)
}
}, [existing, routePokemon])
// Debounced all-Pokemon search (variant rules)
useEffect(() => {
if (!useAllPokemon) return
if (search.length < 2) {
setAllPokemonResults([])
return
}
const timer = setTimeout(async () => {
setIsSearchingAll(true)
try {
const data = await api.get<{ items: Pokemon[] }>(
`/pokemon?search=${encodeURIComponent(search)}&limit=20`
)
setAllPokemonResults(data.items)
} catch {
setAllPokemonResults([])
} finally {
setIsSearchingAll(false)
}
}, 300)
return () => clearTimeout(timer)
}, [search, useAllPokemon])
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, selectedCondition) : []),
[filteredPokemon, selectedCondition]
)
const hasMultipleGroups = groupedPokemon.length > 1
const handleSubmit = () => {
if (isEditing && onUpdate) {
onUpdate({
id: existing.id,
data: {
nickname: nickname || undefined,
status,
faintLevel: faintLevel ? Number(faintLevel) : undefined,
deathCause: deathCause || undefined,
},
})
} else if (selectedPokemon) {
onSubmit({
routeId: route.id,
pokemonId: selectedPokemon.pokemonId,
nickname: nickname || undefined,
status,
catchLevel: catchLevel ? Number(catchLevel) : undefined,
})
}
}
const canSubmit = isEditing || selectedPokemon
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-surface-1 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-surface-1 border-b border-border-default px-6 py-4 rounded-t-xl">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-text-primary">
{isEditing ? 'Edit Encounter' : 'Log Encounter'}
</h2>
<button onClick={onClose} className="text-text-tertiary hover:text-text-primary">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<p className="text-sm text-text-tertiary mt-1">{route.name}</p>
</div>
<div className="px-6 py-4 space-y-4">
{/* Pokemon Selection (only for new encounters) */}
{!isEditing && useAllPokemon && (
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Pokemon</label>
{selectedPokemon ? (
<div className="flex items-center gap-3 p-3 rounded-lg border border-accent-400 bg-accent-900/20">
{selectedPokemon.pokemon.spriteUrl ? (
<img
src={selectedPokemon.pokemon.spriteUrl}
alt={selectedPokemon.pokemon.name}
className="w-10 h-10"
/>
) : (
<div className="w-10 h-10 rounded-full bg-surface-3 flex items-center justify-center text-xs font-bold">
{selectedPokemon.pokemon.name[0]?.toUpperCase()}
</div>
)}
<span className="font-medium text-text-primary capitalize">
{selectedPokemon.pokemon.name}
</span>
<button
onClick={() => {
setSelectedPokemon(null)
setSearch('')
setAllPokemonResults([])
}}
className="ml-auto text-sm text-text-tertiary hover:text-text-secondary"
>
Change
</button>
</div>
) : (
<>
<input
type="text"
placeholder="Search all pokemon by name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary text-sm focus:outline-none focus:ring-2 focus:ring-accent-400"
/>
{isSearchingAll && (
<div className="flex items-center justify-center py-4">
<div className="w-6 h-6 border-2 border-accent-400 border-t-transparent rounded-full animate-spin" />
</div>
)}
{allPokemonResults.length > 0 && (
<div className="mt-2 max-h-64 overflow-y-auto grid grid-cols-3 gap-2">
{allPokemonResults.map((p) => {
const isDuped = dupedPokemonIds?.has(p.id) ?? false
return (
<button
key={p.id}
type="button"
onClick={() => {
if (!isDuped) {
setSelectedPokemon({
id: 0,
routeId: 0,
gameId: 0,
pokemonId: p.id,
pokemon: p,
encounterMethod: 'walking',
encounterRate: 0,
condition: '',
minLevel: 1,
maxLevel: 100,
})
}
}}
disabled={isDuped}
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
isDuped
? 'opacity-40 cursor-not-allowed border-border-default'
: 'border-border-default hover:border-accent-400'
}`}
>
{p.spriteUrl ? (
<img src={p.spriteUrl} alt={p.name} className="w-10 h-10" />
) : (
<div className="w-10 h-10 rounded-full bg-surface-3 flex items-center justify-center text-xs font-bold">
{p.name[0]?.toUpperCase()}
</div>
)}
<span className="text-xs text-text-secondary mt-1 capitalize">
{p.name}
</span>
{isDuped && (
<span className="text-[10px] text-text-tertiary italic">
{retiredPokemonIds?.has(p.id) ? 'retired (HoF)' : 'already caught'}
</span>
)}
</button>
)
})}
</div>
)}
{search.length >= 2 && !isSearchingAll && allPokemonResults.length === 0 && (
<p className="text-sm text-text-tertiary py-2">No pokemon found</p>
)}
</>
)}
</div>
)}
{!isEditing && !useAllPokemon && (
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-text-secondary">Pokemon</label>
{!loadingPokemon && routePokemon && routePokemon.length > 0 && (
<button
type="button"
disabled={
loadingPokemon ||
!routePokemon ||
(dupedPokemonIds
? routePokemon.every((rp) => dupedPokemonIds.has(rp.pokemonId))
: false)
}
onClick={() => {
if (routePokemon) {
setSelectedPokemon(pickRandomPokemon(routePokemon, dupedPokemonIds))
}
}}
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-600 text-purple-400 light:text-purple-700 light:border-purple-500 hover:bg-purple-900/20 light:hover:bg-purple-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{selectedPokemon ? 'Re-roll' : 'Randomize'}
</button>
)}
</div>
{loadingPokemon ? (
<div className="flex items-center justify-center py-4">
<div className="w-10 h-10 border-2 border-accent-400 border-t-transparent rounded-full animate-spin" />
</div>
) : filteredPokemon && filteredPokemon.length > 0 ? (
<>
{(routePokemon?.length ?? 0) > 6 && (
<input
type="text"
placeholder="Search pokemon..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full px-3 py-1.5 mb-2 rounded-lg border border-border-default bg-surface-2 text-text-primary text-sm focus:outline-none focus:ring-2 focus:ring-accent-400"
/>
)}
{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-900/40 border-purple-600 text-purple-300'
: 'border-border-default text-text-tertiary 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-900/40 border-purple-600 text-purple-300'
: 'border-border-default text-text-tertiary 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}>
{groupIdx > 0 && <div className="border-t border-border-default mb-3" />}
{hasMultipleGroups && (
<div className="text-xs font-medium text-text-tertiary mb-1.5">
{getMethodLabel(method)}
</div>
)}
<div className="grid grid-cols-3 gap-2">
{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-border-default'
: isSelected
? 'border-accent-400 bg-accent-900/30'
: 'border-border-default hover:border-border-default'
}`}
>
{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-surface-3 flex items-center justify-center text-xs font-bold">
{rp.pokemon.name[0]?.toUpperCase()}
</div>
)}
<span className="text-xs text-text-secondary mt-1 capitalize">
{rp.pokemon.name}
</span>
{isDuped && (
<span className="text-[10px] text-text-tertiary 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-400 light:text-purple-700 font-medium">
{displayRate}%
</span>
)}
{!isDuped &&
selectedCondition === null &&
conditions.length > 0 && (
<span className="text-[10px] text-purple-400 light:text-purple-700">
{conditions.join(', ')}
</span>
)}
{!isDuped && (
<span className="text-[10px] text-text-tertiary">
Lv. {rp.minLevel}
{rp.maxLevel !== rp.minLevel && `${rp.maxLevel}`}
</span>
)}
</button>
)
})}
</div>
</div>
))}
</div>
</>
) : (
<p className="text-sm text-text-tertiary py-2">No pokemon data for this route</p>
)}
</div>
)}
{/* Editing: show pokemon info */}
{isEditing && existing && (
<div className="flex items-center gap-3 p-3 bg-surface-0/50 rounded-lg">
{existing.pokemon.spriteUrl ? (
<img
src={existing.pokemon.spriteUrl}
alt={existing.pokemon.name}
className="w-12 h-12"
/>
) : (
<div className="w-12 h-12 rounded-full bg-surface-3 flex items-center justify-center text-lg font-bold">
{existing.pokemon.name[0]?.toUpperCase()}
</div>
)}
<div>
<div className="font-medium text-text-primary capitalize">
{existing.pokemon.name}
</div>
<div className="text-xs text-text-tertiary">
Caught at Lv. {existing.catchLevel ?? '?'}
</div>
</div>
</div>
)}
{/* Status */}
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Status</label>
<div className="flex gap-2">
{statusOptions.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setStatus(opt.value)}
className={`flex-1 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
status === opt.value
? opt.color
: 'border-border-default text-text-tertiary hover:border-border-accent'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Nickname (for caught) */}
{status === 'caught' && (
<div>
<label
htmlFor="nickname"
className="block text-sm font-medium text-text-secondary mb-1"
>
Nickname
</label>
<input
id="nickname"
type="text"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder="Give it a name..."
className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-400"
/>
{showSuggestions && suggestions && suggestions.length > 0 && (
<div className="mt-2">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-text-tertiary">Suggestions ({namingScheme})</span>
<button
type="button"
onClick={() => regenerate()}
disabled={loadingSuggestions}
className="text-xs text-text-link hover:text-accent-300 disabled:opacity-50 transition-colors"
>
{loadingSuggestions ? 'Loading...' : 'Regenerate'}
</button>
</div>
<div className="flex flex-wrap gap-1.5">
{suggestions.map((name) => (
<button
key={name}
type="button"
onClick={() => setNickname(name)}
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
nickname === name
? 'bg-accent-900/40 border-accent-600 text-accent-300 light:bg-accent-100 light:text-accent-700'
: 'border-border-default text-text-secondary hover:border-accent-600 hover:bg-accent-900/20'
}`}
>
{name}
</button>
))}
</div>
</div>
)}
</div>
)}
{/* Level (for new caught encounters) */}
{!isEditing && status === 'caught' && (
<div>
<label
htmlFor="catch-level"
className="block text-sm font-medium text-text-secondary mb-1"
>
Catch Level
</label>
<input
id="catch-level"
type="number"
min={1}
max={100}
value={catchLevel}
onChange={(e) => setCatchLevel(e.target.value)}
placeholder={
selectedPokemon
? `${selectedPokemon.minLevel}${selectedPokemon.maxLevel}`
: 'Level'
}
className="w-24 px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-400"
/>
</div>
)}
{/* Faint Level + Death Cause (only when editing a caught pokemon to mark dead) */}
{isEditing && existing?.status === 'caught' && existing?.faintLevel === null && (
<>
<div>
<label
htmlFor="faint-level"
className="block text-sm font-medium text-text-secondary mb-1"
>
Faint Level <span className="font-normal text-text-tertiary">(mark as dead)</span>
</label>
<input
id="faint-level"
type="number"
min={1}
max={100}
value={faintLevel}
onChange={(e) => setFaintLevel(e.target.value)}
placeholder="Leave empty if still alive"
className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-400"
/>
</div>
<div>
<label
htmlFor="death-cause"
className="block text-sm font-medium text-text-secondary mb-1"
>
Cause of Death <span className="font-normal text-text-tertiary">(optional)</span>
</label>
<input
id="death-cause"
type="text"
maxLength={100}
value={deathCause}
onChange={(e) => setDeathCause(e.target.value)}
placeholder="e.g. Crit from rival's Charizard"
className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-400"
/>
</div>
</>
)}
</div>
<div className="sticky bottom-0 bg-surface-1 border-t border-border-default px-6 py-4 rounded-b-xl flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-text-secondary bg-surface-2 rounded-lg font-medium hover:bg-surface-3 transition-colors"
>
Cancel
</button>
<button
type="button"
disabled={!canSubmit || isPending}
onClick={handleSubmit}
className="px-4 py-2 bg-accent-600 text-white rounded-lg font-medium hover:bg-accent-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isPending ? 'Saving...' : isEditing ? 'Update' : 'Log Encounter'}
</button>
</div>
</div>
</div>
)
}