Move alive and hofTeam into useMemo hooks above early returns to fix React hook ordering violation. Include transfer and shiny encounters in alive so they appear in the team section and can be selected for the Hall of Fame. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1543 lines
55 KiB
TypeScript
1543 lines
55 KiB
TypeScript
import { useState, useMemo, useEffect, useCallback } from 'react'
|
|
import { useParams, Link, useNavigate } from 'react-router-dom'
|
|
import { useRun, useUpdateRun } from '../hooks/useRuns'
|
|
import { useAdvanceLeg } from '../hooks/useGenlockes'
|
|
import { useGameRoutes } from '../hooks/useGames'
|
|
import { useCreateEncounter, useUpdateEncounter, useBulkRandomize } from '../hooks/useEncounters'
|
|
import { usePokemonFamilies } from '../hooks/usePokemon'
|
|
import { useGameBosses, useBossResults, useCreateBossResult } from '../hooks/useBosses'
|
|
import {
|
|
EggEncounterModal,
|
|
EncounterModal,
|
|
EncounterMethodBadge,
|
|
HofTeamModal,
|
|
TransferModal,
|
|
StatCard,
|
|
PokemonCard,
|
|
StatusChangeModal,
|
|
EndRunModal,
|
|
RuleBadges,
|
|
ShinyBox,
|
|
ShinyEncounterModal,
|
|
TypeBadge,
|
|
} from '../components'
|
|
import { BossDefeatModal } from '../components/BossDefeatModal'
|
|
import type {
|
|
Route,
|
|
RouteWithChildren,
|
|
RunStatus,
|
|
EncounterDetail,
|
|
EncounterStatus,
|
|
CreateEncounterInput,
|
|
BossBattle,
|
|
BossPokemon,
|
|
} from '../types'
|
|
|
|
const statusStyles: Record<RunStatus, string> = {
|
|
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
|
completed:
|
|
'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
|
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
|
}
|
|
|
|
function formatDuration(start: string, end: string) {
|
|
const ms = new Date(end).getTime() - new Date(start).getTime()
|
|
const days = Math.floor(ms / (1000 * 60 * 60 * 24))
|
|
if (days === 0) return 'Less than a day'
|
|
if (days === 1) return '1 day'
|
|
return `${days} days`
|
|
}
|
|
|
|
type RouteStatus = 'caught' | 'fainted' | 'missed' | 'none'
|
|
|
|
function getRouteStatus(encounter?: EncounterDetail): RouteStatus {
|
|
if (!encounter) return 'none'
|
|
return encounter.status
|
|
}
|
|
|
|
const statusIndicator: Record<
|
|
RouteStatus,
|
|
{ dot: string; label: string; bg: string }
|
|
> = {
|
|
caught: {
|
|
dot: 'bg-green-500',
|
|
label: 'Caught',
|
|
bg: 'bg-green-50 dark:bg-green-900/10',
|
|
},
|
|
fainted: {
|
|
dot: 'bg-red-500',
|
|
label: 'Fainted',
|
|
bg: 'bg-red-50 dark:bg-red-900/10',
|
|
},
|
|
missed: {
|
|
dot: 'bg-gray-400',
|
|
label: 'Missed',
|
|
bg: 'bg-gray-50 dark:bg-gray-900/10',
|
|
},
|
|
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
|
|
}
|
|
|
|
/** Whether any child in this group has a pinwheelZone set. */
|
|
function groupHasZones(group: RouteWithChildren): boolean {
|
|
return group.children.some((c) => c.pinwheelZone != null)
|
|
}
|
|
|
|
/** Get the effective zone for a route (null treated as 0). */
|
|
function effectiveZone(route: Route): number {
|
|
return route.pinwheelZone ?? 0
|
|
}
|
|
|
|
/**
|
|
* Get encounters grouped by zone within a route group.
|
|
* Returns a Map from zone number to the encounter in that zone.
|
|
*/
|
|
function getZoneEncounters(
|
|
group: RouteWithChildren,
|
|
encounterByRoute: Map<number, EncounterDetail>,
|
|
): Map<number, EncounterDetail> {
|
|
const zoneMap = new Map<number, EncounterDetail>()
|
|
for (const child of group.children) {
|
|
const enc = encounterByRoute.get(child.id)
|
|
if (enc) {
|
|
zoneMap.set(effectiveZone(child), enc)
|
|
}
|
|
}
|
|
return zoneMap
|
|
}
|
|
|
|
/** Count distinct zones in a group. */
|
|
function countDistinctZones(group: RouteWithChildren): number {
|
|
const zones = new Set(group.children.map(effectiveZone))
|
|
return zones.size
|
|
}
|
|
|
|
function matchVariant(labels: string[], starterName?: string | null): string | null {
|
|
if (!starterName || labels.length === 0) return null
|
|
const lower = starterName.toLowerCase()
|
|
const matches = labels.filter((l) => l.toLowerCase().includes(lower))
|
|
return matches.length === 1 ? matches[0] : null
|
|
}
|
|
|
|
function BossTeamPreview({ pokemon, starterName }: { pokemon: BossPokemon[]; starterName?: string | null }) {
|
|
const variantLabels = useMemo(() => {
|
|
const labels = new Set<string>()
|
|
for (const bp of pokemon) {
|
|
if (bp.conditionLabel) labels.add(bp.conditionLabel)
|
|
}
|
|
return [...labels].sort()
|
|
}, [pokemon])
|
|
|
|
const hasVariants = variantLabels.length > 0
|
|
const autoMatch = useMemo(() => matchVariant(variantLabels, starterName), [variantLabels, starterName])
|
|
const showPills = hasVariants && autoMatch === null
|
|
const [selectedVariant, setSelectedVariant] = useState<string | null>(
|
|
autoMatch ?? (hasVariants ? variantLabels[0] : null),
|
|
)
|
|
|
|
const displayed = useMemo(() => {
|
|
if (!hasVariants) return pokemon
|
|
return pokemon.filter(
|
|
(bp) => bp.conditionLabel === selectedVariant || bp.conditionLabel === null,
|
|
)
|
|
}, [pokemon, hasVariants, selectedVariant])
|
|
|
|
return (
|
|
<div className="mt-2">
|
|
{showPills && (
|
|
<div className="flex gap-1 mb-2 flex-wrap">
|
|
{variantLabels.map((label) => (
|
|
<button
|
|
key={label}
|
|
type="button"
|
|
onClick={() => setSelectedVariant(label)}
|
|
className={`px-2 py-0.5 text-xs font-medium rounded-full transition-colors ${
|
|
selectedVariant === label
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
|
}`}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="flex gap-2 flex-wrap">
|
|
{[...displayed]
|
|
.sort((a, b) => a.order - b.order)
|
|
.map((bp) => (
|
|
<div key={bp.id} className="flex items-center gap-1">
|
|
{bp.pokemon.spriteUrl ? (
|
|
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-20 h-20" />
|
|
) : (
|
|
<div className="w-20 h-20 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
|
)}
|
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
Lvl {bp.level}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface RouteGroupProps {
|
|
group: RouteWithChildren
|
|
encounterByRoute: Map<number, EncounterDetail>
|
|
isExpanded: boolean
|
|
onToggleExpand: () => void
|
|
onRouteClick: (route: Route) => void
|
|
filter: 'all' | RouteStatus
|
|
pinwheelClause: boolean
|
|
}
|
|
|
|
function RouteGroup({
|
|
group,
|
|
encounterByRoute,
|
|
isExpanded,
|
|
onToggleExpand,
|
|
onRouteClick,
|
|
filter,
|
|
pinwheelClause,
|
|
}: RouteGroupProps) {
|
|
const groupEncounter = getGroupEncounter(group, encounterByRoute)
|
|
const usePinwheel = pinwheelClause && groupHasZones(group)
|
|
const zoneEncounters = usePinwheel
|
|
? getZoneEncounters(group, encounterByRoute)
|
|
: null
|
|
|
|
// For pinwheel groups, determine status from all zone statuses
|
|
let groupStatus: RouteStatus
|
|
if (usePinwheel && zoneEncounters && zoneEncounters.size > 0) {
|
|
// Use the first encounter's status as representative for the header
|
|
groupStatus = groupEncounter ? groupEncounter.status : 'none'
|
|
} else {
|
|
groupStatus = groupEncounter ? groupEncounter.status : 'none'
|
|
}
|
|
const si = statusIndicator[groupStatus]
|
|
|
|
// For groups, check if it matches the filter
|
|
if (filter !== 'all') {
|
|
if (usePinwheel) {
|
|
// Show group if any zone matches the filter
|
|
const anyChildMatches = group.children.some((child) => {
|
|
const enc = encounterByRoute.get(child.id)
|
|
return getRouteStatus(enc) === filter
|
|
})
|
|
// Also check children without encounters (for 'none' filter)
|
|
if (!anyChildMatches) return null
|
|
} else if (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-10 h-10"
|
|
/>
|
|
)}
|
|
<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]
|
|
|
|
let isDisabled: boolean
|
|
if (usePinwheel && zoneEncounters) {
|
|
// Zone-aware: only lock if this child's zone already has an encounter
|
|
const myZone = effectiveZone(child)
|
|
isDisabled = zoneEncounters.has(myZone) && !childEncounter
|
|
} else {
|
|
// Classic: whole group shares one encounter
|
|
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>
|
|
{!childEncounter && child.encounterMethods.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-0.5">
|
|
{child.encounterMethods.map((m) => (
|
|
<EncounterMethodBadge key={m} method={m} size="xs" />
|
|
))}
|
|
</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() {
|
|
const { runId } = useParams<{ runId: string }>()
|
|
const navigate = useNavigate()
|
|
const runIdNum = Number(runId)
|
|
const { data: run, isLoading, error } = useRun(runIdNum)
|
|
const advanceLeg = useAdvanceLeg()
|
|
const [showTransferModal, setShowTransferModal] = useState(false)
|
|
const { data: routes, isLoading: loadingRoutes } = useGameRoutes(
|
|
run?.gameId ?? null,
|
|
)
|
|
const createEncounter = useCreateEncounter(runIdNum)
|
|
const updateEncounter = useUpdateEncounter(runIdNum)
|
|
const bulkRandomize = useBulkRandomize(runIdNum)
|
|
const updateRun = useUpdateRun(runIdNum)
|
|
const { data: familiesData } = usePokemonFamilies()
|
|
const { data: bosses } = useGameBosses(run?.gameId ?? null)
|
|
const { data: bossResults } = useBossResults(runIdNum)
|
|
const createBossResult = useCreateBossResult(runIdNum)
|
|
|
|
const [selectedRoute, setSelectedRoute] = useState<Route | null>(null)
|
|
const [selectedBoss, setSelectedBoss] = useState<BossBattle | null>(null)
|
|
const [editingEncounter, setEditingEncounter] =
|
|
useState<EncounterDetail | null>(null)
|
|
const [selectedTeamEncounter, setSelectedTeamEncounter] =
|
|
useState<EncounterDetail | null>(null)
|
|
const [showEndRun, setShowEndRun] = useState(false)
|
|
const [showHofModal, setShowHofModal] = useState(false)
|
|
const [showShinyModal, setShowShinyModal] = useState(false)
|
|
const [showEggModal, setShowEggModal] = useState(false)
|
|
const [expandedBosses, setExpandedBosses] = useState<Set<number>>(new Set())
|
|
const [showTeam, setShowTeam] = useState(true)
|
|
const [filter, setFilter] = useState<'all' | RouteStatus>('all')
|
|
|
|
const storageKey = `expandedGroups-${runId}`
|
|
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(() => {
|
|
try {
|
|
const saved = localStorage.getItem(storageKey)
|
|
if (saved) return new Set(JSON.parse(saved) as number[])
|
|
} catch { /* ignore */ }
|
|
return new Set<number>()
|
|
})
|
|
|
|
const updateExpandedGroups = useCallback(
|
|
(updater: (prev: Set<number>) => Set<number>) => {
|
|
setExpandedGroups((prev) => {
|
|
const next = updater(prev)
|
|
localStorage.setItem(storageKey, JSON.stringify([...next]))
|
|
return next
|
|
})
|
|
},
|
|
[storageKey],
|
|
)
|
|
|
|
// Organize routes into hierarchical structure
|
|
const organizedRoutes = useMemo(() => {
|
|
if (!routes) return []
|
|
return organizeRoutes(routes)
|
|
}, [routes])
|
|
|
|
// Split encounters into normal (non-shiny) and shiny
|
|
const transferIdSet = useMemo(
|
|
() => new Set(run?.transferEncounterIds ?? []),
|
|
[run?.transferEncounterIds],
|
|
)
|
|
|
|
const { normalEncounters, shinyEncounters, transferEncounters } = useMemo(() => {
|
|
if (!run) return { normalEncounters: [], shinyEncounters: [], transferEncounters: [] }
|
|
const normal: EncounterDetail[] = []
|
|
const shiny: EncounterDetail[] = []
|
|
const transfer: EncounterDetail[] = []
|
|
for (const enc of run.encounters) {
|
|
if (transferIdSet.has(enc.id)) {
|
|
transfer.push(enc)
|
|
} else if (enc.isShiny) {
|
|
shiny.push(enc)
|
|
} else {
|
|
normal.push(enc)
|
|
}
|
|
}
|
|
return { normalEncounters: normal, shinyEncounters: shiny, transferEncounters: transfer }
|
|
}, [run, transferIdSet])
|
|
|
|
// Map routeId → encounter for quick lookup (normal encounters only)
|
|
const encounterByRoute = useMemo(() => {
|
|
const map = new Map<number, EncounterDetail>()
|
|
for (const enc of normalEncounters) {
|
|
map.set(enc.routeId, enc)
|
|
}
|
|
return map
|
|
}, [normalEncounters])
|
|
|
|
// Build set of retired Pokemon IDs from genlocke context
|
|
const retiredPokemonIds = useMemo(() => {
|
|
const ids = run?.genlocke?.retiredPokemonIds
|
|
if (!ids || ids.length === 0) return undefined
|
|
return new Set(ids)
|
|
}, [run])
|
|
|
|
// Build set of duped Pokemon IDs (for duplicates clause)
|
|
const dupedPokemonIds = useMemo(() => {
|
|
const dupesClauseOn = run?.rules?.duplicatesClause ?? true
|
|
if (!dupesClauseOn || !familiesData) return undefined
|
|
|
|
// Build pokemonId → family members map
|
|
const pokemonToFamily = new Map<number, number[]>()
|
|
for (const family of familiesData.families) {
|
|
for (const id of family) {
|
|
pokemonToFamily.set(id, family)
|
|
}
|
|
}
|
|
|
|
const duped = new Set<number>()
|
|
|
|
// Seed with retired Pokemon IDs from prior genlocke legs
|
|
if (retiredPokemonIds) {
|
|
for (const id of retiredPokemonIds) {
|
|
duped.add(id)
|
|
}
|
|
}
|
|
|
|
for (const enc of normalEncounters) {
|
|
if (enc.status !== 'caught') continue
|
|
const pokemonId = enc.currentPokemonId ?? enc.pokemonId
|
|
// Add the pokemon itself and all family members
|
|
duped.add(pokemonId)
|
|
duped.add(enc.pokemonId)
|
|
const family = pokemonToFamily.get(pokemonId)
|
|
if (family) {
|
|
for (const memberId of family) {
|
|
duped.add(memberId)
|
|
}
|
|
}
|
|
// Also check original pokemon's family
|
|
const origFamily = pokemonToFamily.get(enc.pokemonId)
|
|
if (origFamily) {
|
|
for (const memberId of origFamily) {
|
|
duped.add(memberId)
|
|
}
|
|
}
|
|
}
|
|
return duped.size > 0 ? duped : undefined
|
|
}, [run, normalEncounters, familiesData, retiredPokemonIds])
|
|
|
|
// Find starter Pokemon name for auto-matching variant boss teams
|
|
// Note: enc.route from the run detail doesn't include encounterMethods
|
|
// (it's computed only in the game routes endpoint), so we look up the
|
|
// route from the separately-fetched routes data instead.
|
|
const starterName = useMemo(() => {
|
|
if (!routes) return null
|
|
const routeMap = new Map(routes.map((r) => [r.id, r]))
|
|
for (const enc of normalEncounters) {
|
|
const route = routeMap.get(enc.routeId)
|
|
if (route?.encounterMethods.includes('starter')) {
|
|
return enc.pokemon.name
|
|
}
|
|
}
|
|
return null
|
|
}, [normalEncounters, routes])
|
|
|
|
// Boss battle data
|
|
const defeatedBossIds = useMemo(() => {
|
|
const set = new Set<number>()
|
|
if (bossResults) {
|
|
for (const r of bossResults) {
|
|
if (r.result === 'won') set.add(r.bossBattleId)
|
|
}
|
|
}
|
|
return set
|
|
}, [bossResults])
|
|
|
|
const sortedBosses = useMemo(() => {
|
|
if (!bosses) return []
|
|
return [...bosses].sort((a, b) => a.order - b.order)
|
|
}, [bosses])
|
|
|
|
const nextBoss = useMemo(() => {
|
|
return sortedBosses.find((b) => !defeatedBossIds.has(b.id)) ?? null
|
|
}, [sortedBosses, defeatedBossIds])
|
|
|
|
const currentLevelCap = useMemo(() => {
|
|
if (!nextBoss) {
|
|
// All defeated — no cap (or use last boss's level)
|
|
return sortedBosses.length > 0
|
|
? sortedBosses[sortedBosses.length - 1].levelCap
|
|
: null
|
|
}
|
|
return nextBoss.levelCap
|
|
}, [nextBoss, sortedBosses])
|
|
|
|
// Pre-compute which bosses get a section divider rendered AFTER them
|
|
// (when the next boss in order has a different section)
|
|
const sectionDividerAfterBoss = useMemo(() => {
|
|
const map = new Map<number, string>()
|
|
for (let i = 0; i < sortedBosses.length - 1; i++) {
|
|
const current = sortedBosses[i]
|
|
const next = sortedBosses[i + 1]
|
|
if (next.section != null && current.section !== next.section) {
|
|
map.set(current.id, next.section)
|
|
}
|
|
}
|
|
return map
|
|
}, [sortedBosses])
|
|
|
|
// Map afterRouteId → BossBattle[] for interleaving
|
|
const bossesAfterRoute = useMemo(() => {
|
|
const map = new Map<number, BossBattle[]>()
|
|
if (!bosses) return map
|
|
for (const boss of bosses) {
|
|
if (boss.afterRouteId != null) {
|
|
const list = map.get(boss.afterRouteId) ?? []
|
|
list.push(boss)
|
|
map.set(boss.afterRouteId, list)
|
|
}
|
|
}
|
|
return map
|
|
}, [bosses])
|
|
|
|
// Auto-expand the first unvisited group on initial load
|
|
useEffect(() => {
|
|
if (organizedRoutes.length === 0 || expandedGroups.size > 0) return
|
|
const firstUnvisited = organizedRoutes.find(
|
|
(r) =>
|
|
r.children.length > 0 &&
|
|
getGroupEncounter(r, encounterByRoute) === null,
|
|
)
|
|
if (firstUnvisited) {
|
|
updateExpandedGroups(() => new Set([firstUnvisited.id]))
|
|
}
|
|
}, [organizedRoutes, encounterByRoute]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const alive = useMemo(
|
|
() => [...normalEncounters, ...transferEncounters, ...shinyEncounters].filter(
|
|
(e) => e.status === 'caught' && e.faintLevel === null,
|
|
),
|
|
[normalEncounters, transferEncounters, shinyEncounters],
|
|
)
|
|
|
|
// Resolve HoF team encounters from IDs
|
|
const hofTeam = useMemo(() => {
|
|
if (!run?.hofEncounterIds || run.hofEncounterIds.length === 0) return null
|
|
const idSet = new Set(run.hofEncounterIds)
|
|
return alive.filter((e) => idSet.has(e.id))
|
|
}, [run?.hofEncounterIds, alive])
|
|
|
|
if (isLoading || loadingRoutes) {
|
|
return (
|
|
<div className="flex items-center justify-center py-16">
|
|
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error || !run) {
|
|
return (
|
|
<div className="max-w-4xl mx-auto p-8">
|
|
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400">
|
|
Failed to load run.
|
|
</div>
|
|
<Link
|
|
to="/runs"
|
|
className="inline-block mt-4 text-blue-600 hover:underline"
|
|
>
|
|
Back to runs
|
|
</Link>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const pinwheelClause = run.rules?.pinwheelClause ?? true
|
|
|
|
// Count completed locations (zone-aware when pinwheel clause is on)
|
|
let completedCount = 0
|
|
let totalLocations = 0
|
|
for (const r of organizedRoutes) {
|
|
if (r.children.length > 0) {
|
|
const usePinwheel = pinwheelClause && groupHasZones(r)
|
|
if (usePinwheel) {
|
|
const distinctZones = countDistinctZones(r)
|
|
const zoneEncs = getZoneEncounters(r, encounterByRoute)
|
|
totalLocations += distinctZones
|
|
completedCount += zoneEncs.size
|
|
} else {
|
|
totalLocations += 1
|
|
if (getGroupEncounter(r, encounterByRoute) !== null) {
|
|
completedCount += 1
|
|
}
|
|
}
|
|
} else {
|
|
totalLocations += 1
|
|
if (encounterByRoute.has(r.id)) {
|
|
completedCount += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
const isActive = run.status === 'active'
|
|
const dead = normalEncounters.filter(
|
|
(e) => e.status === 'caught' && e.faintLevel !== null,
|
|
)
|
|
|
|
const toggleGroup = (groupId: number) => {
|
|
updateExpandedGroups((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(groupId)) {
|
|
next.delete(groupId)
|
|
} else {
|
|
next.add(groupId)
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
const handleRouteClick = (route: Route) => {
|
|
const existing = encounterByRoute.get(route.id)
|
|
if (existing) {
|
|
setEditingEncounter(existing)
|
|
} else {
|
|
setEditingEncounter(null)
|
|
}
|
|
setSelectedRoute(route)
|
|
}
|
|
|
|
const handleCreate = (data: CreateEncounterInput) => {
|
|
createEncounter.mutate(data, {
|
|
onSuccess: () => {
|
|
setSelectedRoute(null)
|
|
setEditingEncounter(null)
|
|
setShowShinyModal(false)
|
|
setShowEggModal(false)
|
|
},
|
|
})
|
|
}
|
|
|
|
const handleUpdate = (data: {
|
|
id: number
|
|
data: {
|
|
nickname?: string
|
|
status?: EncounterStatus
|
|
faintLevel?: number
|
|
deathCause?: string
|
|
}
|
|
}) => {
|
|
updateEncounter.mutate(data, {
|
|
onSuccess: () => {
|
|
setSelectedRoute(null)
|
|
setEditingEncounter(null)
|
|
},
|
|
})
|
|
}
|
|
|
|
// Filter routes
|
|
const filteredRoutes = organizedRoutes.filter((r) => {
|
|
if (filter === 'all') return true
|
|
|
|
if (r.children.length > 0) {
|
|
const usePinwheel = pinwheelClause && groupHasZones(r)
|
|
if (usePinwheel) {
|
|
// Show group if any child/zone matches the filter
|
|
return r.children.some((child) => {
|
|
const enc = encounterByRoute.get(child.id)
|
|
return getRouteStatus(enc) === filter
|
|
})
|
|
}
|
|
// Classic: single status for whole group
|
|
const groupEnc = getGroupEncounter(r, encounterByRoute)
|
|
return getRouteStatus(groupEnc ?? undefined) === filter
|
|
}
|
|
|
|
// Standalone route
|
|
const enc = encounterByRoute.get(r.id)
|
|
return getRouteStatus(enc) === filter
|
|
})
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto p-8">
|
|
{/* Header */}
|
|
<div className="mb-6">
|
|
<Link
|
|
to="/runs"
|
|
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mb-2 inline-block"
|
|
>
|
|
← All Runs
|
|
</Link>
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
|
{run.name}
|
|
</h1>
|
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
|
{run.game.name} · {run.game.region.charAt(0).toUpperCase() + run.game.region.slice(1)} · Started{' '}
|
|
{new Date(run.startedAt).toLocaleDateString(undefined, {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
})}
|
|
</p>
|
|
{run.genlocke && (
|
|
<p className="text-sm text-purple-600 dark:text-purple-400 mt-1 font-medium">
|
|
Leg {run.genlocke.legOrder} of {run.genlocke.totalLegs} — {run.genlocke.genlockeName}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{isActive && run.rules?.shinyClause && (
|
|
<button
|
|
onClick={() => setShowShinyModal(true)}
|
|
className="px-3 py-1 text-sm border border-yellow-400 dark:border-yellow-600 text-yellow-600 dark:text-yellow-400 rounded-full font-medium hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors"
|
|
>
|
|
✦ Log Shiny
|
|
</button>
|
|
)}
|
|
{isActive && (
|
|
<button
|
|
onClick={() => setShowEggModal(true)}
|
|
className="px-3 py-1 text-sm border border-green-400 dark:border-green-600 text-green-600 dark:text-green-400 rounded-full font-medium hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors"
|
|
>
|
|
🥚 Log Egg
|
|
</button>
|
|
)}
|
|
{isActive && (
|
|
<button
|
|
onClick={() => setShowEndRun(true)}
|
|
className="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-full font-medium hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
|
>
|
|
End Run
|
|
</button>
|
|
)}
|
|
<span
|
|
className={`px-3 py-1 rounded-full text-sm font-medium capitalize ${statusStyles[run.status]}`}
|
|
>
|
|
{run.status}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Completion Banner */}
|
|
{!isActive && (
|
|
<div
|
|
className={`rounded-lg p-4 mb-6 ${
|
|
run.status === 'completed'
|
|
? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800'
|
|
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-2xl">{run.status === 'completed' ? '\u{1f3c6}' : '\u{1faa6}'}</span>
|
|
<div>
|
|
<p
|
|
className={`font-semibold ${
|
|
run.status === 'completed'
|
|
? 'text-blue-800 dark:text-blue-200'
|
|
: 'text-red-800 dark:text-red-200'
|
|
}`}
|
|
>
|
|
{run.status === 'completed'
|
|
? run.genlocke?.isFinalLeg
|
|
? 'Genlocke Complete!'
|
|
: 'Victory!'
|
|
: run.genlocke
|
|
? 'Genlocke Failed'
|
|
: 'Defeat'}
|
|
</p>
|
|
<p
|
|
className={`text-sm ${
|
|
run.status === 'completed'
|
|
? 'text-blue-600 dark:text-blue-400'
|
|
: 'text-red-600 dark:text-red-400'
|
|
}`}
|
|
>
|
|
{run.completedAt && (
|
|
<>
|
|
Ended{' '}
|
|
{new Date(run.completedAt).toLocaleDateString(undefined, {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
})}
|
|
{' \u00b7 '}
|
|
Duration: {formatDuration(run.startedAt, run.completedAt)}
|
|
</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{run.status === 'completed' && run.genlocke && !run.genlocke.isFinalLeg && (
|
|
<button
|
|
onClick={() => {
|
|
if (hofTeam && hofTeam.length > 0) {
|
|
setShowTransferModal(true)
|
|
} else {
|
|
advanceLeg.mutate(
|
|
{ genlockeId: run.genlocke!.genlockeId, legOrder: run.genlocke!.legOrder },
|
|
{
|
|
onSuccess: (genlocke) => {
|
|
const nextLeg = genlocke.legs.find(
|
|
(l) => l.legOrder === run.genlocke!.legOrder + 1,
|
|
)
|
|
if (nextLeg?.runId) {
|
|
navigate(`/runs/${nextLeg.runId}`)
|
|
}
|
|
},
|
|
},
|
|
)
|
|
}
|
|
}}
|
|
disabled={advanceLeg.isPending}
|
|
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
|
>
|
|
{advanceLeg.isPending ? 'Advancing...' : 'Advance to Next Leg'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{/* HoF Team Display */}
|
|
{run.status === 'completed' && (
|
|
<div className="mt-3 pt-3 border-t border-blue-200 dark:border-blue-800">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-xs font-medium text-blue-600 dark:text-blue-400 uppercase tracking-wider">
|
|
Hall of Fame
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowHofModal(true)}
|
|
className="text-xs text-blue-500 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
|
|
>
|
|
{hofTeam ? 'Edit' : 'Select team'}
|
|
</button>
|
|
</div>
|
|
{hofTeam ? (
|
|
<div className="flex gap-2 flex-wrap">
|
|
{hofTeam.map((enc) => {
|
|
const dp = enc.currentPokemon ?? enc.pokemon
|
|
return (
|
|
<div key={enc.id} className="flex flex-col items-center">
|
|
{dp.spriteUrl ? (
|
|
<img src={dp.spriteUrl} alt={dp.name} className="w-12 h-12" />
|
|
) : (
|
|
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-sm font-bold">
|
|
{dp.name[0].toUpperCase()}
|
|
</div>
|
|
)}
|
|
<span className="text-[10px] text-blue-600 dark:text-blue-400 capitalize mt-0.5">
|
|
{enc.nickname || dp.name}
|
|
</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-blue-500/60 dark:text-blue-400/60 italic">
|
|
No HoF team selected yet
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
|
<StatCard
|
|
label="Encounters"
|
|
value={normalEncounters.length}
|
|
color="blue"
|
|
/>
|
|
<StatCard label="Alive" value={alive.length} color="green" />
|
|
<StatCard label="Deaths" value={dead.length} color="red" />
|
|
<StatCard
|
|
label="Routes"
|
|
value={completedCount}
|
|
total={totalLocations}
|
|
color="purple"
|
|
/>
|
|
</div>
|
|
|
|
{/* Level Cap Bar */}
|
|
{run.rules?.levelCaps && sortedBosses.length > 0 && (
|
|
<div className="sticky top-0 z-10 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3 mb-6 shadow-sm">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
|
Level Cap: {currentLevelCap ?? '—'}
|
|
</span>
|
|
{nextBoss && (
|
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
|
Next: {nextBoss.name}
|
|
</span>
|
|
)}
|
|
{!nextBoss && (
|
|
<span className="text-sm text-green-600 dark:text-green-400">
|
|
All bosses defeated!
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className="text-xs text-gray-400 dark:text-gray-500">
|
|
{defeatedBossIds.size}/{sortedBosses.length} defeated
|
|
</span>
|
|
</div>
|
|
{/* Badge row — gym leaders only */}
|
|
{sortedBosses.some((b) => b.bossType === 'gym_leader') && (
|
|
<div className="flex gap-2 flex-wrap">
|
|
{sortedBosses
|
|
.filter((b) => b.bossType === 'gym_leader')
|
|
.map((boss) => {
|
|
const earned = defeatedBossIds.has(boss.id)
|
|
return (
|
|
<div
|
|
key={boss.id}
|
|
className={`flex flex-col items-center transition-opacity ${earned ? '' : 'opacity-30 grayscale'}`}
|
|
title={`${boss.badgeName ?? boss.name}${earned ? ' (earned)' : ''}`}
|
|
>
|
|
{boss.badgeImageUrl ? (
|
|
<img
|
|
src={boss.badgeImageUrl}
|
|
alt={boss.badgeName ?? boss.name}
|
|
className="w-6 h-6"
|
|
/>
|
|
) : (
|
|
<div className={`w-6 h-6 rounded-full border-2 flex items-center justify-center text-xs font-bold ${
|
|
earned
|
|
? 'border-yellow-500 bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-300'
|
|
: 'border-gray-300 dark:border-gray-600 text-gray-400'
|
|
}`}>
|
|
{boss.order}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Rules */}
|
|
<div className="mb-6">
|
|
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
|
|
Active Rules
|
|
</h2>
|
|
<RuleBadges rules={run.rules} />
|
|
</div>
|
|
|
|
{/* Team Section */}
|
|
{(alive.length > 0 || dead.length > 0) && (
|
|
<div className="mb-6">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowTeam(!showTeam)}
|
|
className="flex items-center gap-2 mb-3 group"
|
|
>
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
{isActive ? 'Team' : 'Final Team'}
|
|
</h2>
|
|
<span className="text-xs text-gray-400 dark:text-gray-500">
|
|
{alive.length} alive{dead.length > 0 ? `, ${dead.length} dead` : ''}
|
|
</span>
|
|
<svg
|
|
className={`w-4 h-4 text-gray-400 transition-transform ${showTeam ? '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>
|
|
{showTeam && (
|
|
<>
|
|
{alive.length > 0 && (
|
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2 mb-3">
|
|
{alive.map((enc) => (
|
|
<PokemonCard
|
|
key={enc.id}
|
|
encounter={enc}
|
|
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
{dead.length > 0 && (
|
|
<>
|
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
|
|
Graveyard
|
|
</h3>
|
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
|
|
{dead.map((enc) => (
|
|
<PokemonCard
|
|
key={enc.id}
|
|
encounter={enc}
|
|
showFaintLevel
|
|
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
|
|
/>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Shiny Box */}
|
|
{run.rules?.shinyClause && shinyEncounters.length > 0 && (
|
|
<div className="mb-6">
|
|
<ShinyBox
|
|
encounters={shinyEncounters}
|
|
onEncounterClick={isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Transfer Encounters */}
|
|
{transferEncounters.length > 0 && (
|
|
<div className="mb-6">
|
|
<h2 className="text-sm font-medium text-indigo-600 dark:text-indigo-400 mb-2">
|
|
Transferred Pokemon
|
|
</h2>
|
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
|
|
{transferEncounters.map((enc) => (
|
|
<PokemonCard
|
|
key={enc.id}
|
|
encounter={enc}
|
|
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Progress bar */}
|
|
<div className="mb-4">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<div className="flex items-center gap-3">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
Encounters
|
|
</h2>
|
|
{isActive && completedCount < totalLocations && (
|
|
<button
|
|
type="button"
|
|
disabled={bulkRandomize.isPending}
|
|
onClick={() => {
|
|
const remaining = totalLocations - completedCount
|
|
if (window.confirm(`Randomize encounters for all ${remaining} remaining locations?`)) {
|
|
bulkRandomize.mutate()
|
|
}
|
|
}}
|
|
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-300 dark:border-purple-600 text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
{bulkRandomize.isPending ? 'Randomizing...' : 'Randomize All'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
|
{completedCount} / {totalLocations} locations
|
|
</span>
|
|
</div>
|
|
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-blue-500 rounded-full transition-all"
|
|
style={{
|
|
width: `${totalLocations > 0 ? (completedCount / totalLocations) * 100 : 0}%`,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filter tabs */}
|
|
<div className="flex gap-2 mb-4 flex-wrap">
|
|
{(
|
|
[
|
|
{ key: 'all', label: 'All' },
|
|
{ key: 'none', label: 'Unvisited' },
|
|
{ key: 'caught', label: 'Caught' },
|
|
{ key: 'fainted', label: 'Fainted' },
|
|
{ key: 'missed', label: 'Missed' },
|
|
] as const
|
|
).map(({ key, label }) => (
|
|
<button
|
|
key={key}
|
|
onClick={() => setFilter(key)}
|
|
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
|
filter === key
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
|
}`}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Route list */}
|
|
<div className="space-y-1">
|
|
{filteredRoutes.length === 0 && (
|
|
<p className="text-gray-500 dark:text-gray-400 text-sm py-4 text-center">
|
|
{filter === 'all'
|
|
? 'Click a route to log your first encounter'
|
|
: 'No routes match this filter — try a different one'}
|
|
</p>
|
|
)}
|
|
{filteredRoutes.map((route) => {
|
|
// Collect all route IDs to check for boss cards after
|
|
const routeIds: number[] = route.children.length > 0
|
|
? [route.id, ...route.children.map((c) => c.id)]
|
|
: [route.id]
|
|
|
|
// Find boss battles positioned after this route (or any of its children)
|
|
const bossesHere: BossBattle[] = []
|
|
for (const rid of routeIds) {
|
|
const b = bossesAfterRoute.get(rid)
|
|
if (b) bossesHere.push(...b)
|
|
}
|
|
|
|
const routeElement = route.children.length > 0 ? (
|
|
<RouteGroup
|
|
key={route.id}
|
|
group={route}
|
|
encounterByRoute={encounterByRoute}
|
|
isExpanded={expandedGroups.has(route.id)}
|
|
onToggleExpand={() => toggleGroup(route.id)}
|
|
onRouteClick={handleRouteClick}
|
|
filter={filter}
|
|
pinwheelClause={pinwheelClause}
|
|
/>
|
|
) : (() => {
|
|
const encounter = encounterByRoute.get(route.id)
|
|
const rs = getRouteStatus(encounter)
|
|
const si = statusIndicator[rs]
|
|
|
|
return (
|
|
<button
|
|
key={route.id}
|
|
type="button"
|
|
onClick={() => handleRouteClick(route)}
|
|
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg 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">
|
|
{route.name}
|
|
</div>
|
|
{encounter ? (
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
{encounter.pokemon.spriteUrl && (
|
|
<img
|
|
src={encounter.pokemon.spriteUrl}
|
|
alt={encounter.pokemon.name}
|
|
className="w-10 h-10"
|
|
/>
|
|
)}
|
|
<span className="text-xs text-gray-500 dark:text-gray-400 capitalize">
|
|
{encounter.nickname ?? encounter.pokemon.name}
|
|
{encounter.status === 'caught' &&
|
|
encounter.faintLevel !== null &&
|
|
(encounter.deathCause
|
|
? ` — ${encounter.deathCause}`
|
|
: ' (dead)')}
|
|
</span>
|
|
</div>
|
|
) : route.encounterMethods.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-0.5">
|
|
{route.encounterMethods.map((m) => (
|
|
<EncounterMethodBadge key={m} method={m} size="xs" />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
|
|
{si.label}
|
|
</span>
|
|
</button>
|
|
)
|
|
})()
|
|
|
|
return (
|
|
<div key={route.id}>
|
|
{routeElement}
|
|
{/* Boss battle cards after this route */}
|
|
{bossesHere.map((boss) => {
|
|
const isDefeated = defeatedBossIds.has(boss.id)
|
|
const sectionAfter = sectionDividerAfterBoss.get(boss.id)
|
|
const bossTypeLabel: Record<string, string> = {
|
|
gym_leader: 'Gym Leader',
|
|
elite_four: 'Elite Four',
|
|
champion: 'Champion',
|
|
rival: 'Rival',
|
|
evil_team: 'Evil Team',
|
|
other: 'Boss',
|
|
}
|
|
const bossTypeColors: Record<string, string> = {
|
|
gym_leader: 'border-yellow-400 dark:border-yellow-600',
|
|
elite_four: 'border-purple-400 dark:border-purple-600',
|
|
champion: 'border-red-400 dark:border-red-600',
|
|
rival: 'border-blue-400 dark:border-blue-600',
|
|
evil_team: 'border-gray-500 dark:border-gray-400',
|
|
other: 'border-gray-400 dark:border-gray-500',
|
|
}
|
|
|
|
const isBossExpanded = expandedBosses.has(boss.id)
|
|
const toggleBoss = () => {
|
|
setExpandedBosses((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(boss.id)) next.delete(boss.id)
|
|
else next.add(boss.id)
|
|
return next
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div key={`boss-${boss.id}`}>
|
|
<div
|
|
className={`my-2 rounded-lg border-2 ${bossTypeColors[boss.bossType] ?? bossTypeColors.other} ${
|
|
isDefeated ? 'bg-green-50/50 dark:bg-green-900/10' : 'bg-white dark:bg-gray-800'
|
|
} px-4 py-3`}
|
|
>
|
|
<div
|
|
className="flex items-start justify-between cursor-pointer select-none"
|
|
onClick={toggleBoss}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<svg
|
|
className={`w-4 h-4 text-gray-400 transition-transform ${isBossExpanded ? 'rotate-90' : ''}`}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
{boss.spriteUrl && (
|
|
<img src={boss.spriteUrl} alt={boss.name} className="w-10 h-10" />
|
|
)}
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
|
{boss.name}
|
|
</span>
|
|
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
|
|
{bossTypeLabel[boss.bossType] ?? boss.bossType}
|
|
</span>
|
|
{boss.specialtyType && (
|
|
<TypeBadge type={boss.specialtyType} />
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
{boss.location} · Level Cap: {boss.levelCap}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div onClick={(e) => e.stopPropagation()}>
|
|
{isDefeated ? (
|
|
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300">
|
|
Defeated ✓
|
|
</span>
|
|
) : isActive ? (
|
|
<button
|
|
onClick={() => setSelectedBoss(boss)}
|
|
className="px-3 py-1 text-xs font-medium rounded-full bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
|
>
|
|
Battle
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
{/* Boss pokemon team */}
|
|
{isBossExpanded && boss.pokemon.length > 0 && (
|
|
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} />
|
|
)}
|
|
</div>
|
|
{sectionAfter && (
|
|
<div className="flex items-center gap-3 my-4">
|
|
<div className="flex-1 h-px bg-gray-300 dark:bg-gray-600" />
|
|
<span className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">{sectionAfter}</span>
|
|
<div className="flex-1 h-px bg-gray-300 dark:bg-gray-600" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Encounter Modal */}
|
|
{selectedRoute && (
|
|
<EncounterModal
|
|
route={selectedRoute}
|
|
gameId={run!.gameId}
|
|
existing={editingEncounter ?? undefined}
|
|
dupedPokemonIds={dupedPokemonIds}
|
|
retiredPokemonIds={retiredPokemonIds}
|
|
onSubmit={handleCreate}
|
|
onUpdate={handleUpdate}
|
|
onClose={() => {
|
|
setSelectedRoute(null)
|
|
setEditingEncounter(null)
|
|
}}
|
|
isPending={createEncounter.isPending || updateEncounter.isPending}
|
|
/>
|
|
)}
|
|
|
|
{/* Shiny Encounter Modal */}
|
|
{showShinyModal && routes && (
|
|
<ShinyEncounterModal
|
|
routes={routes}
|
|
gameId={run!.gameId}
|
|
onSubmit={handleCreate}
|
|
onClose={() => setShowShinyModal(false)}
|
|
isPending={createEncounter.isPending}
|
|
/>
|
|
)}
|
|
|
|
{/* Egg Encounter Modal */}
|
|
{showEggModal && routes && (
|
|
<EggEncounterModal
|
|
routes={routes}
|
|
onSubmit={handleCreate}
|
|
onClose={() => setShowEggModal(false)}
|
|
isPending={createEncounter.isPending}
|
|
/>
|
|
)}
|
|
|
|
{/* Status Change Modal (team pokemon) */}
|
|
{selectedTeamEncounter && (
|
|
<StatusChangeModal
|
|
encounter={selectedTeamEncounter}
|
|
onUpdate={(data) => {
|
|
updateEncounter.mutate(data, {
|
|
onSuccess: () => setSelectedTeamEncounter(null),
|
|
})
|
|
}}
|
|
onClose={() => setSelectedTeamEncounter(null)}
|
|
isPending={updateEncounter.isPending}
|
|
region={run?.game.region}
|
|
onCreateEncounter={(data) => {
|
|
createEncounter.mutate(data)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Boss Defeat Modal */}
|
|
{selectedBoss && (
|
|
<BossDefeatModal
|
|
boss={selectedBoss}
|
|
onSubmit={(data) => {
|
|
createBossResult.mutate(data, {
|
|
onSuccess: () => setSelectedBoss(null),
|
|
})
|
|
}}
|
|
onClose={() => setSelectedBoss(null)}
|
|
isPending={createBossResult.isPending}
|
|
hardcoreMode={run?.rules?.hardcoreMode}
|
|
starterName={starterName}
|
|
/>
|
|
)}
|
|
|
|
{/* End Run Modal */}
|
|
{showEndRun && (
|
|
<EndRunModal
|
|
onConfirm={(status) => {
|
|
updateRun.mutate(
|
|
{ status },
|
|
{
|
|
onSuccess: () => {
|
|
setShowEndRun(false)
|
|
if (status === 'completed') {
|
|
setShowHofModal(true)
|
|
}
|
|
},
|
|
},
|
|
)
|
|
}}
|
|
onClose={() => setShowEndRun(false)}
|
|
isPending={updateRun.isPending}
|
|
genlockeContext={run.genlocke}
|
|
/>
|
|
)}
|
|
|
|
{/* HoF Team Selection Modal */}
|
|
{showHofModal && (
|
|
<HofTeamModal
|
|
alive={alive}
|
|
onSubmit={(encounterIds) => {
|
|
updateRun.mutate(
|
|
{ hofEncounterIds: encounterIds },
|
|
{ onSuccess: () => setShowHofModal(false) },
|
|
)
|
|
}}
|
|
onSkip={() => setShowHofModal(false)}
|
|
isPending={updateRun.isPending}
|
|
/>
|
|
)}
|
|
|
|
{/* Transfer Modal */}
|
|
{showTransferModal && hofTeam && hofTeam.length > 0 && (
|
|
<TransferModal
|
|
hofTeam={hofTeam}
|
|
onSubmit={(encounterIds) => {
|
|
advanceLeg.mutate(
|
|
{
|
|
genlockeId: run!.genlocke!.genlockeId,
|
|
legOrder: run!.genlocke!.legOrder,
|
|
transferEncounterIds: encounterIds,
|
|
},
|
|
{
|
|
onSuccess: (genlocke) => {
|
|
setShowTransferModal(false)
|
|
const nextLeg = genlocke.legs.find(
|
|
(l) => l.legOrder === run!.genlocke!.legOrder + 1,
|
|
)
|
|
if (nextLeg?.runId) {
|
|
navigate(`/runs/${nextLeg.runId}`)
|
|
}
|
|
},
|
|
},
|
|
)
|
|
}}
|
|
onSkip={() => {
|
|
advanceLeg.mutate(
|
|
{
|
|
genlockeId: run!.genlocke!.genlockeId,
|
|
legOrder: run!.genlocke!.legOrder,
|
|
},
|
|
{
|
|
onSuccess: (genlocke) => {
|
|
setShowTransferModal(false)
|
|
const nextLeg = genlocke.legs.find(
|
|
(l) => l.legOrder === run!.genlocke!.legOrder + 1,
|
|
)
|
|
if (nextLeg?.runId) {
|
|
navigate(`/runs/${nextLeg.runId}`)
|
|
}
|
|
},
|
|
},
|
|
)
|
|
}}
|
|
isPending={advanceLeg.isPending}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|