Release: test infrastructure, rules overhaul, and design refresh #30

Merged
TheFurya merged 43 commits from develop into main 2026-02-21 16:58:18 +01:00
6 changed files with 68 additions and 28 deletions
Showing only changes of commit 85fef68dae - Show all commits

View File

@@ -1,32 +1,38 @@
---
# nuzlocke-tracker-knnc
title: Add static/legendary clause rule
status: todo
title: Add static encounter filter rule
status: in-progress
type: feature
priority: normal
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
---
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
- [ ] Add `static` encounter method to seed data and `METHOD_CONFIG` / `METHOD_ORDER`
- [ ] Add `staticClause` to `NuzlockeRules` interface and `DEFAULT_RULES` (default: false)
- [ ] 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)
- [ ] Update encounter creation frontend to show `static` as a selectable method where appropriate
- [x] Verify `static` encounter method exists in seed data; add to `METHOD_CONFIG` / `METHOD_ORDER` if missing
- [x] Add `staticClause` to `NuzlockeRules` interface and `DEFAULT_RULES` (default: true)
- [x] Add `RuleDefinition` entry under `core` category
- [x] Frontend: filter or gray out static encounters in encounter selector when `staticClause` is disabled
- [x] Backend seed data: add `staticClause` to `DEFAULT_RULES` in `inject_test_data.py`

View File

@@ -145,6 +145,7 @@ DEFAULT_RULES = {
"duplicatesClause": True,
"shinyClause": True,
"giftClause": False,
"staticClause": True,
"pinwheelClause": True,
"levelCaps": False,
"hardcoreMode": False,

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',