Files
Genarrative/src/data/forgeSystem.ts
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +08:00

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('、')}`;
}