Initial TTRPG character meta-builder with visual sheet editor.

Offline Electron app seeds 10 system presets, supports create-from-scratch designer with palette/drag/resize/bricks, and exports .ttrpg-system.json for the player.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-12 14:49:31 +08:00
commit d1f180ee0a
44 changed files with 10548 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
import { app, BrowserWindow, dialog, ipcMain } from 'electron';
import fs from 'node:fs';
import path from 'node:path';
import type { CharacterSheet, SystemDefinition } from '../shared/types.ts';
import { AppStore } from './store.ts';
let mainWindow: BrowserWindow | null = null;
let store: AppStore;
function preloadPath(): string {
return path.join(__dirname, '../preload/index.cjs');
}
function presetsDirs(): string[] {
const candidates = [
path.join(app.getAppPath(), 'presets'),
path.join(process.cwd(), 'presets'),
path.join(__dirname, '../../presets'),
];
if (app.isPackaged) {
candidates.unshift(path.join(process.resourcesPath, 'presets'));
}
return candidates.filter((d) => fs.existsSync(d));
}
function listPresetFiles(): string[] {
const files: string[] = [];
for (const dir of presetsDirs()) {
for (const f of fs.readdirSync(dir)) {
if (f.endsWith('.json') && !f.includes('sample')) {
files.push(path.join(dir, f));
}
}
}
// unique by basename
const byName = new Map<string, string>();
for (const f of files) byName.set(path.basename(f), f);
return [...byName.values()].sort();
}
function seedPresetsIfEmpty(): number {
if (store.listSystems().length > 0) return 0;
let n = 0;
for (const file of listPresetFiles()) {
try {
const system = JSON.parse(fs.readFileSync(file, 'utf8')) as SystemDefinition;
store.upsertSystem(system);
n += 1;
} catch (e) {
console.error('Failed to seed preset', file, e);
}
}
return n;
}
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1480,
height: 920,
minWidth: 1100,
minHeight: 700,
backgroundColor: '#09090b',
title: 'TTRPG Character Builder',
webPreferences: {
preload: preloadPath(),
contextIsolation: true,
nodeIntegration: false,
},
});
const devUrl = process.env.VITE_DEV_SERVER_URL;
if (devUrl) {
void mainWindow.loadURL(devUrl);
} else {
void mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'));
}
}
function registerIpc(): void {
ipcMain.handle('systems:list', () => store.listSystems());
ipcMain.handle('systems:get', (_e, id: string) => store.getSystem(id));
ipcMain.handle('systems:upsert', (_e, system: SystemDefinition) => store.upsertSystem(system));
ipcMain.handle('systems:delete', (_e, id: string) => {
store.deleteSystem(id);
return true;
});
ipcMain.handle('characters:list', (_e, systemId?: string) => store.listCharacters(systemId));
ipcMain.handle('characters:get', (_e, id: string) => store.getCharacter(id));
ipcMain.handle('characters:upsert', (_e, character: CharacterSheet) => store.upsertCharacter(character));
ipcMain.handle('characters:delete', (_e, id: string) => {
store.deleteCharacter(id);
return true;
});
ipcMain.handle('systems:export', async (_e, id: string) => {
const system = store.getSystem(id);
if (!system || !mainWindow) return null;
const result = await dialog.showSaveDialog(mainWindow, {
title: 'Export system',
defaultPath: `${system.id}.ttrpg-system.json`,
filters: [{ name: 'TTRPG System', extensions: ['json'] }],
});
if (result.canceled || !result.filePath) return null;
fs.writeFileSync(result.filePath, JSON.stringify(system, null, 2), 'utf8');
return result.filePath;
});
ipcMain.handle('systems:import', async () => {
if (!mainWindow) return null;
const result = await dialog.showOpenDialog(mainWindow, {
title: 'Import system',
filters: [{ name: 'TTRPG System', extensions: ['json'] }],
properties: ['openFile'],
});
if (result.canceled || !result.filePaths[0]) return null;
const raw = fs.readFileSync(result.filePaths[0], 'utf8');
const system = JSON.parse(raw) as SystemDefinition;
return store.upsertSystem(system);
});
ipcMain.handle('presets:list', () => listPresetFiles());
ipcMain.handle('presets:load', (_e, filePath: string) => {
const raw = fs.readFileSync(filePath, 'utf8');
const system = JSON.parse(raw) as SystemDefinition;
return store.upsertSystem(system);
});
ipcMain.handle('presets:seed', () => {
// Force re-seed missing presets by id
let n = 0;
for (const file of listPresetFiles()) {
const system = JSON.parse(fs.readFileSync(file, 'utf8')) as SystemDefinition;
if (!store.getSystem(system.id)) {
store.upsertSystem(system);
n += 1;
}
}
if (store.listSystems().length === 0) n = seedPresetsIfEmpty();
return n;
});
}
app.whenReady().then(() => {
const dbPath = path.join(app.getPath('userData'), 'builder.sqlite');
store = new AppStore(dbPath);
const seeded = seedPresetsIfEmpty();
if (seeded) console.log(`Seeded ${seeded} presets`);
registerIpc();
createWindow();
});
app.on('window-all-closed', () => {
store?.close();
if (process.platform !== 'darwin') app.quit();
});
+137
View File
@@ -0,0 +1,137 @@
import fs from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import type { CharacterSheet, SystemDefinition } from '../shared/types.ts';
import { validateSystemDefinition } from '../shared/systemValidate.ts';
export class AppStore {
private readonly db: Database.Database;
constructor(dbPath: string) {
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
this.db = new Database(dbPath);
this.db.exec(`
CREATE TABLE IF NOT EXISTS systems (
id TEXT PRIMARY KEY,
revision INTEGER NOT NULL,
schema_version INTEGER NOT NULL,
name TEXT NOT NULL,
json TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS characters (
id TEXT PRIMARY KEY,
system_id TEXT NOT NULL,
name TEXT NOT NULL,
json TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`);
}
listSystems(): Array<{ id: string; name: string; revision: number; schemaVersion: number }> {
return this.db
.prepare('SELECT id, name, revision, schema_version AS schemaVersion FROM systems ORDER BY name')
.all() as Array<{ id: string; name: string; revision: number; schemaVersion: number }>;
}
getSystem(id: string): SystemDefinition | null {
const row = this.db.prepare('SELECT json FROM systems WHERE id = ?').get(id) as
| { json: string }
| undefined;
return row ? (JSON.parse(row.json) as SystemDefinition) : null;
}
upsertSystem(system: SystemDefinition): SystemDefinition {
const check = validateSystemDefinition(system);
if (!check.ok) {
throw new Error(check.errors.map((e) => e.message).join('; '));
}
const existing = this.getSystem(system.id);
const next: SystemDefinition = {
...system,
revision: existing ? existing.revision + 1 : system.revision || 1,
};
const now = new Date().toISOString();
this.db
.prepare(
`INSERT INTO systems (id, revision, schema_version, name, json, updated_at)
VALUES (@id, @revision, @schemaVersion, @name, @json, @updatedAt)
ON CONFLICT(id) DO UPDATE SET
revision=excluded.revision,
schema_version=excluded.schema_version,
name=excluded.name,
json=excluded.json,
updated_at=excluded.updated_at`,
)
.run({
id: next.id,
revision: next.revision,
schemaVersion: next.schemaVersion,
name: next.name,
json: JSON.stringify(next),
updatedAt: now,
});
return next;
}
deleteSystem(id: string): void {
const tx = this.db.transaction(() => {
this.db.prepare('DELETE FROM characters WHERE system_id = ?').run(id);
this.db.prepare('DELETE FROM systems WHERE id = ?').run(id);
});
tx();
}
listCharacters(systemId?: string): Array<{ id: string; name: string; systemId: string }> {
if (systemId) {
return this.db
.prepare(
'SELECT id, name, system_id AS systemId FROM characters WHERE system_id = ? ORDER BY name',
)
.all(systemId) as Array<{ id: string; name: string; systemId: string }>;
}
return this.db
.prepare('SELECT id, name, system_id AS systemId FROM characters ORDER BY name')
.all() as Array<{ id: string; name: string; systemId: string }>;
}
getCharacter(id: string): CharacterSheet | null {
const row = this.db.prepare('SELECT json FROM characters WHERE id = ?').get(id) as
| { json: string }
| undefined;
return row ? (JSON.parse(row.json) as CharacterSheet) : null;
}
upsertCharacter(character: CharacterSheet): CharacterSheet {
const now = new Date().toISOString();
this.db
.prepare(
`INSERT INTO characters (id, system_id, name, json, updated_at)
VALUES (@id, @systemId, @name, @json, @updatedAt)
ON CONFLICT(id) DO UPDATE SET
system_id=excluded.system_id,
name=excluded.name,
json=excluded.json,
updated_at=excluded.updated_at`,
)
.run({
id: character.id,
systemId: character.systemId,
name: character.name,
json: JSON.stringify(character),
updatedAt: now,
});
return character;
}
deleteCharacter(id: string): void {
this.db.prepare('DELETE FROM characters WHERE id = ?').run(id);
}
close(): void {
this.db.close();
}
}
+32
View File
@@ -0,0 +1,32 @@
import { contextBridge, ipcRenderer } from 'electron';
import type { CharacterSheet, SystemDefinition } from '../shared/types.ts';
const api = {
listSystems: () => ipcRenderer.invoke('systems:list') as Promise<
Array<{ id: string; name: string; revision: number; schemaVersion: number }>
>,
getSystem: (id: string) => ipcRenderer.invoke('systems:get', id) as Promise<SystemDefinition | null>,
upsertSystem: (system: SystemDefinition) =>
ipcRenderer.invoke('systems:upsert', system) as Promise<SystemDefinition>,
deleteSystem: (id: string) => ipcRenderer.invoke('systems:delete', id) as Promise<boolean>,
exportSystem: (id: string) => ipcRenderer.invoke('systems:export', id) as Promise<string | null>,
importSystem: () => ipcRenderer.invoke('systems:import') as Promise<SystemDefinition | null>,
listPresets: () => ipcRenderer.invoke('presets:list') as Promise<string[]>,
loadPreset: (filePath: string) =>
ipcRenderer.invoke('presets:load', filePath) as Promise<SystemDefinition>,
seedPresets: () => ipcRenderer.invoke('presets:seed') as Promise<number>,
listCharacters: (systemId?: string) =>
ipcRenderer.invoke('characters:list', systemId) as Promise<
Array<{ id: string; name: string; systemId: string }>
>,
getCharacter: (id: string) =>
ipcRenderer.invoke('characters:get', id) as Promise<CharacterSheet | null>,
upsertCharacter: (character: CharacterSheet) =>
ipcRenderer.invoke('characters:upsert', character) as Promise<CharacterSheet>,
deleteCharacter: (id: string) => ipcRenderer.invoke('characters:delete', id) as Promise<boolean>,
};
contextBridge.exposeInMainWorld('ttrpgBuilder', api);
export type TtrpgBuilderApi = typeof api;
+491
View File
@@ -0,0 +1,491 @@
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<Array<{ id: string; name: string; revision: number; schemaVersion: number }>>;
getSystem: (id: string) => Promise<SystemDefinition | null>;
upsertSystem: (system: SystemDefinition) => Promise<SystemDefinition>;
deleteSystem: (id: string) => Promise<boolean>;
exportSystem: (id: string) => Promise<string | null>;
importSystem: () => Promise<SystemDefinition | null>;
listPresets: () => Promise<string[]>;
loadPreset: (filePath: string) => Promise<SystemDefinition>;
seedPresets: () => Promise<number>;
listCharacters: (systemId?: string) => Promise<Array<{ id: string; name: string; systemId: string }>>;
getCharacter: (id: string) => Promise<CharacterSheet | null>;
upsertCharacter: (character: CharacterSheet) => Promise<CharacterSheet>;
deleteCharacter: (id: string) => Promise<boolean>;
};
}
}
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<string, number | string | boolean> = {};
for (const a of system.bricks.attributes ?? []) {
attributes[a.key] = typeof a.default === 'number' ? a.default : 10;
}
const resources: Record<string, number> = {};
for (const r of system.bricks.resources ?? []) {
resources[r.key] = r.default ?? 0;
}
const skills: Record<string, number | string | boolean> = {};
for (const s of system.bricks.skills ?? []) {
skills[s.key] = typeof s.default === 'number' ? s.default : 0;
}
return { attributes, resources, skills, meta: {} as Record<string, unknown> };
}
export function App() {
const [locale, setLocale] = useState<Locale>('ru');
const [tab, setTab] = useState<Tab>('systems');
const [systems, setSystems] = useState<
Array<{ id: string; name: string; revision: number; schemaVersion: number }>
>([]);
const [current, setCurrent] = useState<SystemDefinition | null>(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<string, unknown>)[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 (
<div className="appShell">
<aside className="sidebar">
<div className="brand" data-testid="brand">
<span>TTRPG</span> Builder
</div>
<div className="row">
<button
type="button"
data-testid="locale-ru"
className={locale === 'ru' ? 'primary' : ''}
onClick={() => setLocale('ru')}
>
RU
</button>
<button
type="button"
data-testid="locale-en"
className={locale === 'en' ? 'primary' : ''}
onClick={() => setLocale('en')}
>
EN
</button>
</div>
<button
type="button"
className={`navBtn${tab === 'systems' ? ' active' : ''}`}
data-testid="tab-systems"
onClick={() => setTab('systems')}
>
{t(locale, 'systems')}
</button>
<button
type="button"
className={`navBtn${tab === 'designer' ? ' active' : ''}`}
data-testid="tab-designer"
onClick={() => setTab('designer')}
disabled={!current}
>
{t(locale, 'designer')}
</button>
<button
type="button"
className={`navBtn${tab === 'fill' ? ' active' : ''}`}
data-testid="tab-fill"
onClick={() => setTab('fill')}
disabled={!current}
>
{t(locale, 'fill')}
</button>
<div className="muted statusLine" data-testid="status">
{booting ? (locale === 'ru' ? 'Загрузка…' : 'Loading…') : status}
</div>
</aside>
<main className="main">
{tab === 'systems' && (
<div>
<div className="heroPanel">
<h1>{locale === 'ru' ? 'Системы и пресеты' : 'Systems & presets'}</h1>
<p className="muted">
{locale === 'ru'
? 'Откройте любой из 10 пресетов в визуальном редакторе или создайте свою схему с нуля.'
: 'Open any of the 10 presets in the visual editor, or create a schema from scratch.'}
</p>
</div>
<div className="panel row">
<button
type="button"
className="primary"
data-testid="new-system"
onClick={createFromScratch}
>
{t(locale, 'newSystem')}
</button>
<button
type="button"
data-testid="import-system"
onClick={async () => {
const s = await window.ttrpgBuilder.importSystem();
if (s) {
setCurrent(s);
setValues(defaultValues(s));
await refresh();
setTab('designer');
}
}}
>
{t(locale, 'import')}
</button>
<button
type="button"
data-testid="load-presets"
onClick={async () => {
const n = await window.ttrpgBuilder.seedPresets();
const files = await window.ttrpgBuilder.listPresets();
for (const f of files) await window.ttrpgBuilder.loadPreset(f);
await refresh();
setStatus(
locale === 'ru'
? `Пресеты обновлены (${files.length}, новых ${n})`
: `Presets refreshed (${files.length}, new ${n})`,
);
}}
>
{t(locale, 'loadPresets')}
</button>
<button type="button" onClick={() => void refresh()}>
{t(locale, 'refresh')}
</button>
<span className="muted" data-testid="systems-count">
{systems.length} / 10+
</span>
</div>
<div className="panel systemsGrid">
{booting && <div className="muted">{locale === 'ru' ? 'Загрузка…' : 'Loading…'}</div>}
{!booting && systems.length === 0 && (
<div className="muted">{t(locale, 'noSystem')}</div>
)}
{systems.map((s) => (
<div key={s.id} className="systemCard" data-testid={`system-${s.id}`}>
<div>
<strong>{s.name}</strong>
<div className="muted">
{s.id} · r{s.revision}
</div>
</div>
<div className="row">
<button type="button" className="primary" onClick={() => void openSystem(s.id)}>
{locale === 'ru' ? 'Редактор' : 'Editor'}
</button>
<button
type="button"
onClick={async () => {
await openSystem(s.id);
setTab('fill');
}}
>
{locale === 'ru' ? 'Лист' : 'Sheet'}
</button>
<button type="button" onClick={() => void window.ttrpgBuilder.exportSystem(s.id)}>
{t(locale, 'export')}
</button>
<button
type="button"
onClick={async () => {
await window.ttrpgBuilder.deleteSystem(s.id);
if (current?.id === s.id) setCurrent(null);
await refresh();
}}
>
{t(locale, 'delete')}
</button>
</div>
</div>
))}
</div>
</div>
)}
{tab === 'designer' && current && (
<div>
<div className="panel row stickyBar">
<input
data-testid="system-name"
className="nameInput"
value={current.name}
onChange={(e) => setCurrent({ ...current, name: e.target.value })}
/>
<button
type="button"
className="primary"
data-testid="save-system"
onClick={() => void saveSystem()}
>
{t(locale, 'save')}
</button>
<button type="button" onClick={() => setTab('fill')}>
{t(locale, 'fill')}
</button>
</div>
<DesignerWorkspace
system={current}
locale={locale}
onChange={(next) => {
setCurrent(next);
setValues(defaultValues(next));
}}
/>
</div>
)}
{tab === 'fill' && current && (
<div>
<div className="panel row stickyBar">
<input
data-testid="character-name"
className="nameInput"
value={characterName}
onChange={(e) => setCharacterName(e.target.value)}
placeholder={t(locale, 'characterName')}
/>
<button
type="button"
className="primary"
data-testid="save-character"
onClick={() => void saveCharacter()}
>
{t(locale, 'createCharacter')}
</button>
<button type="button" onClick={() => setTab('designer')}>
{t(locale, 'designer')}
</button>
{validation && (
<span
className={validation.ok ? 'muted' : 'error'}
data-testid="validation-status"
>
{validation.ok
? t(locale, 'validationOk')
: `${t(locale, 'validationFail')}: ${validation.errors[0]?.message}`}
</span>
)}
</div>
<div className="panel">
<div className="muted">{t(locale, 'derived')}</div>
<pre data-testid="derived-json">{JSON.stringify(derived, null, 2)}</pre>
</div>
<SheetRenderer
system={current}
values={values}
mode="fill"
onChangeValue={(path, value) => onChangeValue(path, value)}
/>
</div>
)}
</main>
</div>
);
}
+388
View File
@@ -0,0 +1,388 @@
import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
import type { SheetWidget, SystemDefinition, WidgetType } from '../shared/types.ts';
import { WidgetPreview } from './WidgetPreview.tsx';
const PALETTE: Array<{ type: WidgetType; label: string; defaultBind?: string; w: number; h: number }> = [
{ type: 'section', label: 'Section', w: 220, h: 40 },
{ type: 'number', label: 'Number', defaultBind: 'attr.str', w: 110, h: 72 },
{ type: 'modBadge', label: 'Mod badge', defaultBind: 'derived.strMod', w: 110, h: 72 },
{ type: 'dots', label: 'Dots', defaultBind: 'attr.str', w: 160, h: 72 },
{ type: 'track', label: 'Track / HP', defaultBind: 'resource.hp', w: 220, h: 80 },
{ type: 'skillList', label: 'Skill list', w: 280, h: 240 },
{ type: 'notes', label: 'Notes', defaultBind: 'meta.notes', w: 280, h: 140 },
{ type: 'locationBar', label: 'Armor zones', w: 360, h: 90 },
{ type: 'text', label: 'Text', defaultBind: 'meta.title', w: 200, h: 60 },
];
type Props = {
system: SystemDefinition;
onChange: (next: SystemDefinition) => void;
locale: 'ru' | 'en';
};
function snap(n: number, grid = 8): number {
return Math.round(n / grid) * grid;
}
export function DesignerWorkspace({ system, onChange, locale }: Props) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [pageIndex, setPageIndex] = useState(0);
const dragRef = useRef<{
id: string;
mode: 'move' | 'resize';
startX: number;
startY: number;
orig: { x: number; y: number; w: number; h: number };
} | null>(null);
const page = system.sheetLayout.pages[pageIndex] ?? system.sheetLayout.pages[0];
const selected = page?.widgets.find((w) => w.id === selectedId) ?? null;
function updateWidgets(mutator: (widgets: SheetWidget[]) => SheetWidget[]) {
if (!page) return;
const pages = system.sheetLayout.pages.map((p, i) =>
i === pageIndex ? { ...p, widgets: mutator(p.widgets) } : p,
);
onChange({ ...system, sheetLayout: { ...system.sheetLayout, pages } });
}
function addWidget(item: (typeof PALETTE)[number]) {
if (!page) return;
const id = `w_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
const widget: SheetWidget = {
id,
type: item.type,
bind: item.defaultBind,
frame: {
x: snap(24 + (page.widgets.length % 5) * 16),
y: snap(24 + (page.widgets.length % 7) * 16),
w: item.w,
h: item.h,
},
props: {
label: item.label,
title: item.label,
max: item.type === 'dots' ? 5 : 20,
},
};
updateWidgets((ws) => [...ws, widget]);
setSelectedId(id);
}
function onPointerDownWidget(e: ReactPointerEvent, id: string, mode: 'move' | 'resize') {
e.stopPropagation();
e.preventDefault();
const w = page?.widgets.find((x) => x.id === id);
if (!w) return;
setSelectedId(id);
dragRef.current = {
id,
mode,
startX: e.clientX,
startY: e.clientY,
orig: { ...w.frame },
};
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
}
function onPointerMove(e: ReactPointerEvent) {
const d = dragRef.current;
if (!d) return;
const dx = e.clientX - d.startX;
const dy = e.clientY - d.startY;
updateWidgets((ws) =>
ws.map((w) => {
if (w.id !== d.id) return w;
if (d.mode === 'move') {
return {
...w,
frame: {
...w.frame,
x: snap(Math.max(0, d.orig.x + dx)),
y: snap(Math.max(0, d.orig.y + dy)),
},
};
}
return {
...w,
frame: {
...w.frame,
w: snap(Math.max(48, d.orig.w + dx)),
h: snap(Math.max(36, d.orig.h + dy)),
},
};
}),
);
}
function onPointerUp() {
dragRef.current = null;
}
function patchSelected(patch: Partial<SheetWidget> & { props?: Record<string, unknown> }) {
if (!selected) return;
updateWidgets((ws) =>
ws.map((w) =>
w.id === selected.id
? {
...w,
...patch,
props: { ...w.props, ...patch.props },
}
: w,
),
);
}
function removeSelected() {
if (!selected) return;
updateWidgets((ws) => ws.filter((w) => w.id !== selected.id));
setSelectedId(null);
}
function addBrick(kind: 'attributes' | 'skills' | 'resources' | 'derived') {
const key = `new_${Date.now().toString(36).slice(-4)}`;
const bricks = { ...system.bricks };
if (kind === 'attributes') {
bricks.attributes = [
...(bricks.attributes ?? []),
{ key, label: key.toUpperCase(), scale: 'numeric', min: 1, max: 20, default: 10 },
];
} else if (kind === 'skills') {
bricks.skills = [
...(bricks.skills ?? []),
{ key, label: key, scale: 'numeric', default: 0 },
];
} else if (kind === 'resources') {
bricks.resources = [
...(bricks.resources ?? []),
{ key, label: key, min: 0, default: 0, track: true },
];
} else {
bricks.derived = [
...(bricks.derived ?? []),
{
key,
label: key,
formula: 'floor((attr.str - 10) / 2)',
dependsOn: ['attr.str'],
},
];
}
onChange({ ...system, bricks });
}
function removeBrick(kind: 'attributes' | 'skills' | 'resources' | 'derived', key: string) {
const bricks = { ...system.bricks };
if (kind === 'attributes') bricks.attributes = (bricks.attributes ?? []).filter((a) => a.key !== key);
if (kind === 'skills') bricks.skills = (bricks.skills ?? []).filter((a) => a.key !== key);
if (kind === 'resources') bricks.resources = (bricks.resources ?? []).filter((a) => a.key !== key);
if (kind === 'derived') bricks.derived = (bricks.derived ?? []).filter((a) => a.key !== key);
onChange({ ...system, bricks });
}
const bindOptions: string[] = [];
for (const a of system.bricks.attributes ?? []) bindOptions.push(`attr.${a.key}`);
for (const s of system.bricks.skills ?? []) bindOptions.push(`skill.${s.key}`);
for (const r of system.bricks.resources ?? []) bindOptions.push(`resource.${r.key}`);
for (const d of system.bricks.derived ?? []) bindOptions.push(`derived.${d.key}`);
bindOptions.push('meta.notes', 'meta.title');
return (
<div className="designerGrid" data-testid="designer-workspace">
<div className="panel designerSide">
<div className="sideTitle">{locale === 'ru' ? 'Палитра' : 'Palette'}</div>
<div className="palette">
{PALETTE.map((item) => (
<button
key={item.type + item.label}
type="button"
className="paletteItem"
data-testid={`palette-${item.type}`}
onClick={() => addWidget(item)}
>
{item.label}
</button>
))}
</div>
<div className="sideTitle">{locale === 'ru' ? 'Кирпичики' : 'Bricks'}</div>
<div className="row wrap">
<button type="button" onClick={() => addBrick('attributes')}>+ Attr</button>
<button type="button" onClick={() => addBrick('skills')}>+ Skill</button>
<button type="button" onClick={() => addBrick('resources')}>+ Resource</button>
<button type="button" onClick={() => addBrick('derived')}>+ Derived</button>
</div>
<div className="brickList">
{(system.bricks.attributes ?? []).map((a) => (
<div key={a.key} className="brickRow">
<code>attr.{a.key}</code>
<button type="button" onClick={() => removeBrick('attributes', a.key)}>×</button>
</div>
))}
{(system.bricks.skills ?? []).map((a) => (
<div key={a.key} className="brickRow">
<code>skill.{a.key}</code>
<button type="button" onClick={() => removeBrick('skills', a.key)}>×</button>
</div>
))}
{(system.bricks.resources ?? []).map((a) => (
<div key={a.key} className="brickRow">
<code>resource.{a.key}</code>
<button type="button" onClick={() => removeBrick('resources', a.key)}>×</button>
</div>
))}
{(system.bricks.derived ?? []).map((a) => (
<div key={a.key} className="brickRow">
<code>derived.{a.key}</code>
<button type="button" onClick={() => removeBrick('derived', a.key)}>×</button>
</div>
))}
</div>
</div>
<div
className="designerCanvasHost"
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onClick={() => setSelectedId(null)}
>
<div className="row" style={{ marginBottom: 8 }}>
{system.sheetLayout.pages.map((p, i) => (
<button
key={p.id}
type="button"
className={i === pageIndex ? 'primary' : ''}
onClick={(e) => {
e.stopPropagation();
setPageIndex(i);
}}
>
{p.label}
</button>
))}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const id = `page_${Date.now().toString(36)}`;
onChange({
...system,
sheetLayout: {
...system.sheetLayout,
pages: [
...system.sheetLayout.pages,
{ id, label: `Page ${system.sheetLayout.pages.length + 1}`, widgets: [] },
],
},
});
setPageIndex(system.sheetLayout.pages.length);
}}
>
+ Page
</button>
</div>
<div className="sheetCanvas designMode" data-testid="sheet-canvas">
{(page?.widgets ?? []).map((w) => (
<div
key={w.id}
className={`widget${selectedId === w.id ? ' selected' : ''}`}
data-testid={`widget-${w.id}`}
style={{ left: w.frame.x, top: w.frame.y, width: w.frame.w, height: w.frame.h }}
onClick={(e) => {
e.stopPropagation();
setSelectedId(w.id);
}}
onPointerDown={(e) => onPointerDownWidget(e, w.id, 'move')}
>
<div className="widgetChrome">{w.type}{w.bind ? ` · ${w.bind}` : ''}</div>
<div className="widgetBody">
<WidgetPreview widget={w} system={system} />
</div>
<div
className="resizeHandle"
onPointerDown={(e) => onPointerDownWidget(e, w.id, 'resize')}
/>
</div>
))}
</div>
</div>
<div className="panel designerSide">
<div className="sideTitle">{locale === 'ru' ? 'Свойства' : 'Properties'}</div>
{!selected ? (
<div className="muted">{locale === 'ru' ? 'Выберите виджет' : 'Select a widget'}</div>
) : (
<div className="propsForm">
<label>
Type
<input value={selected.type} disabled />
</label>
<label>
Bind
<select
data-testid="widget-bind"
value={selected.bind ?? ''}
onChange={(e) => patchSelected({ bind: e.target.value || undefined })}
>
<option value="">—</option>
{bindOptions.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</label>
<label>
Label
<input
value={String(selected.props?.label ?? selected.props?.title ?? '')}
onChange={(e) => patchSelected({ props: { label: e.target.value, title: e.target.value } })}
/>
</label>
<label>
Max
<input
type="number"
value={Number(selected.props?.max ?? 0)}
onChange={(e) => patchSelected({ props: { max: Number(e.target.value) } })}
/>
</label>
<button type="button" data-testid="remove-widget" onClick={removeSelected}>
{locale === 'ru' ? 'Удалить виджет' : 'Remove widget'}
</button>
</div>
)}
<div className="sideTitle" style={{ marginTop: 16 }}>
System
</div>
<label className="propsForm">
ID
<input
data-testid="system-id"
value={system.id}
onChange={(e) => onChange({ ...system, id: e.target.value.trim() || system.id })}
/>
</label>
<label className="propsForm">
Edition
<input
value={system.edition ?? ''}
onChange={(e) => onChange({ ...system, edition: e.target.value })}
/>
</label>
<label className="propsForm">
Description
<textarea
rows={4}
value={system.description ?? ''}
onChange={(e) => onChange({ ...system, description: e.target.value })}
/>
</label>
</div>
</div>
);
}
+216
View File
@@ -0,0 +1,216 @@
import type { SystemDefinition, CharacterValues, SheetWidget } from '../shared/types.ts';
import { computeDerived } from '../shared/derived.ts';
type Props = {
system: SystemDefinition;
values: CharacterValues;
mode: 'design' | 'fill' | 'preview';
selectedWidgetId?: string | null;
onSelectWidget?: (id: string) => void;
onChangeValue?: (path: string, value: unknown) => void;
};
function widgetContent(
widget: SheetWidget,
system: SystemDefinition,
values: CharacterValues,
mode: Props['mode'],
onChangeValue?: Props['onChangeValue'],
) {
const bind = widget.bind ?? '';
const [scope, key] = bind.split('.');
const editable = mode === 'fill';
if (widget.type === 'section') {
return <strong>{String(widget.props?.title ?? widget.id)}</strong>;
}
if (widget.type === 'modBadge' || widget.type === 'number' || bind.startsWith('derived.')) {
const derived = values.derived ?? {};
let display: string | number = '—';
if (scope === 'attr') display = (values.attributes?.[key!] as number) ?? '—';
if (scope === 'resource') display = values.resources?.[key!] ?? '—';
if (scope === 'derived') display = derived[key!] ?? '—';
if (editable && scope === 'attr' && typeof display === 'number') {
return (
<label>
<div className="muted">{key}</div>
<input
data-testid={`field-${bind}`}
type="number"
value={display}
onChange={(e) => onChangeValue?.(bind, Number(e.target.value))}
/>
</label>
);
}
return (
<div data-testid={`field-${bind}`}>
<div className="muted">{String(widget.props?.label ?? key ?? widget.type)}</div>
<div style={{ fontSize: 22, fontWeight: 700 }}>{display}</div>
</div>
);
}
if (widget.type === 'dots') {
const max = Number(widget.props?.max ?? 5);
const current =
scope === 'attr'
? Number(values.attributes?.[key!] ?? 0)
: scope === 'skill'
? Number(values.skills?.[key!] ?? 0)
: 0;
return (
<div>
<div className="muted">{String(widget.props?.label ?? key)}</div>
<div className="row" data-testid={`field-${bind}`}>
{Array.from({ length: max }, (_, i) => (
<button
key={i}
type="button"
disabled={!editable}
style={{
width: 18,
height: 18,
borderRadius: '50%',
padding: 0,
background: i < current ? 'var(--accent2)' : 'transparent',
}}
onClick={() => onChangeValue?.(bind, i + 1 === current ? i : i + 1)}
/>
))}
</div>
</div>
);
}
if (widget.type === 'track' || widget.type === 'resourceBar') {
const v = values.resources?.[key!] ?? 0;
const max = Number(widget.props?.max ?? 10);
return (
<label>
<div className="muted">{String(widget.props?.label ?? key)}</div>
<input
data-testid={`field-${bind}`}
type="number"
disabled={!editable}
min={0}
max={max}
value={v}
onChange={(e) => onChangeValue?.(bind, Number(e.target.value))}
/>
<div
style={{
marginTop: 6,
height: 8,
background: 'var(--stroke)',
borderRadius: 4,
overflow: 'hidden',
}}
>
<div
style={{
width: `${Math.min(100, (v / max) * 100)}%`,
height: '100%',
background: 'var(--accent)',
}}
/>
</div>
</label>
);
}
if (widget.type === 'skillList') {
const skills = system.bricks.skills ?? [];
return (
<div data-testid="skill-list">
{skills.map((sk) => (
<div key={sk.key} className="row" style={{ justifyContent: 'space-between' }}>
<span>{sk.label}</span>
<input
data-testid={`field-skill.${sk.key}`}
type="number"
disabled={!editable}
style={{ width: 72 }}
value={Number(values.skills?.[sk.key] ?? sk.default ?? 0)}
onChange={(e) => onChangeValue?.(`skill.${sk.key}`, Number(e.target.value))}
/>
</div>
))}
</div>
);
}
if (widget.type === 'notes' || widget.type === 'text') {
const metaKey = key || 'notes';
const text = String(values.meta?.[metaKey] ?? '');
return (
<textarea
data-testid={`field-meta.${metaKey}`}
disabled={!editable}
value={text}
style={{ width: '100%', height: '100%', resize: 'none' }}
onChange={(e) => onChangeValue?.(`meta.${metaKey}`, e.target.value)}
/>
);
}
if (widget.type === 'locationBar') {
const zones = (widget.props?.zones as string[]) ?? ['head', 'body', 'arms', 'legs'];
return (
<div className="row" data-testid="location-bar">
{zones.map((z) => (
<label key={z}>
<div className="muted">{z}</div>
<input
type="number"
disabled={!editable}
style={{ width: 64 }}
value={Number((values.meta?.armor as Record<string, number> | undefined)?.[z] ?? 0)}
onChange={(e) => {
const armor = {
...((values.meta?.armor as Record<string, number>) ?? {}),
[z]: Number(e.target.value),
};
onChangeValue?.('meta.armor', armor);
}}
/>
</label>
))}
</div>
);
}
return <div className="muted">{widget.type}</div>;
}
export function SheetRenderer({
system,
values,
mode,
selectedWidgetId,
onSelectWidget,
onChangeValue,
}: Props) {
const page = system.sheetLayout.pages[0];
const derived = computeDerived(system.bricks.derived ?? [], values);
const merged: CharacterValues = { ...values, derived };
if (!page) return <div className="muted">No pages</div>;
return (
<div className="sheetCanvas" data-testid="sheet-canvas">
{page.widgets.map((w) => (
<div
key={w.id}
className={`widget${selectedWidgetId === w.id ? ' selected' : ''}`}
data-testid={`widget-${w.id}`}
style={{ left: w.frame.x, top: w.frame.y, width: w.frame.w, height: w.frame.h }}
onClick={() => onSelectWidget?.(w.id)}
>
{widgetContent(w, system, merged, mode, onChangeValue)}
</div>
))}
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
import type { CharacterValues, SheetWidget, SystemDefinition } from '../shared/types.ts';
import { computeDerived } from '../shared/derived.ts';
type Props = {
widget: SheetWidget;
system: SystemDefinition;
};
/** Compact in-widget preview for design mode (no nested canvas). */
export function WidgetPreview({ widget, system }: Props) {
const values: CharacterValues = {
attributes: Object.fromEntries(
(system.bricks.attributes ?? []).map((a) => [a.key, typeof a.default === 'number' ? a.default : 10]),
),
skills: Object.fromEntries(
(system.bricks.skills ?? []).map((a) => [a.key, typeof a.default === 'number' ? a.default : 0]),
),
resources: Object.fromEntries((system.bricks.resources ?? []).map((a) => [a.key, a.default ?? 0])),
meta: { notes: '…', title: system.name },
};
const derived = computeDerived(system.bricks.derived ?? [], values);
const bind = widget.bind ?? '';
const [, key] = bind.split('.');
if (widget.type === 'section') {
return <strong>{String(widget.props?.title ?? 'Section')}</strong>;
}
if (widget.type === 'dots') {
const max = Number(widget.props?.max ?? 5);
const cur = Number(values.attributes?.[key!] ?? 2);
return (
<div>
<div className="muted">{String(widget.props?.label ?? key)}</div>
<div className="row">
{Array.from({ length: max }, (_, i) => (
<span
key={i}
style={{
width: 12,
height: 12,
borderRadius: '50%',
border: '1px solid var(--stroke2)',
background: i < cur ? 'var(--accent2)' : 'transparent',
}}
/>
))}
</div>
</div>
);
}
if (widget.type === 'track' || widget.type === 'resourceBar') {
const v = Number(values.resources?.[key!] ?? 5);
const max = Number(widget.props?.max ?? 10);
return (
<div>
<div className="muted">{String(widget.props?.label ?? key)}</div>
<div style={{ height: 8, background: 'var(--stroke)', borderRadius: 4 }}>
<div style={{ width: `${(v / max) * 100}%`, height: '100%', background: 'var(--accent)' }} />
</div>
</div>
);
}
if (widget.type === 'skillList') {
return (
<div className="muted">
{(system.bricks.skills ?? []).slice(0, 4).map((s) => (
<div key={s.key}>
{s.label}: {String(values.skills?.[s.key] ?? 0)}
</div>
))}
…
</div>
);
}
if (widget.type === 'notes' || widget.type === 'text') {
return <div className="muted">{String(values.meta?.notes ?? 'Notes')}</div>;
}
if (bind.startsWith('derived.')) {
return (
<div>
<div className="muted">{String(widget.props?.label ?? key)}</div>
<div style={{ fontSize: 20, fontWeight: 700 }}>{derived[key!] ?? 0}</div>
</div>
);
}
if (bind.startsWith('attr.')) {
return (
<div>
<div className="muted">{String(widget.props?.label ?? key)}</div>
<div style={{ fontSize: 20, fontWeight: 700 }}>{String(values.attributes?.[key!] ?? 10)}</div>
</div>
);
}
return <div className="muted">{widget.type}</div>;
}
+52
View File
@@ -0,0 +1,52 @@
export type Locale = 'ru' | 'en';
const messages = {
en: {
brand: 'TTRPG Character Builder',
systems: 'Systems',
characters: 'Characters',
designer: 'Sheet designer',
fill: 'Fill character',
import: 'Import',
export: 'Export',
loadPresets: 'Load presets',
newSystem: 'New system',
save: 'Save',
delete: 'Delete',
refresh: 'Refresh',
noSystem: 'Select or create a system',
attributes: 'Attributes',
derived: 'Derived',
validationOk: 'Validation passed',
validationFail: 'Validation failed',
createCharacter: 'Create character',
characterName: 'Character name',
},
ru: {
brand: 'Конструктор персонажей НРИ',
systems: 'Системы',
characters: 'Персонажи',
designer: 'Дизайнер листа',
fill: 'Заполнение',
import: 'Импорт',
export: 'Экспорт',
loadPresets: 'Загрузить пресеты',
newSystem: 'Новая система',
save: 'Сохранить',
delete: 'Удалить',
refresh: 'Обновить',
noSystem: 'Выберите или создайте систему',
attributes: 'Атрибуты',
derived: 'Производные',
validationOk: 'Валидация пройдена',
validationFail: 'Ошибки валидации',
createCharacter: 'Создать персонажа',
characterName: 'Имя персонажа',
},
} as const;
export type MessageKey = keyof typeof messages.en;
export function t(locale: Locale, key: MessageKey): string {
return messages[locale][key] ?? messages.en[key];
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TTRPG Character Builder</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
import { createRoot } from 'react-dom/client';
import { App } from './App.tsx';
import './styles.css';
createRoot(document.getElementById('root')!).render(<App />);
+188
View File
@@ -0,0 +1,188 @@
:root {
--bg0: #09090b;
--color-panel: rgba(255, 255, 255, 0.04);
--color-panel-2: rgba(255, 255, 255, 0.06);
--stroke: rgba(255, 255, 255, 0.08);
--stroke2: rgba(255, 255, 255, 0.12);
--text0: rgba(255, 255, 255, 0.92);
--text1: rgba(255, 255, 255, 0.72);
--text2: rgba(255, 255, 255, 0.52);
--accent: #7c3aed;
--accent2: #a78bfa;
--accent-border: rgba(124, 58, 237, 0.55);
--accent-fill-solid: rgba(124, 58, 237, 0.92);
--accent-fill-soft: rgba(124, 58, 237, 0.1);
--color-danger: rgba(248, 113, 113, 0.95);
--radius-sm: 8px;
--radius-md: 12px;
--shadow: 0 24px 60px rgba(0, 0, 0, 0.55);
font-family: 'Segoe UI', 'IBM Plex Sans', system-ui, sans-serif;
color: var(--text0);
background: var(--bg0);
}
* { box-sizing: border-box; }
html, body, #root { margin: 0; height: 100%; }
button {
height: 34px;
padding: 0 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--color-panel);
color: var(--text0);
cursor: pointer;
}
button.primary {
border: 1px solid var(--accent-border);
background: var(--accent-fill-solid);
}
button:disabled { opacity: 0.45; cursor: not-allowed; }
input, select, textarea {
background: var(--color-panel-2);
border: 1px solid var(--stroke);
border-radius: var(--radius-sm);
color: var(--text0);
padding: 8px 10px;
width: 100%;
}
.appShell {
display: grid;
grid-template-columns: 260px 1fr;
height: 100%;
}
.sidebar {
border-right: 1px solid var(--stroke);
background: #18181b;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.brand { font-size: 18px; font-weight: 700; }
.brand span { color: var(--accent2); }
.navBtn { text-align: left; width: 100%; }
.navBtn.active {
border-color: var(--accent-border);
background: var(--accent-fill-soft);
}
.statusLine { margin-top: auto; font-size: 12px; min-height: 2.5em; }
.main { padding: 20px; overflow: auto; }
.panel {
background: var(--color-panel);
border: 1px solid var(--stroke);
border-radius: var(--radius-md);
padding: 16px;
margin-bottom: 16px;
}
.heroPanel {
margin-bottom: 16px;
}
.heroPanel h1 {
margin: 0 0 8px;
font-size: 28px;
font-weight: 700;
}
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.row.wrap { flex-wrap: wrap; }
.muted { color: var(--text2); }
.error { color: var(--color-danger); font-size: 13px; }
.systemsGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 12px;
}
.systemCard {
border: 1px solid var(--stroke);
border-radius: var(--radius-md);
background: var(--color-panel-2);
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.nameInput { max-width: 360px; }
.stickyBar { position: sticky; top: 0; z-index: 5; backdrop-filter: blur(8px); }
.sheetCanvas {
position: relative;
min-height: 720px;
background:
linear-gradient(var(--stroke) 1px, transparent 1px) 0 0 / 16px 16px,
linear-gradient(90deg, var(--stroke) 1px, transparent 1px) 0 0 / 16px 16px,
#0c0c10;
border: 1px solid var(--stroke2);
border-radius: var(--radius-md);
overflow: hidden;
}
.widget {
position: absolute;
border: 1px solid var(--stroke2);
background: rgba(10, 10, 14, 0.94);
border-radius: 8px;
padding: 8px;
overflow: hidden;
touch-action: none;
user-select: none;
}
.widget.selected {
border-color: var(--accent2);
box-shadow: 0 0 0 1px var(--accent-border);
}
.widgetChrome {
font-size: 10px;
color: var(--text2);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.widgetBody { pointer-events: none; }
.resizeHandle {
position: absolute;
right: 2px;
bottom: 2px;
width: 14px;
height: 14px;
border-radius: 3px;
background: var(--accent-fill-soft);
border: 1px solid var(--accent-border);
cursor: nwse-resize;
pointer-events: auto;
}
.designerGrid {
display: grid;
grid-template-columns: 220px 1fr 240px;
gap: 12px;
min-height: calc(100vh - 120px);
}
.designerSide {
max-height: calc(100vh - 120px);
overflow: auto;
}
.sideTitle {
font-weight: 700;
margin: 8px 0;
color: var(--text1);
}
.palette { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.paletteItem { width: 100%; text-align: left; }
.brickList { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
.brickRow {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
padding: 4px 6px;
border: 1px solid var(--stroke);
border-radius: 6px;
}
.brickRow button { height: 24px; padding: 0 8px; }
.propsForm { display: flex; flex-direction: column; gap: 10px; }
.propsForm label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--text1); }
.designerCanvasHost { min-width: 0; }
+171
View File
@@ -0,0 +1,171 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { evaluateFormula, buildFormulaContext } from './formula.ts';
import { computeDerived, sortDerived, CycleError } from './derived.ts';
import { validatePointBuy, validateArray, rollDice, sum, validateCreationRules } from './creation.ts';
import { snapshotHash, canonicalJson } from './snapshot.ts';
import { validateSystemDefinition } from './systemValidate.ts';
import type { SystemDefinition } from './types.ts';
import { FORMAT_VERSION, SYSTEM_FORMAT } from './types.ts';
test('evaluateFormula: D&D ability modifier', () => {
const ctx = { 'attr.str': 16 };
assert.equal(evaluateFormula('floor((attr.str - 10) / 2)', ctx), 3);
assert.equal(evaluateFormula('(attr.str - 10) / 2', ctx, 'floor'), 3);
});
test('evaluateFormula: clamp min max', () => {
assert.equal(evaluateFormula('clamp(attr.str, 1, 20)', { 'attr.str': 25 }), 20);
});
test('computeDerived topo order', () => {
const derived = computeDerived(
[
{
key: 'strMod',
label: 'STR mod',
formula: 'floor((attr.str - 10) / 2)',
dependsOn: ['attr.str'],
round: 'none',
},
{
key: 'carry',
label: 'Carry',
formula: 'attr.str * 15',
dependsOn: ['attr.str'],
},
],
{ attributes: { str: 12 } },
);
assert.equal(derived.strMod, 1);
assert.equal(derived.carry, 180);
});
test('sortDerived detects cycles', () => {
assert.throws(
() =>
sortDerived([
{ key: 'a', label: 'a', formula: 'derived.b', dependsOn: ['derived.b'] },
{ key: 'b', label: 'b', formula: 'derived.a', dependsOn: ['derived.a'] },
]),
CycleError,
);
});
test('point buy budget', () => {
const rule = {
id: 'pb',
type: 'point_buy' as const,
appliesTo: 'attribute' as const,
params: {
budget: 27,
keys: ['str', 'dex', 'con', 'int', 'wis', 'cha'],
},
};
const ok = validatePointBuy(rule, { str: 15, dex: 14, con: 13, int: 12, wis: 10, cha: 8 });
assert.equal(ok.length, 0);
const bad = validatePointBuy(rule, { str: 15, dex: 15, con: 15, int: 15, wis: 15, cha: 15 });
assert.ok(bad.some((e) => e.code === 'point_buy_budget'));
});
test('array permutation', () => {
const rule = {
id: 'arr',
type: 'array' as const,
appliesTo: 'attribute' as const,
params: { array: [15, 14, 13, 12, 10, 8], keys: ['str', 'dex', 'con', 'int', 'wis', 'cha'] },
};
assert.equal(
validateArray(rule, { str: 15, dex: 14, con: 13, int: 12, wis: 10, cha: 8 }).length,
0,
);
assert.ok(validateArray(rule, { str: 15, dex: 15, con: 13, int: 12, wis: 10, cha: 8 }).length > 0);
});
test('rollDice 4d6dl', () => {
let i = 0;
const seq = [0.0, 0.9, 0.5, 0.2]; // -> 1,6,4,2 drop 1 => 6+4+2=12
const rolls = rollDice('4d6dl', () => seq[i++] ?? 0);
assert.equal(sum(rolls), 12);
});
test('snapshotHash stable', () => {
const system = minimalSystem();
const a = snapshotHash(system);
const b = snapshotHash({ ...system, name: 'Other display name' });
// name is not part of hash payload — hash equal
assert.equal(a, b);
const c = snapshotHash({ ...system, revision: 2 });
assert.notEqual(a, c);
});
test('canonicalJson sorts keys', () => {
assert.equal(canonicalJson({ b: 1, a: 2 }), '{"a":2,"b":1}');
});
test('validateSystemDefinition', () => {
const r = validateSystemDefinition(minimalSystem());
assert.equal(r.ok, true);
});
test('validateCreationRules integrates point_buy', () => {
const system = minimalSystem();
const result = validateCreationRules(
system,
{ attributes: { str: 15, dex: 14, con: 13, int: 12, wis: 10, cha: 8 } },
'point_buy',
);
assert.equal(result.ok, true);
});
test('buildFormulaContext', () => {
const ctx = buildFormulaContext({ attributes: { str: 10 }, resources: { hp: 8 } });
assert.equal(ctx['attr.str'], 10);
assert.equal(ctx['resource.hp'], 8);
});
function minimalSystem(): SystemDefinition {
return {
format: SYSTEM_FORMAT,
formatVersion: FORMAT_VERSION,
id: 'test',
name: 'Test',
schemaVersion: 1,
revision: 1,
bricks: {
attributes: [
{ key: 'str', label: 'STR', scale: 'numeric', min: 8, max: 15, default: 10 },
{ key: 'dex', label: 'DEX', scale: 'numeric', min: 8, max: 15, default: 10 },
{ key: 'con', label: 'CON', scale: 'numeric', min: 8, max: 15, default: 10 },
{ key: 'int', label: 'INT', scale: 'numeric', min: 8, max: 15, default: 10 },
{ key: 'wis', label: 'WIS', scale: 'numeric', min: 8, max: 15, default: 10 },
{ key: 'cha', label: 'CHA', scale: 'numeric', min: 8, max: 15, default: 10 },
],
derived: [
{
key: 'strMod',
label: 'STR Mod',
formula: 'floor((attr.str - 10) / 2)',
dependsOn: ['attr.str'],
},
],
},
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: [] }],
},
};
}
+143
View File
@@ -0,0 +1,143 @@
import type {
CharacterValues,
CreationRule,
SystemDefinition,
ValidationIssue,
ValidationResult,
} from './types.ts';
function err(path: string, message: string, code: string): ValidationIssue {
return { path, message, code };
}
export function validatePointBuy(
rule: CreationRule,
attributes: Record<string, number | string | boolean>,
): ValidationIssue[] {
const budget = Number(rule.params.budget ?? 27);
const costs = (rule.params.costs as Record<string, number> | undefined) ?? {
'8': 0,
'9': 1,
'10': 2,
'11': 3,
'12': 4,
'13': 5,
'14': 7,
'15': 9,
};
const keys = (rule.params.keys as string[] | undefined) ?? Object.keys(attributes);
let spent = 0;
const issues: ValidationIssue[] = [];
for (const key of keys) {
const raw = attributes[key];
if (typeof raw !== 'number') {
issues.push(err(`attributes.${key}`, 'Point buy requires numeric attribute', 'point_buy_type'));
continue;
}
const c = costs[String(raw)];
if (c === undefined) {
issues.push(err(`attributes.${key}`, `Value ${raw} is not in point-buy cost table`, 'point_buy_value'));
continue;
}
spent += c;
}
if (spent > budget) {
issues.push(err('creation.point_buy', `Spent ${spent} exceeds budget ${budget}`, 'point_buy_budget'));
}
return issues;
}
export function validateRoll(
rule: CreationRule,
values: CharacterValues,
): ValidationIssue[] {
const issues: ValidationIssue[] = [];
const keys = (rule.params.keys as string[] | undefined) ?? [];
const min = Number(rule.params.min ?? 3);
const max = Number(rule.params.max ?? 18);
for (const key of keys) {
const v = values.attributes?.[key];
if (typeof v !== 'number') {
issues.push(err(`attributes.${key}`, 'Roll strategy expects a number', 'roll_type'));
continue;
}
if (v < min || v > max) {
issues.push(err(`attributes.${key}`, `Value ${v} out of roll range ${min}-${max}`, 'roll_range'));
}
}
return issues;
}
export function validateArray(
rule: CreationRule,
attributes: Record<string, number | string | boolean>,
): ValidationIssue[] {
const array = (rule.params.array as number[] | undefined) ?? [15, 14, 13, 12, 10, 8];
const keys = (rule.params.keys as string[] | undefined) ?? Object.keys(attributes);
const used = keys.map((k) => attributes[k]).filter((v): v is number => typeof v === 'number');
const sortedUsed = [...used].sort((a, b) => b - a);
const sortedArray = [...array].sort((a, b) => b - a);
if (sortedUsed.length !== sortedArray.length || sortedUsed.some((v, i) => v !== sortedArray[i])) {
return [err('creation.array', 'Attributes must be a permutation of the fixed array', 'array_mismatch')];
}
return [];
}
export function validateCreationRules(
system: SystemDefinition,
values: CharacterValues,
activeStrategy?: string,
): ValidationResult {
const errors: ValidationIssue[] = [];
const rules = activeStrategy
? system.creationRules.filter((r) => r.type === activeStrategy)
: system.creationRules;
for (const rule of rules) {
if (rule.type === 'point_buy') errors.push(...validatePointBuy(rule, values.attributes ?? {}));
if (rule.type === 'roll') errors.push(...validateRoll(rule, values));
if (rule.type === 'array') errors.push(...validateArray(rule, values.attributes ?? {}));
}
for (const attr of system.bricks.attributes ?? []) {
const v = values.attributes?.[attr.key];
if (v === undefined) continue;
if (typeof v === 'number') {
if (attr.min !== undefined && v < attr.min) {
errors.push(err(`attributes.${attr.key}`, `Below min ${attr.min}`, 'attr_min'));
}
if (attr.max !== undefined && v > attr.max) {
errors.push(err(`attributes.${attr.key}`, `Above max ${attr.max}`, 'attr_max'));
}
}
}
return { ok: errors.length === 0, errors };
}
/** Deterministic dice roller for tests / roll strategy. */
export function rollDice(expr: string, rng: () => number = Math.random): number[] {
// formats: NdM, NdMdl (drop lowest), NdMdh (drop highest)
const m = /^(\d+)d(\d+)(dl|dh)?$/i.exec(expr.trim());
if (!m) throw new Error(`Unsupported dice expression '${expr}'`);
const n = Number(m[1]);
const sides = Number(m[2]);
const drop = m[3]?.toLowerCase();
const rolls: number[] = [];
for (let i = 0; i < n; i += 1) {
rolls.push(1 + Math.floor(rng() * sides));
}
if (drop === 'dl') {
const idx = rolls.indexOf(Math.min(...rolls));
return rolls.filter((_, i) => i !== idx);
}
if (drop === 'dh') {
const idx = rolls.indexOf(Math.max(...rolls));
return rolls.filter((_, i) => i !== idx);
}
return rolls;
}
export function sum(nums: number[]): number {
return nums.reduce((a, b) => a + b, 0);
}
+66
View File
@@ -0,0 +1,66 @@
import type { DerivedDefinition } from './types.ts';
import { buildFormulaContext, evaluateFormula } from './formula.ts';
export class CycleError extends Error {
constructor(message: string) {
super(message);
this.name = 'CycleError';
}
}
/** Kahn topological sort of derived definitions by dependsOn keys (derived.* or attr.* etc.). */
export function sortDerived(defs: DerivedDefinition[]): DerivedDefinition[] {
const byKey = new Map(defs.map((d) => [d.key, d]));
const indeg = new Map<string, number>();
const adj = new Map<string, string[]>();
for (const d of defs) {
indeg.set(d.key, 0);
adj.set(d.key, []);
}
for (const d of defs) {
for (const dep of d.dependsOn) {
const depKey = dep.startsWith('derived.') ? dep.slice('derived.'.length) : null;
if (depKey && byKey.has(depKey)) {
adj.get(depKey)!.push(d.key);
indeg.set(d.key, (indeg.get(d.key) ?? 0) + 1);
}
}
}
const queue = [...indeg.entries()].filter(([, n]) => n === 0).map(([k]) => k);
const ordered: DerivedDefinition[] = [];
while (queue.length) {
const k = queue.shift()!;
const def = byKey.get(k);
if (def) ordered.push(def);
for (const next of adj.get(k) ?? []) {
const n = (indeg.get(next) ?? 0) - 1;
indeg.set(next, n);
if (n === 0) queue.push(next);
}
}
if (ordered.length !== defs.length) {
throw new CycleError('Derived definitions contain a cycle');
}
return ordered;
}
export function computeDerived(
defs: DerivedDefinition[],
values: {
attributes?: Record<string, number | string | boolean>;
skills?: Record<string, number | string | boolean>;
resources?: Record<string, number>;
},
): Record<string, number> {
const ordered = sortDerived(defs);
const derived: Record<string, number> = {};
for (const d of ordered) {
const ctx = buildFormulaContext({ ...values, derived });
derived[d.key] = evaluateFormula(d.formula, ctx, d.round ?? 'none');
}
return derived;
}
+187
View File
@@ -0,0 +1,187 @@
/** Minimal safe formula DSL for derived stats. */
export type FormulaRound = 'floor' | 'ceil' | 'round' | 'none';
export type FormulaContext = Record<string, number>;
const IDENT = /^(attr|skill|resource|derived|val)\.[A-Za-z_][A-Za-z0-9_]*$/;
function tokenize(input: string): string[] {
const src = input.replace(/\s+/g, '');
const tokens: string[] = [];
let i = 0;
while (i < src.length) {
const ch = src[i]!;
if ('+-*/%(),'.includes(ch)) {
tokens.push(ch);
i += 1;
continue;
}
if (/[0-9.]/.test(ch)) {
let j = i + 1;
while (j < src.length && /[0-9.]/.test(src[j]!)) j += 1;
tokens.push(src.slice(i, j));
i = j;
continue;
}
if (/[A-Za-z_]/.test(ch)) {
let j = i + 1;
while (j < src.length && /[A-Za-z0-9_.]/.test(src[j]!)) j += 1;
tokens.push(src.slice(i, j));
i = j;
continue;
}
throw new Error(`Unexpected character '${ch}' in formula`);
}
return tokens;
}
type Parser = {
tokens: string[];
i: number;
ctx: FormulaContext;
};
function peek(p: Parser): string | undefined {
return p.tokens[p.i];
}
function consume(p: Parser, expected?: string): string {
const t = p.tokens[p.i];
if (t === undefined) throw new Error('Unexpected end of formula');
if (expected !== undefined && t !== expected) throw new Error(`Expected '${expected}', got '${t}'`);
p.i += 1;
return t;
}
function parseExpression(p: Parser): number {
let left = parseTerm(p);
while (peek(p) === '+' || peek(p) === '-') {
const op = consume(p);
const right = parseTerm(p);
left = op === '+' ? left + right : left - right;
}
return left;
}
function parseTerm(p: Parser): number {
let left = parseUnary(p);
while (peek(p) === '*' || peek(p) === '/' || peek(p) === '%') {
const op = consume(p);
const right = parseUnary(p);
if (op === '*') left *= right;
else if (op === '/') left /= right;
else left %= right;
}
return left;
}
function parseUnary(p: Parser): number {
if (peek(p) === '-') {
consume(p);
return -parseUnary(p);
}
if (peek(p) === '+') {
consume(p);
return parseUnary(p);
}
return parsePrimary(p);
}
function parsePrimary(p: Parser): number {
const t = peek(p);
if (t === undefined) throw new Error('Unexpected end of formula');
if (t === '(') {
consume(p, '(');
const v = parseExpression(p);
consume(p, ')');
return v;
}
if (/^[0-9]+(\.[0-9]+)?$/.test(t)) {
consume(p);
return Number(t);
}
if (IDENT.test(t)) {
consume(p);
const v = p.ctx[t];
if (typeof v !== 'number' || Number.isNaN(v)) {
throw new Error(`Unknown or non-numeric identifier '${t}'`);
}
return v;
}
// function call
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(t) && p.tokens[p.i + 1] === '(') {
const name = consume(p);
consume(p, '(');
const args: number[] = [];
if (peek(p) !== ')') {
args.push(parseExpression(p));
while (peek(p) === ',') {
consume(p, ',');
args.push(parseExpression(p));
}
}
consume(p, ')');
return callFn(name, args);
}
throw new Error(`Unexpected token '${t}'`);
}
function callFn(name: string, args: number[]): number {
switch (name) {
case 'floor':
return Math.floor(args[0] ?? 0);
case 'ceil':
return Math.ceil(args[0] ?? 0);
case 'round':
return Math.round(args[0] ?? 0);
case 'abs':
return Math.abs(args[0] ?? 0);
case 'min':
return Math.min(...args);
case 'max':
return Math.max(...args);
case 'clamp': {
const [v = 0, lo = 0, hi = 0] = args;
return Math.min(hi, Math.max(lo, v));
}
default:
throw new Error(`Unknown function '${name}'`);
}
}
export function evaluateFormula(
formula: string,
ctx: FormulaContext,
round: FormulaRound = 'none',
): number {
const p: Parser = { tokens: tokenize(formula), i: 0, ctx };
const value = parseExpression(p);
if (p.i !== p.tokens.length) throw new Error('Trailing tokens in formula');
if (round === 'floor') return Math.floor(value);
if (round === 'ceil') return Math.ceil(value);
if (round === 'round') return Math.round(value);
return value;
}
export function buildFormulaContext(values: {
attributes?: Record<string, number | string | boolean>;
skills?: Record<string, number | string | boolean>;
resources?: Record<string, number>;
derived?: Record<string, number>;
}): FormulaContext {
const ctx: FormulaContext = {};
for (const [k, v] of Object.entries(values.attributes ?? {})) {
if (typeof v === 'number') ctx[`attr.${k}`] = v;
}
for (const [k, v] of Object.entries(values.skills ?? {})) {
if (typeof v === 'number') ctx[`skill.${k}`] = v;
}
for (const [k, v] of Object.entries(values.resources ?? {})) {
ctx[`resource.${k}`] = v;
}
for (const [k, v] of Object.entries(values.derived ?? {})) {
ctx[`derived.${k}`] = v;
}
return ctx;
}
+6
View File
@@ -0,0 +1,6 @@
export * from './types.ts';
export * from './formula.ts';
export * from './derived.ts';
export * from './creation.ts';
export * from './snapshot.ts';
export * from './systemValidate.ts';
+34
View File
@@ -0,0 +1,34 @@
import { createHash } from 'node:crypto';
import type { SystemDefinition } from './types.ts';
/** Canonical JSON for stable hashing (sorted keys). */
export function canonicalJson(value: unknown): string {
return JSON.stringify(sortKeys(value));
}
function sortKeys(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortKeys);
if (value && typeof value === 'object') {
const obj = value as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const key of Object.keys(obj).sort()) {
out[key] = sortKeys(obj[key]);
}
return out;
}
return value;
}
export function snapshotHash(system: SystemDefinition): string {
const payload = {
id: system.id,
schemaVersion: system.schemaVersion,
revision: system.revision,
bricks: system.bricks,
creationRules: system.creationRules,
sheetLayout: system.sheetLayout,
compendium: system.compendium,
};
return createHash('sha256').update(canonicalJson(payload)).digest('hex');
}
+40
View File
@@ -0,0 +1,40 @@
import type { SystemDefinition, ValidationIssue, ValidationResult } from './types.ts';
import { SYSTEM_FORMAT, FORMAT_VERSION } from './types.ts';
function err(path: string, message: string, code: string): ValidationIssue {
return { path, message, code };
}
export function validateSystemDefinition(system: unknown): ValidationResult {
const errors: ValidationIssue[] = [];
if (!system || typeof system !== 'object') {
return { ok: false, errors: [err('$', 'System must be an object', 'type')] };
}
const s = system as Partial<SystemDefinition>;
if (s.format !== SYSTEM_FORMAT) errors.push(err('format', `Expected ${SYSTEM_FORMAT}`, 'format'));
if (s.formatVersion !== FORMAT_VERSION) {
errors.push(err('formatVersion', `Expected ${FORMAT_VERSION}`, 'formatVersion'));
}
if (!s.id || typeof s.id !== 'string') errors.push(err('id', 'id required', 'required'));
if (!s.name || typeof s.name !== 'string') errors.push(err('name', 'name required', 'required'));
if (typeof s.schemaVersion !== 'number') errors.push(err('schemaVersion', 'number required', 'type'));
if (typeof s.revision !== 'number') errors.push(err('revision', 'number required', 'type'));
if (!s.bricks || typeof s.bricks !== 'object') errors.push(err('bricks', 'bricks required', 'required'));
if (!Array.isArray(s.creationRules)) errors.push(err('creationRules', 'array required', 'type'));
if (!s.sheetLayout || !Array.isArray(s.sheetLayout.pages)) {
errors.push(err('sheetLayout.pages', 'pages required', 'required'));
}
const derived = s.bricks?.derived ?? [];
const keys = new Set(derived.map((d) => d.key));
for (const d of derived) {
if (!d.formula) errors.push(err(`derived.${d.key}`, 'formula required', 'required'));
for (const dep of d.dependsOn ?? []) {
if (dep.startsWith('derived.') && !keys.has(dep.slice('derived.'.length))) {
// allow forward refs; cycle checked elsewhere
}
}
}
return { ok: errors.length === 0, errors };
}
+223
View File
@@ -0,0 +1,223 @@
/** Portable TTRPG system / character contract (builder + player + future mobile). */
export const SYSTEM_FORMAT = 'ttrpg-system' as const;
export const CHARACTER_FORMAT = 'ttrpg-character' as const;
export const FORMAT_VERSION = 1 as const;
export type ScaleKind = 'numeric' | 'dice' | 'percent' | 'rank' | 'pool' | 'boolean';
export type AttributeDefinition = {
key: string;
label: string;
scale: ScaleKind;
min?: number;
max?: number;
default?: number | string | boolean;
category?: 'physical' | 'mental' | 'social' | 'other';
costPerPoint?: number;
};
export type SkillDefinition = {
key: string;
label: string;
linkedAttribute?: string;
scale: ScaleKind;
min?: number;
max?: number;
default?: number | string | boolean;
};
export type ResourceDefinition = {
key: string;
label: string;
min?: number;
max?: number | string;
default?: number;
track?: boolean;
};
export type TraitDefinition = {
key: string;
label: string;
description?: string;
tags?: string[];
prerequisites?: string[];
};
export type DerivedDefinition = {
key: string;
label: string;
formula: string;
dependsOn: string[];
round?: 'floor' | 'ceil' | 'round' | 'none';
};
export type ProgressionNode = {
id: string;
label: string;
kind: 'career' | 'feat' | 'talent' | 'other';
traitKey?: string;
position?: { x: number; y: number };
};
export type ProgressionEdge = {
id: string;
source: string;
target: string;
kind?: 'prerequisite' | 'exit' | 'unlock';
};
export type ProgressionDefinition = {
label?: string;
graph: {
nodes: ProgressionNode[];
edges: ProgressionEdge[];
};
};
export type CreationStrategy =
| 'point_buy'
| 'roll'
| 'array'
| 'priority'
| 'life_path'
| 'funnel';
export type CreationRule = {
id: string;
type: CreationStrategy;
appliesTo: 'attribute' | 'skill' | 'any';
params: Record<string, unknown>;
};
export type WidgetType =
| 'text'
| 'number'
| 'modBadge'
| 'skillRow'
| 'skillList'
| 'resourceBar'
| 'checkbox'
| 'tagList'
| 'section'
| 'portrait'
| 'notes'
| 'table'
| 'repeatingTable'
| 'statBlock'
| 'dots'
| 'percent'
| 'rankEnum'
| 'track'
| 'clock'
| 'slotMatrix'
| 'diceStep'
| 'locationBar'
| 'spellCards'
| 'unknownWidget';
export type WidgetFrame = { x: number; y: number; w: number; h: number };
export type SheetWidget = {
id: string;
type: WidgetType;
bind?: string;
frame: WidgetFrame;
responsive?: { sm?: Partial<WidgetFrame> & { hide?: boolean; stack?: boolean } };
props?: Record<string, unknown>;
};
export type SheetPage = {
id: string;
label: string;
widgets: SheetWidget[];
};
export type SheetLayout = {
pageSize: 'letter' | 'a4' | 'fluid' | { w: number; h: number };
grid: { size: number; columns?: number };
pages: SheetPage[];
};
export type CompendiumSpell = {
id: string;
name: string;
level: number;
school?: string;
source?: string;
classes?: string[];
castingTime?: string;
range?: string;
components?: string;
duration?: string;
description?: string;
tags?: string[];
};
export type SystemBricks = {
attributes?: AttributeDefinition[];
skills?: SkillDefinition[];
resources?: ResourceDefinition[];
traits?: TraitDefinition[];
derived?: DerivedDefinition[];
progression?: ProgressionDefinition;
constraints?: Record<string, unknown>[];
};
export type SystemDefinition = {
format: typeof SYSTEM_FORMAT;
formatVersion: typeof FORMAT_VERSION;
id: string;
name: string;
description?: string;
edition?: string;
schemaVersion: number;
revision: number;
bricks: SystemBricks;
creationRules: CreationRule[];
sheetLayout: SheetLayout;
compendium?: {
spells?: CompendiumSpell[];
};
theme?: { tokenOverrides?: Record<string, string> };
};
export type CharacterValues = {
meta?: Record<string, unknown>;
attributes?: Record<string, number | string | boolean>;
skills?: Record<string, number | string | boolean>;
resources?: Record<string, number>;
traits?: string[];
ownedTraitIds?: string[];
spells?: Array<{ id: string; prepared?: boolean; notes?: string }>;
inventory?: Array<Record<string, unknown>>;
derived?: Record<string, number>;
[key: string]: unknown;
};
export type CharacterSheet = {
format: typeof CHARACTER_FORMAT;
formatVersion: typeof FORMAT_VERSION;
id: string;
name: string;
systemId: string;
schemaVersion: number;
revisionAtCreate: number;
values: CharacterValues;
creation?: {
strategy?: CreationStrategy;
seed?: number;
rolls?: unknown;
};
};
export type ValidationIssue = {
path: string;
message: string;
code: string;
};
export type ValidationResult = {
ok: boolean;
errors: ValidationIssue[];
};