Add user authentication with login/signup/protected routes, boss pokemon detail fields and result team tracking, moves and abilities selector components and API, run ownership and visibility controls, and various UI improvements across encounters, run list, and journal pages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1808 lines
68 KiB
TypeScript
1808 lines
68 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 {
|
|
CustomRulesDisplay,
|
|
EggEncounterModal,
|
|
EncounterModal,
|
|
EncounterMethodBadge,
|
|
HofTeamModal,
|
|
TransferModal,
|
|
StatCard,
|
|
PokemonCard,
|
|
StatusChangeModal,
|
|
EndRunModal,
|
|
RuleBadges,
|
|
ShinyBox,
|
|
ShinyEncounterModal,
|
|
TypeBadge,
|
|
} from '../components'
|
|
import { BossDefeatModal } from '../components/BossDefeatModal'
|
|
import { ConditionBadge } from '../components/ConditionBadge'
|
|
import { JournalSection } from '../components/journal'
|
|
import type {
|
|
Route,
|
|
RouteWithChildren,
|
|
RunStatus,
|
|
EncounterDetail,
|
|
EncounterStatus,
|
|
CreateEncounterInput,
|
|
BossBattle,
|
|
BossPokemon,
|
|
} from '../types'
|
|
|
|
type TeamSortKey = 'route' | 'level' | 'species' | 'dex'
|
|
|
|
function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): EncounterDetail[] {
|
|
return [...encounters].sort((a, b) => {
|
|
switch (key) {
|
|
case 'route':
|
|
return a.route.order - b.route.order
|
|
case 'level':
|
|
return (a.catchLevel ?? 0) - (b.catchLevel ?? 0)
|
|
case 'species': {
|
|
const nameA = (a.currentPokemon ?? a.pokemon).name
|
|
const nameB = (b.currentPokemon ?? b.pokemon).name
|
|
return nameA.localeCompare(nameB)
|
|
}
|
|
case 'dex':
|
|
return (
|
|
(a.currentPokemon ?? a.pokemon).nationalDex - (b.currentPokemon ?? b.pokemon).nationalDex
|
|
)
|
|
default:
|
|
return 0
|
|
}
|
|
})
|
|
}
|
|
|
|
const statusStyles: Record<RunStatus, string> = {
|
|
active: 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800',
|
|
completed: 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-800',
|
|
failed: 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800',
|
|
}
|
|
|
|
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-900/10',
|
|
},
|
|
fainted: {
|
|
dot: 'bg-red-500',
|
|
label: 'Fainted',
|
|
bg: 'bg-red-900/10',
|
|
},
|
|
missed: {
|
|
dot: 'bg-gray-400',
|
|
label: 'Missed',
|
|
bg: 'bg-surface-0/10',
|
|
},
|
|
none: { dot: 'bg-surface-3', 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) : null
|
|
}
|
|
|
|
/** Count boss pokemon for the effective variant (or all if no variants). */
|
|
function getBossTeamSize(pokemon: BossPokemon[], starterName?: string | null): number {
|
|
const labels = [
|
|
...new Set(pokemon.filter((bp) => bp.conditionLabel).map((bp) => bp.conditionLabel!)),
|
|
]
|
|
if (labels.length === 0) return pokemon.length
|
|
const matched = matchVariant(labels, starterName)
|
|
const variant = matched ?? labels[0] ?? null
|
|
return pokemon.filter((bp) => bp.conditionLabel === variant || bp.conditionLabel === null).length
|
|
}
|
|
|
|
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) : 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-surface-2 text-text-secondary hover:bg-surface-3'
|
|
}`}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="flex gap-2 flex-wrap">
|
|
{[...displayed]
|
|
.sort((a, b) => a.order - b.order)
|
|
.map((bp) => {
|
|
const moves = [bp.move1, bp.move2, bp.move3, bp.move4].filter(Boolean)
|
|
return (
|
|
<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-surface-3 rounded-full" />
|
|
)}
|
|
<div className="flex flex-col items-start gap-0.5">
|
|
<span className="text-xs text-text-tertiary">Lvl {bp.level}</span>
|
|
<ConditionBadge condition={bp.conditionLabel} size="xs" />
|
|
{bp.ability && (
|
|
<span className="text-[10px] text-text-muted">{bp.ability.name}</span>
|
|
)}
|
|
{bp.heldItem && (
|
|
<span className="text-[10px] text-yellow-500/80">{bp.heldItem}</span>
|
|
)}
|
|
{moves.length > 0 && (
|
|
<div className="text-[9px] text-text-muted leading-tight">
|
|
{moves.map((m) => m!.name).join(', ')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface RouteGroupProps {
|
|
group: RouteWithChildren
|
|
encounterByRoute: Map<number, EncounterDetail>
|
|
giftEncounterByRoute: Map<number, EncounterDetail>
|
|
isExpanded: boolean
|
|
onToggleExpand: () => void
|
|
onRouteClick: (route: Route) => void
|
|
filter: 'all' | RouteStatus
|
|
pinwheelClause: boolean
|
|
}
|
|
|
|
function RouteGroup({
|
|
group,
|
|
encounterByRoute,
|
|
giftEncounterByRoute,
|
|
isExpanded,
|
|
onToggleExpand,
|
|
onRouteClick,
|
|
filter,
|
|
pinwheelClause,
|
|
}: RouteGroupProps) {
|
|
const groupEncounter = getGroupEncounter(group, encounterByRoute)
|
|
const usePinwheel = pinwheelClause && groupHasZones(group)
|
|
const zoneEncounters = usePinwheel ? getZoneEncounters(group, encounterByRoute) : null
|
|
|
|
// Find first gift encounter in the group (for display)
|
|
let groupGiftEncounter: EncounterDetail | null = null
|
|
for (const child of group.children) {
|
|
const gift = giftEncounterByRoute.get(child.id)
|
|
if (gift) {
|
|
groupGiftEncounter = gift
|
|
break
|
|
}
|
|
}
|
|
const displayEncounter = groupEncounter ?? groupGiftEncounter
|
|
|
|
// For pinwheel groups, determine status from all zone statuses
|
|
let groupStatus: RouteStatus
|
|
if (usePinwheel && zoneEncounters && zoneEncounters.size > 0) {
|
|
groupStatus = displayEncounter ? displayEncounter.status : 'none'
|
|
} else {
|
|
groupStatus = displayEncounter ? displayEncounter.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) ?? giftEncounterByRoute.get(child.id)
|
|
return getRouteStatus(enc) === filter
|
|
})
|
|
if (!anyChildMatches) return null
|
|
} else if (groupStatus !== filter) {
|
|
return null
|
|
}
|
|
}
|
|
|
|
const hasGroupEncounter = groupEncounter !== null
|
|
|
|
return (
|
|
<div className="border border-border-default 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-surface-2/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-text-primary flex items-center gap-2">
|
|
{group.name}
|
|
<span className="text-xs text-text-muted">({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-text-tertiary capitalize">
|
|
{groupEncounter.nickname ?? groupEncounter.pokemon.name}
|
|
{groupEncounter.status === 'caught' &&
|
|
groupEncounter.faintLevel !== null &&
|
|
(groupEncounter.deathCause ? ` — ${groupEncounter.deathCause}` : ' (dead)')}
|
|
</span>
|
|
{groupGiftEncounter && (
|
|
<>
|
|
{groupGiftEncounter.pokemon.spriteUrl && (
|
|
<img
|
|
src={groupGiftEncounter.pokemon.spriteUrl}
|
|
alt={groupGiftEncounter.pokemon.name}
|
|
className="w-8 h-8 opacity-60"
|
|
/>
|
|
)}
|
|
<span className="text-xs text-text-tertiary capitalize">
|
|
{groupGiftEncounter.nickname ?? groupGiftEncounter.pokemon.name}
|
|
<span className="text-text-muted ml-1">(gift)</span>
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
{!groupEncounter && groupGiftEncounter && (
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
{groupGiftEncounter.pokemon.spriteUrl && (
|
|
<img
|
|
src={groupGiftEncounter.pokemon.spriteUrl}
|
|
alt={groupGiftEncounter.pokemon.name}
|
|
className="w-8 h-8 opacity-60"
|
|
/>
|
|
)}
|
|
<span className="text-xs text-text-tertiary capitalize">
|
|
{groupGiftEncounter.nickname ?? groupGiftEncounter.pokemon.name}
|
|
<span className="text-text-muted ml-1">(gift)</span>
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<span className="text-xs text-text-muted shrink-0">{si.label}</span>
|
|
<svg
|
|
className={`w-4 h-4 text-text-tertiary 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-border-default bg-surface-1/50">
|
|
{group.children.map((child) => {
|
|
const childEncounter = encounterByRoute.get(child.id)
|
|
const giftEncounter = giftEncounterByRoute.get(child.id)
|
|
const displayEncounter = childEncounter ?? giftEncounter
|
|
const childStatus = getRouteStatus(displayEncounter)
|
|
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-surface-2/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-text-secondary">{child.name}</div>
|
|
{giftEncounter && !childEncounter && (
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
{giftEncounter.pokemon.spriteUrl && (
|
|
<img
|
|
src={giftEncounter.pokemon.spriteUrl}
|
|
alt={giftEncounter.pokemon.name}
|
|
className="w-8 h-8 opacity-60"
|
|
/>
|
|
)}
|
|
<span className="text-xs text-text-tertiary capitalize">
|
|
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
|
|
<span className="text-text-muted ml-1">(gift)</span>
|
|
</span>
|
|
</div>
|
|
)}
|
|
{!displayEncounter && 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-text-muted">{childSi.label}</span>}
|
|
{isDisabled && <span className="text-xs text-text-muted 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 rulesAllowedTypes = run?.rules?.allowedTypes ?? []
|
|
const { data: routes, isLoading: loadingRoutes } = useGameRoutes(
|
|
run?.gameId ?? null,
|
|
rulesAllowedTypes.length ? rulesAllowedTypes : undefined
|
|
)
|
|
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 [teamSort, setTeamSort] = useState<TeamSortKey>('route')
|
|
const [filter, setFilter] = useState<'all' | RouteStatus>('all')
|
|
const [activeTab, setActiveTab] = useState<'encounters' | 'journal'>('encounters')
|
|
|
|
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])
|
|
|
|
const giftClauseOn = run?.rules?.giftClause ?? false
|
|
|
|
// Map routeId → encounter for quick lookup (normal encounters only).
|
|
// When gift clause is on, gift-origin encounters are excluded so they
|
|
// don't lock the route for a regular encounter.
|
|
const encounterByRoute = useMemo(() => {
|
|
const map = new Map<number, EncounterDetail>()
|
|
for (const enc of normalEncounters) {
|
|
if (giftClauseOn && enc.origin === 'gift') continue
|
|
map.set(enc.routeId, enc)
|
|
}
|
|
return map
|
|
}, [normalEncounters, giftClauseOn])
|
|
|
|
// Separate map for gift encounters (only populated when gift clause is on)
|
|
const giftEncounterByRoute = useMemo(() => {
|
|
const map = new Map<number, EncounterDetail>()
|
|
if (!giftClauseOn) return map
|
|
for (const enc of normalEncounters) {
|
|
if (enc.origin === 'gift') map.set(enc.routeId, enc)
|
|
}
|
|
return map
|
|
}, [normalEncounters, giftClauseOn])
|
|
|
|
// 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])
|
|
|
|
// Map encounter ID to encounter detail for team display
|
|
const encounterById = useMemo(() => {
|
|
const map = new Map<number, EncounterDetail>()
|
|
if (run) {
|
|
for (const enc of run.encounters) {
|
|
map.set(enc.id, enc)
|
|
}
|
|
}
|
|
return map
|
|
}, [run])
|
|
|
|
// Map boss battle ID to result for team snapshot
|
|
const bossResultByBattleId = useMemo(() => {
|
|
const map = new Map<number, (typeof bossResults)[number]>()
|
|
if (bossResults) {
|
|
for (const r of bossResults) {
|
|
map.set(r.bossBattleId, r)
|
|
}
|
|
}
|
|
return map
|
|
}, [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(
|
|
() =>
|
|
sortEncounters(
|
|
[...normalEncounters, ...transferEncounters, ...shinyEncounters].filter(
|
|
(e) => e.status === 'caught' && e.faintLevel === null
|
|
),
|
|
teamSort
|
|
),
|
|
[normalEncounters, transferEncounters, shinyEncounters, teamSort]
|
|
)
|
|
|
|
const dead = useMemo(
|
|
() =>
|
|
sortEncounters(
|
|
normalEncounters.filter((e) => e.status === 'caught' && e.faintLevel !== null),
|
|
teamSort
|
|
),
|
|
[normalEncounters, teamSort]
|
|
)
|
|
|
|
// 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-status-failed-bg p-4 text-status-failed">
|
|
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
|
|
const useAllPokemon = !!(run.rules?.egglocke || run.rules?.wonderlocke || run.rules?.randomizer)
|
|
|
|
// 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 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 | undefined
|
|
status?: EncounterStatus | undefined
|
|
faintLevel?: number | undefined
|
|
deathCause?: string | undefined
|
|
}
|
|
}) => {
|
|
updateEncounter.mutate(data, {
|
|
onSuccess: () => {
|
|
setSelectedRoute(null)
|
|
setEditingEncounter(null)
|
|
},
|
|
})
|
|
}
|
|
|
|
// Filter routes (check both regular and gift encounters for status)
|
|
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) ?? giftEncounterByRoute.get(child.id)
|
|
return getRouteStatus(enc) === filter
|
|
})
|
|
}
|
|
// Classic: single status for whole group
|
|
const groupEnc = getGroupEncounter(r, encounterByRoute)
|
|
if (groupEnc) return getRouteStatus(groupEnc) === filter
|
|
// Check gift encounters if no regular encounter in group
|
|
for (const child of r.children) {
|
|
const gift = giftEncounterByRoute.get(child.id)
|
|
if (gift) return getRouteStatus(gift) === filter
|
|
}
|
|
return filter === 'none'
|
|
}
|
|
|
|
// Standalone route
|
|
const enc = encounterByRoute.get(r.id) ?? giftEncounterByRoute.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-text-tertiary hover:text-text-primary mb-2 inline-block"
|
|
>
|
|
← All Runs
|
|
</Link>
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-text-primary">{run.name}</h1>
|
|
<p className="text-text-tertiary 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-400 light:text-purple-700 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-600 text-yellow-400 light:text-amber-700 light:border-amber-600 rounded-full font-medium hover:bg-yellow-900/20 light:hover:bg-amber-50 transition-colors"
|
|
>
|
|
✦ Log Shiny
|
|
</button>
|
|
)}
|
|
{isActive && (
|
|
<button
|
|
onClick={() => setShowEggModal(true)}
|
|
className="px-3 py-1 text-sm border border-green-600 text-status-active rounded-full font-medium hover:bg-green-900/20 transition-colors"
|
|
>
|
|
🥚 Log Egg
|
|
</button>
|
|
)}
|
|
{isActive && (
|
|
<button
|
|
onClick={() => setShowEndRun(true)}
|
|
className="px-3 py-1 text-sm border border-border-default rounded-full font-medium hover:bg-surface-2 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-900/20 border border-blue-800'
|
|
: 'bg-status-failed-bg border 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-200' : '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-text-link' : 'text-status-failed'
|
|
}`}
|
|
>
|
|
{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-accent-600 text-white hover:bg-accent-500 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-800">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-xs font-medium text-text-link uppercase tracking-wider">
|
|
Hall of Fame
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowHofModal(true)}
|
|
className="text-xs text-blue-400 hover:text-accent-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-surface-3 flex items-center justify-center text-sm font-bold">
|
|
{dp.name[0]?.toUpperCase()}
|
|
</div>
|
|
)}
|
|
<span className="text-[10px] text-text-link capitalize mt-0.5">
|
|
{enc.nickname || dp.name}
|
|
</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs 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-14 z-30 bg-surface-0 border border-border-default 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-text-primary">
|
|
Level Cap: {currentLevelCap ?? '—'}
|
|
</span>
|
|
{nextBoss && (
|
|
<span className="text-sm text-text-tertiary">
|
|
Next: {nextBoss.name}
|
|
<span className="text-text-muted">
|
|
{' '}
|
|
({getBossTeamSize(nextBoss.pokemon, starterName)} Pokémon)
|
|
</span>
|
|
</span>
|
|
)}
|
|
{!nextBoss && (
|
|
<span className="text-sm text-status-active">All bosses defeated!</span>
|
|
)}
|
|
</div>
|
|
<span className="text-xs text-text-muted">
|
|
{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-900/40 text-yellow-300'
|
|
: 'border-border-default text-text-tertiary'
|
|
}`}
|
|
>
|
|
{boss.order}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Rules */}
|
|
<div className="mb-6">
|
|
<h2 className="text-sm font-medium text-text-tertiary mb-2">Active Rules</h2>
|
|
<RuleBadges rules={run.rules} />
|
|
<CustomRulesDisplay customRules={run.rules?.customRules ?? ''} />
|
|
</div>
|
|
|
|
{/* Tab Navigation */}
|
|
<div className="flex gap-1 mb-6 border-b border-border">
|
|
<button
|
|
type="button"
|
|
onClick={() => setActiveTab('encounters')}
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
|
activeTab === 'encounters'
|
|
? 'border-blue-500 text-blue-500'
|
|
: 'border-transparent text-text-secondary hover:text-text-primary'
|
|
}`}
|
|
>
|
|
Encounters
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setActiveTab('journal')}
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
|
activeTab === 'journal'
|
|
? 'border-blue-500 text-blue-500'
|
|
: 'border-transparent text-text-secondary hover:text-text-primary'
|
|
}`}
|
|
>
|
|
Journal
|
|
</button>
|
|
</div>
|
|
|
|
{/* Journal Tab */}
|
|
{activeTab === 'journal' && (
|
|
<JournalSection runId={runIdNum} bossResults={bossResults} bosses={bosses} />
|
|
)}
|
|
|
|
{/* Encounters Tab */}
|
|
{activeTab === 'encounters' && (
|
|
<>
|
|
{/* Team Section */}
|
|
{(alive.length > 0 || dead.length > 0) && (
|
|
<div className="mb-6">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowTeam(!showTeam)}
|
|
className="flex items-center gap-2 group"
|
|
>
|
|
<h2 className="text-lg font-semibold text-text-primary">
|
|
{isActive ? 'Team' : 'Final Team'}
|
|
</h2>
|
|
<span className="text-xs text-text-muted">
|
|
{alive.length} alive
|
|
{dead.length > 0 ? `, ${dead.length} dead` : ''}
|
|
</span>
|
|
<svg
|
|
className={`w-4 h-4 text-text-tertiary 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 > 1 && (
|
|
<select
|
|
value={teamSort}
|
|
onChange={(e) => setTeamSort(e.target.value as TeamSortKey)}
|
|
className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-1 text-text-primary"
|
|
>
|
|
<option value="route">Route Order</option>
|
|
<option value="level">Catch Level</option>
|
|
<option value="species">Species Name</option>
|
|
<option value="dex">National Dex</option>
|
|
</select>
|
|
)}
|
|
</div>
|
|
{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-text-tertiary 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-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-text-primary">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-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"
|
|
>
|
|
{bulkRandomize.isPending ? 'Randomizing...' : 'Randomize All'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<span className="text-sm text-text-tertiary">
|
|
{completedCount} / {totalLocations} locations
|
|
</span>
|
|
</div>
|
|
<div className="h-2 bg-surface-3 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-surface-2 text-text-secondary hover:bg-surface-3'
|
|
}`}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Route list */}
|
|
<div className="space-y-1">
|
|
{filteredRoutes.length === 0 && (
|
|
<p className="text-text-tertiary 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}
|
|
giftEncounterByRoute={giftEncounterByRoute}
|
|
isExpanded={expandedGroups.has(route.id)}
|
|
onToggleExpand={() => toggleGroup(route.id)}
|
|
onRouteClick={handleRouteClick}
|
|
filter={filter}
|
|
pinwheelClause={pinwheelClause}
|
|
/>
|
|
) : (
|
|
(() => {
|
|
const encounter = encounterByRoute.get(route.id)
|
|
const giftEncounter = giftEncounterByRoute.get(route.id)
|
|
const displayEncounter = encounter ?? giftEncounter
|
|
const rs = getRouteStatus(displayEncounter)
|
|
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-surface-2/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-text-primary">{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-text-tertiary capitalize">
|
|
{encounter.nickname ?? encounter.pokemon.name}
|
|
{encounter.status === 'caught' &&
|
|
encounter.faintLevel !== null &&
|
|
(encounter.deathCause ? ` — ${encounter.deathCause}` : ' (dead)')}
|
|
</span>
|
|
{giftEncounter && (
|
|
<>
|
|
{giftEncounter.pokemon.spriteUrl && (
|
|
<img
|
|
src={giftEncounter.pokemon.spriteUrl}
|
|
alt={giftEncounter.pokemon.name}
|
|
className="w-8 h-8 opacity-60"
|
|
/>
|
|
)}
|
|
<span className="text-xs text-text-tertiary capitalize">
|
|
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
|
|
<span className="text-text-muted ml-1">(gift)</span>
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
) : giftEncounter ? (
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
{giftEncounter.pokemon.spriteUrl && (
|
|
<img
|
|
src={giftEncounter.pokemon.spriteUrl}
|
|
alt={giftEncounter.pokemon.name}
|
|
className="w-8 h-8 opacity-60"
|
|
/>
|
|
)}
|
|
<span className="text-xs text-text-tertiary capitalize">
|
|
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
|
|
<span className="text-text-muted ml-1">(gift)</span>
|
|
</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-text-muted 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',
|
|
kahuna: 'Kahuna',
|
|
totem: 'Totem',
|
|
other: 'Boss',
|
|
}
|
|
const bossTypeColors: Record<string, string> = {
|
|
gym_leader: 'border-yellow-600',
|
|
elite_four: 'border-purple-600',
|
|
champion: 'border-red-600',
|
|
rival: 'border-blue-600',
|
|
evil_team: 'border-gray-400',
|
|
kahuna: 'border-orange-600',
|
|
totem: 'border-teal-600',
|
|
other: '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-900/10' : 'bg-surface-1'
|
|
} 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-text-tertiary 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="h-10 w-auto" />
|
|
)}
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-semibold text-text-primary">
|
|
{boss.name}
|
|
</span>
|
|
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-surface-2 text-text-secondary">
|
|
{bossTypeLabel[boss.bossType] ?? boss.bossType}
|
|
</span>
|
|
{boss.specialtyType && <TypeBadge type={boss.specialtyType} />}
|
|
</div>
|
|
<p className="text-xs text-text-tertiary">
|
|
{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-900/40 text-green-300 light:bg-green-100 light:text-green-800">
|
|
Defeated ✓
|
|
</span>
|
|
) : isActive ? (
|
|
<button
|
|
onClick={() => setSelectedBoss(boss)}
|
|
className="px-3 py-1 text-xs font-medium rounded-full bg-accent-600 text-white hover:bg-accent-500 transition-colors"
|
|
>
|
|
Battle
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
{/* Boss pokemon team */}
|
|
{isBossExpanded && boss.pokemon.length > 0 && (
|
|
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} />
|
|
)}
|
|
{/* Player team snapshot */}
|
|
{isDefeated && (() => {
|
|
const result = bossResultByBattleId.get(boss.id)
|
|
if (!result || result.team.length === 0) return null
|
|
return (
|
|
<div className="mt-3 pt-3 border-t border-border-default">
|
|
<p className="text-xs font-medium text-text-secondary mb-2">Your Team</p>
|
|
<div className="flex gap-2 flex-wrap">
|
|
{result.team.map((tm) => {
|
|
const enc = encounterById.get(tm.encounterId)
|
|
if (!enc) return null
|
|
const dp = enc.currentPokemon ?? enc.pokemon
|
|
return (
|
|
<div key={tm.id} className="flex flex-col items-center">
|
|
{dp.spriteUrl ? (
|
|
<img src={dp.spriteUrl} alt={dp.name} className="w-10 h-10" />
|
|
) : (
|
|
<div className="w-10 h-10 bg-surface-3 rounded-full" />
|
|
)}
|
|
<span className="text-[10px] text-text-tertiary capitalize">
|
|
{enc.nickname ?? dp.name}
|
|
</span>
|
|
<span className="text-[10px] text-text-muted">Lv.{tm.level}</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
})()}
|
|
</div>
|
|
{sectionAfter && (
|
|
<div className="flex items-center gap-3 my-4">
|
|
<div className="flex-1 h-px bg-surface-3" />
|
|
<span className="text-sm font-semibold text-text-tertiary uppercase tracking-wider">
|
|
{sectionAfter}
|
|
</span>
|
|
<div className="flex-1 h-px bg-surface-3" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Encounter Modal */}
|
|
{selectedRoute && (
|
|
<EncounterModal
|
|
route={selectedRoute}
|
|
gameId={run!.gameId}
|
|
runId={runIdNum}
|
|
namingScheme={run!.namingScheme}
|
|
isGenlocke={!!run!.genlocke}
|
|
existing={editingEncounter ?? undefined}
|
|
dupedPokemonIds={dupedPokemonIds}
|
|
retiredPokemonIds={retiredPokemonIds}
|
|
onSubmit={handleCreate}
|
|
onUpdate={handleUpdate}
|
|
onClose={() => {
|
|
setSelectedRoute(null)
|
|
setEditingEncounter(null)
|
|
}}
|
|
isPending={createEncounter.isPending || updateEncounter.isPending}
|
|
useAllPokemon={useAllPokemon}
|
|
staticClause={run?.rules?.staticClause ?? true}
|
|
allowedTypes={rulesAllowedTypes.length ? rulesAllowedTypes : undefined}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* 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}
|
|
aliveEncounters={alive}
|
|
onSubmit={(data) => {
|
|
createBossResult.mutate(data, {
|
|
onSuccess: () => setSelectedBoss(null),
|
|
})
|
|
}}
|
|
onClose={() => setSelectedBoss(null)}
|
|
isPending={createBossResult.isPending}
|
|
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>
|
|
)
|
|
}
|