278 lines
8.2 KiB
TypeScript
278 lines
8.2 KiB
TypeScript
import type {
|
|
InventoryItem,
|
|
ItemBuildProfile,
|
|
ItemRarity,
|
|
ItemStatProfile,
|
|
ItemUseProfile,
|
|
RuntimeItemAiIntent,
|
|
RuntimeItemCompileBudget,
|
|
RuntimeItemGenerationContext,
|
|
RuntimeItemPlan,
|
|
TimedBuildBuff,
|
|
} from '../types';
|
|
import {normalizeBuildRole, normalizeBuildTags} from './buildTags';
|
|
|
|
const RARITY_ORDER: ItemRarity[] = ['common', 'uncommon', 'rare', 'epic', 'legendary'];
|
|
|
|
function clampIndex(index: number) {
|
|
return Math.max(0, Math.min(RARITY_ORDER.length - 1, index));
|
|
}
|
|
|
|
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 buildStructuralTags(plan: RuntimeItemPlan) {
|
|
switch (plan.itemKind) {
|
|
case 'equipment':
|
|
return ['weapon'];
|
|
case 'relic':
|
|
return ['relic'];
|
|
case 'material':
|
|
return ['material'];
|
|
case 'consumable':
|
|
return ['consumable'];
|
|
case 'quest':
|
|
return ['relic'];
|
|
default:
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function resolveEquipmentSlotId(plan: RuntimeItemPlan) {
|
|
if (plan.itemKind === 'equipment') {
|
|
if (plan.targetBuildDirection.includes('守御') || plan.targetBuildDirection.includes('护体')) {
|
|
return 'armor' as const;
|
|
}
|
|
return 'weapon' as const;
|
|
}
|
|
|
|
if (plan.itemKind === 'relic' || plan.itemKind === 'quest') {
|
|
return 'relic' as const;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function resolveCategory(plan: RuntimeItemPlan) {
|
|
switch (plan.itemKind) {
|
|
case 'equipment':
|
|
return resolveEquipmentSlotId(plan) === 'armor' ? '护甲' : '武器';
|
|
case 'consumable':
|
|
return '消耗品';
|
|
case 'material':
|
|
return '材料';
|
|
case 'quest':
|
|
return '专属物';
|
|
default:
|
|
return '稀有品';
|
|
}
|
|
}
|
|
|
|
function buildRuntimeBudget(
|
|
context: RuntimeItemGenerationContext,
|
|
plan: RuntimeItemPlan,
|
|
seedKey: string,
|
|
): RuntimeItemCompileBudget {
|
|
const seed = hashText(`${context.generationChannel}:${seedKey}:${plan.slot}:${plan.itemKind}`);
|
|
const channelBaseIndex = context.generationChannel === 'quest_reward'
|
|
? 2
|
|
: context.generationChannel === 'treasure'
|
|
? 1
|
|
: context.generationChannel === 'monster_drop'
|
|
? 0
|
|
: 1;
|
|
const slotBonus = plan.slot === 'primary' ? 1 : 0;
|
|
const permanenceBonus = plan.permanence === 'permanent' ? 1 : 0;
|
|
const rarityIndex = clampIndex(channelBaseIndex + slotBonus + permanenceBonus + (seed % 2));
|
|
const rarity = RARITY_ORDER[rarityIndex] ?? 'common';
|
|
|
|
return {
|
|
rarity,
|
|
buildTagLimit: rarity === 'legendary' ? 2 : rarity === 'epic' || rarity === 'rare' ? 2 : 1,
|
|
timedBuffTagLimit: rarity === 'legendary' ? 3 : rarity === 'epic' || rarity === 'rare' ? 2 : 1,
|
|
timedBuffDuration: rarity === 'legendary' ? 3 : rarity === 'epic' ? 3 : 2,
|
|
statBudgetTier: rarityIndex + 1,
|
|
};
|
|
}
|
|
|
|
function buildTimedBuffs(
|
|
itemId: string,
|
|
intent: RuntimeItemAiIntent,
|
|
budget: RuntimeItemCompileBudget,
|
|
): TimedBuildBuff[] {
|
|
const tags = normalizeBuildTags(intent.desiredBuildTags, budget.timedBuffTagLimit);
|
|
if (tags.length <= 0) return [];
|
|
|
|
return [{
|
|
id: `${itemId}:buff`,
|
|
sourceType: 'item',
|
|
sourceId: itemId,
|
|
name: `${tags[0]}增益`,
|
|
tags,
|
|
durationTurns: budget.timedBuffDuration,
|
|
}];
|
|
}
|
|
|
|
function buildUseProfile(
|
|
plan: RuntimeItemPlan,
|
|
intent: RuntimeItemAiIntent,
|
|
budget: RuntimeItemCompileBudget,
|
|
itemId: string,
|
|
): ItemUseProfile | null {
|
|
if (plan.itemKind !== 'consumable' && plan.permanence === 'permanent') return null;
|
|
|
|
const useProfile: ItemUseProfile = {};
|
|
|
|
if (intent.desiredFunctionalBias.includes('heal')) {
|
|
useProfile.hpRestore = 16 + budget.statBudgetTier * 10;
|
|
}
|
|
if (intent.desiredFunctionalBias.includes('mana')) {
|
|
useProfile.manaRestore = 14 + budget.statBudgetTier * 8;
|
|
}
|
|
if (intent.desiredFunctionalBias.includes('cooldown')) {
|
|
useProfile.cooldownReduction = budget.rarity === 'legendary' || budget.rarity === 'epic' ? 1 : 0;
|
|
}
|
|
if (plan.permanence === 'timed' || plan.itemKind === 'consumable') {
|
|
useProfile.buildBuffs = buildTimedBuffs(itemId, intent, budget);
|
|
}
|
|
|
|
if (
|
|
(useProfile.hpRestore ?? 0) <= 0
|
|
&& (useProfile.manaRestore ?? 0) <= 0
|
|
&& (useProfile.cooldownReduction ?? 0) <= 0
|
|
&& (useProfile.buildBuffs?.length ?? 0) <= 0
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return useProfile;
|
|
}
|
|
|
|
function buildStatProfile(
|
|
plan: RuntimeItemPlan,
|
|
intent: RuntimeItemAiIntent,
|
|
budget: RuntimeItemCompileBudget,
|
|
): ItemStatProfile | null {
|
|
if (plan.itemKind === 'material') return null;
|
|
|
|
const statProfile: ItemStatProfile = {};
|
|
const tier = budget.statBudgetTier;
|
|
|
|
if (plan.itemKind === 'equipment' || plan.itemKind === 'relic' || plan.itemKind === 'quest') {
|
|
if (intent.desiredFunctionalBias.includes('guard') || plan.targetBuildDirection.includes('守御')) {
|
|
statProfile.maxHpBonus = 6 * tier + (plan.itemKind === 'equipment' ? 10 : 4);
|
|
statProfile.incomingDamageMultiplier = Number(Math.max(0.84, 1 - tier * 0.03).toFixed(2));
|
|
}
|
|
if (intent.desiredFunctionalBias.includes('mana') || plan.targetBuildDirection.includes('法力')) {
|
|
statProfile.maxManaBonus = 8 * tier + (plan.itemKind === 'relic' ? 10 : 0);
|
|
}
|
|
if (intent.desiredFunctionalBias.includes('damage') || plan.targetBuildDirection.length > 0) {
|
|
statProfile.outgoingDamageBonus = Number((0.03 * tier).toFixed(2));
|
|
}
|
|
}
|
|
|
|
if (Object.keys(statProfile).length <= 0) return null;
|
|
return statProfile;
|
|
}
|
|
|
|
function buildProfile(
|
|
plan: RuntimeItemPlan,
|
|
intent: RuntimeItemAiIntent,
|
|
budget: RuntimeItemCompileBudget,
|
|
): ItemBuildProfile | null {
|
|
if (plan.permanence !== 'permanent' || (plan.itemKind !== 'equipment' && plan.itemKind !== 'relic' && plan.itemKind !== 'quest')) {
|
|
return null;
|
|
}
|
|
|
|
const tags = normalizeBuildTags(intent.desiredBuildTags, budget.buildTagLimit);
|
|
if (tags.length <= 0) return null;
|
|
|
|
return {
|
|
role: normalizeBuildRole(tags[0]),
|
|
tags,
|
|
synergy: normalizeBuildTags([
|
|
...plan.targetBuildDirection,
|
|
...intent.desiredBuildTags,
|
|
], 3),
|
|
craftTags: tags,
|
|
forgeRank: 0,
|
|
};
|
|
}
|
|
|
|
function buildValue(
|
|
budget: RuntimeItemCompileBudget,
|
|
plan: RuntimeItemPlan,
|
|
) {
|
|
const baseValue = {
|
|
common: 18,
|
|
uncommon: 32,
|
|
rare: 52,
|
|
epic: 80,
|
|
legendary: 118,
|
|
}[budget.rarity];
|
|
|
|
if (plan.itemKind === 'material') return baseValue - 8;
|
|
if (plan.itemKind === 'consumable') return baseValue - 4;
|
|
if (plan.itemKind === 'quest') return baseValue + 12;
|
|
return baseValue;
|
|
}
|
|
|
|
function buildItemTags(
|
|
plan: RuntimeItemPlan,
|
|
intent: RuntimeItemAiIntent,
|
|
budget: RuntimeItemCompileBudget,
|
|
) {
|
|
const tags = [
|
|
...buildStructuralTags(plan),
|
|
...normalizeBuildTags(intent.desiredBuildTags, budget.buildTagLimit + 1),
|
|
];
|
|
|
|
if (intent.desiredFunctionalBias.includes('heal')) tags.push('healing');
|
|
if (intent.desiredFunctionalBias.includes('mana')) tags.push('mana');
|
|
if (resolveEquipmentSlotId(plan) === 'armor') tags.push('armor');
|
|
if (resolveEquipmentSlotId(plan) === 'weapon') tags.push('weapon');
|
|
if (resolveEquipmentSlotId(plan) === 'relic') tags.push('relic');
|
|
|
|
return [...new Set(tags.filter(Boolean))];
|
|
}
|
|
|
|
export function compileRuntimeItem(params: {
|
|
seedKey: string;
|
|
context: RuntimeItemGenerationContext;
|
|
plan: RuntimeItemPlan;
|
|
intent: RuntimeItemAiIntent;
|
|
}) {
|
|
const {seedKey, context, plan, intent} = params;
|
|
const budget = buildRuntimeBudget(context, plan, seedKey);
|
|
const itemId = `runtime:${context.generationChannel}:${hashText(seedKey).toString(36)}`;
|
|
const runtimeMetadata = {
|
|
origin: 'ai_compiled' as const,
|
|
generationChannel: context.generationChannel,
|
|
seedKey,
|
|
relationAnchor: plan.relationAnchor,
|
|
sourceReason: intent.reasonToAppear,
|
|
recentEventHook: context.recentActions[0],
|
|
};
|
|
|
|
return {
|
|
id: itemId,
|
|
category: resolveCategory(plan),
|
|
name: intent.shortNameSeed || '未命名秘物',
|
|
quantity: 1,
|
|
rarity: budget.rarity,
|
|
tags: buildItemTags(plan, intent, budget),
|
|
equipmentSlotId: resolveEquipmentSlotId(plan),
|
|
statProfile: buildStatProfile(plan, intent, budget),
|
|
useProfile: buildUseProfile(plan, intent, budget, itemId),
|
|
buildProfile: buildProfile(plan, intent, budget),
|
|
value: buildValue(budget, plan),
|
|
runtimeMetadata,
|
|
} satisfies InventoryItem;
|
|
}
|