279 lines
8.2 KiB
TypeScript
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),
|
|
]
|
|
: [];
|
|
}
|