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
7 changed files with 213 additions and 21 deletions
Showing only changes of commit 2298c32691 - Show all commits

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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