243 lines
7.2 KiB
TypeScript
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));
|
|
}
|