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(); 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(); [...(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, }); }