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.
+