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

@@ -1,11 +1,11 @@
---
# nuzlocke-tracker-bs0y
title: Add type restriction rules (monolocke)
status: todo
status: in-progress
type: feature
priority: normal
created_at: 2026-02-20T19:56:16Z
updated_at: 2026-02-20T20:01:40Z
updated_at: 2026-02-21T11:12:40Z
parent: nuzlocke-tracker-49xj
---
@@ -25,9 +25,9 @@ Restrict team composition to specific types (monolocke and similar variants).
## Checklist
- [ ] Add `allowedTypes: string[]` to `NuzlockeRules` interface (default: `[]`)
- [ ] Add a new `'variant'` category to `RuleDefinition` for variant rules
- [ ] Add type multi-select UI to `RulesConfiguration` (shown when allowedTypes toggle is on)
- [ ] Show warning indicator on `PokemonCard` and encounter list for Pokemon that don't match allowed types
- [ ] Add `RuleBadge` display for active type restriction (e.g., "Monolocke: Fire")
- [ ] Update `RuleBadges` color mapping for the new `'variant'` category
- [x] Add `allowedTypes: string[]` to `NuzlockeRules` interface (default: `[]`)
- [x] Add a new `BooleanRuleKeys` type to `RuleDefinition` to exclude non-boolean fields
- [x] Add type multi-select UI to `RulesConfiguration` (shown when allowedTypes toggle is on)
- [x] Show warning indicator on `PokemonCard` and encounter list for Pokemon that don't match allowed types
- [x] Add `RuleBadge` display for active type restriction (e.g., "Monolocke: Fire")
- [x] Update `RuleBadges` to handle `allowedTypes` separately from boolean rules

View File

@@ -1,11 +1,11 @@
---
# nuzlocke-tracker-knnc
title: Add static encounter filter rule
status: in-progress
status: completed
type: feature
priority: normal
created_at: 2026-02-20T19:56:27Z
updated_at: 2026-02-21T11:03:12Z
updated_at: 2026-02-21T11:04:45Z
parent: nuzlocke-tracker-49xj
---

View File

@@ -1,7 +1,7 @@
import json
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -131,6 +131,7 @@ async def get_game(game_id: int, session: AsyncSession = Depends(get_session)):
async def list_game_routes(
game_id: int,
flat: bool = False,
allowed_types: list[str] | None = Query(None),
session: AsyncSession = Depends(get_session),
):
"""
@@ -138,13 +139,18 @@ async def list_game_routes(
By default, returns a hierarchical structure with top-level routes containing
nested children. Use `flat=True` to get a flat list of all routes.
When `allowed_types` is provided, routes with no encounters matching any of
those Pokemon types are excluded.
"""
vg_id = await _get_version_group_id(session, game_id)
result = await session.execute(
select(Route)
.where(Route.version_group_id == vg_id)
.options(selectinload(Route.route_encounters))
.options(
selectinload(Route.route_encounters).selectinload(RouteEncounter.pokemon)
)
.order_by(Route.order)
)
all_routes = result.scalars().all()
@@ -170,7 +176,14 @@ async def list_game_routes(
# Determine which routes have encounters for this game
def has_encounters(route: Route) -> bool:
return any(re.game_id == game_id for re in route.route_encounters)
encounters = [re for re in route.route_encounters if re.game_id == game_id]
if not encounters:
return False
if allowed_types:
return any(
t in allowed_types for re in encounters for t in re.pokemon.types
)
return True
# Collect IDs of parent routes that have at least one child with encounters
parents_with_children = set()

View File

@@ -154,6 +154,7 @@ DEFAULT_RULES = {
"egglocke": False,
"wonderlocke": False,
"randomizer": False,
"allowedTypes": [],
}

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'