From 993ad09d9c5f7916f4f5e4b4510b0aa4be70f4cb Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Sat, 21 Feb 2026 12:22:05 +0100 Subject: [PATCH] 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 --- ...y--add-type-restriction-rules-monolocke.md | 16 ++-- ...r-knnc--add-staticlegendary-clause-rule.md | 4 +- backend/src/app/api/games.py | 19 ++++- backend/src/app/seeds/inject_test_data.py | 1 + frontend/src/api/games.ts | 6 +- frontend/src/components/EncounterModal.tsx | 18 +++-- frontend/src/components/RuleBadges.tsx | 15 +++- .../src/components/RulesConfiguration.tsx | 74 ++++++++++++++++++- frontend/src/hooks/useGames.ts | 6 +- frontend/src/pages/RunEncounters.tsx | 7 +- frontend/src/types/rules.ts | 11 ++- 11 files changed, 149 insertions(+), 28 deletions(-) diff --git a/.beans/nuzlocke-tracker-bs0y--add-type-restriction-rules-monolocke.md b/.beans/nuzlocke-tracker-bs0y--add-type-restriction-rules-monolocke.md index 1377036..74cfdcb 100644 --- a/.beans/nuzlocke-tracker-bs0y--add-type-restriction-rules-monolocke.md +++ b/.beans/nuzlocke-tracker-bs0y--add-type-restriction-rules-monolocke.md @@ -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 \ No newline at end of file +- [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 diff --git a/.beans/nuzlocke-tracker-knnc--add-staticlegendary-clause-rule.md b/.beans/nuzlocke-tracker-knnc--add-staticlegendary-clause-rule.md index d80c090..7690365 100644 --- a/.beans/nuzlocke-tracker-knnc--add-staticlegendary-clause-rule.md +++ b/.beans/nuzlocke-tracker-knnc--add-staticlegendary-clause-rule.md @@ -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 --- diff --git a/backend/src/app/api/games.py b/backend/src/app/api/games.py index 573f99e..dfebb7a 100644 --- a/backend/src/app/api/games.py +++ b/backend/src/app/api/games.py @@ -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() diff --git a/backend/src/app/seeds/inject_test_data.py b/backend/src/app/seeds/inject_test_data.py index 833cc06..d168033 100644 --- a/backend/src/app/seeds/inject_test_data.py +++ b/backend/src/app/seeds/inject_test_data.py @@ -154,6 +154,7 @@ DEFAULT_RULES = { "egglocke": False, "wonderlocke": False, "randomizer": False, + "allowedTypes": [], } diff --git a/frontend/src/api/games.ts b/frontend/src/api/games.ts index a092816..5e998ea 100644 --- a/frontend/src/api/games.ts +++ b/frontend/src/api/games.ts @@ -13,10 +13,12 @@ export function getGame(id: number): Promise { return api.get(`/games/${id}`) } -export function getGameRoutes(gameId: number): Promise { +export function getGameRoutes(gameId: number, allowedTypes?: string[]): Promise { // 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 { diff --git a/frontend/src/components/EncounterModal.tsx b/frontend/src/components/EncounterModal.tsx index 2c64f75..d1b0275 100644 --- a/frontend/src/components/EncounterModal.tsx +++ b/frontend/src/components/EncounterModal.tsx @@ -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)) } }} diff --git a/frontend/src/components/RuleBadges.tsx b/frontend/src/components/RuleBadges.tsx index 2fc97cc..5811a2b 100644 --- a/frontend/src/components/RuleBadges.tsx +++ b/frontend/src/components/RuleBadges.tsx @@ -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 No rules enabled } @@ -29,6 +31,17 @@ export function RuleBadges({ rules }: RuleBadgesProps) { {def.name} ))} + {allowedTypes.length > 0 && ( + 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" + > + Type Restriction + {allowedTypes.map((t) => ( + + ))} + + )} ) } diff --git a/frontend/src/components/RulesConfiguration.tsx b/frontend/src/components/RulesConfiguration.tsx index 6a66da8..8724b24 100644 --- a/frontend/src/components/RulesConfiguration.tsx +++ b/frontend/src/components/RulesConfiguration.tsx @@ -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 (
@@ -111,6 +143,44 @@ export function RulesConfiguration({ ))}
+ +
+
+

Type Restriction

+

+ Monolocke and variants. Select allowed types — a Pokémon qualifies if it shares at least + one type. Leave all deselected to disable. +

+
+
+
+ {POKEMON_TYPES.map((type) => ( + + ))} +
+ {allowedTypes.length > 0 && ( + + )} +
+
) } diff --git a/frontend/src/hooks/useGames.ts b/frontend/src/hooks/useGames.ts index 02187b5..9579712 100644 --- a/frontend/src/hooks/useGames.ts +++ b/frontend/src/hooks/useGames.ts @@ -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, }) } diff --git a/frontend/src/pages/RunEncounters.tsx b/frontend/src/pages/RunEncounters.tsx index 5f4721f..aab626f 100644 --- a/frontend/src/pages/RunEncounters.tsx +++ b/frontend/src/pages/RunEncounters.tsx @@ -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} /> )} diff --git a/frontend/src/types/rules.ts b/frontend/src/types/rules.ts index a74bdde..f8b8cbf 100644 --- a/frontend/src/types/rules.ts +++ b/frontend/src/types/rules.ts @@ -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 + 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'