From 18cc1163487cd90e7a2558c86dd3f7e47914c911 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Fri, 20 Feb 2026 21:55:16 +0100 Subject: [PATCH] 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 --- ...ocke-tracker-sij8--add-gift-clause-rule.md | 22 ++- .../i0d1e2f3a4b5_add_origin_to_encounters.py | 29 ++++ backend/src/app/api/encounters.py | 28 ++-- backend/src/app/models/encounter.py | 1 + backend/src/app/schemas/encounter.py | 1 + backend/src/app/seeds/inject_test_data.py | 1 + frontend/src/components/EncounterModal.tsx | 2 + frontend/src/pages/RunEncounters.tsx | 139 ++++++++++++++++-- frontend/src/types/game.ts | 1 + frontend/src/types/rules.ts | 9 ++ 10 files changed, 201 insertions(+), 32 deletions(-) create mode 100644 backend/src/app/alembic/versions/i0d1e2f3a4b5_add_origin_to_encounters.py diff --git a/.beans/nuzlocke-tracker-sij8--add-gift-clause-rule.md b/.beans/nuzlocke-tracker-sij8--add-gift-clause-rule.md index 2b34da1..4c13503 100644 --- a/.beans/nuzlocke-tracker-sij8--add-gift-clause-rule.md +++ b/.beans/nuzlocke-tracker-sij8--add-gift-clause-rule.md @@ -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" \ No newline at end of file +- [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 \ No newline at end of file diff --git a/backend/src/app/alembic/versions/i0d1e2f3a4b5_add_origin_to_encounters.py b/backend/src/app/alembic/versions/i0d1e2f3a4b5_add_origin_to_encounters.py new file mode 100644 index 0000000..1116c6b --- /dev/null +++ b/backend/src/app/alembic/versions/i0d1e2f3a4b5_add_origin_to_encounters.py @@ -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") diff --git a/backend/src/app/api/encounters.py b/backend/src/app/api/encounters.py index d07b3e9..d5a9b23 100644 --- a/backend/src/app/api/encounters.py +++ b/backend/src/app/api/encounters.py @@ -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() diff --git a/backend/src/app/models/encounter.py b/backend/src/app/models/encounter.py index 241e243..a1a81b1 100644 --- a/backend/src/app/models/encounter.py +++ b/backend/src/app/models/encounter.py @@ -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() ) diff --git a/backend/src/app/schemas/encounter.py b/backend/src/app/schemas/encounter.py index 90a97ba..8387b81 100644 --- a/backend/src/app/schemas/encounter.py +++ b/backend/src/app/schemas/encounter.py @@ -35,6 +35,7 @@ class EncounterResponse(CamelModel): faint_level: int | None death_cause: str | None is_shiny: bool + origin: str | None caught_at: datetime diff --git a/backend/src/app/seeds/inject_test_data.py b/backend/src/app/seeds/inject_test_data.py index d313b7c..07c3f46 100644 --- a/backend/src/app/seeds/inject_test_data.py +++ b/backend/src/app/seeds/inject_test_data.py @@ -144,6 +144,7 @@ RUN_DEFS = [ DEFAULT_RULES = { "duplicatesClause": True, "shinyClause": True, + "giftClause": False, "pinwheelClause": True, "levelCaps": False, "hardcoreMode": False, diff --git a/frontend/src/components/EncounterModal.tsx b/frontend/src/components/EncounterModal.tsx index 2604b52..88410bf 100644 --- a/frontend/src/components/EncounterModal.tsx +++ b/frontend/src/components/EncounterModal.tsx @@ -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, }) } } diff --git a/frontend/src/pages/RunEncounters.tsx b/frontend/src/pages/RunEncounters.tsx index 6dcd7ab..62599d0 100644 --- a/frontend/src/pages/RunEncounters.tsx +++ b/frontend/src/pages/RunEncounters.tsx @@ -254,6 +254,7 @@ function BossTeamPreview({ interface RouteGroupProps { group: RouteWithChildren encounterByRoute: Map + giftEncounterByRoute: Map 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)')} + {groupGiftEncounter && ( + <> + {groupGiftEncounter.pokemon.spriteUrl && ( + {groupGiftEncounter.pokemon.name} + )} + + {groupGiftEncounter.nickname ?? groupGiftEncounter.pokemon.name} + (gift) + + + )} + + )} + {!groupEncounter && groupGiftEncounter && ( +
+ {groupGiftEncounter.pokemon.spriteUrl && ( + {groupGiftEncounter.pokemon.name} + )} + + {groupGiftEncounter.nickname ?? groupGiftEncounter.pokemon.name} + (gift) +
)} @@ -349,7 +390,9 @@ function RouteGroup({
{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({
{child.name}
- {!childEncounter && child.encounterMethods.length > 0 && ( + {giftEncounter && !childEncounter && ( +
+ {giftEncounter.pokemon.spriteUrl && ( + {giftEncounter.pokemon.name} + )} + + {giftEncounter.nickname ?? giftEncounter.pokemon.name} + (gift) + +
+ )} + {!displayEncounter && child.encounterMethods.length > 0 && (
{child.encounterMethods.map((m) => ( @@ -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() 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() + 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)')} + {giftEncounter && ( + <> + {giftEncounter.pokemon.spriteUrl && ( + {giftEncounter.pokemon.name} + )} + + {giftEncounter.nickname ?? giftEncounter.pokemon.name} + (gift) + + + )} +
+ ) : giftEncounter ? ( +
+ {giftEncounter.pokemon.spriteUrl && ( + {giftEncounter.pokemon.name} + )} + + {giftEncounter.nickname ?? giftEncounter.pokemon.name} + (gift) +
) : ( route.encounterMethods.length > 0 && ( diff --git a/frontend/src/types/game.ts b/frontend/src/types/game.ts index 1baa201..d36a571 100644 --- a/frontend/src/types/game.ts +++ b/frontend/src/types/game.ts @@ -79,6 +79,7 @@ export interface Encounter { faintLevel: number | null deathCause: string | null isShiny: boolean + origin: string | null caughtAt: string } diff --git a/frontend/src/types/rules.ts b/frontend/src/types/rules.ts index 6022af6..84e5c96 100644 --- a/frontend/src/types/rules.ts +++ b/frontend/src/types/rules.ts @@ -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',