Add static clause rule for encounter selector filtering
All checks were successful
CI / backend-lint (push) Successful in 10s
CI / actions-lint (push) Successful in 15s
CI / frontend-lint (push) Successful in 23s

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:
2026-02-21 12:04:39 +01:00
parent aea5d1d84d
commit 85fef68dae
6 changed files with 68 additions and 28 deletions

View File

@@ -15,6 +15,10 @@ export const METHOD_CONFIG: Record<string, { label: string; color: string }> = {
label: 'Trade',
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: {
label: 'Grass',
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',
'fossil',
'trade',
'static',
'walk',
'headbutt',
'surf',

View File

@@ -42,6 +42,7 @@ interface EncounterModalProps {
onClose: () => void
isPending: boolean
useAllPokemon?: boolean | undefined
staticClause?: boolean | undefined
}
const statusOptions: {
@@ -132,7 +133,8 @@ function groupByMethod(
} else {
// Determine the display rate
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 (selectedCondition) {
const key = `${rp.pokemonId}:${rp.encounterMethod}`
@@ -198,6 +200,7 @@ export function EncounterModal({
onClose,
isPending,
useAllPokemon,
staticClause = true,
}: EncounterModalProps) {
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
useAllPokemon ? null : route.id,
@@ -443,7 +446,10 @@ export function EncounterModal({
}
onClick={() => {
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"
@@ -508,6 +514,9 @@ export function EncounterModal({
<div className="grid grid-cols-3 gap-2">
{pokemon.map(({ encounter: rp, conditions, displayRate }) => {
const isDuped = dupedPokemonIds?.has(rp.pokemonId) ?? false
const isStaticDisabled =
!staticClause && rp.encounterMethod === 'static'
const isDisabled = isDuped || isStaticDisabled
const isSelected =
selectedPokemon?.pokemonId === rp.pokemonId &&
selectedPokemon?.encounterMethod === rp.encounterMethod
@@ -515,10 +524,10 @@ export function EncounterModal({
<button
key={`${rp.encounterMethod}-${rp.pokemonId}`}
type="button"
onClick={() => !isDuped && setSelectedPokemon(rp)}
disabled={isDuped}
onClick={() => !isDisabled && setSelectedPokemon(rp)}
disabled={isDisabled}
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'
: isSelected
? 'border-accent-400 bg-accent-900/30'
@@ -546,22 +555,31 @@ export function EncounterModal({
: 'already caught'}
</span>
)}
{!isDuped && SPECIAL_METHODS.includes(rp.encounterMethod) && (
<EncounterMethodBadge method={rp.encounterMethod} />
)}
{!isDuped && displayRate !== null && displayRate !== undefined && (
<span className="text-[10px] text-purple-400 light:text-purple-700 font-medium">
{displayRate}%
{isStaticDisabled && (
<span className="text-[10px] text-text-tertiary italic">
static clause off
</span>
)}
{!isDuped &&
{!isDisabled &&
(SPECIAL_METHODS.includes(rp.encounterMethod) ||
rp.encounterMethod === 'static') && (
<EncounterMethodBadge method={rp.encounterMethod} />
)}
{!isDisabled &&
displayRate !== null &&
displayRate !== undefined && (
<span className="text-[10px] text-purple-400 light:text-purple-700 font-medium">
{displayRate}%
</span>
)}
{!isDisabled &&
selectedCondition === null &&
conditions.length > 0 && (
<span className="text-[10px] text-purple-400 light:text-purple-700">
{conditions.join(', ')}
</span>
)}
{!isDuped && (
{!isDisabled && (
<span className="text-[10px] text-text-tertiary">
Lv. {rp.minLevel}
{rp.maxLevel !== rp.minLevel && `${rp.maxLevel}`}

View File

@@ -1543,6 +1543,7 @@ export function RunEncounters() {
}}
isPending={createEncounter.isPending || updateEncounter.isPending}
useAllPokemon={useAllPokemon}
staticClause={run?.rules?.staticClause ?? true}
/>
)}

View File

@@ -3,6 +3,7 @@ export interface NuzlockeRules {
duplicatesClause: boolean
shinyClause: boolean
giftClause: boolean
staticClause: boolean
pinwheelClause: boolean
levelCaps: boolean
@@ -22,6 +23,7 @@ export const DEFAULT_RULES: NuzlockeRules = {
duplicatesClause: true,
shinyClause: true,
giftClause: false,
staticClause: true,
pinwheelClause: true,
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.",
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',
name: 'Pinwheel Clause',