# =============================================================================== # 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 logbook import Logger from sqlalchemy.orm import reconstructor, validates import eos.db from eos.const import FittingSlot from eos.effectHandlerHelpers import HandledCharge, HandledItem from eos.modifiedAttributeDict import ChargeAttrShortcut, ItemAttrShortcut, ModifiedAttributeDict from eos.saveddata.fighterAbility import FighterAbility from eos.utils.cycles import CycleInfo, CycleSequence from eos.utils.default import DEFAULT from eos.utils.float import floatUnerr from eos.utils.stats import DmgTypes pyfalog = Logger(__name__) class Fighter(HandledItem, HandledCharge, ItemAttrShortcut, ChargeAttrShortcut): DAMAGE_TYPES = ("em", "kinetic", "explosive", "thermal") DAMAGE_TYPES2 = ("EM", "Kin", "Exp", "Therm") def __init__(self, item): """Initialize a fighter from the program""" self.__item = item if self.isInvalid: raise ValueError("Passed item is not a Fighter") self.itemID = item.ID if item is not None else None self.projected = False self.projectionRange = None self.active = True # -1 is a placeholder that represents max squadron size, which we may not know yet as ships may modify this with # their effects. If user changes this, it is then overridden with user value. self._amount = -1 self.__abilities = self.__getAbilities() self.build() standardAttackActive = False for ability in self.abilities: if ability.effect.isImplemented and ability.effect.name == 'fighterAbilityAttackM': # Activate "standard attack" if available ability.active = True standardAttackActive = True else: # Activate all other abilities (Neut, Web, etc) except propmods if no standard attack is active if ability.effect.isImplemented and \ standardAttackActive is False and \ ability.effect.name != 'fighterAbilityMicroWarpDrive' and \ ability.effect.name != 'fighterAbilityEvasiveManeuvers': ability.active = True @reconstructor def init(self): """Initialize a fighter from the database and validate""" self.__item = None if self.itemID: self.__item = eos.db.getItem(self.itemID) if self.__item is None: pyfalog.error("Item (id: {0}) does not exist", self.itemID) return if self.isInvalid: pyfalog.error("Item (id: {0}) is not a Fighter", self.itemID) return self.build() def build(self): """ Build object. Assumes proper and valid item already set """ self.__charge = None self.__baseVolley = None self.__miningyield = None self.__itemModifiedAttributes = ModifiedAttributeDict() self.__chargeModifiedAttributes = ModifiedAttributeDict() if {a.effectID for a in self.abilities} != {e.ID for e in self.item.effects.values()}: self.__abilities = [] for ability in self.__getAbilities(): self.__abilities.append(ability) if self.__item: self.__itemModifiedAttributes.original = self.__item.attributes self.__itemModifiedAttributes.overrides = self.__item.overrides self.__slot = self.__calculateSlot(self.__item) chargeID = self.getModifiedItemAttr("fighterAbilityLaunchBombType") if chargeID: charge = eos.db.getItem(int(chargeID)) self.__charge = charge self.__chargeModifiedAttributes.original = charge.attributes self.__chargeModifiedAttributes.overrides = charge.overrides def __getAbilities(self): """Returns list of FighterAbilities that are loaded with data""" return [FighterAbility(effect) for effect in list(self.item.effects.values())] def __calculateSlot(self, item): types = { "Light" : FittingSlot.F_LIGHT, "Support": FittingSlot.F_SUPPORT, "Heavy" : FittingSlot.F_HEAVY, "StandupLight": FittingSlot.FS_LIGHT, "StandupSupport": FittingSlot.FS_SUPPORT, "StandupHeavy": FittingSlot.FS_HEAVY } for t, slot in types.items(): if self.getModifiedItemAttr("fighterSquadronIs{}".format(t)): return slot @property def slot(self): return self.__slot @property def amount(self): return int(self.getModifiedItemAttr("fighterSquadronMaxSize")) if self._amount == -1 else self._amount @amount.setter def amount(self, amount): amount = max(0, int(amount)) if amount >= self.getModifiedItemAttr("fighterSquadronMaxSize"): amount = -1 self._amount = amount @property def fighterSquadronMaxSize(self): return int(self.getModifiedItemAttr("fighterSquadronMaxSize")) @property def abilities(self): return self.__abilities or [] @property def charge(self): return self.__charge @property def itemModifiedAttributes(self): return self.__itemModifiedAttributes @property def chargeModifiedAttributes(self): return self.__chargeModifiedAttributes @property def isInvalid(self): return self.__item is None or self.__item.category.name != "Fighter" @property def item(self): return self.__item @property def hasAmmo(self): return self.charge is not None def isDealingDamage(self): volleyParams = self.getVolleyParametersPerEffect() for effectData in volleyParams.values(): for volley in effectData.values(): if volley.total > 0: return True return False def getVolleyParametersPerEffect(self, targetProfile=None): if not self.active or self.amount <= 0: return {} if self.__baseVolley is None: self.__baseVolley = {} for ability in self.abilities: # Not passing resists here as we want to calculate and store base volley self.__baseVolley[ability.effectID] = {0: ability.getVolley()} adjustedVolley = {} for effectID, effectData in self.__baseVolley.items(): adjustedVolley[effectID] = {} for volleyTime, volleyValue in effectData.items(): adjustedVolley[effectID][volleyTime] = DmgTypes( em=volleyValue.em * (1 - getattr(targetProfile, "emAmount", 0)), thermal=volleyValue.thermal * (1 - getattr(targetProfile, "thermalAmount", 0)), kinetic=volleyValue.kinetic * (1 - getattr(targetProfile, "kineticAmount", 0)), explosive=volleyValue.explosive * (1 - getattr(targetProfile, "explosiveAmount", 0))) return adjustedVolley def getVolleyPerEffect(self, targetProfile=None): volleyParams = self.getVolleyParametersPerEffect(targetProfile=targetProfile) volleyMap = {} for effectID, volleyData in volleyParams.items(): volleyMap[effectID] = volleyData[0] return volleyMap def getVolley(self, targetProfile=None): volleyParams = self.getVolleyParametersPerEffect(targetProfile=targetProfile) em = 0 therm = 0 kin = 0 exp = 0 for volleyData in volleyParams.values(): em += volleyData[0].em therm += volleyData[0].thermal kin += volleyData[0].kinetic exp += volleyData[0].explosive return DmgTypes(em, therm, kin, exp) def getDps(self, targetProfile=None): em = 0 thermal = 0 kinetic = 0 explosive = 0 for dps in self.getDpsPerEffect(targetProfile=targetProfile).values(): em += dps.em thermal += dps.thermal kinetic += dps.kinetic explosive += dps.explosive return DmgTypes(em=em, thermal=thermal, kinetic=kinetic, explosive=explosive) def getDpsPerEffect(self, targetProfile=None): if not self.active or self.amount <= 0: return {} cycleParams = self.getCycleParametersPerEffectOptimizedDps(targetProfile=targetProfile) dpsMap = {} for ability in self.abilities: if ability.effectID in cycleParams: cycleTime = cycleParams[ability.effectID].averageTime dpsMap[ability.effectID] = ability.getDps(targetProfile=targetProfile, cycleTimeOverride=cycleTime) return dpsMap def getCycleParametersPerEffectOptimizedDps(self, targetProfile=None, reloadOverride=None): cycleParamsInfinite = self.getCycleParametersPerEffectInfinite() cycleParamsReload = self.getCycleParametersPerEffect(reloadOverride=reloadOverride) dpsMapOnlyInfinite = {} dpsMapAllWithReloads = {} # Decide if it's better to keep steady dps up and never reload or reload from time to time for ability in self.abilities: if ability.effectID in cycleParamsInfinite: cycleTime = cycleParamsInfinite[ability.effectID].averageTime dpsMapOnlyInfinite[ability.effectID] = ability.getDps(targetProfile=targetProfile, cycleTimeOverride=cycleTime) if ability.effectID in cycleParamsReload: cycleTime = cycleParamsReload[ability.effectID].averageTime dpsMapAllWithReloads[ability.effectID] = ability.getDps(targetProfile=targetProfile, cycleTimeOverride=cycleTime) totalOnlyInfinite = sum(i.total for i in dpsMapOnlyInfinite.values()) totalAllWithReloads = sum(i.total for i in dpsMapAllWithReloads.values()) return cycleParamsInfinite if totalOnlyInfinite >= totalAllWithReloads else cycleParamsReload def getCycleParametersPerEffectInfinite(self): return { a.effectID: CycleInfo(a.cycleTime, 0, math.inf, False) for a in self.abilities if a.numShots == 0 and a.cycleTime > 0} def getCycleParametersPerEffect(self, reloadOverride=None): factorReload = reloadOverride if reloadOverride is not None else self.owner.factorReload # Assume it can cycle infinitely if not factorReload: return {a.effectID: CycleInfo(a.cycleTime, 0, math.inf, False) for a in self.abilities if a.cycleTime > 0} limitedAbilities = [a for a in self.abilities if a.numShots > 0 and a.cycleTime > 0] if len(limitedAbilities) == 0: return {a.effectID: CycleInfo(a.cycleTime, 0, math.inf, False) for a in self.abilities if a.cycleTime > 0} validAbilities = [a for a in self.abilities if a.cycleTime > 0] if len(validAbilities) == 0: return {} mostLimitedAbility = min(limitedAbilities, key=lambda a: a.cycleTime * a.numShots) durationToRefuel = mostLimitedAbility.cycleTime * mostLimitedAbility.numShots # find out how many shots various abilities will do until reload, and how much time # "extra" cycle will last (None for no extra cycle) cyclesUntilRefuel = {mostLimitedAbility.effectID: (mostLimitedAbility.numShots, None)} for ability in (a for a in validAbilities if a is not mostLimitedAbility): fullCycles = int(floatUnerr(durationToRefuel / ability.cycleTime)) extraShotTime = floatUnerr(durationToRefuel - (fullCycles * ability.cycleTime)) if extraShotTime == 0: extraShotTime = None cyclesUntilRefuel[ability.effectID] = (fullCycles, extraShotTime) refuelTimes = {} for ability in validAbilities: spentShots, extraShotTime = cyclesUntilRefuel[ability.effectID] if extraShotTime is not None: spentShots += 1 refuelTimes[ability.effectID] = ability.getReloadTime(spentShots) refuelTime = max(refuelTimes.values()) cycleParams = {} for ability in validAbilities: regularShots, extraShotTime = cyclesUntilRefuel[ability.effectID] sequence = [] if extraShotTime is not None: if regularShots > 0: sequence.append(CycleInfo(ability.cycleTime, 0, regularShots, False)) sequence.append(CycleInfo(extraShotTime, refuelTime, 1, True)) else: regularShotsNonReload = regularShots - 1 if regularShotsNonReload > 0: sequence.append(CycleInfo(ability.cycleTime, 0, regularShotsNonReload, False)) sequence.append(CycleInfo(ability.cycleTime, refuelTime, 1, True)) cycleParams[ability.effectID] = CycleSequence(sequence, math.inf) return cycleParams @property def maxRange(self): attrs = ("shieldTransferRange", "powerTransferRange", "energyDestabilizationRange", "empFieldRange", "ecmBurstRange", "maxRange") for attr in attrs: maxRange = self.getModifiedItemAttr(attr, None) if maxRange is not None: return maxRange if self.charge is not None: delay = self.getModifiedChargeAttr("explosionDelay", None) speed = self.getModifiedChargeAttr("maxVelocity", None) if delay is not None and speed is not None: return delay / 1000.0 * speed # Had to add this to match the falloff property in modules.py # Fscking ship scanners. If you find any other falloff attributes, # Put them in the attrs tuple. @property def falloff(self): attrs = ("falloff", "falloffEffectiveness") for attr in attrs: falloff = self.getModifiedItemAttr(attr, None) if falloff is not None: return falloff @validates("ID", "itemID", "chargeID", "amount") def validator(self, key, val): map = { "ID" : lambda _val: isinstance(_val, int), "itemID" : lambda _val: isinstance(_val, int), "chargeID": lambda _val: isinstance(_val, int), "amount" : lambda _val: isinstance(_val, int) and _val >= -1, } if not map[key](val): raise ValueError(str(val) + " is not a valid value for " + key) else: return val def clear(self): self.__baseVolley = None self.__miningyield = None self.itemModifiedAttributes.clear() self.chargeModifiedAttributes.clear() [x.clear() for x in self.abilities] def canBeApplied(self, projectedOnto): """Check if fighter can engage specific fitting""" item = self.item # Do not allow to apply offensive modules on ship with offensive module immunite, with few exceptions # (all effects which apply instant modification are exception, generally speaking) if item.offensive and projectedOnto.ship.getModifiedItemAttr("disallowOffensiveModifiers") == 1: offensiveNonModifiers = {"energyDestabilizationNew", "leech", "energyNosferatuFalloff", "energyNeutralizerFalloff"} if not offensiveNonModifiers.intersection(set(item.effects)): return False # If assistive modules are not allowed, do not let to apply these altogether if item.assistive and projectedOnto.ship.getModifiedItemAttr("disallowAssistance") == 1: return False else: return True def calculateModifiedAttributes(self, fit, runTime, forceProjected=False, forcedProjRange=DEFAULT): if not self.active: return if self.projected or forceProjected: context = "projected", "fighter" projected = True else: context = ("fighter",) projected = False projectionRange = self.projectionRange if forcedProjRange is DEFAULT else forcedProjRange for ability in self.abilities: if not ability.active: continue effect = ability.effect if effect.runTime == runTime and effect.activeByDefault and \ ((projected and effect.isType("projected")) or not projected): if ability.grouped: effect.handler(fit, self, context, projectionRange, effect=effect) else: i = 0 while i != self.amount: effect.handler(fit, self, context, projectionRange, effect=effect) i += 1 def __deepcopy__(self, memo): copy = Fighter(self.item) copy._amount = self._amount copy.active = self.active for ability in self.abilities: copyAbility = next(filter(lambda a: a.effectID == ability.effectID, copy.abilities)) copyAbility.active = ability.active copy.projectionRange = self.projectionRange return copy def rebase(self, item): amount = self._amount active = self.active abilityEffectStates = {a.effectID: a.active for a in self.abilities} projectionRange = self.projectionRange Fighter.__init__(self, item) self._amount = amount self.active = active for ability in self.abilities: if ability.effectID in abilityEffectStates: ability.active = abilityEffectStates[ability.effectID] self.projectionRange = projectionRange def fits(self, fit): # If ships doesn't support this type of fighter, don't add it if fit.getNumSlots(self.slot) == 0: return False return True def canDealDamage(self, ignoreState=False, ignoreAbilityState=False): if self.item is None: return False if not self.active and not ignoreState: return False for ability in self.abilities: if not ability.active and not ignoreAbilityState: continue if ability.effect.dealsDamage: return True return False