Add type restriction rule (monolocke)
All checks were successful
CI / backend-lint (push) Successful in 10s
CI / actions-lint (push) Successful in 14s
CI / frontend-lint (push) Successful in 22s

Adds allowedTypes: string[] to NuzlockeRules. When set, the encounter
selector hides non-matching Pokemon and the routes endpoint filters out
routes with no matching encounters, so only eligible locations appear.

Type picker UI in RulesConfiguration; active restriction shown in
RuleBadges. Backend accepts allowed_types query param and joins through
RouteEncounter.pokemon to filter by type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 12:22:05 +01:00
parent 85fef68dae
commit 993ad09d9c
11 changed files with 149 additions and 28 deletions

View File

@@ -13,10 +13,12 @@ export function getGame(id: number): Promise<GameDetail> {
return api.get(`/games/${id}`)
}
export function getGameRoutes(gameId: number): Promise<Route[]> {
export function getGameRoutes(gameId: number, allowedTypes?: string[]): Promise<Route[]> {
// Use flat=true to get all routes in a flat list
// The frontend organizes them into hierarchy based on parentRouteId
return api.get(`/games/${gameId}/routes?flat=true`)
const params = new URLSearchParams({ flat: 'true' })
for (const t of allowedTypes ?? []) params.append('allowed_types', t)
return api.get(`/games/${gameId}/routes?${params}`)
}
export function getRoutePokemon(routeId: number, gameId?: number): Promise<RouteEncounterDetail[]> {

View File

@@ -43,6 +43,7 @@ interface EncounterModalProps {
isPending: boolean
useAllPokemon?: boolean | undefined
staticClause?: boolean | undefined
allowedTypes?: string[] | undefined
}
const statusOptions: {
@@ -201,6 +202,7 @@ export function EncounterModal({
isPending,
useAllPokemon,
staticClause = true,
allowedTypes,
}: EncounterModalProps) {
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
useAllPokemon ? null : route.id,
@@ -267,8 +269,10 @@ export function EncounterModal({
[routePokemon]
)
const filteredPokemon = routePokemon?.filter((rp) =>
rp.pokemon.name.toLowerCase().includes(search.toLowerCase())
const filteredPokemon = routePokemon?.filter(
(rp) =>
rp.pokemon.name.toLowerCase().includes(search.toLowerCase()) &&
(!allowedTypes?.length || rp.pokemon.types.some((t) => allowedTypes.includes(t)))
)
const groupedPokemon = useMemo(
@@ -446,9 +450,13 @@ export function EncounterModal({
}
onClick={() => {
if (routePokemon) {
const eligible = staticClause
? routePokemon
: routePokemon.filter((rp) => rp.encounterMethod !== 'static')
const eligible = routePokemon
.filter((rp) => staticClause || rp.encounterMethod !== 'static')
.filter(
(rp) =>
!allowedTypes?.length ||
rp.pokemon.types.some((t) => allowedTypes.includes(t))
)
setSelectedPokemon(pickRandomPokemon(eligible, dupedPokemonIds))
}
}}

View File

@@ -1,5 +1,6 @@
import type { NuzlockeRules } from '../types'
import { RULE_DEFINITIONS } from '../types/rules'
import { TypeBadge } from './TypeBadge'
interface RuleBadgesProps {
rules: NuzlockeRules
@@ -7,8 +8,9 @@ interface RuleBadgesProps {
export function RuleBadges({ rules }: RuleBadgesProps) {
const enabledRules = RULE_DEFINITIONS.filter((def) => rules[def.key])
const allowedTypes = rules.allowedTypes ?? []
if (enabledRules.length === 0) {
if (enabledRules.length === 0 && allowedTypes.length === 0) {
return <span className="text-sm text-text-tertiary">No rules enabled</span>
}
@@ -29,6 +31,17 @@ export function RuleBadges({ rules }: RuleBadgesProps) {
{def.name}
</span>
))}
{allowedTypes.length > 0 && (
<span
title={`Type restriction: ${allowedTypes.map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(', ')}`}
className="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-900/40 text-amber-300 light:bg-amber-100 light:text-amber-700 flex items-center gap-1"
>
<span>Type Restriction</span>
{allowedTypes.map((t) => (
<TypeBadge key={t} type={t} size="sm" />
))}
</span>
)}
</div>
)
}

View File

@@ -1,6 +1,28 @@
import type { NuzlockeRules } from '../types/rules'
import { RULE_DEFINITIONS, DEFAULT_RULES } from '../types/rules'
import { RuleToggle } from './RuleToggle'
import { TypeBadge } from './TypeBadge'
const POKEMON_TYPES = [
'bug',
'dark',
'dragon',
'electric',
'fairy',
'fighting',
'fire',
'flying',
'ghost',
'grass',
'ground',
'ice',
'normal',
'poison',
'psychic',
'rock',
'steel',
'water',
] as const
interface RulesConfigurationProps {
rules: NuzlockeRules
@@ -31,8 +53,18 @@ export function RulesConfiguration({
onReset?.()
}
const enabledCount = visibleRules.filter((r) => rules[r.key]).length
const totalCount = visibleRules.length
const allowedTypes = rules.allowedTypes ?? []
const toggleType = (type: string) => {
const next = allowedTypes.includes(type)
? allowedTypes.filter((t) => t !== type)
: [...allowedTypes, type]
onChange({ ...rules, allowedTypes: next })
}
const enabledCount =
visibleRules.filter((r) => rules[r.key]).length + (allowedTypes.length > 0 ? 1 : 0)
const totalCount = visibleRules.length + 1 // +1 for type restriction
return (
<div className="space-y-6">
@@ -111,6 +143,44 @@ 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">Type Restriction</h3>
<p className="text-sm text-text-tertiary">
Monolocke and variants. Select allowed types a Pokémon qualifies if it shares at least
one type. Leave all deselected to disable.
</p>
</div>
<div className="px-4 py-4">
<div className="flex flex-wrap gap-2">
{POKEMON_TYPES.map((type) => (
<button
key={type}
type="button"
onClick={() => toggleType(type)}
title={type.charAt(0).toUpperCase() + type.slice(1)}
className={`p-1.5 rounded-lg border-2 transition-colors ${
allowedTypes.includes(type)
? 'border-accent-400 bg-accent-900/20'
: 'border-transparent opacity-40 hover:opacity-70'
}`}
>
<TypeBadge type={type} size="md" />
</button>
))}
</div>
{allowedTypes.length > 0 && (
<button
type="button"
onClick={() => onChange({ ...rules, allowedTypes: [] })}
className="mt-3 text-xs text-text-tertiary hover:text-text-secondary"
>
Clear selection
</button>
)}
</div>
</div>
</div>
)
}

View File

@@ -15,10 +15,10 @@ export function useGame(id: number) {
})
}
export function useGameRoutes(gameId: number | null) {
export function useGameRoutes(gameId: number | null, allowedTypes?: string[]) {
return useQuery({
queryKey: ['games', gameId, 'routes'],
queryFn: () => getGameRoutes(gameId!),
queryKey: ['games', gameId, 'routes', allowedTypes],
queryFn: () => getGameRoutes(gameId!, allowedTypes),
enabled: gameId !== null,
})
}

View File

@@ -470,7 +470,11 @@ export function RunEncounters() {
const { data: run, isLoading, error } = useRun(runIdNum)
const advanceLeg = useAdvanceLeg()
const [showTransferModal, setShowTransferModal] = useState(false)
const { data: routes, isLoading: loadingRoutes } = useGameRoutes(run?.gameId ?? null)
const rulesAllowedTypes = run?.rules?.allowedTypes ?? []
const { data: routes, isLoading: loadingRoutes } = useGameRoutes(
run?.gameId ?? null,
rulesAllowedTypes.length ? rulesAllowedTypes : undefined
)
const createEncounter = useCreateEncounter(runIdNum)
const updateEncounter = useUpdateEncounter(runIdNum)
const bulkRandomize = useBulkRandomize(runIdNum)
@@ -1544,6 +1548,7 @@ export function RunEncounters() {
isPending={createEncounter.isPending || updateEncounter.isPending}
useAllPokemon={useAllPokemon}
staticClause={run?.rules?.staticClause ?? true}
allowedTypes={rulesAllowedTypes.length ? rulesAllowedTypes : undefined}
/>
)}

View File

@@ -16,8 +16,14 @@ export interface NuzlockeRules {
egglocke: boolean
wonderlocke: boolean
randomizer: boolean
// Type restriction (monolocke and variants)
allowedTypes: string[]
}
/** Keys of NuzlockeRules that are boolean toggles (excludes array fields) */
type BooleanRuleKeys = Exclude<keyof NuzlockeRules, 'allowedTypes'>
export const DEFAULT_RULES: NuzlockeRules = {
// Core rules
duplicatesClause: true,
@@ -36,10 +42,13 @@ export const DEFAULT_RULES: NuzlockeRules = {
egglocke: false,
wonderlocke: false,
randomizer: false,
// Type restriction - no restriction by default
allowedTypes: [],
}
export interface RuleDefinition {
key: keyof NuzlockeRules
key: BooleanRuleKeys
name: string
description: string
category: 'core' | 'playstyle' | 'variant'