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:
@@ -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.
|
||||
@@ -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": "",
|
||||
}
|
||||
|
||||
|
||||
|
||||
1514
frontend/package-lock.json
generated
1514
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
40
frontend/src/components/CustomRulesDisplay.tsx
Normal file
40
frontend/src/components/CustomRulesDisplay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { CustomRulesDisplay } from './CustomRulesDisplay'
|
||||
export { EggEncounterModal } from './EggEncounterModal'
|
||||
export { EncounterMethodBadge } from './EncounterMethodBadge'
|
||||
export { EncounterModal } from './EncounterModal'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
/* ── Geist font family (variable, self-hosted) ─────────────────── */
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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)
|
||||
</span>
|
||||
)}
|
||||
<span className="text-text-muted">
|
||||
{' '}
|
||||
({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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user