Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/BuilderScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o
)}
{system && definition && page === 'words' && <WordsPage definition={definition} edit={edit} />}
{system && definition && page === 'features' && <FeaturesPage definition={definition} edit={edit} api={api} />}
{system && definition && page === 'rules' && <StatsRulesPage definition={definition} edit={edit} />}
{system && definition && page === 'rules' && <StatsRulesPage definition={definition} edit={edit} api={api} />}
{system && !['problems', 'systems', 'setup', 'words', 'features', 'rules'].includes(page) && (
<div style={{ maxWidth: '60ch', border: '1px dashed color-mix(in srgb, var(--green) 45%, transparent)', padding: '16px 18px' }}>
<p style={{ margin: '0 0 6px', color: 'var(--green)', letterSpacing: 1 }}>{current.label}</p>
Expand Down
125 changes: 119 additions & 6 deletions frontend/src/components/StatsRulesPage.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<typeof systemsApi>;
}

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 };
Expand Down Expand Up @@ -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<string, number>; problems: Record<string, string> } | null>(null);
const [cursor, setCursor] = useState<{ id: string; at: number } | null>(null);
const inputs = useRef<Record<string, HTMLInputElement | null>>({});

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 && (
<>
<span style={{ ...small, opacity: 0.55 }}>{title}</span>
<div style={{ display: 'flex', gap: 5, flexWrap: 'wrap' }}>
{tokens.map((t) => (
<button key={t} type="button" disabled={!cursor} onMouseDown={(e) => e.preventDefault()} onClick={() => insert(t)}
style={{ border: '1px solid var(--dark-green)', background: 'none', color: 'var(--green)', fontFamily: 'monospace', fontSize: 11, padding: '1px 6px', cursor: cursor ? 'pointer' : 'default' }}>{t}</button>
))}
</div>
</>
);

return (
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 230px', gap: 16, alignItems: 'start' }}>
<div>
<p style={{ ...why, margin: '0 0 10px', maxWidth: '68ch' }}>
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.
</p>
{formulas.length > 0 && (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead><tr>
<th scope="col" style={{ ...th, width: 190 }}>NAME</th><th scope="col" style={th}>FORMULA</th>
<th scope="col" style={{ ...th, width: 60, textAlign: 'right' }}>VALUE</th><th scope="col" style={th} />
</tr></thead>
<tbody>
{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 (
<tr key={f.id} data-testid={`formula-${f.id}`}>
<td style={td}>
<input type="text" aria-label={`${f.label || f.id} name`} maxLength={40} value={f.label ?? ''} placeholder={f.id} style={{ ...field, width: '100%' }}
onChange={(e) => edit(withFormula(definition, f.id, { label: e.target.value }))} />
<div style={{ fontSize: 11, opacity: 0.6 }}>@{f.id}{users.length > 0 && ` · used by ${users.join(', ')}`}</div>
</td>
<td style={td}>
{condition
? <span style={{ ...why, fontSize: 11 }}>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.</span>
: <input ref={(el) => { 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 && <div role="alert" style={{ ...why, color: 'var(--danger)', opacity: 1, fontSize: 11 }}>{problem}</div>}
</td>
<td data-testid={`value-${f.id}`} style={{ ...td, textAlign: 'right', color: 'var(--cyan)', fontSize: 13 }}>
{value !== undefined ? value : '·'}
</td>
<td style={td}><button type="button" className="utility-btn" style={btn} aria-label={`Remove ${f.label || f.id}`}
onClick={() => edit(withoutFormula(definition, f.id))}>×</button></td>
</tr>
);
})}
</tbody>
</table>
)}
<div style={{ padding: '8px 0' }}>
<button type="button" className="utility-btn" style={btn} onClick={() => edit(withNewFormula(definition))}>+ FORMULA</button>
</div>
</div>
<aside aria-label="Insert" style={{ border: '1px solid var(--dark-green)', padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
<span style={small}>INSERT</span>
{chips('STATS', allStats(definition).map((s) => `@${s.id}`))}
{chips('FORMULAS', formulas.map((f) => `@${f.id}`))}
{chips('TABLES', tableList(definition).map((t) => `${t.id}()`))}
{chips('FUNCTIONS', FUNCTIONS.map((fn) => `${fn}()`))}
<span style={{ ...why, fontSize: 11 }}>{cursor ? 'Click one to put it at the cursor.' : 'Click in a formula first, then a name to put it there.'}</span>
</aside>
</div>
);
}

export function StatsRulesPage({ definition, edit, api }: Props) {
const [tab, setTab] = useState<Tab>('stats');
const tabs: [Tab, string][] = [['stats', 'STATS'], ['tables', 'TABLES']];
const tabs: [Tab, string][] = [['stats', 'STATS'], ['formulas', 'FORMULAS'], ['tables', 'TABLES']];
return (
<div style={{ maxWidth: 1000 }}>
<div role="tablist" aria-label="Stats and rules" style={{ display: 'flex', borderBottom: '1px solid var(--dark-green)', marginBottom: 14 }}>
Expand All @@ -172,6 +284,7 @@ export function StatsRulesPage({ definition, edit }: Props) {
))}
</div>
{tab === 'stats' && <StatsTab definition={definition} edit={edit} />}
{tab === 'formulas' && <FormulasTab definition={definition} edit={edit} api={api} />}
{tab === 'tables' && <TablesTab definition={definition} edit={edit} />}
</div>
);
Expand Down
115 changes: 112 additions & 3 deletions frontend/src/components/__tests__/StatsRulesPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 <StatsRulesPage definition={def} api={systemsApi('gm', server as typeof fetch)} edit={(next) => { edits.push(next); setDef(next); }} />;
};
render(<Harness />);
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<Response>((resolve) => {
answers.push((body) => resolve({ ok: true, status: 200, json: async () => body } as unknown as Response));
}));
const Harness = () => {
const [def, setDef] = useState<Definition>(SYSTEM);
return <StatsRulesPage definition={def} api={systemsApi('gm', slow as unknown as typeof fetch)} edit={setDef} />;
};
render(<Harness />);
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' }));
Expand Down
Loading