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 # nuzlocke-tracker-bs0y
title: Add type restriction rules (monolocke) title: Add type restriction rules (monolocke)
status: todo status: in-progress
type: feature type: feature
priority: normal priority: normal
created_at: 2026-02-20T19:56:16Z 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 parent: nuzlocke-tracker-49xj
--- ---
@@ -25,9 +25,9 @@ Restrict team composition to specific types (monolocke and similar variants).
## Checklist ## Checklist
- [ ] Add `allowedTypes: string[]` to `NuzlockeRules` interface (default: `[]`) - [x] Add `allowedTypes: string[]` to `NuzlockeRules` interface (default: `[]`)
- [ ] Add a new `'variant'` category to `RuleDefinition` for variant rules - [x] Add a new `BooleanRuleKeys` type to `RuleDefinition` to exclude non-boolean fields
- [ ] Add type multi-select UI to `RulesConfiguration` (shown when allowedTypes toggle is on) - [x] 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 - [x] 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") - [x] Add `RuleBadge` display for active type restriction (e.g., "Monolocke: Fire")
- [ ] Update `RuleBadges` color mapping for the new `'variant'` category - [x] Update `RuleBadges` to handle `allowedTypes` separately from boolean rules

View File

@@ -1,11 +1,11 @@
--- ---
# nuzlocke-tracker-knnc # nuzlocke-tracker-knnc
title: Add static encounter filter rule title: Add static encounter filter rule
status: in-progress status: completed
type: feature type: feature
priority: normal priority: normal
created_at: 2026-02-20T19:56:27Z 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 parent: nuzlocke-tracker-49xj
--- ---

View File

@@ -1,7 +1,7 @@
import json import json
from pathlib import Path 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 import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload 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( async def list_game_routes(
game_id: int, game_id: int,
flat: bool = False, flat: bool = False,
allowed_types: list[str] | None = Query(None),
session: AsyncSession = Depends(get_session), 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 By default, returns a hierarchical structure with top-level routes containing
nested children. Use `flat=True` to get a flat list of all routes. 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) vg_id = await _get_version_group_id(session, game_id)
result = await session.execute( result = await session.execute(
select(Route) select(Route)
.where(Route.version_group_id == vg_id) .where(Route.version_group_id == vg_id)
.options(selectinload(Route.route_encounters)) .options(
selectinload(Route.route_encounters).selectinload(RouteEncounter.pokemon)
)
.order_by(Route.order) .order_by(Route.order)
) )
all_routes = result.scalars().all() all_routes = result.scalars().all()
@@ -170,7 +176,14 @@ async def list_game_routes(
# Determine which routes have encounters for this game # Determine which routes have encounters for this game
def has_encounters(route: Route) -> bool: 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 # Collect IDs of parent routes that have at least one child with encounters
parents_with_children = set() parents_with_children = set()

View File

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

View File

@@ -13,10 +13,12 @@ export function getGame(id: number): Promise<GameDetail> {
return api.get(`/games/${id}`) 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 // Use flat=true to get all routes in a flat list
// The frontend organizes them into hierarchy based on parentRouteId // 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[]> { export function getRoutePokemon(routeId: number, gameId?: number): Promise<RouteEncounterDetail[]> {

View File

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

View File

@@ -1,5 +1,6 @@
import type { NuzlockeRules } from '../types' import type { NuzlockeRules } from '../types'
import { RULE_DEFINITIONS } from '../types/rules' import { RULE_DEFINITIONS } from '../types/rules'
import { TypeBadge } from './TypeBadge'
interface RuleBadgesProps { interface RuleBadgesProps {
rules: NuzlockeRules rules: NuzlockeRules
@@ -7,8 +8,9 @@ interface RuleBadgesProps {
export function RuleBadges({ rules }: RuleBadgesProps) { export function RuleBadges({ rules }: RuleBadgesProps) {
const enabledRules = RULE_DEFINITIONS.filter((def) => rules[def.key]) 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> return <span className="text-sm text-text-tertiary">No rules enabled</span>
} }
@@ -29,6 +31,17 @@ export function RuleBadges({ rules }: RuleBadgesProps) {
{def.name} {def.name}
</span> </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> </div>
) )
} }

View File

@@ -1,6 +1,28 @@
import type { NuzlockeRules } from '../types/rules' import type { NuzlockeRules } from '../types/rules'
import { RULE_DEFINITIONS, DEFAULT_RULES } from '../types/rules' import { RULE_DEFINITIONS, DEFAULT_RULES } from '../types/rules'
import { RuleToggle } from './RuleToggle' 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 { interface RulesConfigurationProps {
rules: NuzlockeRules rules: NuzlockeRules
@@ -31,8 +53,18 @@ export function RulesConfiguration({
onReset?.() onReset?.()
} }
const enabledCount = visibleRules.filter((r) => rules[r.key]).length const allowedTypes = rules.allowedTypes ?? []
const totalCount = visibleRules.length
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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -111,6 +143,44 @@ 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">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> </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({ return useQuery({
queryKey: ['games', gameId, 'routes'], queryKey: ['games', gameId, 'routes', allowedTypes],
queryFn: () => getGameRoutes(gameId!), queryFn: () => getGameRoutes(gameId!, allowedTypes),
enabled: gameId !== null, enabled: gameId !== null,
}) })
} }

View File

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

View File

@@ -16,8 +16,14 @@ export interface NuzlockeRules {
egglocke: boolean egglocke: boolean
wonderlocke: boolean wonderlocke: boolean
randomizer: 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 = { export const DEFAULT_RULES: NuzlockeRules = {
// Core rules // Core rules
duplicatesClause: true, duplicatesClause: true,
@@ -36,10 +42,13 @@ export const DEFAULT_RULES: NuzlockeRules = {
egglocke: false, egglocke: false,
wonderlocke: false, wonderlocke: false,
randomizer: false, randomizer: false,
// Type restriction - no restriction by default
allowedTypes: [],
} }
export interface RuleDefinition { export interface RuleDefinition {
key: keyof NuzlockeRules key: BooleanRuleKeys
name: string name: string
description: string description: string
category: 'core' | 'playstyle' | 'variant' category: 'core' | 'playstyle' | 'variant'