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 @@ -58,6 +58,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Added

- **STATS & RULES' formulas say what they read, and catch a misspelt name.** A name the system
hasn't got (`@strr` for `@str`) used to count as 0 without a word, on STATS & RULES and in the
graph view's panel, and PUBLISH let it through. Now each formula has a line under it saying it in
words ("worked out from Strength", "2 for each level", "your Strength"), and a misspelt name shows
there in red with a USE @str button that fixes it. In a condition, the IF, THEN or ELSE with the
mistake gets the line. The page's own INSERT panel and VALUE column stay as they were, and all its
formulas are read in one request a moment after typing stops.
- **The formula box on ROLLS and RESTS.** A roll's ADD and AGAINST, and a rest's refill amounts,
now get the same box as CHOICES. A line under each says it in words with what it comes to for
the sample character: "against your rolled stat · sample, rolling Strength, rolled stat 16:
Expand Down
15 changes: 9 additions & 6 deletions README.md

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions backend/__tests__/system_builder_formula_preview.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,13 @@ describe('the route', () => {
expect((await request(app).post('/api/systems/preview-formulas').send({ definition: DEF })).status).toBe(401);
});
});

describe('a formula of STATS & RULES (formula box 3)', () => {
it('reads stats, other formulas and tables, and says a name the system hasn\'t got with the nearest', () => {
expect(previewFormula(DEF, '@con_mod + @str', 'formula')).toEqual({ ok: true, value: 18, reads: [
{ id: 'con_mod', label: 'CON modifier', value: 2 }, { id: 'str', label: 'Strength', value: 16 },
] });
expect(previewFormula(DEF, '@strr + 1', 'formula')).toEqual({ ok: false, problem: '@strr isn\'t one of this system\'s names', name: 'strr', suggest: 'str' });
expect(previewFormulas(DEF, [{ text: '@con_mod', kind: 'formula' }])[0].ok).toBe(true);
});
});
4 changes: 2 additions & 2 deletions backend/systemBuilder/formulaPreview.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ const { effectiveSheet, fieldsOf } = require('./sheet');
const SAMPLE_LEVEL = 3;
/**
* What a formula box holds: a modifier's amount, an option's requirement, a feature's uses; a roll's
* ADD and AGAINST; a rest's refill amount.
* ADD and AGAINST; a rest's refill amount; a formula of STATS & RULES (formula box 3).
*/
const KINDS = new Set(['amount', 'requires', 'uses', 'add', 'against', 'refill']);
const KINDS = new Set(['amount', 'requires', 'uses', 'add', 'against', 'refill', 'formula']);
/** What a roll's own names are called in words. */
const ROLL_OWN = new Map([['this', 'rolled stat'], ['this_mod', 'rolled stat\'s modifier'], ['tie', 'tied stat'], ['tie_mod', 'tied stat\'s modifier']]);
/** The most formulas one request reads. */
Expand Down
2 changes: 1 addition & 1 deletion e2e/COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ panel), so a control that looks fine but does nothing is caught for all of them.
| A bonus named on the field it changes | a stat as typed and with it; a formula with it | `choices_numbers` | `sheet-choices` (10 → 11, Guard Stance +1) | **AT THE TABLE** |
| A rest refilling a feature's uses | the rest itself; one it counts as | `picking` | `sheet-choices` (a short rest) | **AT THE TABLE** (the rest itself) |
| A list filled from a file | IMPORT: new and changed options and features, empty cells and `-`, skipped lines, same-named features | `choices_import` | `builder-choices-import` (this list downloaded, changed with one wrong line, uploaded, warned of, applied; played on a sheet) | **AT THE TABLE** |
| Writing a formula | the formula box on REQUIRES, modifier amounts and USES, a roll's ADD and AGAINST, and a rest's amounts: words and the sample's value, a mistake with its fix, SHAPE, INSERT | `formula_preview` | `builder-formula-box` | **BUILDER ONLY** (a builder aid: what it writes is played at the table by `sheet-choices`, `sheet-token-max`, `sheet-rolls` and the rests' own journeys) |
| Writing a formula | the formula box on REQUIRES, modifier amounts and USES, a roll's ADD and AGAINST, a rest's amounts, and STATS & RULES' formulas (words and fixes beside their own INSERT and VALUE): words and the sample's value, a mistake with its fix, SHAPE, INSERT | `formula_preview` | `builder-formula-box` | **BUILDER ONLY** (a builder aid: what it writes is played at the table by `sheet-choices`, `sheet-token-max`, `sheet-rolls` and the rests' own journeys) |

## Sheets and NPCs

Expand Down
2 changes: 1 addition & 1 deletion e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ one machine, files in order.
| `tests/sheet-subclass-levels.spec.ts` | A list a feature opens takes the level of what opened it (7a5d): in the builder ARCHETYPES is set THROUGH FEATURES and says each one's features unlock by the level of the pick that opened it: FIGHTER; Champion's Remarkable Athlete is put at LEVEL 7, saved with no problems; published, a player raises FIGHTER to 3, picks CHAMPION under Martial Archetype (Pick an Archetype) and has Improved Critical with NEXT · LV 7: Remarkable Athlete; at Fighter 7 it unlocks, the archetype's worked level is 7 and the character's levels stay 7 |
| `tests/sheet-rest-levels.spec.ts` | A rest's amount reads the character's levels (rest levels): a published system whose long rest sets Resolve to `@fighter_level + @grit`; a player sets Grit 3 and raises FIGHTER to 3 (Tough, at 2, adds 2 Grit); the GM calls LONG REST from the GAME tab's rests; Resolve becomes 8 on the server and on the player's open sheet; with no dice rolled, the player's DICE_TRAY.EXE stays shut (quiet rests), and a SHORT REST rolling 1d4 then opens it |
| `tests/sheet-token-max.spec.ts` | A feature on the token's maximum (7a5a3): a player with a 20 HP token, a FIGHTER at level 3, picks TOUGH (+2 HP per level) and sees 26 / 26 on HIT_POINTS, the bonus named on the sheet's HP and MAX HEALTH at 26; a level down, 24; the GM opens their sheet from VIEW_PLAYER_INFO and drops TOUGH, and the token is back at 20 |
| `tests/builder-formula-box.spec.ts` | The formula box (formula box 1): on Tough, `2 * @lvl` in red with USE @level, then +2 for each level · sample Level 3: +6 from the real server; PER CLASS LEVEL for FIGHTER at 3 and @con from INSERT at the cursor; AT LEAST on Fighter's REQUIRES (the sample meets it); the draft saved as written with no problems; and formula box 2: on ROLLS, `@thsi` in AGAINST shown in red and fixed with USE @this, then against your rolled stat · sample, rolling Strength, rolled stat 16: target 16; ADD written by ITS MODIFIER; on RESTS, a short rest's amount written by DICE AND A STAT with CON MODIFIER as 1d8 + @con_mod, said 1d8 + CON modifier · sample CON modifier 2: 3 to 10; the draft saved as written with no problems |
| `tests/builder-formula-box.spec.ts` | The formula box (formula box 1): on Tough, `2 * @lvl` in red with USE @level, then +2 for each level · sample Level 3: +6 from the real server; PER CLASS LEVEL for FIGHTER at 3 and @con from INSERT at the cursor; AT LEAST on Fighter's REQUIRES (the sample meets it); the draft saved as written with no problems; and formula box 2: on ROLLS, `@thsi` in AGAINST shown in red and fixed with USE @this, then against your rolled stat · sample, rolling Strength, rolled stat 16: target 16; ADD written by ITS MODIFIER; on RESTS, a short rest's amount written by DICE AND A STAT with CON MODIFIER as 1d8 + @con_mod, said 1d8 + CON modifier · sample CON modifier 2: 3 to 10; the draft saved as written with no problems; and formula box 3: on STATS & RULES' FORMULAS, Grip's `@strr + 1` in red under its box with USE @str, the VALUE 1; fixed, it reads `@str + 1`, says worked out from Strength, the VALUE 17, and the draft is saved so |
| `tests/sheet-visibility.spec.ts` | Who sees what on a customized sheet (coverage family 1): its owner fills in a public field, a private one and a combat value and can't change the GM's field; another player, looking them up from GLOBAL_CHAT's user list (VIEW_PLAYER_INFO), sees only the public lines, and the server sends nothing else; the GM opens the whole sheet the same way (OPEN_SHEET) and sets the GM's field, which the owner then sees |
| `tests/player-journey.spec.ts` | A custom system running (set up through the API), played in two browsers at once: a player makes a character and sees the system's words and its formula worked out; the GM types in a catalogue, gives the player something from it with a note, and calls a rest from the GAME tab; the player sees the gift arrive and the rest change their sheet |
| `tests/builder-smoke.spec.ts` | The GM signs in, opens the System Builder, meets and skips the first-time tour (reset first, so it always shows), and goes back to the map |
32 changes: 32 additions & 0 deletions e2e/tests/builder-formula-box.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,35 @@ test('the formula box on a roll\'s ADD and AGAINST and a rest\'s amount', async
expect([draft.rolls[0].add, draft.rolls[0].against, draft.rests.short_rest.refills[0].amount]).toEqual(['@this_mod', '@this', '1d8 + @con_mod']);
expect(problems).toEqual([]);
});

// Formula box 3: on STATS & RULES, a formula with a misspelt name, which used to read 0 without a
// word, in red under its box with the fix; fixed, it says what it reads, and the VALUE follows.
test('the words and fixes under STATS & RULES formulas', async ({ page, request }, info) => {
const name = `Formula Rules ${info.retry + 1}`;
const token = await gmToken(request);
const { id } = await asGm(request, token, 'post', '/api/systems', { definition: {
format: 1, name,
stats: [{ id: 'abilities', label: 'ABILITIES', stats: [{ id: 'str', label: 'Strength' }] }],
samples: { str: 16 },
derived: [{ id: 'grip', label: 'Grip', formula: '@strr + 1' }],
} });
await asGm(request, token, 'put', '/api/systems/view', { page: 'rules', view: 'forms' });
await signInAsGm(page);
await openBuilder(page);
const card = page.getByTestId(/^system-/).filter({ hasText: name.toUpperCase() });
if (!(await card.getByRole('button', { name: 'OPEN', exact: true }).isVisible())) await card.getByRole('button').first().click();
await card.getByRole('button', { name: 'OPEN', exact: true }).click();
await builderPage(page, 'STATS & RULES');
await page.getByRole('tab', { name: 'FORMULAS' }).click();
const grip = page.getByTestId('formula-grip');
await expect(grip.getByTestId('formula-words')).toContainText("@strr isn't one of this system's names");
await expect(page.getByTestId('value-grip')).toHaveText('1');
await grip.getByRole('button', { name: 'USE @str' }).click();
await expect(grip.getByLabel('Grip formula')).toHaveValue('@str + 1');
await expect(grip.getByTestId('formula-words')).toHaveText('worked out from Strength');
await expect(page.getByTestId('value-grip')).toHaveText('17');

await expect(page.getByTestId('save-status')).toHaveText(/· SAVED /, { timeout: 15_000 });
const { draft } = await asGm(request, token, 'get', `/api/systems/${id}`);
expect(draft.derived[0].formula).toBe('@str + 1');
});
47 changes: 47 additions & 0 deletions frontend/src/components/FormulaWords.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React from 'react';
import { wordsOf, sampleLine, isMistake, type FormulaKind, type FormulaRead } from '../sheets/formulaBox';

// The line under a formula (formula box 1; approved mockup docs/mockups/formula-box.html,
// 2026-10-29): the formula in words with what it comes to for the sample character, or its mistake
// in red with a fix for a name nearly right. The formula box (hooks/useFormulaBox.tsx) draws it under
// its box; STATS & RULES and the graph view's panel draw it under each formula (formula box 3), where
// the VALUE column already says the sample's value.

const fix: React.CSSProperties = {
border: '1px dashed var(--green)', background: 'none', color: 'var(--green)', fontFamily: 'monospace', fontSize: 10, letterSpacing: 1, padding: '3px 7px', cursor: 'pointer',
};

interface Props {
kind: FormulaKind;
text: string;
/** What the server made of this very text, or null while it hasn't answered. */
read: FormulaRead | null | undefined;
/** The text with a name put right, for USE @name. */
onFix: (next: string) => void;
/** The sample's value after the words: off where a VALUE column shows it. */
sample?: boolean;
/** A live status read out as it changes (one box); off where many sit on a page, read with their box instead. */
live?: boolean;
/** For the box's aria-describedby. */
id?: string;
}

export function FormulaWords({ kind, text, read, onFix, sample = true, live = true, id }: Props) {
if (!text.trim()) return null;
const bad = isMistake(read);
const said = wordsOf(kind, text, read && read.ok ? read.reads : []);
return (
<div id={id} role={live ? 'status' : undefined} data-testid="formula-words"
style={{ textAlign: 'left', fontSize: 11, lineHeight: 1.5, color: bad ? 'var(--danger)' : 'var(--cyan)', display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
{bad
? <>
<span>{read.problem}</span>
{read.name && read.suggest && (
<button type="button" style={fix}
onClick={() => onFix(text.replace(new RegExp(`@${read.name}(?![a-z0-9_])`), `@${read.suggest}`))}>USE @{read.suggest}</button>
)}
</>
: <span>{said}{sample && read && read.ok && <span style={{ color: 'var(--green)', opacity: 0.75 }}> · {sampleLine(kind, read)}</span>}</span>}
</div>
);
}
13 changes: 9 additions & 4 deletions frontend/src/components/GraphPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { mapGroupsOf, groupOf, withFormulaInGroup, withMapGroupLabel, withoutMap
import { notesOf, pinChoices, withNoteText, withNotePinned, withoutNote, NOTE_MAX } from '../sheets/graphNotes';
import type { Tried } from '../hooks/usePreviewValues';
import { useFormulaInsert } from '../hooks/useFormulaInsert';
import { useFormulaReads } from '../hooks/useFormulaReads';
import { formulaTexts } from '../sheets/formulaBox';
import { GroupSection, TableSection, FormulaName, FormulaBody, InsertChips } from './statsRulesFields';
import { small, why, btn, field } from './statsRulesStyles';
import { RollEditor, TestRoll } from './RollsTab';
Expand Down Expand Up @@ -184,15 +186,18 @@ export function GroupPanel({ definition, edit, group, onClose, graph, positions
}

/** A formula as FORMULAS shows it, laid out down the panel: name, text, = value, alternative. */
function FormulaForm({ definition, edit, id, preview, tried, insert, trying }: Omit<Props, 'node' | 'onClose' | 'onGoTo'> & { id: string; insert: ReturnType<typeof useFormulaInsert> }) {
function FormulaForm({ definition, edit, id, preview, tried, insert, trying, api }: Omit<Props, 'node' | 'onClose' | 'onGoTo'> & { id: string; insert: ReturnType<typeof useFormulaInsert> }) {
const f = formulaList(definition).find((x) => x.id === id);
// Formula box 3: this formula read by the server, for its words and a name the system hasn't got.
const reads = useFormulaReads(definition, api, f ? formulaTexts([f]) : []);
if (!f) return null;
const value = preview?.values[f.id];
const rule = f.alternative && trying?.has(f.alternative.rule) ? houseRuleList(definition).find((r) => r.id === f.alternative!.rule) : undefined;
return (
<div data-testid={`panel-formula-${f.id}`} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<FormulaName definition={definition} edit={edit} f={f} />
<FormulaBody definition={definition} edit={edit} f={f} problem={preview?.problems[f.id]} tried={tried} bind={(box) => insert.bind(f.id, box)} />
<FormulaBody definition={definition} edit={edit} f={f} problem={preview?.problems[f.id]} tried={tried} bind={(box) => insert.bind(f.id, box)}
reads={(box) => reads[`${f.id}:${box}`] ?? null} />
<OnTheWayIn definition={definition} edit={edit} id={f.id} />
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'flex-end', gap: 8 }}>
<span style={small}>ON THE SAMPLE{rule ? `, WITH ${(rule.name || rule.id).toUpperCase()} (TRYING)` : ''}</span>
Expand All @@ -218,15 +223,15 @@ export function GraphPanel({ definition, edit, node, preview, tried, onClose, on
{formulaIds.length > 0 && (
<>
<span style={small}>MODIFIERS · FROM {node.via?.label}</span>
{formulaIds.map((id) => <FormulaForm key={id} definition={definition} edit={edit} id={id} preview={preview} tried={tried} insert={insert} trying={trying} />)}
{formulaIds.map((id) => <FormulaForm key={id} definition={definition} edit={edit} id={id} preview={preview} tried={tried} insert={insert} trying={trying} api={api} />)}
</>
)}
</>
);
} else if (node.kind === 'formula' || node.kind === 'condition') {
body = (
<>
<FormulaForm definition={definition} edit={edit} id={node.id} preview={preview} tried={tried} insert={insert} trying={trying} />
<FormulaForm definition={definition} edit={edit} id={node.id} preview={preview} tried={tried} insert={insert} trying={trying} api={api} />
<MapGroupField definition={definition} edit={edit} id={node.id} />
<div><button type="button" className="utility-btn" style={btn} onClick={() => edit(withoutFormula(definition, node.id))}>REMOVE {node.kind === 'condition' ? 'CONDITION' : 'FORMULA'}</button></div>
</>
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/components/StatsRulesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { RollsTab } from './RollsTab';
import { DiceTab } from './DiceTab';
import { groupOf } from '../sheets/graphGroups';
import { useFormulaInsert } from '../hooks/useFormulaInsert';
import { useFormulaReads } from '../hooks/useFormulaReads';
import { formulaTexts } from '../sheets/formulaBox';
import { usePreviewValues } from '../hooks/usePreviewValues';
import { HINTS } from '../sheets/builderHelp';
import { ColumnHead } from './HelpHint';
Expand Down Expand Up @@ -104,6 +106,8 @@ function FormulasTab({ definition, edit, api, onEditInGraph }: Props) {
const formulas = formulaList(definition);
const { preview, tried } = usePreviewValues(definition, api);
const insert = useFormulaInsert(definition, edit);
// Formula box 3: every formula read at once, for its words and a name the system hasn't got.
const reads = useFormulaReads(definition, api, formulaTexts(formulas));

return (
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 230px', gap: 16, alignItems: 'start' }}>
Expand All @@ -127,7 +131,8 @@ function FormulasTab({ definition, edit, api, onEditInGraph }: Props) {
<tr data-testid={`formula-${f.id}`}>
<td style={td}><FormulaName definition={definition} edit={edit} f={f} /></td>
<td style={td}>
<FormulaBody definition={definition} edit={edit} f={f} problem={preview?.problems[f.id]} tried={tried} bind={(box) => insert.bind(f.id, box)} />
<FormulaBody definition={definition} edit={edit} f={f} problem={preview?.problems[f.id]} tried={tried} bind={(box) => insert.bind(f.id, box)}
reads={(box) => reads[`${f.id}:${box}`] ?? null} />
</td>
<td data-testid={`value-${f.id}`} style={{ ...td, textAlign: 'right', color: 'var(--cyan)', fontSize: 13 }}>
{value !== undefined ? value : '·'}
Expand Down
Loading
Loading