Merge pull request 'develop' (#45) from develop into main

Reviewed-on: #45
This commit was merged in pull request #45.
This commit is contained in:
2026-03-20 15:16:00 +01:00
15 changed files with 1614 additions and 129 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

@@ -18,7 +18,6 @@ describe('RulesConfiguration', () => {
it('renders all rule section headings', () => {
setup()
expect(screen.getByText('Core Rules')).toBeInTheDocument()
expect(screen.getByText('Playstyle')).toBeInTheDocument()
expect(screen.getByText('Run Variant')).toBeInTheDocument()
expect(screen.getByText('Type Restriction')).toBeInTheDocument()
})

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',