Initial TTRPG character meta-builder with visual sheet editor.
Offline Electron app seeds 10 system presets, supports create-from-scratch designer with palette/drag/resize/bricks, and exports .ttrpg-system.json for the player. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
import { app, BrowserWindow, dialog, ipcMain } from 'electron';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { CharacterSheet, SystemDefinition } from '../shared/types.ts';
|
||||
import { AppStore } from './store.ts';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let store: AppStore;
|
||||
|
||||
function preloadPath(): string {
|
||||
return path.join(__dirname, '../preload/index.cjs');
|
||||
}
|
||||
|
||||
function presetsDirs(): string[] {
|
||||
const candidates = [
|
||||
path.join(app.getAppPath(), 'presets'),
|
||||
path.join(process.cwd(), 'presets'),
|
||||
path.join(__dirname, '../../presets'),
|
||||
];
|
||||
if (app.isPackaged) {
|
||||
candidates.unshift(path.join(process.resourcesPath, 'presets'));
|
||||
}
|
||||
return candidates.filter((d) => fs.existsSync(d));
|
||||
}
|
||||
|
||||
function listPresetFiles(): string[] {
|
||||
const files: string[] = [];
|
||||
for (const dir of presetsDirs()) {
|
||||
for (const f of fs.readdirSync(dir)) {
|
||||
if (f.endsWith('.json') && !f.includes('sample')) {
|
||||
files.push(path.join(dir, f));
|
||||
}
|
||||
}
|
||||
}
|
||||
// unique by basename
|
||||
const byName = new Map<string, string>();
|
||||
for (const f of files) byName.set(path.basename(f), f);
|
||||
return [...byName.values()].sort();
|
||||
}
|
||||
|
||||
function seedPresetsIfEmpty(): number {
|
||||
if (store.listSystems().length > 0) return 0;
|
||||
let n = 0;
|
||||
for (const file of listPresetFiles()) {
|
||||
try {
|
||||
const system = JSON.parse(fs.readFileSync(file, 'utf8')) as SystemDefinition;
|
||||
store.upsertSystem(system);
|
||||
n += 1;
|
||||
} catch (e) {
|
||||
console.error('Failed to seed preset', file, e);
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function createWindow(): void {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1480,
|
||||
height: 920,
|
||||
minWidth: 1100,
|
||||
minHeight: 700,
|
||||
backgroundColor: '#09090b',
|
||||
title: 'TTRPG Character Builder',
|
||||
webPreferences: {
|
||||
preload: preloadPath(),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
const devUrl = process.env.VITE_DEV_SERVER_URL;
|
||||
if (devUrl) {
|
||||
void mainWindow.loadURL(devUrl);
|
||||
} else {
|
||||
void mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'));
|
||||
}
|
||||
}
|
||||
|
||||
function registerIpc(): void {
|
||||
ipcMain.handle('systems:list', () => store.listSystems());
|
||||
ipcMain.handle('systems:get', (_e, id: string) => store.getSystem(id));
|
||||
ipcMain.handle('systems:upsert', (_e, system: SystemDefinition) => store.upsertSystem(system));
|
||||
ipcMain.handle('systems:delete', (_e, id: string) => {
|
||||
store.deleteSystem(id);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('characters:list', (_e, systemId?: string) => store.listCharacters(systemId));
|
||||
ipcMain.handle('characters:get', (_e, id: string) => store.getCharacter(id));
|
||||
ipcMain.handle('characters:upsert', (_e, character: CharacterSheet) => store.upsertCharacter(character));
|
||||
ipcMain.handle('characters:delete', (_e, id: string) => {
|
||||
store.deleteCharacter(id);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('systems:export', async (_e, id: string) => {
|
||||
const system = store.getSystem(id);
|
||||
if (!system || !mainWindow) return null;
|
||||
const result = await dialog.showSaveDialog(mainWindow, {
|
||||
title: 'Export system',
|
||||
defaultPath: `${system.id}.ttrpg-system.json`,
|
||||
filters: [{ name: 'TTRPG System', extensions: ['json'] }],
|
||||
});
|
||||
if (result.canceled || !result.filePath) return null;
|
||||
fs.writeFileSync(result.filePath, JSON.stringify(system, null, 2), 'utf8');
|
||||
return result.filePath;
|
||||
});
|
||||
|
||||
ipcMain.handle('systems:import', async () => {
|
||||
if (!mainWindow) return null;
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
title: 'Import system',
|
||||
filters: [{ name: 'TTRPG System', extensions: ['json'] }],
|
||||
properties: ['openFile'],
|
||||
});
|
||||
if (result.canceled || !result.filePaths[0]) return null;
|
||||
const raw = fs.readFileSync(result.filePaths[0], 'utf8');
|
||||
const system = JSON.parse(raw) as SystemDefinition;
|
||||
return store.upsertSystem(system);
|
||||
});
|
||||
|
||||
ipcMain.handle('presets:list', () => listPresetFiles());
|
||||
ipcMain.handle('presets:load', (_e, filePath: string) => {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const system = JSON.parse(raw) as SystemDefinition;
|
||||
return store.upsertSystem(system);
|
||||
});
|
||||
ipcMain.handle('presets:seed', () => {
|
||||
// Force re-seed missing presets by id
|
||||
let n = 0;
|
||||
for (const file of listPresetFiles()) {
|
||||
const system = JSON.parse(fs.readFileSync(file, 'utf8')) as SystemDefinition;
|
||||
if (!store.getSystem(system.id)) {
|
||||
store.upsertSystem(system);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
if (store.listSystems().length === 0) n = seedPresetsIfEmpty();
|
||||
return n;
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
const dbPath = path.join(app.getPath('userData'), 'builder.sqlite');
|
||||
store = new AppStore(dbPath);
|
||||
const seeded = seedPresetsIfEmpty();
|
||||
if (seeded) console.log(`Seeded ${seeded} presets`);
|
||||
registerIpc();
|
||||
createWindow();
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
store?.close();
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import type { CharacterSheet, SystemDefinition } from '../shared/types.ts';
|
||||
import { validateSystemDefinition } from '../shared/systemValidate.ts';
|
||||
|
||||
export class AppStore {
|
||||
private readonly db: Database.Database;
|
||||
|
||||
constructor(dbPath: string) {
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
this.db = new Database(dbPath);
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS systems (
|
||||
id TEXT PRIMARY KEY,
|
||||
revision INTEGER NOT NULL,
|
||||
schema_version INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS characters (
|
||||
id TEXT PRIMARY KEY,
|
||||
system_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
listSystems(): Array<{ id: string; name: string; revision: number; schemaVersion: number }> {
|
||||
return this.db
|
||||
.prepare('SELECT id, name, revision, schema_version AS schemaVersion FROM systems ORDER BY name')
|
||||
.all() as Array<{ id: string; name: string; revision: number; schemaVersion: number }>;
|
||||
}
|
||||
|
||||
getSystem(id: string): SystemDefinition | null {
|
||||
const row = this.db.prepare('SELECT json FROM systems WHERE id = ?').get(id) as
|
||||
| { json: string }
|
||||
| undefined;
|
||||
return row ? (JSON.parse(row.json) as SystemDefinition) : null;
|
||||
}
|
||||
|
||||
upsertSystem(system: SystemDefinition): SystemDefinition {
|
||||
const check = validateSystemDefinition(system);
|
||||
if (!check.ok) {
|
||||
throw new Error(check.errors.map((e) => e.message).join('; '));
|
||||
}
|
||||
const existing = this.getSystem(system.id);
|
||||
const next: SystemDefinition = {
|
||||
...system,
|
||||
revision: existing ? existing.revision + 1 : system.revision || 1,
|
||||
};
|
||||
const now = new Date().toISOString();
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO systems (id, revision, schema_version, name, json, updated_at)
|
||||
VALUES (@id, @revision, @schemaVersion, @name, @json, @updatedAt)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
revision=excluded.revision,
|
||||
schema_version=excluded.schema_version,
|
||||
name=excluded.name,
|
||||
json=excluded.json,
|
||||
updated_at=excluded.updated_at`,
|
||||
)
|
||||
.run({
|
||||
id: next.id,
|
||||
revision: next.revision,
|
||||
schemaVersion: next.schemaVersion,
|
||||
name: next.name,
|
||||
json: JSON.stringify(next),
|
||||
updatedAt: now,
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
deleteSystem(id: string): void {
|
||||
const tx = this.db.transaction(() => {
|
||||
this.db.prepare('DELETE FROM characters WHERE system_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM systems WHERE id = ?').run(id);
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
listCharacters(systemId?: string): Array<{ id: string; name: string; systemId: string }> {
|
||||
if (systemId) {
|
||||
return this.db
|
||||
.prepare(
|
||||
'SELECT id, name, system_id AS systemId FROM characters WHERE system_id = ? ORDER BY name',
|
||||
)
|
||||
.all(systemId) as Array<{ id: string; name: string; systemId: string }>;
|
||||
}
|
||||
return this.db
|
||||
.prepare('SELECT id, name, system_id AS systemId FROM characters ORDER BY name')
|
||||
.all() as Array<{ id: string; name: string; systemId: string }>;
|
||||
}
|
||||
|
||||
getCharacter(id: string): CharacterSheet | null {
|
||||
const row = this.db.prepare('SELECT json FROM characters WHERE id = ?').get(id) as
|
||||
| { json: string }
|
||||
| undefined;
|
||||
return row ? (JSON.parse(row.json) as CharacterSheet) : null;
|
||||
}
|
||||
|
||||
upsertCharacter(character: CharacterSheet): CharacterSheet {
|
||||
const now = new Date().toISOString();
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO characters (id, system_id, name, json, updated_at)
|
||||
VALUES (@id, @systemId, @name, @json, @updatedAt)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
system_id=excluded.system_id,
|
||||
name=excluded.name,
|
||||
json=excluded.json,
|
||||
updated_at=excluded.updated_at`,
|
||||
)
|
||||
.run({
|
||||
id: character.id,
|
||||
systemId: character.systemId,
|
||||
name: character.name,
|
||||
json: JSON.stringify(character),
|
||||
updatedAt: now,
|
||||
});
|
||||
return character;
|
||||
}
|
||||
|
||||
deleteCharacter(id: string): void {
|
||||
this.db.prepare('DELETE FROM characters WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user