Compare commits
4 Commits
v2.65.2.23
...
v2.65.2.27
| Author | SHA1 | Date | |
|---|---|---|---|
| 665f797d51 | |||
| e119eeb14a | |||
| d8e6cc76c9 | |||
| bfd5bbb881 |
@@ -18,13 +18,14 @@
|
||||
# =============================================================================
|
||||
|
||||
|
||||
from . import fitDamageStats
|
||||
from . import fitEwarStats
|
||||
from . import fitRemoteReps
|
||||
from . import fitShieldRegen
|
||||
from . import fitCapacitor
|
||||
from . import fitMobility
|
||||
from . import fitWarpTime
|
||||
from . import fitLockTime
|
||||
from . import fitDamageStats as fitDamageStats
|
||||
from . import fitEwarStats as fitEwarStats
|
||||
from . import fitRemoteReps as fitRemoteReps
|
||||
from . import fitShieldRegen as fitShieldRegen
|
||||
from . import fitCapacitor as fitCapacitor
|
||||
from . import fitMobility as fitMobility
|
||||
from . import fitWarpTime as fitWarpTime
|
||||
from . import fitLockTime as fitLockTime
|
||||
from . import fitHeat as fitHeat
|
||||
# Hidden graphs, available via ctrl-alt-g
|
||||
from . import fitEcmBurstScanresDamps
|
||||
from . import fitEcmBurstScanresDamps as fitEcmBurstScanresDamps
|
||||
|
||||
25
graphs/data/fitHeat/__init__.py
Normal file
25
graphs/data/fitHeat/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# =============================================================================
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
from .graph import FitHeatGraph
|
||||
|
||||
|
||||
FitHeatGraph.register()
|
||||
|
||||
285
graphs/data/fitHeat/calc.py
Normal file
285
graphs/data/fitHeat/calc.py
Normal file
@@ -0,0 +1,285 @@
|
||||
# =============================================================================
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
import math
|
||||
import random
|
||||
|
||||
from eos.const import FittingModuleState, FittingSlot
|
||||
|
||||
|
||||
_RACK_SUFFIXES = {
|
||||
FittingSlot.HIGH: "Hi",
|
||||
FittingSlot.MED: "Med",
|
||||
FittingSlot.LOW: "Low",
|
||||
}
|
||||
|
||||
# Cache: (fit_id, rack_slot, max_time_s, iterations) -> list of burnout time samples
|
||||
_burnout_samples_cache = {}
|
||||
|
||||
|
||||
def clear_burnout_samples_cache(fit_id=None):
|
||||
if fit_id is None:
|
||||
_burnout_samples_cache.clear()
|
||||
return
|
||||
to_drop = [k for k in _burnout_samples_cache if k[0] == fit_id]
|
||||
for k in to_drop:
|
||||
del _burnout_samples_cache[k]
|
||||
|
||||
|
||||
def _get_rack_suffix(rack_slot):
|
||||
return _RACK_SUFFIXES[rack_slot]
|
||||
|
||||
|
||||
def iter_rack_modules(fit, rack_slot):
|
||||
for mod in fit.modules:
|
||||
if mod.isEmpty:
|
||||
continue
|
||||
if mod.slot == rack_slot:
|
||||
yield mod
|
||||
|
||||
|
||||
def get_rack_heat_value(fit, rack_slot, time_s):
|
||||
"""
|
||||
Deterministic rack heat H(t) for a given rack and time, in [0, 1].
|
||||
"""
|
||||
rack_suffix = _get_rack_suffix(rack_slot)
|
||||
ship = fit.ship
|
||||
heat_capacity = ship.getModifiedItemAttr(f"heatCapacity{rack_suffix}")
|
||||
heat_generation_multiplier = ship.getModifiedItemAttr("heatGenerationMultiplier")
|
||||
if heat_capacity is None or heat_generation_multiplier is None:
|
||||
raise ValueError("Missing heat attributes on ship for rack heat calculation")
|
||||
# Sum heat absorption over all overheated modules in this rack
|
||||
sum_absorption = 0.0
|
||||
for mod in iter_rack_modules(fit, rack_slot):
|
||||
if mod.state >= FittingModuleState.OVERHEATED:
|
||||
sum_absorption += mod.getModifiedItemAttr("heatAbsorbtionRateModifier")
|
||||
argument = -time_s * heat_generation_multiplier * sum_absorption
|
||||
# Guard against numeric issues
|
||||
try:
|
||||
exp_term = math.exp(argument)
|
||||
except OverflowError:
|
||||
exp_term = 0.0 if argument < 0 else float("inf")
|
||||
heat = heat_capacity / 100.0 - exp_term
|
||||
return heat
|
||||
|
||||
|
||||
def _count_online_modules_by_rack(fit):
|
||||
counts = {
|
||||
FittingSlot.HIGH: 0,
|
||||
FittingSlot.MED: 0,
|
||||
FittingSlot.LOW: 0,
|
||||
}
|
||||
for mod in fit.modules:
|
||||
if mod.isEmpty:
|
||||
continue
|
||||
if mod.state >= FittingModuleState.ONLINE and mod.slot in counts:
|
||||
counts[mod.slot] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def _get_total_slot_count(fit):
|
||||
total = 0
|
||||
for slot_type in (FittingSlot.HIGH, FittingSlot.MED, FittingSlot.LOW, FittingSlot.RIG):
|
||||
total += fit.getNumSlots(slot_type)
|
||||
return total
|
||||
|
||||
|
||||
def _get_base_module_hp(mod):
|
||||
hp = mod.getModifiedItemAttr("hp")
|
||||
return float(hp)
|
||||
|
||||
|
||||
def _get_heat_damage(mod):
|
||||
dmg = mod.getModifiedItemAttr("heatDamage")
|
||||
return float(dmg)
|
||||
|
||||
|
||||
def _get_cycle_time_s(mod):
|
||||
cycle_params = mod.getCycleParameters()
|
||||
if cycle_params is None:
|
||||
return None
|
||||
avg_time_ms = cycle_params.averageTime
|
||||
if not math.isfinite(avg_time_ms) or avg_time_ms <= 0:
|
||||
return None
|
||||
return avg_time_ms / 1000.0
|
||||
|
||||
|
||||
def get_first_burnout_samples(fit, rack_slot, max_time_s, iterations):
|
||||
"""
|
||||
Monte Carlo simulation of time until the first module in the given rack burns out.
|
||||
Returns a list of burnout times (seconds). If no burnout happens before max_time_s,
|
||||
the sample is set to max_time_s for that run.
|
||||
"""
|
||||
if max_time_s <= 0 or iterations <= 0:
|
||||
raise ValueError("max_time_s and iterations must be positive.")
|
||||
|
||||
cache_key = (getattr(fit, "ID", None), int(rack_slot), max_time_s, iterations)
|
||||
if cache_key in _burnout_samples_cache:
|
||||
return list(_burnout_samples_cache[cache_key])
|
||||
|
||||
rack_suffix = _get_rack_suffix(rack_slot)
|
||||
ship = fit.ship
|
||||
heat_capacity = ship.getModifiedItemAttr(f"heatCapacity{rack_suffix}")
|
||||
heat_generation_multiplier = ship.getModifiedItemAttr("heatGenerationMultiplier")
|
||||
heat_attenuation = ship.getModifiedItemAttr(f"heatAttenuation{rack_suffix}")
|
||||
if (
|
||||
heat_capacity is None
|
||||
or heat_generation_multiplier is None
|
||||
or heat_generation_multiplier <= 0
|
||||
or heat_attenuation is None
|
||||
):
|
||||
raise ValueError("Missing heat attributes on ship for burnout simulation")
|
||||
|
||||
rack_modules = list(iter_rack_modules(fit, rack_slot))
|
||||
if not rack_modules:
|
||||
raise ValueError("No modules in this rack.")
|
||||
|
||||
overheated_indices = [
|
||||
idx for idx, mod in enumerate(rack_modules) if mod.state >= FittingModuleState.OVERHEATED
|
||||
]
|
||||
if not overheated_indices:
|
||||
raise ValueError(
|
||||
"No overheated modules in this rack. Overheat at least one module in this rack to see the first-burnout CDF."
|
||||
)
|
||||
|
||||
total_slots = _get_total_slot_count(fit)
|
||||
if total_slots <= 0:
|
||||
raise ValueError("Ship has no high/mid/low/rig slots.")
|
||||
base_online_counts = _count_online_modules_by_rack(fit)
|
||||
|
||||
base_hp = [_get_base_module_hp(mod) for mod in rack_modules]
|
||||
heat_damage = [_get_heat_damage(mod) for mod in rack_modules]
|
||||
heat_absorption = [
|
||||
mod.getModifiedItemAttr("heatAbsorbtionRateModifier") for mod in rack_modules
|
||||
]
|
||||
cycle_times = [_get_cycle_time_s(mod) if idx in overheated_indices else None
|
||||
for idx, mod in enumerate(rack_modules)]
|
||||
eligible_targets = [
|
||||
mod.state >= FittingModuleState.ONLINE for mod in rack_modules
|
||||
]
|
||||
positions = list(range(len(rack_modules)))
|
||||
|
||||
samples = []
|
||||
|
||||
for _ in range(iterations):
|
||||
hp = list(base_hp)
|
||||
dead = [hp_val <= 0 for hp_val in hp]
|
||||
online_counts = dict(base_online_counts)
|
||||
next_times = [None] * len(rack_modules)
|
||||
|
||||
for idx in overheated_indices:
|
||||
if not dead[idx] and cycle_times[idx] is not None:
|
||||
next_times[idx] = cycle_times[idx]
|
||||
|
||||
sample_time = max_time_s
|
||||
|
||||
while True:
|
||||
# Find next event time
|
||||
candidates = [t for t in next_times if t is not None]
|
||||
if not candidates:
|
||||
break
|
||||
current_time = min(candidates)
|
||||
if current_time > max_time_s:
|
||||
break
|
||||
|
||||
# Dynamic sum of heat absorption from still-active overheated modules
|
||||
sum_absorption = 0.0
|
||||
for idx in overheated_indices:
|
||||
if not dead[idx] and cycle_times[idx] is not None:
|
||||
sum_absorption += heat_absorption[idx]
|
||||
if sum_absorption <= 0:
|
||||
break
|
||||
|
||||
argument = -current_time * heat_generation_multiplier * sum_absorption
|
||||
try:
|
||||
exp_term = math.exp(argument)
|
||||
except OverflowError:
|
||||
exp_term = 0.0 if argument < 0 else float("inf")
|
||||
heat = heat_capacity / 100.0 - exp_term
|
||||
if heat <= 0:
|
||||
break
|
||||
|
||||
numerator = (
|
||||
online_counts[FittingSlot.HIGH]
|
||||
+ online_counts[FittingSlot.MED]
|
||||
+ online_counts[FittingSlot.LOW]
|
||||
)
|
||||
slot_factor = numerator / float(total_slots)
|
||||
if slot_factor <= 0:
|
||||
break
|
||||
|
||||
# Sources that complete a cycle at this time
|
||||
event_sources = [
|
||||
idx
|
||||
for idx in overheated_indices
|
||||
if not dead[idx]
|
||||
and next_times[idx] is not None
|
||||
and abs(next_times[idx] - current_time) <= 1e-9
|
||||
]
|
||||
if not event_sources:
|
||||
# No actual events despite candidates, advance all timers and continue
|
||||
for idx, next_time in enumerate(next_times):
|
||||
if next_time is not None and cycle_times[idx] is not None:
|
||||
next_times[idx] = next_time + cycle_times[idx]
|
||||
continue
|
||||
|
||||
burn_time = None
|
||||
|
||||
for src_idx in event_sources:
|
||||
dmg = heat_damage[src_idx]
|
||||
if dmg <= 0:
|
||||
continue
|
||||
src_pos = positions[src_idx]
|
||||
for tgt_idx, tgt_hp in enumerate(hp):
|
||||
if dead[tgt_idx] or not eligible_targets[tgt_idx]:
|
||||
continue
|
||||
distance = abs(positions[tgt_idx] - src_pos)
|
||||
attenuation_factor = heat_attenuation ** distance
|
||||
probability = heat * slot_factor * attenuation_factor
|
||||
if probability <= 0:
|
||||
continue
|
||||
if probability >= 1.0 or random.random() < probability:
|
||||
new_hp = tgt_hp - dmg
|
||||
hp[tgt_idx] = new_hp
|
||||
if new_hp <= 0 and not dead[tgt_idx]:
|
||||
dead[tgt_idx] = True
|
||||
if rack_modules[tgt_idx].slot in online_counts and rack_modules[
|
||||
tgt_idx
|
||||
].state >= FittingModuleState.ONLINE:
|
||||
online_counts[rack_modules[tgt_idx].slot] -= 1
|
||||
if tgt_idx in overheated_indices:
|
||||
next_times[tgt_idx] = None
|
||||
if burn_time is None or current_time < burn_time:
|
||||
burn_time = current_time
|
||||
|
||||
if burn_time is not None:
|
||||
sample_time = burn_time
|
||||
break
|
||||
|
||||
# Advance timers for all sources that fired at this time
|
||||
for src_idx in event_sources:
|
||||
if not dead[src_idx] and cycle_times[src_idx] is not None:
|
||||
next_times[src_idx] = current_time + cycle_times[src_idx]
|
||||
|
||||
samples.append(sample_time)
|
||||
|
||||
_burnout_samples_cache[cache_key] = samples
|
||||
return samples
|
||||
|
||||
109
graphs/data/fitHeat/getter.py
Normal file
109
graphs/data/fitHeat/getter.py
Normal file
@@ -0,0 +1,109 @@
|
||||
# =============================================================================
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
from eos.const import FittingSlot
|
||||
from graphs.data.base import SmoothPointGetter
|
||||
from .calc import get_first_burnout_samples, get_rack_heat_value
|
||||
|
||||
|
||||
class _BaseTime2RackHeatGetter(SmoothPointGetter):
|
||||
|
||||
rack_slot = None
|
||||
|
||||
def _getCommonData(self, miscParams, src, tgt):
|
||||
return {"fit": src.item}
|
||||
|
||||
def _calculatePoint(self, x, miscParams, src, tgt, commonData):
|
||||
fit = commonData["fit"]
|
||||
heat_value = get_rack_heat_value(fit, self.rack_slot, x)
|
||||
return heat_value * 100.0
|
||||
|
||||
|
||||
class Time2RackHeatHiGetter(_BaseTime2RackHeatGetter):
|
||||
rack_slot = FittingSlot.HIGH
|
||||
|
||||
|
||||
class Time2RackHeatMedGetter(_BaseTime2RackHeatGetter):
|
||||
rack_slot = FittingSlot.MED
|
||||
|
||||
|
||||
class Time2RackHeatLowGetter(_BaseTime2RackHeatGetter):
|
||||
rack_slot = FittingSlot.LOW
|
||||
|
||||
|
||||
class _BaseTime2BurnoutCdfGetter(SmoothPointGetter):
|
||||
|
||||
rack_slot = None
|
||||
_iterations = 200
|
||||
|
||||
def getRange(self, xRange, miscParams, src, tgt):
|
||||
fit = src.item
|
||||
# Fixed simulation horizon so CDF does not depend on view range
|
||||
max_sim_time = self.graph._limiters["time"](src, tgt)[1]
|
||||
samples = get_first_burnout_samples(
|
||||
fit=fit, rack_slot=self.rack_slot, max_time_s=max_sim_time, iterations=self._iterations
|
||||
)
|
||||
xs = []
|
||||
ys = []
|
||||
if not samples:
|
||||
for x in self._xIterLinear(xRange):
|
||||
xs.append(x)
|
||||
ys.append(0.0)
|
||||
return xs, ys
|
||||
samples = sorted(samples)
|
||||
total = float(len(samples))
|
||||
index = 0
|
||||
for x in self._xIterLinear(xRange):
|
||||
while index < len(samples) and samples[index] <= x:
|
||||
index += 1
|
||||
xs.append(x)
|
||||
ys.append(index / total)
|
||||
return xs, ys
|
||||
|
||||
def _calculatePoint(self, x, miscParams, src, tgt, commonData):
|
||||
return self.getPoint(x=x, miscParams=miscParams, src=src, tgt=tgt)
|
||||
|
||||
def getPoint(self, x, miscParams, src, tgt):
|
||||
fit = src.item
|
||||
max_sim_time = self.graph._limiters["time"](src, tgt)[1]
|
||||
samples = get_first_burnout_samples(
|
||||
fit=fit, rack_slot=self.rack_slot, max_time_s=max_sim_time, iterations=self._iterations
|
||||
)
|
||||
if not samples:
|
||||
return 0.0
|
||||
samples = sorted(samples)
|
||||
total = float(len(samples))
|
||||
index = 0
|
||||
while index < len(samples) and samples[index] <= x:
|
||||
index += 1
|
||||
return index / total
|
||||
|
||||
|
||||
class Time2BurnoutCdfHiGetter(_BaseTime2BurnoutCdfGetter):
|
||||
rack_slot = FittingSlot.HIGH
|
||||
|
||||
|
||||
class Time2BurnoutCdfMedGetter(_BaseTime2BurnoutCdfGetter):
|
||||
rack_slot = FittingSlot.MED
|
||||
|
||||
|
||||
class Time2BurnoutCdfLowGetter(_BaseTime2BurnoutCdfGetter):
|
||||
rack_slot = FittingSlot.LOW
|
||||
|
||||
96
graphs/data/fitHeat/graph.py
Normal file
96
graphs/data/fitHeat/graph.py
Normal file
@@ -0,0 +1,96 @@
|
||||
# =============================================================================
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
# noinspection PyPackageRequirements
|
||||
import wx
|
||||
|
||||
from service.const import GraphCacheCleanupReason
|
||||
from graphs.data.base import FitGraph, Input, XDef, YDef
|
||||
from .getter import (
|
||||
Time2BurnoutCdfHiGetter,
|
||||
Time2BurnoutCdfLowGetter,
|
||||
Time2BurnoutCdfMedGetter,
|
||||
Time2RackHeatHiGetter,
|
||||
Time2RackHeatLowGetter,
|
||||
Time2RackHeatMedGetter,
|
||||
)
|
||||
|
||||
|
||||
_t = wx.GetTranslation
|
||||
|
||||
|
||||
_CDF_Y_HANDLES = frozenset(("burnoutCdfHi", "burnoutCdfMed", "burnoutCdfLow"))
|
||||
|
||||
|
||||
class FitHeatGraph(FitGraph):
|
||||
|
||||
def getPlotPoints(self, mainInput, miscInputs, xSpec, ySpec, src, tgt=None):
|
||||
if ySpec.handle in _CDF_Y_HANDLES:
|
||||
return self._calcPlotPoints(
|
||||
mainInput=mainInput, miscInputs=miscInputs,
|
||||
xSpec=xSpec, ySpec=ySpec, src=src, tgt=tgt)
|
||||
return super().getPlotPoints(
|
||||
mainInput=mainInput, miscInputs=miscInputs,
|
||||
xSpec=xSpec, ySpec=ySpec, src=src, tgt=tgt)
|
||||
|
||||
# UI stuff
|
||||
internalName = "heatGraph"
|
||||
name = _t("Heat")
|
||||
xDefs = [
|
||||
XDef(handle="time", unit="s", label=_t("Time"), mainInput=("time", "s")),
|
||||
]
|
||||
yDefs = [
|
||||
YDef(handle="rackHeatHi", unit="%", label=_t("High rack heat")),
|
||||
YDef(handle="rackHeatMed", unit="%", label=_t("Mid rack heat")),
|
||||
YDef(handle="rackHeatLow", unit="%", label=_t("Low rack heat")),
|
||||
YDef(handle="burnoutCdfHi", unit=None, label=_t("High rack first-burnout CDF")),
|
||||
YDef(handle="burnoutCdfMed", unit=None, label=_t("Mid rack first-burnout CDF")),
|
||||
YDef(handle="burnoutCdfLow", unit=None, label=_t("Low rack first-burnout CDF")),
|
||||
]
|
||||
inputs = [
|
||||
Input(
|
||||
handle="time",
|
||||
unit="s",
|
||||
label=_t("Time"),
|
||||
iconID=1392,
|
||||
defaultValue=300,
|
||||
defaultRange=(0, 120),
|
||||
)
|
||||
]
|
||||
srcExtraCols = ()
|
||||
|
||||
# Calculation stuff
|
||||
_limiters = {
|
||||
"time": lambda src, tgt: (0, 3600),
|
||||
}
|
||||
_getters = {
|
||||
("time", "rackHeatHi"): Time2RackHeatHiGetter,
|
||||
("time", "rackHeatMed"): Time2RackHeatMedGetter,
|
||||
("time", "rackHeatLow"): Time2RackHeatLowGetter,
|
||||
("time", "burnoutCdfHi"): Time2BurnoutCdfHiGetter,
|
||||
("time", "burnoutCdfMed"): Time2BurnoutCdfMedGetter,
|
||||
("time", "burnoutCdfLow"): Time2BurnoutCdfLowGetter,
|
||||
}
|
||||
|
||||
def clearCache(self, reason, extraData=None):
|
||||
super().clearCache(reason=reason, extraData=extraData)
|
||||
from .calc import clear_burnout_samples_cache
|
||||
if reason in (GraphCacheCleanupReason.fitChanged, GraphCacheCleanupReason.fitRemoved) and extraData is not None:
|
||||
clear_burnout_samples_cache(fit_id=extraData)
|
||||
@@ -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()
|
||||
|
||||
@@ -79,6 +79,9 @@ class ItemCompare(wx.Panel):
|
||||
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:
|
||||
@@ -173,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)
|
||||
|
||||
@@ -189,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()
|
||||
@@ -245,6 +266,19 @@ class ItemCompare(wx.Panel):
|
||||
# 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
|
||||
@@ -257,12 +291,22 @@ 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:
|
||||
@@ -303,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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
325
gui/fitCommands/gui/localModuleCargo/batchCargoToLocalModule.py
Normal file
325
gui/fitCommands/gui/localModuleCargo/batchCargoToLocalModule.py
Normal 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
|
||||
167
gui/fitCommands/gui/localModuleCargo/batchLocalModuleToCargo.py
Normal file
167
gui/fitCommands/gui/localModuleCargo/batchLocalModuleToCargo.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user