Files
Genarrative/src/services/storyEngine/carrierNarrativeCompiler.ts
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +08:00

218 lines
7.4 KiB
TypeScript

import type {
CarrierStoryFingerprint,
InventoryItem,
RuntimeItemAiIntent,
RuntimeItemGenerationContext,
RuntimeItemPlan,
RuntimeRelationAnchor,
ThemePack,
WorldStoryGraph,
} from '../../types';
import { buildThemePackFromWorldProfile } from './themePack';
import { buildFallbackWorldStoryGraph } from './worldStoryGraph';
type CarrierNarrativeParams = {
item: InventoryItem;
context: RuntimeItemGenerationContext;
plan: RuntimeItemPlan;
intent: RuntimeItemAiIntent;
};
function dedupeStrings(values: Array<string | null | undefined>, limit = 8) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
.slice(0, limit);
}
function sanitizeFragment(value: string | null | undefined, maxLength = 4) {
return (value ?? '')
.replace(/[^\u4e00-\u9fa5a-z0-9]/giu, '')
.slice(0, maxLength);
}
function resolveAnchorLabel(anchor: RuntimeRelationAnchor) {
switch (anchor.type) {
case 'npc':
return anchor.npcName;
case 'scene':
return anchor.sceneName;
case 'landmark':
return anchor.landmarkName;
case 'monster':
return anchor.monsterName;
case 'faction':
return anchor.factionName;
case 'quest':
return anchor.questName;
default:
return '此地';
}
}
function resolveThemePack(context: RuntimeItemGenerationContext): ThemePack | null {
if (!context.customWorldProfile) {
return null;
}
return context.customWorldProfile.themePack
?? buildThemePackFromWorldProfile(context.customWorldProfile);
}
function resolveStoryGraph(
context: RuntimeItemGenerationContext,
themePack: ThemePack | null,
): WorldStoryGraph | null {
if (!context.customWorldProfile || !themePack) {
return null;
}
return context.customWorldProfile.storyGraph
?? buildFallbackWorldStoryGraph(context.customWorldProfile, themePack);
}
function resolveThreadLabel(
graph: WorldStoryGraph | null,
threadIds: string[],
) {
const thread = [...(graph?.visibleThreads ?? []), ...(graph?.hiddenThreads ?? [])]
.find((candidate) => threadIds.includes(candidate.id));
return thread?.title ?? '未尽旧事';
}
function resolveScarLabel(
graph: WorldStoryGraph | null,
scarIds: string[],
) {
const scar = graph?.scars.find((candidate) => scarIds.includes(candidate.id));
return scar?.title ?? '旧痕';
}
function resolveFunctionWord(item: InventoryItem, plan: RuntimeItemPlan, intent: RuntimeItemAiIntent) {
const topTag = intent.desiredBuildTags[0] ?? plan.targetBuildDirection[0] ?? '';
if (plan.itemKind === 'consumable') {
if (intent.desiredFunctionalBias.includes('heal')) return '灵露';
if (intent.desiredFunctionalBias.includes('mana')) return '回气散';
if (intent.desiredFunctionalBias.includes('cooldown')) return '压纹符';
return '药包';
}
if (plan.itemKind === 'material') {
return topTag ? `${topTag}残材` : '残材';
}
if (plan.itemKind === 'quest') {
return '信物';
}
if (item.equipmentSlotId === 'weapon') {
if (topTag === '快剑' || topTag === '追击') return '短刃';
if (topTag === '远射') return '短弓';
if (topTag === '重击') return '战锤';
return '兵刃';
}
if (item.equipmentSlotId === 'armor') {
return topTag === '守御' ? '护甲' : '护符';
}
if (item.equipmentSlotId === 'relic' || plan.itemKind === 'relic') {
return topTag === '法力' ? '灵坠' : '护心佩';
}
return sanitizeFragment(intent.shortNameSeed) || '秘物';
}
export function buildRuntimeItemStoryFingerprint(
params: Pick<CarrierNarrativeParams, 'context' | 'plan' | 'intent'>,
) {
const { context, plan, intent } = params;
const themePack = resolveThemePack(context);
const graph = resolveStoryGraph(context, themePack);
const anchorLabel = resolveAnchorLabel(plan.relationAnchor);
const relatedThreadIds = dedupeStrings([
...(context.activeThreadIds ?? []),
...(context.relatedNpcNarrativeProfile?.relatedThreadIds ?? []),
graph?.visibleThreads[0]?.id,
], 3);
const relatedScarIds = dedupeStrings([
...(context.relatedNpcNarrativeProfile?.relatedScarIds ?? []),
graph?.scars[0]?.id,
], 2);
const primaryThreadLabel = resolveThreadLabel(graph, relatedThreadIds);
const primaryScarLabel = resolveScarLabel(graph, relatedScarIds);
const sceneLabel = context.sceneName ?? context.customWorldProfile?.name ?? '此地';
return {
visibleClue:
intent.visibleClue
?? `${anchorLabel}留下的${themePack?.clueForms[0] ?? '旧痕'}仍粘在这件物上。`,
witnessMark:
intent.witnessMark
?? `${primaryScarLabel}的余震被磨进了它的纹路与边角。`,
unresolvedQuestion:
intent.unfinishedBusiness
?? context.relatedNpcNarrativeProfile?.contradiction
?? `${primaryThreadLabel}为什么会在${sceneLabel}重新露头?`,
currentAppearanceReason:
intent.reasonToAppear ||
`${sceneLabel}与最近的局势把它推到了你眼前。`,
relatedThreadIds,
relatedScarIds,
reactionHooks: dedupeStrings([
...(intent.reactionHooks ?? []),
...(context.relatedNpcNarrativeProfile?.reactionHooks ?? []),
primaryThreadLabel,
anchorLabel,
], 5),
} satisfies CarrierStoryFingerprint;
}
export function buildCarrierNarrativeName(params: CarrierNarrativeParams) {
const { item, plan, intent } = params;
const fingerprint = buildRuntimeItemStoryFingerprint(params);
const anchorWord = sanitizeFragment(resolveAnchorLabel(plan.relationAnchor), 4) || '旧誓';
const threadWord =
sanitizeFragment(fingerprint.visibleClue, 4)
|| sanitizeFragment(fingerprint.witnessMark, 4)
|| sanitizeFragment(intent.sourcePhrase, 4)
|| '余痕';
const functionWord = resolveFunctionWord(item, plan, intent);
const tabooWord =
sanitizeFragment(params.context.relatedNpcNarrativeProfile?.taboo, 4) || '禁名';
switch (intent.namingPattern) {
case 'scene_relic':
return `${anchorWord}${sanitizeFragment(fingerprint.witnessMark, 4) || '灰纹'}${functionWord}`;
case 'faction_issue':
return `${anchorWord}制式${functionWord}`;
case 'monster_trophy':
return `${anchorWord}${sanitizeFragment(fingerprint.visibleClue, 4) || '猎印'}${functionWord}`;
case 'quest_evidence':
return `${sanitizeFragment(fingerprint.unresolvedQuestion, 4) || anchorWord}${threadWord}信物`;
case 'forbidden_object':
return `${tabooWord}封痕${functionWord}`;
case 'npc_relic':
default:
return `${anchorWord}${threadWord}${functionWord}`;
}
}
export function buildCarrierNarrativeDescription(params: CarrierNarrativeParams) {
const fingerprint = buildRuntimeItemStoryFingerprint(params);
const threadLabel = resolveThreadLabel(
resolveStoryGraph(params.context, resolveThemePack(params.context)),
fingerprint.relatedThreadIds,
);
const buildDirectionText =
params.intent.desiredBuildTags.join('、')
|| params.context.playerBuildTags.join('、')
|| '均衡';
const sceneLabel = params.context.sceneName ?? params.context.customWorldProfile?.name ?? '此地';
return [
fingerprint.visibleClue,
`${fingerprint.witnessMark} 它和${threadLabel}之间的牵连还没有真正结清,${fingerprint.unresolvedQuestion}`,
`如今它会在${sceneLabel}出现,是因为${fingerprint.currentAppearanceReason}。它也自然偏向${buildDirectionText}方向,适合当前局势里的临场构筑调整。`,
].join(' ');
}