Files
Genarrative/server-node/src/modules/runtime/runtimeBuildModule.ts
2026-04-10 15:37:02 +08:00

212 lines
5.7 KiB
TypeScript

import {
getEquipmentBonuses,
type RuntimeEquipmentLoadout,
} from './runtimeEquipmentModule.js';
type RuntimeCharacterLike = {
attributes: {
strength: number;
agility: number;
intelligence: number;
spirit: number;
};
};
type RuntimeBuildBuff = {
id: string;
name: string;
tags: string[];
durationTurns: number;
};
type RuntimeInventoryItemLike = {
buildProfile?: {
role: string;
tags: string[];
synergy: string[];
forgeRank: number;
};
};
type RuntimeGameStateLike<TItem extends RuntimeInventoryItemLike = RuntimeInventoryItemLike> = {
playerEquipment: RuntimeEquipmentLoadout<TItem>;
activeBuildBuffs?: RuntimeBuildBuff[];
playerCharacter?: RuntimeCharacterLike | null;
};
export type BuildContributionRow = {
label: string;
source: 'buff' | 'weapon' | 'armor' | 'relic' | 'character';
fitScore: number;
sourceCoefficient: number;
bonusDelta: number;
attributeSimilarities: Record<string, number>;
attributeWeights: Record<string, number>;
attributeContributions: Record<string, number>;
attributeModifierDeltas: Record<string, number>;
};
export type BuildDamageBreakdown = {
tags: string[];
baseTagCount: number;
buildDamageBonus: number;
buildDamageMultiplier: number;
rows: BuildContributionRow[];
};
export type OutgoingDamageResult = {
damage: number;
isCritical: boolean;
critChance: number;
critDamageMultiplier: number;
attackPowerMultiplier: number;
};
function roundNumber(value: number, digits = 4) {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function hashSeed(seed: string) {
let hash = 0;
for (let index = 0; index < seed.length; index += 1) {
hash = (hash * 31 + seed.charCodeAt(index)) >>> 0;
}
return hash;
}
export function appendBuildBuffs<TBuff extends RuntimeBuildBuff>(
baseBuffs: TBuff[] | null | undefined,
additions: TBuff[] | null | undefined,
) {
const merged = new Map<string, TBuff>();
[...(baseBuffs ?? []), ...(additions ?? [])].forEach((buff) => {
const existing = merged.get(buff.id);
if (!existing || (buff.durationTurns ?? 0) >= (existing.durationTurns ?? 0)) {
merged.set(buff.id, {
...buff,
tags: [...new Set(buff.tags.map((tag) => tag.trim()).filter(Boolean))],
});
}
});
return [...merged.values()].filter(
(buff) => buff.tags.length > 0 && buff.durationTurns > 0,
);
}
function collectBuildTags<TItem extends RuntimeInventoryItemLike>(
state: RuntimeGameStateLike<TItem>,
character: RuntimeCharacterLike,
) {
const tags = new Set<string>();
state.activeBuildBuffs
?.filter((buff) => (buff.durationTurns ?? 0) > 0)
.forEach((buff) => buff.tags.forEach((tag) => tags.add(tag)));
(['weapon', 'armor', 'relic'] as const).forEach((slot) => {
const item = state.playerEquipment[slot];
item?.buildProfile?.tags?.forEach((tag) => tags.add(tag));
if (item?.buildProfile?.role) {
tags.add(item.buildProfile.role);
}
});
if (character.attributes.agility >= 10) tags.add('快剑');
if (character.attributes.strength >= 10) tags.add('重击');
if (character.attributes.spirit >= 10) tags.add('续战');
if (character.attributes.intelligence >= 8) tags.add('法力');
return [...tags].filter(Boolean).slice(0, 8);
}
export function getPlayerBuildDamageBreakdown<
TState extends RuntimeGameStateLike<TItem>,
TItem extends RuntimeInventoryItemLike,
>(state: TState, character: RuntimeCharacterLike) {
const tags = collectBuildTags(state, character);
const rows = tags.map((tag, index) => {
const bonusDelta = roundNumber(0.03 + Math.min(index, 3) * 0.01, 4);
return {
label: tag,
source: index === 0 ? 'buff' : 'weapon',
fitScore: roundNumber(0.6 + Math.max(0, 3 - index) * 0.08, 4),
sourceCoefficient: 1,
bonusDelta,
attributeSimilarities: {},
attributeWeights: {},
attributeContributions: {},
attributeModifierDeltas: {},
} satisfies BuildContributionRow;
});
const buildDamageBonus = roundNumber(
clamp(rows.reduce((sum, row) => sum + row.bonusDelta, 0), 0, 0.6),
4,
);
return {
tags,
baseTagCount: tags.length,
buildDamageBonus,
buildDamageMultiplier: roundNumber(1 + buildDamageBonus, 4),
rows,
} satisfies BuildDamageBreakdown;
}
export function resolvePlayerOutgoingDamageResult<
TState extends RuntimeGameStateLike<TItem>,
TItem extends RuntimeInventoryItemLike,
>(
state: TState,
character: RuntimeCharacterLike,
baseDamage: number,
functionMultiplier = 1,
critRollSeed?: string,
) {
const buildBreakdown = getPlayerBuildDamageBreakdown(state, character);
const equipmentBonuses = getEquipmentBonuses(state.playerEquipment);
const attackPowerMultiplier = roundNumber(
1 +
(character.attributes.strength * 0.01 +
character.attributes.agility * 0.006 +
character.attributes.spirit * 0.004),
4,
);
const critChance = roundNumber(
clamp(0.08 + character.attributes.agility * 0.01, 0.08, 0.45),
4,
);
const critDamageMultiplier = roundNumber(
1.45 + character.attributes.strength * 0.01,
4,
);
const roll = critRollSeed ? (hashSeed(critRollSeed) % 1000) / 1000 : 1;
const isCritical = roll < critChance;
const damage = Math.max(
1,
Math.round(
baseDamage *
functionMultiplier *
equipmentBonuses.outgoingDamageMultiplier *
buildBreakdown.buildDamageMultiplier *
attackPowerMultiplier *
(isCritical ? critDamageMultiplier : 1),
),
);
return {
damage,
isCritical,
critChance,
critDamageMultiplier,
attackPowerMultiplier,
} satisfies OutgoingDamageResult;
}