Files
Genarrative/src/services/storyEngine/knowledgeGraph.ts
高物 ddcb5d5c8c
Some checks failed
CI / verify (push) Has been cancelled
Rework story engine flow and reorganize project docs
2026-04-06 23:19:00 +08:00

279 lines
8.2 KiB
TypeScript

import type {
CustomWorldProfile,
CustomWorldRoleProfile,
InventoryItem,
KnowledgeFact,
WorldStoryGraph,
} from '../../types';
function dedupeStrings(values: Array<string | null | undefined>, limit = 12) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
.slice(0, limit);
}
function slugify(value: string) {
const ascii = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/giu, '-')
.replace(/^-+|-+$/g, '');
return ascii || 'fact';
}
function createFactId(prefix: string, label: string, index: number) {
return `${prefix}:${slugify(label)}:${index + 1}`;
}
function resolveRelatedFacts(
role: Pick<CustomWorldRoleProfile, 'id' | 'narrativeProfile'>,
graph: WorldStoryGraph,
) {
const relatedThreadIds =
role.narrativeProfile?.relatedThreadIds.length
? role.narrativeProfile.relatedThreadIds
: graph.visibleThreads.slice(0, 1).map((thread) => thread.id);
const relatedScarIds = role.narrativeProfile?.relatedScarIds ?? [];
return {
relatedThreadIds,
relatedScarIds,
};
}
function buildFact(
role: Pick<CustomWorldRoleProfile, 'id' | 'name' | 'narrativeProfile'>,
graph: WorldStoryGraph,
options: {
key: string;
title: string;
content: string;
sourceType: KnowledgeFact['sourceType'];
visibility: KnowledgeFact['visibility'];
sayability: KnowledgeFact['sayability'];
aliases?: string[];
index: number;
},
) {
const related = resolveRelatedFacts(role, graph);
return {
id: createFactId(`${role.id}:${options.key}`, options.title, options.index),
title: options.title,
content: options.content,
ownerActorIds: [role.id],
relatedThreadIds: related.relatedThreadIds,
relatedScarIds: related.relatedScarIds,
sourceType: options.sourceType,
visibility: options.visibility,
sayability: options.sayability,
aliases: options.aliases,
} satisfies KnowledgeFact;
}
export function buildActorKnowledgeFacts(
role: CustomWorldRoleProfile,
graph: WorldStoryGraph,
) {
const profile = role.narrativeProfile;
if (!profile) {
return [] as KnowledgeFact[];
}
const chapterFacts = role.backstoryReveal.chapters.map((chapter, index) =>
buildFact(role, graph, {
key: `chapter:${chapter.id}`,
title: chapter.title,
content: chapter.contextSnippet || chapter.content || chapter.teaser,
sourceType: 'actor',
visibility:
index === 0 ? 'public' : index === 1 ? 'discoverable' : index === 2 ? 'private' : 'forbidden',
sayability:
index <= 1 ? 'direct' : index === 2 ? 'indirect' : 'reactive_only',
aliases: [chapter.id, chapter.teaser],
index,
}),
);
return [
buildFact(role, graph, {
key: 'publicMask',
title: `${role.name}的公开面`,
content: profile.publicMask,
sourceType: 'actor',
visibility: 'public',
sayability: 'direct',
aliases: [role.name, role.title],
index: 0,
}),
buildFact(role, graph, {
key: 'firstContactMask',
title: `${role.name}的首遇说辞`,
content: profile.firstContactMask,
sourceType: 'actor',
visibility: 'discoverable',
sayability: 'direct',
aliases: [role.name, '首遇'],
index: 1,
}),
buildFact(role, graph, {
key: 'visibleLine',
title: `${role.name}的表层线`,
content: profile.visibleLine,
sourceType: 'actor',
visibility: 'discoverable',
sayability: 'direct',
aliases: profile.reactionHooks,
index: 2,
}),
buildFact(role, graph, {
key: 'contradiction',
title: `${role.name}的错位`,
content: profile.contradiction,
sourceType: 'actor',
visibility: 'discoverable',
sayability: 'indirect',
aliases: profile.reactionHooks,
index: 3,
}),
buildFact(role, graph, {
key: 'immediatePressure',
title: `${role.name}的当前压力`,
content: profile.immediatePressure,
sourceType: 'actor',
visibility: 'discoverable',
sayability: 'direct',
aliases: profile.relatedThreadIds,
index: 4,
}),
buildFact(role, graph, {
key: 'hiddenLine',
title: `${role.name}的隐藏线`,
content: profile.hiddenLine,
sourceType: 'actor',
visibility: 'private',
sayability: 'reactive_only',
aliases: profile.relatedThreadIds,
index: 5,
}),
buildFact(role, graph, {
key: 'debtOrBurden',
title: `${role.name}的代价线`,
content: profile.debtOrBurden,
sourceType: 'actor',
visibility: 'private',
sayability: 'indirect',
aliases: profile.relatedScarIds,
index: 6,
}),
buildFact(role, graph, {
key: 'taboo',
title: `${role.name}的禁区`,
content: profile.taboo,
sourceType: 'actor',
visibility: 'forbidden',
sayability: 'reactive_only',
aliases: profile.reactionHooks,
index: 7,
}),
...chapterFacts,
];
}
export function buildCarrierKnowledgeFacts(
carrier: InventoryItem,
_graph: WorldStoryGraph,
) {
const fingerprint = carrier.runtimeMetadata?.storyFingerprint;
if (!fingerprint) {
return [] as KnowledgeFact[];
}
return [
{
id: createFactId(`carrier:${carrier.id}`, carrier.name, 0),
title: `${carrier.name}的可见线索`,
content: fingerprint.visibleClue,
ownerActorIds: [],
relatedThreadIds: fingerprint.relatedThreadIds,
relatedScarIds: fingerprint.relatedScarIds,
sourceType: 'item',
visibility: 'discoverable',
sayability: 'direct',
aliases: dedupeStrings([carrier.name]),
},
{
id: createFactId(`carrier:${carrier.id}`, `${carrier.name}-witness`, 1),
title: `${carrier.name}的见证痕`,
content: fingerprint.witnessMark,
ownerActorIds: [],
relatedThreadIds: fingerprint.relatedThreadIds,
relatedScarIds: fingerprint.relatedScarIds,
sourceType: 'item',
visibility: 'discoverable',
sayability: 'indirect',
aliases: fingerprint.reactionHooks,
},
{
id: createFactId(`carrier:${carrier.id}`, `${carrier.name}-question`, 2),
title: `${carrier.name}的未完成问题`,
content: fingerprint.unresolvedQuestion,
ownerActorIds: [],
relatedThreadIds: fingerprint.relatedThreadIds,
relatedScarIds: fingerprint.relatedScarIds,
sourceType: 'item',
visibility: 'private',
sayability: 'indirect',
aliases: fingerprint.reactionHooks,
},
] satisfies KnowledgeFact[];
}
function buildSceneKnowledgeFacts(profile: CustomWorldProfile, graph: WorldStoryGraph) {
return profile.landmarks.flatMap((landmark, landmarkIndex) =>
(landmark.narrativeResidues ?? []).map((residue, residueIndex) => ({
id: createFactId(`scene:${landmark.id}`, residue.title, residueIndex + landmarkIndex * 10),
title: residue.title,
content: residue.visibleClue,
ownerActorIds: landmark.sceneNpcIds,
relatedThreadIds: residue.linkedThreadIds,
relatedScarIds: graph.scars
.filter((scar) => scar.relatedLocationIds.includes(landmark.id))
.map((scar) => scar.id),
sourceType: 'scene',
visibility: 'discoverable',
sayability: 'direct',
aliases: [landmark.name, residue.title],
}) satisfies KnowledgeFact),
);
}
export function buildKnowledgeGraph(profile: CustomWorldProfile) {
const graph = profile.storyGraph;
if (!graph) {
return [] as KnowledgeFact[];
}
return dedupeStrings([
...profile.playableNpcs.flatMap((role) => buildActorKnowledgeFacts(role, graph).map((fact) => fact.id)),
]).length >= 0
? [
...profile.playableNpcs.flatMap((role) => buildActorKnowledgeFacts(role, graph)),
...profile.storyNpcs.flatMap((role) => buildActorKnowledgeFacts(role, graph)),
...profile.items.flatMap((item) =>
buildCarrierKnowledgeFacts(
{
id: item.id,
category: item.category,
name: item.name,
quantity: 1,
rarity: item.rarity,
tags: item.tags,
description: item.description,
} as InventoryItem,
graph,
),
),
...buildSceneKnowledgeFacts(profile, graph),
]
: [];
}