diff --git a/app/main/index.ts b/app/main/index.ts index 1791993..51a6ee9 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -54,6 +54,7 @@ import { closeMaterialsWindow, closeNpcsEditorWindow, closeNpcsWindow, + sendToAppWindows, togglePresentationFullscreen, waitForEditorWindowReady, } from './windows/createWindows'; @@ -229,9 +230,8 @@ function emitSessionState(): void { project, currentSceneId: project?.currentSceneId ?? null, }; - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send(ipcChannels.session.stateChanged, { state }); - } + // Не шлём полный project в editor/splash — редактор обновляется через invoke-ответы. + sendToAppWindows(ipcChannels.session.stateChanged, { state }); } /** @@ -676,8 +676,8 @@ async function main() { }, ); registerHandler(ipcChannels.project.updateNpcPosition, async ({ npcId, x, y }) => { + // Layout-only: Presentation/Control не читают npc.x/y; UI коммитит локально из ответа invoke. const project = await projectStore.updateNpcPosition(npcId, x, y); - emitSessionState(); return { project }; }); registerHandler(ipcChannels.project.deleteNpc, async ({ npcId }) => { @@ -811,8 +811,8 @@ async function main() { return { project }; }); registerHandler(ipcChannels.project.updateSceneGraphNodePosition, async ({ nodeId, x, y }) => { + // Layout-only: граф сцен живёт в editor (optimistic + invoke); session broadcast не нужен. const project = await projectStore.updateSceneGraphNodePosition(nodeId, x, y); - emitSessionState(); return { project }; }); registerHandler(ipcChannels.project.addSceneGraphNode, async ({ sceneId, x, y }) => { diff --git a/app/main/sessionIpc.phase6.networkRegression.test.ts b/app/main/sessionIpc.phase6.networkRegression.test.ts new file mode 100644 index 0000000..444252f --- /dev/null +++ b/app/main/sessionIpc.phase6.networkRegression.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +/** + * Фаза 6 (срез): layout-only мутации не должны слать полный Project во все окна. + * Редактор НПС применяет позицию из ответа invoke локально. + */ +void test('session IPC: нет emitSessionState на graph/NPC position hot-path', () => { + const index = fs.readFileSync(path.join(here, 'index.ts'), 'utf8'); + const createWindows = fs.readFileSync(path.join(here, 'windows/createWindows.ts'), 'utf8'); + const npcsEditor = fs.readFileSync( + path.join(here, '../renderer/npcs/NpcsEditorApp.tsx'), + 'utf8', + ); + + assert.ok(index.includes('sendToAppWindows(ipcChannels.session.stateChanged')); + assert.ok(createWindows.includes('SESSION_STATE_WINDOW_KINDS')); + assert.ok(createWindows.includes('sendToAppWindows')); + const kindsBlock = /SESSION_STATE_WINDOW_KINDS: readonly WindowKind\[\] = \[([\s\S]*?)\] as const/.exec( + createWindows, + ); + assert.ok(kindsBlock, 'SESSION_STATE_WINDOW_KINDS объявлен'); + assert.doesNotMatch(kindsBlock[1] ?? '', /'editor'/, 'editor не получает session.stateChanged'); + assert.match(kindsBlock[1] ?? '', /'presentation'/); + assert.match(kindsBlock[1] ?? '', /'control'/); + + // Handlers больше не вызывают emitSessionState сразу после layout-update. + const npcPosHandler = + /registerHandler\(ipcChannels\.project\.updateNpcPosition,\s*async\s*\(\{ npcId, x, y \}\) => \{([\s\S]*?)\}\);/.exec( + index, + ); + const graphPosHandler = + /registerHandler\(ipcChannels\.project\.updateSceneGraphNodePosition,\s*async\s*\(\{ nodeId, x, y \}\) => \{([\s\S]*?)\}\);/.exec( + index, + ); + assert.ok(npcPosHandler, 'updateNpcPosition handler'); + assert.ok(graphPosHandler, 'updateSceneGraphNodePosition handler'); + assert.doesNotMatch(npcPosHandler[1] ?? '', /emitSessionState/); + assert.doesNotMatch(graphPosHandler[1] ?? '', /emitSessionState/); + assert.match(npcPosHandler[1] ?? '', /return \{ project \}/); + assert.match(graphPosHandler[1] ?? '', /return \{ project \}/); + + assert.ok(npcsEditor.includes('updateNpcPosition')); + assert.match( + npcsEditor, + /onNodePositionCommit[\s\S]*?setSession\([\s\S]*?npcs: prev\.project\.npcs\.map/, + ); + assert.match(npcsEditor, /await api\.invoke\(ipcChannels\.project\.updateNpcPosition/); +}); diff --git a/app/main/windows/createWindows.ts b/app/main/windows/createWindows.ts index 7687e96..03787d6 100644 --- a/app/main/windows/createWindows.ts +++ b/app/main/windows/createWindows.ts @@ -10,7 +10,7 @@ import { safeConsoleError } from '../safeConsole'; import { getBootSplashWindow } from './bootWindow'; import { loadBrandingWindowIcon } from './brandingIcon'; -type WindowKind = +export type WindowKind = | 'editor' | 'presentation' | 'control' @@ -19,6 +19,15 @@ type WindowKind = | 'npcsEditor' | 'npcs'; +/** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */ +export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [ + 'presentation', + 'control', + 'materials', + 'npcs', + 'npcsEditor', +] as const; + const windows = new Map(); let appQuitting = false; @@ -63,6 +72,23 @@ export function markAppQuitting(): void { appQuitting = true; } +/** Точечная рассылка в известные окна приложения (без splash / чужих BrowserWindow). */ +export function sendToAppWindows( + channel: string, + payload: unknown, + kinds: readonly WindowKind[] = SESSION_STATE_WINDOW_KINDS, +): void { + for (const kind of kinds) { + const win = windows.get(kind); + if (!win || win.isDestroyed() || win.webContents.isDestroyed()) continue; + try { + win.webContents.send(channel, payload); + } catch { + /* окно могло закрыться между проверкой и send */ + } + } +} + function quitAppFromEditorClose(): void { markAppQuitting(); app.quit(); diff --git a/app/renderer/npcs/NpcsEditorApp.tsx b/app/renderer/npcs/NpcsEditorApp.tsx index 3947681..9db1b0b 100644 --- a/app/renderer/npcs/NpcsEditorApp.tsx +++ b/app/renderer/npcs/NpcsEditorApp.tsx @@ -685,7 +685,31 @@ export function NpcsEditorApp() { setRelationModal({ mode: 'create', sourceNpcId, targetNpcId }); }} onNodePositionCommit={(npcId, x, y) => { - void api.invoke(ipcChannels.project.updateNpcPosition, { npcId, x, y }); + void (async () => { + setSession((prev) => { + if (!prev?.project) return prev; + return { + ...prev, + project: { + ...prev.project, + npcs: prev.project.npcs.map((n) => (n.id === npcId ? { ...n, x, y } : n)), + }, + }; + }); + try { + const res = await api.invoke(ipcChannels.project.updateNpcPosition, { + npcId, + x, + y, + }); + setSession({ + project: res.project, + currentSceneId: res.project?.currentSceneId ?? null, + }); + } catch { + /* позиция уже оптимистично в UI; следующий session sync поправит при CRUD */ + } + })(); }} onEditRelation={(relationId) => { const rel = relations.find((r) => r.id === relationId); diff --git a/app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts b/app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts index 6da81f6..f68f064 100644 --- a/app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts +++ b/app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts @@ -29,6 +29,8 @@ void test('PxiEffectsOverlay: lazy VFX packs + idle ticker stop', () => { assert.ok(src.includes('collectNeededVfxPacks')); assert.ok(src.includes('syncTickerForState')); assert.ok(src.includes('app.ticker.stop')); + // После clear нужен явный render, иначе «очистить» замораживает кадр вместо исчезновения. + assert.match(src, /app\.render\?\.\(\);[\s\S]*?app\.ticker\.stop/); // Eager preload всех наборов при init убран. assert.doesNotMatch( src, diff --git a/app/renderer/shared/effects/PxiEffectsOverlay.tsx b/app/renderer/shared/effects/PxiEffectsOverlay.tsx index fa23279..75eda3a 100644 --- a/app/renderer/shared/effects/PxiEffectsOverlay.tsx +++ b/app/renderer/shared/effects/PxiEffectsOverlay.tsx @@ -223,6 +223,13 @@ export const PixiEffectsOverlay = forwardRef(fu root.x = 0; root.y = 0; } + // Без этого кадра после syncNodes (clear) canvas остаётся со старым кадром: + // анимация «встаёт», но спрайты визуально не исчезают. + try { + app.render?.(); + } catch { + /* ignore */ + } try { app.ticker.stop(); } catch { diff --git a/package.json b/package.json index 079c5bd..e03e85a 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/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.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/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/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.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/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/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 && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs", + "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.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/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/main/windows/createWindows.editorClose.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/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/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 && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs", "format": "prettier . --check", "format:write": "prettier . --write", "postinstall": "patch-package",