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:
@@ -1,9 +1,34 @@
|
||||
import type { GraphNodeId, SceneGraphEdge, SceneGraphNode, SceneId } from '../types';
|
||||
|
||||
import { getConnectedComponent, getStorylineBucket } from './sceneGraphLineage';
|
||||
|
||||
function isCrossStorylineEdgeRejected(
|
||||
nodes: SceneGraphNode[],
|
||||
edges: SceneGraphEdge[],
|
||||
sourceGraphNodeId: GraphNodeId,
|
||||
targetGraphNodeId: GraphNodeId,
|
||||
): boolean {
|
||||
const srcBucket = getStorylineBucket(nodes, edges, sourceGraphNodeId);
|
||||
const tgtBucket = getStorylineBucket(nodes, edges, targetGraphNodeId);
|
||||
|
||||
// Запрет любых связей между основной (НАЧАЛО) и побочной (ПОБОЧНАЯ) линиями.
|
||||
if (srcBucket === 'main' && tgtBucket === 'side') return true;
|
||||
if (srcBucket === 'side' && tgtBucket === 'main') return true;
|
||||
|
||||
// Разные побочные линии (разные компоненты).
|
||||
if (srcBucket === 'side' && tgtBucket === 'side') {
|
||||
const srcComp = getConnectedComponent(nodes, edges, sourceGraphNodeId);
|
||||
return !srcComp.has(targetGraphNodeId);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* true — связь добавлять нельзя: нет узлов, петля по одной сцене, то же ребро уже есть,
|
||||
* или с этого узла уже ведёт связь к другой карточке той же целевой сцены.
|
||||
* Разрешены несколько исходящих «вариантов» с одной ноды только на разные сцены.
|
||||
* Запрещены связи между основной и побочной линиями, а также между разными побочными линиями.
|
||||
*/
|
||||
export function isSceneGraphEdgeRejected(
|
||||
sceneGraphNodes: SceneGraphNode[],
|
||||
@@ -17,6 +42,10 @@ export function isSceneGraphEdgeRejected(
|
||||
if (srcScene === undefined || tgtScene === undefined) return true;
|
||||
if (srcScene === tgtScene) return true;
|
||||
|
||||
if (isCrossStorylineEdgeRejected(sceneGraphNodes, sceneGraphEdges, sourceGraphNodeId, targetGraphNodeId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const e of sceneGraphEdges) {
|
||||
if (e.sourceGraphNodeId !== sourceGraphNodeId) continue;
|
||||
if (e.targetGraphNodeId === targetGraphNodeId) return true;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { GraphNodeId, SceneGraphEdge, SceneGraphNode } from '../types';
|
||||
|
||||
/** Корень сюжета: основная линия, старт побочной (id узла) или изолированный узел. */
|
||||
export type StoryRoot = 'main' | GraphNodeId | null;
|
||||
|
||||
/** true — корень относится к побочной линии (id стартового узла), не к основной. */
|
||||
export function isSideStoryRoot(root: StoryRoot): root is GraphNodeId {
|
||||
return root !== null && root !== 'main';
|
||||
}
|
||||
|
||||
function buildAdjacency(
|
||||
nodes: SceneGraphNode[],
|
||||
edges: SceneGraphEdge[],
|
||||
): Map<GraphNodeId, Set<GraphNodeId>> {
|
||||
const adj = new Map<GraphNodeId, Set<GraphNodeId>>();
|
||||
for (const n of nodes) {
|
||||
adj.set(n.id, new Set());
|
||||
}
|
||||
for (const e of edges) {
|
||||
adj.get(e.sourceGraphNodeId)?.add(e.targetGraphNodeId);
|
||||
adj.get(e.targetGraphNodeId)?.add(e.sourceGraphNodeId);
|
||||
}
|
||||
return adj;
|
||||
}
|
||||
|
||||
function bfsComponent(adj: Map<GraphNodeId, Set<GraphNodeId>>, startId: GraphNodeId): Set<GraphNodeId> {
|
||||
const seen = new Set<GraphNodeId>();
|
||||
const queue: GraphNodeId[] = [startId];
|
||||
seen.add(startId);
|
||||
while (queue.length > 0) {
|
||||
const cur = queue.shift();
|
||||
if (!cur) continue;
|
||||
for (const nb of adj.get(cur) ?? []) {
|
||||
if (seen.has(nb)) continue;
|
||||
seen.add(nb);
|
||||
queue.push(nb);
|
||||
}
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
/** Неориентированная компонента связности узла. */
|
||||
export function getConnectedComponent(
|
||||
nodes: SceneGraphNode[],
|
||||
edges: SceneGraphEdge[],
|
||||
graphNodeId: GraphNodeId,
|
||||
): Set<GraphNodeId> {
|
||||
const adj = buildAdjacency(nodes, edges);
|
||||
if (!adj.has(graphNodeId)) return new Set();
|
||||
return bfsComponent(adj, graphNodeId);
|
||||
}
|
||||
|
||||
export function componentHasMainStart(nodes: SceneGraphNode[], component: Set<GraphNodeId>): boolean {
|
||||
return nodes.some((n) => component.has(n.id) && n.isStartScene);
|
||||
}
|
||||
|
||||
export function componentHasSideStart(nodes: SceneGraphNode[], component: Set<GraphNodeId>): boolean {
|
||||
return nodes.some((n) => component.has(n.id) && n.isSideStoryStart);
|
||||
}
|
||||
|
||||
/** Классификация связной компоненты графа для правил связей и стилей. */
|
||||
export type StorylineBucket = 'main' | 'side' | 'free';
|
||||
|
||||
/**
|
||||
* main — в компоненте есть «НАЧАЛО» (основной сюжет);
|
||||
* side — есть «ПОБОЧНАЯ», но нет «НАЧАЛО» в той же компоненте;
|
||||
* free — нет ни одной стартовой метки в компоненте.
|
||||
*/
|
||||
export function getStorylineBucket(
|
||||
nodes: SceneGraphNode[],
|
||||
edges: SceneGraphEdge[],
|
||||
graphNodeId: GraphNodeId,
|
||||
): StorylineBucket {
|
||||
const component = getConnectedComponent(nodes, edges, graphNodeId);
|
||||
if (componentHasMainStart(nodes, component)) return 'main';
|
||||
if (componentHasSideStart(nodes, component)) return 'side';
|
||||
return 'free';
|
||||
}
|
||||
|
||||
/** Можно ли поставить синюю метку «ПОБОЧНАЯ» на узел. */
|
||||
export function canSetSideStoryStart(
|
||||
nodes: SceneGraphNode[],
|
||||
edges: SceneGraphEdge[],
|
||||
graphNodeId: GraphNodeId,
|
||||
): boolean {
|
||||
const component = getConnectedComponent(nodes, edges, graphNodeId);
|
||||
if (componentHasMainStart(nodes, component)) return false;
|
||||
if (componentHasSideStart(nodes, component)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Карта узел → корень сюжета (основной приоритетнее побочного). */
|
||||
export function buildStoryRootMap(
|
||||
nodes: SceneGraphNode[],
|
||||
edges: SceneGraphEdge[],
|
||||
): Map<GraphNodeId, StoryRoot> {
|
||||
const adj = buildAdjacency(nodes, edges);
|
||||
const roots = new Map<GraphNodeId, StoryRoot>();
|
||||
|
||||
const mainStart = nodes.find((n) => n.isStartScene);
|
||||
if (mainStart) {
|
||||
for (const id of bfsComponent(adj, mainStart.id)) {
|
||||
roots.set(id, 'main');
|
||||
}
|
||||
}
|
||||
|
||||
for (const sideStart of nodes.filter((n) => n.isSideStoryStart)) {
|
||||
for (const id of bfsComponent(adj, sideStart.id)) {
|
||||
if (!roots.has(id)) {
|
||||
roots.set(id, sideStart.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const n of nodes) {
|
||||
if (!roots.has(n.id)) {
|
||||
roots.set(n.id, null);
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
export function getNodeStoryRoot(
|
||||
nodes: SceneGraphNode[],
|
||||
edges: SceneGraphEdge[],
|
||||
graphNodeId: GraphNodeId,
|
||||
): StoryRoot {
|
||||
return buildStoryRootMap(nodes, edges).get(graphNodeId) ?? null;
|
||||
}
|
||||
|
||||
export function isNodeInSideStoryline(
|
||||
nodes: SceneGraphNode[],
|
||||
edges: SceneGraphEdge[],
|
||||
graphNodeId: GraphNodeId,
|
||||
): boolean {
|
||||
return getStorylineBucket(nodes, edges, graphNodeId) === 'side';
|
||||
}
|
||||
|
||||
export function isNodeInMainStoryline(
|
||||
nodes: SceneGraphNode[],
|
||||
edges: SceneGraphEdge[],
|
||||
graphNodeId: GraphNodeId,
|
||||
): boolean {
|
||||
return getStorylineBucket(nodes, edges, graphNodeId) === 'main';
|
||||
}
|
||||
|
||||
/** Все узлы одной побочной линии (по её стартовому узлу). */
|
||||
export function getSideStoryComponentNodeIds(
|
||||
nodes: SceneGraphNode[],
|
||||
edges: SceneGraphEdge[],
|
||||
sideStartGraphNodeId: GraphNodeId,
|
||||
): Set<GraphNodeId> {
|
||||
return getConnectedComponent(nodes, edges, sideStartGraphNodeId);
|
||||
}
|
||||
|
||||
export function listSideStoryStarts(nodes: SceneGraphNode[]): SceneGraphNode[] {
|
||||
return nodes.filter((n) => n.isSideStoryStart);
|
||||
}
|
||||
|
||||
/** true, если ребро целиком внутри одной побочной линии (по компоненте связности). */
|
||||
export function isSideStoryEdge(
|
||||
nodes: SceneGraphNode[],
|
||||
edges: SceneGraphEdge[],
|
||||
edge: SceneGraphEdge,
|
||||
): boolean {
|
||||
const srcBucket = getStorylineBucket(nodes, edges, edge.sourceGraphNodeId);
|
||||
const tgtBucket = getStorylineBucket(nodes, edges, edge.targetGraphNodeId);
|
||||
if (srcBucket !== 'side' || tgtBucket !== 'side') return false;
|
||||
const srcComp = getConnectedComponent(nodes, edges, edge.sourceGraphNodeId);
|
||||
return srcComp.has(edge.targetGraphNodeId);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { Project, Scene, SceneGraphEdge, SceneGraphNode } from '../types';
|
||||
import { asAssetId, asGraphNodeId, asProjectId, asSceneId } from '../types/ids';
|
||||
|
||||
import {
|
||||
buildPartialExportProject,
|
||||
collectStorylineGraphNodeIds,
|
||||
computeGraphImportOffsetX,
|
||||
findSceneTitleConflicts,
|
||||
listImportableStorylines,
|
||||
mergeStorylinesIntoProject,
|
||||
newExportBundleProjectId,
|
||||
} from './storylineExportImport';
|
||||
|
||||
const LABELS = { main: 'Основная линия', untitled: 'Без названия' };
|
||||
|
||||
function scene(id: string, title: string): Scene {
|
||||
return {
|
||||
id: asSceneId(id),
|
||||
title,
|
||||
description: '',
|
||||
previewAssetId: null,
|
||||
previewAssetType: null,
|
||||
previewThumbAssetId: null,
|
||||
previewVideoAutostart: false,
|
||||
previewRotationDeg: 0,
|
||||
darkenScene: false,
|
||||
media: { videos: [], audios: [] },
|
||||
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
|
||||
connections: [],
|
||||
layout: { x: 0, y: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function node(id: string, sceneId: string, opts?: Partial<SceneGraphNode>): SceneGraphNode {
|
||||
return {
|
||||
id: asGraphNodeId(id),
|
||||
sceneId: asSceneId(sceneId),
|
||||
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) };
|
||||
}
|
||||
|
||||
function minimalProject(overrides: Partial<Project> = {}): Project {
|
||||
return {
|
||||
id: asProjectId('p1'),
|
||||
meta: {
|
||||
name: 'Test',
|
||||
fileBaseName: 'test',
|
||||
createdAt: '2020-01-01T00:00:00.000Z',
|
||||
updatedAt: '2020-01-01T00:00:00.000Z',
|
||||
createdWithAppVersion: '1',
|
||||
appVersion: '1',
|
||||
schemaVersion: 5,
|
||||
},
|
||||
scenes: {},
|
||||
assets: {},
|
||||
campaignAudios: [],
|
||||
currentSceneId: null,
|
||||
currentGraphNodeId: null,
|
||||
sceneGraphNodes: [],
|
||||
sceneGraphEdges: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
void test('collectStorylineGraphNodeIds: main line excludes orphan and side nodes', () => {
|
||||
const project = minimalProject({
|
||||
scenes: {
|
||||
[asSceneId('s1')]: scene('s1', 'Start'),
|
||||
[asSceneId('s2')]: scene('s2', 'A'),
|
||||
[asSceneId('s3')]: scene('s3', 'Side'),
|
||||
[asSceneId('s4')]: scene('s4', 'Orphan'),
|
||||
},
|
||||
sceneGraphNodes: [
|
||||
node('main', 's1', { isStartScene: true, x: 0 }),
|
||||
node('a', 's2', { x: 100 }),
|
||||
node('side', 's3', { isSideStoryStart: true, x: 200 }),
|
||||
node('orphan', 's4', { x: 300 }),
|
||||
],
|
||||
sceneGraphEdges: [edge('e1', 'main', 'a'), edge('e2', 'side', 'orphan')],
|
||||
});
|
||||
|
||||
const mainIds = collectStorylineGraphNodeIds(project, { kind: 'main' });
|
||||
assert.deepEqual([...mainIds].sort(), ['a', 'main'].sort());
|
||||
});
|
||||
|
||||
void test('buildPartialExportProject: subset, manifest, no free nodes', () => {
|
||||
const source = minimalProject({
|
||||
scenes: {
|
||||
[asSceneId('s1')]: scene('s1', 'Start'),
|
||||
[asSceneId('s2')]: scene('s2', 'A'),
|
||||
[asSceneId('s3')]: scene('s3', 'Side'),
|
||||
[asSceneId('s4')]: scene('s4', 'Orphan'),
|
||||
},
|
||||
sceneGraphNodes: [
|
||||
node('main', 's1', { isStartScene: true }),
|
||||
node('a', 's2'),
|
||||
node('side', 's3', { isSideStoryStart: true, sideStoryLineTitle: 'Квест' }),
|
||||
node('orphan', 's4'),
|
||||
],
|
||||
sceneGraphEdges: [edge('e1', 'main', 'a'), edge('e2', 'side', 'orphan')],
|
||||
});
|
||||
|
||||
const partial = buildPartialExportProject(
|
||||
source,
|
||||
[{ kind: 'side', startGraphNodeId: asGraphNodeId('side') }],
|
||||
{ newProjectId: newExportBundleProjectId(), exportTitle: 'Export', labels: LABELS },
|
||||
);
|
||||
|
||||
assert.equal(partial.sceneGraphNodes.length, 2);
|
||||
assert.ok(partial.sceneGraphNodes.some((n) => n.id === asGraphNodeId('side')));
|
||||
assert.ok(partial.sceneGraphNodes.some((n) => n.id === asGraphNodeId('orphan')));
|
||||
assert.equal(Object.keys(partial.scenes).length, 2);
|
||||
assert.ok(partial.scenes[asSceneId('s3')]);
|
||||
assert.ok(!partial.scenes[asSceneId('s1')]);
|
||||
assert.ok(!partial.scenes[asSceneId('s2')]);
|
||||
assert.equal(partial.exportedStorylines?.length, 1);
|
||||
assert.equal(partial.exportedStorylines![0]!.label, 'Квест');
|
||||
assert.equal(partial.exportedStorylines![0]!.kind, 'side');
|
||||
});
|
||||
|
||||
void test('listImportableStorylines: disables main when target already has start', () => {
|
||||
const source = minimalProject({
|
||||
exportedStorylines: [
|
||||
{ kind: 'main', startGraphNodeId: null, label: 'Основная линия' },
|
||||
{ kind: 'side', startGraphNodeId: asGraphNodeId('side'), label: 'Побочная' },
|
||||
],
|
||||
});
|
||||
|
||||
const items = listImportableStorylines(source, LABELS, true);
|
||||
assert.equal(items[0]!.disabled, true);
|
||||
assert.equal(items[0]!.disabledReason, 'main_exists');
|
||||
assert.ok(!items[1]!.disabled);
|
||||
});
|
||||
|
||||
void test('findSceneTitleConflicts: case-insensitive trimmed match', () => {
|
||||
const target = minimalProject({
|
||||
scenes: { [asSceneId('t1')]: scene('t1', ' Tavern ') },
|
||||
});
|
||||
const source = minimalProject({
|
||||
scenes: { [asSceneId('s1')]: scene('s1', 'tavern') },
|
||||
});
|
||||
|
||||
const conflicts = findSceneTitleConflicts(target, source, [asSceneId('s1')]);
|
||||
assert.equal(conflicts.length, 1);
|
||||
assert.equal(conflicts[0]!.sourceTitle, 'tavern');
|
||||
assert.equal(conflicts[0]!.matches[0]!.sceneId, asSceneId('t1'));
|
||||
});
|
||||
|
||||
void test('mergeStorylinesIntoProject: offset X, create scene, rename side title', () => {
|
||||
const target = minimalProject({
|
||||
scenes: { [asSceneId('t1')]: scene('t1', 'Existing') },
|
||||
sceneGraphNodes: [node('tgn', 't1', { x: 400 })],
|
||||
});
|
||||
|
||||
const source = minimalProject({
|
||||
scenes: {
|
||||
[asSceneId('s1')]: scene('s1', 'New scene'),
|
||||
[asSceneId('s2')]: scene('s2', 'Side scene'),
|
||||
},
|
||||
sceneGraphNodes: [
|
||||
node('side', 's2', { isSideStoryStart: true, sideStoryLineTitle: 'Квест', x: 50, y: 10 }),
|
||||
node('a', 's1', { x: 150, y: 10 }),
|
||||
],
|
||||
sceneGraphEdges: [edge('e1', 'side', 'a')],
|
||||
});
|
||||
|
||||
target.sceneGraphNodes.push(
|
||||
node('existingSide', 't1', { isSideStoryStart: true, sideStoryLineTitle: 'Квест' }),
|
||||
);
|
||||
|
||||
const offsetX = computeGraphImportOffsetX(target);
|
||||
const { project: merged, report } = mergeStorylinesIntoProject(
|
||||
target,
|
||||
source,
|
||||
[{ kind: 'side', startGraphNodeId: asGraphNodeId('side') }],
|
||||
[{ sourceSceneId: asSceneId('s1'), mode: 'create' }, { sourceSceneId: asSceneId('s2'), mode: 'create' }],
|
||||
{ graphOffsetX: offsetX },
|
||||
);
|
||||
|
||||
const imported = merged.sceneGraphNodes.filter((n) => n.id !== asGraphNodeId('tgn') && n.id !== asGraphNodeId('existingSide'));
|
||||
assert.equal(imported.length, 2);
|
||||
assert.equal(imported[0]!.x, offsetX);
|
||||
assert.equal(imported[1]!.x, offsetX + 100);
|
||||
|
||||
const sideStart = imported.find((n) => n.isSideStoryStart);
|
||||
assert.equal(sideStart?.sideStoryLineTitle, 'Квест (2)');
|
||||
assert.equal(report.renamedSideTitles.length, 1);
|
||||
assert.equal(report.scenesCreated, 2);
|
||||
assert.equal(report.graphNodesAdded, 2);
|
||||
assert.equal(report.edgesAdded, 1);
|
||||
});
|
||||
|
||||
void test('mergeStorylinesIntoProject: reuse existing scene by resolution', () => {
|
||||
const targetSceneId = asSceneId('t1');
|
||||
const target = minimalProject({
|
||||
scenes: { [targetSceneId]: scene('t1', 'Tavern') },
|
||||
sceneGraphNodes: [],
|
||||
});
|
||||
|
||||
const source = minimalProject({
|
||||
scenes: { [asSceneId('s1')]: scene('s1', 'Tavern') },
|
||||
sceneGraphNodes: [node('gn', 's1', { isStartScene: true, x: 0 })],
|
||||
sceneGraphEdges: [],
|
||||
});
|
||||
|
||||
const { project: merged, report } = mergeStorylinesIntoProject(
|
||||
target,
|
||||
source,
|
||||
[{ kind: 'main' }],
|
||||
[{ sourceSceneId: asSceneId('s1'), mode: 'use', targetSceneId }],
|
||||
{ graphOffsetX: 100 },
|
||||
);
|
||||
|
||||
assert.equal(report.scenesReused, 1);
|
||||
assert.equal(report.scenesCreated, 0);
|
||||
assert.equal(merged.sceneGraphNodes.length, 1);
|
||||
assert.equal(merged.sceneGraphNodes[0]!.sceneId, targetSceneId);
|
||||
});
|
||||
@@ -0,0 +1,519 @@
|
||||
import {
|
||||
getConnectedComponent,
|
||||
getSideStoryComponentNodeIds,
|
||||
listSideStoryStarts,
|
||||
} from './sceneGraphLineage';
|
||||
import type {
|
||||
ExportedStorylineRef,
|
||||
GraphNodeId,
|
||||
Project,
|
||||
Scene,
|
||||
SceneGraphEdge,
|
||||
SceneGraphNode,
|
||||
SceneId,
|
||||
} from '../types';
|
||||
import type { AssetId, ProjectId } from '../types/ids';
|
||||
import { asAssetId, asGraphNodeId, asProjectId, asSceneId } from '../types/ids';
|
||||
|
||||
export type StorylineKind = 'main' | 'side';
|
||||
|
||||
/** Выбор сюжетной линии для экспорта/импорта. */
|
||||
export type StorylineSelection =
|
||||
| { kind: 'main' }
|
||||
| { kind: 'side'; startGraphNodeId: GraphNodeId };
|
||||
|
||||
export type StorylineListItem = {
|
||||
selection: StorylineSelection;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
disabledReason?: string;
|
||||
};
|
||||
|
||||
export type SceneImportResolution =
|
||||
| { sourceSceneId: SceneId; mode: 'create' }
|
||||
| { sourceSceneId: SceneId; mode: 'use'; targetSceneId: SceneId };
|
||||
|
||||
export type SceneTitleConflict = {
|
||||
sourceSceneId: SceneId;
|
||||
sourceTitle: string;
|
||||
matches: { sceneId: SceneId; title: string }[];
|
||||
};
|
||||
|
||||
export type StorylineImportMergeReport = {
|
||||
storylinesImported: number;
|
||||
scenesCreated: number;
|
||||
scenesReused: number;
|
||||
graphNodesAdded: number;
|
||||
edgesAdded: number;
|
||||
assetsCopied: number;
|
||||
assetsReused: number;
|
||||
renamedSideTitles: string[];
|
||||
};
|
||||
|
||||
export type StorylineLabels = {
|
||||
main: string;
|
||||
untitled: string;
|
||||
};
|
||||
|
||||
function generateId(): string {
|
||||
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function storylineSelectionKey(sel: StorylineSelection): string {
|
||||
return sel.kind === 'main' ? 'main' : `side:${sel.startGraphNodeId}`;
|
||||
}
|
||||
|
||||
export function storylineSelectionsEqual(a: StorylineSelection, b: StorylineSelection): boolean {
|
||||
return storylineSelectionKey(a) === storylineSelectionKey(b);
|
||||
}
|
||||
|
||||
export function sideStoryDisplayLabel(
|
||||
node: SceneGraphNode,
|
||||
scenes: Record<SceneId, Scene>,
|
||||
untitledLabel: string,
|
||||
): string {
|
||||
const custom = node.sideStoryLineTitle.trim();
|
||||
if (custom) return custom;
|
||||
const scene = scenes[node.sceneId];
|
||||
const sceneTitle = scene?.title.trim();
|
||||
if (sceneTitle) return sceneTitle;
|
||||
return untitledLabel;
|
||||
}
|
||||
|
||||
function findMainStartNode(nodes: SceneGraphNode[]): SceneGraphNode | null {
|
||||
return nodes.find((n) => n.isStartScene) ?? null;
|
||||
}
|
||||
|
||||
export function collectStorylineGraphNodeIds(
|
||||
project: Project,
|
||||
selection: StorylineSelection,
|
||||
): Set<GraphNodeId> {
|
||||
const { sceneGraphNodes, sceneGraphEdges } = project;
|
||||
if (selection.kind === 'main') {
|
||||
const mainStart = findMainStartNode(sceneGraphNodes);
|
||||
if (!mainStart) return new Set();
|
||||
return getConnectedComponent(sceneGraphNodes, sceneGraphEdges, mainStart.id);
|
||||
}
|
||||
return getSideStoryComponentNodeIds(sceneGraphNodes, sceneGraphEdges, selection.startGraphNodeId);
|
||||
}
|
||||
|
||||
export function collectSelectionsGraphNodeIds(
|
||||
project: Project,
|
||||
selections: StorylineSelection[],
|
||||
): Set<GraphNodeId> {
|
||||
const out = new Set<GraphNodeId>();
|
||||
for (const sel of selections) {
|
||||
for (const id of collectStorylineGraphNodeIds(project, sel)) {
|
||||
out.add(id);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function listExportableStorylines(project: Project, labels: StorylineLabels): StorylineListItem[] {
|
||||
const items: StorylineListItem[] = [];
|
||||
if (findMainStartNode(project.sceneGraphNodes)) {
|
||||
items.push({ selection: { kind: 'main' }, label: labels.main });
|
||||
}
|
||||
for (const gn of listSideStoryStarts(project.sceneGraphNodes)) {
|
||||
items.push({
|
||||
selection: { kind: 'side', startGraphNodeId: gn.id },
|
||||
label: sideStoryDisplayLabel(gn, project.scenes, labels.untitled),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function listImportableStorylines(
|
||||
source: Project,
|
||||
labels: StorylineLabels,
|
||||
targetHasMainStart: boolean,
|
||||
): StorylineListItem[] {
|
||||
const fromManifest = source.exportedStorylines;
|
||||
if (fromManifest && fromManifest.length > 0) {
|
||||
return fromManifest.map((entry) => {
|
||||
const selection: StorylineSelection =
|
||||
entry.kind === 'main'
|
||||
? { kind: 'main' }
|
||||
: { kind: 'side', startGraphNodeId: entry.startGraphNodeId! };
|
||||
const item: StorylineListItem = { selection, label: entry.label };
|
||||
if (entry.kind === 'main' && targetHasMainStart) {
|
||||
item.disabled = true;
|
||||
item.disabledReason = 'main_exists';
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
return listExportableStorylines(source, labels).map((item) => {
|
||||
if (item.selection.kind === 'main' && targetHasMainStart) {
|
||||
return { ...item, disabled: true, disabledReason: 'main_exists' };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
function recomputeOutgoing(
|
||||
nodes: SceneGraphNode[],
|
||||
edges: SceneGraphEdge[],
|
||||
): Map<SceneId, Set<SceneId>> {
|
||||
const gnMap = new Map(nodes.map((n) => [n.id, n]));
|
||||
const outgoing = new Map<SceneId, Set<SceneId>>();
|
||||
for (const e of edges) {
|
||||
const a = gnMap.get(e.sourceGraphNodeId);
|
||||
const b = gnMap.get(e.targetGraphNodeId);
|
||||
if (!a || !b || a.sceneId === b.sceneId) continue;
|
||||
let set = outgoing.get(a.sceneId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
outgoing.set(a.sceneId, set);
|
||||
}
|
||||
set.add(b.sceneId);
|
||||
}
|
||||
return outgoing;
|
||||
}
|
||||
|
||||
function applyConnectionSets(scenes: Record<SceneId, Scene>, outgoing: Map<SceneId, Set<SceneId>>): Record<SceneId, Scene> {
|
||||
const next: Record<SceneId, Scene> = { ...scenes };
|
||||
for (const sid of Object.keys(next) as SceneId[]) {
|
||||
const prev = next[sid];
|
||||
if (!prev) continue;
|
||||
const c = outgoing.get(sid);
|
||||
next[sid] = { ...prev, connections: c ? [...c] : [] };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function selectionToExportedRef(
|
||||
project: Project,
|
||||
selection: StorylineSelection,
|
||||
labels: StorylineLabels,
|
||||
): ExportedStorylineRef {
|
||||
if (selection.kind === 'main') {
|
||||
return { kind: 'main', startGraphNodeId: null, label: labels.main };
|
||||
}
|
||||
const start = project.sceneGraphNodes.find((n) => n.id === selection.startGraphNodeId);
|
||||
return {
|
||||
kind: 'side',
|
||||
startGraphNodeId: selection.startGraphNodeId,
|
||||
label: start
|
||||
? sideStoryDisplayLabel(start, project.scenes, labels.untitled)
|
||||
: labels.untitled,
|
||||
};
|
||||
}
|
||||
|
||||
export function collectSceneIdsForSelections(
|
||||
project: Project,
|
||||
selections: StorylineSelection[],
|
||||
): SceneId[] {
|
||||
const nodeIds = collectSelectionsGraphNodeIds(project, selections);
|
||||
const sceneIds = new Set<SceneId>();
|
||||
for (const gn of project.sceneGraphNodes) {
|
||||
if (nodeIds.has(gn.id)) sceneIds.add(gn.sceneId);
|
||||
}
|
||||
return [...sceneIds];
|
||||
}
|
||||
|
||||
export function buildPartialExportProject(
|
||||
source: Project,
|
||||
selections: StorylineSelection[],
|
||||
opts: {
|
||||
newProjectId: ProjectId;
|
||||
exportTitle: string;
|
||||
labels: StorylineLabels;
|
||||
},
|
||||
): Project {
|
||||
const nodeIds = collectSelectionsGraphNodeIds(source, selections);
|
||||
const sceneIds = new Set(collectSceneIdsForSelections(source, selections));
|
||||
|
||||
const sceneGraphNodes = source.sceneGraphNodes
|
||||
.filter((n) => nodeIds.has(n.id))
|
||||
.map((n) => ({ ...n }));
|
||||
|
||||
const sceneGraphEdges = source.sceneGraphEdges.filter(
|
||||
(e) => nodeIds.has(e.sourceGraphNodeId) && nodeIds.has(e.targetGraphNodeId),
|
||||
);
|
||||
|
||||
const scenes: Record<SceneId, Scene> = {};
|
||||
for (const sid of sceneIds) {
|
||||
const sc = source.scenes[sid];
|
||||
if (sc) scenes[sid] = { ...sc };
|
||||
}
|
||||
|
||||
let draft: Project = {
|
||||
...source,
|
||||
id: opts.newProjectId,
|
||||
meta: {
|
||||
...source.meta,
|
||||
name: opts.exportTitle,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
scenes,
|
||||
sceneGraphNodes,
|
||||
sceneGraphEdges,
|
||||
currentSceneId: null,
|
||||
currentGraphNodeId: null,
|
||||
exportedStorylines: selections.map((s) => selectionToExportedRef(source, s, opts.labels)),
|
||||
};
|
||||
|
||||
const outgoing = recomputeOutgoing(draft.sceneGraphNodes, draft.sceneGraphEdges);
|
||||
draft = { ...draft, scenes: applyConnectionSets(draft.scenes, outgoing) };
|
||||
|
||||
const assetIds = collectReferencedAssetIdsForProject(draft);
|
||||
const assets: Project['assets'] = {} as Project['assets'];
|
||||
for (const id of assetIds) {
|
||||
const a = source.assets[id];
|
||||
if (a) assets[id] = { ...a };
|
||||
}
|
||||
|
||||
const mainStart = findMainStartNode(draft.sceneGraphNodes);
|
||||
const firstSide = listSideStoryStarts(draft.sceneGraphNodes)[0];
|
||||
return {
|
||||
...draft,
|
||||
assets,
|
||||
campaignAudios: source.campaignAudios.map((a) => ({ ...a })),
|
||||
currentSceneId: mainStart?.sceneId ?? firstSide?.sceneId ?? null,
|
||||
currentGraphNodeId: mainStart?.id ?? firstSide?.id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function collectReferencedAssetIdsForProject(p: Project): Set<AssetId> {
|
||||
const refs = new Set<AssetId>();
|
||||
for (const sc of Object.values(p.scenes)) {
|
||||
if (sc.previewAssetId) refs.add(sc.previewAssetId);
|
||||
if (sc.previewThumbAssetId) refs.add(sc.previewThumbAssetId);
|
||||
for (const vid of sc.media.videos) refs.add(vid);
|
||||
for (const au of sc.media.audios) refs.add(au.assetId);
|
||||
}
|
||||
for (const au of p.campaignAudios) refs.add(au.assetId);
|
||||
return refs;
|
||||
}
|
||||
|
||||
export function findSceneTitleConflicts(
|
||||
target: Project,
|
||||
source: Project,
|
||||
sourceSceneIds: SceneId[],
|
||||
): SceneTitleConflict[] {
|
||||
const targetByTitle = new Map<string, { sceneId: SceneId; title: string }[]>();
|
||||
for (const sc of Object.values(target.scenes)) {
|
||||
const key = sc.title.trim().toLowerCase();
|
||||
if (!key) continue;
|
||||
const list = targetByTitle.get(key) ?? [];
|
||||
list.push({ sceneId: sc.id, title: sc.title });
|
||||
targetByTitle.set(key, list);
|
||||
}
|
||||
|
||||
const conflicts: SceneTitleConflict[] = [];
|
||||
const seen = new Set<SceneId>();
|
||||
for (const sid of sourceSceneIds) {
|
||||
if (seen.has(sid)) continue;
|
||||
seen.add(sid);
|
||||
const src = source.scenes[sid];
|
||||
if (!src) continue;
|
||||
const key = src.title.trim().toLowerCase();
|
||||
if (!key) continue;
|
||||
const matches = targetByTitle.get(key);
|
||||
if (matches && matches.length > 0) {
|
||||
conflicts.push({ sourceSceneId: sid, sourceTitle: src.title, matches });
|
||||
}
|
||||
}
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
function remapSceneAssetRefs(scene: Scene, assetMap: Map<AssetId, AssetId>): Scene {
|
||||
const mapId = (id: AssetId | null): AssetId | null => (id ? (assetMap.get(id) ?? id) : null);
|
||||
return {
|
||||
...scene,
|
||||
previewAssetId: mapId(scene.previewAssetId),
|
||||
previewThumbAssetId: mapId(scene.previewThumbAssetId),
|
||||
media: {
|
||||
videos: scene.media.videos.map((id) => assetMap.get(id) ?? id),
|
||||
audios: scene.media.audios.map((a) => ({
|
||||
...a,
|
||||
assetId: assetMap.get(a.assetId) ?? a.assetId,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueSideTitle(existing: Set<string>, desired: string): string {
|
||||
const base = desired.trim() || 'Побочная';
|
||||
if (!existing.has(base.toLowerCase())) {
|
||||
existing.add(base.toLowerCase());
|
||||
return base;
|
||||
}
|
||||
let n = 2;
|
||||
while (existing.has(`${base} (${String(n)})`.toLowerCase())) n += 1;
|
||||
const next = `${base} (${String(n)})`;
|
||||
existing.add(next.toLowerCase());
|
||||
return next;
|
||||
}
|
||||
|
||||
export function mergeStorylinesIntoProject(
|
||||
target: Project,
|
||||
source: Project,
|
||||
selections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
opts: { graphOffsetX: number },
|
||||
): { project: Project; report: StorylineImportMergeReport; assetCopies: { fromId: AssetId; toId: AssetId }[] } {
|
||||
const resolutionBySource = new Map(sceneResolutions.map((r) => [r.sourceSceneId, r]));
|
||||
const nodeIds = collectSelectionsGraphNodeIds(source, selections);
|
||||
const sourceSceneIds = collectSceneIdsForSelections(source, selections);
|
||||
|
||||
const neededAssetIds = new Set<AssetId>();
|
||||
for (const sid of sourceSceneIds) {
|
||||
const sc = source.scenes[sid];
|
||||
if (!sc) continue;
|
||||
const res = resolutionBySource.get(sid);
|
||||
if (res?.mode === 'use') continue;
|
||||
if (sc.previewAssetId) neededAssetIds.add(sc.previewAssetId);
|
||||
if (sc.previewThumbAssetId) neededAssetIds.add(sc.previewThumbAssetId);
|
||||
for (const vid of sc.media.videos) neededAssetIds.add(vid);
|
||||
for (const au of sc.media.audios) neededAssetIds.add(au.assetId);
|
||||
}
|
||||
for (const au of source.campaignAudios) neededAssetIds.add(au.assetId);
|
||||
|
||||
const assetMap = new Map<AssetId, AssetId>();
|
||||
const targetSha = new Map<string, AssetId>();
|
||||
for (const a of Object.values(target.assets)) {
|
||||
targetSha.set(a.sha256, a.id);
|
||||
}
|
||||
let assetsCopied = 0;
|
||||
let assetsReused = 0;
|
||||
const assetCopies: { fromId: AssetId; toId: AssetId }[] = [];
|
||||
for (const id of neededAssetIds) {
|
||||
const srcAsset = source.assets[id];
|
||||
if (!srcAsset) continue;
|
||||
const existing = targetSha.get(srcAsset.sha256);
|
||||
if (existing) {
|
||||
assetMap.set(id, existing);
|
||||
assetsReused += 1;
|
||||
} else {
|
||||
const newId = asAssetId(`a_${generateId()}`);
|
||||
assetMap.set(id, newId);
|
||||
targetSha.set(srcAsset.sha256, newId);
|
||||
assetCopies.push({ fromId: id, toId: newId });
|
||||
assetsCopied += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const sceneIdMap = new Map<SceneId, SceneId>();
|
||||
let scenesCreated = 0;
|
||||
let scenesReused = 0;
|
||||
const scenes: Record<SceneId, Scene> = { ...target.scenes };
|
||||
for (const sid of sourceSceneIds) {
|
||||
const res = resolutionBySource.get(sid);
|
||||
const srcScene = source.scenes[sid];
|
||||
if (!srcScene) continue;
|
||||
if (res?.mode === 'use') {
|
||||
sceneIdMap.set(sid, res.targetSceneId);
|
||||
scenesReused += 1;
|
||||
} else {
|
||||
const newId = asSceneId(`s_${generateId()}`);
|
||||
sceneIdMap.set(sid, newId);
|
||||
scenes[newId] = remapSceneAssetRefs({ ...srcScene, id: newId }, assetMap);
|
||||
scenesCreated += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const importedNodes = source.sceneGraphNodes.filter((n) => nodeIds.has(n.id));
|
||||
const minX = importedNodes.length > 0 ? Math.min(...importedNodes.map((n) => n.x)) : 0;
|
||||
const xShift = opts.graphOffsetX - minX;
|
||||
|
||||
const graphNodeIdMap = new Map<GraphNodeId, GraphNodeId>();
|
||||
for (const gn of importedNodes) {
|
||||
graphNodeIdMap.set(gn.id, asGraphNodeId(`gn_${generateId()}`));
|
||||
}
|
||||
|
||||
const existingSideTitles = new Set(
|
||||
target.sceneGraphNodes
|
||||
.filter((n) => n.isSideStoryStart)
|
||||
.map((n) => (n.sideStoryLineTitle.trim() || 'Побочная').toLowerCase()),
|
||||
);
|
||||
const renamedSideTitles: string[] = [];
|
||||
|
||||
const importMain = selections.some((s) => s.kind === 'main');
|
||||
const targetHasMain = !!findMainStartNode(target.sceneGraphNodes);
|
||||
|
||||
const newGraphNodes: SceneGraphNode[] = importedNodes.map((gn) => {
|
||||
const newId = graphNodeIdMap.get(gn.id)!;
|
||||
let sideStoryLineTitle = gn.sideStoryLineTitle;
|
||||
if (gn.isSideStoryStart) {
|
||||
const desired = sideStoryDisplayLabel(gn, source.scenes, 'Без названия');
|
||||
const resolved = uniqueSideTitle(existingSideTitles, desired);
|
||||
if (resolved !== desired) renamedSideTitles.push(resolved);
|
||||
sideStoryLineTitle = resolved;
|
||||
}
|
||||
return {
|
||||
...gn,
|
||||
id: newId,
|
||||
sceneId: sceneIdMap.get(gn.sceneId) ?? gn.sceneId,
|
||||
x: gn.x + xShift,
|
||||
y: gn.y,
|
||||
isStartScene: importMain && !targetHasMain ? gn.isStartScene : false,
|
||||
sideStoryLineTitle,
|
||||
};
|
||||
});
|
||||
|
||||
const newEdges: SceneGraphEdge[] = source.sceneGraphEdges
|
||||
.filter((e) => nodeIds.has(e.sourceGraphNodeId) && nodeIds.has(e.targetGraphNodeId))
|
||||
.map((e) => ({
|
||||
id: `e_${generateId()}`,
|
||||
sourceGraphNodeId: graphNodeIdMap.get(e.sourceGraphNodeId)!,
|
||||
targetGraphNodeId: graphNodeIdMap.get(e.targetGraphNodeId)!,
|
||||
}));
|
||||
|
||||
const assets: Project['assets'] = { ...target.assets };
|
||||
for (const [srcId, tgtId] of assetMap) {
|
||||
if (assets[tgtId]) continue;
|
||||
const srcAsset = source.assets[srcId];
|
||||
if (srcAsset) {
|
||||
assets[tgtId] = { ...srcAsset, id: tgtId };
|
||||
}
|
||||
}
|
||||
|
||||
const campaignAudios = [...target.campaignAudios];
|
||||
const campaignAssetIds = new Set(campaignAudios.map((a) => a.assetId));
|
||||
for (const au of source.campaignAudios) {
|
||||
const mapped = assetMap.get(au.assetId) ?? au.assetId;
|
||||
if (!campaignAssetIds.has(mapped)) {
|
||||
campaignAudios.push({ ...au, assetId: mapped });
|
||||
campaignAssetIds.add(mapped);
|
||||
}
|
||||
}
|
||||
|
||||
let merged: Project = {
|
||||
...target,
|
||||
scenes,
|
||||
assets,
|
||||
campaignAudios,
|
||||
sceneGraphNodes: [...target.sceneGraphNodes, ...newGraphNodes],
|
||||
sceneGraphEdges: [...target.sceneGraphEdges, ...newEdges],
|
||||
};
|
||||
|
||||
const outgoing = recomputeOutgoing(merged.sceneGraphNodes, merged.sceneGraphEdges);
|
||||
merged = { ...merged, scenes: applyConnectionSets(merged.scenes, outgoing) };
|
||||
|
||||
return {
|
||||
project: merged,
|
||||
report: {
|
||||
storylinesImported: selections.length,
|
||||
scenesCreated,
|
||||
scenesReused,
|
||||
graphNodesAdded: newGraphNodes.length,
|
||||
edgesAdded: newEdges.length,
|
||||
assetsCopied,
|
||||
assetsReused,
|
||||
renamedSideTitles,
|
||||
},
|
||||
assetCopies,
|
||||
};
|
||||
}
|
||||
|
||||
export function computeGraphImportOffsetX(target: Project, padding = 120): number {
|
||||
const maxX = target.sceneGraphNodes.reduce((m, n) => Math.max(m, n.x), 0);
|
||||
return maxX + padding;
|
||||
}
|
||||
|
||||
export function newExportBundleProjectId(): ProjectId {
|
||||
return asProjectId(`p_${generateId()}`);
|
||||
}
|
||||
Reference in New Issue
Block a user