212 lines
5.7 KiB
TypeScript
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;
|
|
}
|