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:
2026-02-20 21:55:16 +01:00
parent ed1f7ad3d0
commit 18cc116348
10 changed files with 201 additions and 32 deletions

View File

@@ -1,18 +1,26 @@
---
# nuzlocke-tracker-sij8
title: Add gift clause rule
status: todo
status: in-progress
type: feature
priority: normal
created_at: 2026-02-20T19:56:10Z
updated_at: 2026-02-20T19:56:10Z
updated_at: 2026-02-20T20:53:15Z
parent: nuzlocke-tracker-49xj
---
Add a new `giftClause` boolean rule: in-game gift Pokemon are free and do not count against the area's encounter limit.
Add a new giftClause boolean rule: in-game gift Pokemon are free and do not count against the area's encounter limit. When enabled, a location with a gift allows both the gift encounter and a regular encounter, in any order.
## Checklist
- [ ] Add `giftClause` to `NuzlockeRules` interface and `DEFAULT_RULES` (default: false)
- [ ] Add `RuleDefinition` entry with appropriate category
- [ ] When enabled, gift-origin encounters bypass the route-lock check in the backend (similar to shinyClause bypass)
- [ ] Update the encounter creation endpoint to check for giftClause when origin is "gift"
- [x] Add giftClause to NuzlockeRules interface and DEFAULT_RULES (default: false)
- [x] Add RuleDefinition entry with core category
- [x] Add origin column to Encounter model + alembic migration
- [x] Add origin to EncounterResponse schema and frontend Encounter type
- [x] Persist origin when creating encounters (frontend sends, backend stores)
- [x] Backend: gift-origin encounters bypass route-lock check (skip_route_lock)
- [x] Backend: existing gift encounters excluded from route-lock query
- [x] Frontend: split encounterByRoute into regular and gift maps
- [x] Frontend: routes with only gift encounters remain clickable for new encounters
- [x] Frontend: gift encounters displayed on route cards with (gift) label
- [x] Frontend: route filtering accounts for gift encounters

View File

@@ -0,0 +1,29 @@
"""add origin to encounters
Revision ID: i0d1e2f3a4b5
Revises: h9c0d1e2f3a4
Create Date: 2026-02-20 12:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "i0d1e2f3a4b5"
down_revision: str | Sequence[str] | None = "h9c0d1e2f3a4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"encounters",
sa.Column("origin", sa.String(20), nullable=True),
)
def downgrade() -> None:
op.drop_column("encounters", "origin")

View File

@@ -58,12 +58,13 @@ async def create_encounter(
detail="Cannot create encounter on a parent route. Use a child route instead.",
)
# Shiny clause: shiny encounters bypass the route-lock check
# Shiny/gift clause: certain encounters bypass the route-lock check
shiny_clause_on = run.rules.get("shinyClause", True) if run.rules else True
skip_route_lock = (data.is_shiny and shiny_clause_on) or data.origin in (
"shed_evolution",
"egg",
"transfer",
gift_clause_on = run.rules.get("giftClause", False) if run.rules else False
skip_route_lock = (
(data.is_shiny and shiny_clause_on)
or (data.origin == "gift" and gift_clause_on)
or data.origin in ("shed_evolution", "egg", "transfer")
)
# If this route has a parent, check if sibling already has an encounter
@@ -93,13 +94,17 @@ async def create_encounter(
# Check if any relevant sibling already has an encounter in this run
# Exclude transfer-target encounters so they don't block the starter
transfer_target_ids = select(GenlockeTransfer.target_encounter_id)
existing_encounter = await session.execute(
select(Encounter).where(
Encounter.run_id == run_id,
Encounter.route_id.in_(sibling_ids),
~Encounter.id.in_(transfer_target_ids),
)
lock_query = select(Encounter).where(
Encounter.run_id == run_id,
Encounter.route_id.in_(sibling_ids),
~Encounter.id.in_(transfer_target_ids),
)
# Gift-origin encounters don't count toward route lock
if gift_clause_on:
lock_query = lock_query.where(
Encounter.origin.is_(None) | (Encounter.origin != "gift")
)
existing_encounter = await session.execute(lock_query)
if existing_encounter.scalar_one_or_none() is not None:
raise HTTPException(
status_code=409,
@@ -119,6 +124,7 @@ async def create_encounter(
status=data.status,
catch_level=data.catch_level,
is_shiny=data.is_shiny,
origin=data.origin,
)
session.add(encounter)
await session.commit()

View File

@@ -24,6 +24,7 @@ class Encounter(Base):
is_shiny: Mapped[bool] = mapped_column(
Boolean, default=False, server_default=text("false")
)
origin: Mapped[str | None] = mapped_column(String(20))
caught_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)

View File

@@ -35,6 +35,7 @@ class EncounterResponse(CamelModel):
faint_level: int | None
death_cause: str | None
is_shiny: bool
origin: str | None
caught_at: datetime

View File

@@ -144,6 +144,7 @@ RUN_DEFS = [
DEFAULT_RULES = {
"duplicatesClause": True,
"shinyClause": True,
"giftClause": False,
"pinwheelClause": True,
"levelCaps": False,
"hardcoreMode": False,

View File

@@ -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,
})
}
}

View File

@@ -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 && (

View File

@@ -79,6 +79,7 @@ export interface Encounter {
faintLevel: number | null
deathCause: string | null
isShiny: boolean
origin: string | null
caughtAt: string
}

View File

@@ -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',