Compare commits

...

4 Commits

Author SHA1 Message Date
e119eeb14a Fix the fucking diff algorithm 2026-02-09 18:09:21 +01:00
d8e6cc76c9 Implement batch module/charge moving 2026-02-05 16:47:19 +01:00
bfd5bbb881 Add a buck/bang column to compare 2026-01-23 18:09:01 +01:00
c64991fb59 Add a "per second" column to some rep modules
Because some have different cycle times, easier to compare
2026-01-21 11:11:07 +01:00
7 changed files with 763 additions and 170 deletions

View File

@@ -159,11 +159,24 @@ class CargoView(d.Display):
else:
dstCargoItemID = None
self.mainFrame.command.Submit(cmd.GuiLocalModuleToCargoCommand(
fitID=self.mainFrame.getActiveFit(),
modPosition=modIdx,
cargoItemID=dstCargoItemID,
copy=wx.GetMouseState().GetModifiers() == wx.MOD_CONTROL))
modifiers = wx.GetMouseState().GetModifiers()
isCopy = modifiers == wx.MOD_CONTROL
isBatch = modifiers == wx.MOD_SHIFT
if isBatch:
self.mainFrame.command.Submit(
cmd.GuiBatchLocalModuleToCargoCommand(
fitID=self.mainFrame.getActiveFit(), modPosition=modIdx, copy=isCopy
)
)
else:
self.mainFrame.command.Submit(
cmd.GuiLocalModuleToCargoCommand(
fitID=self.mainFrame.getActiveFit(),
modPosition=modIdx,
cargoItemID=dstCargoItemID,
copy=isCopy,
)
)
def fitChanged(self, event):
event.Skip()

View File

@@ -9,6 +9,46 @@ from gui.utils.numberFormatter import formatAmount
_t = wx.GetTranslation
# Mapping of repair/transfer amount attributes to their duration attribute and display name
PER_SECOND_ATTRIBUTES = {
"armorDamageAmount": {
"durationAttr": "duration",
"displayName": "Armor Hitpoints Repaired per second",
"unit": "HP/s"
},
"shieldBonus": {
"durationAttr": "duration",
"displayName": "Shield Hitpoints Repaired per second",
"unit": "HP/s"
},
"powerTransferAmount": {
"durationAttr": "duration",
"displayName": "Capacitor Transferred per second",
"unit": "GJ/s"
}
}
class PerSecondAttributeInfo:
"""Helper class to store info about computed per-second attributes"""
def __init__(self, displayName, unit):
self.displayName = displayName
self.unit = PerSecondUnit(unit)
class PerSecondUnit:
"""Helper class to mimic the Unit class for per-second attributes"""
def __init__(self, displayName):
self.displayName = displayName
self.name = ""
class PerSecondAttributeValue:
"""Helper class to store computed per-second attribute values"""
def __init__(self, value):
self.value = value
self.info = None # Will be set when adding to attrs
def defaultSort(item):
return (item.metaLevel or 0, item.name)
@@ -36,8 +76,12 @@ class ItemCompare(wx.Panel):
self.item = item
self.items = sorted(items, key=defaultSort)
self.attrs = {}
self.computedAttrs = {} # Store computed per-second attributes
self.HighlightOn = wx.Colour(255, 255, 0, wx.ALPHA_OPAQUE)
self.highlightedNames = []
self.bangBuckColumn = None # Store the column selected for bang/buck calculation
self.bangBuckColumnName = None # Store the display name of the selected column
self.columnHighlightColour = wx.Colour(173, 216, 230, wx.ALPHA_OPAQUE) # Light blue for column highlight
# get a dict of attrName: attrInfo of all unique attributes across all items
for item in self.items:
@@ -45,23 +89,66 @@ class ItemCompare(wx.Panel):
if item.attributes[attr].info.displayName:
self.attrs[attr] = item.attributes[attr].info
# Compute per-second attributes for items that have both the amount and duration
for perSecondKey, config in PER_SECOND_ATTRIBUTES.items():
amountAttr = perSecondKey
durationAttr = config["durationAttr"]
perSecondAttrName = f"{perSecondKey}_per_second"
# Check if any item has both attributes
hasPerSecondAttr = False
for item in self.items:
if amountAttr in item.attributes and durationAttr in item.attributes:
hasPerSecondAttr = True
break
if hasPerSecondAttr:
# Add the per-second attribute info to attrs
perSecondInfo = PerSecondAttributeInfo(config["displayName"], config["unit"])
self.attrs[perSecondAttrName] = perSecondInfo
self.computedAttrs[perSecondAttrName] = {
"amountAttr": amountAttr,
"durationAttr": durationAttr
}
# Process attributes for items and find ones that differ
for attr in list(self.attrs.keys()):
value = None
for item in self.items:
# we can automatically break here if this item doesn't have the attribute,
# as that means at least one item did
if attr not in item.attributes:
break
# Check if this is a computed attribute
if attr in self.computedAttrs:
computed = self.computedAttrs[attr]
amountAttr = computed["amountAttr"]
durationAttr = computed["durationAttr"]
# this is the first attribute for the item set, set the initial value
if value is None:
value = item.attributes[attr].value
continue
# Item needs both attributes to compute per-second value
if amountAttr not in item.attributes or durationAttr not in item.attributes:
break
if attr not in item.attributes or item.attributes[attr].value != value:
break
# Calculate per-second value
amountValue = item.attributes[amountAttr].value
durationValue = item.attributes[durationAttr].value
# Duration is in milliseconds, convert to seconds
perSecondValue = amountValue / (durationValue / 1000.0) if durationValue > 0 else 0
if value is None:
value = perSecondValue
continue
if perSecondValue != value:
break
else:
# Regular attribute handling
if attr not in item.attributes:
break
if value is None:
value = item.attributes[attr].value
continue
if item.attributes[attr].value != value:
break
else:
# attribute values were all the same, delete
del self.attrs[attr]
@@ -89,6 +176,7 @@ class ItemCompare(wx.Panel):
self.toggleViewBtn.Bind(wx.EVT_TOGGLEBUTTON, self.ToggleViewMode)
self.Bind(wx.EVT_LIST_COL_CLICK, self.SortCompareCols)
self.Bind(wx.EVT_LIST_COL_RIGHT_CLICK, self.OnColumnRightClick)
self.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.HighlightRow)
@@ -105,6 +193,23 @@ class ItemCompare(wx.Panel):
self.Thaw()
event.Skip()
def OnColumnRightClick(self, event):
column = event.GetColumn()
# Column 0 is "Item", column len(self.attrs) + 1 is "Price", len(self.attrs) + 2 is "Buck/bang"
# Only allow selecting attribute columns (1 to len(self.attrs))
if 1 <= column <= len(self.attrs):
# If clicking the same column, deselect it
if self.bangBuckColumn == column:
self.bangBuckColumn = None
self.bangBuckColumnName = None
else:
self.bangBuckColumn = column
# Get the display name of the selected column
attr_key = list(self.attrs.keys())[column - 1]
self.bangBuckColumnName = self.attrs[attr_key].displayName if self.attrs[attr_key].displayName else attr_key
self.UpdateList()
event.Skip()
def SortCompareCols(self, event):
self.Freeze()
self.paramList.ClearAll()
@@ -148,12 +253,32 @@ class ItemCompare(wx.Panel):
# Remember to reduce by 1, because the attrs array
# starts at 0 while the list has the item name as column 0.
attr = str(list(self.attrs.keys())[sort - 1])
func = lambda _val: _val.attributes[attr].value if attr in _val.attributes else 0.0
# Handle computed attributes for sorting
if attr in self.computedAttrs:
computed = self.computedAttrs[attr]
amountAttr = computed["amountAttr"]
durationAttr = computed["durationAttr"]
func = lambda _val: (_val.attributes[amountAttr].value / (_val.attributes[durationAttr].value / 1000.0)) if (amountAttr in _val.attributes and durationAttr in _val.attributes and _val.attributes[durationAttr].value > 0) else 0.0
else:
func = lambda _val: _val.attributes[attr].value if attr in _val.attributes else 0.0
# Clicked on a column that's not part of our array (price most likely)
except IndexError:
# Price
if sort == len(self.attrs) + 1:
func = lambda i: i.price.price if i.price.price != 0 else float("Inf")
# Buck/bang
elif sort == len(self.attrs) + 2:
if self.bangBuckColumn is not None:
attr_key = list(self.attrs.keys())[self.bangBuckColumn - 1]
if attr_key in self.computedAttrs:
computed = self.computedAttrs[attr_key]
amountAttr = computed["amountAttr"]
durationAttr = computed["durationAttr"]
func = lambda i: (i.price.price / (i.attributes[amountAttr].value / (i.attributes[durationAttr].value / 1000.0)) if (amountAttr in i.attributes and durationAttr in i.attributes and i.attributes[durationAttr].value > 0 and (i.attributes[amountAttr].value / (i.attributes[durationAttr].value / 1000.0)) > 0) else float("Inf"))
else:
func = lambda i: (i.price.price / i.attributes[attr_key].value if (attr_key in i.attributes and i.attributes[attr_key].value > 0) else float("Inf"))
else:
func = defaultSort
# Something else
else:
self.sortReverse = False
@@ -166,18 +291,49 @@ class ItemCompare(wx.Panel):
for i, attr in enumerate(self.attrs.keys()):
name = self.attrs[attr].displayName if self.attrs[attr].displayName else attr
# Add indicator if this column is selected for bang/buck calculation
if self.bangBuckColumn == i + 1:
name = "" + name
self.paramList.InsertColumn(i + 1, name)
self.paramList.SetColumnWidth(i + 1, 120)
self.paramList.InsertColumn(len(self.attrs) + 1, _t("Price"))
self.paramList.SetColumnWidth(len(self.attrs) + 1, 60)
# Add Buck/bang column header
buckBangHeader = _t("Buck/bang")
if self.bangBuckColumnName:
buckBangHeader = _t("Buck/bang ({})").format(self.bangBuckColumnName)
self.paramList.InsertColumn(len(self.attrs) + 2, buckBangHeader)
self.paramList.SetColumnWidth(len(self.attrs) + 2, 80)
toHighlight = []
for item in self.items:
i = self.paramList.InsertItem(self.paramList.GetItemCount(), item.name)
for x, attr in enumerate(self.attrs.keys()):
if attr in item.attributes:
# Handle computed attributes
if attr in self.computedAttrs:
computed = self.computedAttrs[attr]
amountAttr = computed["amountAttr"]
durationAttr = computed["durationAttr"]
# Item needs both attributes to display per-second value
if amountAttr in item.attributes and durationAttr in item.attributes:
amountValue = item.attributes[amountAttr].value
durationValue = item.attributes[durationAttr].value
# Duration is in milliseconds, convert to seconds
perSecondValue = amountValue / (durationValue / 1000.0) if durationValue > 0 else 0
info = self.attrs[attr]
if self.toggleView == 1:
valueUnit = formatAmount(perSecondValue, 3, 0, 0) + " " + info.unit.displayName
else:
valueUnit = str(perSecondValue)
self.paramList.SetItem(i, x + 1, valueUnit)
# else: leave cell empty
elif attr in item.attributes:
info = self.attrs[attr]
value = item.attributes[attr].value
if self.toggleView != 1:
@@ -191,6 +347,27 @@ class ItemCompare(wx.Panel):
# Add prices
self.paramList.SetItem(i, len(self.attrs) + 1, formatAmount(item.price.price, 3, 3, 9, currency=True) if item.price.price else "")
# Add buck/bang values
if self.bangBuckColumn is not None and item.price.price and item.price.price > 0:
attr_key = list(self.attrs.keys())[self.bangBuckColumn - 1]
if attr_key in self.computedAttrs:
computed = self.computedAttrs[attr_key]
amountAttr = computed["amountAttr"]
durationAttr = computed["durationAttr"]
if amountAttr in item.attributes and durationAttr in item.attributes:
amountValue = item.attributes[amountAttr].value
durationValue = item.attributes[durationAttr].value
perSecondValue = amountValue / (durationValue / 1000.0) if durationValue > 0 else 0
if perSecondValue > 0:
buckBangValue = item.price.price / perSecondValue
self.paramList.SetItem(i, len(self.attrs) + 2, formatAmount(buckBangValue, 3, 3, 9, currency=True))
elif attr_key in item.attributes:
attrValue = item.attributes[attr_key].value
if attrValue > 0:
buckBangValue = item.price.price / attrValue
self.paramList.SetItem(i, len(self.attrs) + 2, formatAmount(buckBangValue, 3, 3, 9, currency=True))
if item.name in self.highlightedNames:
toHighlight.append(i)

View File

@@ -497,11 +497,27 @@ class FittingView(d.Display):
fit = Fit.getInstance().getFit(fitID)
if mod in fit.modules:
position = fit.modules.index(mod)
self.mainFrame.command.Submit(cmd.GuiCargoToLocalModuleCommand(
fitID=fitID,
cargoItemID=cargoItemID,
modPosition=position,
copy=wx.GetMouseState().GetModifiers() == wx.MOD_CONTROL))
modifiers = wx.GetMouseState().GetModifiers()
isCopy = modifiers == wx.MOD_CONTROL
isBatch = modifiers == wx.MOD_SHIFT
if isBatch:
self.mainFrame.command.Submit(
cmd.GuiBatchCargoToLocalModuleCommand(
fitID=fitID,
cargoItemID=cargoItemID,
targetPosition=position,
copy=isCopy,
)
)
else:
self.mainFrame.command.Submit(
cmd.GuiCargoToLocalModuleCommand(
fitID=fitID,
cargoItemID=cargoItemID,
modPosition=position,
copy=isCopy,
)
)
def swapItems(self, x, y, srcIdx):
"""Swap two modules in fitting window"""
@@ -795,7 +811,6 @@ class FittingView(d.Display):
del mod.restrictionOverridden
hasRestrictionOverriden = not hasRestrictionOverriden
if slotMap[mod.slot] or hasRestrictionOverriden: # Color too many modules as red
self.SetItemBackgroundColour(i, errColorDark if isDark() else errColor)
elif sFit.serviceFittingOptions["colorFitBySlot"]: # Color by slot it enabled

View File

@@ -59,6 +59,12 @@ from .gui.localModule.mutatedRevert import GuiRevertMutatedLocalModuleCommand
from .gui.localModule.remove import GuiRemoveLocalModuleCommand
from .gui.localModule.replace import GuiReplaceLocalModuleCommand
from .gui.localModule.swap import GuiSwapLocalModulesCommand
from .gui.localModuleCargo.batchCargoToLocalModule import (
GuiBatchCargoToLocalModuleCommand,
)
from .gui.localModuleCargo.batchLocalModuleToCargo import (
GuiBatchLocalModuleToCargoCommand,
)
from .gui.localModuleCargo.cargoToLocalModule import GuiCargoToLocalModuleCommand
from .gui.localModuleCargo.localModuleToCargo import GuiLocalModuleToCargoCommand
from .gui.projectedChangeProjectionRange import GuiChangeProjectedItemsProjectionRangeCommand

View File

@@ -0,0 +1,325 @@
import wx
import eos.db
import gui.mainFrame
from gui import globalEvents as GE
from gui.fitCommands.calc.cargo.add import CalcAddCargoCommand
from gui.fitCommands.calc.cargo.remove import CalcRemoveCargoCommand
from gui.fitCommands.calc.module.changeCharges import CalcChangeModuleChargesCommand
from gui.fitCommands.calc.module.localReplace import CalcReplaceLocalModuleCommand
from gui.fitCommands.helpers import (
CargoInfo,
InternalCommandHistory,
ModuleInfo,
restoreRemovedDummies,
)
from service.fit import Fit
class GuiBatchCargoToLocalModuleCommand(wx.Command):
def __init__(self, fitID, cargoItemID, targetPosition, copy):
wx.Command.__init__(self, True, "Batch Cargo to Local Modules")
self.internalHistory = InternalCommandHistory()
self.fitID = fitID
self.srcCargoItemID = cargoItemID
self.targetPosition = targetPosition
self.copy = copy
self.replacedModItemIDs = []
self.savedRemovedDummies = None
def Do(self):
sFit = Fit.getInstance()
fit = sFit.getFit(self.fitID)
if fit is None:
return False
srcCargo = next((c for c in fit.cargo if c.itemID == self.srcCargoItemID), None)
if srcCargo is None:
return False
if srcCargo.item.isCharge:
return self._handleCharges(fit, srcCargo)
if not srcCargo.item.isModule:
return False
if self.targetPosition >= len(fit.modules):
return False
targetMod = fit.modules[self.targetPosition]
if targetMod.isEmpty:
return self._fillEmptySlots(fit, srcCargo, targetMod.slot)
else:
return self._replaceSimilarModules(fit, srcCargo, targetMod)
def _getSimilarModulePositions(self, fit, targetMod):
targetItemID = targetMod.itemID
matchingPositions = []
for position, mod in enumerate(fit.modules):
if mod.isEmpty:
continue
if mod.itemID == targetItemID:
matchingPositions.append(position)
return matchingPositions
def _replaceSimilarModules(self, fit, srcCargo, targetMod):
availableAmount = srcCargo.amount if not self.copy else float("inf")
matchingPositions = self._getSimilarModulePositions(fit, targetMod)
if not matchingPositions:
return False
positionsToReplace = matchingPositions[: int(availableAmount)]
if not positionsToReplace:
return False
self.replacedModItemIDs = []
commands = []
cargoToRemove = 0
for position in positionsToReplace:
mod = fit.modules[position]
if mod.isEmpty:
continue
dstModItemID = mod.itemID
newModInfo = ModuleInfo.fromModule(mod, unmutate=True)
newModInfo.itemID = self.srcCargoItemID
newCargoModItemID = ModuleInfo.fromModule(mod, unmutate=True).itemID
commands.append(
CalcAddCargoCommand(
fitID=self.fitID,
cargoInfo=CargoInfo(itemID=newCargoModItemID, amount=1),
)
)
cmdReplace = CalcReplaceLocalModuleCommand(
fitID=self.fitID,
position=position,
newModInfo=newModInfo,
unloadInvalidCharges=True,
)
commands.append(cmdReplace)
self.replacedModItemIDs.append(dstModItemID)
cargoToRemove += 1
if not self.copy and cargoToRemove > 0:
commands.insert(
0,
CalcRemoveCargoCommand(
fitID=self.fitID,
cargoInfo=CargoInfo(
itemID=self.srcCargoItemID, amount=cargoToRemove
),
),
)
success = self.internalHistory.submitBatch(*commands)
if not success:
self.internalHistory.undoAll()
return False
eos.db.flush()
sFit = Fit.getInstance()
sFit.recalc(self.fitID)
self.savedRemovedDummies = sFit.fill(self.fitID)
eos.db.commit()
events = []
for removedModItemID in self.replacedModItemIDs:
events.append(
GE.FitChanged(
fitIDs=(self.fitID,), action="moddel", typeID=removedModItemID
)
)
if self.srcCargoItemID is not None:
events.append(
GE.FitChanged(
fitIDs=(self.fitID,), action="modadd", typeID=self.srcCargoItemID
)
)
if not events:
events.append(GE.FitChanged(fitIDs=(self.fitID,)))
for event in events:
wx.PostEvent(gui.mainFrame.MainFrame.getInstance(), event)
return True
def _fillEmptySlots(self, fit, srcCargo, targetSlot):
availableAmount = srcCargo.amount if not self.copy else float("inf")
emptyPositions = []
for position, mod in enumerate(fit.modules):
if mod.isEmpty and mod.slot == targetSlot:
emptyPositions.append(position)
if not emptyPositions:
return False
positionsToFill = emptyPositions[: int(availableAmount)]
if not positionsToFill:
return False
commands = []
cargoToRemove = 0
for position in positionsToFill:
newModInfo = ModuleInfo(itemID=self.srcCargoItemID)
cmdReplace = CalcReplaceLocalModuleCommand(
fitID=self.fitID,
position=position,
newModInfo=newModInfo,
unloadInvalidCharges=True,
)
commands.append(cmdReplace)
cargoToRemove += 1
if not self.copy and cargoToRemove > 0:
commands.insert(
0,
CalcRemoveCargoCommand(
fitID=self.fitID,
cargoInfo=CargoInfo(
itemID=self.srcCargoItemID, amount=cargoToRemove
),
),
)
success = self.internalHistory.submitBatch(*commands)
if not success:
self.internalHistory.undoAll()
return False
eos.db.flush()
sFit = Fit.getInstance()
sFit.recalc(self.fitID)
self.savedRemovedDummies = sFit.fill(self.fitID)
eos.db.commit()
events = []
if self.srcCargoItemID is not None:
events.append(
GE.FitChanged(
fitIDs=(self.fitID,), action="modadd", typeID=self.srcCargoItemID
)
)
if not events:
events.append(GE.FitChanged(fitIDs=(self.fitID,)))
for event in events:
wx.PostEvent(gui.mainFrame.MainFrame.getInstance(), event)
return True
def _handleCharges(self, fit, srcCargo):
availableAmount = srcCargo.amount if not self.copy else float("inf")
targetMod = fit.modules[self.targetPosition]
if targetMod.isEmpty:
return False
targetItemID = targetMod.itemID
matchingPositions = []
for position, mod in enumerate(fit.modules):
if mod.isEmpty:
continue
if mod.itemID == targetItemID:
matchingPositions.append(position)
if not matchingPositions:
return False
positionsToReplace = matchingPositions[: int(availableAmount)]
if not positionsToReplace:
return False
commands = []
chargeMap = {}
totalChargesNeeded = 0
for position in positionsToReplace:
mod = fit.modules[position]
if mod.isEmpty:
continue
oldChargeID = mod.chargeID
oldChargeAmount = mod.numCharges
newChargeAmount = mod.getNumCharges(srcCargo.item)
if oldChargeID is not None and oldChargeID != srcCargo.itemID:
commands.append(
CalcAddCargoCommand(
fitID=self.fitID,
cargoInfo=CargoInfo(itemID=oldChargeID, amount=oldChargeAmount),
)
)
chargeMap[position] = srcCargo.itemID
totalChargesNeeded += newChargeAmount
if not self.copy and totalChargesNeeded > 0:
commands.append(
CalcRemoveCargoCommand(
fitID=self.fitID,
cargoInfo=CargoInfo(
itemID=srcCargo.itemID, amount=totalChargesNeeded
),
)
)
commands.append(
CalcChangeModuleChargesCommand(
fitID=self.fitID, projected=False, chargeMap=chargeMap
)
)
success = self.internalHistory.submitBatch(*commands)
if not success:
self.internalHistory.undoAll()
return False
eos.db.flush()
sFit = Fit.getInstance()
sFit.recalc(self.fitID)
self.savedRemovedDummies = sFit.fill(self.fitID)
eos.db.commit()
events = [GE.FitChanged(fitIDs=(self.fitID,))]
for event in events:
wx.PostEvent(gui.mainFrame.MainFrame.getInstance(), event)
return True
def Undo(self):
sFit = Fit.getInstance()
fit = sFit.getFit(self.fitID)
restoreRemovedDummies(fit, self.savedRemovedDummies)
success = self.internalHistory.undoAll()
eos.db.flush()
sFit.recalc(self.fitID)
sFit.fill(self.fitID)
eos.db.commit()
events = []
if self.srcCargoItemID is not None:
events.append(
GE.FitChanged(
fitIDs=(self.fitID,), action="moddel", typeID=self.srcCargoItemID
)
)
for removedModItemID in self.replacedModItemIDs:
events.append(
GE.FitChanged(
fitIDs=(self.fitID,), action="modadd", typeID=removedModItemID
)
)
if not events:
events.append(GE.FitChanged(fitIDs=(self.fitID,)))
for event in events:
wx.PostEvent(gui.mainFrame.MainFrame.getInstance(), event)
return success

View File

@@ -0,0 +1,167 @@
import wx
import eos.db
import gui.mainFrame
from gui import globalEvents as GE
from gui.fitCommands.calc.cargo.add import CalcAddCargoCommand
from gui.fitCommands.calc.module.localRemove import CalcRemoveLocalModulesCommand
from gui.fitCommands.helpers import (
CargoInfo,
InternalCommandHistory,
ModuleInfo,
restoreRemovedDummies,
)
from service.fit import Fit
class GuiBatchLocalModuleToCargoCommand(wx.Command):
def __init__(self, fitID, modPosition, copy):
wx.Command.__init__(self, True, "Batch Local Module to Cargo")
self.internalHistory = InternalCommandHistory()
self.fitID = fitID
self.srcModPosition = modPosition
self.copy = copy
self.removedModItemIDs = []
self.savedRemovedDummies = None
def Do(self):
fit = Fit.getInstance().getFit(self.fitID)
srcMod = fit.modules[self.srcModPosition]
if srcMod.isEmpty:
return False
if srcMod.chargeID is not None:
return self._unloadCharges(fit, srcMod)
else:
return self._moveModulesToCargo(fit, srcMod)
def _getSimilarModulePositions(self, fit, targetMod):
targetItemID = targetMod.itemID
matchingPositions = []
for position, mod in enumerate(fit.modules):
if mod.isEmpty:
continue
if mod.itemID == targetItemID:
matchingPositions.append(position)
return matchingPositions
def _unloadCharges(self, fit, srcMod):
matchingPositions = self._getSimilarModulePositions(fit, srcMod)
if not matchingPositions:
return False
commands = []
for position in matchingPositions:
mod = fit.modules[position]
if mod.isEmpty:
continue
if mod.chargeID is not None:
commands.append(
CalcAddCargoCommand(
fitID=self.fitID,
cargoInfo=CargoInfo(itemID=mod.chargeID, amount=mod.numCharges),
)
)
if not self.copy:
from gui.fitCommands.calc.module.changeCharges import (
CalcChangeModuleChargesCommand,
)
chargeMap = {pos: None for pos in matchingPositions}
commands.append(
CalcChangeModuleChargesCommand(
fitID=self.fitID, projected=False, chargeMap=chargeMap
)
)
success = self.internalHistory.submitBatch(*commands)
if not success:
self.internalHistory.undoAll()
return False
eos.db.flush()
sFit = Fit.getInstance()
sFit.recalc(self.fitID)
self.savedRemovedDummies = sFit.fill(self.fitID)
eos.db.commit()
events = [GE.FitChanged(fitIDs=(self.fitID,))]
for event in events:
wx.PostEvent(gui.mainFrame.MainFrame.getInstance(), event)
return success
def _moveModulesToCargo(self, fit, srcMod):
matchingPositions = self._getSimilarModulePositions(fit, srcMod)
if not matchingPositions:
return False
commands = []
for position in matchingPositions:
mod = fit.modules[position]
if mod.isEmpty:
continue
commands.append(
CalcAddCargoCommand(
fitID=self.fitID,
cargoInfo=CargoInfo(
itemID=ModuleInfo.fromModule(mod, unmutate=True).itemID,
amount=1,
),
)
)
self.removedModItemIDs.append(mod.itemID)
if not self.copy:
commands.append(
CalcRemoveLocalModulesCommand(
fitID=self.fitID, positions=matchingPositions
)
)
success = self.internalHistory.submitBatch(*commands)
if not success:
self.internalHistory.undoAll()
return False
eos.db.flush()
sFit = Fit.getInstance()
sFit.recalc(self.fitID)
self.savedRemovedDummies = sFit.fill(self.fitID)
eos.db.commit()
events = []
for removedModItemID in self.removedModItemIDs:
events.append(
GE.FitChanged(
fitIDs=(self.fitID,), action="moddel", typeID=removedModItemID
)
)
if not events:
events.append(GE.FitChanged(fitIDs=(self.fitID,)))
for event in events:
wx.PostEvent(gui.mainFrame.MainFrame.getInstance(), event)
return success
def Undo(self):
sFit = Fit.getInstance()
fit = sFit.getFit(self.fitID)
restoreRemovedDummies(fit, self.savedRemovedDummies)
success = self.internalHistory.undoAll()
eos.db.flush()
sFit.recalc(self.fitID)
sFit.fill(self.fitID)
eos.db.commit()
events = []
for removedModItemID in self.removedModItemIDs:
events.append(
GE.FitChanged(
fitIDs=(self.fitID,), action="modadd", typeID=removedModItemID
)
)
if not events:
events.append(GE.FitChanged(fitIDs=(self.fitID,)))
for event in events:
wx.PostEvent(gui.mainFrame.MainFrame.getInstance(), event)
return success

View File

@@ -20,15 +20,19 @@
# noinspection PyPackageRequirements
import wx
from collections import Counter
import re
from eos.const import FittingSlot
from service.fit import Fit as svcFit
from service.port.eft import exportEft, importEft, _importPrepare
from service.port.eft import exportEft
from service.const import PortEftOptions
_t = wx.GetTranslation
# Regex for parsing items: itemName x? quantity?, ,? chargeName?
ITEM_REGEX = re.compile(
r"^(?P<itemName>[-\'\w\s]+?)x?\s*(?P<quantity>\d+)?\s*(?:,\s*(?P<chargeName>[-\'\w\s]+))?$"
)
class FitDiffFrame(wx.Frame):
"""A frame to display differences between two fits."""
@@ -169,158 +173,44 @@ class FitDiffFrame(wx.Frame):
self.diffText.SetValue('\n'.join(diffLines))
def parsePastedFit(self, text):
"""Parse pasted EFT text into a fit object."""
try:
lines = _importPrepare(text.splitlines())
if not lines:
return None
return importEft(lines)
except Exception:
return None
"""Parse pasted EFT text into a map of item name to count."""
items = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("["):
continue
match = ITEM_REGEX.match(line)
if match:
item_name = match.group("itemName").strip()
quantity = match.group("quantity")
count = int(quantity) if quantity else 1
if item_name not in items:
items[item_name] = 0
items[item_name] += count
return items
def calculateDiff(self, fit1, fit2):
def calculateDiff(self, fit1_items, fit2_items):
"""Calculate items needed to transform fit1 into fit2.
Returns a list of strings in the format: "<item> <quantity>"
Only shows items that need to be added (no negative values).
Returns a list of strings showing additions and extra items.
"""
diffLines = []
# Get module counts by type for each fit (grouped by slot type)
fit1_modules = self.getModuleCounts(fit1)
fit2_modules = self.getModuleCounts(fit2)
all_items = set(fit1_items.keys()) | set(fit2_items.keys())
additions = []
extras = []
# Slot order
slotOrder = [
FittingSlot.HIGH,
FittingSlot.MED,
FittingSlot.LOW,
FittingSlot.RIG,
FittingSlot.SUBSYSTEM,
FittingSlot.SERVICE,
]
# Diff modules by slot - only show items needed to add
for slot in slotOrder:
fit1_slot_modules = fit1_modules.get(slot, Counter())
fit2_slot_modules = fit2_modules.get(slot, Counter())
all_module_types = set(fit1_slot_modules.keys()) | set(fit2_slot_modules.keys())
slot_diff_lines = []
for module_type in sorted(all_module_types):
count1 = fit1_slot_modules.get(module_type, 0)
count2 = fit2_slot_modules.get(module_type, 0)
if count2 > count1:
slot_diff_lines.append(f"{module_type} x{count2 - count1}")
if slot_diff_lines:
if diffLines:
diffLines.append("")
diffLines.extend(slot_diff_lines)
# Get drone counts
fit1_drones = self.getDroneCounts(fit1)
fit2_drones = self.getDroneCounts(fit2)
all_drone_types = set(fit1_drones.keys()) | set(fit2_drones.keys())
for drone_type in sorted(all_drone_types):
count1 = fit1_drones.get(drone_type, 0)
count2 = fit2_drones.get(drone_type, 0)
for item in sorted(all_items):
count1 = fit1_items.get(item, 0)
count2 = fit2_items.get(item, 0)
if count2 > count1:
diffLines.append(f"{drone_type} x{count2 - count1}")
additions.append(f"{item} x{count2 - count1}")
elif count1 > count2:
extras.append(f"{item} x-{count1 - count2}")
# Get fighter counts
fit1_fighters = self.getFighterCounts(fit1)
fit2_fighters = self.getFighterCounts(fit2)
all_fighter_types = set(fit1_fighters.keys()) | set(fit2_fighters.keys())
for fighter_type in sorted(all_fighter_types):
count1 = fit1_fighters.get(fighter_type, 0)
count2 = fit2_fighters.get(fighter_type, 0)
if count2 > count1:
diffLines.append(f"{fighter_type} x{count2 - count1}")
# Get cargo counts
fit1_cargo = self.getCargoCounts(fit1)
fit2_cargo = self.getCargoCounts(fit2)
all_cargo_types = set(fit1_cargo.keys()) | set(fit2_cargo.keys())
for cargo_type in sorted(all_cargo_types):
count1 = fit1_cargo.get(cargo_type, 0)
count2 = fit2_cargo.get(cargo_type, 0)
if count2 > count1:
diffLines.append(f"{cargo_type} x{count2 - count1}")
# Get implants
fit1_implants = self.getImplantNames(fit1)
fit2_implants = self.getImplantNames(fit2)
for implant in sorted(fit2_implants - fit1_implants):
diffLines.append(f"{implant} x1")
# Get boosters
fit1_boosters = self.getBoosterNames(fit1)
fit2_boosters = self.getBoosterNames(fit2)
for booster in sorted(fit2_boosters - fit1_boosters):
diffLines.append(f"{booster} x1")
diffLines.extend(additions)
if additions and extras:
diffLines.extend(["", ""])
diffLines.extend(extras)
return diffLines
def getModuleCounts(self, fit):
"""Get a counter of module types for a fit, grouped by slot type.
Returns a dict mapping FittingSlot -> Counter of module names.
Position doesn't matter, just counts by module name.
"""
counts_by_slot = {}
for module in fit.modules:
if module.isEmpty:
continue
slot = module.slot
if slot not in counts_by_slot:
counts_by_slot[slot] = Counter()
# Use item type name for comparison
name = module.item.typeName if module.item else ""
counts_by_slot[slot][name] += 1
return counts_by_slot
def getDroneCounts(self, fit):
"""Get a counter of drone types for a fit."""
counts = Counter()
for drone in fit.drones:
if drone.item:
counts[drone.item.typeName] += drone.amount
return counts
def getFighterCounts(self, fit):
"""Get a counter of fighter types for a fit."""
counts = Counter()
for fighter in fit.fighters:
if fighter.item:
counts[fighter.item.typeName] += fighter.amount
return counts
def getCargoCounts(self, fit):
"""Get a counter of cargo items for a fit."""
counts = Counter()
for cargo in fit.cargo:
if cargo.item:
counts[cargo.item.typeName] += cargo.amount
return counts
def getImplantNames(self, fit):
"""Get a set of implant names for a fit."""
names = set()
for implant in fit.implants:
if implant.item:
names.add(implant.item.typeName)
return names
def getBoosterNames(self, fit):
"""Get a set of booster names for a fit."""
names = set()
for booster in fit.boosters:
if booster.item:
names.add(booster.item.typeName)
return names