Add type restriction rule (monolocke)
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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ DEFAULT_RULES = {
|
|||||||
"egglocke": False,
|
"egglocke": False,
|
||||||
"wonderlocke": False,
|
"wonderlocke": False,
|
||||||
"randomizer": False,
|
"randomizer": False,
|
||||||
|
"allowedTypes": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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[]> {
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user