diff --git a/app/main/diceRoll/diceRollSessionStore.test.ts b/app/main/diceRoll/diceRollSessionStore.test.ts index 187af2b..cc97de4 100644 --- a/app/main/diceRoll/diceRollSessionStore.test.ts +++ b/app/main/diceRoll/diceRollSessionStore.test.ts @@ -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', () => { diff --git a/app/main/diceRoll/diceRollSessionStore.ts b/app/main/diceRoll/diceRollSessionStore.ts index 17c5aa5..200fb2f 100644 --- a/app/main/diceRoll/diceRollSessionStore.ts +++ b/app/main/diceRoll/diceRollSessionStore.ts @@ -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; } diff --git a/app/main/gameSystems/gameSystemsStore.test.ts b/app/main/gameSystems/gameSystemsStore.test.ts new file mode 100644 index 0000000..23841a5 --- /dev/null +++ b/app/main/gameSystems/gameSystemsStore.test.ts @@ -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); +}); diff --git a/app/main/gameSystems/gameSystemsStore.ts b/app/main/gameSystems/gameSystemsStore.ts new file mode 100644 index 0000000..e273137 --- /dev/null +++ b/app/main/gameSystems/gameSystemsStore.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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'); +} diff --git a/app/main/index.ts b/app/main/index.ts index 175b712..4e44f9c 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -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(); diff --git a/app/main/players/playersStore.ts b/app/main/players/playersStore.ts index 819a8f7..298e065 100644 --- a/app/main/players/playersStore.ts +++ b/app/main/players/playersStore.ts @@ -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 { + 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 { await fs.mkdir(this.rootDir, { recursive: true }); const payload: PlayersManifest = { players: this.players, teams: this.teams }; diff --git a/app/main/sessionTimer/timerSessionStore.test.ts b/app/main/sessionTimer/timerSessionStore.test.ts index ead977c..2395278 100644 --- a/app/main/sessionTimer/timerSessionStore.test.ts +++ b/app/main/sessionTimer/timerSessionStore.test.ts @@ -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); +}); diff --git a/app/main/windows/createWindows.ts b/app/main/windows/createWindows.ts index 9791e05..d6959e0 100644 --- a/app/main/windows/createWindows.ts +++ b/app/main/windows/createWindows.ts @@ -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(); diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index 116e60b..ec9be4b 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -1224,7 +1224,7 @@ export function ControlApp() { const cw = cr ? cr.w : ps.w; const ch = cr ? cr.h : ps.h; 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.top = `${String(oy + p.y * ch)}px`; el.style.width = `${String(size)}px`; @@ -2110,19 +2110,22 @@ export function ControlApp() {
{t('control.brushRadius')}
{ - const v = Number((e.currentTarget as HTMLInputElement).value); - const next = Math.max(0.01, Math.min(0.25, Number.isFinite(v) ? v : tool.radiusN)); + const ui = Number((e.currentTarget as HTMLInputElement).value); + 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 } }); }} className={styles.range} aria-label={t('control.brushRadius')} /> -
{Math.round(tool.radiusN * 100)}
+
+ {Math.max(1, Math.min(36, Math.round(tool.radiusN / 0.005)))} +
{tool.tool === 'exploreBrush' || tool.tool === 'closeBrush' ? (
diff --git a/app/renderer/dice/DiceApp.module.css b/app/renderer/dice/DiceApp.module.css index 6e2d257..84eac94 100644 --- a/app/renderer/dice/DiceApp.module.css +++ b/app/renderer/dice/DiceApp.module.css @@ -49,6 +49,14 @@ border-radius: var(--radius-sm); padding: 8px; 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 { @@ -58,6 +66,15 @@ margin-top: 4px; } +.checkRow { + display: flex; + align-items: center; + gap: 8px; + font-size: var(--text-sm); + font-weight: 600; + user-select: none; +} + .actions > * { width: 100%; max-width: none; diff --git a/app/renderer/dice/DiceApp.tsx b/app/renderer/dice/DiceApp.tsx index 444619d..2859fa1 100644 --- a/app/renderer/dice/DiceApp.tsx +++ b/app/renderer/dice/DiceApp.tsx @@ -1,7 +1,13 @@ import React, { useEffect, useState } from 'react'; 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 { getDndApi } from '../shared/dndApi'; import { useDiceRollState } from '../shared/dice/useDiceRollState'; @@ -15,6 +21,7 @@ export function DiceApp() { const [state, diceApi] = useDiceRollState(); const [sides, setSides] = useState(20); const [count, setCount] = useState(1); + const [countDraft, setCountDraft] = useState('1'); useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -26,6 +33,32 @@ export function DiceApp() { return () => window.removeEventListener('keydown', onKey); }, [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 (
{t('dice.sides')}
@@ -46,27 +79,80 @@ export function DiceApp() {
-
- {count} -
+ { + 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" + />
+ +
+
+
+
+ +
+ {items.length === 0 ? ( +
{labels.empty}
+ ) : ( + items.map((s) => ( +
+
+
{s.name}
+
+ {s.id} · r{s.revision} +
+
+
+ + +
+
+ )) + )} +
+
+ ); +} diff --git a/app/renderer/editor/about/EditorAboutModals.tsx b/app/renderer/editor/about/EditorAboutModals.tsx index fca2ec7..648d215 100644 --- a/app/renderer/editor/about/EditorAboutModals.tsx +++ b/app/renderer/editor/about/EditorAboutModals.tsx @@ -13,6 +13,15 @@ import { } from '../help/helpSections'; 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 = { open: boolean; onClose: () => void; @@ -80,6 +89,24 @@ export function AppAboutModal({ open, onClose, appVersion }: AppAboutModalProps) {t('app.about.websiteUrl')}
+
{t('app.about.sponsorsLabel')}
+
+ {ABOUT_SPONSORS.map((sponsor) => + sponsor.kind === 'link' ? ( + + {sponsor.name} + + ) : ( +
{sponsor.name}
+ ), + )} +
diff --git a/app/renderer/editor/i18n/editorMessages.ts b/app/renderer/editor/i18n/editorMessages.ts index 37c2618..bfe1655 100644 --- a/app/renderer/editor/i18n/editorMessages.ts +++ b/app/renderer/editor/i18n/editorMessages.ts @@ -132,12 +132,20 @@ export const EDITOR_MESSAGES: Record> = { 'menu.aboutLicense': 'О лицензии', 'menu.checkUpdates': 'Проверить обновления', 'menu.presentationSettings': 'Экран презентации', + 'menu.gameSystems': 'Игровые системы', 'menu.language': 'Язык', 'menu.langRu': 'Русский', 'menu.langEn': 'English', 'menu.aboutProgram': 'О программе', 'menu.instructions': 'Инструкция', + 'gameSystems.title': 'Игровые системы', + 'gameSystems.import': 'Импорт…', + 'gameSystems.preview': 'Просмотр', + 'gameSystems.remove': 'Удалить', + 'gameSystems.empty': 'Нет импортированных систем', + 'gameSystems.close': 'Закрыть', + 'top.aboutApp': 'О приложении', 'app.about.title': 'О программе', @@ -152,6 +160,7 @@ export const EDITOR_MESSAGES: Record> = { 'app.about.supportEmail': 'player.ttrpg@gmail.com', 'app.about.websiteLabel': 'САЙТ И ОБНОВЛЕНИЯ', 'app.about.websiteUrl': 'https://ttrpgplayer.ru/', + 'app.about.sponsorsLabel': 'СПОНСОРЫ', 'help.title': 'Инструкция', 'help.navAria': 'Разделы инструкции', @@ -676,6 +685,7 @@ export const EDITOR_MESSAGES: Record> = { 'dice.noResult': 'Пока нет броска', 'dice.inc': 'Больше', 'dice.dec': 'Меньше', + 'dice.showAnimationToPlayers': 'Показать анимацию игрокам', 'sessionTimer.duration': 'Длительность (MM:SS)', 'sessionTimer.start': 'Старт', @@ -877,12 +887,20 @@ export const EDITOR_MESSAGES: Record> = { 'menu.aboutLicense': 'About license', 'menu.checkUpdates': 'Check for updates', 'menu.presentationSettings': 'Presentation display', + 'menu.gameSystems': 'Game systems', 'menu.language': 'Language', 'menu.langRu': 'Русский', 'menu.langEn': 'English', 'menu.aboutProgram': 'About', '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', 'app.about.title': 'About', @@ -897,6 +915,7 @@ export const EDITOR_MESSAGES: Record> = { 'app.about.supportEmail': 'player.ttrpg@gmail.com', 'app.about.websiteLabel': 'WEBSITE & UPDATES', 'app.about.websiteUrl': 'https://ttrpgplayer.ru/', + 'app.about.sponsorsLabel': 'SPONSORS', 'help.title': 'Instructions', 'help.navAria': 'Instruction sections', @@ -1423,6 +1442,7 @@ export const EDITOR_MESSAGES: Record> = { 'dice.noResult': 'No roll yet', 'dice.inc': 'More', 'dice.dec': 'Fewer', + 'dice.showAnimationToPlayers': 'Show animation to players', 'sessionTimer.duration': 'Duration (MM:SS)', 'sessionTimer.start': 'Start', diff --git a/app/shared/characterSheet/core.test.ts b/app/shared/characterSheet/core.test.ts new file mode 100644 index 0000000..b21386b --- /dev/null +++ b/app/shared/characterSheet/core.test.ts @@ -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: [] }], + }, + }; +} diff --git a/app/shared/characterSheet/creation.ts b/app/shared/characterSheet/creation.ts new file mode 100644 index 0000000..bc9f207 --- /dev/null +++ b/app/shared/characterSheet/creation.ts @@ -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, +): ValidationIssue[] { + const budget = Number(rule.params.budget ?? 27); + const costs = (rule.params.costs as Record | 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, +): 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); +} diff --git a/app/shared/characterSheet/derived.ts b/app/shared/characterSheet/derived.ts new file mode 100644 index 0000000..eec0705 --- /dev/null +++ b/app/shared/characterSheet/derived.ts @@ -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(); + const adj = new Map(); + + 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; + skills?: Record; + resources?: Record; + }, +): Record { + const ordered = sortDerived(defs); + const derived: Record = {}; + for (const d of ordered) { + const ctx = buildFormulaContext({ ...values, derived }); + derived[d.key] = evaluateFormula(d.formula, ctx, d.round ?? 'none'); + } + return derived; +} diff --git a/app/shared/characterSheet/formula.ts b/app/shared/characterSheet/formula.ts new file mode 100644 index 0000000..3b8ee1f --- /dev/null +++ b/app/shared/characterSheet/formula.ts @@ -0,0 +1,187 @@ +/** Minimal safe formula DSL for derived stats. */ + +export type FormulaRound = 'floor' | 'ceil' | 'round' | 'none'; + +export type FormulaContext = Record; + +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; + skills?: Record; + resources?: Record; + derived?: Record; +}): 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; +} diff --git a/app/shared/characterSheet/index.ts b/app/shared/characterSheet/index.ts new file mode 100644 index 0000000..49bce7a --- /dev/null +++ b/app/shared/characterSheet/index.ts @@ -0,0 +1,6 @@ +export * from './types'; +export * from './formula'; +export * from './derived'; +export * from './creation'; +export * from './snapshot'; +export * from './systemValidate'; diff --git a/app/shared/characterSheet/snapshot.ts b/app/shared/characterSheet/snapshot.ts new file mode 100644 index 0000000..be7da0b --- /dev/null +++ b/app/shared/characterSheet/snapshot.ts @@ -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; + const out: Record = {}; + 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'); +} diff --git a/app/shared/characterSheet/systemValidate.ts b/app/shared/characterSheet/systemValidate.ts new file mode 100644 index 0000000..e7c4751 --- /dev/null +++ b/app/shared/characterSheet/systemValidate.ts @@ -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; + 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 }; +} diff --git a/app/shared/characterSheet/types.ts b/app/shared/characterSheet/types.ts new file mode 100644 index 0000000..10ed76b --- /dev/null +++ b/app/shared/characterSheet/types.ts @@ -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; +}; + +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 & { hide?: boolean; stack?: boolean } }; + props?: Record; +}; + +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[]; +}; + +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 }; +}; + +export type CharacterValues = { + meta?: Record; + attributes?: Record; + skills?: Record; + resources?: Record; + traits?: string[]; + ownedTraitIds?: string[]; + spells?: Array<{ id: string; prepared?: boolean; notes?: string }>; + inventory?: Array>; + derived?: Record; + [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[]; +}; diff --git a/app/shared/ipc/contracts.ts b/app/shared/ipc/contracts.ts index 061ec05..3172ca4 100644 --- a/app/shared/ipc/contracts.ts +++ b/app/shared/ipc/contracts.ts @@ -76,11 +76,22 @@ import type { StorylineListItem, StorylineSelection, } 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 = { app: { quit: 'app.quit', getVersion: 'app.getVersion', + openExternal: 'app.openExternal', }, updater: { check: 'updater.check', @@ -284,6 +295,14 @@ export const ipcChannels = { upsertProgress: 'players.upsertProgress', stateChanged: 'players.stateChanged', }, + gameSystems: { + list: 'gameSystems.list', + importFromFile: 'gameSystems.importFromFile', + delete: 'gameSystems.delete', + get: 'gameSystems.get', + bindSnapshot: 'gameSystems.bindSnapshot', + getSnapshot: 'gameSystems.getSnapshot', + }, playlists: { getState: 'playlists.getState', create: 'playlists.create', @@ -427,6 +446,10 @@ export type IpcInvokeMap = { req: Record; res: { version: string; buildNumber: string | null; packaged: boolean }; }; + [ipcChannels.app.openExternal]: { + req: { url: string }; + res: { ok: true } | { ok: false; error: string }; + }; [ipcChannels.updater.check]: { req: Record; res: UpdaterCheckResponse; @@ -1052,6 +1075,30 @@ export type IpcInvokeMap = { req: { id: PlayerId }; res: { url: string | null }; }; + [ipcChannels.gameSystems.list]: { + req: Record; + res: { systems: GameSystemListItem[] }; + }; + [ipcChannels.gameSystems.importFromFile]: { + req: Record; + 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]: { req: Record; res: PlaylistsState; diff --git a/app/shared/types/appPlayers.ts b/app/shared/types/appPlayers.ts index 2e8ba8f..a41edbf 100644 --- a/app/shared/types/appPlayers.ts +++ b/app/shared/types/appPlayers.ts @@ -31,6 +31,16 @@ export type AppPlayer = { imageOffset: PlayerImageOffset; /** Масштаб аватара внутри круга (1 = по умолчанию). */ 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; + } | null; }; /** Расстановка кампанийного НПС на карте как игрового токена (в проекте). */ @@ -276,7 +286,7 @@ export function normalizeAppPlayer(raw: unknown, teamIds?: Set): AppPlay if (typeof obj.teamId === 'string' && obj.teamId) { if (!teamIds || teamIds.has(obj.teamId)) teamId = asPlayerTeamId(obj.teamId); } - return { + const out: AppPlayer = { id: asPlayerId(obj.id), name, imageRelPath: obj.imageRelPath, @@ -286,6 +296,35 @@ export function normalizeAppPlayer(raw: unknown, teamIds?: Set): AppPlay imageOffset: clampPlayerImageOffset(obj.imageOffset), 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, + }; + } + } + return out; } export function normalizeSceneNpcToken(raw: unknown): SceneNpcToken | null { diff --git a/app/shared/types/diceRoll.ts b/app/shared/types/diceRoll.ts index 6f2c049..25790ee 100644 --- a/app/shared/types/diceRoll.ts +++ b/app/shared/types/diceRoll.ts @@ -14,8 +14,10 @@ export type DiceRollRequest = { export type DiceRollState = { revision: number; - /** Идёт показ броска (оверлей виден). */ + /** Идёт показ броска (оверлей виден на презентации). */ active: boolean; + /** Показывать 3D-анимацию игрокам на экране презентации. */ + visibleToPlayers: boolean; /** Например "2d20". */ notation: string; /** Заранее выбранные исходы — для синхрона пульт/презентация. */ @@ -27,6 +29,7 @@ export type DiceRollState = { export type DiceRollEvent = | { kind: 'roll'; count: number; sides: DiceSides } + | { kind: 'setVisibleToPlayers'; visible: boolean } | { kind: 'hideOverlay' } | { kind: 'clear' }; @@ -34,6 +37,7 @@ export function createInitialDiceRollState(): DiceRollState { return { revision: 1, active: false, + visibleToPlayers: true, notation: '', values: [], 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 { - if (!Number.isFinite(n)) return 1; - return Math.max(1, Math.min(20, Math.round(n))); + if (!Number.isFinite(n)) return DICE_COUNT_MIN; + return Math.max(DICE_COUNT_MIN, Math.min(DICE_COUNT_MAX, Math.round(n))); } /** Грани d100 в dice-box-threejs: 10,20,…,90,100 (лицо «00»). */ diff --git a/docs/instructions-new-features-after-1.0.29.md b/docs/instructions-new-features-after-1.0.29.md index e32582e..37f257a 100644 --- a/docs/instructions-new-features-after-1.0.29.md +++ b/docs/instructions-new-features-after-1.0.29.md @@ -9,16 +9,17 @@ **Где:** Пульт → **Инструменты** → 🎲 **Кубики**. 1. Выберите тип кубика: d4, d6, d8, d10, d12, d20 или d100. -2. Задайте **Количество** кнопками **−** / **+** (от 1 до 20). -3. Нажмите **Бросить**. -4. На экране игроков (презентация) проиграет 3D-анимация; в окне «Кубики» сразу появятся грани и **Сумма**. -5. Чтобы убрать кубики с экрана и очистить результат — **Убрать**. -6. **Esc** закрывает окно (результат на экране при этом не снимается, пока не нажали **Убрать**). +2. Задайте **Количество** кнопками **−** / **+** или введите число вручную (от 0 до 20). +3. При необходимости включите **Показать анимацию игрокам** (по умолчанию включено). +4. Нажмите **Бросить**. +5. Если галочка включена — на экране игроков (презентация) проиграет 3D-анимация; в окне «Кубики» сразу появятся грани и **Сумма**. Если выключена — анимация не показывается, результат виден только мастеру в окне «Кубики». +6. Чтобы убрать кубики с экрана и очистить результат — **Убрать**. +7. **Esc** (или крестик) закрывает окно. Если была включена галочка показа игрокам и на экране шла анимация — слой кубиков на презентации тоже скрывается (числа в сессии очищаются кнопкой **Убрать**). **Важно:** -- Анимация показывается на **презентации**; мастер смотрит числа в окне «Кубики». +- Анимация показывается на **презентации** только при включённой галочке; мастер всегда смотрит числа в окне «Кубики». - d100 в приложении — кубик **десятков** (грани 10, 20, …, 90, 00/100). Результат совпадает с гранью, а не с диапазоном 20–29. -- Оверлей не скрывается сам — только кнопкой **Убрать**. +- Оверлей не скрывается сам после броска — только кнопкой **Убрать**, снятием галочки или закрытием окна «Кубики» (при включённом показе игрокам). - Новый бросок можно сделать, не нажимая **Убрать**. --- @@ -54,7 +55,7 @@ 3. Включите **Показать игрокам** — виджет появится на предпросмотре и на презентации. 4. На предпросмотре пульта: перетащите таймер; углы — размер; ручка сверху — поворот. На презентации только просмотр. -**Важно:** пока идёт отсчёт, длительность менять нельзя. При нуле таймер останавливается. Без **Показать игрокам** игроки его не видят. +**Важно:** пока идёт отсчёт, длительность менять нельзя. При нуле таймер останавливается. Без **Показать игрокам** игроки его не видят. Закрытие окна «Таймер» (Esc / крестик) скрывает виджет с презентации и предпросмотра и ставит отсчёт на паузу, если он шёл. --- diff --git a/docs/vk-covers/vk-cover-1-1-0.png b/docs/vk-covers/vk-cover-1-1-0.png new file mode 100644 index 0000000..c10e1ee Binary files /dev/null and b/docs/vk-covers/vk-cover-1-1-0.png differ diff --git a/docs/vk-covers/vk-cover-key-mailing.png b/docs/vk-covers/vk-cover-key-mailing.png new file mode 100644 index 0000000..aed10d6 Binary files /dev/null and b/docs/vk-covers/vk-cover-key-mailing.png differ diff --git a/docs/vk-covers/vk-cover-keys-sent.png b/docs/vk-covers/vk-cover-keys-sent.png new file mode 100644 index 0000000..a85ac91 Binary files /dev/null and b/docs/vk-covers/vk-cover-keys-sent.png differ diff --git a/package.json b/package.json index b49e686..160a767 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build:obfuscate": "node scripts/build.mjs --production --obfuscate", "lint": "eslint . --max-warnings 0", "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:write": "prettier . --write", "postinstall": "patch-package",