Files
DndGamePlayer/app/shared/graph/storylineExportImport.ts
T
Ivan Fontosh 37ba855faf feat(npcs): add campaign NPCs with relation graph and session overlay
Add a dedicated NPC editor window, directed relations, control/presentation avatar overlay, and ru/en help for the new section.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 13:13:58 +08:00

619 lines
20 KiB
TypeScript

import {
getConnectedComponent,
getSideStoryComponentNodeIds,
listSideStoryStarts,
} from './sceneGraphLineage';
import { reconcileSceneListOrder } from './sceneListOrder';
import type {
ExportedStorylineRef,
GraphNodeId,
Project,
Scene,
SceneGraphEdge,
SceneGraphNode,
SceneId,
} from '../types';
import type { AssetId, ProjectId } from '../types/ids';
import {
asAssetId,
asGraphNodeId,
asMaterialId,
asNpcId,
asNpcRelationId,
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,
sceneListOrder: reconcileSceneListOrder(scenes, source.sceneListOrder),
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 })),
materials: (source.materials ?? []).map((m) => ({ ...m })),
npcs: (source.npcs ?? []).map((n) => ({ ...n })),
npcRelations: (source.npcRelations ?? []).map((r) => ({ ...r })),
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);
for (const m of p.materials ?? []) refs.add(m.assetId);
for (const n of p.npcs ?? []) refs.add(n.avatarAssetId);
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);
for (const m of source.materials ?? []) neededAssetIds.add(m.assetId);
for (const n of source.npcs ?? []) neededAssetIds.add(n.avatarAssetId);
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);
}
}
const materials = [...(target.materials ?? [])];
const materialNameKeys = new Set(materials.map((m) => m.name.trim().toLowerCase()));
const materialAssetIds = new Set(materials.map((m) => m.assetId));
for (const m of source.materials ?? []) {
const mapped = assetMap.get(m.assetId) ?? m.assetId;
if (materialAssetIds.has(mapped)) continue;
let name = m.name.trim();
const baseKey = name.toLowerCase();
if (materialNameKeys.has(baseKey)) {
let n = 2;
while (materialNameKeys.has(`${baseKey} (${String(n)})`)) n += 1;
name = `${name} (${String(n)})`;
}
materials.push({
id: asMaterialId(`mat_${generateId()}`),
name,
assetId: mapped,
rotationDeg: m.rotationDeg ?? 0,
});
materialNameKeys.add(name.toLowerCase());
materialAssetIds.add(mapped);
}
const npcs = [...(target.npcs ?? [])];
const npcNameKeys = new Set(npcs.map((n) => n.name.trim().toLowerCase()));
const npcAssetIds = new Set(npcs.map((n) => n.avatarAssetId));
const npcIdMap = new Map<string, string>();
for (const n of source.npcs ?? []) {
const mappedAvatar = assetMap.get(n.avatarAssetId) ?? n.avatarAssetId;
const existingByAsset = npcs.find((x) => x.avatarAssetId === mappedAvatar);
if (existingByAsset || npcAssetIds.has(mappedAvatar)) {
if (existingByAsset) npcIdMap.set(n.id, existingByAsset.id);
continue;
}
let name = n.name.trim();
const baseKey = name.toLowerCase();
if (npcNameKeys.has(baseKey)) {
let i = 2;
while (npcNameKeys.has(`${baseKey} (${String(i)})`)) i += 1;
name = `${name} (${String(i)})`;
}
const newId = asNpcId(`npc_${generateId()}`);
npcIdMap.set(n.id, newId);
npcs.push({
id: newId,
name,
avatarAssetId: mappedAvatar,
description: n.description ?? '',
x: n.x,
y: n.y,
});
npcNameKeys.add(name.toLowerCase());
npcAssetIds.add(mappedAvatar);
}
const npcRelations = [...(target.npcRelations ?? [])];
for (const r of source.npcRelations ?? []) {
const sourceId = npcIdMap.get(r.sourceNpcId) ?? r.sourceNpcId;
const targetId = npcIdMap.get(r.targetNpcId) ?? r.targetNpcId;
if (!npcs.some((n) => n.id === sourceId) || !npcs.some((n) => n.id === targetId)) continue;
const label = r.label.trim();
if (!label) continue;
const dup = npcRelations.some(
(x) =>
x.label === label && x.sourceNpcId === sourceId && x.targetNpcId === targetId,
);
if (dup) continue;
npcRelations.push({
id: asNpcRelationId(`nrel_${generateId()}`),
sourceNpcId: asNpcId(sourceId),
targetNpcId: asNpcId(targetId),
label,
});
}
let merged: Project = {
...target,
scenes,
assets,
campaignAudios,
materials,
npcs,
npcRelations,
sceneGraphNodes: [...target.sceneGraphNodes, ...newGraphNodes],
sceneGraphEdges: [...target.sceneGraphEdges, ...newEdges],
};
const outgoing = recomputeOutgoing(merged.sceneGraphNodes, merged.sceneGraphEdges);
merged = {
...merged,
scenes: applyConnectionSets(merged.scenes, outgoing),
sceneListOrder: reconcileSceneListOrder(scenes, target.sceneListOrder),
};
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()}`);
}