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
+38 -1
View File
@@ -13,12 +13,37 @@ void test('diceRoll: roll produces values in range and notation', () => {
store.dispatch({ kind: 'roll', count: 3, sides: 6 }); store.dispatch({ kind: 'roll', count: 3, sides: 6 });
const st = store.getState(); const st = store.getState();
assert.equal(st.active, true); assert.equal(st.active, true);
assert.equal(st.visibleToPlayers, true);
assert.equal(st.notation, '3d6'); assert.equal(st.notation, '3d6');
assert.equal(st.values.length, 3); assert.equal(st.values.length, 3);
assert.ok(st.values.every((v) => v >= 1 && v <= 6)); assert.ok(st.values.every((v) => v >= 1 && v <= 6));
assert.equal(st.total, st.values.reduce((a, b) => a + b, 0)); assert.equal(st.total, st.values.reduce((a, b) => a + b, 0));
}); });
void test('diceRoll: roll without visibleToPlayers keeps master result only', () => {
const store = new DiceRollSessionStore();
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
store.dispatch({ kind: 'roll', count: 2, sides: 8 });
const st = store.getState();
assert.equal(st.visibleToPlayers, false);
assert.equal(st.active, false);
assert.equal(st.notation, '2d8');
assert.equal(st.values.length, 2);
assert.ok(st.values.every((v) => v >= 1 && v <= 8));
assert.equal(st.startedAt, null);
});
void test('diceRoll: unchecking visibleToPlayers hides overlay', () => {
const store = new DiceRollSessionStore();
store.dispatch({ kind: 'roll', count: 1, sides: 20 });
assert.equal(store.getState().active, true);
const values = [...store.getState().values];
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
assert.equal(store.getState().active, false);
assert.equal(store.getState().visibleToPlayers, false);
assert.deepEqual(store.getState().values, values);
});
void test('diceRoll: d100 values are tens faces only', () => { void test('diceRoll: d100 values are tens faces only', () => {
const store = new DiceRollSessionStore(); const store = new DiceRollSessionStore();
store.dispatch({ kind: 'roll', count: 5, sides: 100 }); store.dispatch({ kind: 'roll', count: 5, sides: 100 });
@@ -30,12 +55,14 @@ void test('diceRoll: d100 values are tens faces only', () => {
assert.equal(st.total, st.values.reduce((a, b) => a + b, 0)); assert.equal(st.total, st.values.reduce((a, b) => a + b, 0));
}); });
void test('diceRoll: clear resets', () => { void test('diceRoll: clear resets but keeps visibleToPlayers', () => {
const store = new DiceRollSessionStore(); const store = new DiceRollSessionStore();
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
store.dispatch({ kind: 'roll', count: 1, sides: 20 }); store.dispatch({ kind: 'roll', count: 1, sides: 20 });
store.dispatch({ kind: 'clear' }); store.dispatch({ kind: 'clear' });
assert.equal(store.getState().active, false); assert.equal(store.getState().active, false);
assert.deepEqual(store.getState().values, []); assert.deepEqual(store.getState().values, []);
assert.equal(store.getState().visibleToPlayers, false);
}); });
void test('diceRoll: hideOverlay keeps values for master UI', () => { void test('diceRoll: hideOverlay keeps values for master UI', () => {
@@ -48,6 +75,16 @@ void test('diceRoll: hideOverlay keeps values for master UI', () => {
assert.deepEqual(store.getState().values, values); assert.deepEqual(store.getState().values, values);
assert.equal(store.getState().total, total); assert.equal(store.getState().total, total);
assert.equal(store.getState().notation, '2d6'); assert.equal(store.getState().notation, '2d6');
assert.equal(store.getState().visibleToPlayers, true);
});
void test('diceRoll: roll with zero count is no-op', () => {
const store = new DiceRollSessionStore();
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
const before = store.getState().revision;
store.dispatch({ kind: 'roll', count: 0, sides: 6 });
assert.equal(store.getState().revision, before);
assert.deepEqual(store.getState().values, []);
}); });
void test('formatPredeterminedDiceNotation', () => { void test('formatPredeterminedDiceNotation', () => {
+18 -2
View File
@@ -27,6 +27,19 @@ export class DiceRollSessionStore {
this.state = { this.state = {
...createInitialDiceRollState(), ...createInitialDiceRollState(),
revision: this.state.revision + 1, revision: this.state.revision + 1,
visibleToPlayers: this.state.visibleToPlayers,
};
return this.state;
}
case 'setVisibleToPlayers': {
if (this.state.visibleToPlayers === event.visible) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
visibleToPlayers: event.visible,
...(event.visible === false && this.state.active
? { active: false, startedAt: null }
: {}),
}; };
return this.state; return this.state;
} }
@@ -43,15 +56,18 @@ export class DiceRollSessionStore {
case 'roll': { case 'roll': {
if (!isDiceSides(event.sides)) return this.state; if (!isDiceSides(event.sides)) return this.state;
const count = clampDiceCount(event.count); const count = clampDiceCount(event.count);
if (count < 1) return this.state;
const values = rollDiceValues(count, event.sides); const values = rollDiceValues(count, event.sides);
const total = values.reduce((a, b) => a + b, 0); const total = values.reduce((a, b) => a + b, 0);
const showToPlayers = this.state.visibleToPlayers;
this.state = { this.state = {
revision: this.state.revision + 1, revision: this.state.revision + 1,
active: true, active: showToPlayers,
visibleToPlayers: showToPlayers,
notation: formatDiceNotation(count, event.sides), notation: formatDiceNotation(count, event.sides),
values, values,
total, total,
startedAt: Date.now(), startedAt: showToPlayers ? Date.now() : null,
}; };
return this.state; return this.state;
} }
@@ -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');
}
+89 -1
View File
@@ -1,13 +1,14 @@
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { app, BrowserWindow, dialog, Menu, protocol } from 'electron'; import { app, BrowserWindow, dialog, Menu, protocol, shell } from 'electron';
import { installStdoutEpipeGuards } from './safeConsole'; import { installStdoutEpipeGuards } from './safeConsole';
installStdoutEpipeGuards(); installStdoutEpipeGuards();
import { openDialogFilterLabel } from '../shared/appBranding'; import { openDialogFilterLabel } from '../shared/appBranding';
import type { SystemDefinition } from '../shared/characterSheet/types';
import { USERS_BRANCH_FEATURES_ENABLED } from '../shared/features/usersBranchFeatures'; import { USERS_BRANCH_FEATURES_ENABLED } from '../shared/features/usersBranchFeatures';
import { ipcChannels, type ScenePreviewImportEvent, type SessionState } from '../shared/ipc/contracts'; import { ipcChannels, type ScenePreviewImportEvent, type SessionState } from '../shared/ipc/contracts';
import { import {
@@ -43,6 +44,7 @@ import { ZipProjectStore } from './project/zipStore';
import { SceneViewStore } from './sceneView/sceneViewStore'; import { SceneViewStore } from './sceneView/sceneViewStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol'; import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
import { PlayersStore } from './players/playersStore'; import { PlayersStore } from './players/playersStore';
import { GameSystemsStore } from './gameSystems/gameSystemsStore';
import { PlaylistsStore } from './playlists/playlistsStore'; import { PlaylistsStore } from './playlists/playlistsStore';
import { SceneNpcTokensSessionStore } from './players/sceneNpcTokensSessionStore'; import { SceneNpcTokensSessionStore } from './players/sceneNpcTokensSessionStore';
import { ScenePlayerTokensSessionStore } from './players/scenePlayerTokensSessionStore'; import { ScenePlayerTokensSessionStore } from './players/scenePlayerTokensSessionStore';
@@ -91,6 +93,8 @@ import {
closeTokenPathEditorWindow, closeTokenPathEditorWindow,
closeNpcsWindow, closeNpcsWindow,
sendToAppWindows, sendToAppWindows,
setDiceWindowClosedListener,
setTimerWindowClosedListener,
syncAllWindowChromeTitles, syncAllWindowChromeTitles,
togglePresentationFullscreen, togglePresentationFullscreen,
waitForEditorWindowReady, waitForEditorWindowReady,
@@ -199,6 +203,7 @@ const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore();
const tokenGridSnapSessionStore = new TokenGridSnapSessionStore(); const tokenGridSnapSessionStore = new TokenGridSnapSessionStore();
let tokensStore: TokensStore | null = null; let tokensStore: TokensStore | null = null;
let playersStore: PlayersStore | null = null; let playersStore: PlayersStore | null = null;
let gameSystemsStore: GameSystemsStore | null = null;
let playlistsStore: PlaylistsStore | null = null; let playlistsStore: PlaylistsStore | null = null;
function emitTokenPathSessionState(): void { function emitTokenPathSessionState(): void {
@@ -293,6 +298,22 @@ function emitSessionTimerState(): void {
} }
} }
function dismissSessionTimerWhenWindowCloses(): void {
const st = timerSessionStore.getState();
let changed = false;
if (st.running) {
timerSessionStore.dispatch({ kind: 'pause' });
changed = true;
}
if (st.visibleToPlayers) {
timerSessionStore.dispatch({ kind: 'setVisibleToPlayers', visible: false });
changed = true;
}
if (changed) emitSessionTimerState();
}
setTimerWindowClosedListener(dismissSessionTimerWhenWindowCloses);
function emitInitiativeState(): void { function emitInitiativeState(): void {
const state = initiativeSessionStore.getState(); const state = initiativeSessionStore.getState();
for (const win of BrowserWindow.getAllWindows()) { for (const win of BrowserWindow.getAllWindows()) {
@@ -317,6 +338,15 @@ function emitDiceRollState(): void {
} }
} }
function hideDiceOverlayWhenWindowCloses(): void {
const st = diceRollSessionStore.getState();
if (!st.visibleToPlayers || !st.active) return;
diceRollSessionStore.dispatch({ kind: 'hideOverlay' });
emitDiceRollState();
}
setDiceWindowClosedListener(hideDiceOverlayWhenWindowCloses);
function syncInitiativePresenceFromSession(): void { function syncInitiativePresenceFromSession(): void {
const project = projectStore.getOpenProject(); const project = projectStore.getOpenProject();
const sceneId = project?.currentSceneId ?? null; const sceneId = project?.currentSceneId ?? null;
@@ -633,6 +663,8 @@ async function main() {
playersStore = new PlayersStore(app.getPath('userData')); playersStore = new PlayersStore(app.getPath('userData'));
await playersStore.ensureLoaded(); await playersStore.ensureLoaded();
} }
gameSystemsStore = new GameSystemsStore(app.getPath('userData'));
await gameSystemsStore.ensureLoaded();
playlistsStore = new PlaylistsStore(app.getPath('userData')); playlistsStore = new PlaylistsStore(app.getPath('userData'));
await playlistsStore.ensureLoaded(); await playlistsStore.ensureLoaded();
setLicenseAssert(() => { setLicenseAssert(() => {
@@ -650,6 +682,18 @@ async function main() {
buildNumber: getOptionalBuildNumber(), buildNumber: getOptionalBuildNumber(),
packaged: app.isPackaged, packaged: app.isPackaged,
})); }));
registerHandler(ipcChannels.app.openExternal, async ({ url }) => {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { ok: false as const, error: 'Only http(s) URLs are allowed' };
}
await shell.openExternal(parsed.toString());
return { ok: true as const };
} catch (err) {
return { ok: false as const, error: err instanceof Error ? err.message : String(err) };
}
});
registerHandler(ipcChannels.license.getStatus, () => licenseService.getStatus()); registerHandler(ipcChannels.license.getStatus, () => licenseService.getStatus());
registerHandler(ipcChannels.license.setToken, async ({ token }) => licenseService.setToken(token)); registerHandler(ipcChannels.license.setToken, async ({ token }) => licenseService.setToken(token));
registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken()); registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken());
@@ -1945,6 +1989,50 @@ async function main() {
return { url: playersStore.getImageUrl(id) }; return { url: playersStore.getImageUrl(id) };
}); });
registerHandler(ipcChannels.gameSystems.list, async () => {
if (!gameSystemsStore) return { systems: [] };
return { systems: await gameSystemsStore.list() };
});
registerHandler(ipcChannels.gameSystems.importFromFile, async () => {
if (!gameSystemsStore) throw new Error('Game systems store unavailable');
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: app.getLocale().toLowerCase().startsWith('ru') ? 'Системы (JSON)' : 'Systems (JSON)',
extensions: ['json'],
},
],
});
if (canceled || filePaths.length === 0) return { canceled: true as const };
const filePath = filePaths[0]!;
const raw = await fs.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as SystemDefinition;
const system = await gameSystemsStore.importSystem(parsed);
return { canceled: false as const, system };
});
registerHandler(ipcChannels.gameSystems.delete, async ({ id }) => {
if (!gameSystemsStore) return { ok: true as const };
await gameSystemsStore.delete(id);
if (playersStore) {
const cleared = await playersStore.clearSystemBindings(id);
if (cleared > 0) emitPlayersState();
}
return { ok: true as const };
});
registerHandler(ipcChannels.gameSystems.get, async ({ id }) => {
if (!gameSystemsStore) return { system: null };
return { system: await gameSystemsStore.get(id) };
});
registerHandler(ipcChannels.gameSystems.bindSnapshot, async ({ systemId }) => {
if (!gameSystemsStore) throw new Error('Game systems store unavailable');
return gameSystemsStore.bindSnapshot(systemId);
});
registerHandler(ipcChannels.gameSystems.getSnapshot, async ({ hash }) => {
if (!gameSystemsStore) return { system: null };
return { system: await gameSystemsStore.getSnapshot(hash) };
});
registerHandler(ipcChannels.playlists.getState, async () => { registerHandler(ipcChannels.playlists.getState, async () => {
if (!playlistsStore) return { playlists: [], audios: [] }; if (!playlistsStore) return { playlists: [], audios: [] };
await playlistsStore.ensureLoaded(); await playlistsStore.ensureLoaded();
+16
View File
@@ -188,6 +188,8 @@ export class PlayersStore {
input.imageScale ?? existing?.imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE, input.imageScale ?? existing?.imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE,
), ),
}; };
if (existing?.systemId !== undefined) player.systemId = existing.systemId;
if (existing?.characterSheet !== undefined) player.characterSheet = existing.characterSheet;
if (existing) { if (existing) {
this.players = this.players.map((p) => (p.id === player.id ? player : p)); this.players = this.players.map((p) => (p.id === player.id ? player : p));
@@ -287,6 +289,20 @@ export class PlayersStore {
return this.getById(playerId); return this.getById(playerId);
} }
/** Clears systemId / characterSheet on players bound to a removed game system. */
async clearSystemBindings(systemId: string): Promise<number> {
await this.ensureLoaded();
let changed = 0;
this.players = this.players.map((p) => {
const hit = p.systemId === systemId || p.characterSheet?.systemId === systemId;
if (!hit) return p;
changed += 1;
return { ...p, systemId: null, characterSheet: null };
});
if (changed > 0) await this.persist();
return changed;
}
private async persist(): Promise<void> { private async persist(): Promise<void> {
await fs.mkdir(this.rootDir, { recursive: true }); await fs.mkdir(this.rootDir, { recursive: true });
const payload: PlayersManifest = { players: this.players, teams: this.teams }; const payload: PlayersManifest = { players: this.players, teams: this.teams };
@@ -81,3 +81,16 @@ void test('clear restores defaults', () => {
assert.equal(store.getState().visibleToPlayers, false); assert.equal(store.getState().visibleToPlayers, false);
assert.equal(store.getState().running, false); assert.equal(store.getState().running, false);
}); });
void test('dismiss on window close: pause + hide from players', () => {
const store = new TimerSessionStore();
const t0 = 1_000_000;
store.dispatch({ kind: 'setDuration', durationMs: 60_000 }, t0);
store.dispatch({ kind: 'setVisibleToPlayers', visible: true });
store.dispatch({ kind: 'start' }, t0);
store.dispatch({ kind: 'pause' }, t0 + 5_000);
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
assert.equal(store.getState().running, false);
assert.equal(store.getState().visibleToPlayers, false);
assert.equal(store.getState().remainingMs, 55_000);
});
+37 -2
View File
@@ -1,6 +1,6 @@
import path from 'node:path'; import path from 'node:path';
import { app, BrowserWindow, screen } from 'electron'; import { app, BrowserWindow, screen, shell } from 'electron';
import type { PresentationSettings } from '../../shared/types/presentationSettings'; import type { PresentationSettings } from '../../shared/types/presentationSettings';
import { readPresentationSettings } from '../preferences/appPreferences'; import { readPresentationSettings } from '../preferences/appPreferences';
@@ -66,13 +66,27 @@ const PLAYLISTS_WINDOW_HEIGHT = 800;
const TIMER_WINDOW_WIDTH = 360; const TIMER_WINDOW_WIDTH = 360;
const TIMER_WINDOW_HEIGHT = 420; const TIMER_WINDOW_HEIGHT = 420;
/** Колбэк при закрытии окна таймера (Esc / крестик / programmatic). */
let timerWindowClosedListener: (() => void) | null = null;
export function setTimerWindowClosedListener(listener: (() => void) | null): void {
timerWindowClosedListener = listener;
}
/** Окно трекера инициативы. */ /** Окно трекера инициативы. */
const INITIATIVE_WINDOW_WIDTH = 920; const INITIATIVE_WINDOW_WIDTH = 920;
const INITIATIVE_WINDOW_HEIGHT = 420; const INITIATIVE_WINDOW_HEIGHT = 420;
/** Компактное окно броска кубиков. */ /** Компактное окно броска кубиков. */
const DICE_WINDOW_WIDTH = 380; const DICE_WINDOW_WIDTH = 380;
const DICE_WINDOW_HEIGHT = 420; const DICE_WINDOW_HEIGHT = 460;
/** Колбэк при закрытии окна кубиков (Esc / крестик / programmatic). */
let diceWindowClosedListener: (() => void) | null = null;
export function setDiceWindowClosedListener(listener: (() => void) | null): void {
diceWindowClosedListener = listener;
}
/** Редактор НПС — как основной редактор, шире инспектор. */ /** Редактор НПС — как основной редактор, шире инспектор. */
const NPCS_EDITOR_WINDOW_WIDTH = 1400; const NPCS_EDITOR_WINDOW_WIDTH = 1400;
@@ -452,6 +466,17 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
win.webContents.on('preload-error', (_event, preloadPath, error) => { win.webContents.on('preload-error', (_event, preloadPath, error) => {
safeConsoleError(`[preload-error] ${preloadPath}:`, error); safeConsoleError(`[preload-error] ${preloadPath}:`, error);
}); });
win.webContents.setWindowOpenHandler(({ url }) => {
try {
const parsed = new URL(url);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
void shell.openExternal(parsed.toString());
}
} catch {
/* ignore invalid urls */
}
return { action: 'deny' };
});
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
// -3 ERR_ABORTED: частый артефакт при navigate/maximize/закрытии — не шумим. // -3 ERR_ABORTED: частый артефакт при navigate/maximize/закрытии — не шумим.
if (errorCode === -3) return; if (errorCode === -3) return;
@@ -491,6 +516,16 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
}); });
} }
win.on('closed', () => windows.delete(kind)); win.on('closed', () => windows.delete(kind));
if (kind === 'timer') {
win.on('closed', () => {
timerWindowClosedListener?.();
});
}
if (kind === 'dice') {
win.on('closed', () => {
diceWindowClosedListener?.();
});
}
if (kind === 'sceneEditor') { if (kind === 'sceneEditor') {
win.on('closed', () => { win.on('closed', () => {
closeTokenPathEditorWindow(); closeTokenPathEditorWindow();
+11 -8
View File
@@ -1224,7 +1224,7 @@ export function ControlApp() {
const cw = cr ? cr.w : ps.w; const cw = cr ? cr.w : ps.w;
const ch = cr ? cr.h : ps.h; const ch = cr ? cr.h : ps.h;
const minDim = Math.min(cw, ch); const minDim = Math.min(cw, ch);
const size = Math.max(2, t.radiusN * minDim * 2); const size = Math.max(1, t.radiusN * minDim * 2);
el.style.left = `${String(ox + p.x * cw)}px`; el.style.left = `${String(ox + p.x * cw)}px`;
el.style.top = `${String(oy + p.y * ch)}px`; el.style.top = `${String(oy + p.y * ch)}px`;
el.style.width = `${String(size)}px`; el.style.width = `${String(size)}px`;
@@ -2110,19 +2110,22 @@ export function ControlApp() {
<div className={styles.radiusLabel}>{t('control.brushRadius')}</div> <div className={styles.radiusLabel}>{t('control.brushRadius')}</div>
<input <input
type="range" type="range"
min={0.015} min={1}
max={0.18} max={36}
step={0.001} step={1}
value={tool.radiusN} value={Math.max(1, Math.min(36, Math.round(tool.radiusN / 0.005)))}
onChange={(e) => { onChange={(e) => {
const v = Number((e.currentTarget as HTMLInputElement).value); const ui = Number((e.currentTarget as HTMLInputElement).value);
const next = Math.max(0.01, Math.min(0.25, Number.isFinite(v) ? v : tool.radiusN)); const uiClamped = Math.max(1, Math.min(36, Number.isFinite(ui) ? Math.round(ui) : 1));
const next = uiClamped * 0.005;
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, radiusN: next } }); void fx.dispatch({ kind: 'tool.set', tool: { ...tool, radiusN: next } });
}} }}
className={styles.range} className={styles.range}
aria-label={t('control.brushRadius')} aria-label={t('control.brushRadius')}
/> />
<div className={styles.radiusValue}>{Math.round(tool.radiusN * 100)}</div> <div className={styles.radiusValue}>
{Math.max(1, Math.min(36, Math.round(tool.radiusN / 0.005)))}
</div>
</div> </div>
{tool.tool === 'exploreBrush' || tool.tool === 'closeBrush' ? ( {tool.tool === 'exploreBrush' || tool.tool === 'closeBrush' ? (
<div className={styles.brushShapeRow}> <div className={styles.brushShapeRow}>
+17
View File
@@ -49,6 +49,14 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 8px; padding: 8px;
background: var(--color-overlay-dark-3, rgba(0, 0, 0, 0.28)); background: var(--color-overlay-dark-3, rgba(0, 0, 0, 0.28));
color: inherit;
width: 100%;
box-sizing: border-box;
outline: none;
}
.countValue:focus {
border-color: rgba(147, 197, 253, 0.75);
} }
.actions { .actions {
@@ -58,6 +66,15 @@
margin-top: 4px; margin-top: 4px;
} }
.checkRow {
display: flex;
align-items: center;
gap: 8px;
font-size: var(--text-sm);
font-weight: 600;
user-select: none;
}
.actions > * { .actions > * {
width: 100%; width: 100%;
max-width: none; max-width: none;
+93 -7
View File
@@ -1,7 +1,13 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { ipcChannels } from '../../shared/ipc/contracts'; import { ipcChannels } from '../../shared/ipc/contracts';
import { DICE_SIDES, type DiceSides } from '../../shared/types/diceRoll'; import {
DICE_COUNT_MAX,
DICE_COUNT_MIN,
DICE_SIDES,
clampDiceCount,
type DiceSides,
} from '../../shared/types/diceRoll';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi'; import { getDndApi } from '../shared/dndApi';
import { useDiceRollState } from '../shared/dice/useDiceRollState'; import { useDiceRollState } from '../shared/dice/useDiceRollState';
@@ -15,6 +21,7 @@ export function DiceApp() {
const [state, diceApi] = useDiceRollState(); const [state, diceApi] = useDiceRollState();
const [sides, setSides] = useState<DiceSides>(20); const [sides, setSides] = useState<DiceSides>(20);
const [count, setCount] = useState(1); const [count, setCount] = useState(1);
const [countDraft, setCountDraft] = useState('1');
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@@ -26,6 +33,32 @@ export function DiceApp() {
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [api]); }, [api]);
const commitCountDraft = () => {
const trimmed = countDraft.trim();
if (trimmed === '') {
setCount(DICE_COUNT_MIN);
setCountDraft(String(DICE_COUNT_MIN));
return;
}
const parsed = Number(trimmed);
if (!Number.isFinite(parsed) || parsed < DICE_COUNT_MIN) {
setCount(DICE_COUNT_MIN);
setCountDraft(String(DICE_COUNT_MIN));
return;
}
const next = clampDiceCount(parsed);
setCount(next);
setCountDraft(String(next));
};
const setCountClamped = (n: number) => {
const next = clampDiceCount(n);
setCount(next);
setCountDraft(String(next));
};
const showToPlayers = state?.visibleToPlayers ?? true;
return ( return (
<div className={styles.page} data-testid="dice-app"> <div className={styles.page} data-testid="dice-app">
<div className={styles.sectionLabel}>{t('dice.sides')}</div> <div className={styles.sectionLabel}>{t('dice.sides')}</div>
@@ -46,27 +79,80 @@ export function DiceApp() {
<div className={styles.countRow}> <div className={styles.countRow}>
<Button <Button
variant="ghost" variant="ghost"
onClick={() => setCount((c) => Math.max(1, c - 1))} onClick={() => setCountClamped(count - 1)}
ariaLabel={t('dice.dec')} ariaLabel={t('dice.dec')}
disabled={count <= DICE_COUNT_MIN}
> >
− −
</Button> </Button>
<div className={styles.countValue} data-testid="dice-count"> <input
{count} className={styles.countValue}
</div> type="text"
inputMode="numeric"
value={countDraft}
onChange={(e) => {
const digits = e.target.value.replace(/\D/g, '');
if (digits === '') {
setCountDraft('');
return;
}
const n = Number(digits);
if (!Number.isFinite(n)) {
setCountDraft(String(DICE_COUNT_MIN));
setCount(DICE_COUNT_MIN);
return;
}
if (n > DICE_COUNT_MAX) {
setCountDraft(String(DICE_COUNT_MAX));
setCount(DICE_COUNT_MAX);
return;
}
setCountDraft(digits);
}}
onBlur={() => commitCountDraft()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
commitCountDraft();
(e.currentTarget as HTMLInputElement).blur();
}
}}
aria-label={t('dice.count')}
data-testid="dice-count"
/>
<Button <Button
variant="ghost" variant="ghost"
onClick={() => setCount((c) => Math.min(20, c + 1))} onClick={() => setCountClamped(count + 1)}
ariaLabel={t('dice.inc')} ariaLabel={t('dice.inc')}
disabled={count >= DICE_COUNT_MAX}
> >
+ +
</Button> </Button>
</div> </div>
<label className={styles.checkRow}>
<input
type="checkbox"
checked={showToPlayers}
onChange={(e) =>
void diceApi.dispatch({ kind: 'setVisibleToPlayers', visible: e.target.checked })
}
data-testid="dice-show-to-players"
/>
<span>{t('dice.showAnimationToPlayers')}</span>
</label>
<div className={styles.actions}> <div className={styles.actions}>
<Button <Button
variant="primary" variant="primary"
onClick={() => void diceApi.dispatch({ kind: 'roll', count, sides })} disabled={clampDiceCount(Number(countDraft.trim()) || count) < 1}
onClick={() => {
const next = clampDiceCount(Number(countDraft.trim()));
setCount(next);
setCountDraft(String(next));
if (next < 1) return;
void diceApi.dispatch({ kind: 'roll', count: next, sides });
}}
data-testid="dice-roll" data-testid="dice-roll"
> >
{t('dice.roll')} {t('dice.roll')}
+7
View File
@@ -688,6 +688,13 @@
margin-top: 4px; margin-top: 4px;
} }
.aboutSponsorsList {
display: flex;
flex-direction: column;
gap: 4px;
line-height: 1.45;
}
.aboutLink { .aboutLink {
color: var(--color-accent, #a78bfa); color: var(--color-accent, #a78bfa);
text-decoration: none; text-decoration: none;
+47
View File
@@ -62,6 +62,7 @@ import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals';
import { PlayersManagerModal } from './PlayersModals'; import { PlayersManagerModal } from './PlayersModals';
import { LaunchPlayersModal } from './LaunchPlayersModal'; import { LaunchPlayersModal } from './LaunchPlayersModal';
import { PresentationSettingsModal } from './PresentationSettingsModal'; import { PresentationSettingsModal } from './PresentationSettingsModal';
import { GameSystemsModal } from './GameSystemsModal';
import { PlaylistsManagerModal } from './PlaylistsModal'; import { PlaylistsManagerModal } from './PlaylistsModal';
import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml'; import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml';
import { SceneDescriptionModal } from './SceneDescriptionModal'; import { SceneDescriptionModal } from './SceneDescriptionModal';
@@ -166,10 +167,28 @@ export function EditorApp() {
const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false); const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false);
const [playlistsManagerOpen, setPlaylistsManagerOpen] = useState(false); const [playlistsManagerOpen, setPlaylistsManagerOpen] = useState(false);
const [presentationSettingsOpen, setPresentationSettingsOpen] = useState(false); const [presentationSettingsOpen, setPresentationSettingsOpen] = useState(false);
const [gameSystemsOpen, setGameSystemsOpen] = useState(false);
const [runMenuOpen, setRunMenuOpen] = useState(false); const [runMenuOpen, setRunMenuOpen] = useState(false);
const [runMenuPos, setRunMenuPos] = useState<{ left: number; top: number } | null>(null); const [runMenuPos, setRunMenuPos] = useState<{ left: number; top: number } | null>(null);
const runSplitRef = useRef<HTMLDivElement | null>(null); const runSplitRef = useRef<HTMLDivElement | null>(null);
const [materialEdit, setMaterialEdit] = useState<ProjectMaterial | null | 'new'>(null); const [materialEdit, setMaterialEdit] = useState<ProjectMaterial | null | 'new'>(null);
const listGameSystems = useCallback(async () => {
const res = await getDndApi().invoke(ipcChannels.gameSystems.list, {});
return res.systems;
}, []);
const importGameSystem = useCallback(async () => {
const res = await getDndApi().invoke(ipcChannels.gameSystems.importFromFile, {});
return res.canceled ? null : res.system;
}, []);
const deleteGameSystem = useCallback(async (id: string) => {
await getDndApi().invoke(ipcChannels.gameSystems.delete, { id });
}, []);
const previewGameSystem = useCallback(async (id: string) => {
console.log('[gameSystems] preview stub', id);
window.alert(`Preview (stub): ${id}`);
}, []);
const onProjectNotice = useCallback( const onProjectNotice = useCallback(
(code: ProjectNoticeCode) => { (code: ProjectNoticeCode) => {
const handlers: Record<ProjectNoticeCode, () => void> = { const handlers: Record<ProjectNoticeCode, () => void> = {
@@ -1360,6 +1379,18 @@ export function EditorApp() {
> >
{t('menu.presentationSettings')} {t('menu.presentationSettings')}
</button> </button>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setSettingsMenuOpen(false);
setSettingsLangSubOpen(false);
setGameSystemsOpen(true);
}}
>
{t('menu.gameSystems')}
</button>
<div className={styles.fileMenuSubHost} role="presentation"> <div className={styles.fileMenuSubHost} role="presentation">
<button <button
type="button" type="button"
@@ -1682,6 +1713,22 @@ export function EditorApp() {
open={presentationSettingsOpen} open={presentationSettingsOpen}
onClose={() => setPresentationSettingsOpen(false)} onClose={() => setPresentationSettingsOpen(false)}
/> />
<GameSystemsModal
open={gameSystemsOpen}
onClose={() => setGameSystemsOpen(false)}
labels={{
title: t('gameSystems.title'),
import: t('gameSystems.import'),
preview: t('gameSystems.preview'),
remove: t('gameSystems.remove'),
empty: t('gameSystems.empty'),
close: t('gameSystems.close'),
}}
listSystems={listGameSystems}
importSystem={importGameSystem}
deleteSystem={deleteGameSystem}
previewSystem={previewGameSystem}
/>
<PlaylistsManagerModal <PlaylistsManagerModal
open={playlistsManagerOpen} open={playlistsManagerOpen}
onClose={() => setPlaylistsManagerOpen(false)} onClose={() => setPlaylistsManagerOpen(false)}
+134
View File
@@ -0,0 +1,134 @@
import { useEffect, useState } from 'react';
import type { GameSystemListItem } from '../../shared/ipc/contracts';
export type { GameSystemListItem };
type Props = {
open: boolean;
onClose: () => void;
labels: {
title: string;
import: string;
preview: string;
remove: string;
empty: string;
close: string;
};
listSystems: () => Promise<GameSystemListItem[]>;
importSystem: () => Promise<GameSystemListItem | null>;
deleteSystem: (id: string) => Promise<void>;
previewSystem: (id: string) => Promise<void>;
};
export function GameSystemsModal({
open,
onClose,
labels,
listSystems,
importSystem,
deleteSystem,
previewSystem,
}: Props) {
const [items, setItems] = useState<GameSystemListItem[]>([]);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!open) return;
void (async () => {
setItems(await listSystems());
})();
}, [open, listSystems]);
if (!open) return null;
return (
<div
data-testid="game-systems-modal"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.55)',
display: 'grid',
placeItems: 'center',
zIndex: 10000,
}}
>
<div
style={{
width: 560,
maxHeight: '80vh',
overflow: 'auto',
background: 'rgba(10,10,14,0.98)',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 12,
padding: 16,
color: 'rgba(255,255,255,0.92)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
<strong>{labels.title}</strong>
<button type="button" data-testid="game-systems-close" onClick={onClose}>
{labels.close}
</button>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<button
type="button"
data-testid="game-systems-import"
disabled={busy}
onClick={async () => {
setBusy(true);
try {
await importSystem();
setItems(await listSystems());
} finally {
setBusy(false);
}
}}
>
{labels.import}
</button>
</div>
{items.length === 0 ? (
<div style={{ opacity: 0.6 }}>{labels.empty}</div>
) : (
items.map((s) => (
<div
key={s.id}
data-testid={`game-system-${s.id}`}
style={{
display: 'flex',
justifyContent: 'space-between',
gap: 8,
padding: '10px 0',
borderBottom: '1px solid rgba(255,255,255,0.08)',
}}
>
<div>
<div>{s.name}</div>
<div style={{ opacity: 0.55, fontSize: 12 }}>
{s.id} · r{s.revision}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" onClick={() => void previewSystem(s.id)}>
{labels.preview}
</button>
<button
type="button"
onClick={async () => {
await deleteSystem(s.id);
setItems(await listSystems());
}}
>
{labels.remove}
</button>
</div>
</div>
))
)}
</div>
</div>
);
}
@@ -13,6 +13,15 @@ import {
} from '../help/helpSections'; } from '../help/helpSections';
import { useEditorI18n } from '../i18n/EditorI18nContext'; import { useEditorI18n } from '../i18n/EditorI18nContext';
const ABOUT_SPONSORS = [
{ kind: 'text', name: 'Исаков Артур' },
{ kind: 'text', name: 'Подчуфарова Евгения' },
{ kind: 'text', name: 'Никитин Виктор' },
{ kind: 'text', name: 'Майер С.В. (Клуб «Серый Май»)' },
{ kind: 'link', name: 'true_rus(КАВ)', url: 'https://truecorp.su/' },
{ kind: 'text', name: 'Макс LonelyFlame Чурсин' },
] as const;
type AppAboutModalProps = { type AppAboutModalProps = {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
@@ -80,6 +89,24 @@ export function AppAboutModal({ open, onClose, appVersion }: AppAboutModalProps)
{t('app.about.websiteUrl')} {t('app.about.websiteUrl')}
</a> </a>
</div> </div>
<div className={styles.fieldLabel}>{t('app.about.sponsorsLabel')}</div>
<div className={styles.aboutSponsorsList}>
{ABOUT_SPONSORS.map((sponsor) =>
sponsor.kind === 'link' ? (
<a
key={sponsor.name}
className={styles.aboutLink}
href={sponsor.url}
target="_blank"
rel="noreferrer"
>
{sponsor.name}
</a>
) : (
<div key={sponsor.name}>{sponsor.name}</div>
),
)}
</div>
</div> </div>
</div> </div>
<div className={styles.modalFooter}> <div className={styles.modalFooter}>
@@ -132,12 +132,20 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'menu.aboutLicense': 'О лицензии', 'menu.aboutLicense': 'О лицензии',
'menu.checkUpdates': 'Проверить обновления', 'menu.checkUpdates': 'Проверить обновления',
'menu.presentationSettings': 'Экран презентации', 'menu.presentationSettings': 'Экран презентации',
'menu.gameSystems': 'Игровые системы',
'menu.language': 'Язык', 'menu.language': 'Язык',
'menu.langRu': 'Русский', 'menu.langRu': 'Русский',
'menu.langEn': 'English', 'menu.langEn': 'English',
'menu.aboutProgram': 'О программе', 'menu.aboutProgram': 'О программе',
'menu.instructions': 'Инструкция', 'menu.instructions': 'Инструкция',
'gameSystems.title': 'Игровые системы',
'gameSystems.import': 'Импорт…',
'gameSystems.preview': 'Просмотр',
'gameSystems.remove': 'Удалить',
'gameSystems.empty': 'Нет импортированных систем',
'gameSystems.close': 'Закрыть',
'top.aboutApp': 'О приложении', 'top.aboutApp': 'О приложении',
'app.about.title': 'О программе', 'app.about.title': 'О программе',
@@ -152,6 +160,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'app.about.supportEmail': 'player.ttrpg@gmail.com', 'app.about.supportEmail': 'player.ttrpg@gmail.com',
'app.about.websiteLabel': 'САЙТ И ОБНОВЛЕНИЯ', 'app.about.websiteLabel': 'САЙТ И ОБНОВЛЕНИЯ',
'app.about.websiteUrl': 'https://ttrpgplayer.ru/', 'app.about.websiteUrl': 'https://ttrpgplayer.ru/',
'app.about.sponsorsLabel': 'СПОНСОРЫ',
'help.title': 'Инструкция', 'help.title': 'Инструкция',
'help.navAria': 'Разделы инструкции', 'help.navAria': 'Разделы инструкции',
@@ -676,6 +685,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'dice.noResult': 'Пока нет броска', 'dice.noResult': 'Пока нет броска',
'dice.inc': 'Больше', 'dice.inc': 'Больше',
'dice.dec': 'Меньше', 'dice.dec': 'Меньше',
'dice.showAnimationToPlayers': 'Показать анимацию игрокам',
'sessionTimer.duration': 'Длительность (MM:SS)', 'sessionTimer.duration': 'Длительность (MM:SS)',
'sessionTimer.start': 'Старт', 'sessionTimer.start': 'Старт',
@@ -877,12 +887,20 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'menu.aboutLicense': 'About license', 'menu.aboutLicense': 'About license',
'menu.checkUpdates': 'Check for updates', 'menu.checkUpdates': 'Check for updates',
'menu.presentationSettings': 'Presentation display', 'menu.presentationSettings': 'Presentation display',
'menu.gameSystems': 'Game systems',
'menu.language': 'Language', 'menu.language': 'Language',
'menu.langRu': 'Русский', 'menu.langRu': 'Русский',
'menu.langEn': 'English', 'menu.langEn': 'English',
'menu.aboutProgram': 'About', 'menu.aboutProgram': 'About',
'menu.instructions': 'Instructions', 'menu.instructions': 'Instructions',
'gameSystems.title': 'Game systems',
'gameSystems.import': 'Import…',
'gameSystems.preview': 'Preview',
'gameSystems.remove': 'Remove',
'gameSystems.empty': 'No imported systems',
'gameSystems.close': 'Close',
'top.aboutApp': 'About', 'top.aboutApp': 'About',
'app.about.title': 'About', 'app.about.title': 'About',
@@ -897,6 +915,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'app.about.supportEmail': 'player.ttrpg@gmail.com', 'app.about.supportEmail': 'player.ttrpg@gmail.com',
'app.about.websiteLabel': 'WEBSITE & UPDATES', 'app.about.websiteLabel': 'WEBSITE & UPDATES',
'app.about.websiteUrl': 'https://ttrpgplayer.ru/', 'app.about.websiteUrl': 'https://ttrpgplayer.ru/',
'app.about.sponsorsLabel': 'SPONSORS',
'help.title': 'Instructions', 'help.title': 'Instructions',
'help.navAria': 'Instruction sections', 'help.navAria': 'Instruction sections',
@@ -1423,6 +1442,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'dice.noResult': 'No roll yet', 'dice.noResult': 'No roll yet',
'dice.inc': 'More', 'dice.inc': 'More',
'dice.dec': 'Fewer', 'dice.dec': 'Fewer',
'dice.showAnimationToPlayers': 'Show animation to players',
'sessionTimer.duration': 'Duration (MM:SS)', 'sessionTimer.duration': 'Duration (MM:SS)',
'sessionTimer.start': 'Start', 'sessionTimer.start': 'Start',
+171
View File
@@ -0,0 +1,171 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { evaluateFormula, buildFormulaContext } from './formula';
import { computeDerived, sortDerived, CycleError } from './derived';
import { validatePointBuy, validateArray, rollDice, sum, validateCreationRules } from './creation';
import { snapshotHash, canonicalJson } from './snapshot';
import { validateSystemDefinition } from './systemValidate';
import type { SystemDefinition } from './types';
import { FORMAT_VERSION, SYSTEM_FORMAT } from './types';
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';
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';
import { buildFormulaContext, evaluateFormula } from './formula';
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';
export * from './formula';
export * from './derived';
export * from './creation';
export * from './snapshot';
export * from './systemValidate';
+34
View File
@@ -0,0 +1,34 @@
import { createHash } from 'node:crypto';
import type { SystemDefinition } from './types';
/** Canonical JSON for stable hashing (sorted keys). */
export function canonicalJson(value: unknown): string {
return JSON.stringify(sortKeys(value));
}
function sortKeys(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortKeys);
if (value && typeof value === 'object') {
const obj = value as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const key of Object.keys(obj).sort()) {
out[key] = sortKeys(obj[key]);
}
return out;
}
return value;
}
export function snapshotHash(system: SystemDefinition): string {
const payload = {
id: system.id,
schemaVersion: system.schemaVersion,
revision: system.revision,
bricks: system.bricks,
creationRules: system.creationRules,
sheetLayout: system.sheetLayout,
compendium: system.compendium,
};
return createHash('sha256').update(canonicalJson(payload)).digest('hex');
}
@@ -0,0 +1,40 @@
import type { SystemDefinition, ValidationIssue, ValidationResult } from './types';
import { SYSTEM_FORMAT, FORMAT_VERSION } from './types';
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[];
};
+47
View File
@@ -76,11 +76,22 @@ import type {
StorylineListItem, StorylineListItem,
StorylineSelection, StorylineSelection,
} from '../graph/storylineExportImport'; } from '../graph/storylineExportImport';
import type { SystemDefinition } from '../characterSheet/types';
/** Catalog row for imported TTRPG systems (userData/game-systems). */
export type GameSystemListItem = {
id: string;
name: string;
edition?: string;
schemaVersion: number;
revision: number;
};
export const ipcChannels = { export const ipcChannels = {
app: { app: {
quit: 'app.quit', quit: 'app.quit',
getVersion: 'app.getVersion', getVersion: 'app.getVersion',
openExternal: 'app.openExternal',
}, },
updater: { updater: {
check: 'updater.check', check: 'updater.check',
@@ -284,6 +295,14 @@ export const ipcChannels = {
upsertProgress: 'players.upsertProgress', upsertProgress: 'players.upsertProgress',
stateChanged: 'players.stateChanged', stateChanged: 'players.stateChanged',
}, },
gameSystems: {
list: 'gameSystems.list',
importFromFile: 'gameSystems.importFromFile',
delete: 'gameSystems.delete',
get: 'gameSystems.get',
bindSnapshot: 'gameSystems.bindSnapshot',
getSnapshot: 'gameSystems.getSnapshot',
},
playlists: { playlists: {
getState: 'playlists.getState', getState: 'playlists.getState',
create: 'playlists.create', create: 'playlists.create',
@@ -427,6 +446,10 @@ export type IpcInvokeMap = {
req: Record<string, never>; req: Record<string, never>;
res: { version: string; buildNumber: string | null; packaged: boolean }; res: { version: string; buildNumber: string | null; packaged: boolean };
}; };
[ipcChannels.app.openExternal]: {
req: { url: string };
res: { ok: true } | { ok: false; error: string };
};
[ipcChannels.updater.check]: { [ipcChannels.updater.check]: {
req: Record<string, never>; req: Record<string, never>;
res: UpdaterCheckResponse; res: UpdaterCheckResponse;
@@ -1052,6 +1075,30 @@ export type IpcInvokeMap = {
req: { id: PlayerId }; req: { id: PlayerId };
res: { url: string | null }; res: { url: string | null };
}; };
[ipcChannels.gameSystems.list]: {
req: Record<string, never>;
res: { systems: GameSystemListItem[] };
};
[ipcChannels.gameSystems.importFromFile]: {
req: Record<string, never>;
res: { canceled: true } | { canceled: false; system: GameSystemListItem };
};
[ipcChannels.gameSystems.delete]: {
req: { id: string };
res: { ok: true };
};
[ipcChannels.gameSystems.get]: {
req: { id: string };
res: { system: SystemDefinition | null };
};
[ipcChannels.gameSystems.bindSnapshot]: {
req: { systemId: string };
res: { systemId: string; snapshotHash: string; system: SystemDefinition };
};
[ipcChannels.gameSystems.getSnapshot]: {
req: { hash: string };
res: { system: SystemDefinition | null };
};
[ipcChannels.playlists.getState]: { [ipcChannels.playlists.getState]: {
req: Record<string, never>; req: Record<string, never>;
res: PlaylistsState; res: PlaylistsState;
+40 -1
View File
@@ -31,6 +31,16 @@ export type AppPlayer = {
imageOffset: PlayerImageOffset; imageOffset: PlayerImageOffset;
/** Масштаб аватара внутри круга (1 = по умолчанию). */ /** Масштаб аватара внутри круга (1 = по умолчанию). */
imageScale: number; imageScale: number;
/** Optional TTRPG system id from Game Systems catalog. */
systemId?: string | null;
/** Frozen sheet binding: snapshotHash + values (schema freeze on bind). */
characterSheet?: {
systemId: string;
snapshotHash: string;
schemaVersion: number;
revisionAtBind: number;
values: Record<string, unknown>;
} | null;
}; };
/** Расстановка кампанийного НПС на карте как игрового токена (в проекте). */ /** Расстановка кампанийного НПС на карте как игрового токена (в проекте). */
@@ -276,7 +286,7 @@ export function normalizeAppPlayer(raw: unknown, teamIds?: Set<string>): AppPlay
if (typeof obj.teamId === 'string' && obj.teamId) { if (typeof obj.teamId === 'string' && obj.teamId) {
if (!teamIds || teamIds.has(obj.teamId)) teamId = asPlayerTeamId(obj.teamId); if (!teamIds || teamIds.has(obj.teamId)) teamId = asPlayerTeamId(obj.teamId);
} }
return { const out: AppPlayer = {
id: asPlayerId(obj.id), id: asPlayerId(obj.id),
name, name,
imageRelPath: obj.imageRelPath, imageRelPath: obj.imageRelPath,
@@ -286,6 +296,35 @@ export function normalizeAppPlayer(raw: unknown, teamIds?: Set<string>): AppPlay
imageOffset: clampPlayerImageOffset(obj.imageOffset), imageOffset: clampPlayerImageOffset(obj.imageOffset),
imageScale: clampPlayerImageScale((obj as { imageScale?: unknown }).imageScale), imageScale: clampPlayerImageScale((obj as { imageScale?: unknown }).imageScale),
}; };
if (obj.systemId === null) out.systemId = null;
else if (typeof obj.systemId === 'string') out.systemId = obj.systemId;
if (obj.characterSheet === null) out.characterSheet = null;
else if (obj.characterSheet && typeof obj.characterSheet === 'object') {
const cs = obj.characterSheet as {
systemId?: unknown;
snapshotHash?: unknown;
schemaVersion?: unknown;
revisionAtBind?: unknown;
values?: unknown;
};
if (
typeof cs.systemId === 'string' &&
typeof cs.snapshotHash === 'string' &&
typeof cs.schemaVersion === 'number' &&
typeof cs.revisionAtBind === 'number' &&
cs.values &&
typeof cs.values === 'object'
) {
out.characterSheet = {
systemId: cs.systemId,
snapshotHash: cs.snapshotHash,
schemaVersion: cs.schemaVersion,
revisionAtBind: cs.revisionAtBind,
values: cs.values as Record<string, unknown>,
};
}
}
return out;
} }
export function normalizeSceneNpcToken(raw: unknown): SceneNpcToken | null { export function normalizeSceneNpcToken(raw: unknown): SceneNpcToken | null {
+11 -3
View File
@@ -14,8 +14,10 @@ export type DiceRollRequest = {
export type DiceRollState = { export type DiceRollState = {
revision: number; revision: number;
/** Идёт показ броска (оверлей виден). */ /** Идёт показ броска (оверлей виден на презентации). */
active: boolean; active: boolean;
/** Показывать 3D-анимацию игрокам на экране презентации. */
visibleToPlayers: boolean;
/** Например "2d20". */ /** Например "2d20". */
notation: string; notation: string;
/** Заранее выбранные исходы — для синхрона пульт/презентация. */ /** Заранее выбранные исходы — для синхрона пульт/презентация. */
@@ -27,6 +29,7 @@ export type DiceRollState = {
export type DiceRollEvent = export type DiceRollEvent =
| { kind: 'roll'; count: number; sides: DiceSides } | { kind: 'roll'; count: number; sides: DiceSides }
| { kind: 'setVisibleToPlayers'; visible: boolean }
| { kind: 'hideOverlay' } | { kind: 'hideOverlay' }
| { kind: 'clear' }; | { kind: 'clear' };
@@ -34,6 +37,7 @@ export function createInitialDiceRollState(): DiceRollState {
return { return {
revision: 1, revision: 1,
active: false, active: false,
visibleToPlayers: true,
notation: '', notation: '',
values: [], values: [],
total: 0, total: 0,
@@ -41,9 +45,13 @@ export function createInitialDiceRollState(): DiceRollState {
}; };
} }
/** Минимум / максимум количества кубиков в UI и store. */
export const DICE_COUNT_MIN = 0;
export const DICE_COUNT_MAX = 20;
export function clampDiceCount(n: number): number { export function clampDiceCount(n: number): number {
if (!Number.isFinite(n)) return 1; if (!Number.isFinite(n)) return DICE_COUNT_MIN;
return Math.max(1, Math.min(20, Math.round(n))); return Math.max(DICE_COUNT_MIN, Math.min(DICE_COUNT_MAX, Math.round(n)));
} }
/** Грани d100 в dice-box-threejs: 10,20,…,90,100 (лицо «00»). */ /** Грани d100 в dice-box-threejs: 10,20,…,90,100 (лицо «00»). */
@@ -9,16 +9,17 @@
**Где:** Пульт → **Инструменты** → 🎲 **Кубики**. **Где:** Пульт → **Инструменты** → 🎲 **Кубики**.
1. Выберите тип кубика: d4, d6, d8, d10, d12, d20 или d100. 1. Выберите тип кубика: d4, d6, d8, d10, d12, d20 или d100.
2. Задайте **Количество** кнопками **−** / **+** (от 1 до 20). 2. Задайте **Количество** кнопками **−** / **+** или введите число вручную (от 0 до 20).
3. Нажмите **Бросить**. 3. При необходимости включите **Показать анимацию игрокам** (по умолчанию включено).
4. На экране игроков (презентация) проиграет 3D-анимация; в окне «Кубики» сразу появятся грани и **Сумма**. 4. Нажмите **Бросить**.
5. Чтобы убрать кубики с экрана и очистить результат — **Убрать**. 5. Если галочка включена — на экране игроков (презентация) проиграет 3D-анимация; в окне «Кубики» сразу появятся грани и **Сумма**. Если выключена — анимация не показывается, результат виден только мастеру в окне «Кубики».
6. **Esc** закрывает окно (результат на экране при этом не снимается, пока не нажали **Убрать**). 6. Чтобы убрать кубики с экрана и очистить результат — **Убрать**.
7. **Esc** (или крестик) закрывает окно. Если была включена галочка показа игрокам и на экране шла анимация — слой кубиков на презентации тоже скрывается (числа в сессии очищаются кнопкой **Убрать**).
**Важно:** **Важно:**
- Анимация показывается на **презентации**; мастер смотрит числа в окне «Кубики». - Анимация показывается на **презентации** только при включённой галочке; мастер всегда смотрит числа в окне «Кубики».
- d100 в приложении — кубик **десятков** (грани 10, 20, …, 90, 00/100). Результат совпадает с гранью, а не с диапазоном 20–29. - d100 в приложении — кубик **десятков** (грани 10, 20, …, 90, 00/100). Результат совпадает с гранью, а не с диапазоном 20–29.
- Оверлей не скрывается сам — только кнопкой **Убрать**. - Оверлей не скрывается сам после броска — только кнопкой **Убрать**, снятием галочки или закрытием окна «Кубики» (при включённом показе игрокам).
- Новый бросок можно сделать, не нажимая **Убрать**. - Новый бросок можно сделать, не нажимая **Убрать**.
--- ---
@@ -54,7 +55,7 @@
3. Включите **Показать игрокам** — виджет появится на предпросмотре и на презентации. 3. Включите **Показать игрокам** — виджет появится на предпросмотре и на презентации.
4. На предпросмотре пульта: перетащите таймер; углы — размер; ручка сверху — поворот. На презентации только просмотр. 4. На предпросмотре пульта: перетащите таймер; углы — размер; ручка сверху — поворот. На презентации только просмотр.
**Важно:** пока идёт отсчёт, длительность менять нельзя. При нуле таймер останавливается. Без **Показать игрокам** игроки его не видят. **Важно:** пока идёт отсчёт, длительность менять нельзя. При нуле таймер останавливается. Без **Показать игрокам** игроки его не видят. Закрытие окна «Таймер» (Esc / крестик) скрывает виджет с презентации и предпросмотра и ставит отсчёт на паузу, если он шёл.
--- ---
Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

+1 -1
View File
@@ -10,7 +10,7 @@
"build:obfuscate": "node scripts/build.mjs --production --obfuscate", "build:obfuscate": "node scripts/build.mjs --production --obfuscate",
"lint": "eslint . --max-warnings 0", "lint": "eslint . --max-warnings 0",
"typecheck": "tsc -p tsconfig.eslint.json --noEmit", "typecheck": "tsc -p tsconfig.eslint.json --noEmit",
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/shared/fieldEffectStampPoints.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/sessionTimer/timerSessionStore.test.ts app/main/initiative/initiativeSessionStore.test.ts app/main/diceRoll/diceRollSessionStore.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/ensureParentDir.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/sceneAudioRef.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts app/renderer/shared/tokens/tokenPathLiveDom.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs", "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/shared/fieldEffectStampPoints.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/sessionTimer/timerSessionStore.test.ts app/main/initiative/initiativeSessionStore.test.ts app/main/diceRoll/diceRollSessionStore.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/ensureParentDir.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/sceneAudioRef.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/gameSystems/gameSystemsStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts app/renderer/shared/tokens/tokenPathLiveDom.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs",
"format": "prettier . --check", "format": "prettier . --check",
"format:write": "prettier . --write", "format:write": "prettier . --write",
"postinstall": "patch-package", "postinstall": "patch-package",