diff --git a/eos/saveddata/fighter.py b/eos/saveddata/fighter.py index 333ce8def..32867587f 100644 --- a/eos/saveddata/fighter.py +++ b/eos/saveddata/fighter.py @@ -151,10 +151,6 @@ class Fighter(HandledItem, HandledCharge, ItemAttrShortcut, ChargeAttrShortcut): def abilities(self): return self.__abilities or [] - @property - def abilityMap(self): - return {a.effectID: a for a in self.abilities} - @property def charge(self): return self.__charge @@ -206,6 +202,13 @@ class Fighter(HandledItem, HandledCharge, ItemAttrShortcut, ChargeAttrShortcut): explosive=volleyValue.explosive * (1 - getattr(targetResists, "explosiveAmount", 0))) return adjustedVolley + def getVolleyPerEffect(self, targetResists=None): + volleyParams = self.getVolleyParametersPerEffect(targetResists=targetResists) + volleyMap = {} + for effectID, volleyData in volleyParams.items(): + volleyMap[effectID] = volleyData[0] + return volleyMap + def getVolley(self, targetResists=None): volleyParams = self.getVolleyParametersPerEffect(targetResists=targetResists) em = 0 diff --git a/eos/utils/stats.py b/eos/utils/stats.py index 4d41300b4..4b97ea2d9 100644 --- a/eos/utils/stats.py +++ b/eos/utils/stats.py @@ -72,6 +72,23 @@ class DmgTypes: self._calcTotal() return self + def __mul__(self, mul): + return type(self)( + em=self.em * mul, + thermal=self.thermal * mul, + kinetic=self.kinetic * mul, + explosive=self.explosive * mul) + + def __imul__(self, mul): + if mul == 1: + return + self.em *= mul + self.thermal *= mul + self.kinetic *= mul + self.explosive *= mul + self._calcTotal() + return self + def __truediv__(self, div): return type(self)( em=self.em / div, @@ -80,6 +97,8 @@ class DmgTypes: explosive=self.explosive / div) def __itruediv__(self, div): + if div == 1: + return self.em /= div self.thermal /= div self.kinetic /= div diff --git a/gui/builtinGraphs/fitDamageStats/calc.py b/gui/builtinGraphs/fitDamageStats/calc.py index 14b22ce4a..f31d398aa 100644 --- a/gui/builtinGraphs/fitDamageStats/calc.py +++ b/gui/builtinGraphs/fitDamageStats/calc.py @@ -44,13 +44,12 @@ def getLauncherMult(mod, fit, distance, tgtSpeed, tgtSigRadius): modRange = mod.maxRange if modRange is None: return 0 - mult = _calcMissileMult( - atkRadius=fit.ship.getModifiedItemAttr('radius'), - atkRange=modRange, + if distance + fit.ship.getModifiedItemAttr('radius') > modRange: + return 0 + mult = _calcMissileFactor( atkEr=mod.getModifiedChargeAttr('aoeCloudSize'), atkEv=mod.getModifiedChargeAttr('aoeVelocity'), atkDrf=mod.getModifiedChargeAttr('aoeDamageReductionFactor'), - distance=distance, tgtSpeed=tgtSpeed, tgtSigRadius=tgtSigRadius) return mult @@ -167,25 +166,6 @@ def _calcTrackingFactor(atkTracking, atkOptimalSigRadius, angularSpeed, tgtSigRa # Missile-specific @lru_cache(maxsize=200) -def _calcMissileMult(atkRadius, atkRange, atkEr, atkEv, atkDrf, distance, tgtSpeed, tgtSigRadius): - """Calculate damage multiplier for missile launcher.""" - # Missiles spawn in the center of the attacking ship - if distance + atkRadius > atkRange: - mult = 0 - else: - mult = _calcMissileFactor(atkEr, atkEv, atkDrf, tgtSpeed, tgtSigRadius) - return mult - - -@lru_cache(maxsize=200) -def _calcFighterMult(atkOptimalRange, atkFalloffRange, atkEr, atkEv, atkDrf, distance, tgtSpeed, tgtSigRadius): - """Calculate damage multiplier for separate fighter ability,""" - rangeFactor = _calcRangeFactor(atkOptimalRange, atkFalloffRange, distance) - missileFactor = _calcMissileFactor(atkEr, atkEv, atkDrf, tgtSpeed, tgtSigRadius) - mult = rangeFactor * missileFactor - return mult - - def _calcMissileFactor(atkEr, atkEv, atkDrf, tgtSpeed, tgtSigRadius): """Missile application.""" factors = [1] diff --git a/gui/builtinGraphs/fitDamageStats/graph.py b/gui/builtinGraphs/fitDamageStats/graph.py index 54b7bc139..15f4d2d27 100644 --- a/gui/builtinGraphs/fitDamageStats/graph.py +++ b/gui/builtinGraphs/fitDamageStats/graph.py @@ -19,9 +19,10 @@ import eos.config -from eos.const import FittingHardpoint, FittingModuleState +from eos.const import FittingHardpoint from eos.utils.float import floatUnerr from eos.utils.spoolSupport import SpoolType, SpoolOptions +from eos.utils.stats import DmgTypes from gui.builtinGraphs.base import FitGraph, XDef, YDef, Input, VectorDef from .calc import getTurretMult, getLauncherMult, getDroneMult, getFighterAbilityMult from .timeCache import TimeCache @@ -63,82 +64,28 @@ class FitDamageStatsGraph(FitGraph): ('distance', 'km'): lambda v, fit, tgt: v * 1000, ('atkSpeed', '%'): lambda v, fit, tgt: v / 100 * fit.ship.getModifiedItemAttr('maxVelocity'), ('tgtSpeed', '%'): lambda v, fit, tgt: v / 100 * tgt.ship.getModifiedItemAttr('maxVelocity'), - ('tgtSigRad', '%'): lambda v, fit, tgt: v / 100 * fit.ship.getModifiedItemAttr('signatureRadius')} + ('tgtSigRad', '%'): lambda v, fit, tgt: v / 100 * tgt.ship.getModifiedItemAttr('signatureRadius')} _limiters = { 'time': lambda fit, tgt: (0, 2500)} _denormalizers = { ('distance', 'km'): lambda v, fit, tgt: v / 1000, ('tgtSpeed', '%'): lambda v, fit, tgt: v * 100 / tgt.ship.getModifiedItemAttr('maxVelocity'), - ('tgtSigRad', '%'): lambda v, fit, tgt: v * 100 / fit.ship.getModifiedItemAttr('signatureRadius')} + ('tgtSigRad', '%'): lambda v, fit, tgt: v * 100 / tgt.ship.getModifiedItemAttr('signatureRadius')} def _distance2dps(self, mainInput, miscInputs, fit, tgt): - xs = [] - ys = [] - defaultSpoolValue = eos.config.settings['globalDefaultSpoolupPercentage'] - miscInputMap = dict(miscInputs) - tgtSigRad = miscInputMap.get('tgtSigRad', tgt.ship.getModifiedItemAttr('signatureRadius')) - for distance in self._iterLinear(mainInput[1]): - totalDps = 0 - for mod in fit.modules: - if not mod.isDealingDamage(): - continue - modDps = mod.getDps(spoolOptions=SpoolOptions(SpoolType.SCALE, defaultSpoolValue, False)).total - if mod.hardpoint == FittingHardpoint.TURRET: - if mod.state >= FittingModuleState.ACTIVE: - totalDps += modDps * getTurretMult( - mod=mod, - fit=fit, - tgt=tgt, - atkSpeed=miscInputMap['atkSpeed'], - atkAngle=miscInputMap['atkAngle'], - distance=distance, - tgtSpeed=miscInputMap['tgtSpeed'], - tgtAngle=miscInputMap['tgtAngle'], - tgtSigRadius=tgtSigRad) - elif mod.hardpoint == FittingHardpoint.MISSILE: - if mod.state >= FittingModuleState.ACTIVE: - totalDps += modDps * getLauncherMult( - mod=mod, - fit=fit, - distance=distance, - tgtSpeed=miscInputMap['tgtSpeed'], - tgtSigRadius=tgtSigRad) - for drone in fit.drones: - if not drone.isDealingDamage(): - continue - droneDps = drone.getDps().total - totalDps += droneDps * getDroneMult( - drone=drone, - fit=fit, - tgt=tgt, - atkSpeed=miscInputMap['atkSpeed'], - atkAngle=miscInputMap['atkAngle'], - distance=distance, - tgtSpeed=miscInputMap['tgtSpeed'], - tgtAngle=miscInputMap['tgtAngle'], - tgtSigRadius=tgtSigRad) - for fighter in fit.fighters: - if not fighter.isDealingDamage(): - continue - abilityMap = fighter.abilityMap - for effectID, abilityDps in fighter.getDpsPerEffect().items(): - ability = abilityMap[effectID] - totalDps += abilityDps.total * getFighterAbilityMult( - fighter=fighter, - ability=ability, - fit=fit, - distance=distance, - tgtSpeed=miscInputMap['tgtSpeed'], - tgtSigRadius=tgtSigRad) - xs.append(distance) - ys.append(totalDps) - return xs, ys + return self._xDistanceGetter( + mainInput=mainInput, miscInputs=miscInputs, fit=fit, tgt=tgt, + dmgFunc=self._getDpsPerKey, timeCacheFunc=self._timeCache.prepareDpsData) def _distance2volley(self, mainInput, miscInputs, fit, tgt): - return [], [] + return self._xDistanceGetter( + mainInput=mainInput, miscInputs=miscInputs, fit=fit, tgt=tgt, + dmgFunc=self._getVolleyPerKey, timeCacheFunc=self._timeCache.prepareVolleyData) def _distance2damage(self, mainInput, miscInputs, fit, tgt): - return [], [] + return self._xDistanceGetter( + mainInput=mainInput, miscInputs=miscInputs, fit=fit, tgt=tgt, + dmgFunc=self._getDmgPerKey, timeCacheFunc=self._timeCache.prepareDmgData) def _time2dps(self, mainInput, miscInputs, fit, tgt): def calcDpsTmp(timeDmg): @@ -159,22 +106,34 @@ class FitDamageStatsGraph(FitGraph): return self._composeTimeGraph(mainInput, fit, self._timeCache.getDmgData, calcDamageTmp) def _tgtSpeed2dps(self, mainInput, miscInputs, fit, tgt): - return [], [] + return self._xTgtSpeedGetter( + mainInput=mainInput, miscInputs=miscInputs, fit=fit, tgt=tgt, + dmgFunc=self._getDpsPerKey, timeCacheFunc=self._timeCache.prepareDpsData) def _tgtSpeed2volley(self, mainInput, miscInputs, fit, tgt): - return [], [] + return self._xTgtSpeedGetter( + mainInput=mainInput, miscInputs=miscInputs, fit=fit, tgt=tgt, + dmgFunc=self._getVolleyPerKey, timeCacheFunc=self._timeCache.prepareVolleyData) def _tgtSpeed2damage(self, mainInput, miscInputs, fit, tgt): - return [], [] + return self._xTgtSpeedGetter( + mainInput=mainInput, miscInputs=miscInputs, fit=fit, tgt=tgt, + dmgFunc=self._getDmgPerKey, timeCacheFunc=self._timeCache.prepareDmgData) def _tgtSigRad2dps(self, mainInput, miscInputs, fit, tgt): - return [], [] + return self._xTgtSigRadiusGetter( + mainInput=mainInput, miscInputs=miscInputs, fit=fit, tgt=tgt, + dmgFunc=self._getDpsPerKey, timeCacheFunc=self._timeCache.prepareDpsData) def _tgtSigRad2volley(self, mainInput, miscInputs, fit, tgt): - return [], [] + return self._xTgtSigRadiusGetter( + mainInput=mainInput, miscInputs=miscInputs, fit=fit, tgt=tgt, + dmgFunc=self._getVolleyPerKey, timeCacheFunc=self._timeCache.prepareVolleyData) def _tgtSigRad2damage(self, mainInput, miscInputs, fit, tgt): - return [], [] + return self._xTgtSigRadiusGetter( + mainInput=mainInput, miscInputs=miscInputs, fit=fit, tgt=tgt, + dmgFunc=self._getDmgPerKey, timeCacheFunc=self._timeCache.prepareDmgData) _getters = { ('distance', 'dps'): _distance2dps, @@ -190,6 +149,188 @@ class FitDamageStatsGraph(FitGraph): ('tgtSigRad', 'volley'): _tgtSigRad2volley, ('tgtSigRad', 'damage'): _tgtSigRad2damage} + # Point getter helpers + def _xDistanceGetter(self, mainInput, miscInputs, fit, tgt, dmgFunc, timeCacheFunc): + xs = [] + ys = [] + tgtSigRadius = tgt.ship.getModifiedItemAttr('signatureRadius') + # Process inputs into more convenient form + miscInputMap = dict(miscInputs) + # Get all data we need for all distances into maps/caches + timeCacheFunc(fit, miscInputMap['time']) + dmgMap = dmgFunc(fit=fit, time=miscInputMap['time']) + # Go through distances and calculate distance-dependent data + for distance in self._iterLinear(mainInput[1]): + applicationMap = self._getApplicationPerKey( + fit=fit, + tgt=tgt, + atkSpeed=miscInputMap['atkSpeed'], + atkAngle=miscInputMap['atkAngle'], + distance=distance, + tgtSpeed=miscInputMap['tgtSpeed'], + tgtAngle=miscInputMap['tgtAngle'], + tgtSigRadius=tgtSigRadius) + dmg = self._aggregate(dmgMap=dmgMap, applicationMap=applicationMap).total + xs.append(distance) + ys.append(dmg) + return xs, ys + + def _xTgtSpeedGetter(self, mainInput, miscInputs, fit, tgt, dmgFunc, timeCacheFunc): + xs = [] + ys = [] + tgtSigRadius = tgt.ship.getModifiedItemAttr('signatureRadius') + # Process inputs into more convenient form + miscInputMap = dict(miscInputs) + # Get all data we need for all target speeds into maps/caches + timeCacheFunc(fit, miscInputMap['time']) + dmgMap = dmgFunc(fit=fit, time=miscInputMap['time']) + # Go through target speeds and calculate distance-dependent data + for tgtSpeed in self._iterLinear(mainInput[1]): + applicationMap = self._getApplicationPerKey( + fit=fit, + tgt=tgt, + atkSpeed=miscInputMap['atkSpeed'], + atkAngle=miscInputMap['atkAngle'], + distance=miscInputMap['distance'], + tgtSpeed=tgtSpeed, + tgtAngle=miscInputMap['tgtAngle'], + tgtSigRadius=tgtSigRadius) + dmg = self._aggregate(dmgMap=dmgMap, applicationMap=applicationMap).total + xs.append(tgtSpeed) + ys.append(dmg) + return xs, ys + + def _xTgtSigRadiusGetter(self, mainInput, miscInputs, fit, tgt, dmgFunc, timeCacheFunc): + xs = [] + ys = [] + # Process inputs into more convenient form + miscInputMap = dict(miscInputs) + # Get all data we need for all target speeds into maps/caches + timeCacheFunc(fit, miscInputMap['time']) + dmgMap = dmgFunc(fit=fit, time=miscInputMap['time']) + # Go through target speeds and calculate distance-dependent data + for tgtSigRadius in self._iterLinear(mainInput[1]): + applicationMap = self._getApplicationPerKey( + fit=fit, + tgt=tgt, + atkSpeed=miscInputMap['atkSpeed'], + atkAngle=miscInputMap['atkAngle'], + distance=miscInputMap['distance'], + tgtSpeed=miscInputMap['tgtSpeed'], + tgtAngle=miscInputMap['tgtAngle'], + tgtSigRadius=tgtSigRadius) + dmg = self._aggregate(dmgMap=dmgMap, applicationMap=applicationMap).total + xs.append(tgtSigRadius) + ys.append(dmg) + return xs, ys + + # Damage data per key getters + def _getDpsPerKey(self, fit, time): + if time is not None: + return self._timeCache.getDpsDataPoint(fit, time) + dpsMap = {} + defaultSpoolValue = eos.config.settings['globalDefaultSpoolupPercentage'] + for mod in fit.modules: + if not mod.isDealingDamage(): + continue + dpsMap[mod] = mod.getDps(spoolOptions=SpoolOptions(SpoolType.SCALE, defaultSpoolValue, False)) + for drone in fit.drones: + if not drone.isDealingDamage(): + continue + dpsMap[drone] = drone.getDps() + for fighter in fit.fighters: + if not fighter.isDealingDamage(): + continue + for effectID, effectDps in fighter.getDpsPerEffect().items(): + dpsMap[(fighter, effectID)] = effectDps + return dpsMap + + def _getVolleyPerKey(self, fit, time): + if time is not None: + return self._timeCache.getVolleyDataPoint(fit, time) + volleyMap = {} + defaultSpoolValue = eos.config.settings['globalDefaultSpoolupPercentage'] + for mod in fit.modules: + if not mod.isDealingDamage(): + continue + volleyMap[mod] = mod.getVolley(spoolOptions=SpoolOptions(SpoolType.SCALE, defaultSpoolValue, False)) + for drone in fit.drones: + if not drone.isDealingDamage(): + continue + volleyMap[drone] = drone.getVolley() + for fighter in fit.fighters: + if not fighter.isDealingDamage(): + continue + for effectID, effectVolley in fighter.getVolleyPerEffect().items(): + volleyMap[(fighter, effectID)] = effectVolley + return volleyMap + + def _getDmgPerKey(self, fit, time): + # Damage inflicted makes no sense without time specified + if time is None: + raise ValueError + return self._timeCache.getDmgDataPoint(fit, time) + + # Application getter + def _getApplicationPerKey(self, fit, tgt, atkSpeed, atkAngle, distance, tgtSpeed, tgtAngle, tgtSigRadius): + applicationMap = {} + for mod in fit.modules: + if not mod.isDealingDamage(): + continue + if mod.hardpoint == FittingHardpoint.TURRET: + applicationMap[mod] = getTurretMult( + mod=mod, + fit=fit, + tgt=tgt, + atkSpeed=atkSpeed, + atkAngle=atkAngle, + distance=distance, + tgtSpeed=tgtSpeed, + tgtAngle=tgtAngle, + tgtSigRadius=tgtSigRadius) + elif mod.hardpoint == FittingHardpoint.MISSILE: + applicationMap[mod] = getLauncherMult( + mod=mod, + fit=fit, + distance=distance, + tgtSpeed=tgtSpeed, + tgtSigRadius=tgtSigRadius) + for drone in fit.drones: + if not drone.isDealingDamage(): + continue + applicationMap[drone] = getDroneMult( + drone=drone, + fit=fit, + tgt=tgt, + atkSpeed=atkSpeed, + atkAngle=atkAngle, + distance=distance, + tgtSpeed=tgtSpeed, + tgtAngle=tgtAngle, + tgtSigRadius=tgtSigRadius) + for fighter in fit.fighters: + if not fighter.isDealingDamage(): + continue + for ability in fighter.abilities: + if not ability.dealsDamage or not ability.active: + continue + applicationMap[(fighter, ability.effectID)] = getFighterAbilityMult( + fighter=fighter, + ability=ability, + fit=fit, + distance=distance, + tgtSpeed=tgtSpeed, + tgtSigRadius=tgtSigRadius) + return applicationMap + + # Calculate damage from maps + def _aggregate(self, dmgMap, applicationMap): + total = DmgTypes(0, 0, 0, 0) + for key, dmg in dmgMap.items(): + total += dmg * applicationMap.get(key, 1) + return total + + ############# TO REFACTOR: time graph stuff def _composeTimeGraph(self, mainInput, fit, cacheFunc, calcFunc): xs = [] ys = [] diff --git a/gui/builtinGraphs/fitDamageStats/timeCache.py b/gui/builtinGraphs/fitDamageStats/timeCache.py index 726f1d474..c557eaecf 100644 --- a/gui/builtinGraphs/fitDamageStats/timeCache.py +++ b/gui/builtinGraphs/fitDamageStats/timeCache.py @@ -28,15 +28,33 @@ from gui.builtinGraphs.base import FitDataCache class TimeCache(FitDataCache): + # Whole data getters def getDpsData(self, fit): + """Return DPS data in {time: {key: dps}} format.""" return self._data[fit.ID]['finalDps'] def getVolleyData(self, fit): + """Return volley data in {time: {key: volley}} format.""" return self._data[fit.ID]['finalVolley'] def getDmgData(self, fit): + """Return inflicted damage data in {time: {key: damage}} format.""" return self._data[fit.ID]['finalDmg'] + # Specific data point getters + def getDpsDataPoint(self, fit, time): + """Get DPS data by specified time in {key: dps} format.""" + return self._getDataPoint(fit, time, self.getDpsData) + + def getVolleyDataPoint(self, fit, time): + """Get volley data by specified time in {key: volley} format.""" + return self._getDataPoint(fit, time, self.getVolleyData) + + def getDmgDataPoint(self, fit, time): + """Get inflicted damage data by specified time in {key: dmg} format.""" + return self._getDataPoint(fit, time, self.getDmgData) + + # Preparation functions def prepareDpsData(self, fit, maxTime): self._prepareDpsVolleyData(fit, maxTime) @@ -74,6 +92,7 @@ class TimeCache(FitDataCache): # We do not need internal cache once we have final del fitCache['internalDmg'] + # Private stuff def _prepareDpsVolleyData(self, fit, maxTime): # Time is none means that time parameter has to be ignored, # we do not need cache for that @@ -223,3 +242,13 @@ class TimeCache(FitDataCache): except KeyError: return False return maxTime <= cacheMaxTime + + def _getDataPoint(self, fit, time, dataFunc): + data = dataFunc(fit) + timesBefore = [t for t in data if floatUnerr(t) <= floatUnerr(time)] + try: + time = max(timesBefore) + except ValueError: + return {} + else: + return data[time]