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:
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-ztx8
|
||||||
|
title: Handle Nincada Split Evolution (Ninjask + Shedinja)
|
||||||
|
status: completed
|
||||||
|
type: feature
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-02-08T20:42:44Z
|
||||||
|
updated_at: 2026-02-08T20:44:58Z
|
||||||
|
---
|
||||||
|
|
||||||
|
Implement UI and backend support for Nincada's unique split evolution into both Ninjask and Shedinja. When evolving Nincada, show a confirmation prompt offering to also add Shedinja as a new encounter.
|
||||||
@@ -58,7 +58,7 @@ async def create_encounter(
|
|||||||
|
|
||||||
# Shiny clause: shiny encounters bypass the route-lock check
|
# Shiny clause: shiny encounters bypass the route-lock check
|
||||||
shiny_clause_on = run.rules.get("shinyClause", True) if run.rules else True
|
shiny_clause_on = run.rules.get("shinyClause", True) if run.rules else True
|
||||||
skip_route_lock = data.is_shiny and shiny_clause_on
|
skip_route_lock = (data.is_shiny and shiny_clause_on) or data.origin == "shed_evolution"
|
||||||
|
|
||||||
# If this route has a parent, check if sibling already has an encounter
|
# If this route has a parent, check if sibling already has an encounter
|
||||||
if route.parent_route_id is not None and not skip_route_lock:
|
if route.parent_route_id is not None and not skip_route_lock:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class EncounterCreate(CamelModel):
|
|||||||
status: str
|
status: str
|
||||||
catch_level: int | None = None
|
catch_level: int | None = None
|
||||||
is_shiny: bool = False
|
is_shiny: bool = False
|
||||||
|
origin: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class EncounterUpdate(CamelModel):
|
class EncounterUpdate(CamelModel):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import type { EncounterDetail, UpdateEncounterInput } from '../types'
|
import type { EncounterDetail, UpdateEncounterInput, CreateEncounterInput } from '../types'
|
||||||
import { useEvolutions, useForms } from '../hooks/useEncounters'
|
import { useEvolutions, useForms } from '../hooks/useEncounters'
|
||||||
import { TypeBadge } from './TypeBadge'
|
import { TypeBadge } from './TypeBadge'
|
||||||
import { formatEvolutionMethod } from '../utils/formatEvolution'
|
import { formatEvolutionMethod } from '../utils/formatEvolution'
|
||||||
@@ -13,6 +13,7 @@ interface StatusChangeModalProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
isPending: boolean
|
isPending: boolean
|
||||||
region?: string
|
region?: string
|
||||||
|
onCreateEncounter?: (data: CreateEncounterInput) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusChangeModal({
|
export function StatusChangeModal({
|
||||||
@@ -21,6 +22,7 @@ export function StatusChangeModal({
|
|||||||
onClose,
|
onClose,
|
||||||
isPending,
|
isPending,
|
||||||
region,
|
region,
|
||||||
|
onCreateEncounter,
|
||||||
}: StatusChangeModalProps) {
|
}: StatusChangeModalProps) {
|
||||||
const { pokemon, currentPokemon, route, nickname, catchLevel, faintLevel, deathCause } =
|
const { pokemon, currentPokemon, route, nickname, catchLevel, faintLevel, deathCause } =
|
||||||
encounter
|
encounter
|
||||||
@@ -29,16 +31,27 @@ export function StatusChangeModal({
|
|||||||
const [showConfirm, setShowConfirm] = useState(false)
|
const [showConfirm, setShowConfirm] = useState(false)
|
||||||
const [showEvolve, setShowEvolve] = useState(false)
|
const [showEvolve, setShowEvolve] = useState(false)
|
||||||
const [showFormChange, setShowFormChange] = 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 [deathLevel, setDeathLevel] = useState('')
|
||||||
const [cause, setCause] = useState('')
|
const [cause, setCause] = useState('')
|
||||||
|
|
||||||
const activePokemonId = currentPokemon?.id ?? pokemon.id
|
const activePokemonId = currentPokemon?.id ?? pokemon.id
|
||||||
const { data: evolutions, isLoading: evolutionsLoading } = useEvolutions(
|
const { data: evolutions, isLoading: evolutionsLoading } = useEvolutions(
|
||||||
showEvolve ? activePokemonId : null,
|
showEvolve || showShedConfirm ? activePokemonId : null,
|
||||||
region,
|
region,
|
||||||
)
|
)
|
||||||
const { data: forms } = useForms(isDead ? null : activePokemonId)
|
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 = () => {
|
const handleConfirmDeath = () => {
|
||||||
onUpdate({
|
onUpdate({
|
||||||
id: encounter.id,
|
id: encounter.id,
|
||||||
@@ -50,12 +63,35 @@ export function StatusChangeModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleEvolve = (toPokemonId: number) => {
|
const handleEvolve = (toPokemonId: number) => {
|
||||||
|
if (shedCompanion && onCreateEncounter) {
|
||||||
|
setPendingEvolutionId(toPokemonId)
|
||||||
|
setShowEvolve(false)
|
||||||
|
setShowShedConfirm(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
onUpdate({
|
onUpdate({
|
||||||
id: encounter.id,
|
id: encounter.id,
|
||||||
data: { currentPokemonId: toPokemonId },
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||||
@@ -151,7 +187,7 @@ export function StatusChangeModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Alive pokemon: actions */}
|
{/* Alive pokemon: actions */}
|
||||||
{!isDead && !showConfirm && !showEvolve && !showFormChange && (
|
{!isDead && !showConfirm && !showEvolve && !showFormChange && !showShedConfirm && (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -197,12 +233,12 @@ export function StatusChangeModal({
|
|||||||
{evolutionsLoading && (
|
{evolutionsLoading && (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">Loading evolutions...</p>
|
<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>
|
<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">
|
<div className="space-y-2">
|
||||||
{evolutions.map((evo) => (
|
{normalEvolutions.map((evo) => (
|
||||||
<button
|
<button
|
||||||
key={evo.id}
|
key={evo.id}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -232,6 +268,84 @@ export function StatusChangeModal({
|
|||||||
</div>
|
</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 */}
|
{/* Form change selection */}
|
||||||
{!isDead && showFormChange && (
|
{!isDead && showFormChange && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -351,7 +465,7 @@ export function StatusChangeModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer for dead/no-confirm/no-evolve views */}
|
{/* 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">
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { useRun, useUpdateRun } from '../hooks/useRuns'
|
import { useRun, useUpdateRun } from '../hooks/useRuns'
|
||||||
import { useGameRoutes } from '../hooks/useGames'
|
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 { StatCard, PokemonCard, RuleBadges, StatusChangeModal, EndRunModal } from '../components'
|
||||||
import type { RunStatus, EncounterDetail } from '../types'
|
import type { RunStatus, EncounterDetail } from '../types'
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ export function RunDashboard() {
|
|||||||
const runIdNum = Number(runId)
|
const runIdNum = Number(runId)
|
||||||
const { data: run, isLoading, error } = useRun(runIdNum)
|
const { data: run, isLoading, error } = useRun(runIdNum)
|
||||||
const { data: routes } = useGameRoutes(run?.gameId ?? null)
|
const { data: routes } = useGameRoutes(run?.gameId ?? null)
|
||||||
|
const createEncounter = useCreateEncounter(runIdNum)
|
||||||
const updateEncounter = useUpdateEncounter(runIdNum)
|
const updateEncounter = useUpdateEncounter(runIdNum)
|
||||||
const updateRun = useUpdateRun(runIdNum)
|
const updateRun = useUpdateRun(runIdNum)
|
||||||
const [selectedEncounter, setSelectedEncounter] =
|
const [selectedEncounter, setSelectedEncounter] =
|
||||||
@@ -243,6 +244,9 @@ export function RunDashboard() {
|
|||||||
onClose={() => setSelectedEncounter(null)}
|
onClose={() => setSelectedEncounter(null)}
|
||||||
isPending={updateEncounter.isPending}
|
isPending={updateEncounter.isPending}
|
||||||
region={run?.game.region}
|
region={run?.game.region}
|
||||||
|
onCreateEncounter={(data) => {
|
||||||
|
createEncounter.mutate(data)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1269,6 +1269,9 @@ export function RunEncounters() {
|
|||||||
onClose={() => setSelectedTeamEncounter(null)}
|
onClose={() => setSelectedTeamEncounter(null)}
|
||||||
isPending={updateEncounter.isPending}
|
isPending={updateEncounter.isPending}
|
||||||
region={run?.game.region}
|
region={run?.game.region}
|
||||||
|
onCreateEncounter={(data) => {
|
||||||
|
createEncounter.mutate(data)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export interface CreateEncounterInput {
|
|||||||
status: EncounterStatus
|
status: EncounterStatus
|
||||||
catchLevel?: number
|
catchLevel?: number
|
||||||
isShiny?: boolean
|
isShiny?: boolean
|
||||||
|
origin?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateEncounterInput {
|
export interface UpdateEncounterInput {
|
||||||
|
|||||||
Reference in New Issue
Block a user