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
+13
View File
@@ -0,0 +1,13 @@
node_modules/
dist/
release/
.cursor/
*.log
.DS_Store
Thumbs.db
coverage/
playwright-report/
test-results/
*.db
.env
.env.*
+17
View File
@@ -0,0 +1,17 @@
# TTRPG Character Builder — Project
## Vision
Offline Electron meta-builder for TTRPG system schemas and character sheets, with export into TTRPG Player (`meta_builder` branch only).
## Constraints
- Electron offline; same pack targets as TTRPG Player
- Player changes only on git branch `meta_builder` (never merge to main in this workstream)
- Portable `app/shared` contract for player + future mobile
- Functional tests + e2e (not smoke-only)
- Depth B sheets; 10 presets; spell compendium UI; React Flow progression; no ICE combat / hit-location roller
## Current status
Scaffold live: shared engine + SQLite builder + UI shell + 10 preset files + player GameSystemsStore/IPC/modal.
+11
View File
@@ -0,0 +1,11 @@
# Roadmap
1. [x] Scaffold Electron/React/Vite + shared core + tests
2. [x] SQLite + IPC + basic Admin/Designer/Fill UI
3. [x] 10 preset scaffolds + preset smoke
4. [x] Player `meta_builder`: GameSystems store/IPC/settings modal
5. [ ] Visual designer DnD polish + React Flow progression editor
6. [ ] Spell compendium CRUD + dnd.su PHB import script
7. [ ] Player: system field on players + sheet fill window + persist
8. [ ] Full functional e2e (Playwright Electron) builder + player
9. [ ] Deepen presets to depth B (inventory, spells UI, armor SVG)
+6
View File
@@ -0,0 +1,6 @@
# State
- Branch builder: local git master (new repo)
- Player branch: `meta_builder`
- GSD installer: requires Node >=24 and may need `GSD_ALLOW_SYMLINKED_DEST=1` on this machine
- Next: React Flow editor, player sheet window, e2e suite
+27
View File
@@ -0,0 +1,27 @@
# TTRPG Character Builder
Offline Electron meta-builder for TTRPG system schemas and character sheets.
Designed to export `.ttrpg-system.json` for import into **TTRPG Player** (`meta_builder` branch).
## Stack
- Electron 41 + React 19 + TypeScript + Vite
- SQLite (`better-sqlite3`)
- Shared portable contract in `app/shared` (formula DSL, creation rules, snapshots)
## Scripts
```bash
npm install
npm test
npm run dev
npm run build
```
## Presets
`presets/` — start with Shadowdark + D&D 5e SRD scaffold. More systems follow the roadmap.
## Legal
Fan mechanical scaffolds. D&D spell text may be imported from third-party aggregators at the product owner's responsibility — see `docs/adr/`.
+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[];
};
+39
View File
@@ -0,0 +1,39 @@
# ADR-001: Stack and storage
## Decision
Electron + React + TypeScript + Vite; SQLite with JSON documents for systems/characters; portable `app/shared` contract for future TTRPG Player + mobile.
## Rationale
Matches TTRPG Player packaging targets; offline-first; no Postgres.
# ADR-002: Formula DSL
## Decision
Custom minimal expression language (`floor`, `ceil`, `round`, `min`, `max`, `clamp`, arithmetic, `attr.*` / `skill.*` / `resource.*` / `derived.*`).
## Rationale
Same evaluator can be copied into the player; explicit rounding; no heavy CEL/JSONata dependency.
# ADR-003: Schema freeze via snapshotHash
## Decision
Player binds store `snapshotHash` (content-addressed) rather than inline full schema per character.
## Rationale
Implements freeze-on-bind without bloating `players.json`.
# ADR-004: Spells from dnd.su
## Decision
Provide an import pipeline for PHB-labelled spells from dnd.su at product owner request, with README/IP disclaimer. Prefer SRD stubs in default presets.
## Rationale
Owner requested PHB coverage; redistribution risk acknowledged.
+27
View File
@@ -0,0 +1,27 @@
# Testing
## Layers
- **Unit** (`tsx --test`): `app/shared/*` — formula, derived, creation, snapshot, system validate
- **Integration**: SQLite store (builder), GameSystemsStore (player)
- **E2E** (Playwright Electron): functional asserts — not smoke-only (see plan)
## Commands
```bash
# builder
npm test
npm run test:e2e # when e2e suite is added
# player (meta_builder)
npx tsx --test app/main/gameSystems/gameSystemsStore.test.ts
npx tsx --test app/shared/characterSheet/core.test.ts
```
## Preset smoke
```bash
node scripts/preset-smoke.mjs
```
Validates every `presets/*.json` (except samples) via `validateSystemDefinition` + sample derived compute.
+5896
View File
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
{
"name": "ttrpg-character-builder",
"version": "0.1.0",
"description": "Offline meta-builder for TTRPG character system schemas and sheets",
"main": "dist/main/index.cjs",
"type": "module",
"scripts": {
"dev": "node scripts/dev.mjs",
"build": "node scripts/build.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "tsx --test app/shared/core.test.ts",
"test:presets": "tsx scripts/preset-smoke.mjs",
"sync:player": "node scripts/sync-to-player.mjs",
"test:e2e": "playwright test",
"pack:win": "npm run build && electron-builder --win",
"pack:mac": "npm run build && electron-builder --mac",
"pack:linux": "npm run build && electron-builder --linux"
},
"author": "TTRPG",
"license": "UNLICENSED",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"better-sqlite3": "^11.10.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"reactflow": "^11.11.4"
},
"devDependencies": {
"@playwright/test": "^1.62.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"electron": "^41.2.0",
"electron-builder": "^26.8.1",
"esbuild": "^0.28.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^8.0.8"
},
"build": {
"appId": "com.ttrpg.characterbuilder",
"productName": "TTRPGCharacterBuilder",
"directories": {
"output": "release",
"buildResources": "build"
},
"files": [
"dist/**/*",
"package.json"
],
"extraResources": [
{
"from": "presets",
"to": "presets"
}
],
"asar": true,
"mac": {
"target": [
{ "target": "dmg", "arch": ["x64", "arm64"] },
{ "target": "zip", "arch": ["x64", "arm64"] }
]
},
"win": {
"target": [
{ "target": "nsis", "arch": ["x64"] },
{ "target": "zip", "arch": ["x64"] }
]
},
"linux": {
"target": [
{ "target": "AppImage", "arch": ["x64", "arm64"] }
],
"category": "Game"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
}
}
}
+177
View File
@@ -0,0 +1,177 @@
{
"format": "ttrpg-system",
"formatVersion": 1,
"id": "alien",
"name": "Alien RPG",
"description": "Year Zero stress and career. Fan mechanical scaffold.",
"edition": "fan",
"schemaVersion": 1,
"revision": 1,
"bricks": {
"attributes": [
{
"key": "str",
"label": "STR",
"scale": "numeric",
"min": 1,
"max": 10,
"default": 2
},
{
"key": "dex",
"label": "DEX",
"scale": "numeric",
"min": 1,
"max": 10,
"default": 2
},
{
"key": "mind",
"label": "Mind",
"scale": "numeric",
"min": 1,
"max": 10,
"default": 2
}
],
"resources": [
{
"key": "hp",
"label": "HP",
"min": 0,
"default": 10,
"track": true
}
],
"derived": [
{
"key": "strMod",
"label": "STR Mod",
"formula": "attr.str",
"dependsOn": [
"attr.str"
]
}
],
"progression": {
"label": "Features",
"graph": {
"nodes": [
{
"id": "n1",
"label": "Start",
"kind": "feat",
"position": {
"x": 0,
"y": 0
}
},
{
"id": "n2",
"label": "Advance",
"kind": "feat",
"position": {
"x": 200,
"y": 0
}
}
],
"edges": [
{
"id": "e1",
"source": "n1",
"target": "n2",
"kind": "prerequisite"
}
]
}
}
},
"creationRules": [
{
"id": "arr",
"type": "array",
"appliesTo": "attribute",
"params": {
"array": [
3,
2,
2
],
"keys": [
"str",
"dex",
"mind"
]
}
}
],
"sheetLayout": {
"pageSize": "fluid",
"grid": {
"size": 8
},
"pages": [
{
"id": "main",
"label": "Main",
"widgets": [
{
"id": "t",
"type": "section",
"frame": {
"x": 16,
"y": 16,
"w": 280,
"h": 36
},
"props": {
"title": "Alien RPG"
}
},
{
"id": "str",
"type": "dots",
"bind": "attr.str",
"frame": {
"x": 16,
"y": 64,
"w": 160,
"h": 70
},
"props": {
"label": "STR",
"max": 5
}
},
{
"id": "hp",
"type": "track",
"bind": "resource.hp",
"frame": {
"x": 200,
"y": 64,
"w": 200,
"h": 80
},
"props": {
"label": "HP",
"max": 20
}
},
{
"id": "notes",
"type": "notes",
"bind": "meta.notes",
"frame": {
"x": 16,
"y": 160,
"w": 380,
"h": 120
}
}
]
}
]
}
}
+177
View File
@@ -0,0 +1,177 @@
{
"format": "ttrpg-system",
"formatVersion": 1,
"id": "bitd",
"name": "Blades in the Dark",
"description": "Action ratings, stress, harm, clocks. Fan mechanical scaffold.",
"edition": "fan",
"schemaVersion": 1,
"revision": 1,
"bricks": {
"attributes": [
{
"key": "str",
"label": "STR",
"scale": "numeric",
"min": 1,
"max": 10,
"default": 2
},
{
"key": "dex",
"label": "DEX",
"scale": "numeric",
"min": 1,
"max": 10,
"default": 2
},
{
"key": "mind",
"label": "Mind",
"scale": "numeric",
"min": 1,
"max": 10,
"default": 2
}
],
"resources": [
{
"key": "hp",
"label": "HP",
"min": 0,
"default": 10,
"track": true
}
],
"derived": [
{
"key": "strMod",
"label": "STR Mod",
"formula": "attr.str",
"dependsOn": [
"attr.str"
]
}
],
"progression": {
"label": "Features",
"graph": {
"nodes": [
{
"id": "n1",
"label": "Start",
"kind": "feat",
"position": {
"x": 0,
"y": 0
}
},
{
"id": "n2",
"label": "Advance",
"kind": "feat",
"position": {
"x": 200,
"y": 0
}
}
],
"edges": [
{
"id": "e1",
"source": "n1",
"target": "n2",
"kind": "prerequisite"
}
]
}
}
},
"creationRules": [
{
"id": "arr",
"type": "array",
"appliesTo": "attribute",
"params": {
"array": [
3,
2,
2
],
"keys": [
"str",
"dex",
"mind"
]
}
}
],
"sheetLayout": {
"pageSize": "fluid",
"grid": {
"size": 8
},
"pages": [
{
"id": "main",
"label": "Main",
"widgets": [
{
"id": "t",
"type": "section",
"frame": {
"x": 16,
"y": 16,
"w": 280,
"h": 36
},
"props": {
"title": "Blades in the Dark"
}
},
{
"id": "str",
"type": "dots",
"bind": "attr.str",
"frame": {
"x": 16,
"y": 64,
"w": 160,
"h": 70
},
"props": {
"label": "STR",
"max": 5
}
},
{
"id": "hp",
"type": "track",
"bind": "resource.hp",
"frame": {
"x": 200,
"y": 64,
"w": 200,
"h": 80
},
"props": {
"label": "HP",
"max": 20
}
},
{
"id": "notes",
"type": "notes",
"bind": "meta.notes",
"frame": {
"x": 16,
"y": 160,
"w": 380,
"h": 120
}
}
]
}
]
}
}
+177
View File
@@ -0,0 +1,177 @@
{
"format": "ttrpg-system",
"formatVersion": 1,
"id": "coc",
"name": "Call of Cthulhu",
"description": "Investigators, percentiles, Sanity. Fan mechanical scaffold.",
"edition": "fan",
"schemaVersion": 1,
"revision": 1,
"bricks": {
"attributes": [
{
"key": "str",
"label": "STR",
"scale": "numeric",
"min": 1,
"max": 10,
"default": 2
},
{
"key": "dex",
"label": "DEX",
"scale": "numeric",
"min": 1,
"max": 10,
"default": 2
},
{
"key": "mind",
"label": "Mind",
"scale": "numeric",
"min": 1,
"max": 10,
"default": 2
}
],
"resources": [
{
"key": "hp",
"label": "HP",
"min": 0,
"default": 10,
"track": true
}
],
"derived": [
{
"key": "strMod",
"label": "STR Mod",
"formula": "attr.str",
"dependsOn": [
"attr.str"
]
}
],
"progression": {
"label": "Features",
"graph": {
"nodes": [
{
"id": "n1",
"label": "Start",
"kind": "feat",
"position": {
"x": 0,
"y": 0
}
},
{
"id": "n2",
"label": "Advance",
"kind": "feat",
"position": {
"x": 200,
"y": 0
}
}
],
"edges": [
{
"id": "e1",
"source": "n1",
"target": "n2",
"kind": "prerequisite"
}
]
}
}
},
"creationRules": [
{
"id": "arr",
"type": "array",
"appliesTo": "attribute",
"params": {
"array": [
3,
2,
2
],
"keys": [
"str",
"dex",
"mind"
]
}
}
],
"sheetLayout": {
"pageSize": "fluid",
"grid": {
"size": 8
},
"pages": [
{
"id": "main",
"label": "Main",
"widgets": [
{
"id": "t",
"type": "section",
"frame": {
"x": 16,
"y": 16,
"w": 280,
"h": 36
},
"props": {
"title": "Call of Cthulhu"
}
},
{
"id": "str",
"type": "dots",
"bind": "attr.str",
"frame": {
"x": 16,
"y": 64,
"w": 160,
"h": 70
},
"props": {
"label": "STR",
"max": 5
}
},
{
"id": "hp",
"type": "track",
"bind": "resource.hp",
"frame": {
"x": 200,
"y": 64,
"w": 200,
"h": 80
},
"props": {
"label": "HP",
"max": 20
}
},
{
"id": "notes",
"type": "notes",
"bind": "meta.notes",
"frame": {
"x": 16,
"y": 160,
"w": 380,
"h": 120
}
}
]
}
]
}
}
+177
View File
@@ -0,0 +1,177 @@
{
"format": "ttrpg-system",
"formatVersion": 1,
"id": "cyberpunk-red",
"name": "Cyberpunk RED",
"description": "STATs, role, humanity, net page. Fan mechanical scaffold.",
"edition": "fan",
"schemaVersion": 1,
"revision": 1,
"bricks": {
"attributes": [
{
"key": "str",
"label": "STR",
"scale": "numeric",
"min": 1,
"max": 10,
"default": 2
},
{
"key": "dex",
"label": "DEX",
"scale": "numeric",
"min": 1,
"max": 10,
"default": 2
},
{
"key": "mind",
"label": "Mind",
"scale": "numeric",
"min": 1,
"max": 10,
"default": 2
}
],
"resources": [
{
"key": "hp",
"label": "HP",
"min": 0,
"default": 10,
"track": true
}
],
"derived": [
{
"key": "strMod",
"label": "STR Mod",
"formula": "attr.str",
"dependsOn": [
"attr.str"
]
}
],
"progression": {
"label": "Features",
"graph": {
"nodes": [
{
"id": "n1",
"label": "Start",
"kind": "feat",
"position": {
"x": 0,
"y": 0
}
},
{
"id": "n2",
"label": "Advance",
"kind": "feat",
"position": {
"x": 200,
"y": 0
}
}
],
"edges": [
{
"id": "e1",
"source": "n1",
"target": "n2",
"kind": "prerequisite"
}
]
}
}
},
"creationRules": [
{
"id": "arr",
"type": "array",
"appliesTo": "attribute",
"params": {
"array": [
3,
2,
2
],
"keys": [
"str",
"dex",
"mind"
]
}
}
],
"sheetLayout": {
"pageSize": "fluid",
"grid": {
"size": 8
},
"pages": [
{
"id": "main",
"label": "Main",
"widgets": [
{
"id": "t",
"type": "section",
"frame": {
"x": 16,
"y": 16,
"w": 280,
"h": 36
},
"props": {
"title": "Cyberpunk RED"
}
},
{
"id": "str",
"type": "dots",
"bind": "attr.str",
"frame": {
"x": 16,
"y": 64,
"w": 160,
"h": 70
},
"props": {
"label": "STR",
"max": 5
}
},
{
"id": "hp",
"type": "track",
"bind": "resource.hp",
"frame": {
"x": 200,
"y": 64,
"w": 200,
"h": 80
},
"props": {
"label": "HP",
"max": 20
}
},
{
"id": "notes",
"type": "notes",
"bind": "meta.notes",
"frame": {
"x": 16,
"y": 160,
"w": 380,
"h": 120
}
}
]
}
]
}
}
+118
View File
@@ -0,0 +1,118 @@
{
"format": "ttrpg-system",
"formatVersion": 1,
"id": "dnd5e-srd",
"name": "D&D 5e (SRD scaffold)",
"description": "Fan mechanical scaffold inspired by D&D 5e SRD. Spell compendium filled separately from open sources / user import.",
"edition": "5e",
"schemaVersion": 1,
"revision": 1,
"bricks": {
"attributes": [
{ "key": "str", "label": "Strength", "scale": "numeric", "min": 8, "max": 15, "default": 10, "category": "physical" },
{ "key": "dex", "label": "Dexterity", "scale": "numeric", "min": 8, "max": 15, "default": 10, "category": "physical" },
{ "key": "con", "label": "Constitution", "scale": "numeric", "min": 8, "max": 15, "default": 10, "category": "physical" },
{ "key": "int", "label": "Intelligence", "scale": "numeric", "min": 8, "max": 15, "default": 10, "category": "mental" },
{ "key": "wis", "label": "Wisdom", "scale": "numeric", "min": 8, "max": 15, "default": 10, "category": "mental" },
{ "key": "cha", "label": "Charisma", "scale": "numeric", "min": 8, "max": 15, "default": 10, "category": "social" }
],
"skills": [
{ "key": "athletics", "label": "Athletics", "linkedAttribute": "str", "scale": "numeric", "default": 0 },
{ "key": "acrobatics", "label": "Acrobatics", "linkedAttribute": "dex", "scale": "numeric", "default": 0 },
{ "key": "stealth", "label": "Stealth", "linkedAttribute": "dex", "scale": "numeric", "default": 0 },
{ "key": "arcana", "label": "Arcana", "linkedAttribute": "int", "scale": "numeric", "default": 0 },
{ "key": "perception", "label": "Perception", "linkedAttribute": "wis", "scale": "numeric", "default": 0 },
{ "key": "persuasion", "label": "Persuasion", "linkedAttribute": "cha", "scale": "numeric", "default": 0 }
],
"resources": [
{ "key": "hp", "label": "Hit Points", "min": 0, "default": 10, "track": true },
{ "key": "hpMax", "label": "HP Max", "min": 1, "default": 10 },
{ "key": "hitDice", "label": "Hit Dice", "min": 0, "default": 1 }
],
"derived": [
{ "key": "strMod", "label": "STR Mod", "formula": "floor((attr.str - 10) / 2)", "dependsOn": ["attr.str"] },
{ "key": "dexMod", "label": "DEX Mod", "formula": "floor((attr.dex - 10) / 2)", "dependsOn": ["attr.dex"] },
{ "key": "conMod", "label": "CON Mod", "formula": "floor((attr.con - 10) / 2)", "dependsOn": ["attr.con"] },
{ "key": "intMod", "label": "INT Mod", "formula": "floor((attr.int - 10) / 2)", "dependsOn": ["attr.int"] },
{ "key": "wisMod", "label": "WIS Mod", "formula": "floor((attr.wis - 10) / 2)", "dependsOn": ["attr.wis"] },
{ "key": "chaMod", "label": "CHA Mod", "formula": "floor((attr.cha - 10) / 2)", "dependsOn": ["attr.cha"] },
{ "key": "initiative", "label": "Initiative", "formula": "derived.dexMod", "dependsOn": ["derived.dexMod"] }
],
"traits": [
{ "key": "race", "label": "Species" },
{ "key": "class", "label": "Class" },
{ "key": "background", "label": "Background" }
],
"progression": {
"label": "Class features",
"graph": {
"nodes": [
{ "id": "l1", "label": "Level 1 Feature", "kind": "feat", "position": { "x": 0, "y": 0 } },
{ "id": "l2", "label": "Level 2 Feature", "kind": "feat", "position": { "x": 220, "y": 0 } }
],
"edges": [{ "id": "e1", "source": "l1", "target": "l2", "kind": "prerequisite" }]
}
}
},
"creationRules": [
{
"id": "pb",
"type": "point_buy",
"appliesTo": "attribute",
"params": {
"budget": 27,
"keys": ["str", "dex", "con", "int", "wis", "cha"],
"costs": { "8": 0, "9": 1, "10": 2, "11": 3, "12": 4, "13": 5, "14": 7, "15": 9 }
}
},
{
"id": "roll",
"type": "roll",
"appliesTo": "attribute",
"params": { "expr": "4d6dl", "keys": ["str", "dex", "con", "int", "wis", "cha"], "min": 3, "max": 18 }
}
],
"sheetLayout": {
"pageSize": "fluid",
"grid": { "size": 8 },
"pages": [
{
"id": "main",
"label": "Main",
"widgets": [
{ "id": "title", "type": "section", "frame": { "x": 16, "y": 16, "w": 320, "h": 36 }, "props": { "title": "D&D 5e Character" } },
{ "id": "str", "type": "number", "bind": "attr.str", "frame": { "x": 16, "y": 64, "w": 90, "h": 70 }, "props": { "label": "STR" } },
{ "id": "strMod", "type": "modBadge", "bind": "derived.strMod", "frame": { "x": 16, "y": 140, "w": 90, "h": 56 }, "props": { "label": "Mod" } },
{ "id": "dex", "type": "number", "bind": "attr.dex", "frame": { "x": 116, "y": 64, "w": 90, "h": 70 }, "props": { "label": "DEX" } },
{ "id": "con", "type": "number", "bind": "attr.con", "frame": { "x": 216, "y": 64, "w": 90, "h": 70 }, "props": { "label": "CON" } },
{ "id": "int", "type": "number", "bind": "attr.int", "frame": { "x": 316, "y": 64, "w": 90, "h": 70 }, "props": { "label": "INT" } },
{ "id": "wis", "type": "number", "bind": "attr.wis", "frame": { "x": 416, "y": 64, "w": 90, "h": 70 }, "props": { "label": "WIS" } },
{ "id": "cha", "type": "number", "bind": "attr.cha", "frame": { "x": 516, "y": 64, "w": 90, "h": 70 }, "props": { "label": "CHA" } },
{ "id": "hp", "type": "track", "bind": "resource.hp", "frame": { "x": 16, "y": 220, "w": 240, "h": 80 }, "props": { "label": "HP", "max": 30 } },
{ "id": "skills", "type": "skillList", "frame": { "x": 280, "y": 220, "w": 320, "h": 260 } },
{ "id": "notes", "type": "notes", "bind": "meta.notes", "frame": { "x": 16, "y": 320, "w": 240, "h": 160 } }
]
}
]
},
"compendium": {
"spells": [
{
"id": "light",
"name": "Light",
"level": 0,
"school": "evocation",
"source": "SRD",
"description": "Placeholder SRD cantrip stub — replace via dnd.su PHB import pipeline."
},
{
"id": "mage-armor",
"name": "Mage Armor",
"level": 1,
"school": "abjuration",
"source": "SRD",
"description": "Placeholder SRD spell stub."
}
]
}
}
+177
View File
@@ -0,0 +1,177 @@
{
"format": "ttrpg-system",
"formatVersion": 1,
"id": "pf2e",
"name": "Pathfinder 2e",
"description": "Proficiency ranks, feats graph. Fan mechanical scaffold.",
"edition": "fan",
"schemaVersion": 1,
"revision": 1,
"bricks": {
"attributes": [
{
"key": "str",
"label": "STR",
"scale": "numeric",
"min": 1,
"max": 10,
"default": 2
},
{
"key": "dex",
"label": "DEX",
"scale": "numeric",
"min": 1,
"max": 10,
"default": 2
},
{
"key": "mind",
"label": "Mind",
"scale": "numeric",
"min": 1,
"max": 10,
"default": 2
}
],
"resources": [
{
"key": "hp",
"label": "HP",
"min": 0,
"default": 10,
"track": true
}
],
"derived": [
{
"key": "strMod",
"label": "STR Mod",
"formula": "attr.str",
"dependsOn": [
"attr.str"
]
}
],
"progression": {
"label": "Features",
"graph": {
"nodes": [
{
"id": "n1",
"label": "Start",
"kind": "feat",
"position": {
"x": 0,
"y": 0
}
},
{
"id": "n2",
"label": "Advance",
"kind": "feat",
"position": {
"x": 200,
"y": 0
}
}
],
"edges": [
{
"id": "e1",
"source": "n1",
"target": "n2",
"kind": "prerequisite"
}
]
}
}
},
"creationRules": [
{
"id": "arr",
"type": "array",
"appliesTo": "attribute",
"params": {
"array": [
3,
2,
2
],
"keys": [
"str",
"dex",
"mind"
]
}
}
],
"sheetLayout": {
"pageSize": "fluid",
"grid": {
"size": 8
},
"pages": [
{
"id": "main",
"label": "Main",
"widgets": [
{
"id": "t",
"type": "section",
"frame": {
"x": 16,
"y": 16,
"w": 280,
"h": 36
},
"props": {
"title": "Pathfinder 2e"
}
},
{
"id": "str",
"type": "dots",
"bind": "attr.str",
"frame": {
"x": 16,
"y": 64,
"w": 160,
"h": 70
},
"props": {
"label": "STR",
"max": 5
}
},
{
"id": "hp",
"type": "track",
"bind": "resource.hp",
"frame": {
"x": 200,
"y": 64,
"w": 200,
"h": 80
},
"props": {
"label": "HP",
"max": 20
}
},
{
"id": "notes",
"type": "notes",
"bind": "meta.notes",
"frame": {
"x": 16,
"y": 160,
"w": 380,
"h": 120
}
}
]
}
]
}
}
+62
View File
@@ -0,0 +1,62 @@
{
"format": "ttrpg-system",
"formatVersion": 1,
"id": "shadowdark",
"name": "Shadowdark RPG",
"description": "Minimalist dark fantasy fan schema (mechanical scaffold).",
"edition": "1e-fan",
"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" }
],
"resources": [
{ "key": "hp", "label": "HP", "min": 0, "default": 8, "track": true },
{ "key": "hpMax", "label": "HP Max", "min": 1, "default": 8 }
],
"derived": [
{ "key": "strMod", "label": "STR Mod", "formula": "floor((attr.str - 10) / 2)", "dependsOn": ["attr.str"] },
{ "key": "dexMod", "label": "DEX Mod", "formula": "floor((attr.dex - 10) / 2)", "dependsOn": ["attr.dex"] },
{ "key": "conMod", "label": "CON Mod", "formula": "floor((attr.con - 10) / 2)", "dependsOn": ["attr.con"] }
],
"traits": [
{ "key": "ancestry", "label": "Ancestry" },
{ "key": "class", "label": "Class" }
]
},
"creationRules": [
{
"id": "roll",
"type": "roll",
"appliesTo": "attribute",
"params": { "expr": "3d6", "keys": ["str", "dex", "con", "int", "wis", "cha"], "min": 3, "max": 18 }
}
],
"sheetLayout": {
"pageSize": "fluid",
"grid": { "size": 8 },
"pages": [
{
"id": "main",
"label": "Main",
"widgets": [
{ "id": "title", "type": "section", "frame": { "x": 16, "y": 16, "w": 280, "h": 36 }, "props": { "title": "Shadowdark" } },
{ "id": "str", "type": "number", "bind": "attr.str", "frame": { "x": 16, "y": 64, "w": 90, "h": 70 }, "props": { "label": "STR" } },
{ "id": "dex", "type": "number", "bind": "attr.dex", "frame": { "x": 116, "y": 64, "w": 90, "h": 70 }, "props": { "label": "DEX" } },
{ "id": "con", "type": "number", "bind": "attr.con", "frame": { "x": 216, "y": 64, "w": 90, "h": 70 }, "props": { "label": "CON" } },
{ "id": "int", "type": "number", "bind": "attr.int", "frame": { "x": 16, "y": 144, "w": 90, "h": 70 }, "props": { "label": "INT" } },
{ "id": "wis", "type": "number", "bind": "attr.wis", "frame": { "x": 116, "y": 144, "w": 90, "h": 70 }, "props": { "label": "WIS" } },
{ "id": "cha", "type": "number", "bind": "attr.cha", "frame": { "x": 216, "y": 144, "w": 90, "h": 70 }, "props": { "label": "CHA" } },
{ "id": "hp", "type": "track", "bind": "resource.hp", "frame": { "x": 320, "y": 64, "w": 200, "h": 80 }, "props": { "label": "HP", "max": 20 } },
{ "id": "notes", "type": "notes", "bind": "meta.notes", "frame": { "x": 16, "y": 240, "w": 500, "h": 140 } }
]
}
]
}
}
+177
View File
@@ -0,0 +1,177 @@
{
"format": "ttrpg-system",
"formatVersion": 1,
"id": "sw-d20",
"name": "Star Wars RPG (d20)",
"description": "d20-line abilities and powers. Fan mechanical scaffold.",
"edition": "fan",
"schemaVersion": 1,
"revision": 1,
"bricks": {
"attributes": [
{
"key": "str",
"label": "STR",
"scale": "numeric",
"min": 1,
"max": 10,
"default": 2
},
{
"key": "dex",
"label": "DEX",
"scale": "numeric",
"min": 1,
"max": 10,
"default": 2
},
{
"key": "mind",
"label": "Mind",
"scale": "numeric",
"min": 1,
"max": 10,
"default": 2
}
],
"resources": [
{
"key": "hp",
"label": "HP",
"min": 0,
"default": 10,
"track": true
}
],
"derived": [
{
"key": "strMod",
"label": "STR Mod",
"formula": "attr.str",
"dependsOn": [
"attr.str"
]
}
],
"progression": {
"label": "Features",
"graph": {
"nodes": [
{
"id": "n1",
"label": "Start",
"kind": "feat",
"position": {
"x": 0,
"y": 0
}
},
{
"id": "n2",
"label": "Advance",
"kind": "feat",
"position": {
"x": 200,
"y": 0
}
}
],
"edges": [
{
"id": "e1",
"source": "n1",
"target": "n2",
"kind": "prerequisite"
}
]
}
}
},
"creationRules": [
{
"id": "arr",
"type": "array",
"appliesTo": "attribute",
"params": {
"array": [
3,
2,
2
],
"keys": [
"str",
"dex",
"mind"
]
}
}
],
"sheetLayout": {
"pageSize": "fluid",
"grid": {
"size": 8
},
"pages": [
{
"id": "main",
"label": "Main",
"widgets": [
{
"id": "t",
"type": "section",
"frame": {
"x": 16,
"y": 16,
"w": 280,
"h": 36
},
"props": {
"title": "Star Wars RPG (d20)"
}
},
{
"id": "str",
"type": "dots",
"bind": "attr.str",
"frame": {
"x": 16,
"y": 64,
"w": 160,
"h": 70
},
"props": {
"label": "STR",
"max": 5
}
},
{
"id": "hp",
"type": "track",
"bind": "resource.hp",
"frame": {
"x": 200,
"y": 64,
"w": 200,
"h": 80
},
"props": {
"label": "HP",
"max": 20
}
},
{
"id": "notes",
"type": "notes",
"bind": "meta.notes",
"frame": {
"x": 16,
"y": 160,
"w": 380,
"h": 120
}
}
]
}
]
}
}
+177
View File
@@ -0,0 +1,177 @@
{
"format": "ttrpg-system",
"formatVersion": 1,
"id": "vtm",
"name": "Vampire: The Masquerade",
"description": "Dots attributes, disciplines, blood pool. Fan mechanical scaffold.",
"edition": "fan",
"schemaVersion": 1,
"revision": 1,
"bricks": {
"attributes": [
{
"key": "str",
"label": "STR",
"scale": "numeric",
"min": 1,
"max": 10,
"default": 2
},
{
"key": "dex",
"label": "DEX",
"scale": "numeric",
"min": 1,
"max": 10,
"default": 2
},
{
"key": "mind",
"label": "Mind",
"scale": "numeric",
"min": 1,
"max": 10,
"default": 2
}
],
"resources": [
{
"key": "hp",
"label": "HP",
"min": 0,
"default": 10,
"track": true
}
],
"derived": [
{
"key": "strMod",
"label": "STR Mod",
"formula": "attr.str",
"dependsOn": [
"attr.str"
]
}
],
"progression": {
"label": "Features",
"graph": {
"nodes": [
{
"id": "n1",
"label": "Start",
"kind": "feat",
"position": {
"x": 0,
"y": 0
}
},
{
"id": "n2",
"label": "Advance",
"kind": "feat",
"position": {
"x": 200,
"y": 0
}
}
],
"edges": [
{
"id": "e1",
"source": "n1",
"target": "n2",
"kind": "prerequisite"
}
]
}
}
},
"creationRules": [
{
"id": "arr",
"type": "array",
"appliesTo": "attribute",
"params": {
"array": [
3,
2,
2
],
"keys": [
"str",
"dex",
"mind"
]
}
}
],
"sheetLayout": {
"pageSize": "fluid",
"grid": {
"size": 8
},
"pages": [
{
"id": "main",
"label": "Main",
"widgets": [
{
"id": "t",
"type": "section",
"frame": {
"x": 16,
"y": 16,
"w": 280,
"h": 36
},
"props": {
"title": "Vampire: The Masquerade"
}
},
{
"id": "str",
"type": "dots",
"bind": "attr.str",
"frame": {
"x": 16,
"y": 64,
"w": 160,
"h": 70
},
"props": {
"label": "STR",
"max": 5
}
},
{
"id": "hp",
"type": "track",
"bind": "resource.hp",
"frame": {
"x": 200,
"y": 64,
"w": 200,
"h": 80
},
"props": {
"label": "HP",
"max": 20
}
},
{
"id": "notes",
"type": "notes",
"bind": "meta.notes",
"frame": {
"x": 16,
"y": 160,
"w": 380,
"h": 120
}
}
]
}
]
}
}
+177
View File
@@ -0,0 +1,177 @@
{
"format": "ttrpg-system",
"formatVersion": 1,
"id": "wfrp",
"name": "Warhammer Fantasy Roleplay",
"description": "Careers, % advances, wounds. Fan mechanical scaffold.",
"edition": "fan",
"schemaVersion": 1,
"revision": 1,
"bricks": {
"attributes": [
{
"key": "str",
"label": "STR",
"scale": "numeric",
"min": 1,
"max": 10,
"default": 2
},
{
"key": "dex",
"label": "DEX",
"scale": "numeric",
"min": 1,
"max": 10,
"default": 2
},
{
"key": "mind",
"label": "Mind",
"scale": "numeric",
"min": 1,
"max": 10,
"default": 2
}
],
"resources": [
{
"key": "hp",
"label": "HP",
"min": 0,
"default": 10,
"track": true
}
],
"derived": [
{
"key": "strMod",
"label": "STR Mod",
"formula": "attr.str",
"dependsOn": [
"attr.str"
]
}
],
"progression": {
"label": "Features",
"graph": {
"nodes": [
{
"id": "n1",
"label": "Start",
"kind": "feat",
"position": {
"x": 0,
"y": 0
}
},
{
"id": "n2",
"label": "Advance",
"kind": "feat",
"position": {
"x": 200,
"y": 0
}
}
],
"edges": [
{
"id": "e1",
"source": "n1",
"target": "n2",
"kind": "prerequisite"
}
]
}
}
},
"creationRules": [
{
"id": "arr",
"type": "array",
"appliesTo": "attribute",
"params": {
"array": [
3,
2,
2
],
"keys": [
"str",
"dex",
"mind"
]
}
}
],
"sheetLayout": {
"pageSize": "fluid",
"grid": {
"size": 8
},
"pages": [
{
"id": "main",
"label": "Main",
"widgets": [
{
"id": "t",
"type": "section",
"frame": {
"x": 16,
"y": 16,
"w": 280,
"h": 36
},
"props": {
"title": "Warhammer Fantasy Roleplay"
}
},
{
"id": "str",
"type": "dots",
"bind": "attr.str",
"frame": {
"x": 16,
"y": 64,
"w": 160,
"h": 70
},
"props": {
"label": "STR",
"max": 5
}
},
{
"id": "hp",
"type": "track",
"bind": "resource.hp",
"frame": {
"x": 200,
"y": 64,
"w": 200,
"h": 80
},
"props": {
"label": "HP",
"max": 20
}
},
{
"id": "notes",
"type": "notes",
"bind": "meta.notes",
"frame": {
"x": 16,
"y": 160,
"w": 380,
"h": 120
}
}
]
}
]
}
}
+36
View File
@@ -0,0 +1,36 @@
import { build } from 'esbuild';
import { spawn } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
await build({
entryPoints: [path.join(root, 'app/main/index.ts')],
bundle: true,
platform: 'node',
format: 'cjs',
outfile: path.join(root, 'dist/main/index.cjs'),
external: ['electron', 'better-sqlite3'],
sourcemap: true,
});
await build({
entryPoints: [path.join(root, 'app/preload/index.ts')],
bundle: true,
platform: 'node',
format: 'cjs',
outfile: path.join(root, 'dist/preload/index.cjs'),
external: ['electron'],
sourcemap: true,
});
await new Promise((resolve, reject) => {
const p = spawn(
process.platform === 'win32' ? 'npx.cmd' : 'npx',
['vite', 'build', '--config', 'vite.config.ts'],
{ cwd: root, stdio: 'inherit', shell: true },
);
p.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`vite exit ${code}`))));
});
+58
View File
@@ -0,0 +1,58 @@
import { build } from 'esbuild';
import { spawn } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
async function buildMain() {
await build({
entryPoints: [path.join(root, 'app/main/index.ts')],
bundle: true,
platform: 'node',
format: 'cjs',
outfile: path.join(root, 'dist/main/index.cjs'),
external: ['electron', 'better-sqlite3'],
sourcemap: true,
});
await build({
entryPoints: [path.join(root, 'app/preload/index.ts')],
bundle: true,
platform: 'node',
format: 'cjs',
outfile: path.join(root, 'dist/preload/index.cjs'),
external: ['electron'],
sourcemap: true,
});
}
function electronPath() {
const name = process.platform === 'win32' ? 'electron.cmd' : 'electron';
const bin = path.join(root, 'node_modules', '.bin', name);
if (fs.existsSync(bin)) return bin;
return path.join(root, 'node_modules', 'electron', 'cli.js');
}
await buildMain();
const vite = spawn(
process.platform === 'win32' ? 'npx.cmd' : 'npx',
['vite', '--config', 'vite.config.ts'],
{ cwd: root, stdio: 'inherit', shell: true },
);
await new Promise((r) => setTimeout(r, 2000));
const elec = spawn(electronPath(), ['.'], {
cwd: root,
stdio: 'inherit',
shell: true,
env: { ...process.env, VITE_DEV_SERVER_URL: 'http://localhost:5174' },
});
elec.on('exit', (code) => {
vite.kill();
process.exit(code ?? 0);
});
+43
View File
@@ -0,0 +1,43 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
// Run via tsx so we can import TS modules
const { validateSystemDefinition } = await import('../app/shared/systemValidate.ts');
const { computeDerived } = await import('../app/shared/derived.ts');
const presetsDir = path.join(root, 'presets');
const files = fs.readdirSync(presetsDir).filter((f) => f.endsWith('.json') && !f.includes('sample'));
let failed = 0;
for (const file of files) {
const system = JSON.parse(fs.readFileSync(path.join(presetsDir, file), 'utf8'));
const v = validateSystemDefinition(system);
if (!v.ok) {
console.error('FAIL validate', file, v.errors);
failed += 1;
continue;
}
const attrs = {};
for (const a of system.bricks.attributes ?? []) {
attrs[a.key] = typeof a.default === 'number' ? a.default : 10;
}
try {
computeDerived(system.bricks.derived ?? [], { attributes: attrs });
console.log('OK', file);
} catch (e) {
console.error('FAIL derived', file, e);
failed += 1;
}
}
if (failed) {
console.error(`preset-smoke: ${failed} failed`);
process.exit(1);
}
console.log(`preset-smoke: ${files.length} presets ok`);
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env node
/** Sync portable characterSheet module into dnd_player (meta_builder). */
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const src = path.resolve(__dirname, '../app/shared');
const dest = path.resolve(__dirname, '../../dnd_player/app/shared/characterSheet');
fs.mkdirSync(dest, { recursive: true });
for (const file of fs.readdirSync(src)) {
if (!file.endsWith('.ts')) continue;
let text = fs.readFileSync(path.join(src, file), 'utf8');
text = text.replace(/from '\.\/([^']+)\.ts'/g, "from './$1'");
fs.writeFileSync(path.join(dest, file), text);
console.log('synced', file);
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"allowImportingTsExtensions": true,
"types": ["node"]
},
"include": ["app/**/*", "scripts/**/*", "e2e/**/*"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
export default defineConfig({
root: path.resolve('app/renderer'),
plugins: [react()],
base: './',
build: {
outDir: path.resolve('dist/renderer'),
emptyOutDir: true,
},
server: {
port: 5174,
strictPort: true,
},
});