Add static clause rule for encounter selector filtering
When disabled, static encounters (legendaries, scripted Pokémon) are grayed out and unselectable in the encounter selector. Enabled by default. Adds 'static' to METHOD_CONFIG/METHOD_ORDER with a teal badge. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,32 +1,38 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-knnc
|
# nuzlocke-tracker-knnc
|
||||||
title: Add static/legendary clause rule
|
title: Add static encounter filter rule
|
||||||
status: todo
|
status: in-progress
|
||||||
type: feature
|
type: feature
|
||||||
priority: normal
|
priority: normal
|
||||||
created_at: 2026-02-20T19:56:27Z
|
created_at: 2026-02-20T19:56:27Z
|
||||||
updated_at: 2026-02-20T20:02:07Z
|
updated_at: 2026-02-21T11:03:12Z
|
||||||
parent: nuzlocke-tracker-49xj
|
parent: nuzlocke-tracker-49xj
|
||||||
---
|
---
|
||||||
|
|
||||||
Control whether static/legendary encounters count against the area's encounter limit.
|
Control whether static encounters are available in the encounter selector. Static encounters already exist in the route encounter tables (e.g., Zapdos in Power Plant, Snorlax on Route 7 in X/Y). This rule acts as a display filter, not a route-lock bypass like gift clause.
|
||||||
|
|
||||||
## Design Decisions
|
## Motivation
|
||||||
|
|
||||||
**Scope:** This rule covers overworld Pokemon that are always available (legendaries, Snorlax blocking the road, Sudowoodo, Voltorb in the power plant, etc.). These are distinct from gifts (given by NPCs) which are covered by giftClause (sij8).
|
Static encounters can feel unfair in nuzlockes because they are deterministic — the player is forced to pick a specific Pokemon rather than getting the randomness that makes nuzlockes fun. Example: Snorlax blocks Route 7 in X/Y. By definition it is the first encounter, but being forced to take it reduces variety.
|
||||||
|
|
||||||
**Encounter method:** The existing encounter method list (walk, surf, gift, fossil, etc.) doesn't have a "static" method. Add `static` as a new encounter method in the seed data and `METHOD_CONFIG`. Static encounters are one-time overworld Pokemon the player walks up to and battles.
|
Some static encounters are also overpowered (legendaries), which some players want to avoid.
|
||||||
|
|
||||||
**Rule behavior:** `staticClause: boolean` (default: false). When enabled, encounters with method `static` bypass the route-lock check (same pattern as shinyClause and giftClause). This means static Pokemon are "free" and don't consume the area's encounter.
|
## Design
|
||||||
|
|
||||||
**No legendary ban:** Rather than banning legendaries outright, the community standard is to let the player choose. The tracker just needs to support logging static encounters correctly. Players who want to ban legendaries simply don't catch them.
|
**Rule:** `staticClause: boolean` (default: true — static encounters enabled by default). When disabled, encounters with a `static` encounter method are hidden or grayed out in the encounter selector, so the player skips them and gets a different first encounter.
|
||||||
|
|
||||||
**Interaction with giftClause:** These are separate rules. `giftClause` covers NPC gifts (method: `gift`). `staticClause` covers overworld statics (method: `static`). A player can enable both, one, or neither.
|
**This is NOT like gift clause.** There is no dual-encounter per route. Disabling static encounters simply filters them out of the available encounter pool for a location. The player still gets one encounter per area — just not the static one.
|
||||||
|
|
||||||
|
**Encounter method:** The existing encounter tables already include static encounters (e.g., Zapdos in Power Plant). The `static` encounter method may already exist in seed data — verify before adding. If not present, add it to seed data and `METHOD_CONFIG` / `METHOD_ORDER`.
|
||||||
|
|
||||||
|
**Frontend behavior:**
|
||||||
|
- When `staticClause` is **enabled** (default): static encounters appear normally in the encounter selector
|
||||||
|
- When `staticClause` is **disabled**: static encounters are hidden or visually grayed out in the encounter selector, preventing the player from selecting them
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
- [ ] Add `static` encounter method to seed data and `METHOD_CONFIG` / `METHOD_ORDER`
|
- [x] Verify `static` encounter method exists in seed data; add to `METHOD_CONFIG` / `METHOD_ORDER` if missing
|
||||||
- [ ] Add `staticClause` to `NuzlockeRules` interface and `DEFAULT_RULES` (default: false)
|
- [x] Add `staticClause` to `NuzlockeRules` interface and `DEFAULT_RULES` (default: true)
|
||||||
- [ ] Add `RuleDefinition` entry under `'core'` category
|
- [x] Add `RuleDefinition` entry under `core` category
|
||||||
- [ ] When enabled, encounters with method `static` bypass route-lock check in backend (add to `skip_route_lock` condition alongside shiny/egg/shed/transfer)
|
- [x] Frontend: filter or gray out static encounters in encounter selector when `staticClause` is disabled
|
||||||
- [ ] Update encounter creation frontend to show `static` as a selectable method where appropriate
|
- [x] Backend seed data: add `staticClause` to `DEFAULT_RULES` in `inject_test_data.py`
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ DEFAULT_RULES = {
|
|||||||
"duplicatesClause": True,
|
"duplicatesClause": True,
|
||||||
"shinyClause": True,
|
"shinyClause": True,
|
||||||
"giftClause": False,
|
"giftClause": False,
|
||||||
|
"staticClause": True,
|
||||||
"pinwheelClause": True,
|
"pinwheelClause": True,
|
||||||
"levelCaps": False,
|
"levelCaps": False,
|
||||||
"hardcoreMode": False,
|
"hardcoreMode": False,
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export const METHOD_CONFIG: Record<string, { label: string; color: string }> = {
|
|||||||
label: 'Trade',
|
label: 'Trade',
|
||||||
color: 'bg-emerald-900/40 text-emerald-300 light:bg-emerald-100 light:text-emerald-700',
|
color: 'bg-emerald-900/40 text-emerald-300 light:bg-emerald-100 light:text-emerald-700',
|
||||||
},
|
},
|
||||||
|
static: {
|
||||||
|
label: 'Static',
|
||||||
|
color: 'bg-teal-900/40 text-teal-300 light:bg-teal-100 light:text-teal-700',
|
||||||
|
},
|
||||||
walk: {
|
walk: {
|
||||||
label: 'Grass',
|
label: 'Grass',
|
||||||
color: 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-700',
|
color: 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-700',
|
||||||
@@ -59,6 +63,7 @@ export const METHOD_ORDER = [
|
|||||||
'gift',
|
'gift',
|
||||||
'fossil',
|
'fossil',
|
||||||
'trade',
|
'trade',
|
||||||
|
'static',
|
||||||
'walk',
|
'walk',
|
||||||
'headbutt',
|
'headbutt',
|
||||||
'surf',
|
'surf',
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ interface EncounterModalProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
isPending: boolean
|
isPending: boolean
|
||||||
useAllPokemon?: boolean | undefined
|
useAllPokemon?: boolean | undefined
|
||||||
|
staticClause?: boolean | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusOptions: {
|
const statusOptions: {
|
||||||
@@ -132,7 +133,8 @@ function groupByMethod(
|
|||||||
} else {
|
} else {
|
||||||
// Determine the display rate
|
// Determine the display rate
|
||||||
let displayRate: number | null = null
|
let displayRate: number | null = null
|
||||||
const isSpecial = SPECIAL_METHODS.includes(rp.encounterMethod)
|
const isSpecial =
|
||||||
|
SPECIAL_METHODS.includes(rp.encounterMethod) || rp.encounterMethod === 'static'
|
||||||
if (!isSpecial) {
|
if (!isSpecial) {
|
||||||
if (selectedCondition) {
|
if (selectedCondition) {
|
||||||
const key = `${rp.pokemonId}:${rp.encounterMethod}`
|
const key = `${rp.pokemonId}:${rp.encounterMethod}`
|
||||||
@@ -198,6 +200,7 @@ export function EncounterModal({
|
|||||||
onClose,
|
onClose,
|
||||||
isPending,
|
isPending,
|
||||||
useAllPokemon,
|
useAllPokemon,
|
||||||
|
staticClause = true,
|
||||||
}: EncounterModalProps) {
|
}: EncounterModalProps) {
|
||||||
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
|
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
|
||||||
useAllPokemon ? null : route.id,
|
useAllPokemon ? null : route.id,
|
||||||
@@ -443,7 +446,10 @@ export function EncounterModal({
|
|||||||
}
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (routePokemon) {
|
if (routePokemon) {
|
||||||
setSelectedPokemon(pickRandomPokemon(routePokemon, dupedPokemonIds))
|
const eligible = staticClause
|
||||||
|
? routePokemon
|
||||||
|
: routePokemon.filter((rp) => rp.encounterMethod !== 'static')
|
||||||
|
setSelectedPokemon(pickRandomPokemon(eligible, dupedPokemonIds))
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-600 text-purple-400 light:text-purple-700 light:border-purple-500 hover:bg-purple-900/20 light:hover:bg-purple-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-600 text-purple-400 light:text-purple-700 light:border-purple-500 hover:bg-purple-900/20 light:hover:bg-purple-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
@@ -508,6 +514,9 @@ export function EncounterModal({
|
|||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{pokemon.map(({ encounter: rp, conditions, displayRate }) => {
|
{pokemon.map(({ encounter: rp, conditions, displayRate }) => {
|
||||||
const isDuped = dupedPokemonIds?.has(rp.pokemonId) ?? false
|
const isDuped = dupedPokemonIds?.has(rp.pokemonId) ?? false
|
||||||
|
const isStaticDisabled =
|
||||||
|
!staticClause && rp.encounterMethod === 'static'
|
||||||
|
const isDisabled = isDuped || isStaticDisabled
|
||||||
const isSelected =
|
const isSelected =
|
||||||
selectedPokemon?.pokemonId === rp.pokemonId &&
|
selectedPokemon?.pokemonId === rp.pokemonId &&
|
||||||
selectedPokemon?.encounterMethod === rp.encounterMethod
|
selectedPokemon?.encounterMethod === rp.encounterMethod
|
||||||
@@ -515,10 +524,10 @@ export function EncounterModal({
|
|||||||
<button
|
<button
|
||||||
key={`${rp.encounterMethod}-${rp.pokemonId}`}
|
key={`${rp.encounterMethod}-${rp.pokemonId}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => !isDuped && setSelectedPokemon(rp)}
|
onClick={() => !isDisabled && setSelectedPokemon(rp)}
|
||||||
disabled={isDuped}
|
disabled={isDisabled}
|
||||||
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
|
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
|
||||||
isDuped
|
isDisabled
|
||||||
? 'opacity-40 cursor-not-allowed border-border-default'
|
? 'opacity-40 cursor-not-allowed border-border-default'
|
||||||
: isSelected
|
: isSelected
|
||||||
? 'border-accent-400 bg-accent-900/30'
|
? 'border-accent-400 bg-accent-900/30'
|
||||||
@@ -546,22 +555,31 @@ export function EncounterModal({
|
|||||||
: 'already caught'}
|
: 'already caught'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!isDuped && SPECIAL_METHODS.includes(rp.encounterMethod) && (
|
{isStaticDisabled && (
|
||||||
|
<span className="text-[10px] text-text-tertiary italic">
|
||||||
|
static clause off
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!isDisabled &&
|
||||||
|
(SPECIAL_METHODS.includes(rp.encounterMethod) ||
|
||||||
|
rp.encounterMethod === 'static') && (
|
||||||
<EncounterMethodBadge method={rp.encounterMethod} />
|
<EncounterMethodBadge method={rp.encounterMethod} />
|
||||||
)}
|
)}
|
||||||
{!isDuped && displayRate !== null && displayRate !== undefined && (
|
{!isDisabled &&
|
||||||
|
displayRate !== null &&
|
||||||
|
displayRate !== undefined && (
|
||||||
<span className="text-[10px] text-purple-400 light:text-purple-700 font-medium">
|
<span className="text-[10px] text-purple-400 light:text-purple-700 font-medium">
|
||||||
{displayRate}%
|
{displayRate}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!isDuped &&
|
{!isDisabled &&
|
||||||
selectedCondition === null &&
|
selectedCondition === null &&
|
||||||
conditions.length > 0 && (
|
conditions.length > 0 && (
|
||||||
<span className="text-[10px] text-purple-400 light:text-purple-700">
|
<span className="text-[10px] text-purple-400 light:text-purple-700">
|
||||||
{conditions.join(', ')}
|
{conditions.join(', ')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!isDuped && (
|
{!isDisabled && (
|
||||||
<span className="text-[10px] text-text-tertiary">
|
<span className="text-[10px] text-text-tertiary">
|
||||||
Lv. {rp.minLevel}
|
Lv. {rp.minLevel}
|
||||||
{rp.maxLevel !== rp.minLevel && `–${rp.maxLevel}`}
|
{rp.maxLevel !== rp.minLevel && `–${rp.maxLevel}`}
|
||||||
|
|||||||
@@ -1543,6 +1543,7 @@ export function RunEncounters() {
|
|||||||
}}
|
}}
|
||||||
isPending={createEncounter.isPending || updateEncounter.isPending}
|
isPending={createEncounter.isPending || updateEncounter.isPending}
|
||||||
useAllPokemon={useAllPokemon}
|
useAllPokemon={useAllPokemon}
|
||||||
|
staticClause={run?.rules?.staticClause ?? true}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export interface NuzlockeRules {
|
|||||||
duplicatesClause: boolean
|
duplicatesClause: boolean
|
||||||
shinyClause: boolean
|
shinyClause: boolean
|
||||||
giftClause: boolean
|
giftClause: boolean
|
||||||
|
staticClause: boolean
|
||||||
pinwheelClause: boolean
|
pinwheelClause: boolean
|
||||||
levelCaps: boolean
|
levelCaps: boolean
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export const DEFAULT_RULES: NuzlockeRules = {
|
|||||||
duplicatesClause: true,
|
duplicatesClause: true,
|
||||||
shinyClause: true,
|
shinyClause: true,
|
||||||
giftClause: false,
|
giftClause: false,
|
||||||
|
staticClause: true,
|
||||||
pinwheelClause: true,
|
pinwheelClause: true,
|
||||||
levelCaps: false,
|
levelCaps: false,
|
||||||
|
|
||||||
@@ -66,6 +68,13 @@ export const RULE_DEFINITIONS: RuleDefinition[] = [
|
|||||||
"In-game gift Pokémon (starters, trades, fossils) do not count against a location's encounter limit.",
|
"In-game gift Pokémon (starters, trades, fossils) do not count against a location's encounter limit.",
|
||||||
category: 'core',
|
category: 'core',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'staticClause',
|
||||||
|
name: 'Static Clause',
|
||||||
|
description:
|
||||||
|
'Static encounters (legendaries, scripted Pokémon) are available in the encounter selector. Disable to skip them and treat the next wild encounter as your pick.',
|
||||||
|
category: 'core',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'pinwheelClause',
|
key: 'pinwheelClause',
|
||||||
name: 'Pinwheel Clause',
|
name: 'Pinwheel Clause',
|
||||||
|
|||||||
Reference in New Issue
Block a user