542
src/data/buildDamage.ts
Normal file
542
src/data/buildDamage.ts
Normal file
@@ -0,0 +1,542 @@
|
||||
import type {
|
||||
AttributeVector,
|
||||
Character,
|
||||
CustomWorldProfile,
|
||||
EquipmentLoadout,
|
||||
GameState,
|
||||
InventoryItem,
|
||||
RoleAttributeProfile,
|
||||
SceneMonster,
|
||||
TimedBuildBuff,
|
||||
WorldAttributeSchema,
|
||||
} from '../types';
|
||||
import {
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
getNormalizedAttributeWeights,
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
} from './attributeResolver';
|
||||
import { normalizeAttributeVector } from './attributeValidation';
|
||||
import {getBuildTagAttributeSimilarityProfile} from './buildTagAttributeAffinity';
|
||||
import {
|
||||
buildSetBuildTagLabel,
|
||||
getBuildTagDefinition,
|
||||
getCharacterCombatTags,
|
||||
getSceneMonsterCombatTags,
|
||||
getTimedBuildBuffTags,
|
||||
normalizeBuildRole,
|
||||
normalizeBuildTags,
|
||||
} from './buildTags';
|
||||
import { getRuntimeCustomWorldProfile } from './customWorldRuntime';
|
||||
import { getEquipmentBonuses } from './equipmentEffects';
|
||||
|
||||
const MAX_ACTIVE_BUILD_TAGS = 8;
|
||||
export const BASE_TAG_BONUS = 0.12;
|
||||
export const MAX_BUILD_BONUS = 0.6;
|
||||
|
||||
export type BuildTagSource =
|
||||
| 'buff'
|
||||
| 'character'
|
||||
| 'weapon'
|
||||
| 'armor'
|
||||
| 'relic'
|
||||
| 'set'
|
||||
| 'monster';
|
||||
|
||||
type ResolvedBuildTag = {
|
||||
label: string;
|
||||
source: BuildTagSource;
|
||||
priority: number;
|
||||
relatedTags?: string[];
|
||||
};
|
||||
|
||||
export type BuildContributionRow = {
|
||||
label: string;
|
||||
source: BuildTagSource;
|
||||
fitScore: number;
|
||||
sourceCoefficient: number;
|
||||
bonusDelta: number;
|
||||
attributeSimilarities: AttributeVector;
|
||||
attributeWeights: AttributeVector;
|
||||
attributeContributions: AttributeVector;
|
||||
attributeModifierDeltas: AttributeVector;
|
||||
};
|
||||
|
||||
export type BuildDamageBreakdown = {
|
||||
tags: string[];
|
||||
baseTagCount: number;
|
||||
buildDamageBonus: number;
|
||||
buildDamageMultiplier: number;
|
||||
rows: BuildContributionRow[];
|
||||
};
|
||||
|
||||
export type BuildContributionAttributeRow = {
|
||||
slotId: string;
|
||||
label: string;
|
||||
definition: string;
|
||||
similarity: number;
|
||||
weight: number;
|
||||
value: number;
|
||||
modifierDelta: number;
|
||||
percent: number;
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function roundNumber(value: number, digits = 4) {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
function getSourceCoefficient(source: BuildTagSource) {
|
||||
switch (source) {
|
||||
case 'buff':
|
||||
return 1;
|
||||
case 'character':
|
||||
return 0.9;
|
||||
case 'weapon':
|
||||
return 0.85;
|
||||
case 'armor':
|
||||
return 0.75;
|
||||
case 'relic':
|
||||
return 0.8;
|
||||
case 'set':
|
||||
return 0.9;
|
||||
case 'monster':
|
||||
return 0.88;
|
||||
default:
|
||||
return 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
function pushTag(
|
||||
target: ResolvedBuildTag[],
|
||||
label: string | null | undefined,
|
||||
source: BuildTagSource,
|
||||
priority: number,
|
||||
relatedTags?: string[],
|
||||
) {
|
||||
const normalizedLabel = label?.trim();
|
||||
if (!normalizedLabel) return;
|
||||
target.push({
|
||||
label: normalizedLabel,
|
||||
source,
|
||||
priority,
|
||||
relatedTags: relatedTags && relatedTags.length > 0 ? [...relatedTags] : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function getItemBuildTags(item: InventoryItem | null) {
|
||||
if (!item?.buildProfile) return [];
|
||||
return normalizeBuildTags([
|
||||
normalizeBuildRole(item.buildProfile.role),
|
||||
...(item.buildProfile.tags ?? []),
|
||||
]);
|
||||
}
|
||||
|
||||
function getLoadoutBuildTags(loadout: EquipmentLoadout | null | undefined) {
|
||||
if (!loadout) return [];
|
||||
|
||||
const tags: ResolvedBuildTag[] = [];
|
||||
const setPieces = new Map<string, { count: number; tags: string[]; setName: string }>();
|
||||
|
||||
([
|
||||
['weapon', loadout.weapon],
|
||||
['armor', loadout.armor],
|
||||
['relic', loadout.relic],
|
||||
] as const).forEach(([slotId, item]) => {
|
||||
if (!item) return;
|
||||
const itemTags = getItemBuildTags(item);
|
||||
itemTags.forEach(tag => pushTag(tags, tag, slotId, 60));
|
||||
|
||||
const setId = item.buildProfile?.setId?.trim();
|
||||
const setName = item.buildProfile?.setName?.trim();
|
||||
if (!setId || !setName) return;
|
||||
|
||||
const entry = setPieces.get(setId) ?? {
|
||||
count: 0,
|
||||
tags: [],
|
||||
setName,
|
||||
};
|
||||
entry.count += 1;
|
||||
entry.tags = [...new Set([...entry.tags, ...itemTags])];
|
||||
setPieces.set(setId, entry);
|
||||
});
|
||||
|
||||
setPieces.forEach(entry => {
|
||||
if (entry.count < 2) return;
|
||||
pushTag(
|
||||
tags,
|
||||
buildSetBuildTagLabel(entry.setName, entry.count),
|
||||
'set',
|
||||
entry.count >= 3 ? 70 : 65,
|
||||
entry.tags,
|
||||
);
|
||||
});
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
function dedupeAndLimitTags(tags: ResolvedBuildTag[]) {
|
||||
const bestByLabel = new Map<string, ResolvedBuildTag>();
|
||||
|
||||
tags.forEach(tag => {
|
||||
const existing = bestByLabel.get(tag.label);
|
||||
if (!existing || tag.priority > existing.priority) {
|
||||
bestByLabel.set(tag.label, tag);
|
||||
}
|
||||
});
|
||||
|
||||
return [...bestByLabel.values()]
|
||||
.sort((left, right) => right.priority - left.priority || left.label.localeCompare(right.label, 'zh-CN'))
|
||||
.slice(0, MAX_ACTIVE_BUILD_TAGS);
|
||||
}
|
||||
|
||||
function averageAttributeVectors(vectors: AttributeVector[], slotIds: readonly string[]) {
|
||||
if (vectors.length === 0) {
|
||||
const evenShare = 1 / Math.max(slotIds.length, 1);
|
||||
return Object.fromEntries(slotIds.map(slotId => [slotId, evenShare]));
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
slotIds.map(slotId => [
|
||||
slotId,
|
||||
roundNumber(vectors.reduce((sum, vector) => sum + (vector[slotId] ?? 0), 0) / vectors.length, 4),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTagAffinity(tag: ResolvedBuildTag, schema: WorldAttributeSchema) {
|
||||
const definition = getBuildTagDefinition(tag.label);
|
||||
if (definition) {
|
||||
return getBuildTagAttributeSimilarityProfile(definition.id, schema);
|
||||
}
|
||||
|
||||
const relatedAffinities = (tag.relatedTags ?? []).flatMap(relatedTag => {
|
||||
const relatedDefinition = getBuildTagDefinition(relatedTag);
|
||||
if (!relatedDefinition) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [getBuildTagAttributeSimilarityProfile(relatedDefinition.id, schema).rawSimilarity];
|
||||
});
|
||||
|
||||
const rawSimilarity = averageAttributeVectors(relatedAffinities, schema.slots.map(slot => slot.slotId));
|
||||
|
||||
return {
|
||||
rawSimilarity,
|
||||
normalizedSimilarity: normalizeAttributeVector(rawSimilarity, schema.slots.map(slot => slot.slotId)),
|
||||
};
|
||||
}
|
||||
|
||||
function buildAttributeContributions(
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
tagAffinity: AttributeVector,
|
||||
schema: WorldAttributeSchema,
|
||||
sourceCoefficient: number,
|
||||
) {
|
||||
const slotIds = schema.slots.map(slot => slot.slotId);
|
||||
const attributeWeights = getNormalizedAttributeWeights(profile, schema);
|
||||
const normalizedAffinity = normalizeAttributeVector(tagAffinity ?? {}, slotIds);
|
||||
const attributeContributions = Object.fromEntries(
|
||||
slotIds.map(slotId => [
|
||||
slotId,
|
||||
roundNumber((attributeWeights[slotId] ?? 0) * (normalizedAffinity[slotId] ?? 0), 4),
|
||||
]),
|
||||
);
|
||||
const attributeModifierDeltas = Object.fromEntries(
|
||||
slotIds.map(slotId => [
|
||||
slotId,
|
||||
roundNumber(BASE_TAG_BONUS * sourceCoefficient * (attributeContributions[slotId] ?? 0), 4),
|
||||
]),
|
||||
);
|
||||
const fitScore = roundNumber(
|
||||
slotIds.reduce((sum, slotId) => sum + (attributeContributions[slotId] ?? 0), 0),
|
||||
4,
|
||||
);
|
||||
|
||||
return {
|
||||
fitScore,
|
||||
attributeWeights,
|
||||
normalizedAffinity,
|
||||
attributeContributions,
|
||||
attributeModifierDeltas,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBreakdownFromTags(
|
||||
tags: ResolvedBuildTag[],
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
schema: WorldAttributeSchema,
|
||||
): BuildDamageBreakdown {
|
||||
if (tags.length === 0) {
|
||||
return {
|
||||
tags: [],
|
||||
baseTagCount: 0,
|
||||
buildDamageBonus: 0,
|
||||
buildDamageMultiplier: 1,
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
|
||||
const rows = tags.map(currentTag => {
|
||||
const tagAffinity = resolveTagAffinity(currentTag, schema);
|
||||
const sourceCoefficient = getSourceCoefficient(currentTag.source);
|
||||
const {
|
||||
fitScore,
|
||||
attributeWeights,
|
||||
normalizedAffinity,
|
||||
attributeContributions,
|
||||
attributeModifierDeltas,
|
||||
} = buildAttributeContributions(profile, tagAffinity.normalizedSimilarity, schema, sourceCoefficient);
|
||||
const bonusDelta = roundNumber(
|
||||
Object.values(attributeModifierDeltas).reduce((sum, value) => sum + value, 0),
|
||||
4,
|
||||
);
|
||||
|
||||
return {
|
||||
label: currentTag.label,
|
||||
source: currentTag.source,
|
||||
fitScore,
|
||||
sourceCoefficient,
|
||||
bonusDelta,
|
||||
attributeSimilarities: normalizedAffinity,
|
||||
attributeWeights,
|
||||
attributeContributions,
|
||||
attributeModifierDeltas,
|
||||
} satisfies BuildContributionRow;
|
||||
});
|
||||
|
||||
const buildDamageBonus = roundNumber(
|
||||
clamp(rows.reduce((sum, row) => sum + row.bonusDelta, 0), 0, MAX_BUILD_BONUS),
|
||||
4,
|
||||
);
|
||||
const buildDamageMultiplier = roundNumber(1 + buildDamageBonus, 4);
|
||||
|
||||
return {
|
||||
tags: tags.map(tag => tag.label),
|
||||
baseTagCount: tags.length,
|
||||
buildDamageBonus,
|
||||
buildDamageMultiplier,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
export function getBuildSourceLabel(source: BuildTagSource) {
|
||||
switch (source) {
|
||||
case 'buff':
|
||||
return '\u589e\u76ca';
|
||||
case 'character':
|
||||
return '\u89d2\u8272\u56fa\u6709';
|
||||
case 'weapon':
|
||||
return '\u6b66\u5668';
|
||||
case 'armor':
|
||||
return '\u62a4\u7532';
|
||||
case 'relic':
|
||||
return '\u9970\u54c1';
|
||||
case 'set':
|
||||
return '\u5957\u88c5';
|
||||
case 'monster':
|
||||
return '\u602a\u7269';
|
||||
default:
|
||||
return '\u6807\u7b7e';
|
||||
}
|
||||
}
|
||||
|
||||
export function getBuildContributionAttributeRows(
|
||||
row: Pick<
|
||||
BuildContributionRow,
|
||||
'attributeContributions' | 'attributeModifierDeltas' | 'attributeSimilarities' | 'attributeWeights'
|
||||
>,
|
||||
schema: WorldAttributeSchema,
|
||||
minimumValue = 0.0001,
|
||||
) {
|
||||
const totalModifierDelta = Object.values(row.attributeModifierDeltas ?? {}).reduce((sum, value) => sum + value, 0);
|
||||
|
||||
return schema.slots
|
||||
.map(slot => {
|
||||
const value = roundNumber(row.attributeContributions[slot.slotId] ?? 0, 4);
|
||||
const modifierDelta = roundNumber(row.attributeModifierDeltas?.[slot.slotId] ?? 0, 4);
|
||||
const percent = totalModifierDelta > 0 ? roundNumber(modifierDelta / totalModifierDelta, 4) : 0;
|
||||
|
||||
return {
|
||||
slotId: slot.slotId,
|
||||
label: slot.name,
|
||||
definition: slot.definition,
|
||||
similarity: roundNumber(row.attributeSimilarities?.[slot.slotId] ?? 0, 4),
|
||||
weight: roundNumber(row.attributeWeights?.[slot.slotId] ?? 0, 4),
|
||||
value,
|
||||
modifierDelta,
|
||||
percent,
|
||||
} satisfies BuildContributionAttributeRow;
|
||||
})
|
||||
.filter(entry => entry.value > minimumValue || entry.modifierDelta > minimumValue)
|
||||
.sort((left, right) => right.value - left.value || left.label.localeCompare(right.label, 'zh-CN'));
|
||||
}
|
||||
|
||||
export function describeBuildContribution(
|
||||
row: Pick<
|
||||
BuildContributionRow,
|
||||
'attributeContributions' | 'attributeModifierDeltas' | 'attributeSimilarities' | 'attributeWeights'
|
||||
>,
|
||||
schema: WorldAttributeSchema,
|
||||
limit = 2,
|
||||
) {
|
||||
const topRows = getBuildContributionAttributeRows(row, schema).slice(0, limit);
|
||||
if (topRows.length === 0) {
|
||||
return '\u5f53\u524d\u5c5e\u6027\u9002\u914d\u8f83\u5f31';
|
||||
}
|
||||
|
||||
if (topRows.length === 1) {
|
||||
return `${topRows[0]?.label ?? '\u4e3b\u5c5e\u6027'}\u4e3b\u5bfc`;
|
||||
}
|
||||
|
||||
return `${topRows[0]?.label ?? '\u4e3b\u5c5e\u6027'}\u4e3b\u5bfc\uff0c${topRows[1]?.label ?? '\u8f85\u52a9\u5c5e\u6027'}\u8f85\u52a9`;
|
||||
}
|
||||
|
||||
function getPlayerBuffs(gameState: GameState) {
|
||||
return (gameState.activeBuildBuffs ?? []).filter(buff => (buff.durationTurns ?? 0) > 0);
|
||||
}
|
||||
|
||||
export function tickBuildBuffs(buffs: TimedBuildBuff[] | null | undefined) {
|
||||
return (buffs ?? [])
|
||||
.map(buff => ({
|
||||
...buff,
|
||||
durationTurns: Math.max(0, (buff.durationTurns ?? 0) - 1),
|
||||
}))
|
||||
.filter(buff => buff.durationTurns > 0);
|
||||
}
|
||||
|
||||
export function appendBuildBuffs(
|
||||
baseBuffs: TimedBuildBuff[] | null | undefined,
|
||||
additions: TimedBuildBuff[] | null | undefined,
|
||||
) {
|
||||
const merged = new Map<string, TimedBuildBuff>();
|
||||
|
||||
[...(baseBuffs ?? []), ...(additions ?? [])].forEach(buff => {
|
||||
const existing = merged.get(buff.id);
|
||||
if (!existing || (buff.durationTurns ?? 0) >= (existing.durationTurns ?? 0)) {
|
||||
merged.set(buff.id, {
|
||||
...buff,
|
||||
tags: normalizeBuildTags(buff.tags),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return [...merged.values()].filter(buff => buff.tags.length > 0 && buff.durationTurns > 0);
|
||||
}
|
||||
|
||||
export function getPlayerBuildDamageBreakdown(gameState: GameState, character: Character) {
|
||||
const tags: ResolvedBuildTag[] = [];
|
||||
getTimedBuildBuffTags(getPlayerBuffs(gameState)).forEach(tag => pushTag(tags, tag, 'buff', 100));
|
||||
getCharacterCombatTags(character).forEach(tag => pushTag(tags, tag, 'character', 90));
|
||||
getLoadoutBuildTags(gameState.playerEquipment).forEach(tag => tags.push(tag));
|
||||
|
||||
return buildBreakdownFromTags(
|
||||
dedupeAndLimitTags(tags),
|
||||
resolveCharacterAttributeProfile(character, gameState.worldType, gameState.customWorldProfile),
|
||||
resolveAttributeSchema(gameState.worldType, gameState.customWorldProfile),
|
||||
);
|
||||
}
|
||||
|
||||
export function getCompanionBuildDamageBreakdown(
|
||||
character: Character,
|
||||
worldType: WorldType | null = null,
|
||||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||||
) {
|
||||
const tags: ResolvedBuildTag[] = [];
|
||||
getCharacterCombatTags(character).forEach(tag => pushTag(tags, tag, 'character', 90));
|
||||
const resolvedWorldType = worldType ?? (customWorldProfile ? WorldType.CUSTOM : WorldType.WUXIA);
|
||||
|
||||
return buildBreakdownFromTags(
|
||||
dedupeAndLimitTags(tags),
|
||||
resolveCharacterAttributeProfile(character, resolvedWorldType, customWorldProfile),
|
||||
resolveAttributeSchema(resolvedWorldType, customWorldProfile),
|
||||
);
|
||||
}
|
||||
|
||||
export function getMonsterBuildDamageBreakdown(
|
||||
monster: SceneMonster,
|
||||
worldType: WorldType | null = null,
|
||||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||||
) {
|
||||
const tags: ResolvedBuildTag[] = [];
|
||||
getSceneMonsterCombatTags(monster).forEach(tag => pushTag(tags, tag, 'monster', 90));
|
||||
const resolvedWorldType = worldType
|
||||
?? (monster.attributeProfile?.schemaId?.includes('xianxia') ? WorldType.XIANXIA : customWorldProfile ? WorldType.CUSTOM : WorldType.WUXIA);
|
||||
|
||||
return buildBreakdownFromTags(
|
||||
dedupeAndLimitTags(tags),
|
||||
monster.attributeProfile ?? null,
|
||||
resolveAttributeSchema(resolvedWorldType, customWorldProfile),
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateOutgoingDamage(
|
||||
baseDamage: number,
|
||||
options: {
|
||||
functionMultiplier?: number;
|
||||
equipmentMultiplier?: number;
|
||||
buildMultiplier?: number;
|
||||
} = {},
|
||||
) {
|
||||
return Math.max(
|
||||
1,
|
||||
Math.round(
|
||||
baseDamage
|
||||
* (options.functionMultiplier ?? 1)
|
||||
* (options.equipmentMultiplier ?? 1)
|
||||
* (options.buildMultiplier ?? 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePlayerOutgoingDamage(
|
||||
gameState: GameState,
|
||||
character: Character,
|
||||
baseDamage: number,
|
||||
functionMultiplier = 1,
|
||||
) {
|
||||
const buildBreakdown = getPlayerBuildDamageBreakdown(gameState, character);
|
||||
const equipmentBonuses = getEquipmentBonuses(gameState.playerEquipment);
|
||||
|
||||
return calculateOutgoingDamage(baseDamage, {
|
||||
functionMultiplier,
|
||||
equipmentMultiplier: equipmentBonuses.outgoingDamageMultiplier,
|
||||
buildMultiplier: buildBreakdown.buildDamageMultiplier,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveCompanionOutgoingDamage(
|
||||
character: Character,
|
||||
baseDamage: number,
|
||||
functionMultiplier = 1,
|
||||
worldType: WorldType | null = null,
|
||||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||||
) {
|
||||
const buildBreakdown = getCompanionBuildDamageBreakdown(character, worldType, customWorldProfile);
|
||||
|
||||
return calculateOutgoingDamage(baseDamage, {
|
||||
functionMultiplier,
|
||||
buildMultiplier: buildBreakdown.buildDamageMultiplier,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveMonsterOutgoingDamage(
|
||||
monster: SceneMonster,
|
||||
baseDamage: number,
|
||||
functionMultiplier = 1,
|
||||
worldType: WorldType | null = null,
|
||||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||||
) {
|
||||
const buildBreakdown = getMonsterBuildDamageBreakdown(monster, worldType, customWorldProfile);
|
||||
|
||||
return calculateOutgoingDamage(baseDamage, {
|
||||
functionMultiplier,
|
||||
buildMultiplier: buildBreakdown.buildDamageMultiplier,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user