diff --git a/.beans/nuzlocke-tracker-fitk--add-egglocke-wonderlocke-and-randomizer-rules.md b/.beans/nuzlocke-tracker-fitk--add-egglocke-wonderlocke-and-randomizer-rules.md index eaa1627..6f0d54f 100644 --- a/.beans/nuzlocke-tracker-fitk--add-egglocke-wonderlocke-and-randomizer-rules.md +++ b/.beans/nuzlocke-tracker-fitk--add-egglocke-wonderlocke-and-randomizer-rules.md @@ -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 \ No newline at end of file diff --git a/backend/src/app/seeds/inject_test_data.py b/backend/src/app/seeds/inject_test_data.py index 6b6a017..d313b7c 100644 --- a/backend/src/app/seeds/inject_test_data.py +++ b/backend/src/app/seeds/inject_test_data.py @@ -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, } diff --git a/frontend/src/components/EncounterModal.tsx b/frontend/src/components/EncounterModal.tsx index 7b17f07..2604b52 100644 --- a/frontend/src/components/EncounterModal.tsx +++ b/frontend/src/components/EncounterModal.tsx @@ -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(null) const [status, setStatus] = useState(existing?.status ?? 'caught') @@ -199,6 +211,8 @@ export function EncounterModal({ const [deathCause, setDeathCause] = useState('') const [search, setSearch] = useState('') const [selectedCondition, setSelectedCondition] = useState(null) + const [allPokemonResults, setAllPokemonResults] = useState([]) + 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({
{/* Pokemon Selection (only for new encounters) */} - {!isEditing && ( + {!isEditing && useAllPokemon && ( +
+ + {selectedPokemon ? ( +
+ {selectedPokemon.pokemon.spriteUrl ? ( + {selectedPokemon.pokemon.name} + ) : ( +
+ {selectedPokemon.pokemon.name[0]?.toUpperCase()} +
+ )} + + {selectedPokemon.pokemon.name} + + +
+ ) : ( + <> + 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 && ( +
+
+
+ )} + {allPokemonResults.length > 0 && ( +
+ {allPokemonResults.map((p) => { + const isDuped = dupedPokemonIds?.has(p.id) ?? false + return ( + + ) + })} +
+ )} + {search.length >= 2 && !isSearchingAll && allPokemonResults.length === 0 && ( +

No pokemon found

+ )} + + )} +
+ )} + + {!isEditing && !useAllPokemon && (
diff --git a/frontend/src/components/RuleBadges.tsx b/frontend/src/components/RuleBadges.tsx index b9e8156..2fc97cc 100644 --- a/frontend/src/components/RuleBadges.tsx +++ b/frontend/src/components/RuleBadges.tsx @@ -21,7 +21,9 @@ 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' - : '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} diff --git a/frontend/src/components/RulesConfiguration.tsx b/frontend/src/components/RulesConfiguration.tsx index d129c2c..6a66da8 100644 --- a/frontend/src/components/RulesConfiguration.tsx +++ b/frontend/src/components/RulesConfiguration.tsx @@ -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({ ))}
+ +
+
+

Run Variant

+

+ Changes which Pokémon can appear — affects the encounter selector +

+
+
+ {variantRules.map((rule) => ( + handleRuleChange(rule.key, value)} + /> + ))} +
+
) } diff --git a/frontend/src/pages/RunEncounters.tsx b/frontend/src/pages/RunEncounters.tsx index ff9577d..6dcd7ab 100644 --- a/frontend/src/pages/RunEncounters.tsx +++ b/frontend/src/pages/RunEncounters.tsx @@ -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} /> )} diff --git a/frontend/src/types/rules.ts b/frontend/src/types/rules.ts index 7249599..6022af6 100644 --- a/frontend/src/types/rules.ts +++ b/frontend/src/types/rules.ts @@ -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', + }, ]