feat(control): аккордеон «Сцены» на пульте управления
Список сцен активной линии в порядке бокового списка редактора; переход с записью в историю. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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'));
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user