diff --git a/eos/effects.py b/eos/effects.py index 6c0ff2d10..9cc969ca3 100644 --- a/eos/effects.py +++ b/eos/effects.py @@ -26,6 +26,8 @@ from eos.utils.spoolSupport import SpoolType, SpoolOptions, calculateSpoolup, re class BaseEffect: + dealsDamage = False + @staticmethod def handler(fit, module, context, projectionRange, **kwargs): pass @@ -62,6 +64,7 @@ class Effect10(BaseEffect): Modules from group: Energy Weapon (212 of 214) """ + dealsDamage = True type = 'active' @staticmethod @@ -169,6 +172,7 @@ class Effect34(BaseEffect): Modules from group: Projectile Weapon (165 of 165) """ + dealsDamage = True type = 'active' @staticmethod @@ -189,6 +193,7 @@ class Effect38(BaseEffect): Modules from group: Smart Bomb (118 of 118) """ + dealsDamage = True type = 'active' @@ -555,6 +560,7 @@ class Effect101(BaseEffect): Structure Modules named like: Standup Launcher (7 of 7) """ + dealsDamage = True type = 'active', 'projected' @staticmethod @@ -15124,6 +15130,7 @@ class Effect4489(BaseEffect): Module: 'Judgment' Electromagnetic Doomsday """ + dealsDamage = True type = 'active' @staticmethod @@ -15140,6 +15147,7 @@ class Effect4490(BaseEffect): Module: 'Oblivion' Kinetic Doomsday """ + dealsDamage = True type = 'active' @staticmethod @@ -15156,6 +15164,7 @@ class Effect4491(BaseEffect): Module: 'Aurora Ominae' Thermal Doomsday """ + dealsDamage = True type = 'active' @staticmethod @@ -15172,6 +15181,7 @@ class Effect4492(BaseEffect): Module: 'Gjallarhorn' Explosive Doomsday """ + dealsDamage = True type = 'active' @staticmethod @@ -27364,6 +27374,7 @@ class Effect6431(BaseEffect): Fighters from group: Light Fighter (32 of 32) """ + dealsDamage = True displayName = 'Missile Attack' hasCharges = True prefix = 'fighterAbilityMissiles' @@ -27644,6 +27655,7 @@ class Effect6465(BaseEffect): Fighters from group: Heavy Fighter (34 of 34) """ + dealsDamage = True displayName = 'Turret Attack' prefix = 'fighterAbilityAttackMissile' type = 'active' @@ -27686,6 +27698,7 @@ class Effect6472(BaseEffect): Modules named like: Lance (4 of 4) """ + dealsDamage = True type = 'active' @staticmethod @@ -27702,6 +27715,7 @@ class Effect6473(BaseEffect): Module: Bosonic Field Generator """ + dealsDamage = True type = 'active' @staticmethod @@ -27912,6 +27926,7 @@ class Effect6485(BaseEffect): Fighters from group: Heavy Fighter (16 of 34) """ + dealsDamage = True displayName = 'Bomb' hasCharges = True prefix = 'fighterAbilityLaunchBomb' @@ -33964,6 +33979,7 @@ class Effect6995(BaseEffect): Modules from group: Precursor Weapon (19 of 19) """ + dealsDamage = True type = 'active' @staticmethod diff --git a/eos/gamedata.py b/eos/gamedata.py index 03ec7d367..737b58d48 100644 --- a/eos/gamedata.py +++ b/eos/gamedata.py @@ -146,6 +146,12 @@ class Effect(EqBase): return self.__effectDef is not None + @property + def dealsDamage(self): + if not self.__generated: + self.__generateHandler() + return self.__dealsDamage + def isType(self, type): """ Check if this effect is of the passed type @@ -167,6 +173,7 @@ class Effect(EqBase): self.__handler = getattr(effectDef, "handler", eos.effects.BaseEffect.handler) self.__runTime = getattr(effectDef, "runTime", "normal") self.__activeByDefault = getattr(effectDef, "activeByDefault", True) + self.__dealsDamage = effectDef.dealsDamage effectType = getattr(effectDef, "type", None) effectType = effectType if isinstance(effectType, tuple) or effectType is None else (effectType,) self.__type = effectType @@ -175,6 +182,7 @@ class Effect(EqBase): self.__handler = eos.effects.DummyEffect.handler self.__runTime = "normal" self.__activeByDefault = True + self.__dealsDamage = False self.__type = None pyfalog.debug("ImportError generating handler: {0}", e) except AttributeError as e: @@ -182,6 +190,7 @@ class Effect(EqBase): self.__handler = eos.effects.DummyEffect.handler self.__runTime = "normal" self.__activeByDefault = True + self.__dealsDamage = False self.__type = None pyfalog.error("AttributeError generating handler: {0}", e) except (KeyboardInterrupt, SystemExit): @@ -190,6 +199,7 @@ class Effect(EqBase): self.__handler = eos.effects.DummyEffect.handler self.__runTime = "normal" self.__activeByDefault = True + self.__dealsDamage = False self.__type = None pyfalog.critical("Exception generating handler:") pyfalog.critical(e) diff --git a/eos/saveddata/drone.py b/eos/saveddata/drone.py index 5dbfdfb5d..7c54b7920 100644 --- a/eos/saveddata/drone.py +++ b/eos/saveddata/drone.py @@ -364,3 +364,11 @@ class Drone(HandledItem, HandledCharge, ItemAttrShortcut, ChargeAttrShortcut): if self.item.groupID in fitDroneGroupLimits: return True return False + + def canDealDamage(self, ignoreState=False): + if self.item is None: + return False + for effect in self.item.effects.values(): + if effect.dealsDamage and (ignoreState or self.amountActive > 0): + return True + return False diff --git a/eos/saveddata/fighter.py b/eos/saveddata/fighter.py index c2e46b5b6..2192aaeea 100644 --- a/eos/saveddata/fighter.py +++ b/eos/saveddata/fighter.py @@ -441,3 +441,15 @@ class Fighter(HandledItem, HandledCharge, ItemAttrShortcut, ChargeAttrShortcut): 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 diff --git a/eos/saveddata/module.py b/eos/saveddata/module.py index 85476fac3..f3bb4f208 100644 --- a/eos/saveddata/module.py +++ b/eos/saveddata/module.py @@ -461,6 +461,20 @@ class Module(HandledItem, HandledCharge, ItemAttrShortcut, ChargeAttrShortcut): return True return False + def canDealDamage(self, ignoreState=False): + if self.isEmpty: + return False + for effect in self.item.effects.values(): + if effect.dealsDamage and ( + ignoreState or + effect.isType('offline') or + (effect.isType('passive') and self.state >= FittingModuleState.ONLINE) or + (effect.isType('active') and self.state >= FittingModuleState.ACTIVE) or + (effect.isType('overheat') and self.state >= FittingModuleState.OVERHEATED) + ): + return True + return False + def getVolleyParameters(self, spoolOptions=None, targetProfile=None, ignoreState=False): if self.isEmpty or (self.state < FittingModuleState.ACTIVE and not ignoreState): return {0: DmgTypes(0, 0, 0, 0)} diff --git a/graphs/gui/frame.py b/graphs/gui/frame.py index 82847861c..f0dddcb76 100644 --- a/graphs/gui/frame.py +++ b/graphs/gui/frame.py @@ -27,7 +27,7 @@ import gui.globalEvents as GE import gui.mainFrame from graphs.data.base import FitGraph from graphs.events import RESIST_MODE_CHANGED -from gui.auxFrame import AuxiliaryFrame +from gui.auxWindow import AuxiliaryFrame from gui.bitmap_loader import BitmapLoader from service.const import GraphCacheCleanupReason from service.settings import GraphSettings diff --git a/gui/auxFrame.py b/gui/auxWindow.py similarity index 87% rename from gui/auxFrame.py rename to gui/auxWindow.py index 2a66827ff..00a32923c 100644 --- a/gui/auxFrame.py +++ b/gui/auxWindow.py @@ -22,7 +22,7 @@ import wx -class AuxiliaryFrame(wx.Frame): +class AuxiliaryMixin: _instance = None @@ -55,14 +55,26 @@ class AuxiliaryFrame(wx.Frame): self.SetBackgroundColour(wx.SystemSettings.GetColour(wx.SYS_COLOUR_BTNFACE)) @classmethod - def openOne(cls, parent, *args, **kwargs): + def openOne(cls, parent, *args, forceReopen=False, **kwargs): """If window is open and alive - raise it, open otherwise""" - if not cls._instance: + if not cls._instance or forceReopen: + if cls._instance: + cls._instance.Close() frame = cls(parent, *args, **kwargs) cls._instance = frame frame.Show() else: cls._instance.Raise() + return cls._instance + def OnSuppressedAction(self, event): return + + +class AuxiliaryFrame(AuxiliaryMixin, wx.Frame): + pass + + +class AuxiliaryDialog(AuxiliaryMixin, wx.Dialog): + pass diff --git a/gui/builtinAdditionPanes/droneView.py b/gui/builtinAdditionPanes/droneView.py index efd4b62a3..e111c7536 100644 --- a/gui/builtinAdditionPanes/droneView.py +++ b/gui/builtinAdditionPanes/droneView.py @@ -36,6 +36,11 @@ import gui.fitCommands as cmd from gui.fitCommands.helpers import droneStackLimit +DRONE_ORDER = ('Light Scout Drones', 'Medium Scout Drones', + 'Heavy Attack Drones', 'Sentry Drones', 'Combat Utility Drones', + 'Electronic Warfare Drones', 'Logistic Drones', 'Mining Drones', 'Salvage Drones') + + class DroneViewDrop(wx.DropTarget): def __init__(self, dropFn, *args, **kwargs): super(DroneViewDrop, self).__init__(*args, **kwargs) @@ -186,17 +191,13 @@ class DroneView(Display): self.mainFrame.command.Submit(cmd.GuiMergeLocalDroneStacksCommand( fitID=fitID, srcPosition=srcPosition, dstPosition=dstPosition)) - DRONE_ORDER = ('Light Scout Drones', 'Medium Scout Drones', - 'Heavy Attack Drones', 'Sentry Drones', 'Combat Utility Drones', - 'Electronic Warfare Drones', 'Logistic Drones', 'Mining Drones', 'Salvage Drones') - - def droneKey(self, drone): + @staticmethod + def droneKey(drone): sMkt = Market.getInstance() groupName = sMkt.getMarketGroupByItem(drone.item).name - return (self.DRONE_ORDER.index(groupName), - drone.item.name) + return (DRONE_ORDER.index(groupName), drone.item.name) def fitChanged(self, event): event.Skip() diff --git a/gui/builtinAdditionPanes/fighterView.py b/gui/builtinAdditionPanes/fighterView.py index d37346b3f..588dbda6f 100644 --- a/gui/builtinAdditionPanes/fighterView.py +++ b/gui/builtinAdditionPanes/fighterView.py @@ -34,6 +34,9 @@ from service.fit import Fit from service.market import Market +FIGHTER_ORDER = ('Light Fighter', 'Heavy Fighter', 'Support Fighter') + + class FighterViewDrop(wx.DropTarget): def __init__(self, dropFn, *args, **kwargs): super(FighterViewDrop, self).__init__(*args, **kwargs) @@ -250,11 +253,10 @@ class FighterDisplay(d.Display): def _merge(src, dst): return - FIGHTER_ORDER = ('Light Fighter', 'Heavy Fighter', 'Support Fighter') - - def fighterKey(self, fighter): + @staticmethod + def fighterKey(fighter): groupName = Market.getInstance().getGroupByItem(fighter.item).name - orderPos = self.FIGHTER_ORDER.index(groupName) + orderPos = FIGHTER_ORDER.index(groupName) # Sort support fighters by name, ignore their abilities if groupName == 'Support Fighter': abilityEffectIDs = () diff --git a/gui/builtinContextMenus/__init__.py b/gui/builtinContextMenus/__init__.py index 8ccfe8472..0546e7a0f 100644 --- a/gui/builtinContextMenus/__init__.py +++ b/gui/builtinContextMenus/__init__.py @@ -6,6 +6,7 @@ from gui.builtinContextMenus import fitAddCurrentlyOpen from gui.builtinContextMenus import envEffectAdd from gui.builtinContextMenus import commandFitAdd from gui.builtinContextMenus.targetProfile import adder +from gui.builtinContextMenus import graphFitAmmoPicker # Often-used item manipulations from gui.builtinContextMenus import shipModeChange from gui.builtinContextMenus import moduleAmmoChange diff --git a/gui/builtinContextMenus/graphFitAmmoPicker.py b/gui/builtinContextMenus/graphFitAmmoPicker.py new file mode 100644 index 000000000..f13d63446 --- /dev/null +++ b/gui/builtinContextMenus/graphFitAmmoPicker.py @@ -0,0 +1,241 @@ +# noinspection PyPackageRequirements +import wx + +import gui.mainFrame +from gui.auxWindow import AuxiliaryDialog +from gui.contextMenu import ContextMenuSingle +from service.ammo import Ammo +from service.market import Market + + +class GraphFitAmmoPicker(ContextMenuSingle): + + def __init__(self): + self.mainFrame = gui.mainFrame.MainFrame.getInstance() + + def display(self, callingWindow, srcContext, mainItem): + if srcContext != 'graphFitList': + return False + if mainItem is None or not mainItem.isFit: + return False + if callingWindow.graphFrame.getView().internalName != 'dmgStatsGraph': + return False + return True + + def getText(self, callingWindow, itmContext, mainItem): + return 'Plot with Different Ammo...' + + def activate(self, callingWindow, fullContext, mainItem, i): + AmmoPickerFrame.openOne(callingWindow, mainItem.item, forceReopen=True) + + +GraphFitAmmoPicker.register() + + +class AmmoPickerFrame(AuxiliaryDialog): + + def __init__(self, parent, fit): + super().__init__(parent, title='Choose Different Ammo', style=wx.DEFAULT_DIALOG_STYLE, resizeable=True) + padding = 5 + + mainSizer = wx.BoxSizer(wx.VERTICAL) + + contents = AmmoPickerContents(self, fit) + mainSizer.Add(contents, 1, wx.EXPAND | wx.ALL, padding) + + buttonSizer = self.CreateButtonSizer(wx.OK | wx.CANCEL) + if buttonSizer: + mainSizer.Add(buttonSizer, 0, wx.EXPAND | wx.ALL, padding) + + self.SetSizer(mainSizer) + self.Layout() + + contW, contH = contents.GetVirtualSize() + bestW = contW + padding * 2 + bestH = contH + padding * 2 + if buttonSizer: + # Yeah right... whatever + buttW, buttH = buttonSizer.GetSize() + bestW = max(bestW, buttW + padding * 2) + bestH += buttH + padding * 2 + bestW = min(1000, bestW) + bestH = min(700, bestH) + self.SetSize(bestW, bestH) + self.SetMinSize(wx.Size(int(bestW * 0.7), int(bestH * 0.7))) + self.CenterOnParent() + self.Bind(wx.EVT_CHAR_HOOK, self.kbEvent) + + def kbEvent(self, event): + if event.GetKeyCode() == wx.WXK_ESCAPE and event.GetModifiers() == wx.MOD_NONE: + self.Close() + return + event.Skip() + + +class AmmoPickerContents(wx.ScrolledCanvas): + + indent = 15 + + def __init__(self, parent, fit): + wx.ScrolledCanvas.__init__(self, parent) + self.SetScrollRate(0, 15) + + mods = self.getMods(fit) + drones = self.getDrones(fit) + fighters = self.getFighters(fit) + self.rbLabelMap = {} + self.rbCheckboxMap = {} + + mainSizer = wx.BoxSizer(wx.VERTICAL) + + moduleSizer = wx.BoxSizer(wx.VERTICAL) + mainSizer.Add(moduleSizer, 0, wx.ALL, 0) + + self.droneSizer = wx.BoxSizer(wx.VERTICAL) + mainSizer.Add(self.droneSizer, 0, wx.ALL, 0) + + fighterSizer = wx.BoxSizer(wx.VERTICAL) + mainSizer.Add(fighterSizer, 0, wx.ALL, 0) + + firstRadio = True + + for modInfo, modAmmo in mods: + text = '\n'.join('{}x {}'.format(amount, item.name) for item, amount in modInfo) + modRb = self.addRadioButton(moduleSizer, text, firstRadio) + firstRadio = False + # Get actual module, as ammo getters need it + mod = next((m for m in fit.modules if m.itemID == next(iter(modInfo))[0].ID), None) + _, ammoTree = Ammo.getInstance().getModuleStructuredAmmo(mod) + if len(ammoTree) == 1: + for ammoCatName, ammos in ammoTree.items(): + for ammo in ammos: + self.addCheckbox(moduleSizer, ammo.name, modRb, indentLvl=1) + else: + for ammoCatName, ammos in ammoTree.items(): + if len(ammos) == 1: + ammo = next(iter(ammos)) + self.addCheckbox(moduleSizer, ammo.name, modRb, indentLvl=1) + else: + self.addLabel(moduleSizer, '{}:'.format(ammoCatName), modRb, indentLvl=1) + for ammo in ammos: + self.addCheckbox(moduleSizer, ammo.name, modRb, indentLvl=2) + if drones: + droneRb = self.addRadioButton(self.droneSizer, 'Drones', firstRadio) + from gui.builtinAdditionPanes.droneView import DroneView + for drone in sorted(drones, key=DroneView.droneKey): + self.addCheckbox(self.droneSizer, '{}x {}'.format(drone.amount, drone.item.name), droneRb, indentLvl=1) + addBtn = wx.Button(self, wx.ID_ANY, '+', style=wx.BU_EXACTFIT) + addBtn.Bind(wx.EVT_BUTTON, self.OnDroneGroupAdd) + mainSizer.Add(addBtn, 0, wx.LEFT, self.indent) + if fighters: + fighterRb = self.addRadioButton(fighterSizer, 'Fighters', firstRadio) + from gui.builtinAdditionPanes.fighterView import FighterDisplay + for fighter in sorted(fighters, key=FighterDisplay.fighterKey): + self.addCheckbox(fighterSizer, '{}x {}'.format(fighter.amount, fighter.item.name), fighterRb, indentLvl=1) + + self.SetSizer(mainSizer) + self.refreshStatus() + + def addRadioButton(self, sizer, text, firstRadio=False): + if firstRadio: + rb = wx.RadioButton(self, wx.ID_ANY, text, style=wx.RB_GROUP) + rb.SetValue(True) + else: + rb = wx.RadioButton(self, wx.ID_ANY, text) + rb.SetValue(False) + rb.Bind(wx.EVT_RADIOBUTTON, self.rbSelected) + sizer.Add(rb, 0, wx.EXPAND | wx.ALL, 0) + return rb + + def addCheckbox(self, sizer, text, currentRb, indentLvl=0): + cb = wx.CheckBox(self, -1, text) + sizer.Add(cb, 0, wx.EXPAND | wx.LEFT, self.indent * indentLvl) + if currentRb is not None: + self.rbCheckboxMap.setdefault(currentRb, []).append(cb) + + def addLabel(self, sizer, text, currentRb, indentLvl=0): + text = text[0].capitalize() + text[1:] + label = wx.StaticText(self, wx.ID_ANY, text) + sizer.Add(label, 0, wx.EXPAND | wx.LEFT, self.indent * indentLvl) + if currentRb is not None: + self.rbLabelMap.setdefault(currentRb, []).append(label) + + def getMods(self, fit): + sMkt = Market.getInstance() + sAmmo = Ammo.getInstance() + loadableChargesCache = {} + # Modules, format: {frozenset(ammo): {item: count}} + modsPrelim = {} + if fit is not None: + for mod in fit.modules: + if not mod.canDealDamage(): + continue + typeID = mod.item.ID + if typeID not in loadableChargesCache: + loadableChargesCache[typeID] = sAmmo.getModuleFlatAmmo(mod) + charges = loadableChargesCache[typeID] + # We're not interested in modules which contain no charges + if charges: + data = modsPrelim.setdefault(frozenset(charges), {}) + if mod.item not in data: + data[mod.item] = 0 + data[mod.item] += 1 + # Format: [([(item, count), ...], frozenset(ammo)), ...] + modsFinal = [] + for charges, itemCounts in modsPrelim.items(): + modsFinal.append(( + # Sort items within group + sorted(itemCounts.items(), key=lambda i: sMkt.itemSort(i[0], reverseMktGrp=True), reverse=True), + charges)) + # Sort item groups + modsFinal.sort(key=lambda i: sMkt.itemSort(i[0][0][0], reverseMktGrp=True), reverse=True) + return modsFinal + + def getDrones(self, fit): + drones = [] + if fit is not None: + for drone in fit.drones: + if drone.item is None: + continue + # Drones are our "ammo", so we want to pick even those which are inactive + if drone.canDealDamage(ignoreState=True): + drones.append(drone) + continue + if {'remoteWebifierEntity', 'remoteTargetPaintEntity'}.intersection(drone.item.effects): + drones.append(drone) + continue + return drones + + def getFighters(self, fit): + fighters = [] + if fit is not None: + for fighter in fit.fighters: + if fighter.item is None: + continue + # Fighters are our "ammo" as well + if fighter.canDealDamage(ignoreState=True): + fighters.append(fighter) + continue + for ability in fighter.abilities: + if not ability.active: + continue + if ability.effect.name == 'fighterAbilityStasisWebifier': + fighters.append(fighter) + break + return fighters + + def OnDroneGroupAdd(self, event): + event.Skip() + sizer = wx.BoxSizer(wx.HORIZONTAL) + label = wx.StaticText() + self.droneSizer.Add(sizer, 0, wx.EXPAND | wx.LEFT, self.indent) + + def refreshStatus(self): + for map in (self.rbLabelMap, self.rbCheckboxMap): + for rb, items in map.items(): + for item in items: + item.Enable(rb.GetValue()) + + def rbSelected(self, event): + event.Skip() + self.refreshStatus() diff --git a/gui/builtinContextMenus/moduleAmmoChange.py b/gui/builtinContextMenus/moduleAmmoChange.py index bdcf2dc6e..ba7250526 100644 --- a/gui/builtinContextMenus/moduleAmmoChange.py +++ b/gui/builtinContextMenus/moduleAmmoChange.py @@ -3,33 +3,28 @@ import wx import gui.fitCommands as cmd import gui.mainFrame -from eos.const import FittingHardpoint -from eos.saveddata.module import Module from gui.bitmap_loader import BitmapLoader from gui.contextMenu import ContextMenuCombined from gui.fitCommands.helpers import getSimilarModPositions +from service.ammo import Ammo from service.fit import Fit -from service.market import Market class ChangeModuleAmmo(ContextMenuCombined): - DAMAGE_TYPES = ("em", "explosive", "kinetic", "thermal") - MISSILE_ORDER = ("em", "thermal", "kinetic", "explosive", "mixed") - def __init__(self): self.mainFrame = gui.mainFrame.MainFrame.getInstance() # Format: {type ID: set(loadable, charges)} - self.loadableCharges = {} + self.loadableChargesCache = {} def display(self, callingWindow, srcContext, mainItem, selection): - if srcContext not in ("fittingModule", "projectedModule"): + if srcContext not in ('fittingModule', 'projectedModule'): return False if self.mainFrame.getActiveFit() is None: return False - self.mainCharges = self.getChargesForMod(mainItem) + self.mainCharges = self._getAmmo(mainItem) if not self.mainCharges: return False @@ -39,186 +34,81 @@ class ChangeModuleAmmo(ContextMenuCombined): return True def getText(self, callingWindow, itmContext, mainItem, selection): - return "Charge" + return 'Charge' - def getChargesForMod(self, mod): - sMkt = Market.getInstance() - if mod is None or mod.isEmpty: + def _getAmmo(self, mod): + if mod.itemID is None: return set() - typeID = mod.item.ID - if typeID in self.loadableCharges: - return self.loadableCharges[typeID] - chargeSet = self.loadableCharges.setdefault(typeID, set()) - # Do not try to grab it for modes which can also be passed as part of selection - if isinstance(mod, Module): - for charge in mod.getValidCharges(): - if sMkt.getPublicityByItem(charge): - chargeSet.add(charge) - return chargeSet + if mod.itemID not in self.loadableChargesCache: + self.loadableChargesCache[mod.itemID] = Ammo.getInstance().getModuleFlatAmmo(mod) + return self.loadableChargesCache[mod.itemID] - def turretSorter(self, charge): - damage = 0 - range_ = (self.module.item.getAttribute("maxRange")) * \ - (charge.getAttribute("weaponRangeMultiplier") or 1) - falloff = (self.module.item.getAttribute("falloff") or 0) * \ - (charge.getAttribute("fallofMultiplier") or 1) - for type_ in self.DAMAGE_TYPES: - d = charge.getAttribute("%sDamage" % type_) - if d > 0: - damage += d - - # Take optimal and falloff as range factor - rangeFactor = range_ + falloff - - return - rangeFactor, charge.name.rsplit()[-2:], damage, charge.name - - def missileSorter(self, charge): - # Get charge damage type and total damage - chargeDamageType, totalDamage = self.damageInfo(charge) - # Find its position in sort list - position = self.MISSILE_ORDER.index(chargeDamageType) - return position, totalDamage, charge.name - - def damageInfo(self, charge): - # Set up data storage for missile damage stuff - damageMap = {} - totalDamage = 0 - # Fill them with the data about charge - for damageType in self.DAMAGE_TYPES: - currentDamage = charge.getAttribute("{0}Damage".format(damageType)) or 0 - damageMap[damageType] = currentDamage - totalDamage += currentDamage - # Detect type of ammo - chargeDamageType = None - for damageType in damageMap: - # If all damage belongs to certain type purely, set appropriate - # ammoType - if damageMap[damageType] == totalDamage: - chargeDamageType = damageType - break - # Else consider ammo as mixed damage - if chargeDamageType is None: - chargeDamageType = "mixed" - - return chargeDamageType, totalDamage - - @staticmethod - def numericConverter(string): - return int(string) if string.isdigit() else string - - def nameSorter(self, charge): - parts = charge.name.split(" ") - return list(map(self.numericConverter, parts)) - - def addCharge(self, menu, charge): + def _addCharge(self, menu, charge): id_ = ContextMenuCombined.nextID() - name = charge.name if charge is not None else "Empty" - self.chargeIds[id_] = charge + name = charge.name if charge is not None else 'Empty' + self.chargeEventMap[id_] = charge item = wx.MenuItem(menu, id_, name) menu.Bind(wx.EVT_MENU, self.handleAmmoSwitch, item) item.charge = charge if charge is not None and charge.iconID is not None: - bitmap = BitmapLoader.getBitmap(charge.iconID, "icons") + bitmap = BitmapLoader.getBitmap(charge.iconID, 'icons') if bitmap is not None: item.SetBitmap(bitmap) - return item @staticmethod - def addSeperator(m, text): + def _addSeparator(m, text): id_ = ContextMenuCombined.nextID() m.Append(id_, '─ %s ─' % text) m.Enable(id_, False) def getSubMenu(self, callingWindow, context, mainItem, selection, rootMenu, i, pitem): - msw = True if "wxMSW" in wx.PlatformInfo else False - m = wx.Menu() - self.chargeIds = {} - hardpoint = self.module.hardpoint - moduleName = self.module.item.name - # Make sure we do not consider mining turrets as combat turrets - if hardpoint == FittingHardpoint.TURRET and self.module.getModifiedItemAttr("miningAmount", None) is None: - self.addSeperator(m, "Long Range") - items = [] - range_ = None - nameBase = None - sub = None - chargesSorted = sorted(self.mainCharges, key=self.turretSorter) - for charge in chargesSorted: - if "civilian" in charge.name.lower(): - continue - currBase = charge.name.rsplit()[-2:] - currRange = charge.getAttribute("weaponRangeMultiplier") - if nameBase is None or range_ != currRange or nameBase != currBase: - if sub is not None: - self.addSeperator(sub, "More Damage") - - sub = None - base = charge - nameBase = currBase - range_ = currRange - item = self.addCharge(rootMenu if msw else m, charge) - items.append(item) + msw = True if 'wxMSW' in wx.PlatformInfo else False + menu = wx.Menu() + self.chargeEventMap = {} + modType, chargeDict = Ammo.getInstance().getModuleStructuredAmmo(self.module, ammo=self.mainCharges) + if modType == 'ddTurret': + self._addSeparator(menu, 'Long Range') + menuItems = [] + for charges in chargeDict.values(): + if len(charges) == 1: + menuItems.append(self._addCharge(rootMenu if msw else menu, charges[0])) else: - if sub is None and item and base: - sub = wx.Menu() - sub.Bind(wx.EVT_MENU, self.handleAmmoSwitch) - self.addSeperator(sub, "Less Damage") - item.SetSubMenu(sub) - sub.Append(self.addCharge(rootMenu if msw else sub, base)) - - sub.Append(self.addCharge(rootMenu if msw else sub, charge)) - - if sub is not None: - self.addSeperator(sub, "More Damage") - - for item in items: - m.Append(item) - - self.addSeperator(m, "Short Range") - elif hardpoint == FittingHardpoint.MISSILE and moduleName != 'Festival Launcher': - type_ = None - sub = None - defender = None - chargesSorted = sorted(self.mainCharges, key=self.missileSorter) - for charge in chargesSorted: - currType = self.damageInfo(charge)[0] - - if currType != type_ or type_ is None: - if sub is not None: - self.addSeperator(sub, "More Damage") - - type_ = currType - item = wx.MenuItem(m, wx.ID_ANY, type_.capitalize()) - bitmap = BitmapLoader.getBitmap("%s_small" % type_, "gui") - if bitmap is not None: - item.SetBitmap(bitmap) - - sub = wx.Menu() - sub.Bind(wx.EVT_MENU, self.handleAmmoSwitch) - self.addSeperator(sub, "Less Damage") - item.SetSubMenu(sub) - m.Append(item) - - if charge.name not in ("Light Defender Missile I", "Heavy Defender Missile I"): - sub.Append(self.addCharge(rootMenu if msw else sub, charge)) - else: - defender = charge - - if defender is not None: - m.Append(self.addCharge(rootMenu if msw else m, defender)) - if sub is not None: - self.addSeperator(sub, "More Damage") - else: - chargesSorted = sorted(self.mainCharges, key=self.nameSorter) - for charge in chargesSorted: - m.Append(self.addCharge(rootMenu if msw else m, charge)) - - m.Append(self.addCharge(rootMenu if msw else m, None)) - return m + baseCharge = charges[0] + menuItem = self._addCharge(rootMenu if msw else menu, baseCharge) + menuItems.append(menuItem) + subMenu = wx.Menu() + subMenu.Bind(wx.EVT_MENU, self.handleAmmoSwitch) + menuItem.SetSubMenu(subMenu) + self._addSeparator(subMenu, 'Less Damage') + for charge in charges: + subMenu.Append(self._addCharge(rootMenu if msw else subMenu, charge)) + self._addSeparator(subMenu, 'More Damage') + for menuItem in menuItems: + menu.Append(menuItem) + self._addSeparator(menu, 'Short Range') + elif modType == 'ddMissile': + menuItems = [] + for chargeCatName, charges in chargeDict.items(): + menuItem = wx.MenuItem(menu, wx.ID_ANY, chargeCatName.capitalize()) + menuItems.append(menuItem) + subMenu = wx.Menu() + subMenu.Bind(wx.EVT_MENU, self.handleAmmoSwitch) + menuItem.SetSubMenu(subMenu) + self._addSeparator(subMenu, 'Less Damage') + for charge in charges: + subMenu.Append(self._addCharge(rootMenu if msw else subMenu, charge)) + self._addSeparator(subMenu, 'More Damage') + for menuItem in menuItems: + menu.Append(menuItem) + elif modType == 'general': + for charge in chargeDict['general']: + menu.Append(self._addCharge(rootMenu if msw else menu, charge)) + menu.Append(self._addCharge(rootMenu if msw else menu, None)) + return menu def handleAmmoSwitch(self, event): - charge = self.chargeIds.get(event.Id, False) + charge = self.chargeEventMap.get(event.Id, False) if charge is False: event.Skip() return @@ -254,7 +144,7 @@ class ChangeModuleAmmo(ContextMenuCombined): positions = [] for position, mod in enumerate(modContainer): if mod in self.selection: - modCharges = self.getChargesForMod(mod) + modCharges = self._getAmmo(mod) if modCharges.issubset(self.mainCharges): positions.append(position) self.mainFrame.command.Submit(command( diff --git a/gui/builtinItemStatsViews/itemDescription.py b/gui/builtinItemStatsViews/itemDescription.py index 9c62dcc78..7fb990e32 100644 --- a/gui/builtinItemStatsViews/itemDescription.py +++ b/gui/builtinItemStatsViews/itemDescription.py @@ -35,7 +35,7 @@ class ItemDescription(wx.Panel): self.Layout() self.description.Bind(wx.EVT_CONTEXT_MENU, self.onPopupMenu) - self.description.Bind(wx.EVT_KEY_DOWN, self.onKeyDown) + self.description.Bind(wx.EVT_KEY_UP, self.onKeyUp) self.popupMenu = wx.Menu() copyItem = wx.MenuItem(self.popupMenu, 1, 'Copy') @@ -50,7 +50,7 @@ class ItemDescription(wx.Panel): if selectedMenuItem == 1: # Copy was chosen self.copySelectionToClipboard() - def onKeyDown(self, event): + def onKeyUp(self, event): keyCode = event.GetKeyCode() # Ctrl + C if keyCode == 67 and event.ControlDown(): diff --git a/gui/builtinItemStatsViews/itemTraits.py b/gui/builtinItemStatsViews/itemTraits.py index 1ea0514a5..bbabee4eb 100644 --- a/gui/builtinItemStatsViews/itemTraits.py +++ b/gui/builtinItemStatsViews/itemTraits.py @@ -14,7 +14,7 @@ class ItemTraits(wx.Panel): self.traits.SetPage(item.traits.traitText) self.traits.Bind(wx.EVT_CONTEXT_MENU, self.onPopupMenu) - self.traits.Bind(wx.EVT_KEY_DOWN, self.onKeyDown) + self.traits.Bind(wx.EVT_KEY_UP, self.onKeyUp) mainSizer.Add(self.traits, 1, wx.ALL | wx.EXPAND, 0) self.Layout() @@ -32,7 +32,7 @@ class ItemTraits(wx.Panel): if selectedMenuItem == 1: # Copy was chosen self.copySelectionToClipboard() - def onKeyDown(self, event): + def onKeyUp(self, event): keyCode = event.GetKeyCode() # Ctrl + C if keyCode == 67 and event.ControlDown(): diff --git a/gui/builtinMarketBrowser/itemView.py b/gui/builtinMarketBrowser/itemView.py index a0d634f3b..ae20cdfce 100644 --- a/gui/builtinMarketBrowser/itemView.py +++ b/gui/builtinMarketBrowser/itemView.py @@ -203,22 +203,6 @@ class ItemView(Display): self.setToggles() self.filterItemStore() - def itemSort(self, item): - sMkt = self.sMkt - catname = sMkt.getCategoryByItem(item).name - try: - mktgrpid = sMkt.getMarketGroupByItem(item).ID - except AttributeError: - mktgrpid = -1 - pyfalog.warning("unable to find market group for {}".format(item.name)) - parentname = sMkt.getParentItemByItem(item).name - # Get position of market group - metagrpid = sMkt.getMetaGroupIdByItem(item) - metatab = sMkt.META_MAP_REVERSE_INDICES.get(metagrpid) - metalvl = item.metaLevel or 0 - - return catname, mktgrpid, parentname, metatab, metalvl, item.name - def contextMenu(self, event): clickedPos = self.getRowByAbs(event.Position) self.ensureSelection(clickedPos) @@ -241,7 +225,7 @@ class ItemView(Display): self.unselectAll() # Perform sorting, using item's meta levels besides other stuff if self.marketBrowser.mode != 'recent': - items.sort(key=self.itemSort) + items.sort(key=self.sMkt.itemSort) # Mark current item list as active self.active = items # Show them @@ -251,12 +235,10 @@ class ItemView(Display): if len(items) > 1: # Re-sort stuff if self.marketBrowser.mode != 'recent': - items.sort(key=self.itemSort) - + items.sort(key=self.sMkt.itemSort) for i, item in enumerate(items[:9]): # set shortcut info for first 9 modules item.marketShortcut = i + 1 - Display.refresh(self, items) def columnBackground(self, colItem, item): diff --git a/gui/builtinMarketBrowser/pfSearchBox.py b/gui/builtinMarketBrowser/pfSearchBox.py index 38706a34d..d1e8f2f40 100644 --- a/gui/builtinMarketBrowser/pfSearchBox.py +++ b/gui/builtinMarketBrowser/pfSearchBox.py @@ -86,7 +86,7 @@ class PFSearchBox(wx.Window): def OnKeyPress(self, event): if event.RawControlDown() and event.GetKeyCode() == wx.WXK_BACK: - HandleCtrlBackspace(self.EditBox) + HandleCtrlBackspace(self.EditBox) else: event.Skip() diff --git a/gui/builtinViews/fittingView.py b/gui/builtinViews/fittingView.py index 76f4c1aa2..3884eda6e 100644 --- a/gui/builtinViews/fittingView.py +++ b/gui/builtinViews/fittingView.py @@ -166,7 +166,7 @@ class FittingView(d.Display): self.hoveredRow = None self.hoveredColumn = None - self.Bind(wx.EVT_KEY_DOWN, self.kbEvent) + self.Bind(wx.EVT_KEY_UP, self.kbEvent) self.Bind(wx.EVT_LEFT_DOWN, self.click) self.Bind(wx.EVT_RIGHT_DOWN, self.click) self.Bind(wx.EVT_MIDDLE_DOWN, self.click) diff --git a/gui/characterEditor.py b/gui/characterEditor.py index 39f94078d..69d7c8b94 100644 --- a/gui/characterEditor.py +++ b/gui/characterEditor.py @@ -34,7 +34,7 @@ from wx.lib.agw.floatspin import FloatSpin import config import gui.globalEvents as GE -from gui.auxFrame import AuxiliaryFrame +from gui.auxWindow import AuxiliaryFrame from gui.bitmap_loader import BitmapLoader from gui.builtinViews.entityEditor import BaseValidator, EntityEditor, TextEntryValidatedDialog from gui.builtinViews.implantEditor import BaseImplantEditorView diff --git a/gui/devTools.py b/gui/devTools.py index 3e5ac725e..2fb6abb4a 100644 --- a/gui/devTools.py +++ b/gui/devTools.py @@ -26,7 +26,7 @@ import wx from logbook import Logger import eos.db -from gui.auxFrame import AuxiliaryFrame +from gui.auxWindow import AuxiliaryFrame from gui.builtinShipBrowser.events import FitSelected diff --git a/gui/errorDialog.py b/gui/errorDialog.py index b5e83c0d6..be0166b3b 100644 --- a/gui/errorDialog.py +++ b/gui/errorDialog.py @@ -26,7 +26,7 @@ import wx from logbook import Logger import config -from gui.auxFrame import AuxiliaryFrame +from gui.auxWindow import AuxiliaryFrame from service.prereqsCheck import version_block diff --git a/gui/esiFittings.py b/gui/esiFittings.py index 0b87d0187..2789ec673 100644 --- a/gui/esiFittings.py +++ b/gui/esiFittings.py @@ -9,7 +9,7 @@ import config import gui.globalEvents as GE from eos.db import getItem from eos.saveddata.cargo import Cargo -from gui.auxFrame import AuxiliaryFrame +from gui.auxWindow import AuxiliaryFrame from gui.display import Display from gui.characterEditor import APIView from service.character import Character diff --git a/gui/itemStats.py b/gui/itemStats.py index 284121ecc..dda8e68ad 100644 --- a/gui/itemStats.py +++ b/gui/itemStats.py @@ -23,7 +23,7 @@ import wx import config import gui.mainFrame from eos.saveddata.module import Module -from gui.auxFrame import AuxiliaryFrame +from gui.auxWindow import AuxiliaryFrame from gui.bitmap_loader import BitmapLoader from gui.builtinItemStatsViews.itemAffectedBy import ItemAffectedBy from gui.builtinItemStatsViews.itemAttributes import ItemParams diff --git a/gui/patternEditor.py b/gui/patternEditor.py index 40a7ecc31..519d30e4b 100644 --- a/gui/patternEditor.py +++ b/gui/patternEditor.py @@ -21,7 +21,7 @@ import wx from logbook import Logger -from gui.auxFrame import AuxiliaryFrame +from gui.auxWindow import AuxiliaryFrame from gui.bitmap_loader import BitmapLoader from gui.builtinViews.entityEditor import BaseValidator, EntityEditor from gui.utils.clipboard import fromClipboard, toClipboard diff --git a/gui/propertyEditor.py b/gui/propertyEditor.py index 098b65a13..db2a1f57f 100644 --- a/gui/propertyEditor.py +++ b/gui/propertyEditor.py @@ -10,7 +10,7 @@ import gui.builtinMarketBrowser.pfSearchBox as SBox import gui.display as d import gui.globalEvents as GE from eos.db.gamedata.queries import getAttributeInfo, getItem -from gui.auxFrame import AuxiliaryFrame +from gui.auxWindow import AuxiliaryFrame from gui.bitmap_loader import BitmapLoader from gui.marketBrowser import SearchBox from service.fit import Fit @@ -213,19 +213,7 @@ class ItemView(d.Display): def itemSort(self, item): sMkt = Market.getInstance() isFittable = item.group.name in sMkt.FIT_GROUPS or item.category.name in sMkt.FIT_CATEGORIES - catname = sMkt.getCategoryByItem(item).name - try: - mktgrpid = sMkt.getMarketGroupByItem(item).ID - except AttributeError: - mktgrpid = -1 - pyfalog.warning("unable to find market group for {}".format(item.name)) - parentname = sMkt.getParentItemByItem(item).name - # Get position of market group - metagrpid = sMkt.getMetaGroupIdByItem(item) - metatab = sMkt.META_MAP_REVERSE_INDICES.get(metagrpid) - metalvl = item.metaLevel or 0 - - return not isFittable, catname, mktgrpid, parentname, metatab, metalvl, item.name + return (not isFittable, *sMkt.itemSort(item)) def populateSearch(self, itemIDs): items = Market.getItems(itemIDs) diff --git a/gui/setEditor.py b/gui/setEditor.py index eef2077e5..c8b8b7df0 100644 --- a/gui/setEditor.py +++ b/gui/setEditor.py @@ -21,7 +21,7 @@ import wx from logbook import Logger -from gui.auxFrame import AuxiliaryFrame +from gui.auxWindow import AuxiliaryFrame from gui.builtinViews.entityEditor import BaseValidator, EntityEditor from gui.builtinViews.implantEditor import BaseImplantEditorView from gui.utils.clipboard import fromClipboard, toClipboard diff --git a/pyfa.py b/pyfa.py index fbee4220b..e26b1f48a 100755 --- a/pyfa.py +++ b/pyfa.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # ============================================================================== # Copyright (C) 2010 Diego Duclos # diff --git a/service/ammo.py b/service/ammo.py new file mode 100644 index 000000000..b7b69924f --- /dev/null +++ b/service/ammo.py @@ -0,0 +1,151 @@ +# ============================================================================= +# 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 . +# ============================================================================= + + +import math +from collections import OrderedDict + +from eos.const import FittingHardpoint +from eos.saveddata.module import Module +from eos.utils.stats import DmgTypes +from service.market import Market + + +class Ammo: + + instance = None + + @classmethod + def getInstance(cls): + if cls.instance is None: + cls.instance = Ammo() + + return cls.instance + + @staticmethod + def getModuleFlatAmmo(mod): + sMkt = Market.getInstance() + if mod is None or mod.isEmpty: + return set() + chargeSet = set() + # Do not try to grab it for t3d modes which can also be passed as part of selection + if isinstance(mod, Module): + for charge in mod.getValidCharges(): + if sMkt.getPublicityByItem(charge): + chargeSet.add(charge) + return chargeSet + + @classmethod + def getModuleStructuredAmmo(cls, mod, ammo=None): + chargesFlat = cls.getModuleFlatAmmo(mod) if ammo is None else ammo + # Make sure we do not consider mining turrets as combat turrets + if mod.hardpoint == FittingHardpoint.TURRET and mod.getModifiedItemAttr('miningAmount', None) is None: + + def turretSorter(charge): + damage = 0 + range_ = (mod.item.getAttribute('maxRange')) * \ + (charge.getAttribute('weaponRangeMultiplier') or 1) + falloff = (mod.item.getAttribute('falloff') or 0) * \ + (charge.getAttribute('fallofMultiplier') or 1) + for type_ in DmgTypes.names(): + d = charge.getAttribute('%sDamage' % type_) + if d > 0: + damage += d + # Take optimal and falloff as range factor + rangeFactor = range_ + falloff + return -rangeFactor, charge.name.rsplit()[-2:], damage, charge.name + + all = OrderedDict() + sub = [] + prevNameBase = None + prevRange = None + for charge in sorted(chargesFlat, key=turretSorter): + if 'civilian' in charge.name.lower(): + continue + currNameBase = ' '.join(charge.name.rsplit()[-2:]) + currRange = charge.getAttribute('weaponRangeMultiplier') + if sub and (currRange != prevRange or currNameBase != prevNameBase): + all[sub[0].name] = sub + sub = [] + sub.append(charge) + prevNameBase = currNameBase + prevRange = currRange + else: + if sub: + all[sub[0].name] = sub + return 'ddTurret', all + + elif mod.hardpoint == FittingHardpoint.MISSILE and mod.item.name != 'Festival Launcher': + + def getChargeDamageInfo(charge): + # Set up data storage for missile damage stuff + damageMap = {} + totalDamage = 0 + # Fill them with the data about charge + for damageType in DmgTypes.names(): + currentDamage = charge.getAttribute('{}Damage'.format(damageType)) or 0 + damageMap[damageType] = currentDamage + totalDamage += currentDamage + # Detect type of ammo + chargeDamageType = None + for damageType in damageMap: + # If all damage belongs to certain type purely, set appropriate + # ammoType + if damageMap[damageType] == totalDamage: + chargeDamageType = damageType + break + # Else consider ammo as mixed damage + if chargeDamageType is None: + chargeDamageType = 'mixed' + return chargeDamageType, totalDamage + + def missileSorter(charge): + # Get charge damage type and total damage + chargeDamageType, totalDamage = getChargeDamageInfo(charge) + # Find its position in sort list + try: + position = DmgTypes.names().index(chargeDamageType) + # Put charges which have non-standard damage type after charges with + # standard damage type + except ValueError: + position = math.inf + return position, totalDamage, charge.name + + all = OrderedDict() + sub = [] + prevType = None + for charge in sorted(chargesFlat, key=missileSorter): + currType = getChargeDamageInfo(charge)[0] + if sub and currType != prevType: + all[prevType] = sub + sub = [] + sub.append(charge) + prevType = currType + else: + if sub: + all[prevType] = sub + return 'ddMissile', all + + else: + + def nameSorter(charge): + parts = charge.name.split(" ") + return [int(p) if p.isdigit() else p for p in parts] + + return 'general', {'general': sorted(chargesFlat, key=nameSorter)} diff --git a/service/market.py b/service/market.py index b41f2e452..5cf8e5134 100644 --- a/service/market.py +++ b/service/market.py @@ -407,6 +407,12 @@ class Market: self.META_MAP["normal"] = frozenset((0, *(mg.ID for mg in eos.db.getMetaGroups() if mg.ID not in nonNormalMetas))) self.META_MAP.move_to_end("normal", last=False) self.META_MAP_REVERSE = {sv: k for k, v in self.META_MAP.items() for sv in v} + self.META_MAP_REVERSE_GROUPED = {} + i = 0 + for mgids in self.META_MAP.values(): + for mgid in mgids: + self.META_MAP_REVERSE_GROUPED[mgid] = i + i += 1 self.META_MAP_REVERSE_INDICES = self.__makeReverseMetaMapIndices() self.SEARCH_CATEGORIES = ( "Drone", @@ -939,3 +945,19 @@ class Market: while len(recentlyUsedModules) >= 20: recentlyUsedModules.pop(-1) recentlyUsedModules.insert(0, itemID) + + def itemSort(self, item, reverseMktGrp=False): + catname = self.getCategoryByItem(item).name + try: + mktgrpid = self.getMarketGroupByItem(item).ID + except AttributeError: + mktgrpid = -1 + pyfalog.warning("unable to find market group for {}".format(item.name)) + if reverseMktGrp: + mktgrpid = -mktgrpid + parentname = self.getParentItemByItem(item).name + # Get position of market group + metagrpid = self.getMetaGroupIdByItem(item) + metatab = self.META_MAP_REVERSE_GROUPED.get(metagrpid) + metalvl = item.metaLevel or 0 + return catname, mktgrpid, parentname, metatab, metalvl, item.name