feat(editor): побочные сюжетные линии и импорт/экспорт линий
Добавлена полноценная поддержка побочных сюжетных линий в редакторе и на пульте: визуальное выделение компонент графа, запрет недопустимых связей между основной и побочными линиями, метки «ПОБОЧНАЯ» и названия линий. Реализован partial export/import сюжетных линий: - экспорт выбранных линий в урезанный .ttrpg.zip с manifest в project.json; - импорт в открытый проект с выбором линий, разрешением конфликтов названий сцен (одна модалка на операцию) и отчётом о результате; - новое окно источника импорта: «Из проекта» (dropdown, без текущего) или «Из файла»; на главном экране — только полный импорт из файла. Исправлены гонки при открытии/закрытии проекта (сериализация open/close в main, сброс зависших состояний UI), залипание оверлея прогресса экспорта и старт Electron в dev после готовности Vite. Добавлены unit-тесты для lineage, export/import и контрактов zipStore. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { isSceneGraphEdgeRejected } from './sceneGraphEdgeRules';
|
||||
import {
|
||||
canSetSideStoryStart,
|
||||
getNodeStoryRoot,
|
||||
isNodeInSideStoryline,
|
||||
isSideStoryEdge,
|
||||
listSideStoryStarts,
|
||||
} from './sceneGraphLineage';
|
||||
import type { SceneGraphEdge, SceneGraphNode } from '../types';
|
||||
import { asGraphNodeId, asSceneId } from '../types/ids';
|
||||
|
||||
function node(id: string, scene: string, opts?: Partial<SceneGraphNode>): SceneGraphNode {
|
||||
return {
|
||||
id: asGraphNodeId(id),
|
||||
sceneId: asSceneId(scene),
|
||||
x: 0,
|
||||
y: 0,
|
||||
isStartScene: false,
|
||||
isSideStoryStart: false,
|
||||
sideStoryLineTitle: '',
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
function edge(id: string, src: string, tgt: string): SceneGraphEdge {
|
||||
return { id, sourceGraphNodeId: asGraphNodeId(src), targetGraphNodeId: asGraphNodeId(tgt) };
|
||||
}
|
||||
|
||||
void test('canSetSideStoryStart: false when main or side marker in component', () => {
|
||||
const nodes = [
|
||||
node('main', 's1', { isStartScene: true }),
|
||||
node('a', 's2'),
|
||||
node('side', 's3', { isSideStoryStart: true }),
|
||||
node('b', 's4'),
|
||||
node('orphan', 's5'),
|
||||
];
|
||||
const edges = [edge('e1', 'main', 'a'), edge('e2', 'side', 'b')];
|
||||
assert.equal(canSetSideStoryStart(nodes, edges, asGraphNodeId('a')), false);
|
||||
assert.equal(canSetSideStoryStart(nodes, edges, asGraphNodeId('b')), false);
|
||||
assert.equal(canSetSideStoryStart(nodes, edges, asGraphNodeId('orphan')), true);
|
||||
});
|
||||
|
||||
void test('getNodeStoryRoot classifies main, side, orphan', () => {
|
||||
const nodes = [
|
||||
node('main', 's1', { isStartScene: true }),
|
||||
node('a', 's2'),
|
||||
node('side', 's3', { isSideStoryStart: true }),
|
||||
node('b', 's4'),
|
||||
node('orphan', 's5'),
|
||||
];
|
||||
const edges = [edge('e1', 'main', 'a'), edge('e2', 'side', 'b')];
|
||||
assert.equal(getNodeStoryRoot(nodes, edges, asGraphNodeId('main')), 'main');
|
||||
assert.equal(getNodeStoryRoot(nodes, edges, asGraphNodeId('a')), 'main');
|
||||
assert.equal(getNodeStoryRoot(nodes, edges, asGraphNodeId('b')), asGraphNodeId('side'));
|
||||
assert.equal(getNodeStoryRoot(nodes, edges, asGraphNodeId('orphan')), null);
|
||||
assert.equal(isNodeInSideStoryline(nodes, edges, asGraphNodeId('main')), false);
|
||||
assert.equal(isNodeInSideStoryline(nodes, edges, asGraphNodeId('a')), false);
|
||||
assert.equal(isNodeInSideStoryline(nodes, edges, asGraphNodeId('b')), true);
|
||||
});
|
||||
|
||||
void test('isSceneGraphEdgeRejected: blocks main↔side and side↔side', () => {
|
||||
const nodes = [
|
||||
node('main', 's1', { isStartScene: true }),
|
||||
node('side1', 's2', { isSideStoryStart: true }),
|
||||
node('side2', 's3', { isSideStoryStart: true }),
|
||||
node('a', 's4'),
|
||||
node('b', 's5'),
|
||||
];
|
||||
const edges = [edge('e1', 'side1', 'a'), edge('e2', 'side2', 'b')];
|
||||
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('main'), asGraphNodeId('side1')), true);
|
||||
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('side1'), asGraphNodeId('main')), true);
|
||||
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('main'), asGraphNodeId('a')), true);
|
||||
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('a'), asGraphNodeId('main')), true);
|
||||
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('a'), asGraphNodeId('b')), true);
|
||||
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('a'), asGraphNodeId('side1')), false);
|
||||
});
|
||||
|
||||
void test('isSceneGraphEdgeRejected: blocks main to any node in side component without badge', () => {
|
||||
const nodes = [
|
||||
node('main', 's1', { isStartScene: true }),
|
||||
node('side1', 's2', { isSideStoryStart: true }),
|
||||
node('a', 's3'),
|
||||
node('b', 's4'),
|
||||
];
|
||||
const edges = [edge('e1', 'side1', 'a'), edge('e2', 'a', 'b')];
|
||||
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('main'), asGraphNodeId('a')), true);
|
||||
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('main'), asGraphNodeId('b')), true);
|
||||
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('a'), asGraphNodeId('main')), true);
|
||||
});
|
||||
|
||||
void test('isSideStoryEdge: side chain edges are blue even without badge on endpoint', () => {
|
||||
const nodes = [
|
||||
node('side', 's1', { isSideStoryStart: true }),
|
||||
node('a', 's2'),
|
||||
node('b', 's3'),
|
||||
];
|
||||
const edges = [edge('e1', 'side', 'a'), edge('e2', 'a', 'b')];
|
||||
assert.equal(isSideStoryEdge(nodes, edges, edges[0]!), true);
|
||||
assert.equal(isSideStoryEdge(nodes, edges, edges[1]!), true);
|
||||
});
|
||||
|
||||
void test('isNodeInSideStoryline: follows current component after reconnect', () => {
|
||||
const nodes = [
|
||||
node('main', 's1', { isStartScene: true }),
|
||||
node('side', 's2', { isSideStoryStart: true }),
|
||||
node('x', 's3'),
|
||||
];
|
||||
const mainEdge = [edge('e1', 'main', 'x')];
|
||||
const sideEdge = [edge('e2', 'side', 'x')];
|
||||
assert.equal(isNodeInSideStoryline(nodes, mainEdge, asGraphNodeId('x')), false);
|
||||
assert.equal(isNodeInSideStoryline(nodes, sideEdge, asGraphNodeId('x')), true);
|
||||
});
|
||||
|
||||
void test('isSideStoryEdge: main edges are not side edges', () => {
|
||||
const nodes = [
|
||||
node('main', 's1', { isStartScene: true }),
|
||||
node('a', 's2'),
|
||||
node('side', 's3', { isSideStoryStart: true }),
|
||||
node('b', 's4'),
|
||||
];
|
||||
const edges = [edge('e1', 'main', 'a'), edge('e2', 'side', 'b')];
|
||||
assert.equal(isSideStoryEdge(nodes, edges, edges[0]!), false);
|
||||
assert.equal(isSideStoryEdge(nodes, edges, edges[1]!), true);
|
||||
});
|
||||
|
||||
void test('isSceneGraphEdgeRejected: allows main-to-main edges', () => {
|
||||
const nodes = [node('main', 's1', { isStartScene: true }), node('a', 's2')];
|
||||
const edges: SceneGraphEdge[] = [];
|
||||
assert.equal(
|
||||
isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('main'), asGraphNodeId('a')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
void test('isSceneGraphEdgeRejected: allows main to free connected chain without markers', () => {
|
||||
const nodes = [
|
||||
node('main', 's1', { isStartScene: true }),
|
||||
node('a', 's2'),
|
||||
node('b', 's3'),
|
||||
node('c', 's4'),
|
||||
];
|
||||
const edges = [edge('e1', 'main', 'a'), edge('e2', 'b', 'c')];
|
||||
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('main'), asGraphNodeId('b')), false);
|
||||
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('a'), asGraphNodeId('b')), false);
|
||||
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('a'), asGraphNodeId('c')), false);
|
||||
});
|
||||
|
||||
void test('isSceneGraphEdgeRejected: allows connections within main component without start badge on target', () => {
|
||||
const nodes = [
|
||||
node('main', 's1', { isStartScene: true }),
|
||||
node('a', 's2'),
|
||||
node('b', 's3'),
|
||||
node('c', 's4'),
|
||||
];
|
||||
const edges = [edge('e1', 'main', 'a'), edge('e2', 'a', 'b')];
|
||||
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('main'), asGraphNodeId('b')), false);
|
||||
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('a'), asGraphNodeId('c')), false);
|
||||
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('b'), asGraphNodeId('c')), false);
|
||||
});
|
||||
|
||||
void test('listSideStoryStarts returns all side starters', () => {
|
||||
const nodes = [
|
||||
node('side1', 's1', { isSideStoryStart: true, sideStoryLineTitle: 'Quest A' }),
|
||||
node('side2', 's2', { isSideStoryStart: true }),
|
||||
];
|
||||
assert.equal(listSideStoryStarts(nodes).length, 2);
|
||||
});
|
||||
Reference in New Issue
Block a user