Add egglocke, wonderlocke, and randomizer variant rules
All checks were successful
CI / backend-lint (push) Successful in 9s
CI / actions-lint (push) Successful in 14s
CI / frontend-lint (push) Successful in 21s

When any variant rule is enabled, the encounter modal switches from
the game's regional dex to an all-Pokemon search (same debounced
API pattern as EggEncounterModal). A new "Run Variant" section in
rules configuration groups these rules, and badges render in amber.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 21:33:01 +01:00
parent e25d1cf24c
commit 2298c32691
7 changed files with 213 additions and 21 deletions

View File

@@ -1,22 +1,10 @@
--- ---
# nuzlocke-tracker-fitk # nuzlocke-tracker-fitk
title: Add egglocke, wonderlocke, and randomizer rules title: Add egglocke, wonderlocke, and randomizer rules
status: todo status: completed
type: feature type: feature
priority: normal
created_at: 2026-02-20T19:56:05Z created_at: 2026-02-20T19:56:05Z
updated_at: 2026-02-20T19:56:05Z updated_at: 2026-02-20T20:31:29Z
parent: nuzlocke-tracker-49xj parent: nuzlocke-tracker-49xj
--- ---
Add three new boolean rules that all share the same tracker logic: when enabled, the encounter Pokemon selector allows picking from ALL Pokemon (not just the game's regional dex).
- `egglocke` — all caught Pokemon are replaced with traded eggs
- `wonderlocke` — all caught Pokemon are Wonder Traded away
- `randomizer` — the run uses a randomized ROM
## Checklist
- [ ] Add `egglocke`, `wonderlocke`, `randomizer` to `NuzlockeRules` interface and `DEFAULT_RULES` (default: false)
- [ ] Add `RuleDefinition` entries with appropriate categories
- [ ] When any of these is enabled, encounter Pokemon selector should allow picking from ALL Pokemon
- [ ] Reuse the existing "all Pokemon" selector pattern used in admin panel encounter creation and boss team creation

View File

@@ -125,7 +125,7 @@ RUN_DEFS = [
"name": "Unova Adventure", "name": "Unova Adventure",
"status": "active", "status": "active",
"progress": 0.35, "progress": 0.35,
"rules": {}, "rules": {"randomizer": True},
"started_days_ago": 5, "started_days_ago": 5,
"ended_days_ago": None, "ended_days_ago": None,
}, },
@@ -148,6 +148,9 @@ DEFAULT_RULES = {
"levelCaps": False, "levelCaps": False,
"hardcoreMode": False, "hardcoreMode": False,
"setModeOnly": False, "setModeOnly": False,
"egglocke": False,
"wonderlocke": False,
"randomizer": False,
} }

View File

@@ -1,8 +1,15 @@
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import { api } from '../api/client'
import { useRoutePokemon } from '../hooks/useGames' import { useRoutePokemon } from '../hooks/useGames'
import { useNameSuggestions } from '../hooks/useRuns' import { useNameSuggestions } from '../hooks/useRuns'
import { EncounterMethodBadge, getMethodLabel, METHOD_ORDER } from './EncounterMethodBadge' import { EncounterMethodBadge, getMethodLabel, METHOD_ORDER } from './EncounterMethodBadge'
import type { Route, EncounterDetail, EncounterStatus, RouteEncounterDetail } from '../types' import type {
Route,
EncounterDetail,
EncounterStatus,
RouteEncounterDetail,
Pokemon,
} from '../types'
interface EncounterModalProps { interface EncounterModalProps {
route: Route route: Route
@@ -33,6 +40,7 @@ interface EncounterModalProps {
| undefined | undefined
onClose: () => void onClose: () => void
isPending: boolean isPending: boolean
useAllPokemon?: boolean | undefined
} }
const statusOptions: { const statusOptions: {
@@ -188,8 +196,12 @@ export function EncounterModal({
onUpdate, onUpdate,
onClose, onClose,
isPending, isPending,
useAllPokemon,
}: EncounterModalProps) { }: EncounterModalProps) {
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(route.id, gameId) const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
useAllPokemon ? null : route.id,
useAllPokemon ? undefined : gameId
)
const [selectedPokemon, setSelectedPokemon] = useState<RouteEncounterDetail | null>(null) const [selectedPokemon, setSelectedPokemon] = useState<RouteEncounterDetail | null>(null)
const [status, setStatus] = useState<EncounterStatus>(existing?.status ?? 'caught') const [status, setStatus] = useState<EncounterStatus>(existing?.status ?? 'caught')
@@ -199,6 +211,8 @@ export function EncounterModal({
const [deathCause, setDeathCause] = useState('') const [deathCause, setDeathCause] = useState('')
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [selectedCondition, setSelectedCondition] = useState<string | null>(null) const [selectedCondition, setSelectedCondition] = useState<string | null>(null)
const [allPokemonResults, setAllPokemonResults] = useState<Pokemon[]>([])
const [isSearchingAll, setIsSearchingAll] = useState(false)
const isEditing = !!existing const isEditing = !!existing
@@ -218,6 +232,32 @@ export function EncounterModal({
} }
}, [existing, routePokemon]) }, [existing, routePokemon])
// Debounced all-Pokemon search (variant rules)
useEffect(() => {
if (!useAllPokemon) return
if (search.length < 2) {
setAllPokemonResults([])
return
}
const timer = setTimeout(async () => {
setIsSearchingAll(true)
try {
const data = await api.get<{ items: Pokemon[] }>(
`/pokemon?search=${encodeURIComponent(search)}&limit=20`
)
setAllPokemonResults(data.items)
} catch {
setAllPokemonResults([])
} finally {
setIsSearchingAll(false)
}
}, 300)
return () => clearTimeout(timer)
}, [search, useAllPokemon])
const availableConditions = useMemo( const availableConditions = useMemo(
() => (routePokemon ? getUniqueConditions(routePokemon) : []), () => (routePokemon ? getUniqueConditions(routePokemon) : []),
[routePokemon] [routePokemon]
@@ -282,7 +322,110 @@ export function EncounterModal({
<div className="px-6 py-4 space-y-4"> <div className="px-6 py-4 space-y-4">
{/* Pokemon Selection (only for new encounters) */} {/* Pokemon Selection (only for new encounters) */}
{!isEditing && ( {!isEditing && useAllPokemon && (
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Pokemon</label>
{selectedPokemon ? (
<div className="flex items-center gap-3 p-3 rounded-lg border border-accent-400 bg-accent-900/20">
{selectedPokemon.pokemon.spriteUrl ? (
<img
src={selectedPokemon.pokemon.spriteUrl}
alt={selectedPokemon.pokemon.name}
className="w-10 h-10"
/>
) : (
<div className="w-10 h-10 rounded-full bg-surface-3 flex items-center justify-center text-xs font-bold">
{selectedPokemon.pokemon.name[0]?.toUpperCase()}
</div>
)}
<span className="font-medium text-text-primary capitalize">
{selectedPokemon.pokemon.name}
</span>
<button
onClick={() => {
setSelectedPokemon(null)
setSearch('')
setAllPokemonResults([])
}}
className="ml-auto text-sm text-text-tertiary hover:text-text-secondary"
>
Change
</button>
</div>
) : (
<>
<input
type="text"
placeholder="Search all pokemon by name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary text-sm focus:outline-none focus:ring-2 focus:ring-accent-400"
/>
{isSearchingAll && (
<div className="flex items-center justify-center py-4">
<div className="w-6 h-6 border-2 border-accent-400 border-t-transparent rounded-full animate-spin" />
</div>
)}
{allPokemonResults.length > 0 && (
<div className="mt-2 max-h-64 overflow-y-auto grid grid-cols-3 gap-2">
{allPokemonResults.map((p) => {
const isDuped = dupedPokemonIds?.has(p.id) ?? false
return (
<button
key={p.id}
type="button"
onClick={() => {
if (!isDuped) {
setSelectedPokemon({
id: 0,
routeId: 0,
gameId: 0,
pokemonId: p.id,
pokemon: p,
encounterMethod: 'walking',
encounterRate: 0,
condition: '',
minLevel: 1,
maxLevel: 100,
})
}
}}
disabled={isDuped}
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
isDuped
? 'opacity-40 cursor-not-allowed border-border-default'
: 'border-border-default hover:border-accent-400'
}`}
>
{p.spriteUrl ? (
<img src={p.spriteUrl} alt={p.name} className="w-10 h-10" />
) : (
<div className="w-10 h-10 rounded-full bg-surface-3 flex items-center justify-center text-xs font-bold">
{p.name[0]?.toUpperCase()}
</div>
)}
<span className="text-xs text-text-secondary mt-1 capitalize">
{p.name}
</span>
{isDuped && (
<span className="text-[10px] text-text-tertiary italic">
{retiredPokemonIds?.has(p.id) ? 'retired (HoF)' : 'already caught'}
</span>
)}
</button>
)
})}
</div>
)}
{search.length >= 2 && !isSearchingAll && allPokemonResults.length === 0 && (
<p className="text-sm text-text-tertiary py-2">No pokemon found</p>
)}
</>
)}
</div>
)}
{!isEditing && !useAllPokemon && (
<div> <div>
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-text-secondary">Pokemon</label> <label className="block text-sm font-medium text-text-secondary">Pokemon</label>

View File

@@ -21,7 +21,9 @@ export function RuleBadges({ rules }: RuleBadgesProps) {
className={`px-2 py-0.5 rounded-full text-xs font-medium ${ className={`px-2 py-0.5 rounded-full text-xs font-medium ${
def.category === 'core' def.category === 'core'
? 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-700' ? 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-700'
: 'bg-purple-900/40 text-purple-300 light:bg-purple-100 light:text-purple-700' : def.category === 'variant'
? 'bg-amber-900/40 text-amber-300 light:bg-amber-100 light:text-amber-700'
: 'bg-purple-900/40 text-purple-300 light:bg-purple-100 light:text-purple-700'
}`} }`}
> >
{def.name} {def.name}

View File

@@ -20,6 +20,7 @@ export function RulesConfiguration({
: RULE_DEFINITIONS : RULE_DEFINITIONS
const coreRules = visibleRules.filter((r) => r.category === 'core') const coreRules = visibleRules.filter((r) => r.category === 'core')
const playstyleRules = visibleRules.filter((r) => r.category === 'playstyle') const playstyleRules = visibleRules.filter((r) => r.category === 'playstyle')
const variantRules = visibleRules.filter((r) => r.category === 'variant')
const handleRuleChange = (key: keyof NuzlockeRules, value: boolean) => { const handleRuleChange = (key: keyof NuzlockeRules, value: boolean) => {
onChange({ ...rules, [key]: value }) onChange({ ...rules, [key]: value })
@@ -90,6 +91,26 @@ export function RulesConfiguration({
))} ))}
</div> </div>
</div> </div>
<div className="bg-surface-1 rounded-lg shadow">
<div className="px-4 py-3 border-b border-border-default">
<h3 className="text-lg font-medium text-text-primary">Run Variant</h3>
<p className="text-sm text-text-tertiary">
Changes which Pokémon can appear affects the encounter selector
</p>
</div>
<div className="px-4">
{variantRules.map((rule) => (
<RuleToggle
key={rule.key}
name={rule.name}
description={rule.description}
enabled={rules[rule.key]}
onChange={(value) => handleRuleChange(rule.key, value)}
/>
))}
</div>
</div>
</div> </div>
) )
} }

View File

@@ -677,6 +677,7 @@ export function RunEncounters() {
} }
const pinwheelClause = run.rules?.pinwheelClause ?? true const pinwheelClause = run.rules?.pinwheelClause ?? true
const useAllPokemon = !!(run.rules?.egglocke || run.rules?.wonderlocke || run.rules?.randomizer)
// Count completed locations (zone-aware when pinwheel clause is on) // Count completed locations (zone-aware when pinwheel clause is on)
let completedCount = 0 let completedCount = 0
@@ -1411,6 +1412,7 @@ export function RunEncounters() {
setEditingEncounter(null) setEditingEncounter(null)
}} }}
isPending={createEncounter.isPending || updateEncounter.isPending} isPending={createEncounter.isPending || updateEncounter.isPending}
useAllPokemon={useAllPokemon}
/> />
)} )}

View File

@@ -8,6 +8,11 @@ export interface NuzlockeRules {
// Playstyle (informational, for stats/categorization) // Playstyle (informational, for stats/categorization)
hardcoreMode: boolean hardcoreMode: boolean
setModeOnly: boolean setModeOnly: boolean
// Variant (changes which Pokemon can appear)
egglocke: boolean
wonderlocke: boolean
randomizer: boolean
} }
export const DEFAULT_RULES: NuzlockeRules = { export const DEFAULT_RULES: NuzlockeRules = {
@@ -20,13 +25,18 @@ export const DEFAULT_RULES: NuzlockeRules = {
// Playstyle - off by default // Playstyle - off by default
hardcoreMode: false, hardcoreMode: false,
setModeOnly: false, setModeOnly: false,
// Variant - off by default
egglocke: false,
wonderlocke: false,
randomizer: false,
} }
export interface RuleDefinition { export interface RuleDefinition {
key: keyof NuzlockeRules key: keyof NuzlockeRules
name: string name: string
description: string description: string
category: 'core' | 'playstyle' category: 'core' | 'playstyle' | 'variant'
} }
export const RULE_DEFINITIONS: RuleDefinition[] = [ export const RULE_DEFINITIONS: RuleDefinition[] = [
@@ -74,4 +84,27 @@ 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',
}, },
// Variant
{
key: 'egglocke',
name: 'Egglocke',
description:
'All caught Pokémon are replaced with traded eggs. The encounter selector shows all Pokémon since the hatched species is unknown.',
category: 'variant',
},
{
key: 'wonderlocke',
name: 'Wonderlocke',
description:
'All caught Pokémon are Wonder Traded away. The encounter selector shows all Pokémon since the received species is unknown.',
category: 'variant',
},
{
key: 'randomizer',
name: 'Randomizer',
description:
"The ROM's wild Pokémon are randomized, so the encounter selector shows all Pokémon instead of the game's regional dex.",
category: 'variant',
},
] ]