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:
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App.tsx';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<App />);
|
||||
@@ -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; }
|
||||
@@ -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: [] }],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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[];
|
||||
};
|
||||
Reference in New Issue
Block a user