218 lines
7.4 KiB
TypeScript
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(' ');
|
|
}
|