916 lines
24 KiB
TypeScript
916 lines
24 KiB
TypeScript
import type {
|
||
AttributeVector,
|
||
Character,
|
||
CustomWorldProfile,
|
||
EquipmentLoadout,
|
||
GameState,
|
||
InventoryItem,
|
||
SceneHostileNpc,
|
||
TimedBuildBuff,
|
||
WorldAttributeSchema,
|
||
} from '../types';
|
||
import { WorldType } from '../types';
|
||
import {
|
||
resolveCriticalStrike,
|
||
resolveRoleCombatStats,
|
||
} from './attributeCombat';
|
||
import {
|
||
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 BuildContributionQuality =
|
||
| 'common'
|
||
| 'fine'
|
||
| 'rare'
|
||
| 'epic'
|
||
| 'legendary';
|
||
export type BuildContributionResourceLabels = {
|
||
maxHp?: string | null;
|
||
maxMp?: string | null;
|
||
};
|
||
|
||
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;
|
||
};
|
||
|
||
export type OutgoingDamageResult = {
|
||
damage: number;
|
||
isCritical: boolean;
|
||
critChance: number;
|
||
critDamageMultiplier: number;
|
||
attackPowerMultiplier: number;
|
||
};
|
||
|
||
type BuildContributionTarget = {
|
||
slotId: string;
|
||
label: string;
|
||
definition: string;
|
||
};
|
||
|
||
type ResolvedTagAffinity = {
|
||
rawSimilarity: AttributeVector;
|
||
};
|
||
|
||
const BUILD_CONTRIBUTION_QUALITY_LEVELS: Array<{
|
||
tier: BuildContributionQuality;
|
||
label: string;
|
||
minimumBonus: number;
|
||
colorRatio: number;
|
||
}> = [
|
||
{ tier: 'legendary', label: '传说', minimumBonus: 0.06, colorRatio: 1 },
|
||
{ tier: 'epic', label: '史诗', minimumBonus: 0.045, colorRatio: 0.78 },
|
||
{ tier: 'rare', label: '稀有', minimumBonus: 0.03, colorRatio: 0.56 },
|
||
{ tier: 'fine', label: '优秀', minimumBonus: 0.018, colorRatio: 0.32 },
|
||
{ tier: 'common', label: '普通', minimumBonus: 0, colorRatio: 0.08 },
|
||
];
|
||
|
||
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 {
|
||
rawSimilarity: getBuildTagAttributeSimilarityProfile(
|
||
definition.id,
|
||
schema,
|
||
).rawSimilarity,
|
||
} satisfies ResolvedTagAffinity;
|
||
}
|
||
|
||
const relatedSchemaAffinities = (tag.relatedTags ?? []).flatMap(
|
||
(relatedTag) => {
|
||
const relatedDefinition = getBuildTagDefinition(relatedTag);
|
||
if (!relatedDefinition) {
|
||
return [];
|
||
}
|
||
|
||
return [
|
||
getBuildTagAttributeSimilarityProfile(relatedDefinition.id, schema)
|
||
.rawSimilarity,
|
||
];
|
||
},
|
||
);
|
||
const rawSimilarity = averageAttributeVectors(
|
||
relatedSchemaAffinities,
|
||
schema.slots.map((slot) => slot.slotId),
|
||
);
|
||
|
||
return {
|
||
rawSimilarity,
|
||
} satisfies ResolvedTagAffinity;
|
||
}
|
||
|
||
function resolveContributionTargets(
|
||
schema: WorldAttributeSchema,
|
||
_resourceLabels?: BuildContributionResourceLabels | null,
|
||
) {
|
||
return schema.slots.map((slot) => ({
|
||
slotId: slot.slotId,
|
||
label: slot.name,
|
||
definition: slot.definition,
|
||
})) satisfies BuildContributionTarget[];
|
||
}
|
||
|
||
function buildAttributeContributions(
|
||
tagAffinity: ResolvedTagAffinity,
|
||
schema: WorldAttributeSchema,
|
||
sourceCoefficient: number,
|
||
resourceLabels?: BuildContributionResourceLabels | null,
|
||
) {
|
||
const targets = resolveContributionTargets(schema, resourceLabels);
|
||
const slotIds = targets.map((target) => target.slotId);
|
||
const rawSimilarity = Object.fromEntries(
|
||
targets.map((target) => {
|
||
return [
|
||
target.slotId,
|
||
roundNumber(tagAffinity.rawSimilarity[target.slotId] ?? 0, 4),
|
||
];
|
||
}),
|
||
);
|
||
const normalizedAffinity = normalizeAttributeVector(rawSimilarity, slotIds);
|
||
const effectiveSlotIds = new Set(
|
||
[...slotIds]
|
||
.sort((left, right) => {
|
||
const difference =
|
||
(normalizedAffinity[right] ?? 0) - (normalizedAffinity[left] ?? 0);
|
||
if (Math.abs(difference) > 0.0001) {
|
||
return difference;
|
||
}
|
||
|
||
return left.localeCompare(right, 'zh-CN');
|
||
})
|
||
.slice(0, 2),
|
||
);
|
||
const attributeContributions = Object.fromEntries(
|
||
slotIds.map((slotId) => [
|
||
slotId,
|
||
roundNumber(
|
||
effectiveSlotIds.has(slotId) ? (normalizedAffinity[slotId] ?? 0) : 0,
|
||
4,
|
||
),
|
||
]),
|
||
);
|
||
const attributeWeights = Object.fromEntries(
|
||
slotIds.map((slotId) => [
|
||
slotId,
|
||
roundNumber(
|
||
effectiveSlotIds.has(slotId) ? (normalizedAffinity[slotId] ?? 0) : 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[],
|
||
schema: WorldAttributeSchema,
|
||
resourceLabels?: BuildContributionResourceLabels | null,
|
||
): 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(
|
||
tagAffinity,
|
||
schema,
|
||
sourceCoefficient,
|
||
resourceLabels,
|
||
);
|
||
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 getBuildContributionQuality(
|
||
bonusDelta: number,
|
||
): (typeof BUILD_CONTRIBUTION_QUALITY_LEVELS)[number] {
|
||
const fallbackLevel =
|
||
BUILD_CONTRIBUTION_QUALITY_LEVELS[
|
||
BUILD_CONTRIBUTION_QUALITY_LEVELS.length - 1
|
||
] ?? BUILD_CONTRIBUTION_QUALITY_LEVELS[0]!;
|
||
|
||
return (
|
||
BUILD_CONTRIBUTION_QUALITY_LEVELS.find(
|
||
(level) => bonusDelta >= level.minimumBonus,
|
||
) ?? fallbackLevel
|
||
);
|
||
}
|
||
|
||
export function getBuildContributionQualityLabel(bonusDelta: number) {
|
||
return getBuildContributionQuality(bonusDelta).label;
|
||
}
|
||
|
||
export function getBuildContributionQualityRatio(bonusDelta: number) {
|
||
return getBuildContributionQuality(bonusDelta).colorRatio;
|
||
}
|
||
|
||
export function formatBuildContributionPercent(value: number, digits = 1) {
|
||
const percentValue = roundNumber(value * 100, digits);
|
||
const normalizedDigits = Math.max(0, digits);
|
||
return `${percentValue >= 0 ? '+' : ''}${percentValue.toFixed(normalizedDigits)}%`;
|
||
}
|
||
|
||
export function getBuildContributionAttributeRows(
|
||
row: Pick<
|
||
BuildContributionRow,
|
||
| 'attributeContributions'
|
||
| 'attributeModifierDeltas'
|
||
| 'attributeSimilarities'
|
||
| 'attributeWeights'
|
||
>,
|
||
schema: WorldAttributeSchema,
|
||
options: {
|
||
minimumValue?: number;
|
||
resourceLabels?: BuildContributionResourceLabels | null;
|
||
} = {},
|
||
) {
|
||
const minimumValue = options.minimumValue ?? 0.0001;
|
||
const totalModifierDelta = Object.values(
|
||
row.attributeModifierDeltas ?? {},
|
||
).reduce((sum, value) => sum + value, 0);
|
||
const targets = resolveContributionTargets(schema, options.resourceLabels);
|
||
|
||
return targets
|
||
.map((target) => {
|
||
const value = roundNumber(
|
||
row.attributeContributions[target.slotId] ?? 0,
|
||
4,
|
||
);
|
||
const modifierDelta = roundNumber(
|
||
row.attributeModifierDeltas?.[target.slotId] ?? 0,
|
||
4,
|
||
);
|
||
const percent =
|
||
totalModifierDelta > 0
|
||
? roundNumber(modifierDelta / totalModifierDelta, 4)
|
||
: 0;
|
||
|
||
return {
|
||
slotId: target.slotId,
|
||
label: target.label,
|
||
definition: target.definition,
|
||
similarity: roundNumber(
|
||
row.attributeSimilarities?.[target.slotId] ?? 0,
|
||
4,
|
||
),
|
||
weight: roundNumber(row.attributeWeights?.[target.slotId] ?? 0, 4),
|
||
value,
|
||
modifierDelta,
|
||
percent,
|
||
} satisfies BuildContributionAttributeRow;
|
||
})
|
||
.filter(
|
||
(entry) =>
|
||
entry.value > minimumValue || entry.modifierDelta > minimumValue,
|
||
)
|
||
.sort(
|
||
(left, right) =>
|
||
right.modifierDelta - left.modifierDelta ||
|
||
left.label.localeCompare(right.label, 'zh-CN'),
|
||
);
|
||
}
|
||
|
||
export function describeBuildContribution(
|
||
row: Pick<
|
||
BuildContributionRow,
|
||
| 'attributeContributions'
|
||
| 'attributeModifierDeltas'
|
||
| 'attributeSimilarities'
|
||
| 'attributeWeights'
|
||
>,
|
||
schema: WorldAttributeSchema,
|
||
options: {
|
||
limit?: number;
|
||
resourceLabels?: BuildContributionResourceLabels | null;
|
||
} = {},
|
||
) {
|
||
const limit = options.limit ?? 2;
|
||
const topRows = getBuildContributionAttributeRows(row, schema, options).slice(
|
||
0,
|
||
limit,
|
||
);
|
||
if (topRows.length === 0) {
|
||
return '\u6682\u65e0\u53ef\u89c1\u5c5e\u6027\u52a0\u6210';
|
||
}
|
||
|
||
return topRows
|
||
.map(
|
||
(entry) =>
|
||
`${entry.label} ${formatBuildContributionPercent(entry.modifierDelta)}`,
|
||
)
|
||
.join(',');
|
||
}
|
||
|
||
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),
|
||
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),
|
||
resolveAttributeSchema(resolvedWorldType, customWorldProfile),
|
||
);
|
||
}
|
||
|
||
export function getMonsterBuildDamageBreakdown(
|
||
monster: SceneHostileNpc,
|
||
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),
|
||
resolveAttributeSchema(resolvedWorldType, customWorldProfile),
|
||
);
|
||
}
|
||
|
||
export function calculateOutgoingDamage(
|
||
baseDamage: number,
|
||
options: {
|
||
functionMultiplier?: number;
|
||
equipmentMultiplier?: number;
|
||
buildMultiplier?: number;
|
||
attackPowerMultiplier?: number;
|
||
} = {},
|
||
) {
|
||
return Math.max(
|
||
1,
|
||
Math.round(
|
||
baseDamage *
|
||
(options.functionMultiplier ?? 1) *
|
||
(options.equipmentMultiplier ?? 1) *
|
||
(options.buildMultiplier ?? 1) *
|
||
(options.attackPowerMultiplier ?? 1),
|
||
),
|
||
);
|
||
}
|
||
|
||
export function calculateOutgoingDamageResult(
|
||
baseDamage: number,
|
||
options: {
|
||
functionMultiplier?: number;
|
||
equipmentMultiplier?: number;
|
||
buildMultiplier?: number;
|
||
attackPowerMultiplier?: number;
|
||
criticalHit?: boolean;
|
||
critDamageMultiplier?: number;
|
||
critChance?: number;
|
||
} = {},
|
||
): OutgoingDamageResult {
|
||
const baseResolvedDamage = calculateOutgoingDamage(baseDamage, options);
|
||
const isCritical = options.criticalHit ?? false;
|
||
const critDamageMultiplier = options.critDamageMultiplier ?? 1;
|
||
|
||
return {
|
||
damage: Math.max(
|
||
1,
|
||
Math.round(baseResolvedDamage * (isCritical ? critDamageMultiplier : 1)),
|
||
),
|
||
isCritical,
|
||
critChance: options.critChance ?? 0,
|
||
critDamageMultiplier,
|
||
attackPowerMultiplier: options.attackPowerMultiplier ?? 1,
|
||
};
|
||
}
|
||
|
||
export function resolvePlayerOutgoingDamage(
|
||
gameState: GameState,
|
||
character: Character,
|
||
baseDamage: number,
|
||
functionMultiplier = 1,
|
||
) {
|
||
const attributeProfile = resolveCharacterAttributeProfile(
|
||
character,
|
||
gameState.worldType,
|
||
gameState.customWorldProfile,
|
||
);
|
||
const combatStats = resolveRoleCombatStats(attributeProfile);
|
||
const buildBreakdown = getPlayerBuildDamageBreakdown(gameState, character);
|
||
const equipmentBonuses = getEquipmentBonuses(gameState.playerEquipment);
|
||
|
||
return calculateOutgoingDamage(baseDamage, {
|
||
functionMultiplier,
|
||
equipmentMultiplier: equipmentBonuses.outgoingDamageMultiplier,
|
||
buildMultiplier: buildBreakdown.buildDamageMultiplier,
|
||
attackPowerMultiplier: combatStats.attackPowerMultiplier,
|
||
});
|
||
}
|
||
|
||
export function resolvePlayerOutgoingDamageResult(
|
||
gameState: GameState,
|
||
character: Character,
|
||
baseDamage: number,
|
||
functionMultiplier = 1,
|
||
critRollSeed?: string,
|
||
) {
|
||
const attributeProfile = resolveCharacterAttributeProfile(
|
||
character,
|
||
gameState.worldType,
|
||
gameState.customWorldProfile,
|
||
);
|
||
const combatStats = resolveRoleCombatStats(attributeProfile);
|
||
const criticalStrike = critRollSeed
|
||
? resolveCriticalStrike(attributeProfile, critRollSeed)
|
||
: null;
|
||
const buildBreakdown = getPlayerBuildDamageBreakdown(gameState, character);
|
||
const equipmentBonuses = getEquipmentBonuses(gameState.playerEquipment);
|
||
|
||
return calculateOutgoingDamageResult(baseDamage, {
|
||
functionMultiplier,
|
||
equipmentMultiplier: equipmentBonuses.outgoingDamageMultiplier,
|
||
buildMultiplier: buildBreakdown.buildDamageMultiplier,
|
||
attackPowerMultiplier: combatStats.attackPowerMultiplier,
|
||
criticalHit: criticalStrike?.isCritical ?? false,
|
||
critChance: combatStats.critChance,
|
||
critDamageMultiplier: combatStats.critDamageMultiplier,
|
||
});
|
||
}
|
||
|
||
export function resolveCompanionOutgoingDamage(
|
||
character: Character,
|
||
baseDamage: number,
|
||
functionMultiplier = 1,
|
||
worldType: WorldType | null = null,
|
||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||
) {
|
||
const attributeProfile = resolveCharacterAttributeProfile(
|
||
character,
|
||
worldType,
|
||
customWorldProfile,
|
||
);
|
||
const combatStats = resolveRoleCombatStats(attributeProfile);
|
||
const buildBreakdown = getCompanionBuildDamageBreakdown(
|
||
character,
|
||
worldType,
|
||
customWorldProfile,
|
||
);
|
||
|
||
return calculateOutgoingDamage(baseDamage, {
|
||
functionMultiplier,
|
||
buildMultiplier: buildBreakdown.buildDamageMultiplier,
|
||
attackPowerMultiplier: combatStats.attackPowerMultiplier,
|
||
});
|
||
}
|
||
|
||
export function resolveCompanionOutgoingDamageResult(
|
||
character: Character,
|
||
baseDamage: number,
|
||
functionMultiplier = 1,
|
||
worldType: WorldType | null = null,
|
||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||
critRollSeed?: string,
|
||
) {
|
||
const attributeProfile = resolveCharacterAttributeProfile(
|
||
character,
|
||
worldType,
|
||
customWorldProfile,
|
||
);
|
||
const combatStats = resolveRoleCombatStats(attributeProfile);
|
||
const criticalStrike = critRollSeed
|
||
? resolveCriticalStrike(attributeProfile, critRollSeed)
|
||
: null;
|
||
const buildBreakdown = getCompanionBuildDamageBreakdown(
|
||
character,
|
||
worldType,
|
||
customWorldProfile,
|
||
);
|
||
|
||
return calculateOutgoingDamageResult(baseDamage, {
|
||
functionMultiplier,
|
||
buildMultiplier: buildBreakdown.buildDamageMultiplier,
|
||
attackPowerMultiplier: combatStats.attackPowerMultiplier,
|
||
criticalHit: criticalStrike?.isCritical ?? false,
|
||
critChance: combatStats.critChance,
|
||
critDamageMultiplier: combatStats.critDamageMultiplier,
|
||
});
|
||
}
|
||
|
||
export function resolveMonsterOutgoingDamage(
|
||
monster: SceneHostileNpc,
|
||
baseDamage: number,
|
||
functionMultiplier = 1,
|
||
worldType: WorldType | null = null,
|
||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||
) {
|
||
const combatStats = resolveRoleCombatStats(monster.attributeProfile);
|
||
const buildBreakdown = getMonsterBuildDamageBreakdown(
|
||
monster,
|
||
worldType,
|
||
customWorldProfile,
|
||
);
|
||
|
||
return calculateOutgoingDamage(baseDamage, {
|
||
functionMultiplier,
|
||
buildMultiplier: buildBreakdown.buildDamageMultiplier,
|
||
attackPowerMultiplier: combatStats.attackPowerMultiplier,
|
||
});
|
||
}
|
||
|
||
export function resolveMonsterOutgoingDamageResult(
|
||
monster: SceneHostileNpc,
|
||
baseDamage: number,
|
||
functionMultiplier = 1,
|
||
worldType: WorldType | null = null,
|
||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||
critRollSeed?: string,
|
||
) {
|
||
const combatStats = resolveRoleCombatStats(monster.attributeProfile);
|
||
const criticalStrike = critRollSeed
|
||
? resolveCriticalStrike(monster.attributeProfile, critRollSeed)
|
||
: null;
|
||
const buildBreakdown = getMonsterBuildDamageBreakdown(
|
||
monster,
|
||
worldType,
|
||
customWorldProfile,
|
||
);
|
||
|
||
return calculateOutgoingDamageResult(baseDamage, {
|
||
functionMultiplier,
|
||
buildMultiplier: buildBreakdown.buildDamageMultiplier,
|
||
attackPowerMultiplier: combatStats.attackPowerMultiplier,
|
||
criticalHit: criticalStrike?.isCritical ?? false,
|
||
critChance: combatStats.critChance,
|
||
critDamageMultiplier: combatStats.critDamageMultiplier,
|
||
});
|
||
}
|