diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a419d24..37d6836b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **The builder's STATS & RULES page: formulas.** FORMULAS lists each formula with the name + players see, its formula written the rulebook way, and its value for the sample character, + worked out by the game's own engine a moment after you stop typing. A mistake shows in red under + its formula while the others keep their values, and each formula says which others read it. An + INSERT panel puts a stat, formula, table or function at the cursor. STATS & RULES is now + complete. + - **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 diff --git a/frontend/src/components/BuilderScreen.tsx b/frontend/src/components/BuilderScreen.tsx index 33575dde..ebe5af73 100644 --- a/frontend/src/components/BuilderScreen.tsx +++ b/frontend/src/components/BuilderScreen.tsx @@ -321,7 +321,7 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o )} {system && definition && page === 'words' && } {system && definition && page === 'features' && } - {system && definition && page === 'rules' && } + {system && definition && page === 'rules' && } {system && !['problems', 'systems', 'setup', 'words', 'features', 'rules'].includes(page) && (

{current.label}

diff --git a/frontend/src/components/StatsRulesPage.tsx b/frontend/src/components/StatsRulesPage.tsx index ca258a9d..738b52fb 100644 --- a/frontend/src/components/StatsRulesPage.tsx +++ b/frontend/src/components/StatsRulesPage.tsx @@ -1,8 +1,9 @@ -import React, { useState } from 'react'; -import type { Definition } from '../sheets/systemsApi'; +import React, { useEffect, useRef, useState } from 'react'; +import type { Definition, systemsApi } from '../sheets/systemsApi'; import { - statGroups, withNewGroup, withGroupLabel, withoutGroup, withNewStat, withStat, withoutStat, sampleOf, withSample, + statGroups, allStats, withNewGroup, withGroupLabel, withoutGroup, withNewStat, withStat, withoutStat, sampleOf, withSample, tableList, withNewTable, withTableLabel, withoutTable, withBands, lookupIn, + formulaList, withNewFormula, withFormula, withoutFormula, usedBy, problemsByFormula, insertToken, FUNCTIONS, } from '../sheets/statsRules'; // The builder's STATS & RULES page (4b2d): the numbers players fill in (STATS) and the lookup @@ -15,9 +16,14 @@ import { interface Props { definition: Definition; edit: (next: Definition) => void; + /** For the live values (FORMULAS); without it the tab shows no values. */ + api?: ReturnType; } -type Tab = 'stats' | 'tables'; +type Tab = 'stats' | 'formulas' | 'tables'; + +/** How long after the last keystroke the live values are asked for. */ +export const PREVIEW_DELAY_MS = 400; const small: React.CSSProperties = { fontSize: 10, letterSpacing: 2, opacity: 0.75 }; const why: React.CSSProperties = { fontSize: 12, lineHeight: 1.45, opacity: 0.85 }; @@ -161,9 +167,115 @@ function TablesTab({ definition, edit }: Props) { ); } -export function StatsRulesPage({ definition, edit }: Props) { +/** + * FORMULAS: each formula's name and text, its value for the sample character a moment after + * typing stops (worked out by the server, POST /api/systems/preview-values, so it is the game's own + * engine), its mistake under it, and which formulas read it; INSERT puts a name at the cursor. + */ +function FormulasTab({ definition, edit, api }: Props) { + const formulas = formulaList(definition); + const [preview, setPreview] = useState<{ values: Record; problems: Record } | null>(null); + const [cursor, setCursor] = useState<{ id: string; at: number } | null>(null); + const inputs = useRef>({}); + + useEffect(() => { + if (!api) return undefined; + let live = true; + const timer = setTimeout(() => { + api.previewValues(definition).then((r) => { + if (live && r.ok) setPreview({ values: r.value.values, problems: problemsByFormula(r.value.problems) }); + }); + }, PREVIEW_DELAY_MS); + return () => { live = false; clearTimeout(timer); }; + }, [api, definition]); + + const insert = (token: string) => { + if (!cursor) return; + const f = formulas.find((x) => x.id === cursor.id); + if (!f) return; + const next = insertToken(f.formula ?? '', cursor.at, token); + edit(withFormula(definition, f.id, { formula: next.text })); + setCursor({ id: f.id, at: next.cursor }); + requestAnimationFrame(() => { const el = inputs.current[f.id]; if (el) { el.focus(); el.setSelectionRange(next.cursor, next.cursor); } }); + }; + const chips = (title: string, tokens: string[]) => tokens.length > 0 && ( + <> + {title} +
+ {tokens.map((t) => ( + + ))} +
+ + ); + + return ( +
+
+

+ Values worked out from stats, tables and each other, written the way a rulebook says them. The value on the right is the sample character's. +

+ {formulas.length > 0 && ( + + + + + + {formulas.map((f) => { + const problem = preview?.problems[f.id]; + const value = preview?.values[f.id]; + const users = usedBy(definition, f.id); + const condition = (f as { kind?: string }).kind === 'condition'; + return ( + + + + + + + ); + })} + +
NAMEFORMULAVALUE +
+ edit(withFormula(definition, f.id, { label: e.target.value }))} /> +
@{f.id}{users.length > 0 && ` · used by ${users.join(', ')}`}
+
+ {condition + ? A condition: if {(f as { when?: string }).when} then {(f as { then?: string }).then} else {(f as { else?: string }).else}. Edited in the node graph later. + : { inputs.current[f.id] = el; }} type="text" aria-label={`${f.label || f.id} formula`} maxLength={1000} value={f.formula ?? ''} + aria-invalid={problem ? true : undefined} + style={{ ...field, width: '100%', borderColor: problem ? 'var(--danger)' : 'var(--green)' }} + onChange={(e) => { edit(withFormula(definition, f.id, { formula: e.target.value })); setCursor({ id: f.id, at: e.target.selectionStart ?? e.target.value.length }); }} + onSelect={(e) => setCursor({ id: f.id, at: (e.target as HTMLInputElement).selectionStart ?? 0 })} + onFocus={(e) => setCursor({ id: f.id, at: e.target.selectionStart ?? e.target.value.length })} />} + {problem &&
{problem}
} +
+ {value !== undefined ? value : '·'} +
+ )} +
+ +
+
+ +
+ ); +} + +export function StatsRulesPage({ definition, edit, api }: Props) { const [tab, setTab] = useState('stats'); - const tabs: [Tab, string][] = [['stats', 'STATS'], ['tables', 'TABLES']]; + const tabs: [Tab, string][] = [['stats', 'STATS'], ['formulas', 'FORMULAS'], ['tables', 'TABLES']]; return (
@@ -172,6 +284,7 @@ export function StatsRulesPage({ definition, edit }: Props) { ))}
{tab === 'stats' && } + {tab === 'formulas' && } {tab === 'tables' && }
); diff --git a/frontend/src/components/__tests__/StatsRulesPage.test.tsx b/frontend/src/components/__tests__/StatsRulesPage.test.tsx index 741b5585..c983ffd5 100644 --- a/frontend/src/components/__tests__/StatsRulesPage.test.tsx +++ b/frontend/src/components/__tests__/StatsRulesPage.test.tsx @@ -1,10 +1,11 @@ import React, { useState } from 'react'; -import { describe, it, expect, afterEach } from 'vitest'; -import { render, screen, cleanup, within, fireEvent } from '@testing-library/react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, screen, cleanup, within, fireEvent, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { createRequire } from 'module'; import { StatsRulesPage } from '../StatsRulesPage'; -import type { Definition } from '../../sheets/systemsApi'; +import { systemsApi, type Definition } from '../../sheets/systemsApi'; /** * The builder's STATS & RULES page, STATS and TABLES (4b2d1). Approved mockup builder-stats-rules @@ -82,6 +83,114 @@ describe('STATS', () => { }); }); +describe('FORMULAS', () => { + const req = createRequire(import.meta.url); + const { previewDerived } = req('../../../../backend/systemBuilder/derived.js'); + /** The route's own work, done here with the server's engine. */ + const server = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + const { definition } = JSON.parse(String(init!.body)); + const body = previewDerived({ lookups: definition.lookups, derived: definition.derived }, definition.samples || {}); + return { ok: true, status: 200, json: async () => body } as unknown as Response; + }); + const SYSTEM: Definition = { ...HEARTH, samples: { str: 16, dex: 14 }, + lookups: { mod: { bands: [{ upTo: 7, value: -1 }, { upTo: 13, value: 0 }, { value: 1 }] } }, + derived: [{ id: 'str_mod', label: 'Strength mod', formula: 'mod(@str)' }, { id: 'save', label: 'Save', formula: '16 - @str_mod' }] }; + const openFormulas = async (start: Definition = SYSTEM) => { + const edits: Definition[] = []; + const Harness = () => { + const [def, setDef] = useState(start); + return { edits.push(next); setDef(next); }} />; + }; + render(); + await userEvent.click(screen.getByRole('tab', { name: 'FORMULAS' })); + return { last: () => edits[edits.length - 1] }; + }; + const value = (id: string) => screen.getByTestId(`value-${id}`).textContent; + + it('shows each formula with its name, id, users, and its value for the sample', async () => { + await openFormulas(); + expect(screen.getByTestId('formula-str_mod').textContent).toContain('@str_mod · used by Save'); + await waitFor(() => expect(value('save')).toBe('15')); + expect(value('str_mod')).toBe('1'); + }); + + it('works the values out again a moment after typing stops', async () => { + await openFormulas(); + await waitFor(() => expect(value('save')).toBe('15')); + const calls = server.mock.calls.length; + const formula = screen.getByLabelText('Save formula'); + await userEvent.clear(formula); + await userEvent.type(formula, '20 - @str_mod'); + await waitFor(() => expect(value('save')).toBe('19')); + // Asked once for the whole burst of typing, not once a key. + expect(server.mock.calls.length - calls).toBeLessThanOrEqual(2); + }); + + it('never lets a late answer for an older formula overwrite a newer one', async () => { + const answers: ((body: object) => void)[] = []; + const slow = vi.fn(() => new Promise((resolve) => { + answers.push((body) => resolve({ ok: true, status: 200, json: async () => body } as unknown as Response)); + })); + const Harness = () => { + const [def, setDef] = useState(SYSTEM); + return ; + }; + render(); + await userEvent.click(screen.getByRole('tab', { name: 'FORMULAS' })); + await waitFor(() => expect(answers).toHaveLength(1)); + const formula = screen.getByLabelText('Save formula'); + await userEvent.clear(formula); + await userEvent.type(formula, '20'); + await waitFor(() => expect(answers).toHaveLength(2)); + answers[1]({ values: { save: 20 }, problems: [] }); + await waitFor(() => expect(value('save')).toBe('20')); + answers[0]({ values: { save: 15 }, problems: [] }); + await new Promise((r) => setTimeout(r, 50)); + expect(value('save')).toBe('20'); + }); + + it('shows a mistake under its formula, keeping the others\' values', async () => { + await openFormulas({ ...SYSTEM, derived: [...(SYSTEM.derived as object[]), { id: 'broken', formula: 'mod(' }] }); + expect((await screen.findByRole('alert')).textContent).toMatch(/.+/); + expect(screen.getByLabelText('broken formula').getAttribute('aria-invalid')).toBe('true'); + expect(value('broken')).toBe('·'); + expect(value('save')).toBe('15'); + }); + + it('names, adds and removes formulas', async () => { + const { last } = await openFormulas({ format: 1, name: 'Hearth' }); + await userEvent.click(screen.getByText('+ FORMULA')); + expect(last().derived).toEqual([{ id: 'new_formula', label: 'New formula', formula: '0' }]); + const name = screen.getByLabelText('New formula name'); + await userEvent.clear(name); + expect(last().derived).toEqual([{ id: 'new_formula', formula: '0' }]); + await userEvent.click(screen.getByLabelText('Remove new_formula')); + expect('derived' in last()).toBe(false); + }); + + it('puts a name from INSERT at the cursor, once a formula is clicked', async () => { + const { last } = await openFormulas(); + const chip = screen.getByRole('button', { name: '@dex' }); + expect((chip as HTMLButtonElement).disabled).toBe(true); + const formula = screen.getByLabelText('Save formula') as HTMLInputElement; + await userEvent.click(formula); + formula.setSelectionRange(2, 2); + fireEvent.select(formula); + await userEvent.click(screen.getByRole('button', { name: '@dex' })); + expect((last().derived as { id: string; formula: string }[]).find((f) => f.id === 'save')!.formula).toBe('16@dex - @str_mod'); + await userEvent.click(screen.getByRole('button', { name: 'max()' })); + expect((last().derived as { id: string; formula: string }[]).find((f) => f.id === 'save')!.formula).toBe('16@dexmax() - @str_mod'); + expect(screen.getByRole('button', { name: 'mod()' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '@str_mod' })).toBeTruthy(); + }); + + it('shows a condition without editing it', async () => { + await openFormulas({ ...SYSTEM, derived: [{ id: 'hurt', kind: 'condition', when: '@str < 5', then: '1', else: '0' }] }); + expect(screen.getByTestId('formula-hurt').textContent).toContain('A condition: if @str < 5 then 1 else 0.'); + expect(screen.queryByLabelText('hurt formula')).toBeNull(); + }); +}); + 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' }));