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 | undefined retiredPokemonIds?: Set | 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() 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>() // Build a lookup: pokemonId+method -> condition -> rate const rateByCondition = new Map>() 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 ): RouteEncounterDetail | null { // Deduplicate by pokemonId (conditions may create multiple entries) const seen = new Set() 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(null) const [status, setStatus] = useState(existing?.status ?? 'caught') const [nickname, setNickname] = useState(existing?.nickname ?? '') const [catchLevel, setCatchLevel] = useState(existing?.catchLevel?.toString() ?? '') const [faintLevel, setFaintLevel] = useState('') const [deathCause, setDeathCause] = useState('') const [search, setSearch] = useState('') const [selectedCondition, setSelectedCondition] = useState(null) const [allPokemonResults, setAllPokemonResults] = useState([]) 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 (

{isEditing ? 'Edit Encounter' : 'Log Encounter'}

{route.name}

{/* Pokemon Selection (only for new encounters) */} {!isEditing && useAllPokemon && (
{selectedPokemon ? (
{selectedPokemon.pokemon.spriteUrl ? ( {selectedPokemon.pokemon.name} ) : (
{selectedPokemon.pokemon.name[0]?.toUpperCase()}
)} {selectedPokemon.pokemon.name}
) : ( <> 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 && (
)} {allPokemonResults.length > 0 && (
{allPokemonResults.map((p) => { const isDuped = dupedPokemonIds?.has(p.id) ?? false return ( ) })}
)} {search.length >= 2 && !isSearchingAll && allPokemonResults.length === 0 && (

No pokemon found

)} )}
)} {!isEditing && !useAllPokemon && (
{!loadingPokemon && routePokemon && routePokemon.length > 0 && ( )}
{loadingPokemon ? (
) : filteredPokemon && filteredPokemon.length > 0 ? ( <> {(routePokemon?.length ?? 0) > 6 && ( 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 && (
{availableConditions.map((cond) => ( ))}
)}
{groupedPokemon.map(({ method, pokemon }, groupIdx) => (
{groupIdx > 0 &&
} {hasMultipleGroups && (
{getMethodLabel(method)}
)}
{pokemon.map(({ encounter: rp, conditions, displayRate }) => { const isDuped = dupedPokemonIds?.has(rp.pokemonId) ?? false const isSelected = selectedPokemon?.pokemonId === rp.pokemonId && selectedPokemon?.encounterMethod === rp.encounterMethod return ( ) })}
))}
) : (

No pokemon data for this route

)}
)} {/* Editing: show pokemon info */} {isEditing && existing && (
{existing.pokemon.spriteUrl ? ( {existing.pokemon.name} ) : (
{existing.pokemon.name[0]?.toUpperCase()}
)}
{existing.pokemon.name}
Caught at Lv. {existing.catchLevel ?? '?'}
)} {/* Status */}
{statusOptions.map((opt) => ( ))}
{/* Nickname (for caught) */} {status === 'caught' && (
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 && (
Suggestions ({namingScheme})
{suggestions.map((name) => ( ))}
)}
)} {/* Level (for new caught encounters) */} {!isEditing && status === 'caught' && (
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" />
)} {/* Faint Level + Death Cause (only when editing a caught pokemon to mark dead) */} {isEditing && existing?.status === 'caught' && existing?.faintLevel === null && ( <>
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" />
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" />
)}
) }