Add gift clause rule for free gift encounters
When enabled, in-game gift Pokemon (starters, trades, fossils) do not count against a location's encounter limit. Both a gift encounter and a regular encounter can coexist on the same route, in any order. Persists encounter origin on the Encounter model so the backend can exclude gift encounters from route-lock checks bidirectionally, and the frontend can split them into a separate display layer that doesn't lock the route for regular encounters. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ interface EncounterModalProps {
|
||||
nickname?: string | undefined
|
||||
status: EncounterStatus
|
||||
catchLevel?: number | undefined
|
||||
origin?: string | undefined
|
||||
}) => void
|
||||
onUpdate?:
|
||||
| ((data: {
|
||||
@@ -291,6 +292,7 @@ export function EncounterModal({
|
||||
nickname: nickname || undefined,
|
||||
status,
|
||||
catchLevel: catchLevel ? Number(catchLevel) : undefined,
|
||||
origin: SPECIAL_METHODS.includes(selectedPokemon.encounterMethod) ? 'gift' : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +254,7 @@ function BossTeamPreview({
|
||||
interface RouteGroupProps {
|
||||
group: RouteWithChildren
|
||||
encounterByRoute: Map<number, EncounterDetail>
|
||||
giftEncounterByRoute: Map<number, EncounterDetail>
|
||||
isExpanded: boolean
|
||||
onToggleExpand: () => void
|
||||
onRouteClick: (route: Route) => void
|
||||
@@ -264,6 +265,7 @@ interface RouteGroupProps {
|
||||
function RouteGroup({
|
||||
group,
|
||||
encounterByRoute,
|
||||
giftEncounterByRoute,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
onRouteClick,
|
||||
@@ -274,13 +276,23 @@ function RouteGroup({
|
||||
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) {
|
||||
// Use the first encounter's status as representative for the header
|
||||
groupStatus = groupEncounter ? groupEncounter.status : 'none'
|
||||
groupStatus = displayEncounter ? displayEncounter.status : 'none'
|
||||
} else {
|
||||
groupStatus = groupEncounter ? groupEncounter.status : 'none'
|
||||
groupStatus = displayEncounter ? displayEncounter.status : 'none'
|
||||
}
|
||||
const si = statusIndicator[groupStatus]
|
||||
|
||||
@@ -289,10 +301,9 @@ function RouteGroup({
|
||||
if (usePinwheel) {
|
||||
// Show group if any zone matches the filter
|
||||
const anyChildMatches = group.children.some((child) => {
|
||||
const enc = encounterByRoute.get(child.id)
|
||||
const enc = encounterByRoute.get(child.id) ?? giftEncounterByRoute.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
|
||||
@@ -330,6 +341,36 @@ function RouteGroup({
|
||||
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>
|
||||
@@ -349,7 +390,9 @@ function RouteGroup({
|
||||
<div className="border-t border-border-default bg-surface-1/50">
|
||||
{group.children.map((child) => {
|
||||
const childEncounter = encounterByRoute.get(child.id)
|
||||
const childStatus = getRouteStatus(childEncounter)
|
||||
const giftEncounter = giftEncounterByRoute.get(child.id)
|
||||
const displayEncounter = childEncounter ?? giftEncounter
|
||||
const childStatus = getRouteStatus(displayEncounter)
|
||||
const childSi = statusIndicator[childStatus]
|
||||
|
||||
let isDisabled: boolean
|
||||
@@ -375,7 +418,22 @@ function RouteGroup({
|
||||
<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>
|
||||
{!childEncounter && child.encounterMethods.length > 0 && (
|
||||
{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" />
|
||||
@@ -484,14 +542,29 @@ export function RunEncounters() {
|
||||
}
|
||||
}, [run, transferIdSet])
|
||||
|
||||
// Map routeId → encounter for quick lookup (normal encounters only)
|
||||
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])
|
||||
}, [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(() => {
|
||||
@@ -756,7 +829,7 @@ export function RunEncounters() {
|
||||
})
|
||||
}
|
||||
|
||||
// Filter routes
|
||||
// Filter routes (check both regular and gift encounters for status)
|
||||
const filteredRoutes = organizedRoutes.filter((r) => {
|
||||
if (filter === 'all') return true
|
||||
|
||||
@@ -765,17 +838,23 @@ export function RunEncounters() {
|
||||
if (usePinwheel) {
|
||||
// Show group if any child/zone matches the filter
|
||||
return r.children.some((child) => {
|
||||
const enc = encounterByRoute.get(child.id)
|
||||
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)
|
||||
return getRouteStatus(groupEnc ?? undefined) === filter
|
||||
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)
|
||||
const enc = encounterByRoute.get(r.id) ?? giftEncounterByRoute.get(r.id)
|
||||
return getRouteStatus(enc) === filter
|
||||
})
|
||||
|
||||
@@ -1226,6 +1305,7 @@ export function RunEncounters() {
|
||||
key={route.id}
|
||||
group={route}
|
||||
encounterByRoute={encounterByRoute}
|
||||
giftEncounterByRoute={giftEncounterByRoute}
|
||||
isExpanded={expandedGroups.has(route.id)}
|
||||
onToggleExpand={() => toggleGroup(route.id)}
|
||||
onRouteClick={handleRouteClick}
|
||||
@@ -1235,7 +1315,9 @@ export function RunEncounters() {
|
||||
) : (
|
||||
(() => {
|
||||
const encounter = encounterByRoute.get(route.id)
|
||||
const rs = getRouteStatus(encounter)
|
||||
const giftEncounter = giftEncounterByRoute.get(route.id)
|
||||
const displayEncounter = encounter ?? giftEncounter
|
||||
const rs = getRouteStatus(displayEncounter)
|
||||
const si = statusIndicator[rs]
|
||||
|
||||
return (
|
||||
@@ -1263,6 +1345,35 @@ export function RunEncounters() {
|
||||
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 && (
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface Encounter {
|
||||
faintLevel: number | null
|
||||
deathCause: string | null
|
||||
isShiny: boolean
|
||||
origin: string | null
|
||||
caughtAt: string
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface NuzlockeRules {
|
||||
// Core rules (affect tracker behavior)
|
||||
duplicatesClause: boolean
|
||||
shinyClause: boolean
|
||||
giftClause: boolean
|
||||
pinwheelClause: boolean
|
||||
levelCaps: boolean
|
||||
|
||||
@@ -19,6 +20,7 @@ export const DEFAULT_RULES: NuzlockeRules = {
|
||||
// Core rules
|
||||
duplicatesClause: true,
|
||||
shinyClause: true,
|
||||
giftClause: false,
|
||||
pinwheelClause: true,
|
||||
levelCaps: false,
|
||||
|
||||
@@ -55,6 +57,13 @@ export const RULE_DEFINITIONS: RuleDefinition[] = [
|
||||
'Shiny Pokémon may always be caught, regardless of whether they are your first encounter.',
|
||||
category: 'core',
|
||||
},
|
||||
{
|
||||
key: 'giftClause',
|
||||
name: 'Gift Clause',
|
||||
description:
|
||||
"In-game gift Pokémon (starters, trades, fossils) do not count against a location's encounter limit.",
|
||||
category: 'core',
|
||||
},
|
||||
{
|
||||
key: 'pinwheelClause',
|
||||
name: 'Pinwheel Clause',
|
||||
|
||||
Reference in New Issue
Block a user