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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-13 15:37:44 +08:00
parent 3d0474fe97
commit 712086ddca
32 changed files with 1712 additions and 34 deletions
+38 -1
View File
@@ -13,12 +13,37 @@ void test('diceRoll: roll produces values in range and notation', () => {
store.dispatch({ kind: 'roll', count: 3, sides: 6 });
const st = store.getState();
assert.equal(st.active, true);
assert.equal(st.visibleToPlayers, true);
assert.equal(st.notation, '3d6');
assert.equal(st.values.length, 3);
assert.ok(st.values.every((v) => v >= 1 && v <= 6));
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', () => {
const store = new DiceRollSessionStore();
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));
});
void test('diceRoll: clear resets', () => {
void test('diceRoll: clear resets but keeps visibleToPlayers', () => {
const store = new DiceRollSessionStore();
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
store.dispatch({ kind: 'roll', count: 1, sides: 20 });
store.dispatch({ kind: 'clear' });
assert.equal(store.getState().active, false);
assert.deepEqual(store.getState().values, []);
assert.equal(store.getState().visibleToPlayers, false);
});
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.equal(store.getState().total, total);
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', () => {
+18 -2
View File
@@ -27,6 +27,19 @@ export class DiceRollSessionStore {
this.state = {
...createInitialDiceRollState(),
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;
}
@@ -43,15 +56,18 @@ export class DiceRollSessionStore {
case 'roll': {
if (!isDiceSides(event.sides)) return this.state;
const count = clampDiceCount(event.count);
if (count < 1) return this.state;
const values = rollDiceValues(count, event.sides);
const total = values.reduce((a, b) => a + b, 0);
const showToPlayers = this.state.visibleToPlayers;
this.state = {
revision: this.state.revision + 1,
active: true,
active: showToPlayers,
visibleToPlayers: showToPlayers,
notation: formatDiceNotation(count, event.sides),
values,
total,
startedAt: Date.now(),
startedAt: showToPlayers ? Date.now() : null,
};
return this.state;
}
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
import { GameSystemsStore } from './gameSystemsStore';
import type { SystemDefinition } from '../../shared/characterSheet/types';
import { FORMAT_VERSION, SYSTEM_FORMAT } from '../../shared/characterSheet/types';
function sampleSystem(): SystemDefinition {
return {
format: SYSTEM_FORMAT,
formatVersion: FORMAT_VERSION,
id: 'shadowdark',
name: 'Shadowdark',
schemaVersion: 1,
revision: 1,
bricks: { attributes: [{ key: 'str', label: 'STR', scale: 'numeric', default: 10 }] },
creationRules: [],
sheetLayout: { pageSize: 'fluid', grid: { size: 8 }, pages: [{ id: 'main', label: 'Main', widgets: [] }] },
};
}
test('GameSystemsStore import list delete', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gs-'));
const store = new GameSystemsStore(dir);
await store.importSystem(sampleSystem());
const list = await store.list();
assert.equal(list.length, 1);
assert.equal(list[0]!.id, 'shadowdark');
const bind = await store.bindSnapshot('shadowdark');
assert.ok(bind.snapshotHash.length === 64);
const snap = await store.getSnapshot(bind.snapshotHash);
assert.equal(snap?.id, 'shadowdark');
await store.delete('shadowdark');
assert.equal((await store.list()).length, 0);
});
+129
View File
@@ -0,0 +1,129 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import type { GameSystemListItem } from '../../shared/ipc/contracts';
import type { SystemDefinition } from '../../shared/characterSheet/types';
import { validateSystemDefinition } from '../../shared/characterSheet/systemValidate';
import { snapshotHash } from '../../shared/characterSheet/snapshot';
export type { GameSystemListItem };
type Manifest = {
systems: GameSystemListItem[];
};
export class GameSystemsStore {
private readonly rootDir: string;
private readonly manifestPath: string;
private readonly snapshotsDir: string;
private manifest: Manifest = { systems: [] };
private loaded = false;
constructor(userData: string) {
this.rootDir = path.join(userData, 'game-systems');
this.manifestPath = path.join(this.rootDir, 'manifest.json');
this.snapshotsDir = path.join(this.rootDir, 'snapshots');
}
async ensureLoaded(): Promise<void> {
if (this.loaded) return;
await fs.mkdir(this.snapshotsDir, { recursive: true });
try {
const raw = await fs.readFile(this.manifestPath, 'utf8');
this.manifest = JSON.parse(raw) as Manifest;
if (!Array.isArray(this.manifest.systems)) this.manifest.systems = [];
} catch {
this.manifest = { systems: [] };
}
this.loaded = true;
}
async list(): Promise<GameSystemListItem[]> {
await this.ensureLoaded();
return [...this.manifest.systems];
}
private systemPath(id: string): string {
return path.join(this.rootDir, id, 'system.json');
}
async get(id: string): Promise<SystemDefinition | null> {
await this.ensureLoaded();
try {
const raw = await fs.readFile(this.systemPath(id), 'utf8');
return JSON.parse(raw) as SystemDefinition;
} catch {
return null;
}
}
async importSystem(system: SystemDefinition): Promise<GameSystemListItem> {
await this.ensureLoaded();
const check = validateSystemDefinition(system);
if (!check.ok) throw new Error(check.errors.map((e) => e.message).join('; '));
const dir = path.join(this.rootDir, system.id);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(this.systemPath(system.id), JSON.stringify(system, null, 2), 'utf8');
const hash = snapshotHash(system);
await fs.writeFile(path.join(this.snapshotsDir, `${hash}.json`), JSON.stringify(system), 'utf8');
const item: GameSystemListItem = {
id: system.id,
name: system.name,
edition: system.edition,
schemaVersion: system.schemaVersion,
revision: system.revision,
};
this.manifest.systems = this.manifest.systems.filter((s) => s.id !== system.id).concat(item);
await this.saveManifest();
return item;
}
async delete(id: string): Promise<string[]> {
await this.ensureLoaded();
this.manifest.systems = this.manifest.systems.filter((s) => s.id !== id);
await this.saveManifest();
try {
await fs.rm(path.join(this.rootDir, id), { recursive: true, force: true });
} catch {
// ignore
}
// Caller clears player bindings; return id for convenience
return [id];
}
async getSnapshot(hash: string): Promise<SystemDefinition | null> {
try {
const raw = await fs.readFile(path.join(this.snapshotsDir, `${hash}.json`), 'utf8');
return JSON.parse(raw) as SystemDefinition;
} catch {
return null;
}
}
async bindSnapshot(systemId: string): Promise<{ systemId: string; snapshotHash: string; system: SystemDefinition }> {
const system = await this.get(systemId);
if (!system) throw new Error(`System not found: ${systemId}`);
const hash = snapshotHash(system);
const snapPath = path.join(this.snapshotsDir, `${hash}.json`);
try {
await fs.access(snapPath);
} catch {
await fs.writeFile(snapPath, JSON.stringify(system), 'utf8');
}
return { systemId, snapshotHash: hash, system };
}
private async saveManifest(): Promise<void> {
await fs.mkdir(this.rootDir, { recursive: true });
await fs.writeFile(this.manifestPath, JSON.stringify(this.manifest, null, 2), 'utf8');
}
}
/** Test helper — content hash of arbitrary buffer. */
export function sha256Hex(data: string): string {
return createHash('sha256').update(data).digest('hex');
}
+89 -1
View File
@@ -1,13 +1,14 @@
import fs from 'node:fs/promises';
import 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';
installStdoutEpipeGuards();
import { openDialogFilterLabel } from '../shared/appBranding';
import type { SystemDefinition } from '../shared/characterSheet/types';
import { USERS_BRANCH_FEATURES_ENABLED } from '../shared/features/usersBranchFeatures';
import { ipcChannels, type ScenePreviewImportEvent, type SessionState } from '../shared/ipc/contracts';
import {
@@ -43,6 +44,7 @@ import { ZipProjectStore } from './project/zipStore';
import { SceneViewStore } from './sceneView/sceneViewStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
import { PlayersStore } from './players/playersStore';
import { GameSystemsStore } from './gameSystems/gameSystemsStore';
import { PlaylistsStore } from './playlists/playlistsStore';
import { SceneNpcTokensSessionStore } from './players/sceneNpcTokensSessionStore';
import { ScenePlayerTokensSessionStore } from './players/scenePlayerTokensSessionStore';
@@ -91,6 +93,8 @@ import {
closeTokenPathEditorWindow,
closeNpcsWindow,
sendToAppWindows,
setDiceWindowClosedListener,
setTimerWindowClosedListener,
syncAllWindowChromeTitles,
togglePresentationFullscreen,
waitForEditorWindowReady,
@@ -199,6 +203,7 @@ const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore();
const tokenGridSnapSessionStore = new TokenGridSnapSessionStore();
let tokensStore: TokensStore | null = null;
let playersStore: PlayersStore | null = null;
let gameSystemsStore: GameSystemsStore | null = null;
let playlistsStore: PlaylistsStore | null = null;
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 {
const state = initiativeSessionStore.getState();
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 {
const project = projectStore.getOpenProject();
const sceneId = project?.currentSceneId ?? null;
@@ -633,6 +663,8 @@ async function main() {
playersStore = new PlayersStore(app.getPath('userData'));
await playersStore.ensureLoaded();
}
gameSystemsStore = new GameSystemsStore(app.getPath('userData'));
await gameSystemsStore.ensureLoaded();
playlistsStore = new PlaylistsStore(app.getPath('userData'));
await playlistsStore.ensureLoaded();
setLicenseAssert(() => {
@@ -650,6 +682,18 @@ async function main() {
buildNumber: getOptionalBuildNumber(),
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.setToken, async ({ token }) => licenseService.setToken(token));
registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken());
@@ -1945,6 +1989,50 @@ async function main() {
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 () => {
if (!playlistsStore) return { playlists: [], audios: [] };
await playlistsStore.ensureLoaded();
+16
View File
@@ -188,6 +188,8 @@ export class PlayersStore {
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) {
this.players = this.players.map((p) => (p.id === player.id ? player : p));
@@ -287,6 +289,20 @@ export class PlayersStore {
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> {
await fs.mkdir(this.rootDir, { recursive: true });
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().running, false);
});
void test('dismiss on window close: pause + hide from players', () => {
const store = new TimerSessionStore();
const t0 = 1_000_000;
store.dispatch({ kind: 'setDuration', durationMs: 60_000 }, t0);
store.dispatch({ kind: 'setVisibleToPlayers', visible: true });
store.dispatch({ kind: 'start' }, t0);
store.dispatch({ kind: 'pause' }, t0 + 5_000);
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
assert.equal(store.getState().running, false);
assert.equal(store.getState().visibleToPlayers, false);
assert.equal(store.getState().remainingMs, 55_000);
});
+37 -2
View File
@@ -1,6 +1,6 @@
import path from 'node:path';
import { app, BrowserWindow, screen } from 'electron';
import { app, BrowserWindow, screen, shell } from 'electron';
import type { PresentationSettings } from '../../shared/types/presentationSettings';
import { readPresentationSettings } from '../preferences/appPreferences';
@@ -66,13 +66,27 @@ const PLAYLISTS_WINDOW_HEIGHT = 800;
const TIMER_WINDOW_WIDTH = 360;
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_HEIGHT = 420;
/** Компактное окно броска кубиков. */
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;
@@ -452,6 +466,17 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
win.webContents.on('preload-error', (_event, 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) => {
// -3 ERR_ABORTED: частый артефакт при navigate/maximize/закрытии — не шумим.
if (errorCode === -3) return;
@@ -491,6 +516,16 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
});
}
win.on('closed', () => windows.delete(kind));
if (kind === 'timer') {
win.on('closed', () => {
timerWindowClosedListener?.();
});
}
if (kind === 'dice') {
win.on('closed', () => {
diceWindowClosedListener?.();
});
}
if (kind === 'sceneEditor') {
win.on('closed', () => {
closeTokenPathEditorWindow();