535 lines
16 KiB
TypeScript
535 lines
16 KiB
TypeScript
import type {
|
|
EquipmentSlotId,
|
|
InventoryItem,
|
|
ItemBuildProfile,
|
|
ItemRarity,
|
|
ItemStatProfile,
|
|
WorldType,
|
|
} from '../types';
|
|
import { getSimilarBuildTags, normalizeBuildRole, normalizeBuildTags } from './buildTags';
|
|
import { formatCurrency } from './economy';
|
|
import { getEquipmentSlotFromItem } from './equipmentEffects';
|
|
import { addInventoryItems, removeInventoryItem } from './npcInteractions';
|
|
|
|
export type ForgeRecipeKind = 'synthesis' | 'forge';
|
|
|
|
type ForgeRequirement = {
|
|
id: string;
|
|
label: string;
|
|
quantity: number;
|
|
matches: (item: InventoryItem) => boolean;
|
|
};
|
|
|
|
type ForgeRecipeDefinition = {
|
|
id: string;
|
|
name: string;
|
|
kind: ForgeRecipeKind;
|
|
description: string;
|
|
resultLabel: string;
|
|
currencyCost: number;
|
|
requirements: ForgeRequirement[];
|
|
createResult: (worldType: WorldType | null) => InventoryItem;
|
|
};
|
|
|
|
export type ForgeRecipeView = {
|
|
id: string;
|
|
name: string;
|
|
kind: ForgeRecipeKind;
|
|
description: string;
|
|
resultLabel: string;
|
|
currencyCost: number;
|
|
currencyText: string;
|
|
requirements: Array<{
|
|
id: string;
|
|
label: string;
|
|
quantity: number;
|
|
owned: number;
|
|
}>;
|
|
canCraft: boolean;
|
|
};
|
|
|
|
export type ForgeExecutionResult = {
|
|
inventory: InventoryItem[];
|
|
currency: number;
|
|
createdItem: InventoryItem;
|
|
};
|
|
|
|
export type DismantleExecutionResult = {
|
|
inventory: InventoryItem[];
|
|
outputs: InventoryItem[];
|
|
};
|
|
|
|
export type ReforgeExecutionResult = {
|
|
inventory: InventoryItem[];
|
|
reforgedItem: InventoryItem;
|
|
currencyCost: number;
|
|
};
|
|
|
|
function createItemId(prefix: string) {
|
|
return `${prefix}:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
function normalizeQuantity(quantity: number) {
|
|
return Math.max(1, Math.floor(quantity));
|
|
}
|
|
|
|
function buildMaterialItem(
|
|
name: string,
|
|
quantity: number,
|
|
tags: string[],
|
|
rarity: ItemRarity = 'uncommon',
|
|
description?: string,
|
|
): InventoryItem {
|
|
return {
|
|
id: createItemId(`forge-material:${name}`),
|
|
category: '材料',
|
|
name,
|
|
quantity: normalizeQuantity(quantity),
|
|
rarity,
|
|
tags: ['material', ...normalizeBuildTags(tags)],
|
|
description,
|
|
buildProfile: {
|
|
role: normalizeBuildRole('工巧'),
|
|
tags: normalizeBuildTags(tags),
|
|
craftTags: normalizeBuildTags(tags),
|
|
forgeRank: 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildEquipmentItem(params: {
|
|
name: string;
|
|
slot: EquipmentSlotId;
|
|
rarity: ItemRarity;
|
|
description: string;
|
|
role: string;
|
|
tags: string[];
|
|
setId: string;
|
|
setName: string;
|
|
pieceName: string;
|
|
synergy: string[];
|
|
statProfile: ItemStatProfile;
|
|
}) {
|
|
return {
|
|
id: createItemId(`forge-equip:${params.name}`),
|
|
category: params.slot === 'weapon' ? '武器' : params.slot === 'armor' ? '护甲' : '饰品',
|
|
name: params.name,
|
|
quantity: 1,
|
|
rarity: params.rarity,
|
|
tags: [
|
|
params.slot === 'weapon' ? 'weapon' : params.slot === 'armor' ? 'armor' : 'relic',
|
|
...normalizeBuildTags(params.tags),
|
|
],
|
|
description: params.description,
|
|
equipmentSlotId: params.slot,
|
|
statProfile: params.statProfile,
|
|
buildProfile: {
|
|
role: normalizeBuildRole(params.role),
|
|
tags: normalizeBuildTags(params.tags),
|
|
setId: params.setId,
|
|
setName: params.setName,
|
|
pieceName: params.pieceName,
|
|
synergy: params.synergy,
|
|
craftTags: normalizeBuildTags(params.tags),
|
|
forgeRank: 1,
|
|
} satisfies ItemBuildProfile,
|
|
} satisfies InventoryItem;
|
|
}
|
|
|
|
function buildRefinedIngot() {
|
|
return buildMaterialItem('精炼锭材', 1, ['工巧', '守御'], 'rare', '经过二次锻压的通用金属锭材,可用于武器与护甲锻造。');
|
|
}
|
|
|
|
function buildCondensedSilk() {
|
|
return buildMaterialItem('凝光纱', 1, ['工巧', '法力'], 'rare', '适合饰品与法器类配方的高阶纤维材料。');
|
|
}
|
|
|
|
function buildTagEssence(tag: string) {
|
|
return buildMaterialItem(`${tag}精粹`, 1, [tag, '工巧'], 'rare', `从旧装备中提炼出的 ${tag} 构筑精粹。`);
|
|
}
|
|
|
|
function buildAnyMaterialRequirement(id: string, label: string, quantity: number): ForgeRequirement {
|
|
return {
|
|
id,
|
|
label,
|
|
quantity,
|
|
matches: item => item.tags.includes('material') || item.category.includes('材料'),
|
|
};
|
|
}
|
|
|
|
function buildNamedMaterialRequirement(name: string, quantity: number): ForgeRequirement {
|
|
return {
|
|
id: `name:${name}`,
|
|
label: name,
|
|
quantity,
|
|
matches: item => item.name === name,
|
|
};
|
|
}
|
|
|
|
const FORGE_RECIPES: ForgeRecipeDefinition[] = [
|
|
{
|
|
id: 'synthesis-refined-ingot',
|
|
name: '压炼锭材',
|
|
kind: 'synthesis',
|
|
description: '把零散残片和基础材料压成稳定可用的金属锭材。',
|
|
resultLabel: '精炼锭材',
|
|
currencyCost: 18,
|
|
requirements: [
|
|
buildAnyMaterialRequirement('material:any', '任意材料', 3),
|
|
],
|
|
createResult: () => buildRefinedIngot(),
|
|
},
|
|
{
|
|
id: 'synthesis-condensed-silk',
|
|
name: '凝光纺丝',
|
|
kind: 'synthesis',
|
|
description: '用灵性残材与粉末纺出适合饰品锻造的凝光纱。',
|
|
resultLabel: '凝光纱',
|
|
currencyCost: 24,
|
|
requirements: [
|
|
buildAnyMaterialRequirement('material:any', '任意材料', 2),
|
|
{
|
|
id: 'tag:mana',
|
|
label: '含法力标签材料',
|
|
quantity: 1,
|
|
matches: item => (item.tags.includes('material') || item.category.includes('材料')) && item.tags.includes('mana'),
|
|
},
|
|
],
|
|
createResult: () => buildCondensedSilk(),
|
|
},
|
|
{
|
|
id: 'forge-duelist-blade',
|
|
name: '锻造 百炼追风剑',
|
|
kind: 'forge',
|
|
description: '围绕快剑、突进、追击构筑的轻灵主武器。',
|
|
resultLabel: '百炼追风剑',
|
|
currencyCost: 72,
|
|
requirements: [
|
|
buildNamedMaterialRequirement('精炼锭材', 2),
|
|
buildNamedMaterialRequirement('快剑精粹', 1),
|
|
buildNamedMaterialRequirement('突进精粹', 1),
|
|
],
|
|
createResult: () => buildEquipmentItem({
|
|
name: '百炼追风剑',
|
|
slot: 'weapon',
|
|
rarity: 'epic',
|
|
description: '为快剑与追身构筑准备的锻造兵刃,挥动时更容易连续压进对手空门。',
|
|
role: '快剑',
|
|
tags: ['快剑', '突进', '追击'],
|
|
setId: 'forge-set-duelist',
|
|
setName: '追风连锋',
|
|
pieceName: 'weapon',
|
|
synergy: ['快剑', '突进', '追击'],
|
|
statProfile: {
|
|
maxManaBonus: 10,
|
|
outgoingDamageBonus: 0.2,
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
id: 'forge-ward-armor',
|
|
name: '锻造 镇岳护甲',
|
|
kind: 'forge',
|
|
description: '面向前排承压的护甲,适合守御与护体构筑。',
|
|
resultLabel: '镇岳护甲',
|
|
currencyCost: 78,
|
|
requirements: [
|
|
buildNamedMaterialRequirement('精炼锭材', 2),
|
|
buildNamedMaterialRequirement('守御精粹', 1),
|
|
buildNamedMaterialRequirement('护体精粹', 1),
|
|
],
|
|
createResult: () => buildEquipmentItem({
|
|
name: '镇岳护甲',
|
|
slot: 'armor',
|
|
rarity: 'epic',
|
|
description: '厚重但稳定的护甲套件,适合顶住正面压力后再伺机反打。',
|
|
role: '守御',
|
|
tags: ['守御', '护体', '先锋'],
|
|
setId: 'forge-set-ward',
|
|
setName: '镇岳守阵',
|
|
pieceName: 'armor',
|
|
synergy: ['守御', '护体', '先锋'],
|
|
statProfile: {
|
|
maxHpBonus: 56,
|
|
maxManaBonus: 8,
|
|
outgoingDamageBonus: 0.08,
|
|
incomingDamageMultiplier: 0.84,
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
id: 'forge-thunder-relic',
|
|
name: '锻造 雷纹灵坠',
|
|
kind: 'forge',
|
|
description: '为法修、雷法、过载 build 提供资源与爆发补强。',
|
|
resultLabel: '雷纹灵坠',
|
|
currencyCost: 88,
|
|
requirements: [
|
|
buildNamedMaterialRequirement('凝光纱', 2),
|
|
buildNamedMaterialRequirement('法力精粹', 1),
|
|
buildNamedMaterialRequirement('雷法精粹', 1),
|
|
],
|
|
createResult: () => buildEquipmentItem({
|
|
name: '雷纹灵坠',
|
|
slot: 'relic',
|
|
rarity: 'epic',
|
|
description: '内封雷纹与灵引回路的饰品,能在短窗口内快速放大法术节奏。',
|
|
role: '法修',
|
|
tags: ['法修', '雷法', '过载'],
|
|
setId: 'forge-set-thunder',
|
|
setName: '雷纹御法',
|
|
pieceName: 'relic',
|
|
synergy: ['法修', '雷法', '过载'],
|
|
statProfile: {
|
|
maxHpBonus: 8,
|
|
maxManaBonus: 42,
|
|
outgoingDamageBonus: 0.14,
|
|
incomingDamageMultiplier: 0.92,
|
|
},
|
|
}),
|
|
},
|
|
];
|
|
|
|
function countMatchingItems(inventory: InventoryItem[], requirement: ForgeRequirement) {
|
|
return inventory
|
|
.filter(item => requirement.matches(item))
|
|
.reduce((sum, item) => sum + item.quantity, 0);
|
|
}
|
|
|
|
function consumeRequirement(inventory: InventoryItem[], requirement: ForgeRequirement) {
|
|
let remaining = requirement.quantity;
|
|
let nextInventory = [...inventory];
|
|
|
|
for (const item of inventory) {
|
|
if (remaining <= 0) break;
|
|
if (!requirement.matches(item)) continue;
|
|
|
|
const consumed = Math.min(item.quantity, remaining);
|
|
nextInventory = removeInventoryItem(nextInventory, item.id, consumed);
|
|
remaining -= consumed;
|
|
}
|
|
|
|
return remaining === 0 ? nextInventory : null;
|
|
}
|
|
|
|
function applyRequirementsIfPossible(inventory: InventoryItem[], requirements: ForgeRequirement[]) {
|
|
let nextInventory = [...inventory];
|
|
for (const requirement of requirements) {
|
|
const consumedInventory = consumeRequirement(nextInventory, requirement);
|
|
if (!consumedInventory) return null;
|
|
nextInventory = consumedInventory;
|
|
}
|
|
return nextInventory;
|
|
}
|
|
|
|
function buildDismantleBaseMaterials(item: InventoryItem, slot: EquipmentSlotId | null) {
|
|
const rarityScale: Record<ItemRarity, number> = {
|
|
common: 1,
|
|
uncommon: 2,
|
|
rare: 3,
|
|
epic: 4,
|
|
legendary: 5,
|
|
};
|
|
|
|
const amount = rarityScale[item.rarity];
|
|
if (slot === 'weapon') {
|
|
return [buildMaterialItem('武器残片', amount, ['工巧', '重击'], item.rarity === 'common' ? 'common' : 'uncommon')];
|
|
}
|
|
if (slot === 'armor') {
|
|
return [buildMaterialItem('甲片', amount, ['工巧', '守御'], item.rarity === 'common' ? 'common' : 'uncommon')];
|
|
}
|
|
if (slot === 'relic') {
|
|
return [buildMaterialItem('灵饰碎片', amount, ['工巧', '法力'], item.rarity === 'common' ? 'common' : 'uncommon')];
|
|
}
|
|
return [buildMaterialItem('零散材料', Math.max(1, Math.ceil(amount / 2)), ['工巧'], 'common')];
|
|
}
|
|
|
|
function buildDismantleEssences(item: InventoryItem) {
|
|
const buildTags = normalizeBuildTags([
|
|
...(item.buildProfile?.tags ?? []),
|
|
item.buildProfile?.role ?? '',
|
|
]).slice(0, item.rarity === 'legendary' ? 3 : 2);
|
|
|
|
return buildTags.map(tag => buildTagEssence(tag));
|
|
}
|
|
|
|
function enhanceStatProfile(statProfile: ItemStatProfile | null | undefined, slot: EquipmentSlotId | null) {
|
|
const nextProfile = { ...(statProfile ?? {}) };
|
|
nextProfile.maxHpBonus = (nextProfile.maxHpBonus ?? 0) + (slot === 'armor' ? 10 : 4);
|
|
nextProfile.maxManaBonus = (nextProfile.maxManaBonus ?? 0) + (slot === 'relic' ? 10 : 4);
|
|
nextProfile.outgoingDamageBonus = Number(((nextProfile.outgoingDamageBonus ?? 0) + 0.03).toFixed(3));
|
|
|
|
if (typeof nextProfile.incomingDamageMultiplier === 'number') {
|
|
nextProfile.incomingDamageMultiplier = Number(Math.max(0.72, nextProfile.incomingDamageMultiplier - 0.03).toFixed(3));
|
|
} else if (slot === 'armor' || slot === 'relic') {
|
|
nextProfile.incomingDamageMultiplier = slot === 'armor' ? 0.94 : 0.97;
|
|
}
|
|
|
|
return nextProfile;
|
|
}
|
|
|
|
function buildReforgedItem(item: InventoryItem) {
|
|
const slot = getEquipmentSlotFromItem(item);
|
|
if (!slot || !item.buildProfile) return null;
|
|
|
|
const currentTags = normalizeBuildTags(item.buildProfile.tags);
|
|
const primaryTag = currentTags[0];
|
|
const replacement = primaryTag
|
|
? getSimilarBuildTags(primaryTag, 0.6).find(tag => !currentTags.includes(tag)) ?? primaryTag
|
|
: null;
|
|
|
|
const nextTags = normalizeBuildTags([
|
|
...(replacement ? [replacement] : []),
|
|
...currentTags.slice(replacement && replacement !== primaryTag ? 1 : 0),
|
|
]).slice(0, 3);
|
|
|
|
return {
|
|
...item,
|
|
id: createItemId(`reforge:${item.name}`),
|
|
name: item.name.includes('重铸') ? item.name : `${item.name}·重铸`,
|
|
statProfile: enhanceStatProfile(item.statProfile, slot),
|
|
buildProfile: {
|
|
...item.buildProfile,
|
|
role: normalizeBuildRole(item.buildProfile.role),
|
|
tags: nextTags,
|
|
forgeRank: (item.buildProfile.forgeRank ?? 0) + 1,
|
|
synergy: nextTags,
|
|
},
|
|
} satisfies InventoryItem;
|
|
}
|
|
|
|
function getReforgeCost(slot: EquipmentSlotId | null) {
|
|
if (slot === 'relic') {
|
|
return {
|
|
requirements: [buildNamedMaterialRequirement('凝光纱', 1)],
|
|
currencyCost: 52,
|
|
};
|
|
}
|
|
|
|
return {
|
|
requirements: [buildNamedMaterialRequirement('精炼锭材', 1)],
|
|
currencyCost: 46,
|
|
};
|
|
}
|
|
|
|
export function getForgeRecipeViews(
|
|
inventory: InventoryItem[],
|
|
playerCurrency = 0,
|
|
worldType: WorldType | null = null,
|
|
) {
|
|
return FORGE_RECIPES.map(recipe => ({
|
|
id: recipe.id,
|
|
name: recipe.name,
|
|
kind: recipe.kind,
|
|
description: recipe.description,
|
|
resultLabel: recipe.resultLabel,
|
|
currencyCost: recipe.currencyCost,
|
|
currencyText: formatCurrency(recipe.currencyCost, worldType),
|
|
requirements: recipe.requirements.map(requirement => ({
|
|
id: requirement.id,
|
|
label: requirement.label,
|
|
quantity: requirement.quantity,
|
|
owned: countMatchingItems(inventory, requirement),
|
|
})),
|
|
canCraft:
|
|
playerCurrency >= recipe.currencyCost &&
|
|
recipe.requirements.every(requirement => countMatchingItems(inventory, requirement) >= requirement.quantity),
|
|
})) satisfies ForgeRecipeView[];
|
|
}
|
|
|
|
export function executeForgeRecipe(
|
|
inventory: InventoryItem[],
|
|
recipeId: string,
|
|
worldType: WorldType | null,
|
|
playerCurrency: number,
|
|
): ForgeExecutionResult | null {
|
|
const recipe = FORGE_RECIPES.find(candidate => candidate.id === recipeId);
|
|
if (!recipe || playerCurrency < recipe.currencyCost) return null;
|
|
|
|
const consumedInventory = applyRequirementsIfPossible(inventory, recipe.requirements);
|
|
if (!consumedInventory) return null;
|
|
|
|
const createdItem = recipe.createResult(worldType);
|
|
return {
|
|
inventory: addInventoryItems(consumedInventory, [createdItem]),
|
|
currency: playerCurrency - recipe.currencyCost,
|
|
createdItem,
|
|
};
|
|
}
|
|
|
|
export function executeDismantleItem(inventory: InventoryItem[], itemId: string): DismantleExecutionResult | null {
|
|
const targetItem = inventory.find(item => item.id === itemId);
|
|
if (!targetItem || targetItem.quantity <= 0) return null;
|
|
|
|
const slot = getEquipmentSlotFromItem(targetItem);
|
|
if (!slot && !targetItem.buildProfile) return null;
|
|
|
|
const outputs = [
|
|
...buildDismantleBaseMaterials(targetItem, slot),
|
|
...buildDismantleEssences(targetItem),
|
|
];
|
|
|
|
return {
|
|
inventory: addInventoryItems(removeInventoryItem(inventory, itemId, 1), outputs),
|
|
outputs,
|
|
};
|
|
}
|
|
|
|
export function executeReforgeItem(
|
|
inventory: InventoryItem[],
|
|
itemId: string,
|
|
playerCurrency: number,
|
|
): ReforgeExecutionResult | null {
|
|
const targetItem = inventory.find(item => item.id === itemId);
|
|
if (!targetItem || targetItem.quantity <= 0) return null;
|
|
|
|
const slot = getEquipmentSlotFromItem(targetItem);
|
|
const reforgedItem = buildReforgedItem(targetItem);
|
|
const reforgeCost = getReforgeCost(slot);
|
|
if (!reforgedItem || playerCurrency < reforgeCost.currencyCost) return null;
|
|
|
|
const consumedInventory = applyRequirementsIfPossible(
|
|
removeInventoryItem(inventory, itemId, 1),
|
|
reforgeCost.requirements,
|
|
);
|
|
if (!consumedInventory) return null;
|
|
|
|
return {
|
|
inventory: addInventoryItems(consumedInventory, [reforgedItem]),
|
|
reforgedItem,
|
|
currencyCost: reforgeCost.currencyCost,
|
|
};
|
|
}
|
|
|
|
export function getReforgeCostView(item: InventoryItem, worldType: WorldType | null) {
|
|
const slot = getEquipmentSlotFromItem(item);
|
|
const cost = getReforgeCost(slot);
|
|
return {
|
|
currencyCost: cost.currencyCost,
|
|
currencyText: formatCurrency(cost.currencyCost, worldType),
|
|
requirements: cost.requirements.map(requirement => ({
|
|
id: requirement.id,
|
|
label: requirement.label,
|
|
quantity: requirement.quantity,
|
|
})),
|
|
};
|
|
}
|
|
|
|
export function buildForgeSuccessText(action: 'craft' | 'dismantle' | 'reforge', params: {
|
|
sourceItemName?: string;
|
|
recipeName?: string;
|
|
createdItemName?: string;
|
|
outputNames?: string[];
|
|
currencyText?: string;
|
|
}) {
|
|
if (action === 'craft') {
|
|
return `你在工坊中完成了${params.recipeName},获得了${params.createdItemName}${params.currencyText ? `,并支付了${params.currencyText}` : ''}。`;
|
|
}
|
|
|
|
if (action === 'reforge') {
|
|
return `你消耗材料重新淬炼了${params.sourceItemName},最终得到${params.createdItemName}${params.currencyText ? `,并支付了${params.currencyText}` : ''}。`;
|
|
}
|
|
|
|
return `你拆解了${params.sourceItemName},回收出${(params.outputNames ?? []).join('、')}。`;
|
|
}
|