feat: кубики/таймер/кисть/спонсоры и задел игровых систем
Показ анимации кубиков игрокам, закрытие оверлеев с окнами, ручной ввод количества, меньший радиус кисти, блок спонсоров в «О программе»; плюс черновик game systems / character sheet. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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', () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: [] }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/** Minimal safe formula DSL for derived stats. */
|
||||||
|
|
||||||
|
export type FormulaRound = 'floor' | 'ceil' | 'round' | 'none';
|
||||||
|
|
||||||
|
export type FormulaContext = Record<string, number>;
|
||||||
|
|
||||||
|
const IDENT = /^(attr|skill|resource|derived|val)\.[A-Za-z_][A-Za-z0-9_]*$/;
|
||||||
|
|
||||||
|
function tokenize(input: string): string[] {
|
||||||
|
const src = input.replace(/\s+/g, '');
|
||||||
|
const tokens: string[] = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < src.length) {
|
||||||
|
const ch = src[i]!;
|
||||||
|
if ('+-*/%(),'.includes(ch)) {
|
||||||
|
tokens.push(ch);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/[0-9.]/.test(ch)) {
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < src.length && /[0-9.]/.test(src[j]!)) j += 1;
|
||||||
|
tokens.push(src.slice(i, j));
|
||||||
|
i = j;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/[A-Za-z_]/.test(ch)) {
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < src.length && /[A-Za-z0-9_.]/.test(src[j]!)) j += 1;
|
||||||
|
tokens.push(src.slice(i, j));
|
||||||
|
i = j;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected character '${ch}' in formula`);
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Parser = {
|
||||||
|
tokens: string[];
|
||||||
|
i: number;
|
||||||
|
ctx: FormulaContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
function peek(p: Parser): string | undefined {
|
||||||
|
return p.tokens[p.i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function consume(p: Parser, expected?: string): string {
|
||||||
|
const t = p.tokens[p.i];
|
||||||
|
if (t === undefined) throw new Error('Unexpected end of formula');
|
||||||
|
if (expected !== undefined && t !== expected) throw new Error(`Expected '${expected}', got '${t}'`);
|
||||||
|
p.i += 1;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseExpression(p: Parser): number {
|
||||||
|
let left = parseTerm(p);
|
||||||
|
while (peek(p) === '+' || peek(p) === '-') {
|
||||||
|
const op = consume(p);
|
||||||
|
const right = parseTerm(p);
|
||||||
|
left = op === '+' ? left + right : left - right;
|
||||||
|
}
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTerm(p: Parser): number {
|
||||||
|
let left = parseUnary(p);
|
||||||
|
while (peek(p) === '*' || peek(p) === '/' || peek(p) === '%') {
|
||||||
|
const op = consume(p);
|
||||||
|
const right = parseUnary(p);
|
||||||
|
if (op === '*') left *= right;
|
||||||
|
else if (op === '/') left /= right;
|
||||||
|
else left %= right;
|
||||||
|
}
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUnary(p: Parser): number {
|
||||||
|
if (peek(p) === '-') {
|
||||||
|
consume(p);
|
||||||
|
return -parseUnary(p);
|
||||||
|
}
|
||||||
|
if (peek(p) === '+') {
|
||||||
|
consume(p);
|
||||||
|
return parseUnary(p);
|
||||||
|
}
|
||||||
|
return parsePrimary(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePrimary(p: Parser): number {
|
||||||
|
const t = peek(p);
|
||||||
|
if (t === undefined) throw new Error('Unexpected end of formula');
|
||||||
|
if (t === '(') {
|
||||||
|
consume(p, '(');
|
||||||
|
const v = parseExpression(p);
|
||||||
|
consume(p, ')');
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
if (/^[0-9]+(\.[0-9]+)?$/.test(t)) {
|
||||||
|
consume(p);
|
||||||
|
return Number(t);
|
||||||
|
}
|
||||||
|
if (IDENT.test(t)) {
|
||||||
|
consume(p);
|
||||||
|
const v = p.ctx[t];
|
||||||
|
if (typeof v !== 'number' || Number.isNaN(v)) {
|
||||||
|
throw new Error(`Unknown or non-numeric identifier '${t}'`);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
// function call
|
||||||
|
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(t) && p.tokens[p.i + 1] === '(') {
|
||||||
|
const name = consume(p);
|
||||||
|
consume(p, '(');
|
||||||
|
const args: number[] = [];
|
||||||
|
if (peek(p) !== ')') {
|
||||||
|
args.push(parseExpression(p));
|
||||||
|
while (peek(p) === ',') {
|
||||||
|
consume(p, ',');
|
||||||
|
args.push(parseExpression(p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
consume(p, ')');
|
||||||
|
return callFn(name, args);
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected token '${t}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function callFn(name: string, args: number[]): number {
|
||||||
|
switch (name) {
|
||||||
|
case 'floor':
|
||||||
|
return Math.floor(args[0] ?? 0);
|
||||||
|
case 'ceil':
|
||||||
|
return Math.ceil(args[0] ?? 0);
|
||||||
|
case 'round':
|
||||||
|
return Math.round(args[0] ?? 0);
|
||||||
|
case 'abs':
|
||||||
|
return Math.abs(args[0] ?? 0);
|
||||||
|
case 'min':
|
||||||
|
return Math.min(...args);
|
||||||
|
case 'max':
|
||||||
|
return Math.max(...args);
|
||||||
|
case 'clamp': {
|
||||||
|
const [v = 0, lo = 0, hi = 0] = args;
|
||||||
|
return Math.min(hi, Math.max(lo, v));
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown function '${name}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateFormula(
|
||||||
|
formula: string,
|
||||||
|
ctx: FormulaContext,
|
||||||
|
round: FormulaRound = 'none',
|
||||||
|
): number {
|
||||||
|
const p: Parser = { tokens: tokenize(formula), i: 0, ctx };
|
||||||
|
const value = parseExpression(p);
|
||||||
|
if (p.i !== p.tokens.length) throw new Error('Trailing tokens in formula');
|
||||||
|
if (round === 'floor') return Math.floor(value);
|
||||||
|
if (round === 'ceil') return Math.ceil(value);
|
||||||
|
if (round === 'round') return Math.round(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFormulaContext(values: {
|
||||||
|
attributes?: Record<string, number | string | boolean>;
|
||||||
|
skills?: Record<string, number | string | boolean>;
|
||||||
|
resources?: Record<string, number>;
|
||||||
|
derived?: Record<string, number>;
|
||||||
|
}): FormulaContext {
|
||||||
|
const ctx: FormulaContext = {};
|
||||||
|
for (const [k, v] of Object.entries(values.attributes ?? {})) {
|
||||||
|
if (typeof v === 'number') ctx[`attr.${k}`] = v;
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries(values.skills ?? {})) {
|
||||||
|
if (typeof v === 'number') ctx[`skill.${k}`] = v;
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries(values.resources ?? {})) {
|
||||||
|
ctx[`resource.${k}`] = v;
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries(values.derived ?? {})) {
|
||||||
|
ctx[`derived.${k}`] = v;
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export * from './types';
|
||||||
|
export * from './formula';
|
||||||
|
export * from './derived';
|
||||||
|
export * from './creation';
|
||||||
|
export * from './snapshot';
|
||||||
|
export * from './systemValidate';
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user