feat: кубики/таймер/кисть/спонсоры и задел игровых систем

Показ анимации кубиков игрокам, закрытие оверлеев с окнами, ручной ввод количества, меньший радиус кисти, блок спонсоров в «О программе»; плюс черновик game systems / character sheet.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-13 15:37:44 +08:00
parent 3d0474fe97
commit 712086ddca
32 changed files with 1712 additions and 34 deletions
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
import { GameSystemsStore } from './gameSystemsStore';
import type { SystemDefinition } from '../../shared/characterSheet/types';
import { FORMAT_VERSION, SYSTEM_FORMAT } from '../../shared/characterSheet/types';
function sampleSystem(): SystemDefinition {
return {
format: SYSTEM_FORMAT,
formatVersion: FORMAT_VERSION,
id: 'shadowdark',
name: 'Shadowdark',
schemaVersion: 1,
revision: 1,
bricks: { attributes: [{ key: 'str', label: 'STR', scale: 'numeric', default: 10 }] },
creationRules: [],
sheetLayout: { pageSize: 'fluid', grid: { size: 8 }, pages: [{ id: 'main', label: 'Main', widgets: [] }] },
};
}
test('GameSystemsStore import list delete', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gs-'));
const store = new GameSystemsStore(dir);
await store.importSystem(sampleSystem());
const list = await store.list();
assert.equal(list.length, 1);
assert.equal(list[0]!.id, 'shadowdark');
const bind = await store.bindSnapshot('shadowdark');
assert.ok(bind.snapshotHash.length === 64);
const snap = await store.getSnapshot(bind.snapshotHash);
assert.equal(snap?.id, 'shadowdark');
await store.delete('shadowdark');
assert.equal((await store.list()).length, 0);
});
+129
View File
@@ -0,0 +1,129 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import type { GameSystemListItem } from '../../shared/ipc/contracts';
import type { SystemDefinition } from '../../shared/characterSheet/types';
import { validateSystemDefinition } from '../../shared/characterSheet/systemValidate';
import { snapshotHash } from '../../shared/characterSheet/snapshot';
export type { GameSystemListItem };
type Manifest = {
systems: GameSystemListItem[];
};
export class GameSystemsStore {
private readonly rootDir: string;
private readonly manifestPath: string;
private readonly snapshotsDir: string;
private manifest: Manifest = { systems: [] };
private loaded = false;
constructor(userData: string) {
this.rootDir = path.join(userData, 'game-systems');
this.manifestPath = path.join(this.rootDir, 'manifest.json');
this.snapshotsDir = path.join(this.rootDir, 'snapshots');
}
async ensureLoaded(): Promise<void> {
if (this.loaded) return;
await fs.mkdir(this.snapshotsDir, { recursive: true });
try {
const raw = await fs.readFile(this.manifestPath, 'utf8');
this.manifest = JSON.parse(raw) as Manifest;
if (!Array.isArray(this.manifest.systems)) this.manifest.systems = [];
} catch {
this.manifest = { systems: [] };
}
this.loaded = true;
}
async list(): Promise<GameSystemListItem[]> {
await this.ensureLoaded();
return [...this.manifest.systems];
}
private systemPath(id: string): string {
return path.join(this.rootDir, id, 'system.json');
}
async get(id: string): Promise<SystemDefinition | null> {
await this.ensureLoaded();
try {
const raw = await fs.readFile(this.systemPath(id), 'utf8');
return JSON.parse(raw) as SystemDefinition;
} catch {
return null;
}
}
async importSystem(system: SystemDefinition): Promise<GameSystemListItem> {
await this.ensureLoaded();
const check = validateSystemDefinition(system);
if (!check.ok) throw new Error(check.errors.map((e) => e.message).join('; '));
const dir = path.join(this.rootDir, system.id);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(this.systemPath(system.id), JSON.stringify(system, null, 2), 'utf8');
const hash = snapshotHash(system);
await fs.writeFile(path.join(this.snapshotsDir, `${hash}.json`), JSON.stringify(system), 'utf8');
const item: GameSystemListItem = {
id: system.id,
name: system.name,
edition: system.edition,
schemaVersion: system.schemaVersion,
revision: system.revision,
};
this.manifest.systems = this.manifest.systems.filter((s) => s.id !== system.id).concat(item);
await this.saveManifest();
return item;
}
async delete(id: string): Promise<string[]> {
await this.ensureLoaded();
this.manifest.systems = this.manifest.systems.filter((s) => s.id !== id);
await this.saveManifest();
try {
await fs.rm(path.join(this.rootDir, id), { recursive: true, force: true });
} catch {
// ignore
}
// Caller clears player bindings; return id for convenience
return [id];
}
async getSnapshot(hash: string): Promise<SystemDefinition | null> {
try {
const raw = await fs.readFile(path.join(this.snapshotsDir, `${hash}.json`), 'utf8');
return JSON.parse(raw) as SystemDefinition;
} catch {
return null;
}
}
async bindSnapshot(systemId: string): Promise<{ systemId: string; snapshotHash: string; system: SystemDefinition }> {
const system = await this.get(systemId);
if (!system) throw new Error(`System not found: ${systemId}`);
const hash = snapshotHash(system);
const snapPath = path.join(this.snapshotsDir, `${hash}.json`);
try {
await fs.access(snapPath);
} catch {
await fs.writeFile(snapPath, JSON.stringify(system), 'utf8');
}
return { systemId, snapshotHash: hash, system };
}
private async saveManifest(): Promise<void> {
await fs.mkdir(this.rootDir, { recursive: true });
await fs.writeFile(this.manifestPath, JSON.stringify(this.manifest, null, 2), 'utf8');
}
}
/** Test helper — content hash of arbitrary buffer. */
export function sha256Hex(data: string): string {
return createHash('sha256').update(data).digest('hex');
}