Replace playstyle rules with free-text custom rules markdown field
Some checks failed
CI / backend-tests (push) Successful in 28s
CI / frontend-tests (push) Failing after 28s

Remove hardcoreMode, setModeOnly, and bossTeamMatch toggles which had
no mechanical impact on the tracker. Replace them with a customRules
markdown field so users can document their own rules (especially useful
for genlockes). Add react-markdown + remark-gfm for rendering and
@tailwindcss/typography for prose styling. The custom rules display is
collapsible and hidden by default.

Also simplifies the BossDefeatModal by removing the Lost result and
attempts counter, and always shows boss team size in the level cap bar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Julian Tabel
2026-03-20 15:09:02 +01:00
parent 535154a056
commit 1cd1389408
14 changed files with 1614 additions and 128 deletions

View File

@@ -0,0 +1,11 @@
---
# nuzlocke-tracker-wb85
title: Replace playstyle rules with custom rules markdown field
status: completed
type: feature
priority: normal
created_at: 2026-03-20T13:48:50Z
updated_at: 2026-03-20T13:53:08Z
---
Remove hardcoreMode, setModeOnly, bossTeamMatch playstyle rules. Add a free-text markdown customRules field so users can track their own rules (especially useful for genlockes). Also: remove 'Lost' result and attempts from BossDefeatModal, always show boss team size.

View File

@@ -87,7 +87,7 @@ RUN_DEFS = [
"name": "Kanto Heartbreak",
"status": "failed",
"progress": 0.45,
"rules": {"hardcoreMode": True, "setModeOnly": True},
"rules": {"customRules": "- Hardcore mode: no items in battle\n- Set mode only"},
"started_days_ago": 30,
"ended_days_ago": 20,
},
@@ -134,7 +134,7 @@ RUN_DEFS = [
"name": "Crystal Nuzlocke",
"status": "active",
"progress": 0.20,
"rules": {"hardcoreMode": True, "levelCaps": True, "setModeOnly": True},
"rules": {"levelCaps": True, "customRules": "- Hardcore mode\n- Set mode only"},
"started_days_ago": 2,
"ended_days_ago": None,
},
@@ -148,13 +148,11 @@ DEFAULT_RULES = {
"staticClause": True,
"pinwheelClause": True,
"levelCaps": False,
"hardcoreMode": False,
"setModeOnly": False,
"bossTeamMatch": False,
"egglocke": False,
"wonderlocke": False,
"randomizer": False,
"allowedTypes": [],
"customRules": "",
}

File diff suppressed because it is too large Load Diff

View File

@@ -19,10 +19,13 @@
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "5.90.20",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-markdown": "^10.1.0",
"react-router-dom": "7.13.0",
"remark-gfm": "^4.0.1",
"sonner": "2.0.7"
},
"devDependencies": {

View File

@@ -1,4 +1,4 @@
import { type FormEvent, useState, useMemo } from 'react'
import { type FormEvent, useMemo, useState } from 'react'
import type { BossBattle, CreateBossResultInput } from '../types/game'
import { ConditionBadge } from './ConditionBadge'
@@ -7,7 +7,6 @@ interface BossDefeatModalProps {
onSubmit: (data: CreateBossResultInput) => void
onClose: () => void
isPending?: boolean
hardcoreMode?: boolean
starterName?: string | null
}
@@ -23,11 +22,8 @@ export function BossDefeatModal({
onSubmit,
onClose,
isPending,
hardcoreMode,
starterName,
}: BossDefeatModalProps) {
const [result, setResult] = useState<'won' | 'lost'>('won')
const [attempts, setAttempts] = useState('1')
const variantLabels = useMemo(() => {
const labels = new Set<string>()
@@ -58,8 +54,8 @@ export function BossDefeatModal({
e.preventDefault()
onSubmit({
bossBattleId: boss.id,
result: hardcoreMode ? 'won' : result,
attempts: hardcoreMode ? 1 : Number(attempts) || 1,
result: 'won',
attempts: 1,
})
}
@@ -113,51 +109,6 @@ export function BossDefeatModal({
)}
<form onSubmit={handleSubmit}>
<div className="px-6 py-4 space-y-4">
<div>
<label className="block text-sm font-medium mb-2">Result</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setResult('won')}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md border transition-colors ${
result === 'won'
? 'bg-green-600 text-white border-green-600'
: 'border-border-default hover:bg-surface-2'
}`}
>
Won
</button>
{!hardcoreMode && (
<button
type="button"
onClick={() => setResult('lost')}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md border transition-colors ${
result === 'lost'
? 'bg-red-600 text-white border-red-600'
: 'border-border-default hover:bg-surface-2'
}`}
>
Lost
</button>
)}
</div>
</div>
{!hardcoreMode && (
<div>
<label className="block text-sm font-medium mb-1">Attempts</label>
<input
type="number"
min={1}
value={attempts}
onChange={(e) => setAttempts(e.target.value)}
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
/>
</div>
)}
</div>
<div className="px-6 py-4 border-t border-border-default flex justify-end gap-3">
<button
type="button"

View File

@@ -0,0 +1,40 @@
import { useState } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
interface CustomRulesDisplayProps {
customRules: string
}
export function CustomRulesDisplay({ customRules }: CustomRulesDisplayProps) {
const trimmed = customRules.trim()
const [open, setOpen] = useState(false)
if (!trimmed) return null
return (
<div className="mt-3 rounded-lg border border-border-default bg-surface-1">
<button
type="button"
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between px-4 py-2.5 text-xs font-medium text-text-tertiary hover:text-text-secondary transition-colors"
>
<span>Custom Rules</span>
<svg
className={`w-4 h-4 transition-transform ${open ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="px-4 pb-3 prose prose-sm prose-invert light:prose text-text-secondary max-w-none">
<Markdown remarkPlugins={[remarkGfm]}>{trimmed}</Markdown>
</div>
)}
</div>
)
}

View File

@@ -9,8 +9,9 @@ interface RuleBadgesProps {
export function RuleBadges({ rules }: RuleBadgesProps) {
const enabledRules = RULE_DEFINITIONS.filter((def) => rules[def.key])
const allowedTypes = rules.allowedTypes ?? []
const hasCustomRules = (rules.customRules ?? '').trim().length > 0
if (enabledRules.length === 0 && allowedTypes.length === 0) {
if (enabledRules.length === 0 && allowedTypes.length === 0 && !hasCustomRules) {
return <span className="text-sm text-text-tertiary">No rules enabled</span>
}
@@ -23,9 +24,7 @@ export function RuleBadges({ rules }: RuleBadgesProps) {
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
def.category === 'core'
? 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-700'
: def.category === 'variant'
? 'bg-amber-900/40 text-amber-300 light:bg-amber-100 light:text-amber-700'
: 'bg-purple-900/40 text-purple-300 light:bg-purple-100 light:text-purple-700'
: 'bg-amber-900/40 text-amber-300 light:bg-amber-100 light:text-amber-700'
}`}
>
{def.name}
@@ -42,6 +41,14 @@ export function RuleBadges({ rules }: RuleBadgesProps) {
))}
</span>
)}
{hasCustomRules && (
<span
title={rules.customRules}
className="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-900/40 text-purple-300 light:bg-purple-100 light:text-purple-700"
>
Custom Rules
</span>
)}
</div>
)
}

View File

@@ -41,7 +41,6 @@ export function RulesConfiguration({
? RULE_DEFINITIONS.filter((r) => !hiddenRules.has(r.key))
: RULE_DEFINITIONS
const coreRules = visibleRules.filter((r) => r.category === 'core')
const playstyleRules = visibleRules.filter((r) => r.category === 'playstyle')
const variantRules = visibleRules.filter((r) => r.category === 'variant')
const handleRuleChange = (key: keyof NuzlockeRules, value: boolean) => {
@@ -62,9 +61,13 @@ export function RulesConfiguration({
onChange({ ...rules, allowedTypes: next })
}
const customRules = rules.customRules ?? ''
const enabledCount =
visibleRules.filter((r) => rules[r.key]).length + (allowedTypes.length > 0 ? 1 : 0)
const totalCount = visibleRules.length + 1 // +1 for type restriction
visibleRules.filter((r) => rules[r.key]).length +
(allowedTypes.length > 0 ? 1 : 0) +
(customRules.trim().length > 0 ? 1 : 0)
const totalCount = visibleRules.length + 2 // +1 for type restriction, +1 for custom rules
return (
<div className="space-y-6">
@@ -106,21 +109,19 @@ export function RulesConfiguration({
<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">Playstyle</h3>
<h3 className="text-lg font-medium text-text-primary">Custom Rules</h3>
<p className="text-sm text-text-tertiary">
Describe how you're playing — doesn't affect tracker behavior
Track your own rules supports markdown. Doesn't affect tracker behavior.
</p>
</div>
<div className="px-4">
{playstyleRules.map((rule) => (
<RuleToggle
key={rule.key}
name={rule.name}
description={rule.description}
enabled={rules[rule.key]}
onChange={(value) => handleRuleChange(rule.key, value)}
<div className="px-4 py-4">
<textarea
value={customRules}
onChange={(e) => onChange({ ...rules, customRules: e.target.value })}
placeholder="e.g. No items in battle, Set mode only, must match boss team size..."
rows={4}
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default text-text-primary placeholder:text-text-muted text-sm resize-y"
/>
))}
</div>
</div>

View File

@@ -1,3 +1,4 @@
export { CustomRulesDisplay } from './CustomRulesDisplay'
export { EggEncounterModal } from './EggEncounterModal'
export { EncounterMethodBadge } from './EncounterMethodBadge'
export { EncounterModal } from './EncounterModal'

View File

@@ -1,4 +1,5 @@
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
/* ── Geist font family (variable, self-hosted) ─────────────────── */

View File

@@ -1,7 +1,7 @@
import { Link, useParams } from 'react-router-dom'
import { useGenlocke } from '../hooks/useGenlockes'
import { usePokemonFamilies } from '../hooks/usePokemon'
import { GenlockeGraveyard, GenlockeLineage, StatCard, RuleBadges } from '../components'
import { CustomRulesDisplay, GenlockeGraveyard, GenlockeLineage, StatCard, RuleBadges } from '../components'
import type { GenlockeLegDetail, RetiredPokemon, RunStatus } from '../types'
import { useMemo, useState } from 'react'
@@ -226,6 +226,7 @@ export function GenlockeDetail() {
<div>
<h3 className="text-sm font-medium text-text-tertiary mb-2">Nuzlocke Rules</h3>
<RuleBadges rules={genlocke.nuzlockeRules} />
<CustomRulesDisplay customRules={genlocke.nuzlockeRules?.customRules ?? ''} />
</div>
</div>
</section>

View File

@@ -3,7 +3,7 @@ import { useParams, Link } from 'react-router-dom'
import { useRun, useUpdateRun, useNamingCategories } from '../hooks/useRuns'
import { useGameRoutes } from '../hooks/useGames'
import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters'
import { StatCard, PokemonCard, RuleBadges, StatusChangeModal, EndRunModal } from '../components'
import { CustomRulesDisplay, StatCard, PokemonCard, RuleBadges, StatusChangeModal, EndRunModal } from '../components'
import type { RunStatus, EncounterDetail } from '../types'
type TeamSortKey = 'route' | 'level' | 'species' | 'dex'
@@ -187,6 +187,7 @@ export function RunDashboard() {
<div className="mb-6">
<h2 className="text-sm font-medium text-text-tertiary mb-2">Active Rules</h2>
<RuleBadges rules={run.rules} />
<CustomRulesDisplay customRules={run.rules?.customRules ?? ''} />
</div>
{/* Naming Scheme */}

View File

@@ -7,6 +7,7 @@ import { useCreateEncounter, useUpdateEncounter, useBulkRandomize } from '../hoo
import { usePokemonFamilies } from '../hooks/usePokemon'
import { useGameBosses, useBossResults, useCreateBossResult } from '../hooks/useBosses'
import {
CustomRulesDisplay,
EggEncounterModal,
EncounterModal,
EncounterMethodBadge,
@@ -1077,12 +1078,10 @@ export function RunEncounters() {
{nextBoss && (
<span className="text-sm text-text-tertiary">
Next: {nextBoss.name}
{run.rules?.bossTeamMatch && (
<span className="text-text-muted">
{' '}
({getBossTeamSize(nextBoss.pokemon, starterName)} Pokémon match their team)
({getBossTeamSize(nextBoss.pokemon, starterName)} Pokémon)
</span>
)}
</span>
)}
{!nextBoss && (
@@ -1135,6 +1134,7 @@ export function RunEncounters() {
<div className="mb-6">
<h2 className="text-sm font-medium text-text-tertiary mb-2">Active Rules</h2>
<RuleBadges rules={run.rules} />
<CustomRulesDisplay customRules={run.rules?.customRules ?? ''} />
</div>
{/* Team Section */}
@@ -1602,7 +1602,6 @@ export function RunEncounters() {
}}
onClose={() => setSelectedBoss(null)}
isPending={createBossResult.isPending}
hardcoreMode={run?.rules?.hardcoreMode}
starterName={starterName}
/>
)}

View File

@@ -7,11 +7,6 @@ export interface NuzlockeRules {
pinwheelClause: boolean
levelCaps: boolean
// Playstyle (informational, for stats/categorization)
hardcoreMode: boolean
setModeOnly: boolean
bossTeamMatch: boolean
// Variant (changes which Pokemon can appear)
egglocke: boolean
wonderlocke: boolean
@@ -19,10 +14,13 @@ export interface NuzlockeRules {
// Type restriction (monolocke and variants)
allowedTypes: string[]
// Free-text rules (markdown, for tracking custom/personal rules)
customRules: string
}
/** Keys of NuzlockeRules that are boolean toggles (excludes array fields) */
type BooleanRuleKeys = Exclude<keyof NuzlockeRules, 'allowedTypes'>
/** Keys of NuzlockeRules that are boolean toggles (excludes array and string fields) */
type BooleanRuleKeys = Exclude<keyof NuzlockeRules, 'allowedTypes' | 'customRules'>
export const DEFAULT_RULES: NuzlockeRules = {
// Core rules
@@ -33,11 +31,6 @@ export const DEFAULT_RULES: NuzlockeRules = {
pinwheelClause: true,
levelCaps: false,
// Playstyle - off by default
hardcoreMode: false,
setModeOnly: false,
bossTeamMatch: false,
// Variant - off by default
egglocke: false,
wonderlocke: false,
@@ -45,13 +38,16 @@ export const DEFAULT_RULES: NuzlockeRules = {
// Type restriction - no restriction by default
allowedTypes: [],
// Custom rules - empty by default
customRules: '',
}
export interface RuleDefinition {
key: BooleanRuleKeys
name: string
description: string
category: 'core' | 'playstyle' | 'variant'
category: 'core' | 'variant'
}
export const RULE_DEFINITIONS: RuleDefinition[] = [
@@ -99,28 +95,6 @@ export const RULE_DEFINITIONS: RuleDefinition[] = [
category: 'core',
},
// Playstyle
{
key: 'hardcoreMode',
name: 'Hardcore Mode',
description: 'No items may be used during battle. Held items are still allowed.',
category: 'playstyle',
},
{
key: 'setModeOnly',
name: 'Set Mode Only',
description:
'The game must be played in "Set" battle style, meaning you cannot switch Pokémon after knocking out an opponent.',
category: 'playstyle',
},
{
key: 'bossTeamMatch',
name: 'Boss Team Match',
description:
'Limit your active party to the same number of Pokémon as the boss you are challenging.',
category: 'playstyle',
},
// Variant
{
key: 'egglocke',