231 lines
8.0 KiB
TypeScript
231 lines
8.0 KiB
TypeScript
import {
|
|
buildCarrierNarrativeDescription,
|
|
buildCarrierNarrativeName,
|
|
buildRuntimeItemStoryFingerprint,
|
|
} from '../services/storyEngine/carrierNarrativeCompiler';
|
|
import type {
|
|
DirectedRuntimeReward,
|
|
InventoryItem,
|
|
RuntimeItemAiIntent,
|
|
RuntimeItemAiPromptInput,
|
|
RuntimeItemGenerationContext,
|
|
RuntimeItemPlan,
|
|
RuntimeRelationAnchor,
|
|
} from '../types';
|
|
|
|
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 '无名之地';
|
|
}
|
|
}
|
|
|
|
export function buildRuntimeItemAiPromptInput(
|
|
context: RuntimeItemGenerationContext,
|
|
plan: RuntimeItemPlan,
|
|
): RuntimeItemAiPromptInput {
|
|
const storyGraph = context.customWorldProfile?.storyGraph;
|
|
const activeThreadSummary = (context.activeThreadIds ?? [])
|
|
.map((threadId) =>
|
|
[...(storyGraph?.visibleThreads ?? []), ...(storyGraph?.hiddenThreads ?? [])]
|
|
.find((thread) => thread.id === threadId)?.title ?? threadId,
|
|
)
|
|
.join('、');
|
|
|
|
return {
|
|
worldSummary: context.customWorldProfile?.summary ?? context.worldType ?? '未知世界',
|
|
sceneSummary: [context.sceneName, context.sceneDescription].filter(Boolean).join(' / '),
|
|
encounterSummary: [context.encounterNpcName, context.encounterContextText].filter(Boolean).join(' / '),
|
|
relatedNpcSummary: context.relatedNpcNarrativeProfile
|
|
? `${context.encounterNpcName ?? '相关人物'}:公开面 ${context.relatedNpcNarrativeProfile.publicMask};当前压力 ${context.relatedNpcNarrativeProfile.immediatePressure}`
|
|
: context.relatedNpcState
|
|
? `${context.encounterNpcName ?? '相关人物'} 当前好感 ${context.relatedNpcState.affinity}`
|
|
: '暂无明确人物关系',
|
|
recentStorySummary: context.recentStorySummary,
|
|
activeThreadSummary,
|
|
generationChannel: context.generationChannel,
|
|
playerBuildDirection: context.playerBuildTags,
|
|
playerBuildGaps: context.playerBuildGaps,
|
|
desiredItemKind: plan.itemKind,
|
|
permanence: plan.permanence,
|
|
};
|
|
}
|
|
|
|
export function buildRuntimeItemAiIntent(
|
|
context: RuntimeItemGenerationContext,
|
|
plan: RuntimeItemPlan,
|
|
): RuntimeItemAiIntent {
|
|
const anchorLabel = resolveAnchorLabel(plan.relationAnchor);
|
|
const sourceSeed = sanitizeFragment(context.sceneName, 4)
|
|
|| sanitizeFragment(context.customWorldProfile?.name, 4)
|
|
|| sanitizeFragment(anchorLabel, 4)
|
|
|| '旧誓';
|
|
const functionalBias: RuntimeItemAiIntent['desiredFunctionalBias'] = [];
|
|
|
|
if (plan.permanence === 'timed') {
|
|
functionalBias.push(context.playerBuildGaps.includes('survival_gap') ? 'heal' : 'cooldown');
|
|
}
|
|
if (context.playerBuildGaps.includes('mana_gap')) functionalBias.push('mana');
|
|
if (context.playerBuildGaps.includes('survival_gap')) functionalBias.push('guard');
|
|
if (
|
|
functionalBias.length <= 0
|
|
|| context.playerBuildGaps.includes('finisher_gap')
|
|
|| plan.itemKind === 'equipment'
|
|
) {
|
|
functionalBias.push('damage');
|
|
}
|
|
|
|
return {
|
|
shortNameSeed: sourceSeed,
|
|
sourcePhrase: anchorLabel,
|
|
reasonToAppear: context.generationChannel === 'monster_drop'
|
|
? `${anchorLabel}倒下后,${context.sceneName ?? '这片战场'}里最值得带走的残留被翻了出来。`
|
|
: `${anchorLabel}与最近局势把它推到了你面前。`,
|
|
relationHooks: [
|
|
context.encounterContextText ?? context.sceneName ?? anchorLabel,
|
|
...context.recentActions,
|
|
].filter(Boolean).slice(0, 2) as string[],
|
|
desiredBuildTags: [...new Set([
|
|
...plan.targetBuildDirection,
|
|
...context.playerBuildTags.slice(0, 2),
|
|
])].slice(0, 3),
|
|
desiredFunctionalBias: [...new Set(functionalBias)].slice(0, 2),
|
|
tone: context.generationChannel === 'monster_drop'
|
|
? 'grim'
|
|
: context.generationChannel === 'quest_reward'
|
|
? 'ritual'
|
|
: context.playerBuildGaps.includes('survival_gap')
|
|
? 'survival'
|
|
: 'martial',
|
|
visibleClue:
|
|
context.relatedNpcNarrativeProfile?.visibleLine
|
|
?? `${anchorLabel}身上留下的旧痕`,
|
|
witnessMark:
|
|
context.relatedNpcNarrativeProfile?.debtOrBurden
|
|
?? `${anchorLabel}尚未散尽的使用痕`,
|
|
unfinishedBusiness:
|
|
context.relatedNpcNarrativeProfile?.contradiction
|
|
?? `${anchorLabel}背后还有没说完的问题`,
|
|
hiddenHook:
|
|
context.relatedNpcNarrativeProfile?.taboo
|
|
?? `${anchorLabel}为什么会在此刻重新出现`,
|
|
reactionHooks: [
|
|
...(context.relatedNpcNarrativeProfile?.reactionHooks ?? []),
|
|
...(context.activeThreadIds ?? []),
|
|
].slice(0, 4),
|
|
namingPattern:
|
|
plan.itemKind === 'quest'
|
|
? 'quest_evidence'
|
|
: plan.itemKind === 'material'
|
|
? 'scene_relic'
|
|
: plan.relationAnchor.type === 'monster'
|
|
? 'monster_trophy'
|
|
: plan.relationAnchor.type === 'npc'
|
|
? 'npc_relic'
|
|
: 'faction_issue',
|
|
};
|
|
}
|
|
|
|
export function applyRuntimeItemNarrative(params: {
|
|
item: InventoryItem;
|
|
context: RuntimeItemGenerationContext;
|
|
plan: RuntimeItemPlan;
|
|
intent: RuntimeItemAiIntent;
|
|
}) {
|
|
const fingerprint = buildRuntimeItemStoryFingerprint(params);
|
|
const runtimeMetadata =
|
|
params.item.runtimeMetadata ?? {
|
|
origin: 'ai_compiled' as const,
|
|
generationChannel: params.context.generationChannel,
|
|
seedKey: `${params.context.generationChannel}:${params.item.id}`,
|
|
sourceReason: params.intent.reasonToAppear,
|
|
};
|
|
|
|
return {
|
|
...params.item,
|
|
name: buildCarrierNarrativeName(params),
|
|
description: buildCarrierNarrativeDescription(params),
|
|
runtimeMetadata: {
|
|
...runtimeMetadata,
|
|
storyFingerprint: fingerprint,
|
|
},
|
|
} satisfies InventoryItem;
|
|
}
|
|
|
|
export function applyRuntimeItemNarrativeToExistingItem(params: {
|
|
item: InventoryItem;
|
|
context: RuntimeItemGenerationContext;
|
|
plan: RuntimeItemPlan;
|
|
intent: RuntimeItemAiIntent;
|
|
preserveName?: boolean;
|
|
}) {
|
|
const fingerprint = buildRuntimeItemStoryFingerprint(params);
|
|
const runtimeMetadata =
|
|
params.item.runtimeMetadata ?? {
|
|
origin: 'procedural' as const,
|
|
generationChannel: params.context.generationChannel,
|
|
relationAnchor: params.plan.relationAnchor,
|
|
seedKey: `${params.context.generationChannel}:${params.item.id}`,
|
|
sourceReason: params.intent.reasonToAppear,
|
|
};
|
|
|
|
return {
|
|
...params.item,
|
|
name: params.preserveName
|
|
? params.item.name
|
|
: buildCarrierNarrativeName(params),
|
|
description: buildCarrierNarrativeDescription(params),
|
|
runtimeMetadata: {
|
|
...runtimeMetadata,
|
|
relationAnchor: runtimeMetadata.relationAnchor ?? params.plan.relationAnchor,
|
|
sourceReason: params.intent.reasonToAppear,
|
|
storyFingerprint: fingerprint,
|
|
},
|
|
} satisfies InventoryItem;
|
|
}
|
|
|
|
export function describeRuntimeRelationAnchor(anchor: RuntimeRelationAnchor | undefined) {
|
|
if (!anchor) return '无明确锚点';
|
|
return `${anchor.type}:${resolveAnchorLabel(anchor)}`;
|
|
}
|
|
|
|
export function flattenDirectedRuntimeRewardItems(reward: DirectedRuntimeReward) {
|
|
return [
|
|
...(reward.primaryItem ? [reward.primaryItem] : []),
|
|
...reward.supportItems,
|
|
];
|
|
}
|
|
|
|
export function buildRuntimeRewardStoryHint(reward: DirectedRuntimeReward) {
|
|
const primaryItem = reward.primaryItem;
|
|
const fingerprint = primaryItem?.runtimeMetadata?.storyFingerprint;
|
|
if (!primaryItem) {
|
|
return reward.storyHint ?? '你得到了一件与当前局势相关的物品。';
|
|
}
|
|
if (reward.storyHint) {
|
|
return reward.storyHint;
|
|
}
|
|
if (fingerprint) {
|
|
return `${primaryItem.name}先露出的是“${fingerprint.visibleClue}”,但它背后还压着“${fingerprint.unresolvedQuestion}”。`;
|
|
}
|
|
return `这次得到的核心物件是 ${primaryItem.name}。`;
|
|
}
|