diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e6fb5b5..14c766ee 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 +- **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, + and is never one something else already has. Removing a stat takes its sample and any skill's tie + to it. Each formula knows which others read it, and the server's problems are matched to the + formula they belong to. The page itself comes next. + - **Formulas worked out while you write them.** The server can now work out a draft's formulas from its sample character without saving anything, for the STATS & RULES page to show each formula's value as you type. A formula with a mistake, and any formula that reads it, is left diff --git a/frontend/src/sheets/__tests__/statsRules.test.ts b/frontend/src/sheets/__tests__/statsRules.test.ts new file mode 100644 index 00000000..a1112139 --- /dev/null +++ b/frontend/src/sheets/__tests__/statsRules.test.ts @@ -0,0 +1,193 @@ +/** + * The STATS & RULES page as logic (4b2c). Approved mockup builder-stats-rules (2026-10-06): stats in + * groups, a sample character, formulas with names, lookup tables; ids from first names, kept after. + * Every step is held to the server's own check (backend/systemBuilder/definition.js) and engine. + */ +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'module'; +import { + statGroups, allStats, formulaList, tableList, takenIds, FUNCTIONS, + withNewGroup, withGroupLabel, withoutGroup, withNewStat, withStat, withoutStat, sampleOf, withSample, + withNewFormula, withFormula, withoutFormula, usedBy, problemsByFormula, insertToken, + withNewTable, withTableLabel, withoutTable, withBands, lookupIn, +} from '../statsRules'; +import type { Definition } from '../systemsApi'; + +const req = createRequire(import.meta.url); +const { checkDefinition } = req('../../../../backend/systemBuilder/definition.js'); +const { previewDerived } = req('../../../../backend/systemBuilder/derived.js'); +const { BUILTINS } = req('../../../../backend/systemBuilder/expression.js'); + +const H: Definition = { format: 1, name: 'Hearth' }; +const problems = (def: Definition) => checkDefinition(def).problems.map((p: { where: string; message: string }) => `${p.where}: ${p.message}`); + +/** Hearth with abilities STR and DEX, a mod table, and two formulas. */ +const built = (): Definition => { + let def = withNewGroup(H, 'ABILITIES'); + def = withNewStat(def, 'abilities', 'Strength'); + def = withNewStat(def, 'abilities', 'Dexterity'); + def = withStat(def, 'strength', { min: 3, max: 18 }); + def = withNewTable(def, 'Mod'); + def = withBands(def, 'mod', { add: true }); + def = withBands(def, 'mod', { set: 0, upTo: 7, value: -1 }); + def = withBands(def, 'mod', { set: 1, value: 1 }); + def = withNewFormula(def, 'Strength mod'); + def = withFormula(def, 'strength_mod', { formula: 'mod(@strength)' }); + def = withNewFormula(def, 'Save'); + def = withFormula(def, 'save', { formula: '16 - @strength_mod' }); + return def; +}; + +describe('held to the server', () => { + it('names the functions the engine has', () => { + expect([...FUNCTIONS].sort()).toEqual(Object.keys(BUILTINS).sort()); + }); + + it('builds a system the server accepts at every step, and works it out', () => { + const def = withSample(withSample(built(), 'strength', 16), 'dexterity', 12); + expect(problems(def)).toEqual([]); + expect(previewDerived({ lookups: def.lookups, derived: def.derived }, def.samples).values).toEqual({ strength_mod: 1, save: 15 }); + }); +}); + +describe('stats', () => { + it('adds groups and stats with ids from their first names, kept when renamed', () => { + let def = built(); + expect(statGroups(def)).toEqual([{ id: 'abilities', label: 'ABILITIES', stats: [ + { id: 'strength', label: 'Strength', min: 3, max: 18 }, { id: 'dexterity', label: 'Dexterity', min: 0, max: 10 }, + ] }]); + def = withGroupLabel(def, 'abilities', 'ATTRIBUTES'); + def = withStat(def, 'strength', { label: 'Might' }); + expect(statGroups(def)[0].label).toBe('ATTRIBUTES'); + expect(allStats(def)[0]).toEqual({ id: 'strength', label: 'Might', min: 3, max: 18 }); + }); + + it('renames only the group, or table, asked for', () => { + let def = withNewTable(withNewTable(withNewGroup(withNewGroup(H, 'ONE'), 'TWO'), 'Alpha'), 'Beta'); + def = withTableLabel(withGroupLabel(def, 'two', 'SECOND'), 'beta', 'Second table'); + expect(statGroups(def).map((g) => g.label)).toEqual(['ONE', 'SECOND']); + expect(tableList(def).map((t) => t.label)).toEqual(['Alpha', 'Second table']); + }); + + it('never gives an id something else has', () => { + let def = withNewGroup(H, 'Notes'); + expect(statGroups(def)[0].id).toBe('notes_2'); + def = withNewStat(def, 'notes_2', 'HP'); + expect(allStats(def)[0].id).toBe('hp_2'); + def = withNewStat(withNewTable(def, 'Mod'), 'notes_2', 'mod'); + expect(allStats(def)[1].id).toBe('mod_2'); + def = withNewTable(def, 'max'); + expect(tableList(def).map((t) => t.id)).toEqual(['mod', 'max_2']); + expect(takenIds(H)).toEqual(expect.arrayContaining(['name', 'cash', 'hp', 'hp_max', ...FUNCTIONS])); + expect(problems(def)).toEqual([]); + }); + + it('clears a range end, ties a skill and unties it', () => { + let def = withNewStat(withNewGroup(built(), 'SKILLS'), 'skills', 'Shoot'); + def = withStat(def, 'shoot', { min: null, tie: 'dexterity' }); + expect(allStats(def).find((s) => s.id === 'shoot')).toEqual({ id: 'shoot', label: 'Shoot', max: 10, tie: 'dexterity' }); + expect(problems(def)).toEqual([]); + def = withStat(def, 'shoot', { tie: '' }); + expect(allStats(def).find((s) => s.id === 'shoot')).toEqual({ id: 'shoot', label: 'Shoot', max: 10 }); + }); + + it('removing a stat takes its sample and the ties to it; removing a group, all of its stats', () => { + let def = withNewStat(withNewGroup(built(), 'SKILLS'), 'skills', 'Shoot'); + def = withStat(def, 'shoot', { tie: 'dexterity' }); + def = withSample(withSample(def, 'dexterity', 14), 'shoot', 2); + const noDex = withoutStat(def, 'dexterity'); + expect(allStats(noDex).map((s) => s.id)).toEqual(['strength', 'shoot']); + expect(allStats(noDex).find((s) => s.id === 'shoot')!.tie).toBeUndefined(); + expect(noDex.samples).toEqual({ shoot: 2 }); + const noAbilities = withoutGroup(def, 'abilities'); + expect(allStats(noAbilities).map((s) => s.id)).toEqual(['shoot']); + expect(noAbilities.samples).toEqual({ shoot: 2 }); + expect('stats' in withoutGroup(withoutGroup(def, 'abilities'), 'skills')).toBe(false); + }); + + it('sets and clears a sample, the section going when empty', () => { + let def = withSample(built(), 'strength', 16); + expect(sampleOf(def, 'strength')).toBe(16); + expect(sampleOf(def, 'dexterity')).toBeNull(); + def = withSample(def, 'strength', null); + expect('samples' in def).toBe(false); + expect('samples' in withSample(built(), 'strength', NaN)).toBe(false); + expect(sampleOf({ ...H, samples: { strength: 'x' } }, 'strength')).toBeNull(); + expect(sampleOf({ ...H, samples: { strength: Infinity } }, 'strength')).toBeNull(); + }); +}); + +describe('formulas', () => { + it('names and writes one, a blank name leaving its id to show', () => { + let def = withFormula(built(), 'save', { label: 'Physical save' }); + expect(formulaList(def).find((f) => f.id === 'save')).toEqual({ id: 'save', label: 'Physical save', formula: '16 - @strength_mod' }); + def = withFormula(def, 'save', { label: ' ' }); + expect(formulaList(def).find((f) => f.id === 'save')).toEqual({ id: 'save', formula: '16 - @strength_mod' }); + expect(withNewFormula(H).derived).toEqual([{ id: 'new_formula', label: 'New formula', formula: '0' }]); + }); + + it('says which formulas read one, by name', () => { + const def = built(); + expect(usedBy(def, 'strength_mod')).toEqual(['Save']); + expect(usedBy(def, 'save')).toEqual([]); + expect(usedBy(withFormula(def, 'save', { formula: '@strength_modx' }), 'strength_mod')).toEqual([]); + // One that reads itself is a loop, shown as a problem, not as its own user. + expect(usedBy(withFormula(def, 'save', { formula: '@save + 1' }), 'save')).toEqual([]); + }); + + it('removes one, and the last takes the section', () => { + const def = withoutFormula(built(), 'save'); + expect(formulaList(def).map((f) => f.id)).toEqual(['strength_mod']); + expect('derived' in withoutFormula(def, 'strength_mod')).toBe(false); + }); + + it('pins the server\'s problems to their formulas', () => { + const def = withFormula(built(), 'save', { formula: '16 - (' }); + const found = problemsByFormula(checkDefinition(def).problems); + expect(Object.keys(found)).toEqual(['save']); + expect(problemsByFormula([{ where: 'derived value 3', message: 'x' }, { where: 'lookup mod', message: 'y' }, { where: 'derived a', message: 'first' }, { where: 'derived a, formula', message: 'second' }])) + .toEqual({ a: 'first' }); + }); + + it('puts a chip at the cursor, inside the brackets of a call', () => { + expect(insertToken('16 - ', 5, '@str')).toEqual({ text: '16 - @str', cursor: 9 }); + expect(insertToken('1 + ', 4, 'max()')).toEqual({ text: '1 + max()', cursor: 8 }); + expect(insertToken('ab', 1, '@x')).toEqual({ text: 'a@xb', cursor: 3 }); + expect(insertToken('ab', 99, '@x')).toEqual({ text: 'ab@x', cursor: 4 }); + }); +}); + +describe('tables', () => { + it('starts a table giving 0 for everything, and adds rows above the last', () => { + let def = withNewTable(H, 'Mod'); + expect(tableList(def)).toEqual([{ id: 'mod', label: 'Mod', bands: [{ value: 0 }] }]); + def = withBands(def, 'mod', { add: true }); + def = withBands(def, 'mod', { add: true }); + expect(tableList(def)[0].bands).toEqual([{ upTo: 0, value: 0 }, { upTo: 1, value: 0 }, { value: 0 }]); + expect(problems(def)).toEqual([]); + }); + + it('sets a row, never giving the last one an "up to", and removes any but the last', () => { + let def = withBands(withBands(withNewTable(H, 'Mod'), 'mod', { add: true }), 'mod', { add: true }); + def = withBands(def, 'mod', { set: 2, upTo: 9, value: 5 }); + expect(tableList(def)[0].bands[2]).toEqual({ value: 5 }); + def = withBands(def, 'mod', { remove: 0 }); + expect(tableList(def)[0].bands).toEqual([{ upTo: 1, value: 0 }, { value: 5 }]); + expect(withBands(def, 'mod', { remove: 1 })).toEqual(def); + }); + + it('renames a table, keeping what formulas call, and removes it', () => { + let def = withTableLabel(built(), 'mod', 'Attribute modifier'); + expect(tableList(def)[0]).toMatchObject({ id: 'mod', label: 'Attribute modifier' }); + expect(problems(def)).toEqual([]); + def = withoutTable(def, 'mod'); + expect('lookups' in def).toBe(false); + expect(problems(def).some((p: string) => p.startsWith('derived strength_mod'))).toBe(true); + }); + + it('reads a table as the engine does', () => { + const bands = [{ upTo: 3, value: -2 }, { upTo: 7, value: -1 }, { value: 1 }]; + expect([2, 3, 4, 7, 8, 99].map((x) => lookupIn(bands, x))).toEqual([-2, -2, -1, -1, 1, 1]); + expect(lookupIn([{ upTo: 1, value: 4 }], 5)).toBe(0); + }); +}); diff --git a/frontend/src/sheets/__tests__/systemsApi.test.ts b/frontend/src/sheets/__tests__/systemsApi.test.ts index 00847f25..253bf12e 100644 --- a/frontend/src/sheets/__tests__/systemsApi.test.ts +++ b/frontend/src/sheets/__tests__/systemsApi.test.ts @@ -28,6 +28,7 @@ describe('the requests', () => { [(a) => a.get(ID), `/api/systems/${ID}`, 'GET', undefined], [(a) => a.saveDraft(ID, { format: 1, name: 'Hearth' }), `/api/systems/${ID}/draft`, 'PUT', { definition: { format: 1, name: 'Hearth' } }], [(a) => a.publish(ID), `/api/systems/${ID}/publish`, 'POST', {}], + [(a) => a.previewValues({ format: 1, name: 'H' }), '/api/systems/preview-values', 'POST', { definition: { format: 1, name: 'H' } }], [(a) => a.create('Hearth'), '/api/systems', 'POST', { name: 'Hearth' }], [(a) => a.rename(ID, 'Emberhold'), `/api/systems/${ID}/name`, 'PUT', { name: 'Emberhold' }], [(a) => a.duplicate(ID), `/api/systems/${ID}/duplicate`, 'POST', {}], diff --git a/frontend/src/sheets/statsRules.ts b/frontend/src/sheets/statsRules.ts new file mode 100644 index 00000000..38e1cf14 --- /dev/null +++ b/frontend/src/sheets/statsRules.ts @@ -0,0 +1,216 @@ +// The builder's STATS & RULES page as logic (4b2c): a system's stats (in groups), its sample +// character, its formulas and its lookup tables, read from its definition and written back. Pure, +// so the page only draws it. Approved mockup docs/mockups/builder-stats-rules.html (2026-10-06). +// +// The server checks all of it (backend/systemBuilder: stats.js, derived.js, expression.js) and works +// the formulas out (POST /api/systems/preview-values; systemsApi.previewValues), so there is one +// engine for the builder and the game. New entries get ids from their first names, kept when the +// name changes, so formulas reading them never break (the user, same day). + +import type { Definition } from './systemsApi'; +import { idFor, healthLayout } from './setup'; + +type Obj = Record; +const isObject = (v: unknown): v is Obj => !!v && typeof v === 'object' && !Array.isArray(v); + +export interface Stat { id: string; label: string; min?: number; max?: number; tie?: string } +export interface StatGroup { id: string; label: string; stats: Stat[] } +export interface Formula { id: string; label?: string; formula: string } +export interface Band { upTo?: number; value: number } +export interface Table { id: string; label?: string; bands: Band[] } + +/** Functions every formula can call (expression.js BUILTINS): a table may not take these names. */ +export const FUNCTIONS = ['min', 'max', 'floor', 'ceil', 'round', 'abs', 'clamp', 'if']; +/** Fields the starter sheet always has (sheet.js): a stat may not take these ids. */ +const STARTER_IDS = ['name', 'concept', 'description', 'cash', 'notes']; + +/** The system's stat groups, as stored. */ +export const statGroups = (def: Definition): StatGroup[] => + (Array.isArray(def.stats) ? def.stats.filter((g) => isObject(g) && Array.isArray((g as Obj).stats)) as unknown as StatGroup[] : []); +/** Every stat, in order. */ +export const allStats = (def: Definition): Stat[] => statGroups(def).flatMap((g) => g.stats.filter(isObject) as Stat[]); +/** The system's formulas, as stored. */ +export const formulaList = (def: Definition): Formula[] => + (Array.isArray(def.derived) ? def.derived.filter((d) => isObject(d) && typeof (d as Obj).id === 'string') as unknown as Formula[] : []); +/** The system's tables, in the order written. */ +export const tableList = (def: Definition): Table[] => (isObject(def.lookups) + ? Object.entries(def.lookups).filter(([, t]) => isObject(t)).map(([id, t]) => ({ id, ...(t as Obj) } as Table)) : []); + +/** Every id a new stat, formula or table must not take. */ +export const takenIds = (def: Definition): string[] => [ + ...STARTER_IDS, + ...healthLayout(isObject(def.core) ? (def.core as Obj).health : undefined).sections.flatMap((s) => s.fields.map((f) => f.id)), + ...statGroups(def).map((g) => g.id), + ...allStats(def).map((s) => s.id), + ...formulaList(def).map((f) => f.id), + ...tableList(def).map((t) => t.id), + ...FUNCTIONS, +]; + +const withList = (def: Definition, key: 'stats' | 'derived', list: unknown[]): Definition => { + const next: Definition = { ...def }; + if (list.length) next[key] = list; else delete next[key]; + return next; +}; + +// ─── STATS ────────────────────────────────────────────────────────────────── + +/** A new, empty group named `label`. */ +export const withNewGroup = (def: Definition, label = 'NEW GROUP'): Definition => + withList(def, 'stats', [...statGroups(def), { id: idFor(label, takenIds(def)), label, stats: [] }]); + +/** A group renamed; its id stays. */ +export const withGroupLabel = (def: Definition, groupId: string, label: string): Definition => + withList(def, 'stats', statGroups(def).map((g) => (g.id === groupId ? { ...g, label } : g))); + +/** A group removed, with its stats, their samples and any ties to them. */ +export const withoutGroup = (def: Definition, groupId: string): Definition => { + const gone = new Set((statGroups(def).find((g) => g.id === groupId)?.stats ?? []).map((s) => s.id)); + const next = withList(def, 'stats', statGroups(def).filter((g) => g.id !== groupId)); + return forgetStats(next, gone); +}; + +/** A new stat at the end of a group, 0 to 10 until the GM says otherwise. */ +export const withNewStat = (def: Definition, groupId: string, label = 'New stat'): Definition => { + const id = idFor(label, takenIds(def)); + return withList(def, 'stats', statGroups(def).map((g) => (g.id === groupId ? { ...g, stats: [...g.stats, { id, label, min: 0, max: 10 }] } : g))); +}; + +/** + * A stat changed; its id stays. An empty lowest or highest (null) is left out; an empty tie means + * none. + */ +export const withStat = (def: Definition, statId: string, patch: { label?: string; min?: number | null; max?: number | null; tie?: string | null }): Definition => + withList(def, 'stats', statGroups(def).map((g) => ({ + ...g, + stats: g.stats.map((s) => { + if (s.id !== statId) return s; + const next: Stat = { ...s }; + if (patch.label !== undefined) next.label = patch.label; + for (const end of ['min', 'max'] as const) { + if (patch[end] === null) delete next[end]; + else if (patch[end] !== undefined) next[end] = patch[end]!; + } + if (patch.tie !== undefined) { if (patch.tie) next.tie = patch.tie; else delete next.tie; } + return next; + }), + }))); + +/** A stat removed, with its sample and any ties to it. */ +export const withoutStat = (def: Definition, statId: string): Definition => + forgetStats(withList(def, 'stats', statGroups(def).map((g) => ({ ...g, stats: g.stats.filter((s) => s.id !== statId) }))), new Set([statId])); + +/** Samples and ties to stats that are gone, removed. */ +const forgetStats = (def: Definition, gone: Set): Definition => { + let next = withList(def, 'stats', statGroups(def).map((g) => ({ + ...g, stats: g.stats.map((s) => { if (s.tie && gone.has(s.tie)) { const { tie: _t, ...rest } = s; return rest; } return s; }), + }))); + for (const id of gone) next = withSample(next, id, null); + return next; +}; + +/** The sample character's value for a stat, or null when it has none (it reads as 0). */ +export const sampleOf = (def: Definition, statId: string): number | null => { + const v = isObject(def.samples) ? def.samples[statId] : undefined; + return typeof v === 'number' && Number.isFinite(v) ? v : null; +}; + +/** A sample value set, or cleared with null. */ +export const withSample = (def: Definition, statId: string, value: number | null): Definition => { + const samples = { ...(isObject(def.samples) ? def.samples : {}) }; + if (value === null || !Number.isFinite(value)) delete samples[statId]; else samples[statId] = value; + const next: Definition = { ...def }; + if (Object.keys(samples).length) next.samples = samples; else delete next.samples; + return next; +}; + +// ─── FORMULAS ─────────────────────────────────────────────────────────────── + +/** A new formula at the end, worth 0 until written. */ +export const withNewFormula = (def: Definition, label = 'New formula'): Definition => + withList(def, 'derived', [...formulaList(def), { id: idFor(label, takenIds(def)), label, formula: '0' }]); + +/** A formula's name or text changed; its id stays. A blank name shows its id, so it isn't stored. */ +export const withFormula = (def: Definition, id: string, patch: { label?: string; formula?: string }): Definition => + withList(def, 'derived', formulaList(def).map((f) => { + if (f.id !== id) return f; + const next: Formula = { ...f, ...patch }; + if (patch.label !== undefined && !patch.label.trim()) delete next.label; + return next; + })); + +/** A formula removed. Formulas reading it show the problem until they're changed. */ +export const withoutFormula = (def: Definition, id: string): Definition => withList(def, 'derived', formulaList(def).filter((f) => f.id !== id)); + +/** The names of the formulas that read @id. */ +export const usedBy = (def: Definition, id: string): string[] => { + const reads = new RegExp(`@${id}(?![a-z0-9_])`); + return formulaList(def).filter((f) => f.id !== id && reads.test(f.formula ?? '')).map((f) => f.label || f.id); +}; + +/** A formula's problems, by its id, from the preview's list ("derived save, formula" is save's). */ +export const problemsByFormula = (problems: { where: string; message: string }[]): Record => { + const out: Record = {}; + for (const p of problems) { + const m = /^derived ([a-z][a-z0-9_]*)(,|$)/.exec(p.where); + if (m && !out[m[1]]) out[m[1]] = p.message; + } + return out; +}; + +/** `text` with `token` put in at `at`, and where the cursor goes: inside a table's or function's ( ). */ +export const insertToken = (text: string, at: number, token: string): { text: string; cursor: number } => { + const p = Math.max(0, Math.min(at, text.length)); + const call = token.endsWith('()'); + return { text: text.slice(0, p) + token + text.slice(p), cursor: p + token.length - (call ? 1 : 0) }; +}; + +// ─── TABLES ───────────────────────────────────────────────────────────────── + +const withTables = (def: Definition, tables: Table[]): Definition => { + const next: Definition = { ...def }; + if (tables.length) next.lookups = Object.fromEntries(tables.map(({ id, ...rest }) => [id, rest])); + else delete next.lookups; + return next; +}; + +/** A new table that gives 0 for everything, until rows are added. */ +export const withNewTable = (def: Definition, label = 'New table'): Definition => + withTables(def, [...tableList(def), { id: idFor(label, takenIds(def)), label, bands: [{ value: 0 }] }]); + +/** A table renamed; its id (what formulas call) stays. */ +export const withTableLabel = (def: Definition, id: string, label: string): Definition => + withTables(def, tableList(def).map((t) => (t.id === id ? { ...t, label } : t))); + +/** A table removed. Formulas calling it show the problem until they're changed. */ +export const withoutTable = (def: Definition, id: string): Definition => withTables(def, tableList(def).filter((t) => t.id !== id)); + +/** + * A table's rows changed: a row added above the open last one (one more than the row before it), + * a row's "up to" or value set, or a row removed. The last row always stays, catching everything + * higher. + */ +export const withBands = (def: Definition, id: string, change: { add: true } | { remove: number } | { set: number; upTo?: number; value?: number }): Definition => + withTables(def, tableList(def).map((t) => { + if (t.id !== id) return t; + let bands = [...t.bands]; + if ('add' in change) { + const closed = bands.filter((b) => b.upTo !== undefined); + const upTo = closed.length ? closed[closed.length - 1].upTo! + 1 : 0; + bands.splice(bands.length - 1, 0, { upTo, value: 0 }); + } else if ('remove' in change) { + if (change.remove >= bands.length - 1) return t; + bands = bands.filter((_, i) => i !== change.remove); + } else { + bands = bands.map((b, i) => (i === change.set ? { + ...b, ...(change.upTo !== undefined && i < bands.length - 1 ? { upTo: change.upTo } : {}), ...(change.value !== undefined ? { value: change.value } : {}), + } : b)); + } + return { ...t, bands }; + })); + +/** What a table gives for `x`, read top to bottom (derived.js lookupIn). */ +export const lookupIn = (bands: Band[], x: number): number => { + for (const b of bands) if (b.upTo === undefined || x <= b.upTo) return b.value; + return 0; +}; diff --git a/frontend/src/sheets/systemsApi.ts b/frontend/src/sheets/systemsApi.ts index bae03268..ad95bebe 100644 --- a/frontend/src/sheets/systemsApi.ts +++ b/frontend/src/sheets/systemsApi.ts @@ -61,6 +61,12 @@ export const systemsApi = (token: string, fetcher: typeof fetch = fetch) => { /** Store the builder's draft; its problems come back, stored either way. */ saveDraft: (systemId: string, definition: Definition) => call<{ problems: Problem[] }>(fetcher, token, `${id(systemId)}/draft`, 'PUT', { definition }), + /** + * A draft's formulas worked out from its sample character, without saving (STATS & RULES): + * the values that can be worked out yet, and the formulas' problems. + */ + previewValues: (definition: Definition) => + call<{ values: Record; problems: Problem[] }>(fetcher, token, '/api/systems/preview-values', 'POST', { definition }), /** Make the stored draft what the game runs; refused, with `problems`, while it has any. */ publish: (systemId: string) => call<{ version: number }>(fetcher, token, `${id(systemId)}/publish`, 'POST', {}), create: (name: string) => call<{ id: string }>(fetcher, token, '/api/systems', 'POST', { name }),