Files
Genarrative/src/data/runtimeItemDirector.ts

243 lines
7.2 KiB
TypeScript

import type {
DirectedRuntimeReward,
InventoryItem,
RuntimeItemGenerationChannel,
RuntimeItemGenerationContext,
RuntimeItemKind,
RuntimeItemPermanence,
RuntimeItemPlan,
RuntimeRelationAnchor,
} from '../types';
import {generateRuntimeItemAiIntents} from '../services/runtimeItemAiDirector';
import {compileRuntimeItem} from './runtimeItemCompiler';
import {
applyRuntimeItemNarrative,
buildRuntimeItemAiIntent,
buildRuntimeRewardStoryHint,
flattenDirectedRuntimeRewardItems,
} from './runtimeItemNarrative';
type RuntimeRewardOptions = {
seedKey: string;
variant?: string;
itemCount?: number;
fixedKinds?: RuntimeItemKind[];
fixedPermanence?: RuntimeItemPermanence[];
baseHp?: number;
baseMana?: number;
baseCurrency?: number;
storyHint?: string;
};
const GAP_TO_TAGS: Record<string, string[]> = {
survival_gap: ['守御', '护体', '回复', '续战'],
mana_gap: ['法力', '冷却', '过载'],
finisher_gap: ['爆发', '重击', '追击'],
mobility_gap: ['突进', '快袭', '风行', '游击'],
control_gap: ['控场', '符阵', '镇邪'],
};
function hashText(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = ((hash << 5) - hash) + value.charCodeAt(index);
hash |= 0;
}
return Math.abs(hash);
}
function resolveGapDrivenTags(context: RuntimeItemGenerationContext) {
const gapTags = context.playerBuildGaps.flatMap(gap => GAP_TO_TAGS[gap] ?? []);
return [...new Set([
...gapTags,
...context.playerBuildTags,
])].slice(0, 3);
}
function resolveRelationAnchor(context: RuntimeItemGenerationContext): RuntimeRelationAnchor {
if (context.encounter?.monsterPresetId) {
return {
type: 'monster',
monsterId: context.encounter.monsterPresetId,
monsterName: context.encounter.npcName,
};
}
if (context.encounterNpcName) {
return {
type: 'npc',
npcId: context.encounterNpcId ?? undefined,
npcName: context.encounterNpcName,
roleText: context.encounterContextText ?? undefined,
};
}
if (context.sceneName) {
return {
type: 'scene',
sceneId: context.sceneId ?? undefined,
sceneName: context.sceneName,
};
}
return {
type: 'landmark',
landmarkName: context.customWorldProfile?.name ?? '无名之地',
};
}
function getChannelTemplate(channel: RuntimeItemGenerationChannel) {
switch (channel) {
case 'treasure':
return {
kinds: ['relic', 'consumable', 'material'] as RuntimeItemKind[],
permanence: ['permanent', 'timed', 'resource'] as RuntimeItemPermanence[],
defaultCount: 2,
};
case 'npc_trade':
return {
kinds: ['consumable', 'material', 'relic', 'equipment'] as RuntimeItemKind[],
permanence: ['timed', 'resource', 'permanent', 'permanent'] as RuntimeItemPermanence[],
defaultCount: 4,
};
case 'npc_reward':
return {
kinds: ['consumable', 'relic', 'equipment'] as RuntimeItemKind[],
permanence: ['timed', 'permanent', 'permanent'] as RuntimeItemPermanence[],
defaultCount: 1,
};
case 'monster_drop':
return {
kinds: ['material', 'consumable', 'relic'] as RuntimeItemKind[],
permanence: ['resource', 'timed', 'permanent'] as RuntimeItemPermanence[],
defaultCount: 2,
};
case 'quest_reward':
return {
kinds: ['equipment', 'relic', 'consumable'] as RuntimeItemKind[],
permanence: ['permanent', 'permanent', 'timed'] as RuntimeItemPermanence[],
defaultCount: 2,
};
default:
return {
kinds: ['consumable', 'material', 'relic'] as RuntimeItemKind[],
permanence: ['timed', 'resource', 'permanent'] as RuntimeItemPermanence[],
defaultCount: 2,
};
}
}
function planRuntimeItems(
context: RuntimeItemGenerationContext,
options: RuntimeRewardOptions,
): RuntimeItemPlan[] {
const template = getChannelTemplate(context.generationChannel);
const count = Math.max(1, options.itemCount ?? template.defaultCount);
const relationAnchor = resolveRelationAnchor(context);
const targetBuildDirection = resolveGapDrivenTags(context);
const seed = hashText(`${options.seedKey}:${options.variant ?? 'default'}:${context.generationChannel}`);
return Array.from({length: count}, (_, index) => {
const kinds = options.fixedKinds?.length ? options.fixedKinds : template.kinds;
const permanences = options.fixedPermanence?.length ? options.fixedPermanence : template.permanence;
const itemKind = kinds[(seed + index) % kinds.length] ?? 'consumable';
const permanence = permanences[(seed + index) % permanences.length] ?? 'timed';
return {
slot: index === 0 ? 'primary' : index === 1 ? 'secondary' : 'support',
itemKind,
permanence,
narrativeWeight: index === 0 ? 'heavy' : 'medium',
targetBuildDirection: targetBuildDirection.length > 0 ? targetBuildDirection : ['均衡'],
relationAnchor,
} satisfies RuntimeItemPlan;
});
}
function compilePlannedItem(
context: RuntimeItemGenerationContext,
plan: RuntimeItemPlan,
seedKey: string,
intentOverride?: ReturnType<typeof buildRuntimeItemAiIntent>,
) {
const intent = intentOverride ?? buildRuntimeItemAiIntent(context, plan);
const compiled = compileRuntimeItem({
seedKey,
context,
plan,
intent,
});
return applyRuntimeItemNarrative({
item: compiled,
context,
plan,
intent,
});
}
function buildDirectedRewardFromItems(
compiledItems: InventoryItem[],
options: RuntimeRewardOptions,
): DirectedRuntimeReward {
const reward: DirectedRuntimeReward = {
primaryItem: compiledItems[0] ?? null,
supportItems: compiledItems.slice(1),
hp: options.baseHp,
mana: options.baseMana,
currency: options.baseCurrency,
storyHint: options.storyHint,
};
return {
...reward,
storyHint: buildRuntimeRewardStoryHint(reward),
};
}
export function buildDirectedRuntimeReward(
context: RuntimeItemGenerationContext,
options: RuntimeRewardOptions,
): DirectedRuntimeReward {
const plans = planRuntimeItems(context, options);
const compiledItems = plans.map((plan, index) =>
compilePlannedItem(context, plan, `${options.seedKey}:${plan.slot}:${index}`),
);
return buildDirectedRewardFromItems(compiledItems, options);
}
export async function generateDirectedRuntimeReward(
context: RuntimeItemGenerationContext,
options: RuntimeRewardOptions,
): Promise<DirectedRuntimeReward> {
const plans = planRuntimeItems(context, options);
try {
const aiIntents = await generateRuntimeItemAiIntents({
context,
plans,
});
const compiledItems = plans.map((plan, index) =>
compilePlannedItem(
context,
plan,
`${options.seedKey}:${plan.slot}:${index}`,
aiIntents[index],
),
);
return buildDirectedRewardFromItems(compiledItems, options);
} catch (error) {
console.warn('[RuntimeItemDirector] falling back to deterministic item intent', error);
return buildDirectedRuntimeReward(context, options);
}
}
export function buildRuntimeInventoryStock(
context: RuntimeItemGenerationContext,
options: RuntimeRewardOptions,
): InventoryItem[] {
return flattenDirectedRuntimeRewardItems(buildDirectedRuntimeReward(context, options));
}