feat(control): история-аккордеон и форма кисти затемнения

Переименовать блок сюжетной линии в «История» и сделать его сворачиваемым. Добавить выбор круглой/квадратной формы для кистей открытия и закрытия с сохранением в штрихах и сбросом при смене инструмента или сцены без затемнения.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-02 10:00:10 +08:00
parent 0a9168b320
commit ea718a26df
8 changed files with 288 additions and 30 deletions
+62 -3
View File
@@ -217,11 +217,42 @@
flex-direction: column;
}
.storyScroll {
flex: 0 0 70%;
.storyWrapCollapsed {
flex: 0 0 auto;
}
.storyWrapCollapsed .historyAccordion {
flex: 0 0 auto;
}
.historyAccordion {
flex: 1;
min-height: 0;
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
border: 1px solid var(--stroke);
border-radius: var(--radius-lg);
overflow: hidden;
background: rgba(255, 255, 255, 0.02);
}
.historyAccordionHead {
width: 100%;
text-align: left;
padding: 10px 12px;
font-size: var(--text-xs);
font-weight: 800;
color: var(--text2);
background: transparent;
border: 0;
cursor: pointer;
flex-shrink: 0;
}
.storyScroll {
flex: 1;
min-height: 0;
border-top: 1px solid var(--stroke);
background: var(--color-overlay-dark-2);
overflow: auto;
padding: 10px;
@@ -394,6 +425,34 @@
pointer-events: none;
}
.brushCursorSquare {
border-radius: 2px;
}
.brushShapeRow {
display: grid;
grid-template-columns: 120px 1fr;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.brushShapeButtons {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.brushShapeIcon {
display: flex;
width: 20px;
height: 20px;
align-items: center;
justify-content: center;
color: var(--text1);
}
.brushLayer {
position: absolute;
inset: 0;
+107 -15
View File
@@ -16,6 +16,7 @@ import type {
MaterialId,
NpcDisposition,
Scene,
SceneDarknessBrushShape,
SceneId,
SceneViewCamera,
} from '../../shared/types';
@@ -185,6 +186,10 @@ export function ControlApp() {
const [session, setSession] = useState<SessionState | null>(null);
const historyRef = useRef<GraphNodeId[]>([]);
const [history, setHistory] = useState<GraphNodeId[]>([]);
const [historyOpen, setHistoryOpen] = useState(true);
const [darknessBrushShape, setDarknessBrushShape] = useState<SceneDarknessBrushShape>('circle');
const darknessBrushShapeRef = useRef(darknessBrushShape);
darknessBrushShapeRef.current = darknessBrushShape;
/** Сцена основного сюжета, с которой ушли в побочную линию (только текущая сессия). */
const mainStoryReturnRef = useRef<GraphNodeId | null>(null);
const [mainStoryReturnGraphNodeId, setMainStoryReturnGraphNodeId] = useState<GraphNodeId | null>(null);
@@ -399,6 +404,12 @@ export function ControlApp() {
);
const isDarkenScene = Boolean(currentScene?.darkenScene);
useEffect(() => {
if (!isDarkenScene) {
setDarknessBrushShape('circle');
}
}, [isDarkenScene]);
const snapNormActive = useCallback(
(nx: number, ny: number) => {
const grid = currentScene?.grid;
@@ -1105,9 +1116,25 @@ export function ControlApp() {
/** Повторный клик по активному инструменту снимает выбор. */
function selectEffectTool(next: Exclude<EffectToolType, 'none'>): void {
const toolName: EffectToolType = tool.tool === next ? 'none' : next;
const leavingDarknessBrush =
(tool.tool === 'exploreBrush' || tool.tool === 'closeBrush') &&
toolName !== 'exploreBrush' &&
toolName !== 'closeBrush';
if (leavingDarknessBrush) {
setDarknessBrushShape('circle');
}
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: toolName } });
}
function darknessBrushDraft(points: { x: number; y: number; tMs: number }[], mode: 'reveal' | 'cover') {
return {
points,
radiusN: toolRef.current.radiusN,
mode,
shape: darknessBrushShapeRef.current,
};
}
function layoutBrushCursor(): void {
const el = brushCursorElRef.current;
const p = cursorPosRef.current;
@@ -1120,6 +1147,13 @@ export function ControlApp() {
return;
}
el.style.visibility = 'visible';
const isDarknessBrush = t.tool === 'exploreBrush' || t.tool === 'closeBrush';
el.className = [
styles.brushCursor,
isDarknessBrush && darknessBrushShapeRef.current === 'square' ? styles.brushCursorSquare : '',
]
.filter(Boolean)
.join(' ');
const ox = cr ? cr.x : 0;
const oy = cr ? cr.y : 0;
const cw = cr ? cr.w : ps.w;
@@ -1297,11 +1331,10 @@ export function ControlApp() {
) {
void sd.dispatch({
kind: 'draft.set',
draft: {
points: b.points,
radiusN: toolRef.current.radiusN,
mode: b.tool === 'closeBrush' ? 'cover' : 'reveal',
},
draft: darknessBrushDraft(
b.points,
b.tool === 'closeBrush' ? 'cover' : 'reveal',
),
});
}
});
@@ -1309,7 +1342,7 @@ export function ControlApp() {
useLayoutEffect(() => {
layoutBrushCursor();
}, [tool.radiusN, previewContentRect, previewSize.w, previewSize.h]);
}, [tool.radiusN, previewContentRect, previewSize.w, previewSize.h, darknessBrushShape]);
useEffect(() => {
return () => {
@@ -1459,6 +1492,7 @@ export function ControlApp() {
points: b.points,
radiusN: tool.radiusN,
mode: b.tool === 'closeBrush' ? 'cover' : 'reveal',
shape: darknessBrushShapeRef.current,
},
});
}
@@ -1891,6 +1925,55 @@ export function ControlApp() {
/>
<div className={styles.radiusValue}>{Math.round(tool.radiusN * 100)}</div>
</div>
{tool.tool === 'exploreBrush' || tool.tool === 'closeBrush' ? (
<div className={styles.brushShapeRow}>
<div className={styles.radiusLabel}>{t('control.brushShape')}</div>
<div className={styles.brushShapeButtons}>
<Button
variant={darknessBrushShape === 'circle' ? 'primary' : 'ghost'}
iconOnly
title={t('control.brushShapeCircle')}
ariaLabel={t('control.brushShapeCircle')}
onClick={() => setDarknessBrushShape('circle')}
>
<span className={styles.brushShapeIcon} aria-hidden>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
<circle
cx="12"
cy="12"
r="7"
fill="none"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</span>
</Button>
<Button
variant={darknessBrushShape === 'square' ? 'primary' : 'ghost'}
iconOnly
title={t('control.brushShapeSquare')}
ariaLabel={t('control.brushShapeSquare')}
onClick={() => setDarknessBrushShape('square')}
>
<span className={styles.brushShapeIcon} aria-hidden>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
<rect
x="5"
y="5"
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="2"
rx="1"
/>
</svg>
</span>
</Button>
</div>
</div>
) : null}
<div className={[styles.radiusRow, styles.effectsSoundRow].join(' ')}>
<div className={styles.radiusLabel}>{t('control.effectsSound')}</div>
<input
@@ -1914,10 +1997,18 @@ export function ControlApp() {
</div>
<div className={styles.spacer12} />
</>
<div className={styles.storyWrap}>
<div className={styles.sectionLabel}>{t('control.storyLine')}</div>
<div className={styles.spacer10} />
<div className={styles.storyScroll}>
<div className={[styles.storyWrap, historyOpen ? '' : styles.storyWrapCollapsed].filter(Boolean).join(' ')}>
<div className={styles.historyAccordion} data-testid="control-history-accordion">
<button
type="button"
className={styles.historyAccordionHead}
aria-expanded={historyOpen}
onClick={() => setHistoryOpen((v) => !v)}
>
{t('control.storyLine')} {historyOpen ? '▾' : '▸'}
</button>
{historyOpen ? (
<div className={styles.storyScroll}>
{history.map((gnId, idx) => {
const gn = project?.sceneGraphNodes.find((n) => n.id === gnId);
const s = gn ? project?.scenes[gn.sceneId] : undefined;
@@ -1952,6 +2043,8 @@ export function ControlApp() {
{history.length === 0 ? (
<div className={styles.emptyStory}>{t('control.noActiveScene')}</div>
) : null}
</div>
) : null}
</div>
</div>
</Surface>
@@ -2175,11 +2268,10 @@ export function ControlApp() {
if (tool.tool === 'exploreBrush' || tool.tool === 'closeBrush') {
void sd.dispatch({
kind: 'draft.set',
draft: {
points: [startPt],
radiusN: tool.radiusN,
mode: tool.tool === 'closeBrush' ? 'cover' : 'reveal',
},
draft: darknessBrushDraft(
[startPt],
tool.tool === 'closeBrush' ? 'cover' : 'reveal',
),
});
}
pushDraftToPixi();
@@ -113,7 +113,7 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
instruments !== -1 && fx !== -1 && instruments < fx,
'Блок инструментов должен быть выше эффектов',
);
assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше сюжетной линии');
assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше истории');
});
void test('ControlApp: чекбокс привязки токенов к сетке', () => {
@@ -128,11 +128,14 @@ void test('ControlApp: чекбокс привязки токенов к сет
assert.ok(css.includes('.snapToGrid'));
});
void test('ControlApp: сюжетная линия — колонка сверху вниз и фон как у карточек ветвления', () => {
void test('ControlApp: история — колонка сверху вниз и фон как у карточек ветвления', () => {
const src = readControlApp();
const css = readControlAppCss();
const story = src.indexOf("t('control.storyLine')");
assert.ok(story !== -1);
assert.ok(src.includes('data-testid="control-history-accordion"'));
assert.ok(src.includes('aria-expanded={historyOpen}'));
assert.ok(src.includes('const [historyOpen, setHistoryOpen] = useState(true)'));
assert.ok(src.includes('className={styles.storyScroll}'));
assert.match(css, /\.storyScroll[\s\S]*?justify-content:\s*flex-start/);
assert.match(css, /\.storyScroll[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
@@ -173,6 +176,27 @@ void test('ControlApp: слой кисти не использует курсо
assert.match(css, /\.brushLayer[\s\S]*?cursor:\s*crosshair/);
});
void test('ControlApp: форма кисти затемнения — UI, shape в штрихах и сброс', () => {
const src = readControlApp();
const css = readControlAppCss();
assert.ok(src.includes("t('control.brushShape')"));
assert.ok(src.includes("t('control.brushShapeCircle')"));
assert.ok(src.includes("t('control.brushShapeSquare')"));
assert.ok(src.includes('darknessBrushShape'));
assert.ok(src.includes('darknessBrushDraft'));
assert.ok(src.includes("shape: darknessBrushShapeRef.current"));
assert.ok(src.includes('setDarknessBrushShape'));
assert.ok(src.includes('styles.brushShapeRow'));
assert.ok(src.includes("tool.tool === 'exploreBrush' || tool.tool === 'closeBrush'"));
assert.ok(css.includes('.brushCursorSquare'));
assert.ok(css.includes('.brushShapeRow'));
const radius = src.indexOf("t('control.brushRadius')");
const shape = src.indexOf("t('control.brushShape')");
const sound = src.indexOf("t('control.effectsSound')");
assert.ok(radius !== -1 && shape !== -1 && sound !== -1);
assert.ok(radius < shape && shape < sound, 'Форма кисти между радиусом и звуком эффектов');
});
void test('ControlApp: радиус кисти не в блоке предпросмотра', () => {
const src = readControlApp();
const previewLabel = src.indexOf("t('control.screenPreview')");
+8 -2
View File
@@ -630,8 +630,11 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.poisonCloud': 'Облако яда',
'control.explosion': 'Взрыв',
'control.brushRadius': 'Радиус кисти',
'control.brushShape': 'Форма кисти',
'control.brushShapeCircle': 'Круглая',
'control.brushShapeSquare': 'Квадратная',
'control.effectsSound': 'Звук эффектов',
'control.storyLine': 'СЮЖЕТНАЯ ЛИНИЯ',
'control.storyLine': 'ИСТОРИЯ',
'control.gotoScene': 'Перейти к этой сцене',
'control.currentSceneBadge': 'ТЕКУЩАЯ СЦЕНА',
'control.passed': 'Пройдено',
@@ -1267,8 +1270,11 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.poisonCloud': 'Poison cloud',
'control.explosion': 'Explosion',
'control.brushRadius': 'Brush radius',
'control.brushShape': 'Brush shape',
'control.brushShapeCircle': 'Circle',
'control.brushShapeSquare': 'Square',
'control.effectsSound': 'Effects sound',
'control.storyLine': 'STORYLINE',
'control.storyLine': 'HISTORY',
'control.gotoScene': 'Go to this scene',
'control.currentSceneBadge': 'CURRENT SCENE',
'control.passed': 'Visited',
@@ -1,11 +1,15 @@
import React, { useEffect, useRef } from 'react';
import type {
SceneDarknessBrushShape,
SceneDarknessRevealStroke,
SceneDarknessState,
SceneDarknessStrokeMode,
} from '../../../shared/types';
import { normalizeSceneDarknessStrokeMode } from '../../../shared/types/sceneDarkness';
import {
normalizeSceneDarknessBrushShape,
normalizeSceneDarknessStrokeMode,
} from '../../../shared/types/sceneDarkness';
export type SceneDarknessOverlayProps = {
state: SceneDarknessState | null;
@@ -15,18 +19,44 @@ export type SceneDarknessOverlayProps = {
style?: React.CSSProperties;
};
function drawDarknessStroke(
type DarknessStrokeLike = SceneDarknessRevealStroke | {
points: { x: number; y: number }[];
radiusN: number;
mode?: SceneDarknessStrokeMode;
shape?: SceneDarknessBrushShape;
};
function stampSquare(
ctx: CanvasRenderingContext2D,
stroke: SceneDarknessRevealStroke | { points: { x: number; y: number }[]; radiusN: number; mode?: SceneDarknessStrokeMode },
x: number,
y: number,
side: number,
): void {
ctx.fillRect(x - side / 2, y - side / 2, side, side);
}
function drawSquareDarknessStroke(
ctx: CanvasRenderingContext2D,
stroke: DarknessStrokeLike,
w: number,
h: number,
): void {
const pts = stroke.points;
if (pts.length === 0) return;
const side = stroke.radiusN * Math.min(w, h) * 2;
for (const p of pts) {
stampSquare(ctx, p.x * w, p.y * h, side);
}
}
function drawCircleDarknessStroke(
ctx: CanvasRenderingContext2D,
stroke: DarknessStrokeLike,
w: number,
h: number,
): void {
const pts = stroke.points;
if (pts.length === 0) return;
const mode = normalizeSceneDarknessStrokeMode(stroke.mode);
ctx.globalCompositeOperation = mode === 'cover' ? 'source-over' : 'destination-out';
ctx.fillStyle = '#000000';
ctx.strokeStyle = '#000000';
const r = stroke.radiusN * Math.min(w, h);
if (pts.length === 1) {
const p = pts[0];
@@ -51,6 +81,24 @@ function drawDarknessStroke(
ctx.stroke();
}
function drawDarknessStroke(
ctx: CanvasRenderingContext2D,
stroke: DarknessStrokeLike,
w: number,
h: number,
): void {
const mode = normalizeSceneDarknessStrokeMode(stroke.mode);
const shape = normalizeSceneDarknessBrushShape(stroke.shape);
ctx.globalCompositeOperation = mode === 'cover' ? 'source-over' : 'destination-out';
ctx.fillStyle = '#000000';
ctx.strokeStyle = '#000000';
if (shape === 'square') {
drawSquareDarknessStroke(ctx, stroke, w, h);
return;
}
drawCircleDarknessStroke(ctx, stroke, w, h);
}
export function SceneDarknessOverlay({
state,
viewport,
+20
View File
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
normalizeSceneDarknessBrushShape,
normalizeSceneDarknessStrokeMode,
} from './sceneDarkness';
void test('normalizeSceneDarknessStrokeMode: reveal по умолчанию', () => {
assert.equal(normalizeSceneDarknessStrokeMode(undefined), 'reveal');
assert.equal(normalizeSceneDarknessStrokeMode('reveal'), 'reveal');
assert.equal(normalizeSceneDarknessStrokeMode('cover'), 'cover');
});
void test('normalizeSceneDarknessBrushShape: circle по умолчанию', () => {
assert.equal(normalizeSceneDarknessBrushShape(undefined), 'circle');
assert.equal(normalizeSceneDarknessBrushShape('circle'), 'circle');
assert.equal(normalizeSceneDarknessBrushShape('square'), 'square');
assert.equal(normalizeSceneDarknessBrushShape('triangle'), 'circle');
});
+9
View File
@@ -3,6 +3,8 @@ import type { NPoint } from './effects';
/** reveal — снимает тьму (Кисть Открытия); cover — возвращает тьму (Кисть Закрытия). */
export type SceneDarknessStrokeMode = 'reveal' | 'cover';
export type SceneDarknessBrushShape = 'circle' | 'square';
export type SceneDarknessRevealStroke = {
id: string;
seed: number;
@@ -11,12 +13,15 @@ export type SceneDarknessRevealStroke = {
radiusN: number;
/** По умолчанию reveal (старые штрихи без поля). */
mode?: SceneDarknessStrokeMode;
/** По умолчанию circle (старые штрихи без поля). */
shape?: SceneDarknessBrushShape;
};
export type SceneDarknessDraft = {
points: NPoint[];
radiusN: number;
mode?: SceneDarknessStrokeMode;
shape?: SceneDarknessBrushShape;
};
export type SceneDarknessState = {
@@ -35,3 +40,7 @@ export type SceneDarknessEvent =
export function normalizeSceneDarknessStrokeMode(raw: unknown): SceneDarknessStrokeMode {
return raw === 'cover' ? 'cover' : 'reveal';
}
export function normalizeSceneDarknessBrushShape(raw: unknown): SceneDarknessBrushShape {
return raw === 'square' ? 'square' : 'circle';
}
+1 -1
View File
@@ -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/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/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/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.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/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/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/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 && 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/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/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/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.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/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/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/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 && 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",