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 # 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`

View File

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

View File

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

View File

@@ -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 && (
<EncounterMethodBadge method={rp.encounterMethod} /> <span className="text-[10px] text-text-tertiary italic">
)} static clause off
{!isDuped && displayRate !== null && displayRate !== undefined && (
<span className="text-[10px] text-purple-400 light:text-purple-700 font-medium">
{displayRate}%
</span> </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 && 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}`}

View File

@@ -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}
/> />
)} )}

View File

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