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 # nuzlocke-tracker-fv7w
title: Add team size limit rule title: Add boss team match rule
status: todo status: in-progress
type: feature type: feature
priority: normal priority: normal
created_at: 2026-02-20T19:56:22Z 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 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. **Scope:** Frontend-only. No backend or data model changes needed.
**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.
## Checklist ## Checklist
- [ ] Add `teamSizeLimit: number | null` to `NuzlockeRules` interface (default: `null`) - [x] Add `bossTeamMatch: boolean` to `NuzlockeRules` interface and `DEFAULT_RULES` (default: false)
- [ ] Add `RuleDefinition` entry under `'difficulty'` category - [x] Add `RuleDefinition` entry (category: `playstyle`)
- [ ] Add numeric input to `RulesConfiguration` (shown when enabled, min 1, max 6) - [x] Show boss team size hint in the sticky level cap banner when the rule is enabled
- [ ] Show warning banner on RunEncounters when alive team count exceeds limit - [x] Handle variant boss teams (use auto-matched variant count when available)
- [ ] 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)

View File

@@ -1,18 +1,26 @@
--- ---
# nuzlocke-tracker-sij8 # nuzlocke-tracker-sij8
title: Add gift clause rule title: Add gift clause rule
status: todo status: completed
type: feature type: feature
priority: normal
created_at: 2026-02-20T19:56:10Z 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 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 ## Checklist
- [ ] Add `giftClause` to `NuzlockeRules` interface and `DEFAULT_RULES` (default: false) - [x] Add giftClause to NuzlockeRules interface and DEFAULT_RULES (default: false)
- [ ] Add `RuleDefinition` entry with appropriate category - [x] Add RuleDefinition entry with core category
- [ ] When enabled, gift-origin encounters bypass the route-lock check in the backend (similar to shinyClause bypass) - [x] Add origin column to Encounter model + alembic migration
- [ ] Update the encounter creation endpoint to check for giftClause when origin is "gift" - [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.", 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 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 ( gift_clause_on = run.rules.get("giftClause", False) if run.rules else False
"shed_evolution", skip_route_lock = (
"egg", (data.is_shiny and shiny_clause_on)
"transfer", 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 # 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 # Check if any relevant sibling already has an encounter in this run
# Exclude transfer-target encounters so they don't block the starter # Exclude transfer-target encounters so they don't block the starter
transfer_target_ids = select(GenlockeTransfer.target_encounter_id) transfer_target_ids = select(GenlockeTransfer.target_encounter_id)
existing_encounter = await session.execute( lock_query = select(Encounter).where(
select(Encounter).where(
Encounter.run_id == run_id, Encounter.run_id == run_id,
Encounter.route_id.in_(sibling_ids), Encounter.route_id.in_(sibling_ids),
~Encounter.id.in_(transfer_target_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: if existing_encounter.scalar_one_or_none() is not None:
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
@@ -119,6 +124,7 @@ async def create_encounter(
status=data.status, status=data.status,
catch_level=data.catch_level, catch_level=data.catch_level,
is_shiny=data.is_shiny, is_shiny=data.is_shiny,
origin=data.origin,
) )
session.add(encounter) session.add(encounter)
await session.commit() await session.commit()

View File

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

View File

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

View File

@@ -144,10 +144,12 @@ RUN_DEFS = [
DEFAULT_RULES = { DEFAULT_RULES = {
"duplicatesClause": True, "duplicatesClause": True,
"shinyClause": True, "shinyClause": True,
"giftClause": False,
"pinwheelClause": True, "pinwheelClause": True,
"levelCaps": False, "levelCaps": False,
"hardcoreMode": False, "hardcoreMode": False,
"setModeOnly": False, "setModeOnly": False,
"bossTeamMatch": False,
"egglocke": False, "egglocke": False,
"wonderlocke": False, "wonderlocke": False,
"randomizer": False, "randomizer": False,

View File

@@ -26,6 +26,7 @@ interface EncounterModalProps {
nickname?: string | undefined nickname?: string | undefined
status: EncounterStatus status: EncounterStatus
catchLevel?: number | undefined catchLevel?: number | undefined
origin?: string | undefined
}) => void }) => void
onUpdate?: onUpdate?:
| ((data: { | ((data: {
@@ -291,6 +292,7 @@ export function EncounterModal({
nickname: nickname || undefined, nickname: nickname || undefined,
status, status,
catchLevel: catchLevel ? Number(catchLevel) : undefined, 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 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({ function BossTeamPreview({
pokemon, pokemon,
starterName, starterName,
@@ -254,6 +265,7 @@ function BossTeamPreview({
interface RouteGroupProps { interface RouteGroupProps {
group: RouteWithChildren group: RouteWithChildren
encounterByRoute: Map<number, EncounterDetail> encounterByRoute: Map<number, EncounterDetail>
giftEncounterByRoute: Map<number, EncounterDetail>
isExpanded: boolean isExpanded: boolean
onToggleExpand: () => void onToggleExpand: () => void
onRouteClick: (route: Route) => void onRouteClick: (route: Route) => void
@@ -264,6 +276,7 @@ interface RouteGroupProps {
function RouteGroup({ function RouteGroup({
group, group,
encounterByRoute, encounterByRoute,
giftEncounterByRoute,
isExpanded, isExpanded,
onToggleExpand, onToggleExpand,
onRouteClick, onRouteClick,
@@ -274,13 +287,23 @@ function RouteGroup({
const usePinwheel = pinwheelClause && groupHasZones(group) const usePinwheel = pinwheelClause && groupHasZones(group)
const zoneEncounters = usePinwheel ? getZoneEncounters(group, encounterByRoute) : null 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 // For pinwheel groups, determine status from all zone statuses
let groupStatus: RouteStatus let groupStatus: RouteStatus
if (usePinwheel && zoneEncounters && zoneEncounters.size > 0) { if (usePinwheel && zoneEncounters && zoneEncounters.size > 0) {
// Use the first encounter's status as representative for the header groupStatus = displayEncounter ? displayEncounter.status : 'none'
groupStatus = groupEncounter ? groupEncounter.status : 'none'
} else { } else {
groupStatus = groupEncounter ? groupEncounter.status : 'none' groupStatus = displayEncounter ? displayEncounter.status : 'none'
} }
const si = statusIndicator[groupStatus] const si = statusIndicator[groupStatus]
@@ -289,10 +312,9 @@ function RouteGroup({
if (usePinwheel) { if (usePinwheel) {
// Show group if any zone matches the filter // Show group if any zone matches the filter
const anyChildMatches = group.children.some((child) => { 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 return getRouteStatus(enc) === filter
}) })
// Also check children without encounters (for 'none' filter)
if (!anyChildMatches) return null if (!anyChildMatches) return null
} else if (groupStatus !== filter) { } else if (groupStatus !== filter) {
return null return null
@@ -330,6 +352,36 @@ function RouteGroup({
groupEncounter.faintLevel !== null && groupEncounter.faintLevel !== null &&
(groupEncounter.deathCause ? `${groupEncounter.deathCause}` : ' (dead)')} (groupEncounter.deathCause ? `${groupEncounter.deathCause}` : ' (dead)')}
</span> </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>
)} )}
</div> </div>
@@ -349,7 +401,9 @@ function RouteGroup({
<div className="border-t border-border-default bg-surface-1/50"> <div className="border-t border-border-default bg-surface-1/50">
{group.children.map((child) => { {group.children.map((child) => {
const childEncounter = encounterByRoute.get(child.id) 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] const childSi = statusIndicator[childStatus]
let isDisabled: boolean let isDisabled: boolean
@@ -375,7 +429,22 @@ function RouteGroup({
<span className={`w-2 h-2 rounded-full shrink-0 ${childSi.dot}`} /> <span className={`w-2 h-2 rounded-full shrink-0 ${childSi.dot}`} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm text-text-secondary">{child.name}</div> <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"> <div className="flex flex-wrap gap-1 mt-0.5">
{child.encounterMethods.map((m) => ( {child.encounterMethods.map((m) => (
<EncounterMethodBadge key={m} method={m} size="xs" /> <EncounterMethodBadge key={m} method={m} size="xs" />
@@ -484,14 +553,29 @@ export function RunEncounters() {
} }
}, [run, transferIdSet]) }, [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 encounterByRoute = useMemo(() => {
const map = new Map<number, EncounterDetail>() const map = new Map<number, EncounterDetail>()
for (const enc of normalEncounters) { for (const enc of normalEncounters) {
if (giftClauseOn && enc.origin === 'gift') continue
map.set(enc.routeId, enc) map.set(enc.routeId, enc)
} }
return map 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 // Build set of retired Pokemon IDs from genlocke context
const retiredPokemonIds = useMemo(() => { 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) => { const filteredRoutes = organizedRoutes.filter((r) => {
if (filter === 'all') return true if (filter === 'all') return true
@@ -765,17 +849,23 @@ export function RunEncounters() {
if (usePinwheel) { if (usePinwheel) {
// Show group if any child/zone matches the filter // Show group if any child/zone matches the filter
return r.children.some((child) => { 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 return getRouteStatus(enc) === filter
}) })
} }
// Classic: single status for whole group // Classic: single status for whole group
const groupEnc = getGroupEncounter(r, encounterByRoute) 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 // Standalone route
const enc = encounterByRoute.get(r.id) const enc = encounterByRoute.get(r.id) ?? giftEncounterByRoute.get(r.id)
return getRouteStatus(enc) === filter return getRouteStatus(enc) === filter
}) })
@@ -974,14 +1064,22 @@ export function RunEncounters() {
{/* Level Cap Bar */} {/* Level Cap Bar */}
{run.rules?.levelCaps && sortedBosses.length > 0 && ( {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 justify-between mb-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-sm font-semibold text-text-primary"> <span className="text-sm font-semibold text-text-primary">
Level Cap: {currentLevelCap ?? '—'} Level Cap: {currentLevelCap ?? '—'}
</span> </span>
{nextBoss && ( {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 && ( {!nextBoss && (
<span className="text-sm text-status-active">All bosses defeated!</span> <span className="text-sm text-status-active">All bosses defeated!</span>
@@ -1226,6 +1324,7 @@ export function RunEncounters() {
key={route.id} key={route.id}
group={route} group={route}
encounterByRoute={encounterByRoute} encounterByRoute={encounterByRoute}
giftEncounterByRoute={giftEncounterByRoute}
isExpanded={expandedGroups.has(route.id)} isExpanded={expandedGroups.has(route.id)}
onToggleExpand={() => toggleGroup(route.id)} onToggleExpand={() => toggleGroup(route.id)}
onRouteClick={handleRouteClick} onRouteClick={handleRouteClick}
@@ -1235,7 +1334,9 @@ export function RunEncounters() {
) : ( ) : (
(() => { (() => {
const encounter = encounterByRoute.get(route.id) 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] const si = statusIndicator[rs]
return ( return (
@@ -1263,6 +1364,35 @@ export function RunEncounters() {
encounter.faintLevel !== null && encounter.faintLevel !== null &&
(encounter.deathCause ? `${encounter.deathCause}` : ' (dead)')} (encounter.deathCause ? `${encounter.deathCause}` : ' (dead)')}
</span> </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> </div>
) : ( ) : (
route.encounterMethods.length > 0 && ( route.encounterMethods.length > 0 && (

View File

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

View File

@@ -2,12 +2,14 @@ export interface NuzlockeRules {
// Core rules (affect tracker behavior) // Core rules (affect tracker behavior)
duplicatesClause: boolean duplicatesClause: boolean
shinyClause: boolean shinyClause: boolean
giftClause: boolean
pinwheelClause: boolean pinwheelClause: boolean
levelCaps: boolean levelCaps: boolean
// Playstyle (informational, for stats/categorization) // Playstyle (informational, for stats/categorization)
hardcoreMode: boolean hardcoreMode: boolean
setModeOnly: boolean setModeOnly: boolean
bossTeamMatch: boolean
// Variant (changes which Pokemon can appear) // Variant (changes which Pokemon can appear)
egglocke: boolean egglocke: boolean
@@ -19,12 +21,14 @@ export const DEFAULT_RULES: NuzlockeRules = {
// Core rules // Core rules
duplicatesClause: true, duplicatesClause: true,
shinyClause: true, shinyClause: true,
giftClause: false,
pinwheelClause: true, pinwheelClause: true,
levelCaps: false, levelCaps: false,
// Playstyle - off by default // Playstyle - off by default
hardcoreMode: false, hardcoreMode: false,
setModeOnly: false, setModeOnly: false,
bossTeamMatch: false,
// Variant - off by default // Variant - off by default
egglocke: false, 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.', 'Shiny Pokémon may always be caught, regardless of whether they are your first encounter.',
category: 'core', 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', key: 'pinwheelClause',
name: 'Pinwheel Clause', 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.', 'The game must be played in "Set" battle style, meaning you cannot switch Pokémon after knocking out an opponent.',
category: 'playstyle', 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 // Variant
{ {