From 001d33e2308354619b417d6c9514ce53e629802a Mon Sep 17 00:00:00 2001 From: hi Date: Tue, 20 Jan 2026 18:16:53 -0600 Subject: [PATCH] . --- .gitignore | 1 + _development/helpers_fits.py | 8 ++ _development/helpers_locale.py | 3 +- eos/db/migrations/upgrade50.py | 14 ++ eos/db/saveddata/module.py | 1 + eos/effects.py | 60 ++++++-- eos/modifiedAttributeDict.py | 112 ++++++++++++++- eos/saveddata/fit.py | 61 +++++++- eos/saveddata/module.py | 88 +++++++++--- eos/utils/timeline.py | 95 +++++++++++++ graphs/data/fitCapacitor/getter.py | 66 +++++++-- graphs/data/fitDamageStats/cache/projected.py | 15 +- graphs/data/fitDamageStats/cache/time.py | 33 ++++- graphs/data/fitDamageStats/getter.py | 132 ++++++++++++------ graphs/data/fitMobility/getter.py | 26 ++-- graphs/data/fitRemoteReps/cache.py | 5 +- graphs/data/fitShieldRegen/getter.py | 34 ++++- graphs/gui/canvasPanel.py | 4 +- gui/builtinViewColumns/pulse.py | 59 ++++++++ gui/builtinViews/fittingView.py | 90 ++++++++++++ gui/fitCommands/__init__.py | 1 + .../calc/module/changePulseInterval.py | 41 ++++++ .../gui/localModule/changePulseInterval.py | 42 ++++++ gui/viewColumn.py | 1 + service/fit.py | 10 +- tests/conftest.py | 16 +++ .../test_eos/test_modifiedAttributeDict.py | 2 +- .../test_eos/test_saveddata/test_pulse.py | 131 +++++++++++++++++ .../test_eos/test_utils/test_stats.py | 2 +- tests/test_unread_desc.py | 18 ++- 30 files changed, 1041 insertions(+), 130 deletions(-) create mode 100644 eos/db/migrations/upgrade50.py create mode 100644 eos/utils/timeline.py create mode 100644 gui/builtinViewColumns/pulse.py create mode 100644 gui/fitCommands/calc/module/changePulseInterval.py create mode 100644 gui/fitCommands/gui/localModule/changePulseInterval.py create mode 100644 tests/conftest.py create mode 100644 tests/test_modules/test_eos/test_saveddata/test_pulse.py diff --git a/.gitignore b/.gitignore index 8f34001cbd..71ed4233a4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +eve.db #Python specific *.pyc diff --git a/_development/helpers_fits.py b/_development/helpers_fits.py index b5dd280fef..8df2f0b8da 100644 --- a/_development/helpers_fits.py +++ b/_development/helpers_fits.py @@ -6,11 +6,13 @@ # noinspection PyShadowingNames @pytest.fixture def RifterFit(DB, Gamedata, Saveddata): + from eos.const import ImplantLocation print("Creating Rifter") item = DB['gamedata_session'].query(Gamedata['Item']).filter(Gamedata['Item'].name == "Rifter").first() ship = Saveddata['Ship'](item) # setup fit fit = Saveddata['Fit'](ship, "My Rifter Fit") + fit.implantLocation = ImplantLocation.FIT return fit @@ -18,11 +20,13 @@ def RifterFit(DB, Gamedata, Saveddata): # noinspection PyShadowingNames @pytest.fixture def KeepstarFit(DB, Gamedata, Saveddata): + from eos.const import ImplantLocation print("Creating Keepstar") item = DB['gamedata_session'].query(Gamedata['Item']).filter(Gamedata['Item'].name == "Keepstar").first() ship = Saveddata['Structure'](item) # setup fit fit = Saveddata['Fit'](ship, "Keepstar Fit") + fit.implantLocation = ImplantLocation.FIT return fit @@ -30,11 +34,13 @@ def KeepstarFit(DB, Gamedata, Saveddata): # noinspection PyShadowingNames @pytest.fixture def CurseFit(DB, Gamedata, Saveddata): + from eos.const import ImplantLocation print("Creating Curse - With Neuts") item = DB['gamedata_session'].query(Gamedata['Item']).filter(Gamedata['Item'].name == "Curse").first() ship = Saveddata['Ship'](item) # setup fit fit = Saveddata['Fit'](ship, "Curse - With Neuts") + fit.implantLocation = ImplantLocation.FIT mod = Saveddata['Module'](DB['db'].getItem("Medium Energy Neutralizer II")) mod.state = Saveddata['State'].ONLINE @@ -49,11 +55,13 @@ def CurseFit(DB, Gamedata, Saveddata): # noinspection PyShadowingNames @pytest.fixture def HeronFit(DB, Gamedata, Saveddata): + from eos.const import ImplantLocation print("Creating Heron - RemoteSebo") item = DB['gamedata_session'].query(Gamedata['Item']).filter(Gamedata['Item'].name == "Heron").first() ship = Saveddata['Ship'](item) # setup fit fit = Saveddata['Fit'](ship, "Heron - RemoteSebo") + fit.implantLocation = ImplantLocation.FIT mod = Saveddata['Module'](DB['db'].getItem("Remote Sensor Booster II")) mod.state = Saveddata['State'].ONLINE diff --git a/_development/helpers_locale.py b/_development/helpers_locale.py index 27983278d5..1035e7c35d 100644 --- a/_development/helpers_locale.py +++ b/_development/helpers_locale.py @@ -82,7 +82,8 @@ def GetPath(root, file=None, codec=None): path = os.path.join(path, file) if codec: - path = path.decode(codec) + # path = path.decode(codec) + pass return path diff --git a/eos/db/migrations/upgrade50.py b/eos/db/migrations/upgrade50.py new file mode 100644 index 0000000000..bc655db948 --- /dev/null +++ b/eos/db/migrations/upgrade50.py @@ -0,0 +1,14 @@ +""" +Migration 50 + +- add pulseInterval column to modules table +""" + +import sqlalchemy + + +def upgrade(saveddata_engine): + try: + saveddata_engine.execute("SELECT pulseInterval FROM modules LIMIT 1;") + except sqlalchemy.exc.DatabaseError: + saveddata_engine.execute("ALTER TABLE modules ADD COLUMN pulseInterval FLOAT;") diff --git a/eos/db/saveddata/module.py b/eos/db/saveddata/module.py index 83196f7af3..0e27fa65c0 100644 --- a/eos/db/saveddata/module.py +++ b/eos/db/saveddata/module.py @@ -43,6 +43,7 @@ Column("spoolType", Integer, nullable=True), Column("spoolAmount", Float, nullable=True), Column("projectionRange", Float, nullable=True), + Column("pulseInterval", Float, nullable=True), CheckConstraint('("dummySlot" = NULL OR "itemID" = NULL) AND "dummySlot" != "itemID"')) diff --git a/eos/effects.py b/eos/effects.py index 4d727f30f8..d55b8c94b3 100644 --- a/eos/effects.py +++ b/eos/effects.py @@ -37,6 +37,34 @@ class DummyEffect(BaseEffect): pass +def _get_effect_cycle_time_ms(container, reloadOverride=None): + try: + cycle_params = container.getCycleParameters(reloadOverride=reloadOverride) + except AttributeError: + cycle_params = None + if cycle_params is not None: + return cycle_params.averageTime + return getattr(container, 'pulseAdjustedCycleTime', 0) + + +def _get_projected_rr_cycle_time_s(container, fit): + reload_override = None + try: + reload_override = container.owner.factorReload + except AttributeError: + reload_override = None + if reload_override is None: + reload_override = getattr(fit, "factorReload", None) + cycle_ms = _get_effect_cycle_time_ms(container, reloadOverride=reload_override) + if reload_override and getattr(container, "charge", None) and getattr(container, "reloadTime", 0): + shots = getattr(container, "numShots", 0) or 0 + if shots == 0: + base_ms = getattr(container, "pulseAdjustedCycleTime", 0) or getattr(container, "rawCycleTime", 0) + if base_ms: + cycle_ms = base_ms + container.reloadTime + return cycle_ms / 1000.0 + + class Effect100000(BaseEffect): """ pyfaCustomSuppressionTackleRange @@ -89,7 +117,7 @@ class Effect4(BaseEffect): @staticmethod def handler(fit, module, context, projectionRange, **kwargs): amount = module.getModifiedItemAttr('shieldBonus') - speed = module.getModifiedItemAttr('duration') / 1000.0 + speed = _get_effect_cycle_time_ms(module) / 1000.0 fit.extraAttributes.increase('shieldRepair', amount / speed, **kwargs) @@ -181,7 +209,7 @@ class Effect26(BaseEffect): @staticmethod def handler(fit, module, context, projectionRange, **kwargs): amount = module.getModifiedItemAttr('structureDamageAmount') - speed = module.getModifiedItemAttr('duration') / 1000.0 + speed = _get_effect_cycle_time_ms(module) / 1000.0 fit.extraAttributes.increase('hullRepair', amount / speed, **kwargs) @@ -199,7 +227,7 @@ class Effect27(BaseEffect): @staticmethod def handler(fit, module, context, projectionRange, **kwargs): amount = module.getModifiedItemAttr('armorDamageAmount') - speed = module.getModifiedItemAttr('duration') / 1000.0 + speed = _get_effect_cycle_time_ms(module) / 1000.0 rps = amount / speed fit.extraAttributes.increase('armorRepair', rps, **kwargs) fit.extraAttributes.increase('armorRepairPreSpool', rps, **kwargs) @@ -16586,7 +16614,7 @@ class Effect4936(BaseEffect): @staticmethod def handler(fit, module, context, projectionRange, **kwargs): amount = module.getModifiedItemAttr('shieldBonus') - speed = module.getModifiedItemAttr('duration') / 1000.0 + speed = _get_effect_cycle_time_ms(module) / 1000.0 fit.extraAttributes.increase('shieldRepair', amount / speed, **kwargs) @@ -18792,7 +18820,7 @@ def handler(fit, module, context, projectionRange, **kwargs): multiplier = 1 amount = module.getModifiedItemAttr('armorDamageAmount') * multiplier - speed = module.getModifiedItemAttr('duration') / 1000.0 + speed = _get_effect_cycle_time_ms(module) / 1000.0 rps = amount / speed fit.extraAttributes.increase('armorRepair', rps, **kwargs) fit.extraAttributes.increase('armorRepairPreSpool', rps, **kwargs) @@ -24762,7 +24790,7 @@ def handler(fit, src, context, projectionRange, **kwargs): if src.getModifiedItemAttr('maxRange', 0) < (projectionRange or 0): return amount = src.getModifiedItemAttr('powerTransferAmount') - duration = src.getModifiedItemAttr('duration') + duration = _get_effect_cycle_time_ms(src) if 'effect' in kwargs: from eos.modifiedAttributeDict import ModifiedAttributeDict amount *= ModifiedAttributeDict.getResistance(fit, kwargs['effect']) @@ -24791,7 +24819,7 @@ def handler(fit, module, context, projectionRange, **kwargs): srcOptimalRange=module.getModifiedItemAttr('maxRange'), srcFalloffRange=module.getModifiedItemAttr('falloffEffectiveness'), distance=projectionRange) - duration = module.getModifiedItemAttr('duration') / 1000.0 + duration = _get_projected_rr_cycle_time_s(module, fit) fit._hullRr.append((bonus, duration)) @@ -24816,7 +24844,7 @@ def handler(fit, container, context, projectionRange, **kwargs): srcOptimalRange=container.getModifiedItemAttr('maxRange'), srcFalloffRange=container.getModifiedItemAttr('falloffEffectiveness'), distance=projectionRange) - duration = container.getModifiedItemAttr('duration') / 1000.0 + duration = _get_projected_rr_cycle_time_s(container, fit) fit._shieldRr.append((bonus, duration)) @@ -24844,7 +24872,7 @@ def handler(fit, src, context, projectionRange, **kwargs): if 'effect' in kwargs: from eos.modifiedAttributeDict import ModifiedAttributeDict amount *= ModifiedAttributeDict.getResistance(fit, kwargs['effect']) - time = src.getModifiedItemAttr('duration') + time = _get_effect_cycle_time_ms(src) fit.addDrain(src, time, amount, 0) @@ -24870,7 +24898,7 @@ def handler(fit, container, context, projectionRange, **kwargs): srcOptimalRange=container.getModifiedItemAttr('maxRange'), srcFalloffRange=container.getModifiedItemAttr('falloffEffectiveness'), distance=projectionRange) - duration = container.getModifiedItemAttr('duration') / 1000.0 + duration = _get_projected_rr_cycle_time_s(container, fit) fit._armorRr.append((bonus, duration)) fit._armorRrPreSpool.append((bonus, duration)) fit._armorRrFullSpool.append((bonus, duration)) @@ -24908,7 +24936,7 @@ class Effect6197(BaseEffect): @staticmethod def handler(fit, src, context, projectionRange, **kwargs): amount = src.getModifiedItemAttr('powerTransferAmount') - time = src.getModifiedItemAttr('duration') + time = _get_effect_cycle_time_ms(src) if 'projected' in context: if 'effect' in kwargs: from eos.modifiedAttributeDict import ModifiedAttributeDict @@ -24996,7 +25024,7 @@ def handler(fit, src, context, projectionRange, **kwargs): if 'effect' in kwargs: from eos.modifiedAttributeDict import ModifiedAttributeDict amount *= ModifiedAttributeDict.getResistance(fit, kwargs['effect']) - time = src.getModifiedItemAttr('duration') + time = src.pulseAdjustedCycleTime fit.addDrain(src, time, amount, 0) @@ -29865,8 +29893,8 @@ def handler(fit, module, context, projectionRange, **kwargs): srcOptimalRange=module.getModifiedItemAttr('maxRange'), srcFalloffRange=module.getModifiedItemAttr('falloffEffectiveness'), distance=projectionRange) - speed = module.getModifiedItemAttr('duration') / 1000.0 - fit._shieldRr.append((amount, speed)) + duration = _get_projected_rr_cycle_time_s(module, fit) + fit._shieldRr.append((amount, duration)) class Effect6653(BaseEffect): @@ -35103,6 +35131,10 @@ def handler(fit, container, context, projectionRange, **kwargs): repSpoolPerCycle = container.getModifiedItemAttr('repairMultiplierBonusPerCycle') defaultSpoolValue = eos.config.settings['globalDefaultSpoolupPercentage'] spoolType, spoolAmount = resolveSpoolOptions(SpoolOptions(SpoolType.SPOOL_SCALE, defaultSpoolValue, False), container) + + if container.pulseInterval is not None and container.pulseInterval > cycleTime + 0.001: + spoolAmount = 0 + amount = repAmountBase * (1 + calculateSpoolup(repSpoolMax, repSpoolPerCycle, cycleTime, spoolType, spoolAmount)[0]) amountPreSpool = repAmountBase * (1 + calculateSpoolup(repSpoolMax, repSpoolPerCycle, cycleTime, SpoolType.SPOOL_SCALE, 0)[0]) amountFullSpool = repAmountBase * (1 + calculateSpoolup(repSpoolMax, repSpoolPerCycle, cycleTime, SpoolType.SPOOL_SCALE, 1)[0]) diff --git a/eos/modifiedAttributeDict.py b/eos/modifiedAttributeDict.py index 0c6f1dc0c9..01359f706b 100644 --- a/eos/modifiedAttributeDict.py +++ b/eos/modifiedAttributeDict.py @@ -32,6 +32,8 @@ cappingAttrKeyCache = {} resistanceCache = {} +_debug_max_velocity_count = 0 + def getAttrDefault(key, fallback=None): try: @@ -217,12 +219,32 @@ def getExtended(self, key, extraMultipliers=None, ignoreAfflictors=None, default multiplierAdjustment = 1 ignorePenalizedMultipliers = {} postIncreaseAdjustment = 0 - for fit, afflictors in self.getAfflictions(key).items(): + afflictions = self.getAfflictions(key) + ignored_zero_multiplier = False + global _debug_max_velocity_count + if key == 'maxVelocity' and _debug_max_velocity_count < 8: + ignore_names = [] + if ignoreAfflictors: + ignore_names = [getattr(getattr(a, 'item', None), 'name', str(a)) for a in ignoreAfflictors] + affliction_names = [] + for fit, afflictors in afflictions.items(): + for afflictor, operator, stackingGroup, preResAmount, postResAmount, used in afflictors: + name = getattr(getattr(afflictor, 'item', None), 'name', str(afflictor)) + affliction_names.append((name, operator, stackingGroup, preResAmount, postResAmount, used)) + print( + "[AttrDebug] key=maxVelocity ignores={} afflictions={}".format(ignore_names, affliction_names), + flush=True) + _debug_max_velocity_count += 1 + + for fit, afflictors in afflictions.items(): for afflictor, operator, stackingGroup, preResAmount, postResAmount, used in afflictors: if afflictor in ignoreAfflictors: if operator == Operator.MULTIPLY: if stackingGroup is None: - multiplierAdjustment /= postResAmount + if postResAmount == 0: + ignored_zero_multiplier = True + else: + multiplierAdjustment /= postResAmount else: ignorePenalizedMultipliers.setdefault(stackingGroup, []).append(postResAmount) elif operator == Operator.PREINCREASE: @@ -230,6 +252,92 @@ def getExtended(self, key, extraMultipliers=None, ignoreAfflictors=None, default elif operator == Operator.POSTINCREASE: postIncreaseAdjustment -= postResAmount + if ignored_zero_multiplier and self.__multipliers.get(key, 1) == 0: + recomputed_multiplier = 1 + for fit, afflictors in afflictions.items(): + for afflictor, operator, stackingGroup, preResAmount, postResAmount, used in afflictors: + if operator != Operator.MULTIPLY or stackingGroup is not None: + continue + if afflictor in ignoreAfflictors: + continue + recomputed_multiplier *= postResAmount + # Recalculate value without applying stored zero multiplier + try: + cappingKey = cappingAttrKeyCache[key] + except KeyError: + attrInfo = getAttributeInfo(key) + if attrInfo is None: + cappingId = cappingAttrKeyCache[key] = None + else: + cappingId = attrInfo.maxAttributeID + if cappingId is None: + cappingKey = None + else: + cappingAttrInfo = getAttributeInfo(cappingId) + cappingKey = None if cappingAttrInfo is None else cappingAttrInfo.name + cappingAttrKeyCache[key] = cappingKey + if cappingKey: + cappingValue = self[cappingKey] + cappingValue = cappingValue.value if hasattr(cappingValue, "value") else cappingValue + else: + cappingValue = None + + preIncrease = self.__preIncreases.get(key, 0) + postIncrease = self.__postIncreases.get(key, 0) + penalizedMultiplierGroups = self.__penalizedMultipliers.get(key, {}) + if extraMultipliers is not None: + penalizedMultiplierGroups = copy(penalizedMultiplierGroups) + for stackGroup, operationsData in extraMultipliers.items(): + multipliers = [] + for mult, resAttrID in operationsData: + if not resAttrID: + multipliers.append(mult) + continue + resAttrInfo = getAttributeInfo(resAttrID) + if not resAttrInfo: + multipliers.append(mult) + continue + resMult = self.fit.ship.itemModifiedAttributes[resAttrInfo.attributeName] + if resMult is None or resMult == 1: + multipliers.append(mult) + continue + mult = (mult - 1) * resMult + 1 + multipliers.append(mult) + penalizedMultiplierGroups[stackGroup] = penalizedMultiplierGroups.get(stackGroup, []) + multipliers + + default = getAttrDefault(key, fallback=0.0) + val = self.__intermediary.get(key, self.__preAssigns.get(key, self.getOriginal(key, default))) + val += preIncrease + if preIncreaseAdjustment is not None: + val += preIncreaseAdjustment + val *= recomputed_multiplier + for penaltyGroup, penalizedMultipliers in penalizedMultiplierGroups.items(): + if ignorePenalizedMultipliers is not None and penaltyGroup in ignorePenalizedMultipliers: + penalizedMultipliers = penalizedMultipliers[:] + for ignoreMult in ignorePenalizedMultipliers[penaltyGroup]: + try: + penalizedMultipliers.remove(ignoreMult) + except ValueError: + pass + l1 = [_val for _val in penalizedMultipliers if _val > 1] + l2 = [_val for _val in penalizedMultipliers if _val < 1] + abssort = lambda _val: -abs(_val - 1) + l1.sort(key=abssort) + l2.sort(key=abssort) + for l in (l1, l2): + for i in range(len(l)): + bonus = l[i] + val *= 1 + (bonus - 1) * exp(- i ** 2 / 7.1289) + val += postIncrease + if postIncreaseAdjustment is not None: + val += postIncreaseAdjustment + + if cappingValue is not None: + val = min(val, cappingValue) + if key in ("cpu", "power", "cpuOutput", "powerOutput"): + val = round(val, 2) + return val + # If we apply no customizations - use regular getter if ( not extraMultipliers and diff --git a/eos/saveddata/fit.py b/eos/saveddata/fit.py index 324fea1207..c4a0d548f4 100644 --- a/eos/saveddata/fit.py +++ b/eos/saveddata/fit.py @@ -41,6 +41,7 @@ from eos.saveddata.targetProfile import TargetProfile from eos.utils.float import floatUnerr from eos.utils.stats import DmgTypes, RRTypes +from eos.utils.timeline import ModuleTimeline pyfalog = Logger(__name__) @@ -165,6 +166,7 @@ def build(self): self._armorRrPreSpool = [] self._armorRrFullSpool = [] self._shieldRr = [] + self.__timelineCache = {} def clearFactorReloadDependentData(self): # Here we clear all data known to rely on cycle parameters @@ -561,6 +563,7 @@ def clear(self, projected=False, command=False): self._armorRrPreSpool.clear() self._armorRrFullSpool.clear() self._shieldRr.clear() + self.__timelineCache.clear() # If this is the active fit that we are clearing, not a projected fit, # then this will run and clear the projected ships and flag the next @@ -1099,6 +1102,7 @@ def calculateModifiedAttributes(self, targetFit=None, type=CalcType.LOCAL): for fit in self.projectedFits: projInfo = fit.getProjectionInfo(self.ID) if projInfo.active: + fit.factorReload = self.factorReload if fit == self: # If doing self projection, no need to run through the recursion process. Simply run the # projection effects on ourselves @@ -1434,9 +1438,7 @@ def __generateDrain(self): capAdded = 0 for mod in self.activeModulesIter(): if (mod.getModifiedItemAttr("capacitorNeed") or 0) != 0: - cycleTime = mod.rawCycleTime or 0 - reactivationTime = mod.getModifiedItemAttr("moduleReactivationDelay") or 0 - fullCycleTime = cycleTime + reactivationTime + fullCycleTime = mod.pulseAdjustedCycleTime or 0 reloadTime = mod.reloadTime if fullCycleTime > 0: capNeed = mod.capUse @@ -1649,13 +1651,15 @@ def calculateSustainableTank(self): # Normal Repairers if usesCap and not afflictor.charge: - cycleTime = afflictor.rawCycleTime + cycle_params = afflictor.getCycleParameters() + cycleTime = (cycle_params.averageTime if cycle_params is not None else afflictor.pulseAdjustedCycleTime) amount = afflictor.getModifiedItemAttr(groupAttrMap[afflictor.item.group.name]) localAdjustment[tankType] -= amount / (cycleTime / 1000.0) repairers.append(afflictor) # Ancillary Armor reps etc elif usesCap and afflictor.charge: - cycleTime = afflictor.rawCycleTime + cycle_params = afflictor.getCycleParameters() + cycleTime = (cycle_params.averageTime if cycle_params is not None else afflictor.pulseAdjustedCycleTime) amount = afflictor.getModifiedItemAttr(groupAttrMap[afflictor.item.group.name]) if afflictor.charge.name == "Nanite Repair Paste": multiplier = afflictor.getModifiedItemAttr("chargedArmorDamageMultiplier") or 1 @@ -1665,7 +1669,8 @@ def calculateSustainableTank(self): repairers.append(afflictor) # Ancillary Shield boosters etc elif not usesCap and afflictor.item.group.name in ("Ancillary Shield Booster", "Ancillary Remote Shield Booster"): - cycleTime = afflictor.rawCycleTime + cycle_params = afflictor.getCycleParameters() + cycleTime = (cycle_params.averageTime if cycle_params is not None else afflictor.pulseAdjustedCycleTime) amount = afflictor.getModifiedItemAttr(groupAttrMap[afflictor.item.group.name]) if self.factorReload and afflictor.charge: reloadtime = afflictor.reloadTime @@ -1692,7 +1697,8 @@ def calculateSustainableTank(self): else: reloadtime = 0.0 - cycleTime = afflictor.rawCycleTime + cycle_params = afflictor.getCycleParameters() + cycleTime = (cycle_params.averageTime if cycle_params is not None else afflictor.pulseAdjustedCycleTime) capPerSec = afflictor.capUse if capPerSec is not None and cycleTime is not None: @@ -1845,6 +1851,47 @@ def activeFighterAbilityIter(self): if ability.active: yield fighter, ability + def getInactiveModulesAt(self, timeMs, exclude=()): + if timeMs is None or timeMs < 0: + return [] + inactive = [] + exclude_set = set(exclude) if exclude else set() + for mod in self.activeModulesIter(): + if mod in exclude_set: + continue + timeline = self.__timelineCache.get(mod) + if timeline is None: + cycleParams = mod.getCycleParameters(reloadOverride=True) + timeline = ModuleTimeline(mod, cycleParams) + self.__timelineCache[mod] = timeline + if not timeline.is_active_at(timeMs): + inactive.append(mod) + return inactive + + def getPulseInactiveAfflictorsAt(self, timeMs, exclude=()): + """Return list of pulsed modules inactive at a given time in ms.""" + if timeMs is None: + return [] + if timeMs < 0: + return [] + exclude_set = set(exclude) if exclude else set() + inactive_afflictors = [] + for mod in self.activeModulesIter(): + if mod in exclude_set: + continue + if mod.pulseInterval is None: + continue + cycle_time = mod.rawCycleTime + if not cycle_time: + continue + full_cycle = mod.pulseAdjustedCycleTime + if full_cycle <= cycle_time: + continue + time_in_cycle = timeMs % full_cycle + if time_in_cycle >= cycle_time: + inactive_afflictors.append(mod) + return inactive_afflictors + def getDampMultScanRes(self): damps = [] for mod in self.activeModulesIter(): diff --git a/eos/saveddata/module.py b/eos/saveddata/module.py index 99a9eaa61b..013d31a81d 100644 --- a/eos/saveddata/module.py +++ b/eos/saveddata/module.py @@ -84,6 +84,7 @@ def __init__(self, item, baseItem=None, mutaplasmid=None): self.projected = False self.projectionRange = None + self.pulseInterval = None self.state = FittingModuleState.ONLINE self.build() @@ -111,6 +112,9 @@ def init(self): pyfalog.error("Item (id: {0}) is not a Module", self.itemID) return + if not hasattr(self, "pulseInterval"): + self.pulseInterval = None + if self.chargeID: self.__charge = eos.db.getItem(self.chargeID) @@ -474,59 +478,81 @@ def canDealDamage(self, ignoreState=False): return True return False - def getVolleyParameters(self, spoolOptions=None, targetProfile=None, ignoreState=False): + def getVolleyParameters(self, spoolOptions=None, targetProfile=None, ignoreState=False, ignoreAfflictors=()): if self.isEmpty or (self.state < FittingModuleState.ACTIVE and not ignoreState): return {0: DmgTypes.default()} - if self.__baseVolley is None: - self.__baseVolley = {} + def get_item_attr(name, default=0): + if ignoreAfflictors: + return self.getModifiedItemAttrExtended(name, ignoreAfflictors=ignoreAfflictors, default=default) + return self.getModifiedItemAttr(name, default) + + def get_charge_attr(name, default=0): + if ignoreAfflictors: + return self.getModifiedChargeAttrExtended(name, ignoreAfflictors=ignoreAfflictors, default=default) + return self.getModifiedChargeAttr(name, default) + + baseVolleys = self.__baseVolley + if ignoreAfflictors: + baseVolleys = None + if baseVolleys is None: + baseVolleys = {} if self.isBreacher: dmgDelay = 1 - subcycles = math.floor(self.getModifiedChargeAttr("dotDuration", 0) / 1000) + subcycles = math.floor(get_charge_attr("dotDuration", 0) / 1000) breacher_info = BreacherInfo( - absolute=self.getModifiedChargeAttr("dotMaxDamagePerTick", 0), - relative=self.getModifiedChargeAttr("dotMaxHPPercentagePerTick", 0) / 100) + absolute=get_charge_attr("dotMaxDamagePerTick", 0), + relative=get_charge_attr("dotMaxHPPercentagePerTick", 0) / 100) for i in range(subcycles): volley = DmgTypes.default() volley.add_breacher(dmgDelay + i, breacher_info) - self.__baseVolley[dmgDelay + i] = volley + baseVolleys[dmgDelay + i] = volley else: - dmgGetter = self.getModifiedChargeAttr if self.charge else self.getModifiedItemAttr - dmgMult = self.getModifiedItemAttr("damageMultiplier", 1) + dmgGetter = get_charge_attr if self.charge else get_item_attr + dmgMult = get_item_attr("damageMultiplier", 1) # Some delay attributes have non-0 default value, so we have to pick according to effects if {'superWeaponAmarr', 'superWeaponCaldari', 'superWeaponGallente', 'superWeaponMinmatar', 'lightningWeapon'}.intersection(self.item.effects): - dmgDelay = self.getModifiedItemAttr("damageDelayDuration", 0) + dmgDelay = get_item_attr("damageDelayDuration", 0) elif {'doomsdayBeamDOT', 'doomsdaySlash', 'doomsdayConeDOT', 'debuffLance'}.intersection(self.item.effects): - dmgDelay = self.getModifiedItemAttr("doomsdayWarningDuration", 0) + dmgDelay = get_item_attr("doomsdayWarningDuration", 0) else: dmgDelay = 0 - dmgDuration = self.getModifiedItemAttr("doomsdayDamageDuration", 0) - dmgSubcycle = self.getModifiedItemAttr("doomsdayDamageCycleTime", 0) + dmgDuration = get_item_attr("doomsdayDamageDuration", 0) + dmgSubcycle = get_item_attr("doomsdayDamageCycleTime", 0) # Reaper DD can damage each target only once if dmgDuration != 0 and dmgSubcycle != 0 and 'doomsdaySlash' not in self.item.effects: subcycles = math.floor(floatUnerr(dmgDuration / dmgSubcycle)) else: subcycles = 1 for i in range(subcycles): - self.__baseVolley[dmgDelay + dmgSubcycle * i] = DmgTypes( + baseVolleys[dmgDelay + dmgSubcycle * i] = DmgTypes( em=(dmgGetter("emDamage", 0)) * dmgMult, thermal=(dmgGetter("thermalDamage", 0)) * dmgMult, kinetic=(dmgGetter("kineticDamage", 0)) * dmgMult, explosive=(dmgGetter("explosiveDamage", 0)) * dmgMult) - spoolType, spoolAmount = resolveSpoolOptions(spoolOptions, self) + if not ignoreAfflictors: + self.__baseVolley = baseVolleys + if self.pulseInterval is not None and self.state >= FittingModuleState.ACTIVE: + spoolType, spoolAmount = None, None + else: + spoolType, spoolAmount = resolveSpoolOptions(spoolOptions, self) spoolBoost = calculateSpoolup( - self.getModifiedItemAttr("damageMultiplierBonusMax", 0), - self.getModifiedItemAttr("damageMultiplierBonusPerCycle", 0), + get_item_attr("damageMultiplierBonusMax", 0), + get_item_attr("damageMultiplierBonusPerCycle", 0), self.rawCycleTime / 1000, spoolType, spoolAmount)[0] spoolMultiplier = 1 + spoolBoost adjustedVolleys = {} - for volleyTime, baseVolley in self.__baseVolley.items(): + for volleyTime, baseVolley in baseVolleys.items(): adjustedVolley = baseVolley * spoolMultiplier adjustedVolley.profile = targetProfile adjustedVolleys[volleyTime] = adjustedVolley return adjustedVolleys - def getVolley(self, spoolOptions=None, targetProfile=None, ignoreState=False): - volleyParams = self.getVolleyParameters(spoolOptions=spoolOptions, targetProfile=targetProfile, ignoreState=ignoreState) + def getVolley(self, spoolOptions=None, targetProfile=None, ignoreState=False, ignoreAfflictors=()): + volleyParams = self.getVolleyParameters( + spoolOptions=spoolOptions, + targetProfile=targetProfile, + ignoreState=ignoreState, + ignoreAfflictors=ignoreAfflictors) if len(volleyParams) == 0: return DmgTypes.default() return volleyParams[min(volleyParams)] @@ -589,7 +615,10 @@ def getRepAmountParameters(self, spoolOptions=None, ignoreState=False): capacitorAmount += self.getModifiedItemAttr("powerTransferAmount", 0) rrDelay = 0 if rrType == "Shield" else self.rawCycleTime self.__baseRRAmount[rrDelay] = RRTypes(shield=shieldAmount, armor=armorAmount, hull=hullAmount, capacitor=capacitorAmount) - spoolType, spoolAmount = resolveSpoolOptions(spoolOptions, self) + if self.pulseInterval is not None and self.state >= FittingModuleState.ACTIVE: + spoolType, spoolAmount = None, None + else: + spoolType, spoolAmount = resolveSpoolOptions(spoolOptions, self) spoolBoost = calculateSpoolup( self.getModifiedItemAttr("repairMultiplierBonusMax", 0), self.getModifiedItemAttr("repairMultiplierBonusPerCycle", 0), @@ -619,6 +648,8 @@ def getRemoteReps(self, spoolOptions=None, ignoreState=False, reloadOverride=Non return rps def getSpoolData(self, spoolOptions=None): + if self.pulseInterval is not None and self.state >= FittingModuleState.ACTIVE: + return 0, 0 weaponMultMax = self.getModifiedItemAttr("damageMultiplierBonusMax", 0) weaponMultPerCycle = self.getModifiedItemAttr("damageMultiplierBonusPerCycle", 0) if weaponMultMax and weaponMultPerCycle: @@ -981,6 +1012,10 @@ def getCycleParameters(self, reloadOverride=None): if active_time == 0: return None forced_inactive_time = self.reactivationDelay + if self.pulseInterval is not None and self.state >= FittingModuleState.ACTIVE: + pulse_interval_ms = max(active_time, self.pulseInterval * 1000) + pulse_inactive_time = max(0, pulse_interval_ms - active_time) + forced_inactive_time = max(forced_inactive_time, pulse_inactive_time) reload_time = self.reloadTime # Effects which cannot be reloaded have the same processing whether # caller wants to take reload time into account or not @@ -1035,6 +1070,17 @@ def rawCycleTime(self): ) return speed + @property + def pulseAdjustedCycleTime(self): + cycle_time = self.rawCycleTime + if cycle_time == 0: + return 0 + inactive_time = self.reactivationDelay + if self.pulseInterval is not None and self.state >= FittingModuleState.ACTIVE: + pulse_interval_ms = max(cycle_time, self.pulseInterval * 1000) + inactive_time = max(inactive_time, pulse_interval_ms - cycle_time) + return cycle_time + inactive_time + @property def disallowRepeatingAction(self): return self.getModifiedItemAttr("disallowRepeatingActivation", 0) diff --git a/eos/utils/timeline.py b/eos/utils/timeline.py new file mode 100644 index 0000000000..243a6a97f3 --- /dev/null +++ b/eos/utils/timeline.py @@ -0,0 +1,95 @@ +# ============================================================================= +# Copyright (C) 2010 Diego Duclos +# +# This file is part of eos. +# +# eos is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# eos is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with eos. If not, see . +# ============================================================================= + +import math + +from eos.utils.cycles import CycleInfo, CycleSequence + + +class ModuleTimeline: + """Represents repeating ON/OFF activation windows for a module.""" + + def __init__(self, module, cycleParams): + self._module = module + self._cycleParams = cycleParams + self._pattern = [] # list of (active_ms, inactive_ms) + self._pattern_time = 0 + self._repeat_inf = False + self._repeat_count = 1 + self._build_pattern() + + def _build_pattern(self): + if self._cycleParams is None: + return + + pulse_forced = self._module.pulseInterval is not None + repeat_allowed = pulse_forced or not self._module.disallowRepeatingAction + + if isinstance(self._cycleParams, CycleInfo): + active = self._cycleParams.activeTime + inactive = self._cycleParams.inactiveTime + quantity = self._cycleParams.quantity + if quantity is math.inf: + self._repeat_inf = repeat_allowed + self._repeat_count = 1 if not repeat_allowed else math.inf + else: + self._repeat_count = quantity if repeat_allowed else 1 + self._pattern = [(active, inactive)] + self._pattern_time = active + inactive + return + + if isinstance(self._cycleParams, CycleSequence): + # Build a single sequence pattern + sequence_pattern = [] + for cycleInfo in self._cycleParams.sequence: + for active, inactive, _ in cycleInfo.iterCycles(): + sequence_pattern.append((active, inactive)) + self._pattern = sequence_pattern + self._pattern_time = sum(active + inactive for active, inactive in sequence_pattern) + if self._cycleParams.quantity is math.inf: + self._repeat_inf = repeat_allowed + self._repeat_count = 1 if not repeat_allowed else math.inf + else: + self._repeat_count = self._cycleParams.quantity if repeat_allowed else 1 + + def is_active_at(self, timeMs): + if timeMs is None or timeMs < 0: + return False + if not self._pattern: + return True + if self._pattern_time <= 0: + return True + + if self._repeat_inf: + time_in_period = timeMs % self._pattern_time + else: + total_time = self._pattern_time * self._repeat_count + if timeMs >= total_time: + return False + time_in_period = timeMs % self._pattern_time + + cursor = 0 + for active, inactive in self._pattern: + if time_in_period < cursor + active: + return True + cursor += active + if time_in_period < cursor + inactive: + return False + cursor += inactive + return False diff --git a/graphs/data/fitCapacitor/getter.py b/graphs/data/fitCapacitor/getter.py index 7a71eb8c7c..8e5244753f 100644 --- a/graphs/data/fitCapacitor/getter.py +++ b/graphs/data/fitCapacitor/getter.py @@ -46,14 +46,27 @@ def getRange(self, xRange, miscParams, src, tgt): # When time range lies to the right of last cap sim data point, return nothing if len(capSimDataBefore) > 0 and max(capSimDataBefore) == capSimDataMaxTime: return xs, ys - maxCapAmount = src.item.ship.getModifiedItemAttr('capacitorCapacity') - capRegenTime = src.item.ship.getModifiedItemAttr('rechargeRate') / 1000 + defaultMaxCapAmount = src.item.ship.getModifiedItemAttr('capacitorCapacity') + defaultCapRegenTime = src.item.ship.getModifiedItemAttr('rechargeRate') / 1000 + + def getCapAttrs(atTime): + if getattr(src, 'isFit', False): + ignore_afflictors = src.item.getInactiveModulesAt(atTime * 1000) + maxCap = src.item.ship.getModifiedItemAttrExtended( + 'capacitorCapacity', + ignoreAfflictors=ignore_afflictors) + capRegen = src.item.ship.getModifiedItemAttrExtended( + 'rechargeRate', + ignoreAfflictors=ignore_afflictors) / 1000 + return maxCap, capRegen + return defaultMaxCapAmount, defaultCapRegenTime def plotCapRegen(prevTime, prevCap, currentTime): subrangeAmount = math.ceil((currentTime - prevTime) / maxPointXDistance) subrangeLength = (currentTime - prevTime) / subrangeAmount for i in range(1, subrangeAmount + 1): subrangeTime = prevTime + subrangeLength * i + maxCapAmount, capRegenTime = getCapAttrs(subrangeTime) subrangeCap = calculateCapAmount( maxCapAmount=maxCapAmount, capRegenTime=capRegenTime, @@ -66,12 +79,14 @@ def plotCapRegen(prevTime, prevCap, currentTime): if capSimDataBefore: timeBefore = max(capSimDataBefore) capBefore = capSimDataBefore[timeBefore] + maxCapAmount, capRegenTime = getCapAttrs(prevTime) prevCap = calculateCapAmount( maxCapAmount=maxCapAmount, capRegenTime=capRegenTime, capAmountT0=capBefore, time=prevTime - timeBefore) else: + maxCapAmount, capRegenTime = getCapAttrs(prevTime) prevCap = calculateCapAmount( maxCapAmount=maxCapAmount, capRegenTime=capRegenTime, @@ -106,8 +121,17 @@ def getPoint(self, x, miscParams, src, tgt): # When time range lies to the right of last cap sim data point, return nothing if len(capSimDataBefore) > 0 and max(capSimDataBefore) == capSimDataMaxTime: return None - maxCapAmount = src.item.ship.getModifiedItemAttr('capacitorCapacity') - capRegenTime = src.item.ship.getModifiedItemAttr('rechargeRate') / 1000 + if getattr(src, 'isFit', False): + ignore_afflictors = src.item.getInactiveModulesAt(currentTime * 1000) + maxCapAmount = src.item.ship.getModifiedItemAttrExtended( + 'capacitorCapacity', + ignoreAfflictors=ignore_afflictors) + capRegenTime = src.item.ship.getModifiedItemAttrExtended( + 'rechargeRate', + ignoreAfflictors=ignore_afflictors) / 1000 + else: + maxCapAmount = src.item.ship.getModifiedItemAttr('capacitorCapacity') + capRegenTime = src.item.ship.getModifiedItemAttr('rechargeRate') / 1000 if capSimDataBefore: timeBefore = max(capSimDataBefore) capBefore = capSimDataBefore[timeBefore] @@ -134,9 +158,20 @@ def _getCommonData(self, miscParams, src, tgt): def _calculatePoint(self, x, miscParams, src, tgt, commonData): time = x + if getattr(src, 'isFit', False): + ignore_afflictors = src.item.getInactiveModulesAt(time * 1000) + maxCapAmount = src.item.ship.getModifiedItemAttrExtended( + 'capacitorCapacity', + ignoreAfflictors=ignore_afflictors) + capRegenTime = src.item.ship.getModifiedItemAttrExtended( + 'rechargeRate', + ignoreAfflictors=ignore_afflictors) / 1000 + else: + maxCapAmount = commonData['maxCapAmount'] + capRegenTime = commonData['capRegenTime'] capAmount = calculateCapAmount( - maxCapAmount=commonData['maxCapAmount'], - capRegenTime=commonData['capRegenTime'], + maxCapAmount=maxCapAmount, + capRegenTime=capRegenTime, capAmountT0=miscParams['capAmountT0'] or 0, time=time) return capAmount @@ -151,14 +186,25 @@ def _getCommonData(self, miscParams, src, tgt): def _calculatePoint(self, x, miscParams, src, tgt, commonData): time = x + if getattr(src, 'isFit', False): + ignore_afflictors = src.item.getInactiveModulesAt(time * 1000) + maxCapAmount = src.item.ship.getModifiedItemAttrExtended( + 'capacitorCapacity', + ignoreAfflictors=ignore_afflictors) + capRegenTime = src.item.ship.getModifiedItemAttrExtended( + 'rechargeRate', + ignoreAfflictors=ignore_afflictors) / 1000 + else: + maxCapAmount = commonData['maxCapAmount'] + capRegenTime = commonData['capRegenTime'] capAmount = calculateCapAmount( - maxCapAmount=commonData['maxCapAmount'], - capRegenTime=commonData['capRegenTime'], + maxCapAmount=maxCapAmount, + capRegenTime=capRegenTime, capAmountT0=miscParams['capAmountT0'] or 0, time=time) capRegen = calculateCapRegen( - maxCapAmount=commonData['maxCapAmount'], - capRegenTime=commonData['capRegenTime'], + maxCapAmount=maxCapAmount, + capRegenTime=capRegenTime, currentCapAmount=capAmount) return capRegen diff --git a/graphs/data/fitDamageStats/cache/projected.py b/graphs/data/fitDamageStats/cache/projected.py index 8b0dbaad0d..9f8bcb22c2 100644 --- a/graphs/data/fitDamageStats/cache/projected.py +++ b/graphs/data/fitDamageStats/cache/projected.py @@ -30,15 +30,22 @@ class ProjectedDataCache(FitDataCache): - def getProjModData(self, src): + def getProjModData(self, src, timeMs=None): try: - projectedData = self._data[src.item.ID]['modules'] + if timeMs is None: + projectedData = self._data[src.item.ID]['modules'] + else: + raise KeyError except KeyError: # Format of items for both: (boost strength, optimal, falloff, stacking group, resistance attr ID) webMods = [] tpMods = [] - projectedData = self._data.setdefault(src.item.ID, {})['modules'] = (webMods, tpMods) + projectedData = (webMods, tpMods) for mod in src.item.activeModulesIter(): + if timeMs is not None and mod.pulseInterval is not None: + inactive = src.item.getInactiveModulesAt(timeMs, exclude=()) + if mod in inactive: + continue for webEffectName in ('remoteWebifierFalloff', 'structureModuleEffectStasisWebifier'): if webEffectName in mod.item.effects: webMods.append(ModProjData( @@ -69,6 +76,8 @@ def getProjModData(self, src): mod.falloff or 0, 'default', getResistanceAttrID(modifyingItem=mod, effect=mod.item.effects['doomsdayAOEPaint']))) + if timeMs is None: + self._data.setdefault(src.item.ID, {})['modules'] = projectedData return projectedData def getProjDroneData(self, src): diff --git a/graphs/data/fitDamageStats/cache/time.py b/graphs/data/fitDamageStats/cache/time.py index 72d8aaacd2..db3f388f9b 100644 --- a/graphs/data/fitDamageStats/cache/time.py +++ b/graphs/data/fitDamageStats/cache/time.py @@ -20,6 +20,8 @@ from copy import copy +from logbook import Logger + from eos.utils.float import floatUnerr from eos.utils.spoolSupport import SpoolOptions, SpoolType from eos.utils.stats import DmgTypes @@ -27,6 +29,7 @@ class TimeCache(FitDataCache): + pyfalog = Logger(__name__) # Whole data getters def getDpsData(self, src): @@ -66,6 +69,7 @@ def prepareDmgData(self, src, maxTime): # we do not need cache for that if maxTime is None: return + print("[DamageStats] prepareDmgData called", flush=True) self._generateInternalForm(src=src, maxTime=maxTime) fitCache = self._data[src.item.ID] # Final cache has been generated already, don't do anything @@ -98,6 +102,7 @@ def _prepareDpsVolleyData(self, src, maxTime): # we do not need cache for that if maxTime is None: return True + print("[DamageStats] prepareDpsVolleyData called", flush=True) self._generateInternalForm(src=src, maxTime=maxTime) fitCache = self._data[src.item.ID] # Final cache has been generated already, don't do anything @@ -150,9 +155,12 @@ def _prepareDpsVolleyData(self, src, maxTime): def _generateInternalForm(self, src, maxTime): if self._isTimeCacheValid(src=src, maxTime=maxTime): return + self.pyfalog.debug("DamageStats TimeCache: generating for fit %s up to %.3fs", getattr(src.item, 'ID', None), maxTime) + print("[DamageStats] TimeCache generating for fit {} up to {:.3f}s".format(getattr(src.item, 'ID', None), maxTime), flush=True) fitCache = self._data[src.item.ID] = {'maxTime': maxTime} intCacheDpsVolley = fitCache['internalDpsVolley'] = {} intCacheDmg = fitCache['internalDmg'] = {} + dmgPrintCount = 0 def addDpsVolley(ddKey, addedTimeStart, addedTimeFinish, addedVolleys): if not addedVolleys: @@ -170,6 +178,10 @@ def addDpsVolley(ddKey, addedTimeStart, addedTimeFinish, addedVolleys): def addDmg(ddKey, addedTime, addedDmg): if addedDmg.total == 0: return + nonlocal dmgPrintCount + if dmgPrintCount < 5: + print("[DamageStats] Dmg event key={} t={:.3f}s total={}".format(ddKey, addedTime, addedDmg.total), flush=True) + dmgPrintCount += 1 addedDmg._breachers = {addedTime + k: v for k, v in addedDmg._breachers.items()} addedDmg._clear_cached() intCacheDmg.setdefault(ddKey, {})[addedTime] = addedDmg @@ -186,9 +198,18 @@ def addDmg(ddKey, addedTime, addedDmg): isBreacher = mod.isBreacher for cycleTimeMs, inactiveTimeMs, isInactivityReload in cycleParams.iterCycles(): cycleVolleys = [] - volleyParams = mod.getVolleyParameters(spoolOptions=SpoolOptions(SpoolType.CYCLES, nonstopCycles, True)) - - for volleyTimeMs, volley in volleyParams.items(): + baseVolleyParams = mod.getVolleyParameters( + spoolOptions=SpoolOptions(SpoolType.CYCLES, nonstopCycles, True)) + if not baseVolleyParams: + self.pyfalog.debug("DamageStats TimeCache: no base volleys for mod %s at t=%.3fs", mod, currentTime) + print("[DamageStats] No base volleys for mod {} at t={:.3f}s".format(mod, currentTime), flush=True) + for volleyTimeMs in baseVolleyParams: + inactive_afflictors = src.item.getInactiveModulesAt( + currentTime * 1000 + volleyTimeMs, exclude=(mod,)) + volleyParams = mod.getVolleyParameters( + spoolOptions=SpoolOptions(SpoolType.CYCLES, nonstopCycles, True), + ignoreAfflictors=inactive_afflictors) + volley = volleyParams.get(volleyTimeMs, DmgTypes.default()) cycleVolleys.append(volley) time = currentTime + volleyTimeMs / 1000 if isBreacher: @@ -209,6 +230,12 @@ def addDmg(ddKey, addedTime, addedDmg): if currentTime > maxTime: break currentTime += cycleTimeMs / 1000 + inactiveTimeMs / 1000 + self.pyfalog.debug("DamageStats TimeCache: generated %d dps segments and %d dmg events", + sum(len(v) for v in intCacheDpsVolley.values()), + sum(len(v) for v in intCacheDmg.values())) + print("[DamageStats] Generated {} dps segments and {} dmg events".format( + sum(len(v) for v in intCacheDpsVolley.values()), + sum(len(v) for v in intCacheDmg.values())), flush=True) # Drones for drone in src.item.activeDronesIter(): if not drone.isDealingDamage(): diff --git a/graphs/data/fitDamageStats/getter.py b/graphs/data/fitDamageStats/getter.py index 5c66593be7..3de18db74f 100644 --- a/graphs/data/fitDamageStats/getter.py +++ b/graphs/data/fitDamageStats/getter.py @@ -19,6 +19,7 @@ import eos.config +from logbook import Logger from eos.saveddata.targetProfile import TargetProfile from eos.utils.spoolSupport import SpoolOptions, SpoolType from eos.utils.stats import DmgTypes @@ -27,6 +28,8 @@ from .calc.application import getApplicationPerKey from .calc.projected import getScramRange, getScrammables, getTackledSpeed, getSigRadiusMult +pyfalog = Logger(__name__) + def applyDamage(dmgMap, applicationMap, tgtResists, tgtFullHp): total = DmgTypes.default() @@ -194,13 +197,13 @@ def _calculatePoint(self, x, miscParams, src, tgt, commonData): class XTimeMixin(PointGetter): - def _prepareApplicationMap(self, miscParams, src, tgt): + def _prepareApplicationMap(self, miscParams, src, tgt, timeMs=None): tgtSpeed = miscParams['tgtSpeed'] tgtSigRadius = tgt.getSigRadius() if GraphSettings.getInstance().get('applyProjected'): srcScramRange = getScramRange(src=src) tgtScrammables = getScrammables(tgt=tgt) - webMods, tpMods = self.graph._projectedCache.getProjModData(src) + webMods, tpMods = self.graph._projectedCache.getProjModData(src, timeMs=timeMs) webDrones, tpDrones = self.graph._projectedCache.getProjDroneData(src) webFighters, tpFighters = self.graph._projectedCache.getProjFighterData(src) tgtSpeed = getTackledSpeed( @@ -236,53 +239,92 @@ def _prepareApplicationMap(self, miscParams, src, tgt): return applicationMap def getRange(self, xRange, miscParams, src, tgt): + print("[DamageStats] TimeGraph getRange called", flush=True) xs = [] ys = [] minTime, maxTime = xRange # Prepare time cache and various shared data self._prepareTimeCache(src=src, maxTime=maxTime) timeCache = self._getTimeCacheData(src=src) - applicationMap = self._prepareApplicationMap(miscParams=miscParams, src=src, tgt=tgt) - # Custom iteration for time graph to show all data points - currentDmg = None - currentTime = None - for currentTime in sorted(timeCache): - prevDmg = currentDmg - currentDmgData = timeCache[currentTime] - currentDmg = applyDamage( - dmgMap=currentDmgData, - applicationMap=applicationMap, - tgtResists=tgt.getResists(), - tgtFullHp=tgt.getFullHp()).total - if currentTime < minTime: - continue - # First set of data points - if not xs: - # Start at exactly requested time, at last known value - initialDmg = prevDmg or 0 - xs.append(minTime) - ys.append(initialDmg) - # If current time is bigger then starting, extend plot to that time with old value - if currentTime > minTime: - xs.append(currentTime) + pyfalog.debug("DamageStats TimeGraph: time cache keys=%s", list(timeCache.keys())[:10]) + print("[DamageStats] TimeGraph cache keys (first 10): {}".format(list(timeCache.keys())[:10]), flush=True) + if timeCache: + print("[DamageStats] TimeGraph timeCache min={} max={} maxTime={}".format(min(timeCache), max(timeCache), maxTime), flush=True) + if not timeCache: + xs.append(minTime) + ys.append(0) + xs.append(maxTime) + ys.append(0) + return xs, ys + if min(timeCache) > maxTime: + pyfalog.debug("DamageStats TimeGraph: first damage time %.3fs beyond maxTime %.3fs", min(timeCache), maxTime) + print("[DamageStats] First damage time {:.3f}s beyond maxTime {:.3f}s".format(min(timeCache), maxTime), flush=True) + xs.append(minTime) + ys.append(0) + xs.append(maxTime) + ys.append(0) + return xs, ys + print("[DamageStats] TimeGraph passed cache checks", flush=True) + try: + applicationMap = self._prepareApplicationMap(miscParams=miscParams, src=src, tgt=tgt, timeMs=minTime * 1000) + # Custom iteration for time graph to show all data points + currentDmg = None + currentTime = None + maxDmg = 0 + sampleCount = 0 + print("[DamageStats] TimeGraph entering loop, points={}".format(len(timeCache)), flush=True) + for currentTime in sorted(timeCache): + prevDmg = currentDmg + currentDmgData = timeCache[currentTime] + applicationMap = self._prepareApplicationMap(miscParams=miscParams, src=src, tgt=tgt, timeMs=currentTime * 1000) + if sampleCount < 5: + missing_keys = [k for k in currentDmgData if k not in applicationMap] + if missing_keys: + print("[DamageStats] TimeGraph missing app keys: {} of {}".format(len(missing_keys), len(currentDmgData)), flush=True) + currentDmg = applyDamage( + dmgMap=currentDmgData, + applicationMap=applicationMap, + tgtResists=tgt.getResists(), + tgtFullHp=tgt.getFullHp()).total + if currentDmg > maxDmg: + maxDmg = currentDmg + if sampleCount < 5: + print("[DamageStats] TimeGraph sample t={:.3f}s dmg={}".format(currentTime, currentDmg), flush=True) + sampleCount += 1 + if currentTime < minTime: + continue + # First set of data points + if not xs: + # Start at exactly requested time, at last known value + initialDmg = prevDmg or 0 + xs.append(minTime) ys.append(initialDmg) - # If new value is different, extend it with new point to the new value + # If current time is bigger then starting, extend plot to that time with old value + if currentTime > minTime: + xs.append(currentTime) + ys.append(initialDmg) + # If new value is different, extend it with new point to the new value + if currentDmg != prevDmg: + xs.append(currentTime) + ys.append(currentDmg) + continue + # Last data point + if currentTime >= maxTime: + xs.append(maxTime) + ys.append(prevDmg if prevDmg is not None else 0) + break + # Anything in-between if currentDmg != prevDmg: + if prevDmg is not None: + xs.append(currentTime) + ys.append(prevDmg) xs.append(currentTime) ys.append(currentDmg) - continue - # Last data point - if currentTime >= maxTime: - xs.append(maxTime) - ys.append(prevDmg) - break - # Anything in-between - if currentDmg != prevDmg: - if prevDmg is not None: - xs.append(currentTime) - ys.append(prevDmg) - xs.append(currentTime) - ys.append(currentDmg) + except Exception as exc: + print("[DamageStats] TimeGraph exception: {}".format(exc), flush=True) + xs = [minTime, maxTime] + ys = [0, 0] + return xs, ys # Special case - there are no damage dealers if currentDmg is None and currentTime is None: xs.append(minTime) @@ -291,6 +333,10 @@ def getRange(self, xRange, miscParams, src, tgt): if maxTime > (currentTime or 0): xs.append(maxTime) ys.append(currentDmg or 0) + print("[DamageStats] TimeGraph exiting loop, max dmg observed: {}".format(maxDmg), flush=True) + print("[DamageStats] TimeGraph series length xs={} ys={} first=({},{}) last=({},{})".format( + len(xs), len(ys), xs[0] if xs else None, ys[0] if ys else None, + xs[-1] if xs else None, ys[-1] if ys else None), flush=True) return xs, ys def getPoint(self, x, miscParams, src, tgt): @@ -298,7 +344,7 @@ def getPoint(self, x, miscParams, src, tgt): # Prepare time cache and various data self._prepareTimeCache(src=src, maxTime=time) dmgData = self._getTimeCacheDataPoint(src=src, time=time) - applicationMap = self._prepareApplicationMap(miscParams=miscParams, src=src, tgt=tgt) + applicationMap = self._prepareApplicationMap(miscParams=miscParams, src=src, tgt=tgt, timeMs=time * 1000) y = applyDamage( dmgMap=dmgData, applicationMap=applicationMap, @@ -328,7 +374,8 @@ def _calculatePoint(self, x, miscParams, src, tgt, commonData): if commonData['applyProjected']: srcScramRange = getScramRange(src=src) tgtScrammables = getScrammables(tgt=tgt) - webMods, tpMods = self.graph._projectedCache.getProjModData(src) + timeMs = miscParams['time'] * 1000 if miscParams.get('time') is not None else None + webMods, tpMods = self.graph._projectedCache.getProjModData(src, timeMs=timeMs) webDrones, tpDrones = self.graph._projectedCache.getProjDroneData(src) webFighters, tpFighters = self.graph._projectedCache.getProjFighterData(src) tgtSpeed = getTackledSpeed( @@ -379,7 +426,8 @@ def _getCommonData(self, miscParams, src, tgt): if GraphSettings.getInstance().get('applyProjected'): srcScramRange = getScramRange(src=src) tgtScrammables = getScrammables(tgt=tgt) - webMods, tpMods = self.graph._projectedCache.getProjModData(src) + timeMs = miscParams['time'] * 1000 if miscParams.get('time') is not None else None + webMods, tpMods = self.graph._projectedCache.getProjModData(src, timeMs=timeMs) webDrones, tpDrones = self.graph._projectedCache.getProjDroneData(src) webFighters, tpFighters = self.graph._projectedCache.getProjFighterData(src) tgtSpeed = getTackledSpeed( diff --git a/graphs/data/fitMobility/getter.py b/graphs/data/fitMobility/getter.py index 49552abc94..46db4864da 100644 --- a/graphs/data/fitMobility/getter.py +++ b/graphs/data/fitMobility/getter.py @@ -27,15 +27,20 @@ class Time2DistanceGetter(SmoothPointGetter): def _getCommonData(self, miscParams, src, tgt): return { - 'maxSpeed': src.getMaxVelocity(), 'mass': src.item.ship.getModifiedItemAttr('mass'), 'agility': src.item.ship.getModifiedItemAttr('agility')} def _calculatePoint(self, x, miscParams, src, tgt, commonData): time = x - maxSpeed = commonData['maxSpeed'] - mass = commonData['mass'] - agility = commonData['agility'] + if getattr(src, 'isFit', False): + ignore_afflictors = src.item.getInactiveModulesAt(time * 1000) + maxSpeed = src.getMaxVelocity(ignoreAfflictors=ignore_afflictors) + mass = src.item.ship.getModifiedItemAttrExtended('mass', ignoreAfflictors=ignore_afflictors) + agility = src.item.ship.getModifiedItemAttrExtended('agility', ignoreAfflictors=ignore_afflictors) + else: + maxSpeed = src.getMaxVelocity() + mass = commonData['mass'] + agility = commonData['agility'] # Definite integral of: # https://wiki.eveuniversity.org/Acceleration#Mathematics_and_formulae distance_t = maxSpeed * time + (maxSpeed * agility * mass * math.exp((-time * 1000000) / (agility * mass)) / 1000000) @@ -48,15 +53,20 @@ class Time2SpeedGetter(SmoothPointGetter): def _getCommonData(self, miscParams, src, tgt): return { - 'maxSpeed': src.getMaxVelocity(), 'mass': src.item.ship.getModifiedItemAttr('mass'), 'agility': src.item.ship.getModifiedItemAttr('agility')} def _calculatePoint(self, x, miscParams, src, tgt, commonData): time = x - maxSpeed = commonData['maxSpeed'] - mass = commonData['mass'] - agility = commonData['agility'] + if getattr(src, 'isFit', False): + ignore_afflictors = src.item.getInactiveModulesAt(time * 1000) + maxSpeed = src.getMaxVelocity(ignoreAfflictors=ignore_afflictors) + mass = src.item.ship.getModifiedItemAttrExtended('mass', ignoreAfflictors=ignore_afflictors) + agility = src.item.ship.getModifiedItemAttrExtended('agility', ignoreAfflictors=ignore_afflictors) + else: + maxSpeed = src.getMaxVelocity() + mass = commonData['mass'] + agility = commonData['agility'] # https://wiki.eveuniversity.org/Acceleration#Mathematics_and_formulae speed = maxSpeed * (1 - math.exp((-time * 1000000) / (agility * mass))) return speed diff --git a/graphs/data/fitRemoteReps/cache.py b/graphs/data/fitRemoteReps/cache.py index eb7a41bfa4..e29562f9ed 100644 --- a/graphs/data/fitRemoteReps/cache.py +++ b/graphs/data/fitRemoteReps/cache.py @@ -164,7 +164,10 @@ def addRepAmount(rrKey, addedTime, addedRepAmount): for cycleTimeMs, inactiveTimeMs, isInactivityReload in cycleParams.iterCycles(): cyclesWithoutReload += 1 cycleRepAmounts = [] - repAmountParams = mod.getRepAmountParameters(spoolOptions=SpoolOptions(SpoolType.CYCLES, nonstopCycles, True)) + inactive_afflictors = src.item.getInactiveModulesAt(currentTime * 1000, exclude=(mod,)) + repAmountParams = mod.getRepAmountParameters( + spoolOptions=SpoolOptions(SpoolType.CYCLES, nonstopCycles, True), + ignoreAfflictors=inactive_afflictors) for repTimeMs, repAmount in repAmountParams.items(): # Loaded ancillary armor rep can keep running at less efficiency if we decide to not reload if isAncArmor and mod.charge and not ancReload and cyclesWithoutReload > cyclesUntilReload: diff --git a/graphs/data/fitShieldRegen/getter.py b/graphs/data/fitShieldRegen/getter.py index 71f19f7efd..4eed066cf2 100644 --- a/graphs/data/fitShieldRegen/getter.py +++ b/graphs/data/fitShieldRegen/getter.py @@ -32,9 +32,20 @@ def _getCommonData(self, miscParams, src, tgt): def _calculatePoint(self, x, miscParams, src, tgt, commonData): time = x + if getattr(src, 'isFit', False): + ignore_afflictors = src.item.getInactiveModulesAt(time * 1000) + maxShieldAmount = src.item.ship.getModifiedItemAttrExtended( + 'shieldCapacity', + ignoreAfflictors=ignore_afflictors) + shieldRegenTime = src.item.ship.getModifiedItemAttrExtended( + 'shieldRechargeRate', + ignoreAfflictors=ignore_afflictors) / 1000 + else: + maxShieldAmount = commonData['maxShieldAmount'] + shieldRegenTime = commonData['shieldRegenTime'] shieldAmount = calculateShieldAmount( - maxShieldAmount=commonData['maxShieldAmount'], - shieldRegenTime=commonData['shieldRegenTime'], + maxShieldAmount=maxShieldAmount, + shieldRegenTime=shieldRegenTime, shieldAmountT0=miscParams['shieldAmountT0'] or 0, time=time) return shieldAmount @@ -49,14 +60,25 @@ def _getCommonData(self, miscParams, src, tgt): def _calculatePoint(self, x, miscParams, src, tgt, commonData): time = x + if getattr(src, 'isFit', False): + ignore_afflictors = src.item.getInactiveModulesAt(time * 1000) + maxShieldAmount = src.item.ship.getModifiedItemAttrExtended( + 'shieldCapacity', + ignoreAfflictors=ignore_afflictors) + shieldRegenTime = src.item.ship.getModifiedItemAttrExtended( + 'shieldRechargeRate', + ignoreAfflictors=ignore_afflictors) / 1000 + else: + maxShieldAmount = commonData['maxShieldAmount'] + shieldRegenTime = commonData['shieldRegenTime'] shieldAmount = calculateShieldAmount( - maxShieldAmount=commonData['maxShieldAmount'], - shieldRegenTime=commonData['shieldRegenTime'], + maxShieldAmount=maxShieldAmount, + shieldRegenTime=shieldRegenTime, shieldAmountT0=miscParams['shieldAmountT0'] or 0, time=time) shieldRegen = calculateShieldRegen( - maxShieldAmount=commonData['maxShieldAmount'], - shieldRegenTime=commonData['shieldRegenTime'], + maxShieldAmount=maxShieldAmount, + shieldRegenTime=shieldRegenTime, currentShieldAmount=shieldAmount) return shieldRegen diff --git a/graphs/gui/canvasPanel.py b/graphs/gui/canvasPanel.py index 4c862f6001..6586097516 100644 --- a/graphs/gui/canvasPanel.py +++ b/graphs/gui/canvasPanel.py @@ -175,7 +175,7 @@ def draw(self, accurateMarks=True): except (KeyboardInterrupt, SystemExit): raise except Exception: - pyfalog.warning('Failed to plot "{}" vs "{}"'.format(source.name, '' if target is None else target.name)) + pyfalog.exception('Failed to plot "{}" vs "{}"'.format(source.name, '' if target is None else target.name)) self.canvas.draw() self.Refresh() return @@ -250,7 +250,7 @@ def addYMark(val): except (KeyboardInterrupt, SystemExit): raise except Exception: - pyfalog.warning('Failed to get X mark for "{}" vs "{}"'.format(source.name, '' if target is None else target.name)) + pyfalog.exception('Failed to get X mark for "{}" vs "{}"'.format(source.name, '' if target is None else target.name)) # Silently skip this mark, otherwise other marks and legend display will fail continue # Otherwise just do linear interpolation between two points diff --git a/gui/builtinViewColumns/pulse.py b/gui/builtinViewColumns/pulse.py new file mode 100644 index 0000000000..e6b8e30738 --- /dev/null +++ b/gui/builtinViewColumns/pulse.py @@ -0,0 +1,59 @@ +# ============================================================================= +# Copyright (C) 2010 Diego Duclos +# +# This file is part of pyfa. +# +# pyfa is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# pyfa is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with pyfa. If not, see . +# ============================================================================= + +# noinspection PyPackageRequirements +import wx + +from eos.saveddata.module import Module, Rack +from gui.utils.numberFormatter import formatAmount +from gui.viewColumn import ViewColumn + + +class Pulse(ViewColumn): + name = "Pulse" + + def __init__(self, fittingView, params): + ViewColumn.__init__(self, fittingView) + self.columnText = "Pulse" + self.resizable = False + self.size = 58 + self.maxsize = self.size + self.mask = wx.LIST_MASK_TEXT + + def getText(self, mod): + if isinstance(mod, Rack): + return "" + if isinstance(mod, Module) and not mod.isEmpty: + if mod.pulseInterval is None: + return "" + return formatAmount(mod.pulseInterval, prec=3, unitName="s") + return "" + + def getToolTip(self, mod): + if isinstance(mod, Module) and not mod.isEmpty: + if mod.pulseInterval is None: + return "Pulse disabled" + return "Pulse every {} seconds".format(formatAmount(mod.pulseInterval, prec=3)) + return None + + def getImageId(self, mod): + return -1 + + +Pulse.register() diff --git a/gui/builtinViews/fittingView.py b/gui/builtinViews/fittingView.py index 8df91591c7..28579c72f2 100644 --- a/gui/builtinViews/fittingView.py +++ b/gui/builtinViews/fittingView.py @@ -36,6 +36,7 @@ from gui.builtinMarketBrowser.events import ITEM_SELECTED from gui.builtinShipBrowser.events import EVT_FIT_SELECTED, FitSelected from gui.builtinViewColumns.state import State +from gui.builtinViewColumns.pulse import Pulse from gui.chrome_tabs import EVT_NOTEBOOK_PAGE_CHANGED from gui.contextMenu import ContextMenu from gui.utils.staticHelpers import DragDropHelper @@ -138,6 +139,7 @@ def OnData(self, x, y, t): class FittingView(d.Display): DEFAULT_COLS = ["State", + "Pulse", "Ammo Icon", "Base Icon", "Base Name", @@ -682,6 +684,26 @@ def click(self, event): clickedRow, _, col = self.HitTestSubItem(event.Position) + # handle pulse column clicks + if clickedRow != -1 and clickedRow not in self.blanks and col == self.getColIndex(Pulse): + selectedRows = [] + currentRow = self.GetFirstSelected() + + while currentRow != -1 and clickedRow not in self.blanks: + selectedRows.append(currentRow) + currentRow = self.GetNextSelected(currentRow) + + if clickedRow not in selectedRows: + try: + selectedMods = [self.mods[clickedRow]] + except IndexError: + return + else: + selectedMods = self.getSelectedMods() + + self._setPulseInterval(selectedMods) + return + # only do State column and ignore invalid rows if clickedRow != -1 and clickedRow not in self.blanks and col == self.getColIndex(State): selectedRows = [] @@ -733,6 +755,74 @@ def click(self, event): else: event.Skip() + def _setPulseInterval(self, selectedMods): + mods = [mod for mod in selectedMods if isinstance(mod, Module) and not mod.isEmpty] + if len(mods) == 0: + return + + raw_cycle_times = [mod.rawCycleTime for mod in mods] + if any(cycle_time == 0 for cycle_time in raw_cycle_times): + wx.MessageBox( + _t("Some selected modules do not have a cycle time."), + _t("Pulse Interval"), + wx.OK | wx.ICON_ERROR) + return + + min_cycle_seconds = max(raw_cycle_times) / 1000.0 + existing = mods[0].pulseInterval + if any(mod.pulseInterval != existing for mod in mods): + initial_value = "" + else: + initial_value = "" if existing is None else "{:.3f}".format(existing) + + message = _t("Pulse every (seconds). Minimum {min:.3f}, maximum 999. Leave blank to disable.").format( + min=min_cycle_seconds) + dlg = wx.TextEntryDialog(self, message, _t("Set Pulse Interval"), initial_value) + if dlg.ShowModal() != wx.ID_OK: + dlg.Destroy() + return + value_text = dlg.GetValue().strip() + dlg.Destroy() + + if value_text == "": + pulse_interval = None + else: + try: + pulse_interval = float(value_text) + except ValueError: + wx.MessageBox( + _t("Please enter a valid number of seconds."), + _t("Pulse Interval"), + wx.OK | wx.ICON_ERROR) + return + if pulse_interval < min_cycle_seconds: + wx.MessageBox( + _t("Pulse interval must be at least {min:.3f} seconds.").format(min=min_cycle_seconds), + _t("Pulse Interval"), + wx.OK | wx.ICON_ERROR) + return + if pulse_interval > 999: + wx.MessageBox( + _t("Pulse interval must be 999 seconds or less."), + _t("Pulse Interval"), + wx.OK | wx.ICON_ERROR) + return + + fitID = self.mainFrame.getActiveFit() + fit = Fit.getInstance().getFit(fitID) + positions = [] + for mod in mods: + if mod in fit.modules: + positions.append(fit.modules.index(mod)) + + if len(positions) == 0: + return + + self.mainFrame.command.Submit(cmd.GuiChangeLocalModulePulseIntervalCommand( + fitID=fitID, + positions=positions, + pulseInterval=pulse_interval)) + def slotColour(self, slot): if isDark(): return slotColourMapDark.get(slot) or self.GetBackgroundColour() diff --git a/gui/fitCommands/__init__.py b/gui/fitCommands/__init__.py index 78536054a0..84fdfb4c0f 100644 --- a/gui/fitCommands/__init__.py +++ b/gui/fitCommands/__init__.py @@ -48,6 +48,7 @@ from .gui.localModule.changeCharges import GuiChangeLocalModuleChargesCommand from .gui.localModule.changeMetas import GuiChangeLocalModuleMetasCommand from .gui.localModule.changeMutation import GuiChangeLocalModuleMutationCommand +from .gui.localModule.changePulseInterval import GuiChangeLocalModulePulseIntervalCommand from .gui.localModule.changeSpool import GuiChangeLocalModuleSpoolCommand from .gui.localModule.changeStates import GuiChangeLocalModuleStatesCommand from .gui.localModule.clone import GuiCloneLocalModuleCommand diff --git a/gui/fitCommands/calc/module/changePulseInterval.py b/gui/fitCommands/calc/module/changePulseInterval.py new file mode 100644 index 0000000000..db8840a833 --- /dev/null +++ b/gui/fitCommands/calc/module/changePulseInterval.py @@ -0,0 +1,41 @@ +import wx +from logbook import Logger + +from service.fit import Fit + + +pyfalog = Logger(__name__) + + +class CalcChangeLocalModulePulseIntervalCommand(wx.Command): + + def __init__(self, fitID, positions, pulseInterval): + wx.Command.__init__(self, True, 'Change Module Pulse Interval') + self.fitID = fitID + self.positions = positions + self.pulseInterval = pulseInterval + self.savedIntervals = {} + + def Do(self): + pyfalog.debug('Doing change of module pulse interval on fit {} positions {} to {}'.format( + self.fitID, self.positions, self.pulseInterval)) + fit = Fit.getInstance().getFit(self.fitID) + positions = [pos for pos in self.positions if not fit.modules[pos].isEmpty] + if len(positions) == 0: + return False + self.savedIntervals = {pos: fit.modules[pos].pulseInterval for pos in positions} + changed = False + for position in positions: + mod = fit.modules[position] + if mod.pulseInterval != self.pulseInterval: + mod.pulseInterval = self.pulseInterval + changed = True + return changed + + def Undo(self): + pyfalog.debug('Undoing change of module pulse interval on fit {} positions {} to {}'.format( + self.fitID, self.positions, self.pulseInterval)) + fit = Fit.getInstance().getFit(self.fitID) + for position, interval in self.savedIntervals.items(): + fit.modules[position].pulseInterval = interval + return True diff --git a/gui/fitCommands/gui/localModule/changePulseInterval.py b/gui/fitCommands/gui/localModule/changePulseInterval.py new file mode 100644 index 0000000000..f4aff9d1de --- /dev/null +++ b/gui/fitCommands/gui/localModule/changePulseInterval.py @@ -0,0 +1,42 @@ +import wx + +import eos.db +import gui.mainFrame +from gui import globalEvents as GE +from gui.fitCommands.calc.module.changePulseInterval import CalcChangeLocalModulePulseIntervalCommand +from gui.fitCommands.helpers import InternalCommandHistory +from service.fit import Fit + + +class GuiChangeLocalModulePulseIntervalCommand(wx.Command): + + def __init__(self, fitID, positions, pulseInterval): + wx.Command.__init__(self, True, 'Change Local Module Pulse Interval') + self.internalHistory = InternalCommandHistory() + self.fitID = fitID + self.positions = positions + self.pulseInterval = pulseInterval + + def Do(self): + cmd = CalcChangeLocalModulePulseIntervalCommand( + fitID=self.fitID, + positions=self.positions, + pulseInterval=self.pulseInterval) + success = self.internalHistory.submit(cmd) + eos.db.flush() + sFit = Fit.getInstance() + sFit.recalc(self.fitID) + sFit.fill(self.fitID) + eos.db.commit() + wx.PostEvent(gui.mainFrame.MainFrame.getInstance(), GE.FitChanged(fitIDs=(self.fitID,))) + return success + + def Undo(self): + success = self.internalHistory.undoAll() + eos.db.flush() + sFit = Fit.getInstance() + sFit.recalc(self.fitID) + sFit.fill(self.fitID) + eos.db.commit() + wx.PostEvent(gui.mainFrame.MainFrame.getInstance(), GE.FitChanged(fitIDs=(self.fitID,))) + return success diff --git a/gui/viewColumn.py b/gui/viewColumn.py index 0c904fa278..80692f2f4c 100644 --- a/gui/viewColumn.py +++ b/gui/viewColumn.py @@ -88,6 +88,7 @@ def delayedText(self, display, colItem): price, projectionRange, propertyDisplay, + pulse, state, sideEffects, targetResists) diff --git a/service/fit.py b/service/fit.py index 4e3a1aa7bc..ac94461e0c 100644 --- a/service/fit.py +++ b/service/fit.py @@ -269,10 +269,11 @@ def toggleFactorReload(self, value=None): for fit in set(self._loadedFits): if fit is None: continue - if fit.calculated: - fit.factorReload = self.serviceFittingOptions['useGlobalForceReload'] - fit.clearFactorReloadDependentData() - fitIDs.add(fit.ID) + if fit.isInvalid: + continue + fit.factorReload = self.serviceFittingOptions['useGlobalForceReload'] + self.recalc(fit) + fitIDs.add(fit.ID) return fitIDs def processOverrideToggle(self): @@ -336,6 +337,7 @@ def getFit(self, fitID, projected=False, basic=False): return None self._loadedFits.add(fit) + fit.factorReload = self.serviceFittingOptions["useGlobalForceReload"] if basic: return fit diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000..8f2430f69b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,16 @@ +import sys +import os +import pytest + +# Ensure we modify sys environment for tests +sys._called_from_test = True + +# Add root folder to python paths +script_dir = os.path.dirname(os.path.abspath(__file__)) +sys.path.append(os.path.realpath(os.path.join(script_dir, '..'))) + +# Import fixtures from helpers +# DBInMemory is nicknamed DB in tests usually +from _development.helpers import DBInMemory as DB, Gamedata, Saveddata +from _development.helpers_fits import RifterFit, KeepstarFit, CurseFit, HeronFit +from _development.helpers_items import StrongBluePillBooster diff --git a/tests/test_modules/test_eos/test_modifiedAttributeDict.py b/tests/test_modules/test_eos/test_modifiedAttributeDict.py index 71bc5d892d..c4dac52de7 100644 --- a/tests/test_modules/test_eos/test_modifiedAttributeDict.py +++ b/tests/test_modules/test_eos/test_modifiedAttributeDict.py @@ -19,7 +19,7 @@ def test_multiply_stacking_penalties(DB, Saveddata, RifterFit): RifterFit.character = char0 starting_em_resist = RifterFit.ship.getModifiedItemAttr("shieldEmDamageResonance") - mod = Saveddata['Module'](DB['db'].getItem("EM Ward Amplifier II")) + mod = Saveddata['Module'](DB['db'].getItem("EM Shield Amplifier II")) item_modifer = mod.item.getAttribute("emDamageResistanceBonus") RifterFit.calculateModifiedAttributes() diff --git a/tests/test_modules/test_eos/test_saveddata/test_pulse.py b/tests/test_modules/test_eos/test_saveddata/test_pulse.py new file mode 100644 index 0000000000..a4696344de --- /dev/null +++ b/tests/test_modules/test_eos/test_saveddata/test_pulse.py @@ -0,0 +1,131 @@ +import os +import sys +import mock +from unittest.mock import MagicMock + +# Add root folder to python paths +script_dir = os.path.dirname(os.path.abspath(__file__)) +sys.path.append(os.path.realpath(os.path.join(script_dir, '..', '..', '..', '..'))) + +from eos.effects import Effect7166 +from eos.const import FittingModuleState + +def test_pulse_inactive_afflictors(RifterFit): + """ + Test that getPulseInactiveAfflictorsAt correctly identifies inactive modules + during the wait period of a pulse cycle. + """ + fit = RifterFit + + # Get a module (e.g. 200mm Steel Plates, though that's passive. Let's find an active one or add one) + # RifterFit usually comes with some modules. Let's assume we can add one. + + # We will mock a module for simplicity of testing strictly the time logic + # or we can use a real module if we knew the ID. + # Let's inspect RifterFit modules in the test? No, let's just make a mock module inside the fit. + + # Create a mock module structure + mod = MagicMock() + mod.state = FittingModuleState.ACTIVE + mod.pulseInterval = 10.0 # Pulse every 10 seconds + mod.getModifiedItemAttr = MagicMock(return_value=5000.0) # 5s duration + + # Set properties used by getPulseInactiveAfflictorsAt + mod.rawCycleTime = 5000 + mod.pulseAdjustedCycleTime = 10000 + + # The method calls self.getModifiedItemAttr('duration') + # wait, getPulseInactiveAfflictorsAt calls: + # duration = mod.getModifiedItemAttr('duration') + # which returns milliseconds? usually traits are in ms. + + # Let's verify unit of duration in fit.py + # "cycleTime = mod.getModifiedItemAttr('duration')" + + mod.getModifiedItemAttr.side_effect = lambda x: 5000.0 if x == 'duration' else 0 + mod.fit = fit + + # Replace fit modules with our mock list for this test + # We patch the property 'modules' on the Fit class to return our list. + with mock.patch('eos.saveddata.fit.Fit.modules', new_callable=mock.PropertyMock) as mock_modules: + mock_modules.return_value = [mod] + + # Case 1: Time = 1s (Inside Cycle) + # 1000 % 10000 = 1000. 1000 < 5000. Active. + inactive = fit.getPulseInactiveAfflictorsAt(1000) + assert mod not in inactive + + # Case 2: Time = 6s (Wait Phase) + # 6000 % 10000 = 6000. 6000 > 5000. Inactive. + inactive = fit.getPulseInactiveAfflictorsAt(6000) + assert mod in inactive + + # Case 3: Time = 11s (Next Cycle) + # 11000 % 10000 = 1000. 1000 < 5000. Active. + inactive = fit.getPulseInactiveAfflictorsAt(11000) + assert mod not in inactive + + +def test_projected_spool_reset_logic(): + """ + Test that Effect7166 (Mutadaptive Remote Armor Repairer) forces SpoolAmount to 0 + when the source module has a pulse interval set (and gap exists). + """ + + # Mock Fit and Ship + mock_fit = MagicMock() + mock_fit.ship.getModifiedItemAttr.return_value = False # disallowAssistance = False + mock_fit._armorRr = [] + mock_fit._armorRrPreSpool = [] + mock_fit._armorRrFullSpool = [] + + # Mock Container (The Module) + mock_container = MagicMock() + mock_container.getModifiedItemAttr.side_effect = lambda x: { + 'armorDamageAmount': 100, + 'maxRange': 10000, + 'falloffEffectiveness': 1000, + 'duration': 5000, # 5s + 'repairMultiplierBonusMax': 1.0, # 100% bonus + 'repairMultiplierBonusPerCycle': 0.1 # 10% per cycle + }.get(x, 0) + + # Mock Context + context = {'projected': True} + + # Mock Config + # Also mock resolveSpoolOptions to return a known high spool amount (1.0 = 100% bonus) + # This isolates the test from user config/defaults/options logic. + from eos.utils.spoolSupport import SpoolType + with mock.patch('eos.config.settings', {'globalDefaultSpoolupPercentage': 1.0}), \ + mock.patch('eos.effects.resolveSpoolOptions', return_value=(SpoolType.SPOOL_SCALE, 1.0)): + + # Scenario 1: No Pulsing + # Spool should default to Max (1.0) because we mocked resolveSpoolOptions + mock_container.pulseInterval = None + Effect7166.handler(mock_fit, mock_container, context, 5000) + + # Resolution should be full spool + # Base 100 * (1 + 1.0) = 200 + result_amount = mock_fit._armorRr[-1][0] + # Check tolerance (float math) + assert abs(result_amount - 200.0) < 0.1 + + # Scenario 2: Pulsing Active (With Gap) + # Pulse 10s (Duration 5s). Gap = 5s. Spool should reset to 0. + mock_container.pulseInterval = 10.0 + Effect7166.handler(mock_fit, mock_container, context, 5000) + + # Resolution should be 0 spool + # Base 100 * (1 + 0) = 100 + result_amount = mock_fit._armorRr[-1][0] + assert abs(result_amount - 100.0) < 0.1 + + # Scenario 3: Pulsing Active (No Gap / Constant) + # Pulse 5s (Duration 5s). Gap = 0s. Spool should NOT reset. + mock_container.pulseInterval = 5.0 + Effect7166.handler(mock_fit, mock_container, context, 5000) + + # Resolution should be full spool + result_amount = mock_fit._armorRr[-1][0] + assert abs(result_amount - 200.0) < 0.1 diff --git a/tests/test_modules/test_eos/test_utils/test_stats.py b/tests/test_modules/test_eos/test_utils/test_stats.py index dc81adccc1..0e196afc04 100644 --- a/tests/test_modules/test_eos/test_utils/test_stats.py +++ b/tests/test_modules/test_eos/test_utils/test_stats.py @@ -22,7 +22,7 @@ def test_dmgtypes_names(): def test_dmgtypes__repr(setup_damage_types): - assert setup_damage_types.__repr__() == '' + assert setup_damage_types.__repr__() == '' def test_dmgtypes_names_lambda(): diff --git a/tests/test_unread_desc.py b/tests/test_unread_desc.py index beb08001a5..f15f5ac445 100644 --- a/tests/test_unread_desc.py +++ b/tests/test_unread_desc.py @@ -17,7 +17,7 @@ # This import is here to hack around circular import issues import gui.mainFrame # noinspection PyPep8 -from service.port import Port, IPortUser +from service.port import Port """ NOTE: @@ -47,11 +47,22 @@ py.test --cov=./ --cov-report=html """ -class PortUser(IPortUser): +class PortUser: + + def __init__(self): + self.userCancelled = False + self.message = "" + self.error = None + self.current = 0 + self.workerWorking = False + self.cbArgs = [] def on_port_processing(self, action, data=None): print(data) return True + + def on_port_process_start(self): + pass #stpw = Stopwatch('test measurementer') @@ -74,9 +85,8 @@ def test_import_xml(print_db_info): xml_file = "jeffy_ja-en[99].xml" fit_count = int(re.search(r"\[(\d+)\]", xml_file).group(1)) fits = None - with open(os.path.join(script_dir, xml_file), "r") as file_: + with open(os.path.join(script_dir, xml_file), "r", encoding="utf-8") as file_: srcString = file_.read() - srcString = str(srcString, "utf-8") # (basestring, IPortUser, basestring) -> list[eos.saveddata.fit.Fit] usr.on_port_process_start() #stpw.reset()