feat(control): аккордеон «Сцены» на пульте управления

Список сцен активной линии в порядке бокового списка редактора; переход с записью в историю.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-02 13:29:12 +08:00
parent 746b862cb9
commit cfb6e62331
7 changed files with 308 additions and 4 deletions
@@ -0,0 +1,125 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Project, SceneGraphEdge, SceneGraphNode } from '../types';
import { asGraphNodeId, asSceneId } from '../types/ids';
import { getActiveStorylineGraphNodeIds, listActiveStorylineScenes } from './activeStorylineScenes';
function node(id: string, sceneId: string, extra: Partial<SceneGraphNode> = {}): SceneGraphNode {
return {
id: asGraphNodeId(id),
sceneId: asSceneId(sceneId),
x: 0,
y: 0,
isStartScene: false,
isSideStoryStart: false,
sideStoryLineTitle: '',
...extra,
};
}
function edge(source: string, target: string): SceneGraphEdge {
return { sourceGraphNodeId: asGraphNodeId(source), targetGraphNodeId: asGraphNodeId(target) };
}
function minimalProject(partial: Partial<Project> & Pick<Project, 'sceneGraphNodes' | 'sceneGraphEdges'>): Project {
return {
id: partial.id ?? ('p1' as Project['id']),
title: partial.title ?? 'Test',
scenes: partial.scenes ?? {},
sceneListOrder: partial.sceneListOrder ?? [],
sceneGraphNodes: partial.sceneGraphNodes,
sceneGraphEdges: partial.sceneGraphEdges,
currentGraphNodeId: partial.currentGraphNodeId ?? null,
currentSceneId: partial.currentSceneId ?? null,
materials: partial.materials ?? [],
campaignAudio: partial.campaignAudio ?? [],
npcs: partial.npcs ?? [],
npcGroups: partial.npcGroups ?? [],
npcRelations: partial.npcRelations ?? [],
};
}
void test('listActiveStorylineScenes: main line ordered by sceneListOrder, deduped', () => {
const s1 = asSceneId('s1');
const s2 = asSceneId('s2');
const s3 = asSceneId('s3');
const nodes = [
node('main', 's1', { isStartScene: true }),
node('a', 's2'),
node('b', 's3'),
node('dup', 's2'),
];
const edges = [edge('main', 'a'), edge('a', 'b'), edge('a', 'dup')];
const project = minimalProject({
scenes: {
[s1]: { id: s1, title: 'Start' },
[s2]: { id: s2, title: 'Mid' },
[s3]: { id: s3, title: 'End' },
},
sceneListOrder: [s3, s1, s2],
sceneGraphNodes: nodes,
sceneGraphEdges: edges,
currentGraphNodeId: asGraphNodeId('a'),
});
const list = listActiveStorylineScenes(project);
assert.deepEqual(
list.map((e) => e.sceneId),
[s3, s1, s2],
);
assert.equal(list[2]?.graphNodeId, asGraphNodeId('a'));
});
void test('listActiveStorylineScenes: side line only', () => {
const s1 = asSceneId('s1');
const s2 = asSceneId('s2');
const sSide = asSceneId('sSide');
const nodes = [
node('main', 's1', { isStartScene: true }),
node('side', 'sSide', { isSideStoryStart: true }),
node('x', 's2'),
];
const edges = [edge('side', 'x')];
const project = minimalProject({
scenes: {
[s1]: { id: s1, title: 'Main' },
[s2]: { id: s2, title: 'Side step' },
[sSide]: { id: sSide, title: 'Side start' },
},
sceneListOrder: [s1, s2, sSide],
sceneGraphNodes: nodes,
sceneGraphEdges: edges,
currentGraphNodeId: asGraphNodeId('x'),
});
const ids = getActiveStorylineGraphNodeIds(nodes, edges, asGraphNodeId('x'));
assert.deepEqual([...ids].sort(), [asGraphNodeId('side'), asGraphNodeId('x')].sort());
const list = listActiveStorylineScenes(project);
assert.deepEqual(
list.map((e) => e.sceneId),
[s2, sSide],
);
});
void test('listActiveStorylineScenes: current node preferred for duplicate sceneId', () => {
const s2 = asSceneId('s2');
const nodes = [
node('main', 's1', { isStartScene: true }),
node('a', 's2'),
node('dup', 's2'),
];
const edges = [edge('main', 'a'), edge('main', 'dup')];
const project = minimalProject({
scenes: { [asSceneId('s1')]: { id: asSceneId('s1'), title: 'S1' }, [s2]: { id: s2, title: 'S2' } },
sceneListOrder: [asSceneId('s1'), s2],
sceneGraphNodes: nodes,
sceneGraphEdges: edges,
currentGraphNodeId: asGraphNodeId('dup'),
});
const list = listActiveStorylineScenes(project);
assert.equal(list.find((e) => e.sceneId === s2)?.graphNodeId, asGraphNodeId('dup'));
});
+77
View File
@@ -0,0 +1,77 @@
import type { GraphNodeId, Project, SceneGraphEdge, SceneGraphNode, SceneId } from '../types';
import { reconcileSceneListOrder } from './sceneListOrder';
import {
buildStoryRootMap,
getNodeStoryRoot,
getSideStoryComponentNodeIds,
isSideStoryRoot,
} from './sceneGraphLineage';
export type ActiveStorylineSceneEntry = {
sceneId: SceneId;
graphNodeId: GraphNodeId;
};
/** Узлы графа, принадлежащие активной сюжетной линии текущего положения. */
export function getActiveStorylineGraphNodeIds(
nodes: SceneGraphNode[],
edges: SceneGraphEdge[],
currentGraphNodeId: GraphNodeId,
): Set<GraphNodeId> {
const root = getNodeStoryRoot(nodes, edges, currentGraphNodeId);
if (root === 'main') {
const rootMap = buildStoryRootMap(nodes, edges);
const ids = new Set<GraphNodeId>();
for (const [id, r] of rootMap) {
if (r === 'main') ids.add(id);
}
return ids;
}
if (isSideStoryRoot(root)) {
return getSideStoryComponentNodeIds(nodes, edges, root);
}
return new Set();
}
function pickGraphNodeForScene(
sceneId: SceneId,
storylineNodeIds: Set<GraphNodeId>,
nodes: SceneGraphNode[],
currentGraphNodeId: GraphNodeId | null,
): GraphNodeId | null {
const matching = nodes.filter((n) => storylineNodeIds.has(n.id) && n.sceneId === sceneId);
if (matching.length === 0) return null;
if (currentGraphNodeId && matching.some((n) => n.id === currentGraphNodeId)) {
return currentGraphNodeId;
}
return matching[0]?.id ?? null;
}
/**
* Сцены активной линии в порядке бокового списка редактора; каждая sceneId — один раз.
*/
export function listActiveStorylineScenes(project: Project): ActiveStorylineSceneEntry[] {
const currentGraphNodeId = project.currentGraphNodeId;
if (!currentGraphNodeId) return [];
const storylineNodeIds = getActiveStorylineGraphNodeIds(
project.sceneGraphNodes,
project.sceneGraphEdges,
currentGraphNodeId,
);
if (storylineNodeIds.size === 0) return [];
const order = reconcileSceneListOrder(project.scenes, project.sceneListOrder);
const out: ActiveStorylineSceneEntry[] = [];
for (const sceneId of order) {
const graphNodeId = pickGraphNodeForScene(
sceneId,
storylineNodeIds,
project.sceneGraphNodes,
currentGraphNodeId,
);
if (graphNodeId) out.push({ sceneId, graphNodeId });
}
return out;
}