Add egglocke, wonderlocke, and randomizer variant rules
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:
@@ -1,22 +1,10 @@
|
||||
---
|
||||
# nuzlocke-tracker-fitk
|
||||
title: Add egglocke, wonderlocke, and randomizer rules
|
||||
status: todo
|
||||
status: completed
|
||||
type: feature
|
||||
priority: normal
|
||||
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
|
||||
---
|
||||
|
||||
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
|
||||
@@ -125,7 +125,7 @@ RUN_DEFS = [
|
||||
"name": "Unova Adventure",
|
||||
"status": "active",
|
||||
"progress": 0.35,
|
||||
"rules": {},
|
||||
"rules": {"randomizer": True},
|
||||
"started_days_ago": 5,
|
||||
"ended_days_ago": None,
|
||||
},
|
||||
@@ -148,6 +148,9 @@ DEFAULT_RULES = {
|
||||
"levelCaps": False,
|
||||
"hardcoreMode": False,
|
||||
"setModeOnly": False,
|
||||
"egglocke": False,
|
||||
"wonderlocke": False,
|
||||
"randomizer": False,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { api } from '../api/client'
|
||||
import { useRoutePokemon } from '../hooks/useGames'
|
||||
import { useNameSuggestions } from '../hooks/useRuns'
|
||||
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 {
|
||||
route: Route
|
||||
@@ -33,6 +40,7 @@ interface EncounterModalProps {
|
||||
| undefined
|
||||
onClose: () => void
|
||||
isPending: boolean
|
||||
useAllPokemon?: boolean | undefined
|
||||
}
|
||||
|
||||
const statusOptions: {
|
||||
@@ -188,8 +196,12 @@ export function EncounterModal({
|
||||
onUpdate,
|
||||
onClose,
|
||||
isPending,
|
||||
useAllPokemon,
|
||||
}: 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 [status, setStatus] = useState<EncounterStatus>(existing?.status ?? 'caught')
|
||||
@@ -199,6 +211,8 @@ export function EncounterModal({
|
||||
const [deathCause, setDeathCause] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedCondition, setSelectedCondition] = useState<string | null>(null)
|
||||
const [allPokemonResults, setAllPokemonResults] = useState<Pokemon[]>([])
|
||||
const [isSearchingAll, setIsSearchingAll] = useState(false)
|
||||
|
||||
const isEditing = !!existing
|
||||
|
||||
@@ -218,6 +232,32 @@ export function EncounterModal({
|
||||
}
|
||||
}, [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(
|
||||
() => (routePokemon ? getUniqueConditions(routePokemon) : []),
|
||||
[routePokemon]
|
||||
@@ -282,7 +322,110 @@ export function EncounterModal({
|
||||
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
{/* 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 className="flex items-center justify-between mb-1">
|
||||
<label className="block text-sm font-medium text-text-secondary">Pokemon</label>
|
||||
|
||||
@@ -21,6 +21,8 @@ export function RuleBadges({ rules }: RuleBadgesProps) {
|
||||
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
def.category === 'core'
|
||||
? 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-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'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -20,6 +20,7 @@ export function RulesConfiguration({
|
||||
: RULE_DEFINITIONS
|
||||
const coreRules = visibleRules.filter((r) => r.category === 'core')
|
||||
const playstyleRules = visibleRules.filter((r) => r.category === 'playstyle')
|
||||
const variantRules = visibleRules.filter((r) => r.category === 'variant')
|
||||
|
||||
const handleRuleChange = (key: keyof NuzlockeRules, value: boolean) => {
|
||||
onChange({ ...rules, [key]: value })
|
||||
@@ -90,6 +91,26 @@ export function RulesConfiguration({
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -677,6 +677,7 @@ export function RunEncounters() {
|
||||
}
|
||||
|
||||
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)
|
||||
let completedCount = 0
|
||||
@@ -1411,6 +1412,7 @@ export function RunEncounters() {
|
||||
setEditingEncounter(null)
|
||||
}}
|
||||
isPending={createEncounter.isPending || updateEncounter.isPending}
|
||||
useAllPokemon={useAllPokemon}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ export interface NuzlockeRules {
|
||||
// Playstyle (informational, for stats/categorization)
|
||||
hardcoreMode: boolean
|
||||
setModeOnly: boolean
|
||||
|
||||
// Variant (changes which Pokemon can appear)
|
||||
egglocke: boolean
|
||||
wonderlocke: boolean
|
||||
randomizer: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_RULES: NuzlockeRules = {
|
||||
@@ -20,13 +25,18 @@ export const DEFAULT_RULES: NuzlockeRules = {
|
||||
// Playstyle - off by default
|
||||
hardcoreMode: false,
|
||||
setModeOnly: false,
|
||||
|
||||
// Variant - off by default
|
||||
egglocke: false,
|
||||
wonderlocke: false,
|
||||
randomizer: false,
|
||||
}
|
||||
|
||||
export interface RuleDefinition {
|
||||
key: keyof NuzlockeRules
|
||||
name: string
|
||||
description: string
|
||||
category: 'core' | 'playstyle'
|
||||
category: 'core' | 'playstyle' | 'variant'
|
||||
}
|
||||
|
||||
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.',
|
||||
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',
|
||||
},
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user