import { useCallback, useEffect, useMemo, useState } from 'react'; import type { CharacterSheet, SystemDefinition } from '../shared/types.ts'; import { FORMAT_VERSION, SYSTEM_FORMAT, CHARACTER_FORMAT } from '../shared/types.ts'; import { computeDerived } from '../shared/derived.ts'; import { validateCreationRules } from '../shared/creation.ts'; import { SheetRenderer } from './SheetRenderer.tsx'; import { DesignerWorkspace } from './DesignerWorkspace.tsx'; import { t, type Locale } from './i18n.ts'; type Tab = 'systems' | 'designer' | 'fill'; declare global { interface Window { ttrpgBuilder: { listSystems: () => Promise>; getSystem: (id: string) => Promise; upsertSystem: (system: SystemDefinition) => Promise; deleteSystem: (id: string) => Promise; exportSystem: (id: string) => Promise; importSystem: () => Promise; listPresets: () => Promise; loadPreset: (filePath: string) => Promise; seedPresets: () => Promise; listCharacters: (systemId?: string) => Promise>; getCharacter: (id: string) => Promise; upsertCharacter: (character: CharacterSheet) => Promise; deleteCharacter: (id: string) => Promise; }; } } function blankSystem(): SystemDefinition { return { format: SYSTEM_FORMAT, formatVersion: FORMAT_VERSION, id: `custom_${Date.now().toString(36)}`, name: 'Новая система', description: 'Система, созданная с нуля в визуальном редакторе.', edition: 'custom', schemaVersion: 1, revision: 1, bricks: { attributes: [ { key: 'str', label: 'STR', scale: 'numeric', min: 3, max: 18, default: 10, category: 'physical' }, { key: 'dex', label: 'DEX', scale: 'numeric', min: 3, max: 18, default: 10, category: 'physical' }, { key: 'con', label: 'CON', scale: 'numeric', min: 3, max: 18, default: 10, category: 'physical' }, { key: 'int', label: 'INT', scale: 'numeric', min: 3, max: 18, default: 10, category: 'mental' }, { key: 'wis', label: 'WIS', scale: 'numeric', min: 3, max: 18, default: 10, category: 'mental' }, { key: 'cha', label: 'CHA', scale: 'numeric', min: 3, max: 18, default: 10, category: 'social' }, ], skills: [], resources: [{ key: 'hp', label: 'HP', min: 0, default: 10, track: true }], derived: [ { key: 'strMod', label: 'STR Mod', formula: 'floor((attr.str - 10) / 2)', dependsOn: ['attr.str'], }, ], traits: [], }, creationRules: [ { id: 'pb', type: 'point_buy', appliesTo: 'attribute', params: { budget: 27, keys: ['str', 'dex', 'con', 'int', 'wis', 'cha'] }, }, ], sheetLayout: { pageSize: 'fluid', grid: { size: 8 }, pages: [ { id: 'main', label: 'Main', widgets: [ { id: 'sec', type: 'section', frame: { x: 16, y: 16, w: 240, h: 40 }, props: { title: 'Attributes' }, }, { id: 'w-str', type: 'number', bind: 'attr.str', frame: { x: 16, y: 72, w: 100, h: 72 }, props: { label: 'STR' }, }, { id: 'w-dex', type: 'number', bind: 'attr.dex', frame: { x: 128, y: 72, w: 100, h: 72 }, props: { label: 'DEX' }, }, { id: 'w-mod', type: 'modBadge', bind: 'derived.strMod', frame: { x: 240, y: 72, w: 100, h: 72 }, props: { label: 'STR Mod' }, }, { id: 'w-hp', type: 'track', bind: 'resource.hp', frame: { x: 16, y: 168, w: 240, h: 80 }, props: { label: 'HP', max: 20 }, }, ], }, ], }, }; } function defaultValues(system: SystemDefinition) { const attributes: Record = {}; for (const a of system.bricks.attributes ?? []) { attributes[a.key] = typeof a.default === 'number' ? a.default : 10; } const resources: Record = {}; for (const r of system.bricks.resources ?? []) { resources[r.key] = r.default ?? 0; } const skills: Record = {}; for (const s of system.bricks.skills ?? []) { skills[s.key] = typeof s.default === 'number' ? s.default : 0; } return { attributes, resources, skills, meta: {} as Record }; } export function App() { const [locale, setLocale] = useState('ru'); const [tab, setTab] = useState('systems'); const [systems, setSystems] = useState< Array<{ id: string; name: string; revision: number; schemaVersion: number }> >([]); const [current, setCurrent] = useState(null); const [characterName, setCharacterName] = useState('Герой'); const [values, setValues] = useState(defaultValues(blankSystem())); const [status, setStatus] = useState(''); const [booting, setBooting] = useState(true); const refresh = useCallback(async () => { if (!window.ttrpgBuilder) return; setSystems(await window.ttrpgBuilder.listSystems()); }, []); useEffect(() => { void (async () => { if (!window.ttrpgBuilder) { setBooting(false); return; } await window.ttrpgBuilder.seedPresets(); await refresh(); setBooting(false); setStatus(locale === 'ru' ? 'Пресеты готовы' : 'Presets ready'); })(); }, [refresh, locale]); const validation = useMemo(() => { if (!current) return null; const strategy = current.creationRules[0]?.type; return validateCreationRules(current, values, strategy); }, [current, values]); const derived = useMemo(() => { if (!current) return {}; try { return computeDerived(current.bricks.derived ?? [], values); } catch { return {}; } }, [current, values]); async function openSystem(id: string) { const s = await window.ttrpgBuilder.getSystem(id); if (!s) return; setCurrent(s); setValues(defaultValues(s)); setTab('designer'); setStatus(`${s.name}`); } async function saveSystem() { if (!current) return; const saved = await window.ttrpgBuilder.upsertSystem(current); setCurrent(saved); setStatus( locale === 'ru' ? `Сохранено: ${saved.name} (r${saved.revision})` : `Saved: ${saved.name} (r${saved.revision})`, ); await refresh(); } function onChangeValue(path: string, value: unknown) { setValues((prev) => { const next = { ...prev, attributes: { ...prev.attributes }, skills: { ...prev.skills }, resources: { ...prev.resources }, meta: { ...prev.meta }, }; const [scope, key] = path.split('.'); if (scope === 'attr' && key) next.attributes![key] = value as number | string | boolean; if (scope === 'skill' && key) next.skills![key] = value as number | string | boolean; if (scope === 'resource' && key) next.resources![key] = value as number; if (scope === 'meta' && key) (next.meta as Record)[key] = value; return next; }); } async function saveCharacter() { if (!current) return; const sheet: CharacterSheet = { format: CHARACTER_FORMAT, formatVersion: FORMAT_VERSION, id: `char_${Date.now().toString(36)}`, name: characterName, systemId: current.id, schemaVersion: current.schemaVersion, revisionAtCreate: current.revision, values: { ...values, derived }, creation: { strategy: current.creationRules[0]?.type ?? 'point_buy' }, }; if (validation && !validation.ok) { setStatus(t(locale, 'validationFail')); return; } await window.ttrpgBuilder.upsertCharacter(sheet); setStatus( locale === 'ru' ? `Персонаж сохранён: ${sheet.name}` : `Character saved: ${sheet.name}`, ); } function createFromScratch() { const s = blankSystem(); setCurrent(s); setValues(defaultValues(s)); setTab('designer'); setStatus(locale === 'ru' ? 'Новая система — сохраните, когда будете готовы' : 'New system — save when ready'); } return (
{tab === 'systems' && (

{locale === 'ru' ? 'Системы и пресеты' : 'Systems & presets'}

{locale === 'ru' ? 'Откройте любой из 10 пресетов в визуальном редакторе или создайте свою схему с нуля.' : 'Open any of the 10 presets in the visual editor, or create a schema from scratch.'}

{systems.length} / 10+
{booting &&
{locale === 'ru' ? 'Загрузка…' : 'Loading…'}
} {!booting && systems.length === 0 && (
{t(locale, 'noSystem')}
)} {systems.map((s) => (
{s.name}
{s.id} · r{s.revision}
))}
)} {tab === 'designer' && current && (
setCurrent({ ...current, name: e.target.value })} />
{ setCurrent(next); setValues(defaultValues(next)); }} />
)} {tab === 'fill' && current && (
setCharacterName(e.target.value)} placeholder={t(locale, 'characterName')} /> {validation && ( {validation.ok ? t(locale, 'validationOk') : `${t(locale, 'validationFail')}: ${validation.errors[0]?.message}`} )}
{t(locale, 'derived')}
{JSON.stringify(derived, null, 2)}
onChangeValue(path, value)} />
)}
); }