Compare commits

...

3 Commits

Author SHA1 Message Date
347c25e8ed Add boss team match playstyle rule
All checks were successful
CI / backend-lint (push) Successful in 9s
CI / actions-lint (push) Successful in 16s
CI / frontend-lint (push) Successful in 21s
When enabled, the sticky boss banner shows the next boss's team size
as a hint for players who voluntarily match the boss's party count.
Handles variant boss teams by using the auto-detected starter variant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 22:03:11 +01:00
6968d35a33 Fix boss banner sticking behind nav header on scroll
The sticky level cap banner had z-10 and top-0, placing it behind the
nav (z-40) and overlapping it. Use top-14 to clear the nav height and
z-30 to layer correctly below the nav but above page content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 21:59:46 +01:00
18cc116348 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>
2026-02-20 21:55:16 +01:00
11 changed files with 245 additions and 51 deletions

View File

@@ -1,33 +1,29 @@
---
# nuzlocke-tracker-fv7w
title: Add team size limit rule
status: todo
title: Add boss team match rule
status: in-progress
type: feature
priority: normal
created_at: 2026-02-20T19:56:22Z
updated_at: 2026-02-20T20:01:53Z
updated_at: 2026-02-20T21:01:36Z
parent: nuzlocke-tracker-49xj
---
Cap the active party size with warnings when the limit is exceeded.
When enabled, hint to the player that they should limit their active party to the same number of Pokemon as the next boss fight. This is a self-imposed difficulty rule — the tracker cannot enforce it since it doesn't track the active party, but it can surface the information.
## Design Decisions
## Design
**Configurable limit:** Add `teamSizeLimit: number | null` to `NuzlockeRules`. `null` means no limit (disabled). Default Pokemon party size is 6, but variants like "trio-locke" use 3.
**Rule:** Add `bossTeamMatch: boolean` to `NuzlockeRules` (default: `false`, category: `playstyle`).
**What counts:** "Active team" = encounters with status `caught` and not fainted. The tracker already tracks this — alive Pokemon are shown in the team section on RunEncounters.
**Display:** When enabled and the sticky boss banner is shown, add a hint next to the boss name showing their team size, e.g. "Next: Brock (2 Pokemon — match their team)". This reuses the existing `nextBoss` and its `pokemon` array.
**No PC box tracking:** The tracker doesn't model a PC box. Excess catches beyond the team limit are still logged normally. The tracker just warns that the team is over capacity.
**Variant bosses:** Some bosses have conditional teams (e.g. rival starter choice). Use the same logic as `BossTeamPreview`: count pokemon without a `conditionLabel` plus those matching the auto-detected variant (via `matchVariant`). Falls back to first variant if no match is detected.
**Enforcement:** Soft enforcement. Show a warning banner on the encounters page when alive count exceeds the limit. Highlight the count in the team section header. Don't block new catches.
**UI:** Add a numeric input to `RulesConfiguration` (shown when team size toggle is on, min 1, max 6). Display the limit in the sticky bar alongside level caps if enabled.
**Scope:** Frontend-only. No backend or data model changes needed.
## Checklist
- [ ] Add `teamSizeLimit: number | null` to `NuzlockeRules` interface (default: `null`)
- [ ] Add `RuleDefinition` entry under `'difficulty'` category
- [ ] Add numeric input to `RulesConfiguration` (shown when enabled, min 1, max 6)
- [ ] Show warning banner on RunEncounters when alive team count exceeds limit
- [ ] Display team size limit in sticky bar alongside level caps
- [ ] Show count in team section header (e.g., "Team (4/3)" in red when over)
- [x] Add `bossTeamMatch: boolean` to `NuzlockeRules` interface and `DEFAULT_RULES` (default: false)
- [x] Add `RuleDefinition` entry (category: `playstyle`)
- [x] Show boss team size hint in the sticky level cap banner when the rule is enabled
- [x] Handle variant boss teams (use auto-matched variant count when available)

View File

@@ -1,18 +1,26 @@
---
# nuzlocke-tracker-sij8
title: Add gift clause rule
status: todo
status: completed
type: feature
priority: normal
created_at: 2026-02-20T19:56:10Z
updated_at: 2026-02-20T19:56:10Z
updated_at: 2026-02-20T20:55:23Z
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,10 +144,12 @@ RUN_DEFS = [
DEFAULT_RULES = {
"duplicatesClause": True,
"shinyClause": True,
"giftClause": False,
"pinwheelClause": True,
"levelCaps": False,
"hardcoreMode": False,
"setModeOnly": False,
"bossTeamMatch": False,
"egglocke": False,
"wonderlocke": False,
"randomizer": 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

@@ -178,6 +178,17 @@ function matchVariant(labels: string[], starterName?: string | null): string | n
return matches.length === 1 ? (matches[0] ?? null) : null
}
/** Count boss pokemon for the effective variant (or all if no variants). */
function getBossTeamSize(pokemon: BossPokemon[], starterName?: string | null): number {
const labels = [
...new Set(pokemon.filter((bp) => bp.conditionLabel).map((bp) => bp.conditionLabel!)),
]
if (labels.length === 0) return pokemon.length
const matched = matchVariant(labels, starterName)
const variant = matched ?? labels[0] ?? null
return pokemon.filter((bp) => bp.conditionLabel === variant || bp.conditionLabel === null).length
}
function BossTeamPreview({
pokemon,
starterName,
@@ -254,6 +265,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 +276,7 @@ interface RouteGroupProps {
function RouteGroup({
group,
encounterByRoute,
giftEncounterByRoute,
isExpanded,
onToggleExpand,
onRouteClick,
@@ -274,13 +287,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 +312,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 +352,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 +401,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 +429,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 +553,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 +840,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 +849,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
})
@@ -974,14 +1064,22 @@ export function RunEncounters() {
{/* Level Cap Bar */}
{run.rules?.levelCaps && sortedBosses.length > 0 && (
<div className="sticky top-0 z-10 bg-surface-0 border border-border-default rounded-lg px-4 py-3 mb-6 shadow-sm">
<div className="sticky top-14 z-30 bg-surface-0 border border-border-default rounded-lg px-4 py-3 mb-6 shadow-sm">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-text-primary">
Level Cap: {currentLevelCap ?? '—'}
</span>
{nextBoss && (
<span className="text-sm text-text-tertiary">Next: {nextBoss.name}</span>
<span className="text-sm text-text-tertiary">
Next: {nextBoss.name}
{run.rules?.bossTeamMatch && (
<span className="text-text-muted">
{' '}
({getBossTeamSize(nextBoss.pokemon, starterName)} Pokémon match their team)
</span>
)}
</span>
)}
{!nextBoss && (
<span className="text-sm text-status-active">All bosses defeated!</span>
@@ -1226,6 +1324,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 +1334,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 +1364,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,12 +2,14 @@ export interface NuzlockeRules {
// Core rules (affect tracker behavior)
duplicatesClause: boolean
shinyClause: boolean
giftClause: boolean
pinwheelClause: boolean
levelCaps: boolean
// Playstyle (informational, for stats/categorization)
hardcoreMode: boolean
setModeOnly: boolean
bossTeamMatch: boolean
// Variant (changes which Pokemon can appear)
egglocke: boolean
@@ -19,12 +21,14 @@ export const DEFAULT_RULES: NuzlockeRules = {
// Core rules
duplicatesClause: true,
shinyClause: true,
giftClause: false,
pinwheelClause: true,
levelCaps: false,
// Playstyle - off by default
hardcoreMode: false,
setModeOnly: false,
bossTeamMatch: false,
// Variant - off by default
egglocke: false,
@@ -55,6 +59,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',
@@ -84,6 +95,13 @@ export const RULE_DEFINITIONS: RuleDefinition[] = [
'The game must be played in "Set" battle style, meaning you cannot switch Pokémon after knocking out an opponent.',
category: 'playstyle',
},
{
key: 'bossTeamMatch',
name: 'Boss Team Match',
description:
'Limit your active party to the same number of Pokémon as the boss you are challenging.',
category: 'playstyle',
},
// Variant
{