Handle Nincada split evolution (Ninjask + Shedinja)

When evolving Nincada, a confirmation prompt now offers to also add
Shedinja as a new encounter on the same route. The Shedinja encounter
uses a "shed_evolution" origin to bypass route-locking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 21:47:35 +01:00
parent fb0ad23c45
commit a2127f2126
7 changed files with 144 additions and 10 deletions

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import type { EncounterDetail, UpdateEncounterInput } from '../types'
import { useState, useMemo } from 'react'
import type { EncounterDetail, UpdateEncounterInput, CreateEncounterInput } from '../types'
import { useEvolutions, useForms } from '../hooks/useEncounters'
import { TypeBadge } from './TypeBadge'
import { formatEvolutionMethod } from '../utils/formatEvolution'
@@ -13,6 +13,7 @@ interface StatusChangeModalProps {
onClose: () => void
isPending: boolean
region?: string
onCreateEncounter?: (data: CreateEncounterInput) => void
}
export function StatusChangeModal({
@@ -21,6 +22,7 @@ export function StatusChangeModal({
onClose,
isPending,
region,
onCreateEncounter,
}: StatusChangeModalProps) {
const { pokemon, currentPokemon, route, nickname, catchLevel, faintLevel, deathCause } =
encounter
@@ -29,16 +31,27 @@ export function StatusChangeModal({
const [showConfirm, setShowConfirm] = useState(false)
const [showEvolve, setShowEvolve] = useState(false)
const [showFormChange, setShowFormChange] = useState(false)
const [showShedConfirm, setShowShedConfirm] = useState(false)
const [pendingEvolutionId, setPendingEvolutionId] = useState<number | null>(null)
const [shedNickname, setShedNickname] = useState('')
const [deathLevel, setDeathLevel] = useState('')
const [cause, setCause] = useState('')
const activePokemonId = currentPokemon?.id ?? pokemon.id
const { data: evolutions, isLoading: evolutionsLoading } = useEvolutions(
showEvolve ? activePokemonId : null,
showEvolve || showShedConfirm ? activePokemonId : null,
region,
)
const { data: forms } = useForms(isDead ? null : activePokemonId)
const { normalEvolutions, shedCompanion } = useMemo(() => {
if (!evolutions) return { normalEvolutions: [], shedCompanion: null }
return {
normalEvolutions: evolutions.filter(e => e.trigger !== 'shed'),
shedCompanion: evolutions.find(e => e.trigger === 'shed') ?? null,
}
}, [evolutions])
const handleConfirmDeath = () => {
onUpdate({
id: encounter.id,
@@ -50,12 +63,35 @@ export function StatusChangeModal({
}
const handleEvolve = (toPokemonId: number) => {
if (shedCompanion && onCreateEncounter) {
setPendingEvolutionId(toPokemonId)
setShowEvolve(false)
setShowShedConfirm(true)
return
}
onUpdate({
id: encounter.id,
data: { currentPokemonId: toPokemonId },
})
}
const applyEvolution = (includeShed: boolean) => {
if (pendingEvolutionId === null) return
onUpdate({
id: encounter.id,
data: { currentPokemonId: pendingEvolutionId },
})
if (includeShed && shedCompanion && onCreateEncounter) {
onCreateEncounter({
routeId: encounter.routeId,
pokemonId: shedCompanion.toPokemon.id,
nickname: shedNickname || undefined,
status: 'caught',
origin: 'shed_evolution',
})
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
@@ -151,7 +187,7 @@ export function StatusChangeModal({
)}
{/* Alive pokemon: actions */}
{!isDead && !showConfirm && !showEvolve && !showFormChange && (
{!isDead && !showConfirm && !showEvolve && !showFormChange && !showShedConfirm && (
<div className="flex gap-3">
<button
type="button"
@@ -197,12 +233,12 @@ export function StatusChangeModal({
{evolutionsLoading && (
<p className="text-sm text-gray-500 dark:text-gray-400">Loading evolutions...</p>
)}
{!evolutionsLoading && evolutions && evolutions.length === 0 && (
{!evolutionsLoading && normalEvolutions.length === 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400">No evolutions available</p>
)}
{!evolutionsLoading && evolutions && evolutions.length > 0 && (
{!evolutionsLoading && normalEvolutions.length > 0 && (
<div className="space-y-2">
{evolutions.map((evo) => (
{normalEvolutions.map((evo) => (
<button
key={evo.id}
type="button"
@@ -232,6 +268,84 @@ export function StatusChangeModal({
</div>
)}
{/* Shed evolution confirmation (Nincada → Ninjask + Shedinja) */}
{!isDead && showShedConfirm && shedCompanion && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Shed Evolution
</h3>
<button
type="button"
onClick={() => {
setShowShedConfirm(false)
setPendingEvolutionId(null)
setShedNickname('')
setShowEvolve(true)
}}
className="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
Back
</button>
</div>
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg p-3">
<div className="flex items-center gap-3">
{shedCompanion.toPokemon.spriteUrl ? (
<img
src={shedCompanion.toPokemon.spriteUrl}
alt={shedCompanion.toPokemon.name}
className="w-12 h-12"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-sm font-bold text-gray-600 dark:text-gray-300">
{shedCompanion.toPokemon.name[0].toUpperCase()}
</div>
)}
<p className="text-sm text-amber-800 dark:text-amber-300">
{displayPokemon.name} shed its shell! Would you also like to add{' '}
<span className="font-semibold">{shedCompanion.toPokemon.name}</span>?
</p>
</div>
</div>
<div>
<label
htmlFor="shed-nickname"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Nickname{' '}
<span className="font-normal text-gray-400">(optional)</span>
</label>
<input
id="shed-nickname"
type="text"
maxLength={30}
value={shedNickname}
onChange={(e) => setShedNickname(e.target.value)}
placeholder={shedCompanion.toPokemon.name}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-amber-500"
/>
</div>
<div className="flex gap-3 pt-1">
<button
type="button"
disabled={isPending}
onClick={() => applyEvolution(false)}
className="flex-1 px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 transition-colors"
>
Skip
</button>
<button
type="button"
disabled={isPending}
onClick={() => applyEvolution(true)}
className="flex-1 px-4 py-2 bg-amber-600 text-white rounded-lg font-medium hover:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isPending ? 'Saving...' : `Add ${shedCompanion.toPokemon.name}`}
</button>
</div>
</div>
)}
{/* Form change selection */}
{!isDead && showFormChange && (
<div className="space-y-3">
@@ -351,7 +465,7 @@ export function StatusChangeModal({
</div>
{/* Footer for dead/no-confirm/no-evolve views */}
{(isDead || (!isDead && !showConfirm && !showEvolve && !showFormChange)) && (
{(isDead || (!isDead && !showConfirm && !showEvolve && !showFormChange && !showShedConfirm)) && (
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end">
<button
type="button"

View File

@@ -2,7 +2,7 @@ import { useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { useRun, useUpdateRun } from '../hooks/useRuns'
import { useGameRoutes } from '../hooks/useGames'
import { useUpdateEncounter } from '../hooks/useEncounters'
import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters'
import { StatCard, PokemonCard, RuleBadges, StatusChangeModal, EndRunModal } from '../components'
import type { RunStatus, EncounterDetail } from '../types'
@@ -26,6 +26,7 @@ export function RunDashboard() {
const runIdNum = Number(runId)
const { data: run, isLoading, error } = useRun(runIdNum)
const { data: routes } = useGameRoutes(run?.gameId ?? null)
const createEncounter = useCreateEncounter(runIdNum)
const updateEncounter = useUpdateEncounter(runIdNum)
const updateRun = useUpdateRun(runIdNum)
const [selectedEncounter, setSelectedEncounter] =
@@ -243,6 +244,9 @@ export function RunDashboard() {
onClose={() => setSelectedEncounter(null)}
isPending={updateEncounter.isPending}
region={run?.game.region}
onCreateEncounter={(data) => {
createEncounter.mutate(data)
}}
/>
)}

View File

@@ -1269,6 +1269,9 @@ export function RunEncounters() {
onClose={() => setSelectedTeamEncounter(null)}
isPending={updateEncounter.isPending}
region={run?.game.region}
onCreateEncounter={(data) => {
createEncounter.mutate(data)
}}
/>
)}

View File

@@ -119,6 +119,7 @@ export interface CreateEncounterInput {
status: EncounterStatus
catchLevel?: number
isShiny?: boolean
origin?: string
}
export interface UpdateEncounterInput {