diff --git a/CHANGELOG.md b/CHANGELOG.md index 66002629..4009407d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **What the builder's CHARACTER SHEET page will change.** Customizing copies the starter sheet in + and going back to automatic drops it. Tabs can be added, renamed (never blank or repeated), moved + and removed, their sections moving to another tab, and removing the last makes the sheet one + page. Sections and fields can be added, renamed, restyled, moved and removed. Stats, formulas and + starter fields taken off wait in a tray to be placed again, and the header forgets fields that + left. Every sheet keeps its Name field. A formula's kind never changes and nobody edits it, and a + value that decides whether attacks hit is never shown to everyone. The page itself comes next. + - **A draft's character sheet, shown without saving.** The server can now answer, for a draft still being edited, the sheet it would be drawn with and the starter sheet it would get without one of its own, for the builder's CHARACTER SHEET page to preview and to copy when you customize. diff --git a/frontend/src/sheets/__tests__/sheetDesigner.test.ts b/frontend/src/sheets/__tests__/sheetDesigner.test.ts new file mode 100644 index 00000000..d12b401a --- /dev/null +++ b/frontend/src/sheets/__tests__/sheetDesigner.test.ts @@ -0,0 +1,322 @@ +/** + * The CHARACTER SHEET page as logic (4b3b). Approved mockup builder-sheet (2026-10-07): automatic + * until CUSTOMIZE copies the starter; stats, formulas and starter fields not placed wait in a tray; + * removing a tab moves its sections, the last one makes one page; attack numbers never EVERYONE. + * Every step is held to the server's own check (backend/systemBuilder/definition.js, sheet.js). + */ +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'module'; +import { + sheetOf, fieldsOn, withCustomized, withAutomatic, fieldKind, tray, pagesOf, sectionsOn, + tabNameProblem, withNewTab, withTabName, withTabMoved, withoutTab, + withNewSection, withSection, withSectionMoved, sectionRemoveProblem, withoutSection, + withNewField, optionsFrom, optionsText, withField, withFieldMoved, withFieldIn, withoutField, withPlaced, + sheetProblems, LIMITS, +} from '../sheetDesigner'; +import { takenIds, withNewStat } from '../statsRules'; +import type { Definition } from '../systemsApi'; +import type { CustomRenderSheet } from '../customTemplates'; + +const req = createRequire(import.meta.url); +const { checkDefinition } = req('../../../../backend/systemBuilder/definition.js'); +const { starterSheet, effectiveSheet } = req('../../../../backend/systemBuilder/sheet.js'); + +/** Hearth: two abilities, a formula, the default HP pool. */ +const H: Definition = { + format: 1, + name: 'Hearth', + stats: [{ id: 'abilities', label: 'ABILITIES', stats: [{ id: 'str', label: 'Strength', min: 3, max: 18 }, { id: 'dex', label: 'Dexterity' }] }], + derived: [{ id: 'save', label: 'Save', formula: '16 - @str' }], +}; +const STARTER: CustomRenderSheet = starterSheet(H); +const custom = (def: Definition = H) => withCustomized(def, starterSheet(def)); +const problems = (def: Definition) => checkDefinition(def).problems.map((p: { where: string; message: string }) => `${p.where}: ${p.message}`); +const ids = (def: Definition) => fieldsOn(sheetOf(def)).map((f) => f.id); +const sections = (def: Definition) => sheetOf(def)!.sections.map((s) => `${s.tab ?? '-'}/${s.id}`); + +describe('automatic and customized', () => { + it('copies the starter in, which the server accepts and draws as it was', () => { + expect(sheetOf(H)).toBeNull(); + const def = custom(); + expect(sheetOf(def)).toEqual(STARTER); + expect(problems(def)).toEqual([]); + expect(effectiveSheet(def)).toEqual(effectiveSheet(H)); + }); + + it('goes back to automatic by dropping it', () => { + expect(withAutomatic(custom())).toEqual(H); + expect('sheet' in withAutomatic(custom())).toBe(false); + }); + + it('says what each field is', () => { + const def = custom(); + const kind = (id: string) => fieldKind(def, fieldsOn(sheetOf(def)).find((f) => f.id === id)!); + expect(['save', 'cash', 'str', 'concept'].map(kind)).toEqual(['formula', 'linked', 'stat', 'plain']); + }); + + it('has nothing in the tray until something comes off, nor while automatic', () => { + expect(tray(H, STARTER)).toEqual([]); + expect(tray(custom(), STARTER)).toEqual([]); + expect(tray(custom(), null)).toEqual([]); + const def = withoutField(withoutField(custom(), 'save'), 'str'); + expect(tray(def, STARTER).map((f) => f.id)).toEqual(['str', 'save']); + }); + + it('lists a stat added after customizing in the tray, not on the sheet', () => { + const def = withNewStat(custom(), 'abilities', 'Grit'); + expect(ids(def)).not.toContain('grit'); + expect(tray(def, starterSheet(def)).map((f) => f.id)).toEqual(['grit']); + }); + + it('never gives a new stat the id of one of the sheet\'s own fields', () => { + const def = withNewField(custom(), 'identity', 'Grit'); + expect(takenIds(def)).toContain('grit'); + expect(withNewStat(def, 'abilities', 'Grit').stats).toEqual(expect.arrayContaining([ + expect.objectContaining({ stats: expect.arrayContaining([expect.objectContaining({ id: 'grit_2' })]) }), + ])); + }); +}); + +describe('tabs', () => { + it('adds TAB 4, TAB 5... and stops at the server\'s limit', () => { + let def = withNewTab(custom()); + expect(sheetOf(def)!.tabs).toEqual(['STATS', 'GEAR', 'NOTES', 'TAB 4']); + expect(problems(def)).toEqual([]); + for (let i = 0; i < 20; i += 1) def = withNewTab(def); + expect(sheetOf(def)!.tabs).toHaveLength(LIMITS.tabs); + expect(problems(def)).toEqual([]); + }); + + it('skips a TAB name already taken', () => { + const def = withNewTab(withTabName(custom(), 'NOTES', 'Tab 4')); + expect(sheetOf(def)!.tabs).toEqual(['STATS', 'GEAR', 'Tab 4', 'TAB 5']); + }); + + it('refuses a blank, long or repeated name, and renames its sections with it', () => { + const def = custom(); + expect(tabNameProblem(def, 'GEAR', ' ')).toBe('A tab needs a name.'); + expect(tabNameProblem(def, 'GEAR', 'x'.repeat(21))).toBe('At most 20 characters.'); + expect(tabNameProblem(def, 'GEAR', 'stats')).toBe('There is already a tab called stats.'); + expect(tabNameProblem(def, 'GEAR', 'gear')).toBeNull(); + expect(withTabName(def, 'GEAR', 'stats')).toBe(def); + expect(withTabName(def, 'MISSING', 'X')).toBe(def); + const renamed = withTabName(def, 'GEAR', ' KIT '); + expect(sheetOf(renamed)!.tabs).toEqual(['STATS', 'KIT', 'NOTES']); + expect(sections(renamed).filter((s) => s.startsWith('KIT/'))).toEqual(['KIT/inventory', 'KIT/money']); + expect(problems(renamed)).toEqual([]); + }); + + it('moves a tab, staying put at either end', () => { + expect(sheetOf(withTabMoved(custom(), 'GEAR', -1))!.tabs).toEqual(['GEAR', 'STATS', 'NOTES']); + expect(sheetOf(withTabMoved(custom(), 'NOTES', 1))!.tabs).toEqual(['STATS', 'GEAR', 'NOTES']); + expect(sheetOf(withTabMoved(custom(), 'STATS', -1))!.tabs).toEqual(['STATS', 'GEAR', 'NOTES']); + }); + + it('moves a removed tab\'s sections where asked, else to the first other tab', () => { + const toNotes = withoutTab(custom(), 'STATS', 'NOTES'); + expect(sheetOf(toNotes)!.tabs).toEqual(['GEAR', 'NOTES']); + expect(sectionsOn(sheetOf(toNotes)!, 'NOTES').map((s) => s.id)).toEqual(['identity', 'health', 'stats_abilities', 'derived', 'notes']); + expect(problems(toNotes)).toEqual([]); + const toFirst = withoutTab(custom(), 'STATS', 'STATS'); + expect(sectionsOn(sheetOf(toFirst)!, 'GEAR').map((s) => s.id)).toEqual(['identity', 'health', 'stats_abilities', 'derived', 'inventory', 'money']); + }); + + it('makes one page of the last tab, and a new tab gathers that page again', () => { + let def = withoutTab(withoutTab(custom(), 'STATS'), 'GEAR'); + def = withoutTab(def, 'NOTES'); + const sheet = sheetOf(def)!; + expect('tabs' in sheet).toBe(false); + expect(sheet.sections.every((s) => !('tab' in s))).toBe(true); + expect(pagesOf(sheet)).toEqual([null]); + expect(sectionsOn(sheet, null)).toHaveLength(7); + expect(problems(def)).toEqual([]); + def = withNewTab(def); + expect(sheetOf(def)!.tabs).toEqual(['TAB 1']); + expect(sheetOf(def)!.sections.every((s) => s.tab === 'TAB 1')).toBe(true); + expect(problems(def)).toEqual([]); + }); + + it('leaves an automatic sheet alone', () => { + for (const change of [withNewTab, (d: Definition) => withTabName(d, 'STATS', 'X'), (d: Definition) => withoutTab(d, 'STATS'), (d: Definition) => withTabMoved(d, 'STATS', 1)]) { + expect(change(H)).toBe(H); + } + }); +}); + +describe('sections', () => { + it('adds an empty list on a tab, ids never repeating, on the first tab when the tab isn\'t one', () => { + let def = withNewSection(withNewSection(custom(), 'GEAR'), 'GEAR'); + expect(sections(def).slice(-2)).toEqual(['GEAR/new_section', 'GEAR/new_section_2']); + def = withNewSection(def, 'NOPE', 'Skills'); + expect(sections(def).slice(-1)).toEqual(['STATS/skills']); + expect(problems(def)).toEqual([]); + const page = withNewSection(withoutTab(withoutTab(withoutTab(custom(), 'STATS'), 'GEAR'), 'NOTES'), 'GEAR'); + expect(sheetOf(page)!.sections.at(-1)).toEqual({ id: 'new_section', label: 'NEW SECTION', layout: 'list', fields: [] }); + }); + + it('changes a name, tab, look and columns, refusing what the server would', () => { + let def = withSection(custom(), 'derived', { label: 'WORKED OUT', tab: 'GEAR', layout: 'list', columns: 12 }); + expect(sheetOf(def)!.sections.find((s) => s.id === 'derived')).toMatchObject({ label: 'WORKED OUT', tab: 'GEAR', layout: 'list', columns: 8 }); + def = withSection(def, 'derived', { label: ' ', tab: 'NOPE', layout: 'cards' as never, columns: 0 }); + expect(sheetOf(def)!.sections.find((s) => s.id === 'derived')).toMatchObject({ label: 'WORKED OUT', tab: 'GEAR', layout: 'list', columns: 1 }); + expect(withSection(def, 'derived', { columns: 2.6 }).sheet).toMatchObject({ sections: expect.arrayContaining([expect.objectContaining({ id: 'derived', columns: 3 })]) }); + expect(sheetOf(withSection(def, 'derived', { label: 'x'.repeat(80) }))!.sections.find((s) => s.id === 'derived')!.label).toHaveLength(60); + expect(problems(def)).toEqual([]); + }); + + it('moves among the sections on its own tab only', () => { + const def = custom(); + expect(sections(withSectionMoved(def, 'inventory', -1))).toEqual(sections(def)); + expect(sections(withSectionMoved(def, 'money', -1)).slice(4, 6)).toEqual(['GEAR/money', 'GEAR/inventory']); + expect(sections(withSectionMoved(def, 'derived', 1))).toEqual(sections(def)); + expect(sections(withSectionMoved(def, 'identity', 1)).slice(0, 2)).toEqual(['STATS/health', 'STATS/identity']); + expect(withSectionMoved(def, 'gone', 1)).toEqual(def); + }); + + it('refuses to remove the name\'s section; another goes, its fields back to the tray', () => { + const def = custom(); + expect(sectionRemoveProblem(def, 'identity')).toBe('It holds the character\'s name. Move Name to another section first.'); + expect(withoutSection(def, 'identity')).toBe(def); + const noStats = withoutSection(withNewField(def, 'stats_abilities', 'Grit'), 'stats_abilities'); + expect(sectionRemoveProblem(def, 'stats_abilities')).toBeNull(); + expect(ids(noStats)).not.toContain('grit'); + expect(tray(noStats, STARTER).map((f) => f.id)).toEqual(['str', 'dex']); + expect(problems(noStats)).toEqual([]); + }); + + it('takes a removed section\'s fields out of the header too', () => { + const def = withoutSection(custom(), 'health'); + expect(sheetOf(custom())!.header).toMatchObject({ hpField: 'hp', hpMaxField: 'hp_max' }); + expect(sheetOf(def)!.header).toEqual({ nameField: 'name', subtitleFields: ['concept'] }); + expect(problems(def)).toEqual([]); + }); +}); + +describe('fields', () => { + it('adds a text field with an id nothing else has', () => { + let def = withNewField(custom(), 'identity', 'HP'); + def = withNewField(def, 'identity', 'Strength'); + def = withNewField(def, 'identity'); + expect(sheetOf(def)!.sections[0].fields.slice(-3)).toEqual([ + { id: 'hp_2', label: 'HP', type: 'text' }, { id: 'strength', label: 'Strength', type: 'text' }, { id: 'new_field', label: 'New field', type: 'text' }, + ]); + expect(fieldsOn(sheetOf(withNewField(def, 'notes'))).at(-1)!.id).toBe('new_field_2'); + // A starter field off the sheet keeps its id, for when it is placed again. + expect(ids(withNewField(withoutField(custom(), 'concept'), 'identity', 'Concept'))).toContain('concept_2'); + expect(problems(def)).toEqual([]); + }); + + it('reads choices one per line or by commas, without blanks or repeats', () => { + expect(optionsFrom('Street, Corp\n\n street \nNomad')).toEqual([ + { value: 'Street', label: 'Street' }, { value: 'Corp', label: 'Corp' }, { value: 'Nomad', label: 'Nomad' }, + ]); + expect(optionsFrom(Array.from({ length: 150 }, (_, i) => `o${i}`).join('\n'))).toHaveLength(100); + expect(optionsText({ id: 'x', label: 'X', type: 'select', options: optionsFrom('A,B') })).toBe('A\nB'); + expect(optionsText({ id: 'x', label: 'X', type: 'text' })).toBe(''); + }); + + it('turns a field into a pick-one with choices, and back', () => { + let def = withField(custom(), 'concept', { type: 'select' }); + const concept = () => fieldsOn(sheetOf(def)).find((f) => f.id === 'concept')!; + expect(concept().options).toEqual(optionsFrom('Option 1\nOption 2')); + expect(problems(def)).toEqual([]); + def = withField(def, 'concept', { options: 'Courier\nFixer' }); + expect(optionsText(concept())).toBe('Courier\nFixer'); + def = withField(def, 'concept', { options: ' , ' }); + expect(optionsText(concept())).toBe('Courier\nFixer'); + def = withField(def, 'concept', { type: 'textarea' }); + expect(concept()).toEqual({ id: 'concept', label: 'Concept', type: 'textarea' }); + expect(withField(def, 'concept', { options: 'A' })).toEqual(def); + expect(problems(def)).toEqual([]); + }); + + it('keeps a formula\'s and a linked field\'s kind, and a formula from being edited', () => { + const def = withField(withField(custom(), 'save', { type: 'text', gmOnly: true, label: 'Physical save' }), 'cash', { type: 'text', gmOnly: true }); + const f = (id: string) => fieldsOn(sheetOf(def)).find((x) => x.id === id)!; + expect(f('save')).toEqual({ id: 'save', label: 'Physical save', type: 'number' }); + expect(f('cash')).toMatchObject({ type: 'number', edit: 'gm' }); + expect(problems(def)).toEqual([]); + }); + + it('shows a field to everyone, never an attack number, and only the GM may change it', () => { + let def = withField(custom(), 'str', { everyone: true, gmOnly: true, hint: ' Rolled at creation ' }); + const str = () => fieldsOn(sheetOf(def)).find((f) => f.id === 'str')!; + expect(str()).toMatchObject({ visibility: 'public', edit: 'gm', hint: ' Rolled at creation ' }); + def = withField(def, 'str', { attack: true }); + expect(str().visibility).toBeUndefined(); + expect(str().sensitivity).toBe('combat'); + def = withField(def, 'str', { everyone: true }); + expect(str().visibility).toBeUndefined(); + expect(problems(def)).toEqual([]); + def = withField(def, 'str', { attack: false, everyone: true, gmOnly: false, hint: ' ' }); + expect(str()).toEqual({ id: 'str', label: 'Strength', type: 'number', visibility: 'public' }); + def = withField(def, 'str', { everyone: false, label: ' ' }); + expect(str()).toEqual({ id: 'str', label: 'Strength', type: 'number' }); + expect(problems(def)).toEqual([]); + }); + + it('cuts text to what the server takes', () => { + const def = withField(custom(), 'concept', { label: 'x'.repeat(99), hint: 'y'.repeat(999) }); + const concept = fieldsOn(sheetOf(def)).find((f) => f.id === 'concept')!; + expect([concept.label.length, concept.hint!.length]).toEqual([60, 300]); + expect(problems(def)).toEqual([]); + }); + + it('moves within its section and to another', () => { + let def = withFieldMoved(custom(), 'dex', -1); + expect(sheetOf(def)!.sections.find((s) => s.id === 'stats_abilities')!.fields.map((f) => f.id)).toEqual(['dex', 'str']); + expect(withFieldMoved(def, 'dex', -1)).toEqual(def); + def = withFieldIn(def, 'name', 'notes'); + expect(sheetOf(def)!.sections.find((s) => s.id === 'notes')!.fields.map((f) => f.id)).toEqual(['notes', 'name']); + expect(sheetOf(def)!.sections[0].fields.map((f) => f.id)).toEqual(['concept', 'description']); + expect(withFieldIn(def, 'name', 'notes')).toBe(def); + expect(withFieldIn(def, 'name', 'gone')).toBe(def); + expect(withFieldIn(def, 'gone', 'notes')).toBe(def); + expect(sectionRemoveProblem(def, 'identity')).toBeNull(); + expect(problems(def)).toEqual([]); + }); + + it('keeps the name, and takes a max\'s pair and the header with a field that goes', () => { + expect(withoutField(custom(), 'name')).toEqual(custom()); + const hp = fieldsOn(sheetOf(custom())).find((f) => f.id === 'hp')!; + expect(hp.maxField).toBe('hp_max'); + const def = withoutField(withoutField(custom(), 'hp_max'), 'concept'); + expect(fieldsOn(sheetOf(def)).find((f) => f.id === 'hp')!.maxField).toBeUndefined(); + expect(sheetOf(def)!.header).toEqual({ nameField: 'name', hpField: 'hp' }); + expect(problems(def)).toEqual([]); + expect(sheetOf(withoutField(custom(), 'hp'))!.header).toEqual({ nameField: 'name', subtitleFields: ['concept'] }); + }); + + it('places from the tray at the end of a section, or of a new one', () => { + let def = withoutField(custom(), 'save'); + const save = tray(def, STARTER)[0]; + def = withPlaced(def, save, 'identity'); + expect(sheetOf(def)!.sections[0].fields.at(-1)).toEqual(save); + expect(tray(def, STARTER)).toEqual([]); + expect(withPlaced(def, save, 'identity')).toBe(def); + expect(problems(def)).toEqual([]); + // Every section gone: placing makes one, on the first tab. + let empty = custom(); + for (const s of sheetOf(empty)!.sections.map((x) => x.id).filter((id) => id !== 'identity')) empty = withoutSection(empty, s); + empty = withoutSection(withFieldIn(withNewSection(empty, 'NOTES'), 'name', 'new_section'), 'identity'); + const placed = withPlaced(empty, tray(empty, STARTER).find((f) => f.id === 'str')!, 'gone', 'GEAR'); + expect(sheetOf(placed)!.sections.at(-1)).toMatchObject({ id: 'new_section_2', tab: 'GEAR', fields: [{ id: 'str' }] }); + expect(problems(placed)).toEqual([]); + }); +}); + +describe('problems', () => { + it('pins the server\'s problems to their field or section', () => { + expect(sheetProblems([ + { where: 'sheet field str, label', message: 'Cannot be blank' }, + { where: 'sheet field str', message: 'second' }, + { where: 'sheet section derived, columns', message: 'A whole number from 1 to 8' }, + { where: 'sheet tabs', message: 'Must be a list of tab names' }, + { where: 'derived save', message: 'not the sheet' }, + ])).toEqual({ + fields: { str: 'Cannot be blank' }, + sections: { derived: 'A whole number from 1 to 8' }, + other: ['sheet tabs: Must be a list of tab names'], + }); + }); +}); diff --git a/frontend/src/sheets/sheetDesigner.ts b/frontend/src/sheets/sheetDesigner.ts new file mode 100644 index 00000000..f21c7979 --- /dev/null +++ b/frontend/src/sheets/sheetDesigner.ts @@ -0,0 +1,364 @@ +// The builder's CHARACTER SHEET page as logic (4b3b): a system's own sheet layout, read from its +// definition and written back. Pure, so the page only draws it. Approved mockup +// docs/mockups/builder-sheet.html (2026-10-07). +// +// A system's sheet is automatic (the server's starter sheet, built from its stats, formulas and +// health model) until the GM customizes it, which copies the starter into `sheet`; going back to +// automatic drops it. The server checks the layout (backend/systemBuilder/sheet.js checkSheet) and +// answers the starter (POST /api/systems/preview-sheet). Decided with the user the same day: +// - stats, formulas and the starter's other fields not on a customized sheet wait in a tray until +// placed, rather than appearing by themselves; +// - removing a tab moves its sections to another; removing the last makes a sheet of one page; +// - who sees a field: the owner and the GM always; EVERYONE adds other players. A value that +// decides whether attacks hit is never shown to anyone else, so it is never EVERYONE. + +import type { Definition } from './systemsApi'; +import type { CustomRenderField, CustomRenderSheet } from './customTemplates'; +import { idFor } from './setup'; +import { allStats, formulaList, takenIds } from './statsRules'; + +type Section = CustomRenderSheet['sections'][number]; +type Field = CustomRenderField; +type Obj = Record; +const isObject = (v: unknown): v is Obj => !!v && typeof v === 'object' && !Array.isArray(v); + +/** The server's limits (sheet.js LIMITS), so the page refuses before the server would. */ +export const LIMITS = { tabs: 12, tabName: 20, label: 60, hint: 300, options: 100, perSection: 100 }; +/** How a section looks (sheet.js LAYOUTS), with the page's names for them. */ +export const LAYOUTS = [ + { id: 'list', label: 'LIST' }, { id: 'grid', label: 'GRID' }, { id: 'notes', label: 'NOTES' }, { id: 'inventory', label: 'ITEMS' }, +] as const; +/** What a field holds (sheet.js TYPES), with the page's names for them. */ +export const TYPES = [ + { id: 'number', label: 'NUMBER' }, { id: 'text', label: 'TEXT' }, { id: 'textarea', label: 'LONG TEXT' }, { id: 'select', label: 'PICK ONE' }, +] as const; +/** The field every sheet keeps: the character's name, which the app shows everywhere. */ +export const NAME_FIELD = 'name'; + +/** The system's own sheet, or null while it is automatic. */ +export const sheetOf = (def: Definition): CustomRenderSheet | null => + (isObject(def.sheet) && Array.isArray((def.sheet as Obj).sections) ? def.sheet as unknown as CustomRenderSheet : null); + +/** Every field on a sheet, in order. */ +export const fieldsOn = (sheet: CustomRenderSheet | null): Field[] => (sheet ? sheet.sections.flatMap((s) => s.fields) : []); + +const withSheet = (def: Definition, sheet: CustomRenderSheet): Definition => ({ ...def, sheet }); + +/** The starter sheet as the system's own: CUSTOMIZE. Every change makes new objects, so it is never altered. */ +export const withCustomized = (def: Definition, starter: CustomRenderSheet): Definition => withSheet(def, starter); + +/** The system's own sheet dropped, so the starter draws it again: BACK TO AUTOMATIC. */ +export const withAutomatic = (def: Definition): Definition => { + const { sheet: _gone, ...rest } = def; + return rest as Definition; +}; + +/** + * What a field is, for the page: a FORMULA (worked out, nobody edits it), LINKED (lives on the + * token or in the bank), a STAT the system defines, or a plain field of the sheet's own. + */ +export const fieldKind = (def: Definition, field: Field): 'formula' | 'linked' | 'stat' | 'plain' => { + if (formulaList(def).some((f) => f.id === field.id)) return 'formula'; + if (field.source) return 'linked'; + if (allStats(def).some((s) => s.id === field.id)) return 'stat'; + return 'plain'; +}; + +/** The starter's fields a customized sheet doesn't have, in the starter's order: NOT ON THE SHEET YET. */ +export const tray = (def: Definition, starter: CustomRenderSheet | null): Field[] => { + const sheet = sheetOf(def); + if (!sheet || !starter) return []; + const on = new Set(fieldsOn(sheet).map((f) => f.id)); + return fieldsOn(starter).filter((f) => !on.has(f.id)); +}; + +/** The ids sections and tabs group by: the tabs, or one untabbed page (null) when there are none. */ +export const pagesOf = (sheet: CustomRenderSheet): (string | null)[] => (sheet.tabs && sheet.tabs.length ? sheet.tabs : [null]); +/** The sections on a tab (or on the one page), in order. */ +export const sectionsOn = (sheet: CustomRenderSheet, tab: string | null): Section[] => + sheet.sections.filter((s) => (tab === null ? true : s.tab === tab)); + +const withSections = (def: Definition, map: (sections: Section[]) => Section[]): Definition => { + const sheet = sheetOf(def); + return sheet ? withSheet(def, { ...sheet, sections: map(sheet.sections) }) : def; +}; + +/** Swap a list's item at `i` with its neighbor `dir` away, when there is one. */ +const swapped = (list: T[], i: number, j: number): T[] => { + if (i < 0 || j < 0 || i >= list.length || j >= list.length) return list; + const next = [...list]; + [next[i], next[j]] = [next[j], next[i]]; + return next; +}; + +// ─── TABS ─────────────────────────────────────────────────────────────────── + +/** Why a tab can't be called `name` (blank, too long, another tab's), or null when it can. */ +export const tabNameProblem = (def: Definition, current: string | null, name: string): string | null => { + const sheet = sheetOf(def); + const trimmed = name.trim(); + if (!trimmed) return 'A tab needs a name.'; + if (trimmed.length > LIMITS.tabName) return `At most ${LIMITS.tabName} characters.`; + if ((sheet?.tabs ?? []).some((t) => t !== current && t.toLowerCase() === trimmed.toLowerCase())) return `There is already a tab called ${trimmed}.`; + return null; +}; + +/** + * A new tab at the end, named TAB 2, TAB 3... A sheet of one page gets its first tab, holding + * every section. Refused (unchanged) at the server's limit. + */ +export const withNewTab = (def: Definition): Definition => { + const sheet = sheetOf(def); + if (!sheet) return def; + const tabs = sheet.tabs ?? []; + if (tabs.length >= LIMITS.tabs) return def; + let n = tabs.length + 1; + while (tabs.some((t) => t.toLowerCase() === `tab ${n}`)) n += 1; + const name = `TAB ${n}`; + const sections = tabs.length ? sheet.sections : sheet.sections.map((s) => ({ ...s, tab: name })); + return withSheet(def, { ...sheet, tabs: [...tabs, name], sections }); +}; + +/** A tab renamed, its sections with it. A name tabNameProblem refuses leaves it unchanged. */ +export const withTabName = (def: Definition, current: string, name: string): Definition => { + const sheet = sheetOf(def); + if (!sheet || !sheet.tabs?.includes(current) || tabNameProblem(def, current, name)) return def; + const next = name.trim(); + return withSheet(def, { + ...sheet, + tabs: sheet.tabs.map((t) => (t === current ? next : t)), + sections: sheet.sections.map((s) => (s.tab === current ? { ...s, tab: next } : s)), + }); +}; + +/** A tab moved one place left (-1) or right (+1). */ +export const withTabMoved = (def: Definition, name: string, dir: -1 | 1): Definition => { + const sheet = sheetOf(def); + if (!sheet?.tabs) return def; + const i = sheet.tabs.indexOf(name); + return withSheet(def, { ...sheet, tabs: swapped(sheet.tabs, i, i + dir) }); +}; + +/** + * A tab removed, its sections moved to `to` (another tab; the first other one when not given). The + * last tab removed makes the sheet one page: no tabs, its sections kept in order. + */ +export const withoutTab = (def: Definition, name: string, to?: string): Definition => { + const sheet = sheetOf(def); + if (!sheet?.tabs?.includes(name)) return def; + const others = sheet.tabs.filter((t) => t !== name); + if (!others.length) { + const { tabs: _gone, ...rest } = sheet; + return withSheet(def, { ...rest, sections: sheet.sections.map(({ tab: _t, ...s }) => s) }); + } + const dest = to && others.includes(to) ? to : others[0]; + return withSheet(def, { ...sheet, tabs: others, sections: sheet.sections.map((s) => (s.tab === name ? { ...s, tab: dest } : s)) }); +}; + +// ─── SECTIONS ─────────────────────────────────────────────────────────────── + +/** + * A new, empty list section at the end of `tab`: the first tab when that isn't one, and none on a + * sheet of one page. + */ +export const withNewSection = (def: Definition, tab: string | null, label = 'NEW SECTION'): Definition => { + const sheet = sheetOf(def); + if (!sheet) return def; + const id = idFor(label, sheet.sections.map((s) => s.id)); + const tabs = sheet.tabs ?? []; + const on = tabs.length ? (tab !== null && tabs.includes(tab) ? tab : tabs[0]) : null; + const section: Section = { id, label, layout: 'list', ...(on !== null ? { tab: on } : {}), fields: [] }; + return withSheet(def, { ...sheet, sections: [...sheet.sections, section] }); +}; + +/** + * A section changed: its name, its tab, how it looks, its columns (1 to 8, for a grid). Its id + * stays. A blank name or one tab the sheet doesn't have is not stored. + */ +export const withSection = (def: Definition, id: string, patch: { label?: string; tab?: string; layout?: Section['layout']; columns?: number }): Definition => { + const tabs = sheetOf(def)?.tabs ?? []; + return withSections(def, (sections) => sections.map((s) => { + if (s.id !== id) return s; + const next: Section = { ...s }; + if (patch.label !== undefined && patch.label.trim()) next.label = patch.label.slice(0, LIMITS.label); + if (patch.tab !== undefined && tabs.includes(patch.tab)) next.tab = patch.tab; + if (patch.layout !== undefined && LAYOUTS.some((l) => l.id === patch.layout)) next.layout = patch.layout; + if (patch.columns !== undefined && Number.isFinite(patch.columns)) next.columns = Math.max(1, Math.min(8, Math.round(patch.columns))); + return next; + })); +}; + +/** A section moved up (-1) or down (+1) among the sections on its own tab. */ +export const withSectionMoved = (def: Definition, id: string, dir: -1 | 1): Definition => withSections(def, (sections) => { + const i = sections.findIndex((s) => s.id === id); + if (i < 0) return sections; + const same = (s: Section) => (s.tab ?? null) === (sections[i].tab ?? null); + let j = i + dir; + while (j >= 0 && j < sections.length && !same(sections[j])) j += dir; + return swapped(sections, i, j); +}); + +/** Why a section can't be removed (it holds the name), or null when it can. */ +export const sectionRemoveProblem = (def: Definition, id: string): string | null => + (sheetOf(def)?.sections.find((s) => s.id === id)?.fields.some((f) => f.id === NAME_FIELD) + ? 'It holds the character\'s name. Move Name to another section first.' : null); + +/** + * A section removed. Its stats, formulas and starter fields go back to the tray; its own plain + * fields go with it. Refused (unchanged) while it holds the name. + */ +export const withoutSection = (def: Definition, id: string): Definition => { + const sheet = sheetOf(def); + const section = sheet?.sections.find((s) => s.id === id); + if (!sheet || !section || sectionRemoveProblem(def, id)) return def; + return forgetFields(withSheet(def, { ...sheet, sections: sheet.sections.filter((s) => s.id !== id) }), section.fields.map((f) => f.id)); +}; + +// ─── FIELDS ───────────────────────────────────────────────────────────────── + +/** + * A new text field at the end of a section, its id from its first name and kept after. It never + * takes an id the system has (takenIds: the starter's fields, health, stats, formulas, tables and + * this sheet's own fields), so it never turns into one of those. + */ +export const withNewField = (def: Definition, sectionId: string, label = 'New field'): Definition => { + const id = idFor(label, takenIds(def)); + return withSections(def, (sections) => sections.map((s) => (s.id === sectionId && s.fields.length < LIMITS.perSection + ? { ...s, fields: [...s.fields, { id, label, type: 'text' }] } : s))); +}; + +/** Choices typed one per line (or comma-separated) as a pick-one field's options. */ +export const optionsFrom = (text: string): { value: string; label: string }[] => { + const seen = new Set(); + return text.split(/[\n,]/).map((o) => o.trim().slice(0, LIMITS.label)).filter((o) => o && !seen.has(o.toLowerCase()) && seen.add(o.toLowerCase())) + .slice(0, LIMITS.options).map((o) => ({ value: o, label: o })); +}; + +/** A pick-one field's choices as the page shows them, one per line. */ +export const optionsText = (field: Field): string => (field.options ?? []).map((o) => o.label).join('\n'); + +export interface FieldPatch { + label?: string; + type?: Field['type']; + /** Choices, one per line. */ + options?: string; + /** EVERYONE (true) or the owner and the GM (false). */ + everyone?: boolean; + /** Decides whether attacks hit: never shown to anyone else. */ + attack?: boolean; + /** Only the GM changes it (true) or the player (false). */ + gmOnly?: boolean; + hint?: string; +} + +/** + * A field changed; its id stays. A formula's kind and editing are fixed (it is worked out), as is + * a linked field's kind. An attack number is never EVERYONE. Blank text clears hints, and a blank + * name is not stored. + */ +export const withField = (def: Definition, id: string, patch: FieldPatch): Definition => { + const fixed = (f: Field) => fieldKind(def, f) === 'formula' || fieldKind(def, f) === 'linked'; + return withSections(def, (sections) => sections.map((s) => ({ + ...s, + fields: s.fields.map((f) => { + if (f.id !== id) return f; + const next: Field = { ...f }; + if (patch.label !== undefined && patch.label.trim()) next.label = patch.label.slice(0, LIMITS.label); + if (patch.type !== undefined && !fixed(f) && TYPES.some((t) => t.id === patch.type)) { + next.type = patch.type; + if (patch.type === 'select') { if (!next.options?.length) next.options = optionsFrom('Option 1\nOption 2'); } else delete next.options; + } + if (patch.options !== undefined && next.type === 'select') { + const options = optionsFrom(patch.options); + if (options.length) next.options = options; + } + if (patch.attack !== undefined) { + if (patch.attack) { next.sensitivity = 'combat'; delete next.visibility; } else delete next.sensitivity; + } + if (patch.everyone !== undefined) { + if (patch.everyone && next.sensitivity !== 'combat') next.visibility = 'public'; else delete next.visibility; + } + if (patch.gmOnly !== undefined && fieldKind(def, f) !== 'formula') { + if (patch.gmOnly) next.edit = 'gm'; else delete next.edit; + } + if (patch.hint !== undefined) { + if (patch.hint.trim()) next.hint = patch.hint.slice(0, LIMITS.hint); else delete next.hint; + } + return next; + }), + }))); +}; + +/** A field moved up (-1) or down (+1) within its section. */ +export const withFieldMoved = (def: Definition, id: string, dir: -1 | 1): Definition => withSections(def, (sections) => sections.map((s) => { + const i = s.fields.findIndex((f) => f.id === id); + return i < 0 ? s : { ...s, fields: swapped(s.fields, i, i + dir) }; +})); + +/** A field moved to the end of another section. */ +export const withFieldIn = (def: Definition, id: string, sectionId: string): Definition => { + const field = fieldsOn(sheetOf(def)).find((f) => f.id === id); + const to = sheetOf(def)?.sections.find((s) => s.id === sectionId); + if (!field || !to || to.fields.some((f) => f.id === id) || to.fields.length >= LIMITS.perSection) return def; + return withSections(def, (sections) => sections.map((s) => (s.id === sectionId + ? { ...s, fields: [...s.fields, field] } : { ...s, fields: s.fields.filter((f) => f.id !== id) }))); +}; + +/** + * A field taken off the sheet: a stat, formula or starter field back to the tray, a plain field of + * the sheet's own gone. The name stays (unchanged). + */ +export const withoutField = (def: Definition, id: string): Definition => { + if (id === NAME_FIELD) return def; + return forgetFields(withSections(def, (sections) => sections.map((s) => ({ ...s, fields: s.fields.filter((f) => f.id !== id) }))), [id]); +}; + +/** + * A field from the tray put at the end of a section, or of a new section when there is none to + * put it in (`sectionId` null, or a section that is gone). + */ +export const withPlaced = (def: Definition, field: Field, sectionId: string | null, tab: string | null = null): Definition => { + const sheet = sheetOf(def); + if (!sheet || fieldsOn(sheet).some((f) => f.id === field.id)) return def; + let next = def; + let target = sectionId && sheet.sections.some((s) => s.id === sectionId) ? sectionId : null; + if (!target) { + next = withNewSection(def, tab ?? sheet.tabs?.[0] ?? null); + const after = sheetOf(next)!.sections; + target = after[after.length - 1].id; + } + return withSections(next, (sections) => sections.map((s) => (s.id === target && s.fields.length < LIMITS.perSection + ? { ...s, fields: [...s.fields, { ...field }] } : s))); +}; + +/** References to fields that left: the header's, and a current value's max. */ +const forgetFields = (def: Definition, gone: string[]): Definition => { + const sheet = sheetOf(def); + if (!sheet || !gone.length) return def; + const left = new Set(gone); + const sections = sheet.sections.map((s) => ({ + ...s, + fields: s.fields.map((f) => { if (f.maxField && left.has(f.maxField)) { const { maxField: _m, ...rest } = f; return rest; } return f; }), + })); + if (!sheet.header) return withSheet(def, { ...sheet, sections }); + const h = { ...sheet.header }; + if (h.nameField && left.has(h.nameField)) delete h.nameField; + if (h.hpField && left.has(h.hpField)) { delete h.hpField; delete h.hpMaxField; } + if (h.hpMaxField && left.has(h.hpMaxField)) delete h.hpMaxField; + if (h.subtitleFields) { h.subtitleFields = h.subtitleFields.filter((f) => !left.has(f)); if (!h.subtitleFields.length) delete h.subtitleFields; } + if (h.chips) { h.chips = h.chips.filter((c) => !left.has(c.field)); if (!h.chips.length) delete h.chips; } + return withSheet(def, { ...sheet, sections, header: h }); +}; + +// ─── PROBLEMS ─────────────────────────────────────────────────────────────── + +/** The server's problems with the sheet, by the field or section they belong to ("sheet field str, label" is str's). */ +export const sheetProblems = (problems: { where: string; message: string }[]): { fields: Record; sections: Record; other: string[] } => { + const out = { fields: {} as Record, sections: {} as Record, other: [] as string[] }; + for (const p of problems) { + const field = /^sheet field ([a-z][a-z0-9_]*)(,|$)/.exec(p.where); + const section = /^sheet section ([a-z][a-z0-9_]*)(,|$)/.exec(p.where); + if (field) { if (!out.fields[field[1]]) out.fields[field[1]] = p.message; } else if (section) { if (!out.sections[section[1]]) out.sections[section[1]] = p.message; } else if (p.where.startsWith('sheet')) out.other.push(`${p.where}: ${p.message}`); + } + return out; +}; diff --git a/frontend/src/sheets/statsRules.ts b/frontend/src/sheets/statsRules.ts index 38e1cf14..4759fbe6 100644 --- a/frontend/src/sheets/statsRules.ts +++ b/frontend/src/sheets/statsRules.ts @@ -45,6 +45,11 @@ export const takenIds = (def: Definition): string[] => [ ...formulaList(def).map((f) => f.id), ...tableList(def).map((t) => t.id), ...FUNCTIONS, + // A customized sheet's own fields (4b3): a new stat or formula taking one's id would land in it. + ...(isObject(def.sheet) && Array.isArray((def.sheet as Obj).sections) + ? ((def.sheet as Obj).sections as unknown[]).flatMap((s) => (isObject(s) && Array.isArray(s.fields) ? s.fields : [])) + .filter((f): f is Obj => isObject(f) && typeof f.id === 'string').map((f) => f.id as string) + : []), ]; const withList = (def: Definition, key: 'stats' | 'derived', list: unknown[]): Definition => {