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
|
# 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
|
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user