From d742412c2a306f5b3bde4788d65a54c17f637136 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 6 Oct 2026 23:20:27 -0500 Subject: [PATCH] feat(system-builder): STATS & RULES page, stats and tables (4b2d1) components/StatsRulesPage.tsx, from the approved mockup builder-stats-rules (2026-10-06), tabs STATS and TABLES (FORMULAS follows in 4b2d2): - STATS: a section per group (name, REMOVE GROUP), a row per stat with name, @id, lowest and highest (blank = none, whole numbers), TIED TO (any other stat), SAMPLE (blank clears it) and remove; + STAT, + GROUP. - TABLES: name and id(), "up to / gives" rows with the last catching everything higher, + ROW and remove (not the last), TRY IT with the table's answer, + TABLE, REMOVE TABLE. BuilderScreen shows it on STATS & RULES. --- CHANGELOG.md | 6 + frontend/src/components/BuilderScreen.tsx | 4 +- frontend/src/components/StatsRulesPage.tsx | 178 ++++++++++++++++++ .../__tests__/BuilderScreen.test.tsx | 10 +- .../__tests__/StatsRulesPage.test.tsx | 128 +++++++++++++ 5 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/StatsRulesPage.tsx create mode 100644 frontend/src/components/__tests__/StatsRulesPage.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 14c766ee..1a419d24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **The builder's STATS & RULES page: stats and tables.** STATS lists the numbers players fill in, + in groups you name, each with its lowest and highest value, the stat it's tied to (a skill to its + ability) and a SAMPLE value for a made-up character. Each group becomes a section of the starter + sheet. TABLES holds lookups such as an ability score's modifier, as "up to / gives" rows, with a + TRY IT box. Formulas, with their live values, come next. + - **What the builder's STATS & RULES page will change.** Adding, renaming and removing stat groups and stats (with their lowest, highest and ties), the sample character, formulas with their names, and lookup tables with their rows. A new entry's id comes from its first name and never changes, diff --git a/frontend/src/components/BuilderScreen.tsx b/frontend/src/components/BuilderScreen.tsx index 6cbab36a..33575dde 100644 --- a/frontend/src/components/BuilderScreen.tsx +++ b/frontend/src/components/BuilderScreen.tsx @@ -9,6 +9,7 @@ import { MySystemsPage } from './MySystemsPage'; import { SetupPage } from './SetupPage'; import { WordsPage } from './WordsPage'; import { FeaturesPage } from './FeaturesPage'; +import { StatsRulesPage } from './StatsRulesPage'; // The system builder (4a2b): it takes over the whole window, with no map. A sidebar down the left // holds the system's name, its pages, SAVE and PUBLISH, and EXIT TO MAP; the open page fills the @@ -320,7 +321,8 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o )} {system && definition && page === 'words' && } {system && definition && page === 'features' && } - {system && !['problems', 'systems', 'setup', 'words', 'features'].includes(page) && ( + {system && definition && page === 'rules' && } + {system && !['problems', 'systems', 'setup', 'words', 'features', 'rules'].includes(page) && (

{current.label}

{current.what} This page arrives in a coming update.

diff --git a/frontend/src/components/StatsRulesPage.tsx b/frontend/src/components/StatsRulesPage.tsx new file mode 100644 index 00000000..ca258a9d --- /dev/null +++ b/frontend/src/components/StatsRulesPage.tsx @@ -0,0 +1,178 @@ +import React, { useState } from 'react'; +import type { Definition } from '../sheets/systemsApi'; +import { + statGroups, withNewGroup, withGroupLabel, withoutGroup, withNewStat, withStat, withoutStat, sampleOf, withSample, + tableList, withNewTable, withTableLabel, withoutTable, withBands, lookupIn, +} from '../sheets/statsRules'; + +// The builder's STATS & RULES page (4b2d): the numbers players fill in (STATS) and the lookup +// tables formulas read (TABLES), with FORMULAS between them (approved mockup +// docs/mockups/builder-stats-rules.html, 2026-10-06). STATS are in groups, each shown as a section +// of the starter sheet; skills are a group whose stats may tie to an ability; the SAMPLE column is a +// made-up character for checking formulas, saved with the draft. What it reads and writes is +// sheets/statsRules.ts; every change goes through the builder's `edit`, which autosaves it. + +interface Props { + definition: Definition; + edit: (next: Definition) => void; +} + +type Tab = 'stats' | 'tables'; + +const small: React.CSSProperties = { fontSize: 10, letterSpacing: 2, opacity: 0.75 }; +const why: React.CSSProperties = { fontSize: 12, lineHeight: 1.45, opacity: 0.85 }; +const btn: React.CSSProperties = { fontFamily: 'monospace', fontSize: 11, letterSpacing: 1, padding: '4px 9px' }; +const field: React.CSSProperties = { + background: 'var(--black)', color: 'var(--green)', border: '1px solid var(--green)', fontFamily: 'monospace', fontSize: 12, padding: '5px 7px', minWidth: 0, boxSizing: 'border-box', +}; +const th: React.CSSProperties = { textAlign: 'left', ...small, fontWeight: 'normal', padding: '6px 8px 4px 10px' }; +const td: React.CSSProperties = { padding: '3px 8px 3px 10px', verticalAlign: 'middle', borderTop: '1px solid color-mix(in srgb, var(--dark-green) 60%, transparent)' }; +const box: React.CSSProperties = { border: '1px solid var(--dark-green)', marginBottom: 12 }; +const head: React.CSSProperties = { display: 'flex', gap: 8, alignItems: 'center', padding: '6px 10px', borderBottom: '1px solid var(--dark-green)', background: 'color-mix(in srgb, var(--green) 8%, transparent)', flexWrap: 'wrap' }; +const tabStyle = (on: boolean): React.CSSProperties => ({ + background: on ? 'color-mix(in srgb, var(--green) 10%, transparent)' : 'none', border: 0, borderBottom: `2px solid ${on ? 'var(--green)' : 'transparent'}`, + color: on ? 'var(--green)' : 'color-mix(in srgb, var(--green) 55%, transparent)', fontFamily: 'monospace', fontSize: 11, letterSpacing: 1, padding: '7px 14px', cursor: 'pointer', +}); + +/** A whole number box where empty means none (null). */ +const wholeOrNone = (v: string): number | null => (v.trim() === '' || !Number.isFinite(Number(v)) ? null : Math.round(Number(v))); + +function StatsTab({ definition, edit }: Props) { + const groups = statGroups(definition); + const abilities = groups[0]?.stats ?? []; + return ( +
+

+ The numbers players fill in on their sheet. Each group becomes a section of the starter sheet. The SAMPLE column is a made-up character to check formulas with. +

+ {groups.map((g) => ( +
+
+ edit(withGroupLabel(definition, g.id, e.target.value))} /> + {g.stats.length} stat{g.stats.length === 1 ? '' : 's'} + + +
+ {g.stats.length > 0 && ( + + + + + + {g.stats.map((s) => ( + + + + + + + + + + ))} + +
NAMEIDLOWESTHIGHESTTIED TOSAMPLE +
edit(withStat(definition, s.id, { label: e.target.value }))} />@{s.id} edit(withStat(definition, s.id, { min: wholeOrNone(e.target.value) }))} /> edit(withStat(definition, s.id, { max: wholeOrNone(e.target.value) }))} /> + + edit(withSample(definition, s.id, e.target.value.trim() === '' ? null : Number(e.target.value)))} />
+ )} +
+ + {g.stats.length === 0 && abilities.length === 0 && Abilities, skills, level: anything players write down as a number.} +
+
+ ))} +
+
+ ); +} + +function TablesTab({ definition, edit }: Props) { + const tables = tableList(definition); + const [tests, setTests] = useState>({}); + return ( +
+

+ Tables a formula reads by name, like an ability score to its modifier: mod(@str). Read top to bottom: the first row the number isn't above gives the answer. +

+ {tables.map((t) => { + const test = tests[t.id] ?? '10'; + return ( +
+
+ edit(withTableLabel(definition, t.id, e.target.value))} /> + {t.id}() + + +
+
+ + + + {t.bands.map((b, i) => { + const last = i === t.bands.length - 1; + return ( + + + + + + ); + })} + +
UP TOGIVES
{last + ? anything higher + : edit(withBands(definition, t.id, { set: i, upTo: Number(e.target.value) }))} />} edit(withBands(definition, t.id, { set: i, value: Number(e.target.value) }))} />{!last && }
+
+ TRY IT + +
+
+
+ +
+
+ ); + })} +
+
+ ); +} + +export function StatsRulesPage({ definition, edit }: Props) { + const [tab, setTab] = useState('stats'); + const tabs: [Tab, string][] = [['stats', 'STATS'], ['tables', 'TABLES']]; + return ( +
+
+ {tabs.map(([id, label]) => ( + + ))} +
+ {tab === 'stats' && } + {tab === 'tables' && } +
+ ); +} diff --git a/frontend/src/components/__tests__/BuilderScreen.test.tsx b/frontend/src/components/__tests__/BuilderScreen.test.tsx index 43746e2f..528f4780 100644 --- a/frontend/src/components/__tests__/BuilderScreen.test.tsx +++ b/frontend/src/components/__tests__/BuilderScreen.test.tsx @@ -93,9 +93,17 @@ describe('the builder', () => { expect(screen.getByTestId('save-status').textContent).toMatch(/^DRAFT · SAVED \d\d:\d\d$/); }); - it('shows what a page not built yet will hold', async () => { + it('STATS & RULES adds stats, saved like any other change', async () => { open({ startPage: 'rules' }); await ready(); + await userEvent.click(await screen.findByText('+ GROUP')); + await userEvent.click(within(sidebar()).getByLabelText('SAVE')); + await waitFor(() => expect(drafts.at(-1)).toEqual({ format: 1, name: 'Hearth', stats: [{ id: 'new_group', label: 'NEW GROUP', stats: [] }] })); + }); + + it('shows what a page not built yet will hold', async () => { + open({ startPage: 'sheet' }); + await ready(); expect(screen.getByText(/This page arrives in a coming update\./)).toBeTruthy(); }); diff --git a/frontend/src/components/__tests__/StatsRulesPage.test.tsx b/frontend/src/components/__tests__/StatsRulesPage.test.tsx new file mode 100644 index 00000000..741b5585 --- /dev/null +++ b/frontend/src/components/__tests__/StatsRulesPage.test.tsx @@ -0,0 +1,128 @@ +import React, { useState } from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup, within, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { StatsRulesPage } from '../StatsRulesPage'; +import type { Definition } from '../../sheets/systemsApi'; + +/** + * The builder's STATS & RULES page, STATS and TABLES (4b2d1). Approved mockup builder-stats-rules + * (2026-10-06): stats in groups with a SAMPLE column; tables as "up to / gives" rows with TRY IT. + */ + +afterEach(() => cleanup()); + +const open = (start: Definition = { format: 1, name: 'Hearth' }) => { + const edits: Definition[] = []; + const Harness = () => { + const [def, setDef] = useState(start); + return { edits.push(next); setDef(next); }} />; + }; + render(); + return { edits, last: () => edits[edits.length - 1] }; +}; +const HEARTH: Definition = { format: 1, name: 'Hearth', stats: [ + { id: 'abilities', label: 'ABILITIES', stats: [{ id: 'str', label: 'Strength', min: 3, max: 18 }, { id: 'dex', label: 'Dexterity' }] }, + { id: 'skills', label: 'SKILLS', stats: [{ id: 'shoot', label: 'Shoot', tie: 'dex' }] }, +], samples: { str: 16 } }; +const stat = (id: string) => screen.getByTestId(`stat-${id}`); + +describe('STATS', () => { + it('shows each group with its stats, ids, ranges, ties and samples', () => { + open(HEARTH); + expect(screen.getByRole('tab', { name: 'STATS' }).getAttribute('aria-selected')).toBe('true'); + expect(screen.getAllByLabelText('Group name').map((i) => (i as HTMLInputElement).value)).toEqual(['ABILITIES', 'SKILLS']); + expect(stat('str').textContent).toContain('@str'); + expect((within(stat('str')).getByLabelText('Strength lowest') as HTMLInputElement).value).toBe('3'); + expect((within(stat('dex')).getByLabelText('Dexterity lowest') as HTMLInputElement).value).toBe(''); + expect((within(stat('shoot')).getByLabelText('Shoot tied to') as HTMLSelectElement).value).toBe('dex'); + expect((within(stat('str')).getByLabelText('Strength sample') as HTMLInputElement).value).toBe('16'); + expect((within(stat('dex')).getByLabelText('Dexterity sample') as HTMLInputElement).value).toBe(''); + }); + + it('adds a group and a stat, and renames them', async () => { + const { last } = open(); + await userEvent.click(screen.getByText('+ GROUP')); + await userEvent.click(screen.getByText('+ STAT')); + expect(last().stats).toEqual([{ id: 'new_group', label: 'NEW GROUP', stats: [{ id: 'new_stat', label: 'New stat', min: 0, max: 10 }] }]); + const group = screen.getByLabelText('Group name'); + await userEvent.clear(group); + await userEvent.type(group, 'ABILITIES'); + const name = screen.getByLabelText('New stat name'); + await userEvent.clear(name); + await userEvent.type(name, 'Grit'); + expect(last().stats).toEqual([{ id: 'new_group', label: 'ABILITIES', stats: [{ id: 'new_stat', label: 'Grit', min: 0, max: 10 }] }]); + }); + + it('sets and clears a range, a tie and a sample', async () => { + const { last } = open(HEARTH); + fireEvent.change(within(stat('str')).getByLabelText('Strength highest'), { target: { value: '' } }); + fireEvent.change(within(stat('dex')).getByLabelText('Dexterity lowest'), { target: { value: '2.6' } }); + await userEvent.selectOptions(within(stat('shoot')).getByLabelText('Shoot tied to'), ''); + fireEvent.change(within(stat('dex')).getByLabelText('Dexterity sample'), { target: { value: '14' } }); + fireEvent.change(within(stat('str')).getByLabelText('Strength sample'), { target: { value: '' } }); + const stats = (last().stats as { stats: object[] }[]).flatMap((g) => g.stats); + expect(stats).toEqual([{ id: 'str', label: 'Strength', min: 3 }, { id: 'dex', label: 'Dexterity', min: 3 }, { id: 'shoot', label: 'Shoot' }]); + expect(last().samples).toEqual({ dex: 14 }); + }); + + it('offers any other stat to tie to, never itself', () => { + open(HEARTH); + const options = [...(within(stat('shoot')).getByLabelText('Shoot tied to') as HTMLSelectElement).options].map((o) => o.textContent); + expect(options).toEqual(['none', 'Strength', 'Dexterity']); + }); + + it('removes a stat, and a whole group', async () => { + const { last } = open(HEARTH); + await userEvent.click(screen.getByLabelText('Remove Strength')); + expect('samples' in last()).toBe(false); + await userEvent.click(screen.getByLabelText('Remove the SKILLS group')); + expect(last().stats).toEqual([{ id: 'abilities', label: 'ABILITIES', stats: [{ id: 'dex', label: 'Dexterity' }] }]); + }); +}); + +describe('TABLES', () => { + const MOD: Definition = { format: 1, name: 'Hearth', lookups: { mod: { label: 'Attribute modifier', bands: [{ upTo: 7, value: -1 }, { upTo: 13, value: 0 }, { value: 1 }] } } }; + const tables = () => userEvent.click(screen.getByRole('tab', { name: 'TABLES' })); + + it('shows each table\'s rows, the last catching everything higher, and tries a number', async () => { + open(MOD); + await tables(); + expect((screen.getByLabelText('Table name') as HTMLInputElement).value).toBe('Attribute modifier'); + expect(screen.getByText('mod()')).toBeTruthy(); + expect(screen.getByTestId('band-mod-2').textContent).toContain('anything higher'); + expect(screen.getByTestId('try-mod').textContent).toBe('0'); + fireEvent.change(screen.getByLabelText('Try Attribute modifier'), { target: { value: '15' } }); + expect(screen.getByTestId('try-mod').textContent).toBe('1'); + fireEvent.change(screen.getByLabelText('Try Attribute modifier'), { target: { value: '3' } }); + expect(screen.getByTestId('try-mod').textContent).toBe('-1'); + }); + + it('adds, sets and removes rows', async () => { + const { last } = open(MOD); + await tables(); + await userEvent.click(screen.getByText('+ ROW')); + const bands = () => (last().lookups as { mod: { bands: object[] } }).mod.bands; + expect(bands()).toEqual([{ upTo: 7, value: -1 }, { upTo: 13, value: 0 }, { upTo: 14, value: 0 }, { value: 1 }]); + fireEvent.change(screen.getByLabelText('Row 3 gives'), { target: { value: '5' } }); + fireEvent.change(screen.getByLabelText('Row 1 up to'), { target: { value: '6' } }); + expect(bands()).toEqual([{ upTo: 6, value: -1 }, { upTo: 13, value: 0 }, { upTo: 14, value: 5 }, { value: 1 }]); + await userEvent.click(screen.getByLabelText('Remove row 2')); + expect(bands()).toEqual([{ upTo: 6, value: -1 }, { upTo: 14, value: 5 }, { value: 1 }]); + expect(screen.queryByLabelText('Remove row 3')).toBeNull(); + }); + + it('adds, renames and removes a table', async () => { + const { last } = open(); + await tables(); + await userEvent.click(screen.getByText('+ TABLE')); + expect(last().lookups).toEqual({ new_table: { label: 'New table', bands: [{ value: 0 }] } }); + const name = screen.getByLabelText('Table name'); + await userEvent.clear(name); + await userEvent.type(name, 'Armor'); + expect((last().lookups as { new_table: { label: string } }).new_table.label).toBe('Armor'); + await userEvent.click(screen.getByLabelText('Remove the Armor table')); + expect('lookups' in last()).toBe(false); + }); +});