fix(tokens): импорт неигровых токенов из zip и плавные пути на macOS

Полный импорт/открытие проекта подхватывает app-tokens. Движение по путям пишется в DOM без ре-рендера кадра, у пульта и презентации выключен background throttling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-07 16:16:30 +08:00
parent 586166cacd
commit fdccc0deb5
16 changed files with 285 additions and 111 deletions
+22 -3
View File
@@ -21,6 +21,7 @@ import {
import type { Project } from '../shared/types';
import { asNpcId } from '../shared/types/ids';
import { listVisibleSceneNpcTokens } from '../shared/types/appPlayers';
import { remapProjectSceneTokenIds } from '../shared/graph/storylineExportImport';
import {
asPreviewRotationDeg,
previewRotationStepsCw,
@@ -390,6 +391,18 @@ function emitTokensState(): void {
}
}
/** Подтянуть `app-tokens/` из открытого кэша (экспорт сюжетной линии / полный zip). */
async function importAppTokensFromOpenProjectCache(): Promise<Project | null> {
const cacheDir = projectStore.getOpenCacheDir();
if (!tokensStore || !cacheDir) return null;
const remap = await tokensStore.importFromExportDir(cacheDir);
if (remap.size === 0) return null;
emitTokensState();
const needsRemap = [...remap.entries()].some(([from, to]) => from !== to);
if (!needsRemap) return null;
return projectStore.updateProject((p) => remapProjectSceneTokenIds(p, remap));
}
function emitSceneTokensSessionState(): void {
const state = sceneTokensSessionStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
@@ -882,7 +895,9 @@ async function main() {
return { project };
});
registerHandler(ipcChannels.project.open, async ({ projectId }) => {
const project = await projectStore.openProjectById(projectId);
let project = await projectStore.openProjectById(projectId);
const remapped = await importAppTokensFromOpenProjectCache();
if (remapped) project = remapped;
sceneViewStore.reset();
sceneTokensSessionStore.reset();
sceneNpcTokensSessionStore.reset();
@@ -1445,7 +1460,7 @@ async function main() {
}
const srcPath = filePaths[0];
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' });
const project = await projectStore.importProjectFromExternalZip(srcPath, (p) => {
let project = await projectStore.importProjectFromExternalZip(srcPath, (p) => {
emitZipProgress({
kind: 'import',
stage: p.stage,
@@ -1454,6 +1469,8 @@ async function main() {
});
});
const cacheDir = projectStore.getOpenCacheDir();
const remapped = await importAppTokensFromOpenProjectCache();
if (remapped) project = remapped;
if (playlistsStore && cacheDir) {
await playlistsStore.importFromExportDir(cacheDir);
emitPlaylistsState();
@@ -1564,7 +1581,7 @@ async function main() {
);
registerHandler(ipcChannels.project.importZipFromPath, async ({ filePath }) => {
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' });
const project = await projectStore.importProjectFromExternalZip(filePath, (p) => {
let project = await projectStore.importProjectFromExternalZip(filePath, (p) => {
emitZipProgress({
kind: 'import',
stage: p.stage,
@@ -1573,6 +1590,8 @@ async function main() {
});
});
const cacheDir = projectStore.getOpenCacheDir();
const remapped = await importAppTokensFromOpenProjectCache();
if (remapped) project = remapped;
if (playlistsStore && cacheDir) {
await playlistsStore.importFromExportDir(cacheDir);
emitPlaylistsState();
@@ -122,6 +122,23 @@ void test('main index: setCurrentGraphNode uses setSessionPointer not updateProj
assert.doesNotMatch(m[0], /updateProject/);
});
void test('main index: full zip import loads app-tokens like playlists', () => {
const src = fs.readFileSync(path.join(here, '..', 'index.ts'), 'utf8');
assert.match(src, /async function importAppTokensFromOpenProjectCache/);
assert.match(src, /tokensStore\.importFromExportDir\(cacheDir\)/);
assert.match(src, /remapProjectSceneTokenIds/);
const importZip = src.match(
/registerHandler\(ipcChannels\.project\.importZip,[\s\S]*?return \{ canceled: false as const, project \}/,
);
assert.ok(importZip);
assert.match(importZip[0], /importAppTokensFromOpenProjectCache/);
const importFromPath = src.match(
/registerHandler\(ipcChannels\.project\.importZipFromPath,[\s\S]*?return \{ project \}/,
);
assert.ok(importFromPath);
assert.match(importFromPath[0], /importAppTokensFromOpenProjectCache/);
});
void test('atomicReplace: replaceFileAtomic must not rm destination before successful commit', () => {
const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8');
const i = src.indexOf('export async function replaceFileAtomic');
@@ -113,3 +113,10 @@ void test('createWindows: логи окон не валят main через EPIP
assert.ok(src.includes('errorCode === -3'));
assert.ok(src.includes('isMainFrame'));
});
void test('createWindows: presentation/control не троттлят RAF в фоне (macOS paths)', () => {
const src = readCreateWindows();
assert.match(src, /kind === 'presentation' \|\| kind === 'control'/);
assert.match(src, /backgroundThrottling:\s*false/);
assert.match(src, /setBackgroundThrottling\(false\)/);
});
+7
View File
@@ -398,6 +398,10 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
autoplayPolicy: 'no-user-gesture-required',
// file:// + бандл Vite: без этого на Windows часто не грузятся чанки; http:// (dev server) оставляем строгим.
webSecurity: Boolean(process.env.VITE_DEV_SERVER_URL),
// macOS throttles RAF in unfocused windows — token paths and video stutter on presentation.
...(kind === 'presentation' || kind === 'control' || kind === 'tokenPathEditor'
? { backgroundThrottling: false }
: {}),
...(kind === 'npcs'
? {
webviewTag: true,
@@ -416,6 +420,9 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
}
bindWindowChromeTitle(win, kind);
if (kind === 'presentation' || kind === 'control' || kind === 'tokenPathEditor') {
win.webContents.setBackgroundThrottling(false);
}
if (kind === 'npcs') {
win.webContents.on('will-attach-webview', (event, guestPrefs, params) => {
delete guestPrefs.preload;
+1 -3
View File
@@ -1073,7 +1073,7 @@ export function ControlApp() {
/** Действия с токенами/ловушками только без активной кисти эффектов. */
const markersInteractive = tool.tool === 'none';
const pathLivePoses = useTokenPathLivePoses({
useTokenPathLivePoses({
tokens: currentScene?.tokens ?? [],
npcTokens: visibleNpcTokens,
pathSession: tokenPathSession,
@@ -2727,7 +2727,6 @@ export function ControlApp() {
viewport={previewContentRect}
editable={markersInteractive}
snapNorm={snapNormActive}
poseOverrides={pathLivePoses.tokenPoses}
dragEnabledById={tokenPathDragEnabled}
onMove={(placementId, nx, ny) => {
const snapped = snapNormActive(nx, ny);
@@ -2767,7 +2766,6 @@ export function ControlApp() {
grid={currentScene?.grid ?? null}
editable={markersInteractive}
snapNorm={snapNormActive}
poseOverrides={pathLivePoses.npcPoses}
dragEnabledById={npcPathDragEnabled}
onMove={(placementId, nx, ny) => {
const snapped = snapNormActive(nx, ny);
@@ -33,3 +33,15 @@ void test('ControlApp: draft кисти без корневого draftFxTick',
assert.ok(pixi.includes('mergeDraftState'));
assert.ok(pixi.includes('forwardRef'));
});
void test('ControlApp: path playback не гоняет setState корня', () => {
const app = fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8');
const hook = fs.readFileSync(
path.join(here, '../shared/tokens/useTokenPathLivePoses.ts'),
'utf8',
);
assert.doesNotMatch(app, /\bpathLivePoses\b/);
assert.doesNotMatch(hook, /\bsetPoses\b/);
assert.ok(hook.includes('applyLivePathPose'));
assert.ok(hook.includes('requestAnimationFrame'));
});
+1 -3
View File
@@ -107,7 +107,7 @@ export function PresentationView({
sceneNpcTokensSession.spawned,
session?.currentSceneId,
);
const pathLivePoses = useTokenPathLivePoses({
useTokenPathLivePoses({
tokens: scene?.tokens ?? [],
npcTokens: visibleNpcTokens,
pathSession: tokenPathSession,
@@ -257,7 +257,6 @@ export function PresentationView({
library={appTokens}
session={sceneTokensSession}
viewport={contentRect}
poseOverrides={pathLivePoses.tokenPoses}
/>
) : null}
{USERS_BRANCH_FEATURES_ENABLED &&
@@ -269,7 +268,6 @@ export function PresentationView({
session={sceneNpcTokensSession}
viewport={contentRect}
grid={scene.grid}
poseOverrides={pathLivePoses.npcPoses}
/>
) : null}
{USERS_BRANCH_FEATURES_ENABLED &&
@@ -7,8 +7,9 @@
.token {
position: absolute;
left: 0;
top: 0;
overflow: visible;
transform: translate(-50%, -50%);
pointer-events: none;
touch-action: none;
}
@@ -1,4 +1,4 @@
import React, { useRef, useState } from 'react';
import React, { useLayoutEffect, useRef, useState } from 'react';
import type {
NpcDisposition,
@@ -16,6 +16,8 @@ import { sceneGridTokenFitFactor } from '../../../shared/types/sceneGrid';
import { useAssetUrl } from '../useAssetImageUrl';
import { PlayerTokenView } from './PlayerTokenView';
import { tokenPathKey } from '../../../shared/types/tokenPathSession';
import { registerPathTokenNode, writeTokenPathTransform } from '../tokens/tokenPathLiveDom';
import { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast';
import {
TokenInitiativeDecor,
@@ -100,6 +102,8 @@ function NpcSprite({
onResize,
String(placement.id),
);
const elRef = useRef<HTMLDivElement>(null);
const pathKey = tokenPathKey('npcToken', String(placement.id));
const pos = localPos ?? { nx, ny };
const shownSizeN = localSize ?? sizeN;
const minDim = Math.min(viewport.w, viewport.h);
@@ -107,6 +111,17 @@ function NpcSprite({
const canDrag = editable && dragEnabled && onMove !== undefined;
const canResize = editable && dragEnabled && onResize !== undefined;
useLayoutEffect(() => {
registerPathTokenNode(pathKey, elRef.current, viewport, false);
return () => registerPathTokenNode(pathKey, null, viewport, false);
}, [pathKey, viewport]);
useLayoutEffect(() => {
const el = elRef.current;
if (!el || el.dataset.pathLive === '1') return;
writeTokenPathTransform(el, pos.nx, pos.ny, 0, viewport, false);
}, [pos.nx, pos.ny, viewport]);
const point = (e: React.PointerEvent, host?: HTMLElement | null) => {
const rect = (host ?? e.currentTarget.parentElement)?.getBoundingClientRect();
return {
@@ -133,6 +148,7 @@ function NpcSprite({
return (
<div
ref={elRef}
className={[
styles.token,
canDrag ? styles.editable : '',
@@ -143,8 +159,6 @@ function NpcSprite({
data-testid={`session-npc-token-${placement.id}`}
data-initiative-active={initiativeActive ? '1' : '0'}
style={{
left: viewport.x + pos.nx * viewport.w,
top: viewport.y + pos.ny * viewport.h,
width: sizePx,
height: sizePx,
pointerEvents: canDrag || canResize || onContextMenu ? 'auto' : undefined,
@@ -8,6 +8,8 @@
.token {
position: absolute;
left: 0;
top: 0;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(0, 0, 0, 0.2);
@@ -1,9 +1,11 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { AppToken, SceneToken, SceneTokensSessionState } from '../../../shared/types';
import { clampSceneTokenSizeN } from '../../../shared/types/appTokens';
import { tokenPathKey } from '../../../shared/types/tokenPathSession';
import { useLiveResizeBroadcast } from '../playerToken/useLiveDragBroadcast';
import { registerPathTokenNode, writeTokenPathTransform } from './tokenPathLiveDom';
import { useTokenImageUrl } from './useTokenImageUrl';
import styles from './SceneTokensOverlay.module.css';
@@ -106,18 +108,29 @@ function TokenSprite({
};
}, []);
const elRef = useRef<HTMLDivElement>(null);
const posNx = localPos?.nx ?? nx;
const posNy = localPos?.ny ?? ny;
const shownSizeN = localSize ?? sizeN;
const pathKey = tokenPathKey('token', String(placement.id));
const minDim = Math.min(viewport.w, viewport.h);
const sizePx = Math.max(16, shownSizeN * minDim);
const left = viewport.x + posNx * viewport.w;
const top = viewport.y + posNy * viewport.h;
const canDrag = editable && dragEnabled && Boolean(onMove);
const canResize = editable && dragEnabled && Boolean(onResize);
const interactive = canDrag || canResize || Boolean(onContextMenu);
useLayoutEffect(() => {
registerPathTokenNode(pathKey, elRef.current, viewport, true);
return () => registerPathTokenNode(pathKey, null, viewport, true);
}, [pathKey, viewport]);
useLayoutEffect(() => {
const el = elRef.current;
if (!el || el.dataset.pathLive === '1') return;
writeTokenPathTransform(el, posNx, posNy, rotationDeg, viewport, true);
}, [posNx, posNy, rotationDeg, viewport]);
const hostToNorm = (clientX: number, clientY: number, host: HTMLElement) => {
const r = host.getBoundingClientRect();
const w = Math.max(1e-6, viewport.w);
@@ -160,6 +173,7 @@ function TokenSprite({
return (
<div
ref={elRef}
className={[
styles.token,
canDrag ? styles.tokenEditable : '',
@@ -168,11 +182,8 @@ function TokenSprite({
.filter(Boolean)
.join(' ')}
style={{
left,
top,
width: sizePx,
height: sizePx,
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
}}
onContextMenu={
onContextMenu
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { writeTokenPathTransform } from './tokenPathLiveDom';
void test('writeTokenPathTransform: GPU-friendly translate3d, not left/top', () => {
const el = { style: { transform: '' } } as unknown as HTMLElement;
writeTokenPathTransform(el, 0.25, 0.5, 45, { x: 10, y: 20, w: 200, h: 100 }, true);
assert.equal(el.style.transform, 'translate3d(60px, 70px, 0) translate(-50%, -50%) rotate(45deg)');
});
void test('writeTokenPathTransform: NPC without rotate keeps centering only', () => {
const el = { style: { transform: '' } } as unknown as HTMLElement;
writeTokenPathTransform(el, 0, 0, 90, { x: 0, y: 0, w: 100, h: 100 }, false);
assert.equal(el.style.transform, 'translate3d(0px, 0px, 0) translate(-50%, -50%)');
});
@@ -0,0 +1,65 @@
/** Direct DOM transforms for path playback — avoids React re-renders on every RAF tick. */
export type TokenPathViewport = { x: number; y: number; w: number; h: number };
type NodeReg = {
el: HTMLElement;
viewport: TokenPathViewport;
rotate: boolean;
};
const registry = new Map<string, NodeReg>();
export function writeTokenPathTransform(
el: HTMLElement,
nx: number,
ny: number,
rotationDeg: number,
viewport: TokenPathViewport,
rotate: boolean,
): void {
const x = viewport.x + nx * viewport.w;
const y = viewport.y + ny * viewport.h;
const rot = rotate ? ` rotate(${String(rotationDeg)}deg)` : '';
el.style.transform = `translate3d(${String(x)}px, ${String(y)}px, 0) translate(-50%, -50%)${rot}`;
}
export function registerPathTokenNode(
key: string,
el: HTMLElement | null,
viewport: TokenPathViewport,
rotate: boolean,
): void {
if (!el) {
registry.delete(key);
return;
}
registry.set(key, { el, viewport, rotate });
}
export function applyLivePathPose(
key: string,
nx: number,
ny: number,
rotationDeg: number,
animating: boolean,
): void {
const n = registry.get(key);
if (!n) return;
n.el.dataset.pathLive = '1';
n.el.style.willChange = animating ? 'transform' : '';
writeTokenPathTransform(n.el, nx, ny, rotationDeg, n.viewport, n.rotate);
}
export function endLivePathPose(key: string): void {
const n = registry.get(key);
if (!n) return;
delete n.el.dataset.pathLive;
n.el.style.willChange = '';
}
export function endAllLivePathPoses(): void {
for (const key of [...registry.keys()]) {
endLivePathPose(key);
}
}
@@ -1,58 +1,17 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef } from 'react';
import { computePathPlaybackSample } from '../../../shared/types/tokenPathPlayback';
import {
tokenPathKey,
type TokenPathPlaybackPhase,
type TokenPathSessionState,
} from '../../../shared/types/tokenPathSession';
import type { SceneNpcToken, SceneToken } from '../../../shared/types';
export type TokenPathLivePose = {
nx: number;
ny: number;
rotationDeg: number;
phase: TokenPathPlaybackPhase;
dist: number;
};
type PoseMaps = {
tokenPoses: Record<string, TokenPathLivePose>;
npcPoses: Record<string, TokenPathLivePose>;
};
const EMPTY: PoseMaps = { tokenPoses: {}, npcPoses: {} };
function posesEqual(a: PoseMaps, b: PoseMaps): boolean {
const aT = a.tokenPoses;
const bT = b.tokenPoses;
const aN = a.npcPoses;
const bN = b.npcPoses;
const aTk = Object.keys(aT);
const bTk = Object.keys(bT);
const aNk = Object.keys(aN);
const bNk = Object.keys(bN);
if (aTk.length !== bTk.length || aNk.length !== bNk.length) return false;
for (const k of aTk) {
const x = aT[k];
const y = bT[k];
if (!y || x!.nx !== y.nx || x!.ny !== y.ny || x!.rotationDeg !== y.rotationDeg || x!.phase !== y.phase) {
return false;
}
}
for (const k of aNk) {
const x = aN[k];
const y = bN[k];
if (!y || x!.nx !== y.nx || x!.ny !== y.ny || x!.rotationDeg !== y.rotationDeg || x!.phase !== y.phase) {
return false;
}
}
return true;
}
import { applyLivePathPose, endAllLivePathPoses, endLivePathPose } from './tokenPathLiveDom';
/**
* RAF poses for control/presentation while path playback is active.
* When phase === 'stopped', pose is omitted so session/placement (drag) wins.
* Writes transforms on registered token DOM nodes — no React setState per frame.
*
* Uses local Date.now() — segmentStartedAtMs is also wall-clock from main (same machine).
* Do NOT clamp to stale serverNowMs: that froze motion after ~250ms until the next IPC bump.
@@ -63,10 +22,10 @@ export function useTokenPathLivePoses(args: {
pathSession: TokenPathSessionState | null;
enabled?: boolean;
onMarkDone?: (kind: 'token' | 'npcToken', placementId: string, atDist: number) => void;
}): PoseMaps {
}): void {
const { tokens, npcTokens, pathSession, enabled = true, onMarkDone } = args;
const [poses, setPoses] = useState<PoseMaps>(EMPTY);
const markedDoneRef = useRef<Set<string>>(new Set());
const liveKeysRef = useRef<Set<string>>(new Set());
const onMarkDoneRef = useRef(onMarkDone);
onMarkDoneRef.current = onMarkDone;
@@ -83,7 +42,8 @@ export function useTokenPathLivePoses(args: {
useEffect(() => {
if (!enabled) {
setPoses(EMPTY);
endAllLivePathPoses();
liveKeysRef.current.clear();
return;
}
let raf = 0;
@@ -92,55 +52,51 @@ export function useTokenPathLivePoses(args: {
const tick = () => {
if (!alive) return;
const session = pathSessionRef.current;
if (!session) {
setPoses((prev) => (prev === EMPTY || Object.keys(prev.tokenPoses).length + Object.keys(prev.npcPoses).length === 0 ? prev : EMPTY));
raf = requestAnimationFrame(tick);
return;
}
const nextKeys = new Set<string>();
if (session) {
const sampleNow = Date.now();
// Wall clock: matches main's Date.now() for segmentStartedAtMs (Electron, one host).
const sampleNow = Date.now();
const tokenPoses: Record<string, TokenPathLivePose> = {};
const npcPoses: Record<string, TokenPathLivePose> = {};
const sampleOne = (
kind: 'token' | 'npcToken',
placementId: string,
path: NonNullable<SceneToken['path']>,
out: Record<string, TokenPathLivePose>,
) => {
const key = tokenPathKey(kind, placementId);
const entry = session.playback[key];
if (!entry) return;
if (entry.phase === 'stopped') return;
const result = computePathPlaybackSample({ path, entry, nowMs: sampleNow });
if (!result) return;
out[placementId] = {
nx: result.sample.nx,
ny: result.sample.ny,
rotationDeg: result.sample.rotationDeg,
phase: result.phase,
dist: result.sample.dist,
const sampleOne = (
kind: 'token' | 'npcToken',
placementId: string,
path: NonNullable<SceneToken['path']>,
) => {
const key = tokenPathKey(kind, placementId);
const entry = session.playback[key];
if (!entry) return;
if (entry.phase === 'stopped') return;
const result = computePathPlaybackSample({ path, entry, nowMs: sampleNow });
if (!result) return;
nextKeys.add(key);
applyLivePathPose(
key,
result.sample.nx,
result.sample.ny,
result.sample.rotationDeg,
entry.phase === 'moving' || entry.phase === 'delay',
);
if (result.markDone && !markedDoneRef.current.has(key)) {
markedDoneRef.current.add(key);
onMarkDoneRef.current?.(kind, placementId, result.sample.dist);
}
};
if (result.markDone && !markedDoneRef.current.has(key)) {
markedDoneRef.current.add(key);
onMarkDoneRef.current?.(kind, placementId, result.sample.dist);
}
};
for (const t of tokensRef.current) {
if (t.path && t.path.points.length >= 2) {
sampleOne('token', String(t.id), t.path, tokenPoses);
for (const t of tokensRef.current) {
if (t.path && t.path.points.length >= 2) {
sampleOne('token', String(t.id), t.path);
}
}
}
for (const t of npcTokensRef.current) {
if (t.path && t.path.points.length >= 2) {
sampleOne('npcToken', String(t.id), t.path, npcPoses);
for (const t of npcTokensRef.current) {
if (t.path && t.path.points.length >= 2) {
sampleOne('npcToken', String(t.id), t.path);
}
}
}
const next: PoseMaps = { tokenPoses, npcPoses };
setPoses((prev) => (posesEqual(prev, next) ? prev : next));
for (const key of liveKeysRef.current) {
if (!nextKeys.has(key)) endLivePathPose(key);
}
liveKeysRef.current = nextKeys;
raf = requestAnimationFrame(tick);
};
@@ -148,8 +104,10 @@ export function useTokenPathLivePoses(args: {
return () => {
alive = false;
cancelAnimationFrame(raf);
for (const key of liveKeysRef.current) {
endLivePathPose(key);
}
liveKeysRef.current.clear();
};
}, [enabled]);
return poses;
}
@@ -11,6 +11,8 @@ import {
asProjectId,
asSceneId,
asSceneNpcTokenId,
asSceneTokenId,
asTokenId,
} from '../types/ids';
import { PROJECT_SCHEMA_VERSION } from '../types';
@@ -21,8 +23,10 @@ import {
findSceneTitleConflicts,
listExportedNpcsFromBundle,
listImportableStorylines,
collectTokenIdsFromProject,
mergeStorylinesIntoProject,
newExportBundleProjectId,
remapProjectSceneTokenIds,
selectNpcsByIds,
} from './storylineExportImport';
@@ -550,3 +554,48 @@ void test('mergeStorylinesIntoProject: reuse existing scene by resolution', () =
assert.equal(merged.sceneGraphNodes.length, 1);
assert.equal(merged.sceneGraphNodes[0]!.sceneId, targetSceneId);
});
void test('collectTokenIdsFromProject: собирает id неигровых токенов со сцен', () => {
const s1 = scene('s1', 'Start');
s1.tokens = [
{
id: asSceneTokenId('st1'),
tokenId: asTokenId('tok_a'),
nx: 0.2,
ny: 0.3,
sizeN: 0.08,
rotationDeg: 0,
},
{
id: asSceneTokenId('st2'),
tokenId: asTokenId('tok_b'),
nx: 0.5,
ny: 0.5,
sizeN: 0.08,
rotationDeg: 0,
},
];
const project = minimalProject({
scenes: { [asSceneId('s1')]: s1 },
sceneGraphNodes: [node('main', 's1', { isStartScene: true })],
});
const ids = collectTokenIdsFromProject(project).map(String).sort();
assert.deepEqual(ids, ['tok_a', 'tok_b']);
});
void test('remapProjectSceneTokenIds: переписывает tokenId на сценах', () => {
const s1 = scene('s1', 'Start');
s1.tokens = [
{
id: asSceneTokenId('st1'),
tokenId: asTokenId('old'),
nx: 0.2,
ny: 0.3,
sizeN: 0.08,
rotationDeg: 0,
},
];
const project = minimalProject({ scenes: { [asSceneId('s1')]: s1 } });
const remapped = remapProjectSceneTokenIds(project, new Map([['old', 'new']]));
assert.equal(String(remapped.scenes[asSceneId('s1')]!.tokens[0]!.tokenId), 'new');
});