Rework story engine flow and reorganize project docs
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
278
src/services/storyEngine/knowledgeGraph.ts
Normal file
278
src/services/storyEngine/knowledgeGraph.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
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),
|
||||
]
|
||||
: [];
|
||||
}
|
||||
Reference in New Issue
Block a user