Compare commits
96 Commits
v2.64.3
...
v2.65.2.33
| Author | SHA1 | Date | |
|---|---|---|---|
| b1c9b57ef7 | |||
| 5c01ecb2d1 | |||
| 564ba1d85d | |||
| 1c7886463d | |||
| bc23f380db | |||
| b9da617009 | |||
| dc38f33536 | |||
| cdc189676b | |||
| 665f797d51 | |||
| e119eeb14a | |||
| d8e6cc76c9 | |||
| bfd5bbb881 | |||
| c64991fb59 | |||
| ce5dca9818 | |||
| 38376046d0 | |||
| 38356acd37 | |||
| 64a11aaa6f | |||
| 1063a1ab49 | |||
| 959467028c | |||
| 9b4c523aa6 | |||
| 411ef933d1 | |||
| 0a1c177442 | |||
| a03c2e4091 | |||
| 564a68e5cb | |||
| aec20c1f5a | |||
| 8800533c8a | |||
| 1db6b3372c | |||
| 169b041677 | |||
| 3a5a9c6e09 | |||
| eadf18ec00 | |||
| b70833ea3e | |||
| f12a0fe237 | |||
| de7f6a0523 | |||
| fa6dc76d10 | |||
| f03ffa85d8 | |||
| 8d6ae56f33 | |||
| 64e339fb46 | |||
| 29ee808337 | |||
| 72d65e6118 | |||
| 135fdd8812 | |||
| 6bb0938be0 | |||
| 8a37ee810a | |||
| 4d1320161a | |||
| b5d6211ae0 | |||
| fa05cd625f | |||
| d18ebb6dc0 | |||
| f3a89157ca | |||
| 766d45dd17 | |||
| 457bbc0dc3 | |||
| 4ddf1733e4 | |||
| ca2a80cc85 | |||
|
|
c7074f499f | ||
|
|
f6f3a69be4 | ||
|
|
23e09729f7 | ||
|
|
0aca05704f | ||
|
|
b08894e984 | ||
|
|
a1bc8742c9 | ||
|
|
6472cabc05 | ||
|
|
56bb8217d3 | ||
|
|
17f9071317 | ||
|
|
50eda1f4db | ||
|
|
84fbc0a46c | ||
|
|
9551195078 | ||
|
|
f01949d892 | ||
|
|
26b4c05b6f | ||
|
|
6ecab03fd8 | ||
|
|
edc0418d9a | ||
|
|
1d413595b9 | ||
|
|
ce5a593f7b | ||
|
|
faea6a97f0 | ||
|
|
b92913cbf9 | ||
|
|
1af2e7f94b | ||
|
|
dbb61a8a37 | ||
|
|
b12adcae3d | ||
|
|
72567a7155 | ||
|
|
0d4c2551c1 | ||
|
|
ce10aeb55e | ||
|
|
7b14266f0d | ||
|
|
b436f6ec89 | ||
|
|
e7e3f4e626 | ||
|
|
ae8405d132 | ||
|
|
977cf61ff5 | ||
|
|
7cc4f6ec27 | ||
|
|
0e6b9b48f1 | ||
|
|
a00a80b4e4 | ||
|
|
6843373283 | ||
|
|
408da2e344 | ||
|
|
dc8ecae0f1 | ||
|
|
50e6ed516f | ||
|
|
42a6bb92a7 | ||
|
|
521a58d77b | ||
|
|
6546f32971 | ||
|
|
ad3019debe | ||
|
|
fe9fa8b4fe | ||
|
|
92fce5be96 | ||
|
|
edec81f4b8 |
@@ -32,7 +32,7 @@ for:
|
|||||||
- sh: export PYFA_VERSION="$(python3 -B scripts/dump_version.py)"
|
- sh: export PYFA_VERSION="$(python3 -B scripts/dump_version.py)"
|
||||||
- sh: mkdir build
|
- sh: mkdir build
|
||||||
# Download packaging tool
|
# Download packaging tool
|
||||||
- sh: curl -o $APPIMAGE_TOOL -L https://github.com/AppImageCrafters/appimage-builder/releases/download/v1.1.0/appimage-builder-1.1.0-x86_64.AppImage
|
- sh: curl --fail-with-body -o $APPIMAGE_TOOL -L https://github.com/AppImageCrafters/appimage-builder/releases/download/v1.1.0/appimage-builder-1.1.0-x86_64.AppImage
|
||||||
- sh: chmod +x $APPIMAGE_TOOL
|
- sh: chmod +x $APPIMAGE_TOOL
|
||||||
build_script:
|
build_script:
|
||||||
- sh: mkdir -p AppDir/opt/pyfa
|
- sh: mkdir -p AppDir/opt/pyfa
|
||||||
|
|||||||
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.venv
|
||||||
|
.git
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache
|
||||||
|
*.zip
|
||||||
|
*.spec
|
||||||
|
imgs
|
||||||
|
dist_assets
|
||||||
2
.gitattributes
vendored
@@ -33,4 +33,4 @@ pyfa.py text eol=lf
|
|||||||
*.jpg binary
|
*.jpg binary
|
||||||
*.icns binary
|
*.icns binary
|
||||||
*.ico binary
|
*.ico binary
|
||||||
|
*.dll filter=lfs diff=lfs merge=lfs -text
|
||||||
|
|||||||
3
.gitignore
vendored
@@ -126,4 +126,5 @@ gitversion
|
|||||||
/locale/progress.json
|
/locale/progress.json
|
||||||
|
|
||||||
# vscode settings
|
# vscode settings
|
||||||
.vscode
|
.vscode
|
||||||
|
eve.db
|
||||||
|
|||||||
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "Pyfa-Mod"]
|
||||||
|
path = Pyfa-Mod
|
||||||
|
url = https://github.com/Eivonz/Pyfa-Mod
|
||||||
11
Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements-server.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements-server.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 9123
|
||||||
|
|
||||||
|
CMD ["python", "pyfa_server.py"]
|
||||||
60
build.sh
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Building pyfa binary..."
|
||||||
|
|
||||||
|
# Ensure we're using the local venv
|
||||||
|
if [ ! -d ".venv" ]; then
|
||||||
|
echo "Creating virtual environment..."
|
||||||
|
uv venv
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
echo "Installing dependencies..."
|
||||||
|
uv pip install -r requirements.txt
|
||||||
|
uv pip install pyinstaller
|
||||||
|
|
||||||
|
# Clean previous builds
|
||||||
|
echo "Cleaning previous builds..."
|
||||||
|
rm -rf build dist
|
||||||
|
|
||||||
|
# Build the binary
|
||||||
|
echo "Building binary with PyInstaller..."
|
||||||
|
uv run pyinstaller pyfa.spec
|
||||||
|
|
||||||
|
# Sim server exe (console) into main dist folder
|
||||||
|
if [ -f dist/pyfa_server/pyfa-server.exe ]; then
|
||||||
|
cp dist/pyfa_server/pyfa-server.exe dist/pyfa/
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Docker image (Python server)
|
||||||
|
DOCKER_REPO="${DOCKER_REPO:-docker.site.quack-lab.dev}"
|
||||||
|
IMAGE_NAME="${IMAGE_NAME:-pyfa-server}"
|
||||||
|
COMMIT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
IMAGE_BASE="${DOCKER_REPO}/${IMAGE_NAME}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Building Docker image..."
|
||||||
|
docker build -t "${IMAGE_BASE}:${COMMIT_SHA}" .
|
||||||
|
|
||||||
|
docker tag "${IMAGE_BASE}:${COMMIT_SHA}" "${IMAGE_BASE}:latest"
|
||||||
|
|
||||||
|
TAGS=$(git tag --points-at HEAD 2>/dev/null || true)
|
||||||
|
if [ -n "$TAGS" ]; then
|
||||||
|
while IFS= read -r tag; do
|
||||||
|
[ -n "$tag" ] && docker tag "${IMAGE_BASE}:${COMMIT_SHA}" "${IMAGE_BASE}:${tag}"
|
||||||
|
done <<< "$TAGS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Build complete! dist/pyfa/pyfa.exe (GUI), dist/pyfa/pyfa-server.exe (POST /simulate :9123)"
|
||||||
|
echo ""
|
||||||
|
echo "Docker image built as:"
|
||||||
|
echo " - ${IMAGE_BASE}:${COMMIT_SHA}"
|
||||||
|
echo " - ${IMAGE_BASE}:latest"
|
||||||
|
if [ -n "$TAGS" ]; then
|
||||||
|
while IFS= read -r tag; do
|
||||||
|
[ -n "$tag" ] && echo " - ${IMAGE_BASE}:${tag}"
|
||||||
|
done <<< "$TAGS"
|
||||||
|
fi
|
||||||
43
config.py
@@ -1,7 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import yaml
|
import yaml
|
||||||
import wx
|
|
||||||
|
|
||||||
from logbook import CRITICAL, DEBUG, ERROR, FingersCrossedHandler, INFO, Logger, NestedSetup, NullHandler, \
|
from logbook import CRITICAL, DEBUG, ERROR, FingersCrossedHandler, INFO, Logger, NestedSetup, NullHandler, \
|
||||||
StreamHandler, TimedRotatingFileHandler, WARNING
|
StreamHandler, TimedRotatingFileHandler, WARNING
|
||||||
@@ -67,20 +66,34 @@ LOGLEVEL_MAP = {
|
|||||||
CATALOG = 'lang'
|
CATALOG = 'lang'
|
||||||
|
|
||||||
|
|
||||||
slotColourMapDark = {
|
def get_slotColourMapDark():
|
||||||
FittingSlot.LOW: wx.Colour(44, 36, 19), # yellow = low slots 24/13
|
import wx
|
||||||
FittingSlot.MED: wx.Colour(28, 39, 51), # blue = mid slots 8.1/9.5
|
return {
|
||||||
FittingSlot.HIGH: wx.Colour(53, 31, 34), # red = high slots 6.5/11.5
|
FittingSlot.LOW: wx.Colour(44, 36, 19), # yellow = low slots 24/13
|
||||||
FittingSlot.RIG: '',
|
FittingSlot.MED: wx.Colour(28, 39, 51), # blue = mid slots 8.1/9.5
|
||||||
FittingSlot.SUBSYSTEM: ''}
|
FittingSlot.HIGH: wx.Colour(53, 31, 34), # red = high slots 6.5/11.5
|
||||||
errColorDark = wx.Colour(70, 20, 20)
|
FittingSlot.RIG: '',
|
||||||
slotColourMap = {
|
FittingSlot.SUBSYSTEM: ''}
|
||||||
FittingSlot.LOW: wx.Colour(250, 235, 204), # yellow = low slots
|
|
||||||
FittingSlot.MED: wx.Colour(188, 215, 241), # blue = mid slots
|
|
||||||
FittingSlot.HIGH: wx.Colour(235, 204, 209), # red = high slots
|
def get_errColorDark():
|
||||||
FittingSlot.RIG: '',
|
import wx
|
||||||
FittingSlot.SUBSYSTEM: ''}
|
return wx.Colour(70, 20, 20)
|
||||||
errColor = wx.Colour(204, 51, 51)
|
|
||||||
|
|
||||||
|
def get_slotColourMap():
|
||||||
|
import wx
|
||||||
|
return {
|
||||||
|
FittingSlot.LOW: wx.Colour(250, 235, 204), # yellow = low slots
|
||||||
|
FittingSlot.MED: wx.Colour(188, 215, 241), # blue = mid slots
|
||||||
|
FittingSlot.HIGH: wx.Colour(235, 204, 209), # red = high slots
|
||||||
|
FittingSlot.RIG: '',
|
||||||
|
FittingSlot.SUBSYSTEM: ''}
|
||||||
|
|
||||||
|
|
||||||
|
def get_errColor():
|
||||||
|
import wx
|
||||||
|
return wx.Colour(204, 51, 51)
|
||||||
|
|
||||||
|
|
||||||
def getClientSecret():
|
def getClientSecret():
|
||||||
|
|||||||
5
docker-compose.yml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
services:
|
||||||
|
pyfa-server:
|
||||||
|
image: docker.site.quack-lab.dev/pyfa-server:latest
|
||||||
|
ports:
|
||||||
|
- "9123:9123"
|
||||||
1098
eos/effects.py
@@ -324,7 +324,7 @@ class ModifiedAttributeDict(MutableMapping):
|
|||||||
cappingAttrKeyCache[key] = cappingKey
|
cappingAttrKeyCache[key] = cappingKey
|
||||||
|
|
||||||
if cappingKey:
|
if cappingKey:
|
||||||
cappingValue = self.original.get(cappingKey, self.__calculateValue(cappingKey))
|
cappingValue = self[cappingKey]
|
||||||
cappingValue = cappingValue.value if hasattr(cappingValue, "value") else cappingValue
|
cappingValue = cappingValue.value if hasattr(cappingValue, "value") else cappingValue
|
||||||
else:
|
else:
|
||||||
cappingValue = None
|
cappingValue = None
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ class Drone(HandledItem, HandledCharge, ItemAttrShortcut, ChargeAttrShortcut, Mu
|
|||||||
self.__baseVolley = None
|
self.__baseVolley = None
|
||||||
self.__baseRRAmount = None
|
self.__baseRRAmount = None
|
||||||
self.__miningYield = None
|
self.__miningYield = None
|
||||||
self.__miningWaste = None
|
self.__miningDrain = None
|
||||||
self.__ehp = None
|
self.__ehp = None
|
||||||
self.__itemModifiedAttributes = ModifiedAttributeDict()
|
self.__itemModifiedAttributes = ModifiedAttributeDict()
|
||||||
self.__itemModifiedAttributes.original = self._item.attributes
|
self.__itemModifiedAttributes.original = self._item.attributes
|
||||||
@@ -240,15 +240,15 @@ class Drone(HandledItem, HandledCharge, ItemAttrShortcut, ChargeAttrShortcut, Mu
|
|||||||
if not ignoreState and self.amountActive <= 0:
|
if not ignoreState and self.amountActive <= 0:
|
||||||
return 0
|
return 0
|
||||||
if self.__miningYield is None:
|
if self.__miningYield is None:
|
||||||
self.__miningYield, self.__miningWaste = self.__calculateMining()
|
self.__miningYield, self.__miningDrain = self.__calculateMining()
|
||||||
return self.__miningYield
|
return self.__miningYield
|
||||||
|
|
||||||
def getMiningWPS(self, ignoreState=False):
|
def getMiningDPS(self, ignoreState=False):
|
||||||
if not ignoreState and self.amountActive <= 0:
|
if not ignoreState and self.amountActive <= 0:
|
||||||
return 0
|
return 0
|
||||||
if self.__miningWaste is None:
|
if self.__miningDrain is None:
|
||||||
self.__miningYield, self.__miningWaste = self.__calculateMining()
|
self.__miningYield, self.__miningDrain = self.__calculateMining()
|
||||||
return self.__miningWaste
|
return self.__miningDrain
|
||||||
|
|
||||||
def __calculateMining(self):
|
def __calculateMining(self):
|
||||||
if self.mines is True:
|
if self.mines is True:
|
||||||
@@ -262,8 +262,8 @@ class Drone(HandledItem, HandledCharge, ItemAttrShortcut, ChargeAttrShortcut, Mu
|
|||||||
yps = yield_ / (cycleTime / 1000.0)
|
yps = yield_ / (cycleTime / 1000.0)
|
||||||
wasteChance = self.getModifiedItemAttr("miningWasteProbability")
|
wasteChance = self.getModifiedItemAttr("miningWasteProbability")
|
||||||
wasteMult = self.getModifiedItemAttr("miningWastedVolumeMultiplier")
|
wasteMult = self.getModifiedItemAttr("miningWastedVolumeMultiplier")
|
||||||
wps = yps * max(0, min(1, wasteChance / 100)) * wasteMult
|
dps = yps * (1 + max(0, min(1, wasteChance / 100)) * wasteMult)
|
||||||
return yps, wps
|
return yps, dps
|
||||||
else:
|
else:
|
||||||
return 0, 0
|
return 0, 0
|
||||||
|
|
||||||
@@ -335,7 +335,7 @@ class Drone(HandledItem, HandledCharge, ItemAttrShortcut, ChargeAttrShortcut, Mu
|
|||||||
self.__baseVolley = None
|
self.__baseVolley = None
|
||||||
self.__baseRRAmount = None
|
self.__baseRRAmount = None
|
||||||
self.__miningYield = None
|
self.__miningYield = None
|
||||||
self.__miningWaste = None
|
self.__miningDrain = None
|
||||||
self.__ehp = None
|
self.__ehp = None
|
||||||
self.itemModifiedAttributes.clear()
|
self.itemModifiedAttributes.clear()
|
||||||
self.chargeModifiedAttributes.clear()
|
self.chargeModifiedAttributes.clear()
|
||||||
|
|||||||
@@ -140,8 +140,8 @@ class Fit:
|
|||||||
self.__remoteRepMap = {}
|
self.__remoteRepMap = {}
|
||||||
self.__minerYield = None
|
self.__minerYield = None
|
||||||
self.__droneYield = None
|
self.__droneYield = None
|
||||||
self.__minerWaste = None
|
self.__minerDrain = None
|
||||||
self.__droneWaste = None
|
self.__droneDrain = None
|
||||||
self.__droneDps = None
|
self.__droneDps = None
|
||||||
self.__droneVolley = None
|
self.__droneVolley = None
|
||||||
self.__sustainableTank = None
|
self.__sustainableTank = None
|
||||||
@@ -378,11 +378,11 @@ class Fit:
|
|||||||
return self.__minerYield
|
return self.__minerYield
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def minerWaste(self):
|
def minerDrain(self):
|
||||||
if self.__minerWaste is None:
|
if self.__minerDrain is None:
|
||||||
self.calculatemining()
|
self.calculatemining()
|
||||||
|
|
||||||
return self.__minerWaste
|
return self.__minerDrain
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def droneYield(self):
|
def droneYield(self):
|
||||||
@@ -392,19 +392,19 @@ class Fit:
|
|||||||
return self.__droneYield
|
return self.__droneYield
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def droneWaste(self):
|
def droneDrain(self):
|
||||||
if self.__droneWaste is None:
|
if self.__droneDrain is None:
|
||||||
self.calculatemining()
|
self.calculatemining()
|
||||||
|
|
||||||
return self.__droneWaste
|
return self.__droneDrain
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def totalYield(self):
|
def totalYield(self):
|
||||||
return self.droneYield + self.minerYield
|
return self.droneYield + self.minerYield
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def totalWaste(self):
|
def totalDrain(self):
|
||||||
return self.droneWaste + self.minerWaste
|
return self.droneDrain + self.minerDrain
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def maxTargets(self):
|
def maxTargets(self):
|
||||||
@@ -518,8 +518,8 @@ class Fit:
|
|||||||
self.__remoteRepMap = {}
|
self.__remoteRepMap = {}
|
||||||
self.__minerYield = None
|
self.__minerYield = None
|
||||||
self.__droneYield = None
|
self.__droneYield = None
|
||||||
self.__minerWaste = None
|
self.__minerDrain = None
|
||||||
self.__droneWaste = None
|
self.__droneDrain = None
|
||||||
self.__effectiveSustainableTank = None
|
self.__effectiveSustainableTank = None
|
||||||
self.__sustainableTank = None
|
self.__sustainableTank = None
|
||||||
self.__droneDps = None
|
self.__droneDps = None
|
||||||
@@ -702,15 +702,12 @@ class Fit:
|
|||||||
mod.item.requiresSkill("High Speed Maneuvering"),
|
mod.item.requiresSkill("High Speed Maneuvering"),
|
||||||
"speedFactor", value, stackingPenalties=True)
|
"speedFactor", value, stackingPenalties=True)
|
||||||
|
|
||||||
if warfareBuffID == 23: # Mining Burst: Mining Laser Field Enhancement: Mining/Survey Range
|
if warfareBuffID == 23: # Mining Burst: Mining Laser Field Enhancement: Mining Range
|
||||||
self.modules.filteredItemBoost(lambda mod: mod.item.requiresSkill("Mining") or
|
self.modules.filteredItemBoost(lambda mod: mod.item.requiresSkill("Mining") or
|
||||||
mod.item.requiresSkill("Ice Harvesting") or
|
mod.item.requiresSkill("Ice Harvesting") or
|
||||||
mod.item.requiresSkill("Gas Cloud Harvesting"),
|
mod.item.requiresSkill("Gas Cloud Harvesting"),
|
||||||
"maxRange", value, stackingPenalties=True)
|
"maxRange", value, stackingPenalties=True)
|
||||||
|
|
||||||
self.modules.filteredItemBoost(lambda mod: mod.item.requiresSkill("CPU Management"),
|
|
||||||
"surveyScanRange", value, stackingPenalties=True)
|
|
||||||
|
|
||||||
if warfareBuffID == 24: # Mining Burst: Mining Laser Optimization: Mining Capacitor/Duration
|
if warfareBuffID == 24: # Mining Burst: Mining Laser Optimization: Mining Capacitor/Duration
|
||||||
self.modules.filteredItemBoost(lambda mod: mod.item.requiresSkill("Mining") or
|
self.modules.filteredItemBoost(lambda mod: mod.item.requiresSkill("Mining") or
|
||||||
mod.item.requiresSkill("Ice Harvesting") or
|
mod.item.requiresSkill("Ice Harvesting") or
|
||||||
@@ -925,6 +922,36 @@ class Fit:
|
|||||||
lambda mod: (mod.item.requiresSkill("Repair Systems")
|
lambda mod: (mod.item.requiresSkill("Repair Systems")
|
||||||
or mod.item.requiresSkill("Capital Repair Systems")),
|
or mod.item.requiresSkill("Capital Repair Systems")),
|
||||||
"armorDamageAmount", value, stackingPenalties=True)
|
"armorDamageAmount", value, stackingPenalties=True)
|
||||||
|
if warfareBuffID == 2464: # Expedition Burst: Probe Strength
|
||||||
|
self.modules.filteredChargeBoost(
|
||||||
|
lambda mod: mod.charge.requiresSkill('Astrometrics'),
|
||||||
|
'baseSensorStrength', value, stackingPenalties=True)
|
||||||
|
if warfareBuffID == 2465: # Expedition Burst: Directional Scanner, Hacking and Salvager Range
|
||||||
|
self.ship.boostItemAttr("maxDirectionalScanRange", value)
|
||||||
|
self.modules.filteredItemBoost(
|
||||||
|
lambda mod: mod.item.group.name in ("Data Miners", "Salvager"), "maxRange", value, stackingPenalties=True)
|
||||||
|
if warfareBuffID == 2466: # Expedition Burst: Maximum Scan Deviation Modifier
|
||||||
|
self.modules.filteredChargeBoost(
|
||||||
|
lambda mod: mod.charge.requiresSkill('Astrometrics'),
|
||||||
|
'baseMaxScanDeviation', value, stackingPenalties=True)
|
||||||
|
if warfareBuffID == 2468: # Expedition Burst: Virus Coherence
|
||||||
|
self.modules.filteredItemIncrease(
|
||||||
|
lambda mod: mod.item.group.name == "Data Miners", "virusCoherence", value)
|
||||||
|
if warfareBuffID == 2474: # Mining burst charges
|
||||||
|
self.ship.forceItemAttr("miningScannerUpgrade", value)
|
||||||
|
if warfareBuffID == 2481: # Expedition Burst: Salvager duration bonus
|
||||||
|
self.modules.filteredItemBoost(lambda mod: mod.item.requiresSkill("Salvaging"), "duration", value)
|
||||||
|
if warfareBuffID == 2516: # Mining Burst: Mining Crit Chance
|
||||||
|
self.modules.filteredItemBoost(
|
||||||
|
lambda mod: mod.item.requiresSkill("Mining") or mod.item.requiresSkill("Ice Harvesting"),
|
||||||
|
"miningCritChance", value)
|
||||||
|
if warfareBuffID == 2517: # Mining Burst: Mining Residue Chance Reduction
|
||||||
|
self.modules.filteredItemBoost(
|
||||||
|
lambda mod: (
|
||||||
|
mod.item.requiresSkill("Mining")
|
||||||
|
or mod.item.requiresSkill("Ice Harvesting")
|
||||||
|
or mod.item.requiresSkill("Gas Cloud Harvesting")),
|
||||||
|
"miningWasteProbability", value, stackingPenalties=True)
|
||||||
|
|
||||||
del self.commandBonuses[warfareBuffID]
|
del self.commandBonuses[warfareBuffID]
|
||||||
|
|
||||||
@@ -1707,21 +1734,21 @@ class Fit:
|
|||||||
|
|
||||||
def calculatemining(self):
|
def calculatemining(self):
|
||||||
minerYield = 0
|
minerYield = 0
|
||||||
minerWaste = 0
|
minerDrain = 0
|
||||||
droneYield = 0
|
droneYield = 0
|
||||||
droneWaste = 0
|
droneDrain = 0
|
||||||
|
|
||||||
for mod in self.modules:
|
for mod in self.modules:
|
||||||
minerYield += mod.getMiningYPS()
|
minerYield += mod.getMiningYPS()
|
||||||
minerWaste += mod.getMiningWPS()
|
minerDrain += mod.getMiningDPS()
|
||||||
for drone in self.drones:
|
for drone in self.drones:
|
||||||
droneYield += drone.getMiningYPS()
|
droneYield += drone.getMiningYPS()
|
||||||
droneWaste += drone.getMiningWPS()
|
droneDrain += drone.getMiningDPS()
|
||||||
|
|
||||||
self.__minerYield = minerYield
|
self.__minerYield = minerYield
|
||||||
self.__minerWaste = minerWaste
|
self.__minerDrain = minerDrain
|
||||||
self.__droneYield = droneYield
|
self.__droneYield = droneYield
|
||||||
self.__droneWaste = droneWaste
|
self.__droneDrain = droneDrain
|
||||||
|
|
||||||
def calculateWeaponDmgStats(self, spoolOptions):
|
def calculateWeaponDmgStats(self, spoolOptions):
|
||||||
weaponVolley = DmgTypes.default()
|
weaponVolley = DmgTypes.default()
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ class Module(HandledItem, HandledCharge, ItemAttrShortcut, ChargeAttrShortcut, M
|
|||||||
self.__baseVolley = None
|
self.__baseVolley = None
|
||||||
self.__baseRRAmount = None
|
self.__baseRRAmount = None
|
||||||
self.__miningYield = None
|
self.__miningYield = None
|
||||||
self.__miningWaste = None
|
self.__miningDrain = None
|
||||||
self.__reloadTime = None
|
self.__reloadTime = None
|
||||||
self.__reloadForce = None
|
self.__reloadForce = None
|
||||||
self.__chargeCycles = None
|
self.__chargeCycles = None
|
||||||
@@ -418,17 +418,17 @@ class Module(HandledItem, HandledCharge, ItemAttrShortcut, ChargeAttrShortcut, M
|
|||||||
if not ignoreState and self.state < FittingModuleState.ACTIVE:
|
if not ignoreState and self.state < FittingModuleState.ACTIVE:
|
||||||
return 0
|
return 0
|
||||||
if self.__miningYield is None:
|
if self.__miningYield is None:
|
||||||
self.__miningYield, self.__miningWaste = self.__calculateMining()
|
self.__miningYield, self.__miningDrain = self.__calculateMining()
|
||||||
return self.__miningYield
|
return self.__miningYield
|
||||||
|
|
||||||
def getMiningWPS(self, ignoreState=False):
|
def getMiningDPS(self, ignoreState=False):
|
||||||
if self.isEmpty:
|
if self.isEmpty:
|
||||||
return 0
|
return 0
|
||||||
if not ignoreState and self.state < FittingModuleState.ACTIVE:
|
if not ignoreState and self.state < FittingModuleState.ACTIVE:
|
||||||
return 0
|
return 0
|
||||||
if self.__miningWaste is None:
|
if self.__miningDrain is None:
|
||||||
self.__miningYield, self.__miningWaste = self.__calculateMining()
|
self.__miningYield, self.__miningDrain = self.__calculateMining()
|
||||||
return self.__miningWaste
|
return self.__miningDrain
|
||||||
|
|
||||||
def __calculateMining(self):
|
def __calculateMining(self):
|
||||||
yield_ = self.getModifiedItemAttr("miningAmount")
|
yield_ = self.getModifiedItemAttr("miningAmount")
|
||||||
@@ -443,8 +443,11 @@ class Module(HandledItem, HandledCharge, ItemAttrShortcut, ChargeAttrShortcut, M
|
|||||||
yps = 0
|
yps = 0
|
||||||
wasteChance = self.getModifiedItemAttr("miningWasteProbability")
|
wasteChance = self.getModifiedItemAttr("miningWasteProbability")
|
||||||
wasteMult = self.getModifiedItemAttr("miningWastedVolumeMultiplier")
|
wasteMult = self.getModifiedItemAttr("miningWastedVolumeMultiplier")
|
||||||
wps = yps * max(0, min(1, wasteChance / 100)) * wasteMult
|
dps = yps * (1 + max(0, min(1, wasteChance / 100)) * wasteMult)
|
||||||
return yps, wps
|
critChance = self.getModifiedItemAttr("miningCritChance")
|
||||||
|
critBonusMult = self.getModifiedItemAttr("miningCritBonusYield")
|
||||||
|
yps += yps * critChance * critBonusMult
|
||||||
|
return yps, dps
|
||||||
|
|
||||||
def isDealingDamage(self, ignoreState=False):
|
def isDealingDamage(self, ignoreState=False):
|
||||||
volleyParams = self.getVolleyParameters(ignoreState=ignoreState)
|
volleyParams = self.getVolleyParameters(ignoreState=ignoreState)
|
||||||
@@ -894,7 +897,7 @@ class Module(HandledItem, HandledCharge, ItemAttrShortcut, ChargeAttrShortcut, M
|
|||||||
self.__baseVolley = None
|
self.__baseVolley = None
|
||||||
self.__baseRRAmount = None
|
self.__baseRRAmount = None
|
||||||
self.__miningYield = None
|
self.__miningYield = None
|
||||||
self.__miningWaste = None
|
self.__miningDrain = None
|
||||||
self.__reloadTime = None
|
self.__reloadTime = None
|
||||||
self.__reloadForce = None
|
self.__reloadForce = None
|
||||||
self.__chargeCycles = None
|
self.__chargeCycles = None
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ class Ship(ItemAttrShortcut, HandledItem):
|
|||||||
valid Item objects, not the Mode objects. Returns None if not a
|
valid Item objects, not the Mode objects. Returns None if not a
|
||||||
t3 dessy
|
t3 dessy
|
||||||
"""
|
"""
|
||||||
if self.item.group.name != "Tactical Destroyer":
|
if self.item.group.name != "Tactical Destroyer" and self.item.name != "Anhinga":
|
||||||
return None
|
return None
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
|
|||||||
@@ -18,13 +18,14 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
from . import fitDamageStats
|
from . import fitDamageStats as fitDamageStats
|
||||||
from . import fitEwarStats
|
from . import fitEwarStats as fitEwarStats
|
||||||
from . import fitRemoteReps
|
from . import fitRemoteReps as fitRemoteReps
|
||||||
from . import fitShieldRegen
|
from . import fitShieldRegen as fitShieldRegen
|
||||||
from . import fitCapacitor
|
from . import fitCapacitor as fitCapacitor
|
||||||
from . import fitMobility
|
from . import fitMobility as fitMobility
|
||||||
from . import fitWarpTime
|
from . import fitWarpTime as fitWarpTime
|
||||||
from . import fitLockTime
|
from . import fitLockTime as fitLockTime
|
||||||
|
from . import fitHeat as fitHeat
|
||||||
# Hidden graphs, available via ctrl-alt-g
|
# Hidden graphs, available via ctrl-alt-g
|
||||||
from . import fitEcmBurstScanresDamps
|
from . import fitEcmBurstScanresDamps as fitEcmBurstScanresDamps
|
||||||
|
|||||||
@@ -332,3 +332,78 @@ class Distance2TpStrGetter(SmoothPointGetter):
|
|||||||
strMult = calculateMultiplier(strMults)
|
strMult = calculateMultiplier(strMults)
|
||||||
strength = (strMult - 1) * 100
|
strength = (strMult - 1) * 100
|
||||||
return strength
|
return strength
|
||||||
|
|
||||||
|
|
||||||
|
class Distance2JamChanceGetter(SmoothPointGetter):
|
||||||
|
|
||||||
|
_baseResolution = 50
|
||||||
|
_extraDepth = 2
|
||||||
|
|
||||||
|
ECM_ATTRS_GENERAL = ('scanGravimetricStrengthBonus', 'scanLadarStrengthBonus', 'scanMagnetometricStrengthBonus', 'scanRadarStrengthBonus')
|
||||||
|
ECM_ATTRS_FIGHTERS = ('fighterAbilityECMStrengthGravimetric', 'fighterAbilityECMStrengthLadar', 'fighterAbilityECMStrengthMagnetometric', 'fighterAbilityECMStrengthRadar')
|
||||||
|
SCAN_TYPES = ('Gravimetric', 'Ladar', 'Magnetometric', 'Radar')
|
||||||
|
|
||||||
|
def _getCommonData(self, miscParams, src, tgt):
|
||||||
|
ecms = []
|
||||||
|
for mod in src.item.activeModulesIter():
|
||||||
|
for effectName in ('remoteECMFalloff', 'structureModuleEffectECM'):
|
||||||
|
if effectName in mod.item.effects:
|
||||||
|
ecms.append((
|
||||||
|
tuple(mod.getModifiedItemAttr(a) or 0 for a in self.ECM_ATTRS_GENERAL),
|
||||||
|
mod.maxRange or 0, mod.falloff or 0, True, False))
|
||||||
|
if 'doomsdayAOEECM' in mod.item.effects:
|
||||||
|
ecms.append((
|
||||||
|
tuple(mod.getModifiedItemAttr(a) or 0 for a in self.ECM_ATTRS_GENERAL),
|
||||||
|
max(0, (mod.maxRange or 0) + mod.getModifiedItemAttr('doomsdayAOERange')),
|
||||||
|
mod.falloff or 0, False, False))
|
||||||
|
for drone in src.item.activeDronesIter():
|
||||||
|
if 'entityECMFalloff' in drone.item.effects:
|
||||||
|
ecms.extend(drone.amountActive * ((
|
||||||
|
tuple(drone.getModifiedItemAttr(a) or 0 for a in self.ECM_ATTRS_GENERAL),
|
||||||
|
math.inf, 0, True, True),))
|
||||||
|
for fighter, ability in src.item.activeFighterAbilityIter():
|
||||||
|
if ability.effect.name == 'fighterAbilityECM':
|
||||||
|
ecms.append((
|
||||||
|
tuple(fighter.getModifiedItemAttr(a) or 0 for a in self.ECM_ATTRS_FIGHTERS),
|
||||||
|
math.inf, 0, True, False))
|
||||||
|
# Determine target's strongest sensor type if target is available
|
||||||
|
targetScanTypeIndex = None
|
||||||
|
if tgt is not None:
|
||||||
|
maxStr = -1
|
||||||
|
for i, scanType in enumerate(self.SCAN_TYPES):
|
||||||
|
currStr = tgt.item.ship.getModifiedItemAttr('scan%sStrength' % scanType) or 0
|
||||||
|
if currStr > maxStr:
|
||||||
|
maxStr = currStr
|
||||||
|
targetScanTypeIndex = i
|
||||||
|
return {'ecms': ecms, 'targetScanTypeIndex': targetScanTypeIndex}
|
||||||
|
|
||||||
|
def _calculatePoint(self, x, miscParams, src, tgt, commonData):
|
||||||
|
distance = x
|
||||||
|
inLockRange = checkLockRange(src=src, distance=distance)
|
||||||
|
inDroneRange = checkDroneControlRange(src=src, distance=distance)
|
||||||
|
jamStrengths = []
|
||||||
|
targetScanTypeIndex = commonData['targetScanTypeIndex']
|
||||||
|
for strengths, optimal, falloff, needsLock, needsDcr in commonData['ecms']:
|
||||||
|
if (needsLock and not inLockRange) or (needsDcr and not inDroneRange):
|
||||||
|
continue
|
||||||
|
rangeFactor = calculateRangeFactor(srcOptimalRange=optimal, srcFalloffRange=falloff, distance=distance)
|
||||||
|
# Use the strength matching the target's sensor type
|
||||||
|
if targetScanTypeIndex is not None and targetScanTypeIndex < len(strengths):
|
||||||
|
strength = strengths[targetScanTypeIndex]
|
||||||
|
effectiveStrength = strength * rangeFactor
|
||||||
|
if effectiveStrength > 0:
|
||||||
|
jamStrengths.append(effectiveStrength)
|
||||||
|
if not jamStrengths:
|
||||||
|
return 0
|
||||||
|
# Get sensor strength from target
|
||||||
|
if tgt is None:
|
||||||
|
return 0
|
||||||
|
sensorStrength = max([tgt.item.ship.getModifiedItemAttr('scan%sStrength' % scanType)
|
||||||
|
for scanType in self.SCAN_TYPES]) or 0
|
||||||
|
if sensorStrength <= 0:
|
||||||
|
return 100 # If target has no sensor strength, 100% jam chance
|
||||||
|
# Calculate jam chance: 1 - (1 - (ecmStrength / sensorStrength)) ^ numJammers
|
||||||
|
retainLockChance = 1
|
||||||
|
for jamStrength in jamStrengths:
|
||||||
|
retainLockChance *= 1 - min(1, jamStrength / sensorStrength)
|
||||||
|
return (1 - retainLockChance) * 100
|
||||||
|
|||||||
@@ -21,8 +21,8 @@
|
|||||||
import wx
|
import wx
|
||||||
|
|
||||||
from graphs.data.base import FitGraph, Input, XDef, YDef
|
from graphs.data.base import FitGraph, Input, XDef, YDef
|
||||||
from .getter import (Distance2DampStrLockRangeGetter, Distance2EcmStrMaxGetter, Distance2GdStrRangeGetter, Distance2NeutingStrGetter, Distance2TdStrOptimalGetter,
|
from .getter import (Distance2DampStrLockRangeGetter, Distance2EcmStrMaxGetter, Distance2GdStrRangeGetter, Distance2JamChanceGetter, Distance2NeutingStrGetter,
|
||||||
Distance2TpStrGetter, Distance2WebbingStrGetter)
|
Distance2TdStrOptimalGetter, Distance2TpStrGetter, Distance2WebbingStrGetter)
|
||||||
|
|
||||||
_t = wx.GetTranslation
|
_t = wx.GetTranslation
|
||||||
|
|
||||||
@@ -31,11 +31,13 @@ class FitEwarStatsGraph(FitGraph):
|
|||||||
# UI stuff
|
# UI stuff
|
||||||
internalName = 'ewarStatsGraph'
|
internalName = 'ewarStatsGraph'
|
||||||
name = _t('Electronic Warfare Stats')
|
name = _t('Electronic Warfare Stats')
|
||||||
|
hasTargets = True
|
||||||
xDefs = [XDef(handle='distance', unit='km', label=_t('Distance'), mainInput=('distance', 'km'))]
|
xDefs = [XDef(handle='distance', unit='km', label=_t('Distance'), mainInput=('distance', 'km'))]
|
||||||
yDefs = [
|
yDefs = [
|
||||||
YDef(handle='neutStr', unit=None, label=_t('Cap neutralized per second'), selectorLabel=_t('Neuts: cap per second')),
|
YDef(handle='neutStr', unit=None, label=_t('Cap neutralized per second'), selectorLabel=_t('Neuts: cap per second')),
|
||||||
YDef(handle='webStr', unit='%', label=_t('Speed reduction'), selectorLabel=_t('Webs: speed reduction')),
|
YDef(handle='webStr', unit='%', label=_t('Speed reduction'), selectorLabel=_t('Webs: speed reduction')),
|
||||||
YDef(handle='ecmStrMax', unit=None, label=_t('Combined ECM strength'), selectorLabel=_t('ECM: combined strength')),
|
YDef(handle='ecmStrMax', unit=None, label=_t('Combined ECM strength'), selectorLabel=_t('ECM: combined strength')),
|
||||||
|
YDef(handle='jamChance', unit='%', label=_t('Jam chance'), selectorLabel=_t('ECM: jam chance')),
|
||||||
YDef(handle='dampStrLockRange', unit='%', label=_t('Lock range reduction'), selectorLabel=_t('Damps: lock range reduction')),
|
YDef(handle='dampStrLockRange', unit='%', label=_t('Lock range reduction'), selectorLabel=_t('Damps: lock range reduction')),
|
||||||
YDef(handle='tdStrOptimal', unit='%', label=_t('Turret optimal range reduction'), selectorLabel=_t('TDs: turret optimal range reduction')),
|
YDef(handle='tdStrOptimal', unit='%', label=_t('Turret optimal range reduction'), selectorLabel=_t('TDs: turret optimal range reduction')),
|
||||||
YDef(handle='gdStrRange', unit='%', label=_t('Missile flight range reduction'), selectorLabel=_t('GDs: missile flight range reduction')),
|
YDef(handle='gdStrRange', unit='%', label=_t('Missile flight range reduction'), selectorLabel=_t('GDs: missile flight range reduction')),
|
||||||
@@ -53,6 +55,7 @@ class FitEwarStatsGraph(FitGraph):
|
|||||||
('distance', 'neutStr'): Distance2NeutingStrGetter,
|
('distance', 'neutStr'): Distance2NeutingStrGetter,
|
||||||
('distance', 'webStr'): Distance2WebbingStrGetter,
|
('distance', 'webStr'): Distance2WebbingStrGetter,
|
||||||
('distance', 'ecmStrMax'): Distance2EcmStrMaxGetter,
|
('distance', 'ecmStrMax'): Distance2EcmStrMaxGetter,
|
||||||
|
('distance', 'jamChance'): Distance2JamChanceGetter,
|
||||||
('distance', 'dampStrLockRange'): Distance2DampStrLockRangeGetter,
|
('distance', 'dampStrLockRange'): Distance2DampStrLockRangeGetter,
|
||||||
('distance', 'tdStrOptimal'): Distance2TdStrOptimalGetter,
|
('distance', 'tdStrOptimal'): Distance2TdStrOptimalGetter,
|
||||||
('distance', 'gdStrRange'): Distance2GdStrRangeGetter,
|
('distance', 'gdStrRange'): Distance2GdStrRangeGetter,
|
||||||
|
|||||||
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()
|
||||||
|
|
||||||
295
graphs/data/fitHeat/calc.py
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 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 has_burnout_samples(fit, rack_slot, max_time_s, iterations):
|
||||||
|
cache_key = (getattr(fit, "ID", None), int(rack_slot), max_time_s, iterations)
|
||||||
|
return cache_key in _burnout_samples_cache
|
||||||
|
|
||||||
|
|
||||||
|
def get_first_burnout_samples(fit, rack_slot, max_time_s, iterations, progress_cb=None):
|
||||||
|
"""
|
||||||
|
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 i 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)
|
||||||
|
|
||||||
|
if progress_cb is not None:
|
||||||
|
# progress_cb should return True to continue, False to cancel
|
||||||
|
if not progress_cb(i + 1):
|
||||||
|
break
|
||||||
|
|
||||||
|
_burnout_samples_cache[cache_key] = samples
|
||||||
|
return samples
|
||||||
|
|
||||||
159
graphs/data/fitHeat/getter.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 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
|
||||||
|
import wx
|
||||||
|
from .calc import get_first_burnout_samples, get_rack_heat_value, has_burnout_samples
|
||||||
|
|
||||||
|
|
||||||
|
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]
|
||||||
|
iterations = miscParams.get("iterations", self._iterations)
|
||||||
|
try:
|
||||||
|
iterations = int(iterations)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
iterations = self._iterations
|
||||||
|
if iterations <= 0:
|
||||||
|
iterations = self._iterations
|
||||||
|
samples = None
|
||||||
|
# Show a progress dialog only on cache miss for expensive runs
|
||||||
|
if iterations >= 1000 and not has_burnout_samples(fit, self.rack_slot, max_sim_time, iterations):
|
||||||
|
app = wx.GetApp()
|
||||||
|
parent = app.GetTopWindow() if app is not None else None
|
||||||
|
dlg = wx.ProgressDialog(
|
||||||
|
"Computing burnout CDF",
|
||||||
|
"Running overheating simulations...",
|
||||||
|
maximum=iterations,
|
||||||
|
parent=parent,
|
||||||
|
style=wx.PD_APP_MODAL | wx.PD_ELAPSED_TIME | wx.PD_AUTO_HIDE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def progress_cb(done):
|
||||||
|
# dlg.Update returns (continue, skip)
|
||||||
|
cont, _ = dlg.Update(done)
|
||||||
|
return cont
|
||||||
|
|
||||||
|
try:
|
||||||
|
samples = get_first_burnout_samples(
|
||||||
|
fit=fit,
|
||||||
|
rack_slot=self.rack_slot,
|
||||||
|
max_time_s=max_sim_time,
|
||||||
|
iterations=iterations,
|
||||||
|
progress_cb=progress_cb,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
dlg.Destroy()
|
||||||
|
else:
|
||||||
|
samples = get_first_burnout_samples(
|
||||||
|
fit=fit,
|
||||||
|
rack_slot=self.rack_slot,
|
||||||
|
max_time_s=max_sim_time,
|
||||||
|
iterations=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]
|
||||||
|
iterations = miscParams.get("iterations", self._iterations)
|
||||||
|
try:
|
||||||
|
iterations = int(iterations)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
iterations = self._iterations
|
||||||
|
if iterations <= 0:
|
||||||
|
iterations = self._iterations
|
||||||
|
samples = get_first_burnout_samples(
|
||||||
|
fit=fit,
|
||||||
|
rack_slot=self.rack_slot,
|
||||||
|
max_time_s=max_sim_time,
|
||||||
|
iterations=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
|
||||||
|
|
||||||
104
graphs/data/fitHeat/graph.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 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="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")),
|
||||||
|
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")),
|
||||||
|
]
|
||||||
|
inputs = [
|
||||||
|
Input(
|
||||||
|
handle="time",
|
||||||
|
unit="s",
|
||||||
|
label=_t("Time"),
|
||||||
|
iconID=1392,
|
||||||
|
defaultValue=300,
|
||||||
|
defaultRange=(0, 120),
|
||||||
|
),
|
||||||
|
Input(
|
||||||
|
handle="iterations",
|
||||||
|
unit=None,
|
||||||
|
label=_t("Iterations"),
|
||||||
|
iconID=1392,
|
||||||
|
defaultValue=10000,
|
||||||
|
defaultRange=(100, 50000),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
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:
|
else:
|
||||||
dstCargoItemID = None
|
dstCargoItemID = None
|
||||||
|
|
||||||
self.mainFrame.command.Submit(cmd.GuiLocalModuleToCargoCommand(
|
modifiers = wx.GetMouseState().GetModifiers()
|
||||||
fitID=self.mainFrame.getActiveFit(),
|
isCopy = modifiers == wx.MOD_CONTROL
|
||||||
modPosition=modIdx,
|
isBatch = modifiers == wx.MOD_SHIFT
|
||||||
cargoItemID=dstCargoItemID,
|
if isBatch:
|
||||||
copy=wx.GetMouseState().GetModifiers() == wx.MOD_CONTROL))
|
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):
|
def fitChanged(self, event):
|
||||||
event.Skip()
|
event.Skip()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from gui.builtinContextMenus import resistMode
|
|||||||
from gui.builtinContextMenus.targetProfile import editor
|
from gui.builtinContextMenus.targetProfile import editor
|
||||||
# Item info
|
# Item info
|
||||||
from gui.builtinContextMenus import itemStats
|
from gui.builtinContextMenus import itemStats
|
||||||
|
from gui.builtinContextMenus import fitDiff
|
||||||
from gui.builtinContextMenus import itemMarketJump
|
from gui.builtinContextMenus import itemMarketJump
|
||||||
from gui.builtinContextMenus import fitSystemSecurity # Not really an item info but want to keep it here
|
from gui.builtinContextMenus import fitSystemSecurity # Not really an item info but want to keep it here
|
||||||
from gui.builtinContextMenus import fitPilotSecurity # Not really an item info but want to keep it here
|
from gui.builtinContextMenus import fitPilotSecurity # Not really an item info but want to keep it here
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ class AddCurrentlyOpenFit(ContextMenuUnconditional):
|
|||||||
if isinstance(page, BlankPage):
|
if isinstance(page, BlankPage):
|
||||||
continue
|
continue
|
||||||
fit = sFit.getFit(page.activeFitID, basic=True)
|
fit = sFit.getFit(page.activeFitID, basic=True)
|
||||||
|
if fit is None:
|
||||||
|
continue
|
||||||
id = ContextMenuUnconditional.nextID()
|
id = ContextMenuUnconditional.nextID()
|
||||||
mitem = wx.MenuItem(rootMenu, id, "{}: {}".format(fit.ship.item.name, fit.name))
|
mitem = wx.MenuItem(rootMenu, id, "{}: {}".format(fit.ship.item.name, fit.name))
|
||||||
bindmenu.Bind(wx.EVT_MENU, self.handleSelection, mitem)
|
bindmenu.Bind(wx.EVT_MENU, self.handleSelection, mitem)
|
||||||
|
|||||||
48
gui/builtinContextMenus/fitDiff.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Copyright (C) 2025
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
|
||||||
|
import gui.mainFrame
|
||||||
|
from gui.contextMenu import ContextMenuSingle
|
||||||
|
|
||||||
|
_t = wx.GetTranslation
|
||||||
|
|
||||||
|
|
||||||
|
class FitDiff(ContextMenuSingle):
|
||||||
|
def __init__(self):
|
||||||
|
self.mainFrame = gui.mainFrame.MainFrame.getInstance()
|
||||||
|
|
||||||
|
def display(self, callingWindow, srcContext, mainItem):
|
||||||
|
# Only show for fittingShip context (right-click on ship)
|
||||||
|
return srcContext == "fittingShip"
|
||||||
|
|
||||||
|
def getText(self, callingWindow, itmContext, mainItem):
|
||||||
|
return _t("Fit Diff...")
|
||||||
|
|
||||||
|
def activate(self, callingWindow, fullContext, mainItem, i):
|
||||||
|
fitID = self.mainFrame.getActiveFit()
|
||||||
|
if fitID is not None:
|
||||||
|
from gui.fitDiffFrame import FitDiffFrame
|
||||||
|
FitDiffFrame(self.mainFrame, fitID)
|
||||||
|
|
||||||
|
|
||||||
|
FitDiff.register()
|
||||||
@@ -5,9 +5,6 @@ import wx
|
|||||||
|
|
||||||
import gui.mainFrame
|
import gui.mainFrame
|
||||||
from gui.contextMenu import ContextMenuSingle
|
from gui.contextMenu import ContextMenuSingle
|
||||||
from gui.fitCommands import (
|
|
||||||
GuiConvertMutatedLocalModuleCommand, GuiRevertMutatedLocalModuleCommand,
|
|
||||||
GuiConvertMutatedLocalDroneCommand, GuiRevertMutatedLocalDroneCommand)
|
|
||||||
from service.fit import Fit
|
from service.fit import Fit
|
||||||
|
|
||||||
_t = wx.GetTranslation
|
_t = wx.GetTranslation
|
||||||
@@ -65,6 +62,8 @@ class ChangeItemMutation(ContextMenuSingle):
|
|||||||
return sub
|
return sub
|
||||||
|
|
||||||
def handleMenu(self, event):
|
def handleMenu(self, event):
|
||||||
|
from gui.fitCommands import (
|
||||||
|
GuiConvertMutatedLocalModuleCommand, GuiConvertMutatedLocalDroneCommand)
|
||||||
mutaplasmid, item = self.eventIDs[event.Id]
|
mutaplasmid, item = self.eventIDs[event.Id]
|
||||||
fitID = self.mainFrame.getActiveFit()
|
fitID = self.mainFrame.getActiveFit()
|
||||||
fit = Fit.getInstance().getFit(fitID)
|
fit = Fit.getInstance().getFit(fitID)
|
||||||
@@ -78,6 +77,8 @@ class ChangeItemMutation(ContextMenuSingle):
|
|||||||
fitID=fitID, position=position, mutaplasmid=mutaplasmid))
|
fitID=fitID, position=position, mutaplasmid=mutaplasmid))
|
||||||
|
|
||||||
def activate(self, callingWindow, fullContext, mainItem, i):
|
def activate(self, callingWindow, fullContext, mainItem, i):
|
||||||
|
from gui.fitCommands import (
|
||||||
|
GuiRevertMutatedLocalModuleCommand, GuiRevertMutatedLocalDroneCommand)
|
||||||
fitID = self.mainFrame.getActiveFit()
|
fitID = self.mainFrame.getActiveFit()
|
||||||
fit = Fit.getInstance().getFit(fitID)
|
fit = Fit.getInstance().getFit(fitID)
|
||||||
if mainItem in fit.modules:
|
if mainItem in fit.modules:
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class ChangeModuleAmmo(ContextMenuCombined):
|
|||||||
('r16', _t('Moon Uncommon')),
|
('r16', _t('Moon Uncommon')),
|
||||||
('r32', _t('Moon Rare')),
|
('r32', _t('Moon Rare')),
|
||||||
('r64', _t('Moon Exceptional')),
|
('r64', _t('Moon Exceptional')),
|
||||||
|
('err', _t('Erratic')),
|
||||||
('misc', _t('Misc'))])
|
('misc', _t('Misc'))])
|
||||||
|
|
||||||
def display(self, callingWindow, srcContext, mainItem, selection):
|
def display(self, callingWindow, srcContext, mainItem, selection):
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ class ChangeShipTacticalMode(ContextMenuUnconditional):
|
|||||||
self.modeMap = {
|
self.modeMap = {
|
||||||
'Defense': _t('Defense'),
|
'Defense': _t('Defense'),
|
||||||
'Propulsion': _t('Propulsion'),
|
'Propulsion': _t('Propulsion'),
|
||||||
'Sharpshooter': _t('Sharpshooter')
|
'Sharpshooter': _t('Sharpshooter'),
|
||||||
|
'Primary': _t('Primary'),
|
||||||
|
'Secondary': _t('Secondary'),
|
||||||
|
'Tertiary': _t('Tertiary'),
|
||||||
}
|
}
|
||||||
|
|
||||||
def display(self, callingWindow, srcContext):
|
def display(self, callingWindow, srcContext):
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class ChangeAffectingSkills(ContextMenuSingle):
|
|||||||
label = _t("Level %s") % i
|
label = _t("Level %s") % i
|
||||||
|
|
||||||
id = ContextMenuSingle.nextID()
|
id = ContextMenuSingle.nextID()
|
||||||
self.skillIds[id] = (skill, i)
|
self.skillIds[id] = (skill, i, False) # False = not "up" for individual skills
|
||||||
menuItem = wx.MenuItem(rootMenu, id, label, kind=wx.ITEM_RADIO)
|
menuItem = wx.MenuItem(rootMenu, id, label, kind=wx.ITEM_RADIO)
|
||||||
rootMenu.Bind(wx.EVT_MENU, self.handleSkillChange, menuItem)
|
rootMenu.Bind(wx.EVT_MENU, self.handleSkillChange, menuItem)
|
||||||
return menuItem
|
return menuItem
|
||||||
@@ -89,6 +89,40 @@ class ChangeAffectingSkills(ContextMenuSingle):
|
|||||||
self.skillIds = {}
|
self.skillIds = {}
|
||||||
sub = wx.Menu()
|
sub = wx.Menu()
|
||||||
|
|
||||||
|
# When rootMenu is None (direct menu access), use sub for binding on Windows
|
||||||
|
bindMenu = rootMenu if (rootMenu is not None and msw) else (sub if msw else None)
|
||||||
|
|
||||||
|
# Add "All" entry
|
||||||
|
allItem = wx.MenuItem(sub, ContextMenuSingle.nextID(), _t("All"))
|
||||||
|
grandSubAll = wx.Menu()
|
||||||
|
allItem.SetSubMenu(grandSubAll)
|
||||||
|
|
||||||
|
# For "All", only show levels 1-5 (not "Not Learned")
|
||||||
|
for i in range(1, 6):
|
||||||
|
id = ContextMenuSingle.nextID()
|
||||||
|
self.skillIds[id] = (None, i, False) # None indicates "All" was selected, False = not "up"
|
||||||
|
label = _t("Level %s") % i
|
||||||
|
menuItem = wx.MenuItem(bindMenu if bindMenu else grandSubAll, id, label, kind=wx.ITEM_RADIO)
|
||||||
|
grandSubAll.Bind(wx.EVT_MENU, self.handleSkillChange, menuItem)
|
||||||
|
grandSubAll.Append(menuItem)
|
||||||
|
|
||||||
|
# Add separator
|
||||||
|
grandSubAll.AppendSeparator()
|
||||||
|
|
||||||
|
# Add "Up Level 1..5" entries
|
||||||
|
for i in range(1, 6):
|
||||||
|
id = ContextMenuSingle.nextID()
|
||||||
|
self.skillIds[id] = (None, i, True) # None indicates "All" was selected, True = "up" only
|
||||||
|
label = _t("Up Level %s") % i
|
||||||
|
menuItem = wx.MenuItem(bindMenu if bindMenu else grandSubAll, id, label, kind=wx.ITEM_RADIO)
|
||||||
|
grandSubAll.Bind(wx.EVT_MENU, self.handleSkillChange, menuItem)
|
||||||
|
grandSubAll.Append(menuItem)
|
||||||
|
|
||||||
|
sub.Append(allItem)
|
||||||
|
|
||||||
|
# Add separator
|
||||||
|
sub.AppendSeparator()
|
||||||
|
|
||||||
for skill in self.skills:
|
for skill in self.skills:
|
||||||
skillItem = wx.MenuItem(sub, ContextMenuSingle.nextID(), skill.item.name)
|
skillItem = wx.MenuItem(sub, ContextMenuSingle.nextID(), skill.item.name)
|
||||||
grandSub = wx.Menu()
|
grandSub = wx.Menu()
|
||||||
@@ -99,7 +133,7 @@ class ChangeAffectingSkills(ContextMenuSingle):
|
|||||||
skillItem.SetBitmap(bitmap)
|
skillItem.SetBitmap(bitmap)
|
||||||
|
|
||||||
for i in range(-1, 6):
|
for i in range(-1, 6):
|
||||||
levelItem = self.addSkill(rootMenu if msw else grandSub, skill, i)
|
levelItem = self.addSkill(bindMenu if bindMenu else grandSub, skill, i)
|
||||||
grandSub.Append(levelItem)
|
grandSub.Append(levelItem)
|
||||||
if (not skill.learned and i == -1) or (skill.learned and skill.level == i):
|
if (not skill.learned and i == -1) or (skill.learned and skill.level == i):
|
||||||
levelItem.Check(True)
|
levelItem.Check(True)
|
||||||
@@ -108,9 +142,24 @@ class ChangeAffectingSkills(ContextMenuSingle):
|
|||||||
return sub
|
return sub
|
||||||
|
|
||||||
def handleSkillChange(self, event):
|
def handleSkillChange(self, event):
|
||||||
skill, level = self.skillIds[event.Id]
|
skill, level, up = self.skillIds[event.Id]
|
||||||
|
|
||||||
|
if skill is None: # "All" was selected
|
||||||
|
for s in self.skills:
|
||||||
|
if up:
|
||||||
|
# Only increase skill if it's below the target level
|
||||||
|
if not s.learned or s.level < level:
|
||||||
|
self.sChar.changeLevel(self.charID, s.item.ID, level)
|
||||||
|
else:
|
||||||
|
self.sChar.changeLevel(self.charID, s.item.ID, level)
|
||||||
|
else:
|
||||||
|
if up:
|
||||||
|
# Only increase skill if it's below the target level
|
||||||
|
if not skill.learned or skill.level < level:
|
||||||
|
self.sChar.changeLevel(self.charID, skill.item.ID, level)
|
||||||
|
else:
|
||||||
|
self.sChar.changeLevel(self.charID, skill.item.ID, level)
|
||||||
|
|
||||||
self.sChar.changeLevel(self.charID, skill.item.ID, level)
|
|
||||||
fitID = self.mainFrame.getActiveFit()
|
fitID = self.mainFrame.getActiveFit()
|
||||||
self.sFit.changeChar(fitID, self.charID)
|
self.sFit.changeChar(fitID, self.charID)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,46 @@ from gui.utils.numberFormatter import formatAmount
|
|||||||
|
|
||||||
_t = wx.GetTranslation
|
_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):
|
def defaultSort(item):
|
||||||
return (item.metaLevel or 0, item.name)
|
return (item.metaLevel or 0, item.name)
|
||||||
@@ -36,8 +76,12 @@ class ItemCompare(wx.Panel):
|
|||||||
self.item = item
|
self.item = item
|
||||||
self.items = sorted(items, key=defaultSort)
|
self.items = sorted(items, key=defaultSort)
|
||||||
self.attrs = {}
|
self.attrs = {}
|
||||||
|
self.computedAttrs = {} # Store computed per-second attributes
|
||||||
self.HighlightOn = wx.Colour(255, 255, 0, wx.ALPHA_OPAQUE)
|
self.HighlightOn = wx.Colour(255, 255, 0, wx.ALPHA_OPAQUE)
|
||||||
self.highlightedNames = []
|
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
|
# get a dict of attrName: attrInfo of all unique attributes across all items
|
||||||
for item in self.items:
|
for item in self.items:
|
||||||
@@ -45,23 +89,66 @@ class ItemCompare(wx.Panel):
|
|||||||
if item.attributes[attr].info.displayName:
|
if item.attributes[attr].info.displayName:
|
||||||
self.attrs[attr] = item.attributes[attr].info
|
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
|
# Process attributes for items and find ones that differ
|
||||||
for attr in list(self.attrs.keys()):
|
for attr in list(self.attrs.keys()):
|
||||||
value = None
|
value = None
|
||||||
|
|
||||||
for item in self.items:
|
for item in self.items:
|
||||||
# we can automatically break here if this item doesn't have the attribute,
|
# Check if this is a computed attribute
|
||||||
# as that means at least one item did
|
if attr in self.computedAttrs:
|
||||||
if attr not in item.attributes:
|
computed = self.computedAttrs[attr]
|
||||||
break
|
amountAttr = computed["amountAttr"]
|
||||||
|
durationAttr = computed["durationAttr"]
|
||||||
|
|
||||||
# this is the first attribute for the item set, set the initial value
|
# Item needs both attributes to compute per-second value
|
||||||
if value is None:
|
if amountAttr not in item.attributes or durationAttr not in item.attributes:
|
||||||
value = item.attributes[attr].value
|
break
|
||||||
continue
|
|
||||||
|
|
||||||
if attr not in item.attributes or item.attributes[attr].value != value:
|
# Calculate per-second value
|
||||||
break
|
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:
|
else:
|
||||||
# attribute values were all the same, delete
|
# attribute values were all the same, delete
|
||||||
del self.attrs[attr]
|
del self.attrs[attr]
|
||||||
@@ -89,6 +176,7 @@ class ItemCompare(wx.Panel):
|
|||||||
|
|
||||||
self.toggleViewBtn.Bind(wx.EVT_TOGGLEBUTTON, self.ToggleViewMode)
|
self.toggleViewBtn.Bind(wx.EVT_TOGGLEBUTTON, self.ToggleViewMode)
|
||||||
self.Bind(wx.EVT_LIST_COL_CLICK, self.SortCompareCols)
|
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)
|
self.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.HighlightRow)
|
||||||
|
|
||||||
@@ -105,6 +193,23 @@ class ItemCompare(wx.Panel):
|
|||||||
self.Thaw()
|
self.Thaw()
|
||||||
event.Skip()
|
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):
|
def SortCompareCols(self, event):
|
||||||
self.Freeze()
|
self.Freeze()
|
||||||
self.paramList.ClearAll()
|
self.paramList.ClearAll()
|
||||||
@@ -148,12 +253,32 @@ class ItemCompare(wx.Panel):
|
|||||||
# Remember to reduce by 1, because the attrs array
|
# Remember to reduce by 1, because the attrs array
|
||||||
# starts at 0 while the list has the item name as column 0.
|
# starts at 0 while the list has the item name as column 0.
|
||||||
attr = str(list(self.attrs.keys())[sort - 1])
|
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)
|
# Clicked on a column that's not part of our array (price most likely)
|
||||||
except IndexError:
|
except IndexError:
|
||||||
# Price
|
# Price
|
||||||
if sort == len(self.attrs) + 1:
|
if sort == len(self.attrs) + 1:
|
||||||
func = lambda i: i.price.price if i.price.price != 0 else float("Inf")
|
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
|
# Something else
|
||||||
else:
|
else:
|
||||||
self.sortReverse = False
|
self.sortReverse = False
|
||||||
@@ -166,18 +291,49 @@ class ItemCompare(wx.Panel):
|
|||||||
|
|
||||||
for i, attr in enumerate(self.attrs.keys()):
|
for i, attr in enumerate(self.attrs.keys()):
|
||||||
name = self.attrs[attr].displayName if self.attrs[attr].displayName else attr
|
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.InsertColumn(i + 1, name)
|
||||||
self.paramList.SetColumnWidth(i + 1, 120)
|
self.paramList.SetColumnWidth(i + 1, 120)
|
||||||
|
|
||||||
self.paramList.InsertColumn(len(self.attrs) + 1, _t("Price"))
|
self.paramList.InsertColumn(len(self.attrs) + 1, _t("Price"))
|
||||||
self.paramList.SetColumnWidth(len(self.attrs) + 1, 60)
|
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 = []
|
toHighlight = []
|
||||||
|
|
||||||
for item in self.items:
|
for item in self.items:
|
||||||
i = self.paramList.InsertItem(self.paramList.GetItemCount(), item.name)
|
i = self.paramList.InsertItem(self.paramList.GetItemCount(), item.name)
|
||||||
for x, attr in enumerate(self.attrs.keys()):
|
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]
|
info = self.attrs[attr]
|
||||||
value = item.attributes[attr].value
|
value = item.attributes[attr].value
|
||||||
if self.toggleView != 1:
|
if self.toggleView != 1:
|
||||||
@@ -191,6 +347,27 @@ class ItemCompare(wx.Panel):
|
|||||||
|
|
||||||
# Add prices
|
# Add prices
|
||||||
self.paramList.SetItem(i, len(self.attrs) + 1, formatAmount(item.price.price, 3, 3, 9, currency=True) if item.price.price else "")
|
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:
|
if item.name in self.highlightedNames:
|
||||||
toHighlight.append(i)
|
toHighlight.append(i)
|
||||||
|
|
||||||
|
|||||||
220
gui/builtinItemStatsViews/itemSkills.py
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
# noinspection PyPackageRequirements
|
||||||
|
import wx
|
||||||
|
import itertools
|
||||||
|
|
||||||
|
import gui.mainFrame
|
||||||
|
from eos.saveddata.character import Skill
|
||||||
|
from eos.saveddata.fighter import Fighter as es_Fighter
|
||||||
|
from eos.saveddata.module import Module as es_Module
|
||||||
|
from eos.saveddata.ship import Ship
|
||||||
|
from gui.utils.clipboard import toClipboard
|
||||||
|
from gui.utils.numberFormatter import formatAmount
|
||||||
|
from service.fit import Fit
|
||||||
|
|
||||||
|
_t = wx.GetTranslation
|
||||||
|
|
||||||
|
|
||||||
|
class ItemSkills(wx.Panel):
|
||||||
|
def __init__(self, parent, stuff, item):
|
||||||
|
wx.Panel.__init__(self, parent)
|
||||||
|
self.stuff = stuff
|
||||||
|
self.item = item
|
||||||
|
|
||||||
|
mainSizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||||
|
|
||||||
|
leftPanel = wx.Panel(self)
|
||||||
|
leftSizer = wx.BoxSizer(wx.VERTICAL)
|
||||||
|
leftPanel.SetSizer(leftSizer)
|
||||||
|
|
||||||
|
header = wx.StaticText(leftPanel, wx.ID_ANY, _t("Components"))
|
||||||
|
font = header.GetFont()
|
||||||
|
font.SetWeight(wx.FONTWEIGHT_BOLD)
|
||||||
|
header.SetFont(font)
|
||||||
|
leftSizer.Add(header, 0, wx.ALL, 5)
|
||||||
|
|
||||||
|
self.checkboxes = {}
|
||||||
|
components = [
|
||||||
|
("Ship", "ship"),
|
||||||
|
("Modules", "modules"),
|
||||||
|
("Drones", "drones"),
|
||||||
|
("Fighters", "fighters"),
|
||||||
|
("Cargo", "cargo"),
|
||||||
|
("Implants", "appliedImplants"),
|
||||||
|
("Boosters", "boosters"),
|
||||||
|
("Necessary", "necessary"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for label, key in components:
|
||||||
|
cb = wx.CheckBox(leftPanel, wx.ID_ANY, label)
|
||||||
|
cb.SetValue(True)
|
||||||
|
cb.Bind(wx.EVT_CHECKBOX, self.onCheckboxChange)
|
||||||
|
self.checkboxes[key] = cb
|
||||||
|
leftSizer.Add(cb, 0, wx.ALL, 2)
|
||||||
|
|
||||||
|
leftSizer.AddStretchSpacer()
|
||||||
|
|
||||||
|
mainSizer.Add(leftPanel, 0, wx.EXPAND | wx.ALL, 5)
|
||||||
|
|
||||||
|
rightPanel = wx.Panel(self)
|
||||||
|
rightSizer = wx.BoxSizer(wx.VERTICAL)
|
||||||
|
rightPanel.SetSizer(rightSizer)
|
||||||
|
|
||||||
|
headerRight = wx.StaticText(rightPanel, wx.ID_ANY, _t("Skills"))
|
||||||
|
fontRight = headerRight.GetFont()
|
||||||
|
fontRight.SetWeight(wx.FONTWEIGHT_BOLD)
|
||||||
|
headerRight.SetFont(fontRight)
|
||||||
|
rightSizer.Add(headerRight, 0, wx.ALL, 5)
|
||||||
|
|
||||||
|
self.skillsText = wx.TextCtrl(rightPanel, wx.ID_ANY, "", style=wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_DONTWRAP)
|
||||||
|
font = wx.Font(9, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL)
|
||||||
|
self.skillsText.SetFont(font)
|
||||||
|
rightSizer.Add(self.skillsText, 1, wx.EXPAND | wx.ALL, 5)
|
||||||
|
|
||||||
|
mainSizer.Add(rightPanel, 1, wx.EXPAND | wx.ALL, 5)
|
||||||
|
|
||||||
|
self.SetSizer(mainSizer)
|
||||||
|
|
||||||
|
self.nbContainer = parent if isinstance(parent, wx.Notebook) else None
|
||||||
|
if self.nbContainer:
|
||||||
|
self.nbContainer.Bind(wx.EVT_NOTEBOOK_PAGE_CHANGED, self.onTabChanged)
|
||||||
|
|
||||||
|
self.updateSkills()
|
||||||
|
|
||||||
|
def onCheckboxChange(self, event):
|
||||||
|
self.updateSkills()
|
||||||
|
self._copyToClipboard()
|
||||||
|
|
||||||
|
def updateSkills(self):
|
||||||
|
fitID = gui.mainFrame.MainFrame.getInstance().getActiveFit()
|
||||||
|
if fitID is None:
|
||||||
|
self.skillsText.SetValue("")
|
||||||
|
self._updateCheckboxStates(None)
|
||||||
|
return
|
||||||
|
|
||||||
|
sFit = Fit.getInstance()
|
||||||
|
fit = sFit.getFit(fitID)
|
||||||
|
if fit is None:
|
||||||
|
self.skillsText.SetValue("")
|
||||||
|
self._updateCheckboxStates(None)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not fit.calculated:
|
||||||
|
fit.calculate()
|
||||||
|
|
||||||
|
self._updateCheckboxStates(fit)
|
||||||
|
|
||||||
|
char = fit.character
|
||||||
|
skillsMap = {}
|
||||||
|
|
||||||
|
items = []
|
||||||
|
if self.checkboxes["ship"].GetValue():
|
||||||
|
items.append(fit.ship)
|
||||||
|
if self.checkboxes["modules"].GetValue():
|
||||||
|
items.extend(fit.modules)
|
||||||
|
if self.checkboxes["drones"].GetValue():
|
||||||
|
items.extend(fit.drones)
|
||||||
|
if self.checkboxes["fighters"].GetValue():
|
||||||
|
items.extend(fit.fighters)
|
||||||
|
if self.checkboxes["cargo"].GetValue():
|
||||||
|
items.extend(fit.cargo)
|
||||||
|
if self.checkboxes["appliedImplants"].GetValue():
|
||||||
|
items.extend(fit.appliedImplants)
|
||||||
|
if self.checkboxes["boosters"].GetValue():
|
||||||
|
items.extend(fit.boosters)
|
||||||
|
|
||||||
|
for thing in items:
|
||||||
|
self._collectAffectingSkills(thing, char, skillsMap)
|
||||||
|
|
||||||
|
if self.checkboxes["necessary"].GetValue():
|
||||||
|
self._collectRequiredSkills(items, char, skillsMap)
|
||||||
|
|
||||||
|
skillsList = ""
|
||||||
|
for skillName in sorted(skillsMap):
|
||||||
|
charLevel = skillsMap[skillName]
|
||||||
|
for level in range(1, charLevel + 1):
|
||||||
|
skillsList += "%s %d\n" % (skillName, level)
|
||||||
|
|
||||||
|
self.skillsText.SetValue(skillsList)
|
||||||
|
self._copyToClipboard()
|
||||||
|
|
||||||
|
def _copyToClipboard(self):
|
||||||
|
skillsText = self.skillsText.GetValue()
|
||||||
|
if skillsText:
|
||||||
|
toClipboard(skillsText)
|
||||||
|
|
||||||
|
def onTabChanged(self, event):
|
||||||
|
if self.nbContainer:
|
||||||
|
pageIndex = self.nbContainer.FindPage(self)
|
||||||
|
if pageIndex != -1 and event.GetSelection() == pageIndex:
|
||||||
|
self.updateSkills()
|
||||||
|
event.Skip()
|
||||||
|
|
||||||
|
def _updateCheckboxStates(self, fit):
|
||||||
|
if fit is None:
|
||||||
|
for cb in self.checkboxes.values():
|
||||||
|
cb.Enable(False)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.checkboxes["ship"].Enable(True)
|
||||||
|
self.checkboxes["modules"].Enable(len(fit.modules) > 0)
|
||||||
|
self.checkboxes["drones"].Enable(len(fit.drones) > 0)
|
||||||
|
self.checkboxes["fighters"].Enable(len(fit.fighters) > 0)
|
||||||
|
self.checkboxes["cargo"].Enable(len(fit.cargo) > 0)
|
||||||
|
self.checkboxes["appliedImplants"].Enable(len(fit.appliedImplants) > 0)
|
||||||
|
self.checkboxes["boosters"].Enable(len(fit.boosters) > 0)
|
||||||
|
self.checkboxes["necessary"].Enable(True)
|
||||||
|
|
||||||
|
def _collectAffectingSkills(self, thing, char, skillsMap):
|
||||||
|
for attr in ("item", "charge"):
|
||||||
|
if attr == "charge" and isinstance(thing, es_Fighter):
|
||||||
|
continue
|
||||||
|
subThing = getattr(thing, attr, None)
|
||||||
|
if subThing is None:
|
||||||
|
continue
|
||||||
|
if isinstance(thing, es_Fighter) and attr == "charge":
|
||||||
|
continue
|
||||||
|
|
||||||
|
if attr == "charge":
|
||||||
|
cont = getattr(thing, "chargeModifiedAttributes", None)
|
||||||
|
else:
|
||||||
|
cont = getattr(thing, "itemModifiedAttributes", None)
|
||||||
|
|
||||||
|
if cont is not None:
|
||||||
|
for attrName in cont.iterAfflictions():
|
||||||
|
for fit, afflictors in cont.getAfflictions(attrName).items():
|
||||||
|
for afflictor, operator, stackingGroup, preResAmount, postResAmount, used in afflictors:
|
||||||
|
if isinstance(afflictor, Skill) and afflictor.character == char:
|
||||||
|
skillName = afflictor.item.name
|
||||||
|
if skillName not in skillsMap:
|
||||||
|
skillsMap[skillName] = afflictor.level
|
||||||
|
elif skillsMap[skillName] < afflictor.level:
|
||||||
|
skillsMap[skillName] = afflictor.level
|
||||||
|
|
||||||
|
def _collectRequiredSkills(self, items, char, skillsMap):
|
||||||
|
"""Collect required skills from items (necessary to use them)"""
|
||||||
|
for thing in items:
|
||||||
|
for attr in ("item", "charge"):
|
||||||
|
if attr == "charge" and isinstance(thing, es_Fighter):
|
||||||
|
continue
|
||||||
|
subThing = getattr(thing, attr, None)
|
||||||
|
if subThing is None:
|
||||||
|
continue
|
||||||
|
if isinstance(thing, es_Fighter) and attr == "charge":
|
||||||
|
continue
|
||||||
|
|
||||||
|
if hasattr(subThing, "requiredSkills"):
|
||||||
|
for reqSkill, level in subThing.requiredSkills.items():
|
||||||
|
skillName = reqSkill.name
|
||||||
|
charSkill = char.getSkill(reqSkill) if char else None
|
||||||
|
charLevel = charSkill.level if charSkill else 0
|
||||||
|
|
||||||
|
if charLevel > 0:
|
||||||
|
if skillName not in skillsMap:
|
||||||
|
skillsMap[skillName] = charLevel
|
||||||
|
elif skillsMap[skillName] < charLevel:
|
||||||
|
skillsMap[skillName] = charLevel
|
||||||
|
else:
|
||||||
|
if skillName not in skillsMap:
|
||||||
|
skillsMap[skillName] = level
|
||||||
|
elif skillsMap[skillName] < level:
|
||||||
|
skillsMap[skillName] = level
|
||||||
@@ -3,8 +3,9 @@ from logbook import Logger
|
|||||||
|
|
||||||
import gui.builtinMarketBrowser.pfSearchBox as SBox
|
import gui.builtinMarketBrowser.pfSearchBox as SBox
|
||||||
import gui.globalEvents as GE
|
import gui.globalEvents as GE
|
||||||
from config import slotColourMap, slotColourMapDark
|
from config import get_slotColourMap, get_slotColourMapDark
|
||||||
from eos.saveddata.module import Module
|
from eos.saveddata.module import Module
|
||||||
|
from eos.const import FittingSlot
|
||||||
from gui.builtinMarketBrowser.events import ItemSelected, RECENTLY_USED_MODULES, CHARGES_FOR_FIT
|
from gui.builtinMarketBrowser.events import ItemSelected, RECENTLY_USED_MODULES, CHARGES_FOR_FIT
|
||||||
from gui.contextMenu import ContextMenu
|
from gui.contextMenu import ContextMenu
|
||||||
from gui.display import Display
|
from gui.display import Display
|
||||||
@@ -22,6 +23,7 @@ class ItemView(Display):
|
|||||||
|
|
||||||
DEFAULT_COLS = ["Base Icon",
|
DEFAULT_COLS = ["Base Icon",
|
||||||
"Base Name",
|
"Base Name",
|
||||||
|
"Price",
|
||||||
"attr:power,,,True",
|
"attr:power,,,True",
|
||||||
"attr:cpu,,,True"]
|
"attr:cpu,,,True"]
|
||||||
|
|
||||||
@@ -153,19 +155,22 @@ class ItemView(Display):
|
|||||||
# skip the event so the other handlers also get called
|
# skip the event so the other handlers also get called
|
||||||
event.Skip()
|
event.Skip()
|
||||||
|
|
||||||
if self.marketBrowser.mode != 'charges':
|
|
||||||
return
|
|
||||||
|
|
||||||
activeFitID = self.mainFrame.getActiveFit()
|
activeFitID = self.mainFrame.getActiveFit()
|
||||||
# if it was not the active fitting that was changed, do not do anything
|
# if it was not the active fitting that was changed, do not do anything
|
||||||
if activeFitID is not None and activeFitID not in event.fitIDs:
|
if activeFitID is not None and activeFitID not in event.fitIDs:
|
||||||
return
|
return
|
||||||
|
|
||||||
items = self.getChargesForActiveFit()
|
# Handle charges mode
|
||||||
|
if self.marketBrowser.mode == 'charges':
|
||||||
|
items = self.getChargesForActiveFit()
|
||||||
|
# update the UI
|
||||||
|
self.updateItemStore(items)
|
||||||
|
self.filterItemStore()
|
||||||
|
return
|
||||||
|
|
||||||
# update the UI
|
# If "Fits" filter is active, re-filter the current view
|
||||||
self.updateItemStore(items)
|
if self.marketBrowser.getFitsFilter():
|
||||||
self.filterItemStore()
|
self.filterItemStore()
|
||||||
|
|
||||||
def updateItemStore(self, items):
|
def updateItemStore(self, items):
|
||||||
self.unfilteredStore = items
|
self.unfilteredStore = items
|
||||||
@@ -197,13 +202,115 @@ class ItemView(Display):
|
|||||||
if btn.userSelected:
|
if btn.userSelected:
|
||||||
selectedMetas.update(sMkt.META_MAP[btn.metaName])
|
selectedMetas.update(sMkt.META_MAP[btn.metaName])
|
||||||
filteredItems = sMkt.filterItemsByMeta(self.unfilteredStore, selectedMetas)
|
filteredItems = sMkt.filterItemsByMeta(self.unfilteredStore, selectedMetas)
|
||||||
|
|
||||||
|
# Apply slot/fits filters - works IDENTICALLY to meta buttons (filters CURRENT VIEW only)
|
||||||
|
activeSlotFilters = []
|
||||||
|
fitsFilterActive = False
|
||||||
|
for btn in self.marketBrowser.slotButtons:
|
||||||
|
if btn.userSelected and btn.IsEnabled():
|
||||||
|
if btn.filterType == "fits":
|
||||||
|
fitsFilterActive = True
|
||||||
|
elif btn.filterType == "slot":
|
||||||
|
activeSlotFilters.append(btn.slotType)
|
||||||
|
|
||||||
|
# Apply fits filter
|
||||||
|
if fitsFilterActive:
|
||||||
|
filteredItems = self._filterByFits(filteredItems)
|
||||||
|
|
||||||
|
# Apply slot filters
|
||||||
|
if activeSlotFilters:
|
||||||
|
filteredItems = [item for item in filteredItems if Module.calculateSlot(item) in activeSlotFilters]
|
||||||
|
|
||||||
return filteredItems
|
return filteredItems
|
||||||
|
|
||||||
|
def _filterByFits(self, items):
|
||||||
|
"""Filter items by remaining CPU/PG - filters CURRENT VIEW only"""
|
||||||
|
fitId = self.mainFrame.getActiveFit()
|
||||||
|
if fitId is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
fit = self.sFit.getFit(fitId)
|
||||||
|
|
||||||
|
# Get remaining CPU and power grid
|
||||||
|
cpuOutput = fit.ship.getModifiedItemAttr("cpuOutput")
|
||||||
|
powerOutput = fit.ship.getModifiedItemAttr("powerOutput")
|
||||||
|
cpuUsed = fit.cpuUsed
|
||||||
|
pgUsed = fit.pgUsed
|
||||||
|
cpuRemaining = cpuOutput - cpuUsed
|
||||||
|
pgRemaining = powerOutput - pgUsed
|
||||||
|
|
||||||
|
# Get remaining calibration (for rigs)
|
||||||
|
calibrationCapacity = fit.ship.getModifiedItemAttr("upgradeCapacity")
|
||||||
|
calibrationUsed = fit.calibrationUsed
|
||||||
|
calibrationRemaining = None
|
||||||
|
if calibrationCapacity is not None and calibrationCapacity > 0:
|
||||||
|
calibrationRemaining = calibrationCapacity - calibrationUsed
|
||||||
|
|
||||||
|
fittingItems = []
|
||||||
|
for item in items:
|
||||||
|
# Check if item is a module (has a slot)
|
||||||
|
slot = Module.calculateSlot(item)
|
||||||
|
if slot is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Rigs don't use CPU/power, they use calibration - check rig size and calibration
|
||||||
|
if slot == FittingSlot.RIG:
|
||||||
|
# Check if item can fit on the ship
|
||||||
|
if not fit.canFit(item):
|
||||||
|
continue
|
||||||
|
# Check rig size compatibility with ship
|
||||||
|
shipRigSize = fit.ship.getModifiedItemAttr("rigSize")
|
||||||
|
itemRigSize = item.attributes.get("rigSize")
|
||||||
|
if shipRigSize is not None and itemRigSize is not None:
|
||||||
|
if shipRigSize != itemRigSize.value:
|
||||||
|
continue
|
||||||
|
# Check calibration requirement
|
||||||
|
if calibrationRemaining is not None and calibrationRemaining > 0:
|
||||||
|
itemCalibration = item.attributes.get("upgradeCost")
|
||||||
|
if itemCalibration is not None:
|
||||||
|
itemCalibrationValue = itemCalibration.value
|
||||||
|
if itemCalibrationValue > calibrationRemaining:
|
||||||
|
continue
|
||||||
|
fittingItems.append(item)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# For non-rigs, check CPU and power requirements
|
||||||
|
itemCpu = item.attributes.get("cpu")
|
||||||
|
itemPower = item.attributes.get("power")
|
||||||
|
|
||||||
|
# Skip items without CPU or power (not modules)
|
||||||
|
if itemCpu is None and itemPower is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check CPU requirement
|
||||||
|
if itemCpu is not None:
|
||||||
|
itemCpuValue = itemCpu.value
|
||||||
|
if itemCpuValue > cpuRemaining:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check power requirement
|
||||||
|
if itemPower is not None:
|
||||||
|
itemPowerValue = itemPower.value
|
||||||
|
if itemPowerValue > pgRemaining:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if item can fit on the ship (most expensive check, do last)
|
||||||
|
if not fit.canFit(item):
|
||||||
|
continue
|
||||||
|
|
||||||
|
fittingItems.append(item)
|
||||||
|
|
||||||
|
return fittingItems
|
||||||
|
|
||||||
def setToggles(self):
|
def setToggles(self):
|
||||||
metaIDs = set()
|
metaIDs = set()
|
||||||
|
slotIDs = set()
|
||||||
sMkt = self.sMkt
|
sMkt = self.sMkt
|
||||||
for item in self.unfilteredStore:
|
for item in self.unfilteredStore:
|
||||||
metaIDs.add(sMkt.getMetaGroupIdByItem(item))
|
metaIDs.add(sMkt.getMetaGroupIdByItem(item))
|
||||||
|
slot = Module.calculateSlot(item)
|
||||||
|
if slot is not None:
|
||||||
|
slotIDs.add(slot)
|
||||||
|
|
||||||
for btn in self.marketBrowser.metaButtons:
|
for btn in self.marketBrowser.metaButtons:
|
||||||
btn.reset()
|
btn.reset()
|
||||||
@@ -212,6 +319,23 @@ class ItemView(Display):
|
|||||||
btn.setMetaAvailable(True)
|
btn.setMetaAvailable(True)
|
||||||
else:
|
else:
|
||||||
btn.setMetaAvailable(False)
|
btn.setMetaAvailable(False)
|
||||||
|
|
||||||
|
# Set toggles for slot/fits buttons
|
||||||
|
for btn in self.marketBrowser.slotButtons:
|
||||||
|
btn.reset()
|
||||||
|
if btn.filterType == "fits":
|
||||||
|
# Fits button is available if there's an active fit
|
||||||
|
fitId = self.mainFrame.getActiveFit()
|
||||||
|
isAvailable = fitId is not None
|
||||||
|
btn.setMetaAvailable(isAvailable)
|
||||||
|
if not isAvailable:
|
||||||
|
btn.setUserSelection(False)
|
||||||
|
elif btn.filterType == "slot":
|
||||||
|
# Slot button is available if items with that slot exist in current view
|
||||||
|
isAvailable = btn.slotType in slotIDs
|
||||||
|
btn.setMetaAvailable(isAvailable)
|
||||||
|
if not isAvailable:
|
||||||
|
btn.setUserSelection(False)
|
||||||
|
|
||||||
def scheduleSearch(self, event=None):
|
def scheduleSearch(self, event=None):
|
||||||
self.searchTimer.Stop() # Cancel any pending timers
|
self.searchTimer.Stop() # Cancel any pending timers
|
||||||
@@ -288,7 +412,7 @@ class ItemView(Display):
|
|||||||
|
|
||||||
def columnBackground(self, colItem, item):
|
def columnBackground(self, colItem, item):
|
||||||
if self.sFit.serviceFittingOptions["colorFitBySlot"]:
|
if self.sFit.serviceFittingOptions["colorFitBySlot"]:
|
||||||
colorMap = slotColourMapDark if isDark() else slotColourMap
|
colorMap = get_slotColourMapDark() if isDark() else get_slotColourMap()
|
||||||
return colorMap.get(Module.calculateSlot(item)) or self.GetBackgroundColour()
|
return colorMap.get(Module.calculateSlot(item)) or self.GetBackgroundColour()
|
||||||
else:
|
else:
|
||||||
return self.GetBackgroundColour()
|
return self.GetBackgroundColour()
|
||||||
|
|||||||
@@ -130,9 +130,9 @@ class MiningYieldViewFull(StatsView):
|
|||||||
def refreshPanel(self, fit):
|
def refreshPanel(self, fit):
|
||||||
# If we did anything intresting, we'd update our labels to reflect the new fit's stats here
|
# If we did anything intresting, we'd update our labels to reflect the new fit's stats here
|
||||||
|
|
||||||
stats = (("labelFullminingyieldMiner", lambda: fit.minerYield, lambda: fit.minerWaste, 3, 0, 0, "{}{} m\u00B3/s", None),
|
stats = (("labelFullminingyieldMiner", lambda: fit.minerYield, lambda: fit.minerDrain, 3, 0, 0, "{} m\u00B3/s", None),
|
||||||
("labelFullminingyieldDrone", lambda: fit.droneYield, lambda: fit.droneWaste, 3, 0, 0, "{}{} m\u00B3/s", None),
|
("labelFullminingyieldDrone", lambda: fit.droneYield, lambda: fit.droneDrain, 3, 0, 0, "{} m\u00B3/s", None),
|
||||||
("labelFullminingyieldTotal", lambda: fit.totalYield, lambda: fit.totalWaste, 3, 0, 0, "{}{} m\u00B3/s", None))
|
("labelFullminingyieldTotal", lambda: fit.totalYield, lambda: fit.totalDrain, 3, 0, 0, "{} m\u00B3/s", None))
|
||||||
|
|
||||||
def processValue(value):
|
def processValue(value):
|
||||||
value = value() if fit is not None else 0
|
value = value() if fit is not None else 0
|
||||||
@@ -140,23 +140,26 @@ class MiningYieldViewFull(StatsView):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
counter = 0
|
counter = 0
|
||||||
for labelName, yieldValue, wasteValue, prec, lowest, highest, valueFormat, altFormat in stats:
|
for labelName, yieldValue, drainValue, prec, lowest, highest, valueFormat, altFormat in stats:
|
||||||
label = getattr(self, labelName)
|
label = getattr(self, labelName)
|
||||||
yieldValue = processValue(yieldValue)
|
yieldValue = processValue(yieldValue)
|
||||||
wasteValue = processValue(wasteValue)
|
drainValue = processValue(drainValue)
|
||||||
if self._cachedValues[counter] != (yieldValue, wasteValue):
|
if self._cachedValues[counter] != (yieldValue, drainValue):
|
||||||
|
try:
|
||||||
|
efficiency = '{}%'.format(formatAmount(yieldValue / drainValue * 100, 4, 0, 0))
|
||||||
|
except ZeroDivisionError:
|
||||||
|
efficiency = '0%'
|
||||||
yps = formatAmount(yieldValue, prec, lowest, highest)
|
yps = formatAmount(yieldValue, prec, lowest, highest)
|
||||||
yph = formatAmount(yieldValue * 3600, prec, lowest, highest)
|
yph = formatAmount(yieldValue * 3600, prec, lowest, highest)
|
||||||
wps = formatAmount(wasteValue, prec, lowest, highest)
|
dps = formatAmount(drainValue, prec, lowest, highest)
|
||||||
wph = formatAmount(wasteValue * 3600, prec, lowest, highest)
|
dph = formatAmount(drainValue * 3600, prec, lowest, highest)
|
||||||
wasteSuffix = '\u02b7' if wasteValue > 0 else ''
|
label.SetLabel(valueFormat.format(yps))
|
||||||
label.SetLabel(valueFormat.format(yps, wasteSuffix))
|
|
||||||
tipLines = []
|
tipLines = []
|
||||||
tipLines.append("{} m\u00B3 mining yield per second ({} m\u00B3 per hour)".format(yps, yph))
|
tipLines.append("{} m\u00B3 yield per second ({} m\u00B3 per hour)".format(yps, yph))
|
||||||
if wasteValue > 0:
|
tipLines.append("{} m\u00B3 drain per second ({} m\u00B3 per hour)".format(dps, dph))
|
||||||
tipLines.append("{} m\u00B3 mining waste per second ({} m\u00B3 per hour)".format(wps, wph))
|
tipLines.append(f'{efficiency} efficiency')
|
||||||
label.SetToolTip(wx.ToolTip('\n'.join(tipLines)))
|
label.SetToolTip(wx.ToolTip('\n'.join(tipLines)))
|
||||||
self._cachedValues[counter] = (yieldValue, wasteValue)
|
self._cachedValues[counter] = (yieldValue, drainValue)
|
||||||
counter += 1
|
counter += 1
|
||||||
self.panel.Layout()
|
self.panel.Layout()
|
||||||
self.headerPanel.Layout()
|
self.headerPanel.Layout()
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ class TargetingMiscViewMinimal(StatsView):
|
|||||||
("specialPlanetaryCommoditiesHoldCapacity", _t("Planetary goods hold")),
|
("specialPlanetaryCommoditiesHoldCapacity", _t("Planetary goods hold")),
|
||||||
("specialQuafeHoldCapacity", _t("Quafe hold")),
|
("specialQuafeHoldCapacity", _t("Quafe hold")),
|
||||||
("specialMobileDepotHoldCapacity", _t("Mobile depot hold")),
|
("specialMobileDepotHoldCapacity", _t("Mobile depot hold")),
|
||||||
|
("specialExpeditionHoldCapacity", _t("Expedition hold")),
|
||||||
))
|
))
|
||||||
|
|
||||||
cargoValues = {
|
cargoValues = {
|
||||||
@@ -154,6 +155,7 @@ class TargetingMiscViewMinimal(StatsView):
|
|||||||
"specialPlanetaryCommoditiesHoldCapacity": lambda: fit.ship.getModifiedItemAttr("specialPlanetaryCommoditiesHoldCapacity"),
|
"specialPlanetaryCommoditiesHoldCapacity": lambda: fit.ship.getModifiedItemAttr("specialPlanetaryCommoditiesHoldCapacity"),
|
||||||
"specialQuafeHoldCapacity": lambda: fit.ship.getModifiedItemAttr("specialQuafeHoldCapacity"),
|
"specialQuafeHoldCapacity": lambda: fit.ship.getModifiedItemAttr("specialQuafeHoldCapacity"),
|
||||||
"specialMobileDepotHoldCapacity": lambda: fit.ship.getModifiedItemAttr("specialMobileDepotHoldCapacity"),
|
"specialMobileDepotHoldCapacity": lambda: fit.ship.getModifiedItemAttr("specialMobileDepotHoldCapacity"),
|
||||||
|
"specialExpeditionHoldCapacity": lambda: fit.ship.getModifiedItemAttr("specialExpeditionHoldCapacity"),
|
||||||
}
|
}
|
||||||
|
|
||||||
stats = (("labelTargets", {"main": lambda: fit.maxTargets}, 3, 0, 0, ""),
|
stats = (("labelTargets", {"main": lambda: fit.maxTargets}, 3, 0, 0, ""),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from gui.viewColumn import ViewColumn
|
|||||||
from gui.bitmap_loader import BitmapLoader
|
from gui.bitmap_loader import BitmapLoader
|
||||||
from gui.utils.numberFormatter import formatAmount
|
from gui.utils.numberFormatter import formatAmount
|
||||||
from gui.utils.listFormatter import formatList
|
from gui.utils.listFormatter import formatList
|
||||||
|
from eos.utils.float import floatUnerr
|
||||||
from eos.utils.spoolSupport import SpoolType, SpoolOptions
|
from eos.utils.spoolSupport import SpoolType, SpoolOptions
|
||||||
import eos.config
|
import eos.config
|
||||||
|
|
||||||
@@ -195,7 +196,7 @@ class Miscellanea(ViewColumn):
|
|||||||
tooltip = "Warp core strength modification"
|
tooltip = "Warp core strength modification"
|
||||||
return text, tooltip
|
return text, tooltip
|
||||||
elif (
|
elif (
|
||||||
itemGroup in ("Stasis Web", "Stasis Webifying Drone", "Structure Stasis Webifier") or
|
itemGroup in ("Stasis Web", "Stasis Grappler", "Stasis Webifying Drone", "Structure Stasis Webifier") or
|
||||||
(itemGroup in ("Structure Burst Projector", "Burst Projectors") and "doomsdayAOEWeb" in item.effects)
|
(itemGroup in ("Structure Burst Projector", "Burst Projectors") and "doomsdayAOEWeb" in item.effects)
|
||||||
):
|
):
|
||||||
speedFactor = stuff.getModifiedItemAttr("speedFactor")
|
speedFactor = stuff.getModifiedItemAttr("speedFactor")
|
||||||
@@ -547,18 +548,24 @@ class Miscellanea(ViewColumn):
|
|||||||
if not yps:
|
if not yps:
|
||||||
return "", None
|
return "", None
|
||||||
yph = yps * 3600
|
yph = yps * 3600
|
||||||
wps = stuff.getMiningWPS(ignoreState=True)
|
dps = stuff.getMiningDPS(ignoreState=True)
|
||||||
wph = wps * 3600
|
dph = dps * 3600
|
||||||
|
try:
|
||||||
|
efficiency = yps / dps
|
||||||
|
except ZeroDivisionError:
|
||||||
|
efficiency = 0
|
||||||
textParts = []
|
textParts = []
|
||||||
textParts.append(formatAmount(yps, 3, 0, 3))
|
|
||||||
tipLines = []
|
tipLines = []
|
||||||
|
textParts.append('{} m\u00B3/s'.format(formatAmount(yps, 3, 0, 3)))
|
||||||
tipLines.append("{} m\u00B3 mining yield per second ({} m\u00B3 per hour)".format(
|
tipLines.append("{} m\u00B3 mining yield per second ({} m\u00B3 per hour)".format(
|
||||||
formatAmount(yps, 3, 0, 3), formatAmount(yph, 3, 0, 3)))
|
formatAmount(yps, 3, 0, 3), formatAmount(yph, 3, 0, 3)))
|
||||||
if wps > 0:
|
tipLines.append("{} m\u00B3 mining drain per second ({} m\u00B3 per hour)".format(
|
||||||
textParts.append(formatAmount(wps, 3, 0, 3))
|
formatAmount(dps, 3, 0, 3), formatAmount(dph, 3, 0, 3)))
|
||||||
tipLines.append("{} m\u00B3 mining waste per second ({} m\u00B3 per hour)".format(
|
if floatUnerr(efficiency) != 1:
|
||||||
formatAmount(wps, 3, 0, 3), formatAmount(wph, 3, 0, 3)))
|
eff_text = '{}%'.format(formatAmount(efficiency * 100, 4, 0, 0))
|
||||||
text = '{} m\u00B3/s'.format('+'.join(textParts))
|
textParts.append(eff_text)
|
||||||
|
tipLines.append(f"{eff_text} mining efficiency")
|
||||||
|
text = '{}'.format(' | '.join(textParts))
|
||||||
tooltip = '\n'.join(tipLines)
|
tooltip = '\n'.join(tipLines)
|
||||||
return text, tooltip
|
return text, tooltip
|
||||||
elif itemGroup == "Logistic Drone":
|
elif itemGroup == "Logistic Drone":
|
||||||
@@ -701,7 +708,7 @@ class Miscellanea(ViewColumn):
|
|||||||
formatAmount(itemArmorResistanceShiftHardenerExp, 3, 0, 3),
|
formatAmount(itemArmorResistanceShiftHardenerExp, 3, 0, 3),
|
||||||
)
|
)
|
||||||
return text, tooltip
|
return text, tooltip
|
||||||
elif itemGroup in ("Cargo Scanner", "Ship Scanner", "Survey Scanner"):
|
elif itemGroup in ("Cargo Scanner", "Ship Scanner"):
|
||||||
duration = stuff.getModifiedItemAttr("duration")
|
duration = stuff.getModifiedItemAttr("duration")
|
||||||
if not duration:
|
if not duration:
|
||||||
return "", None
|
return "", None
|
||||||
@@ -766,15 +773,36 @@ class Miscellanea(ViewColumn):
|
|||||||
elif buffId == 22: # Skirmish Burst: Rapid Deployment: AB/MWD Speed Increase
|
elif buffId == 22: # Skirmish Burst: Rapid Deployment: AB/MWD Speed Increase
|
||||||
textSections.append(f"{formatAmount(buffValue, 3, 0, 3, forceSign=True)}%")
|
textSections.append(f"{formatAmount(buffValue, 3, 0, 3, forceSign=True)}%")
|
||||||
tooltipSections.append("AB/MWD speed increase")
|
tooltipSections.append("AB/MWD speed increase")
|
||||||
elif buffId == 23: # Mining Burst: Mining Laser Field Enhancement: Mining/Survey Range
|
elif buffId == 23: # Mining Burst: Mining Laser Field Enhancement: Mining Range
|
||||||
textSections.append(f"{formatAmount(buffValue, 3, 0, 3, forceSign=True)}%")
|
textSections.append(f"{formatAmount(buffValue, 3, 0, 3, forceSign=True)}%")
|
||||||
tooltipSections.append("mining/survey module range")
|
tooltipSections.append("mining module range")
|
||||||
elif buffId == 24: # Mining Burst: Mining Laser Optimization: Mining Capacitor/Duration
|
elif buffId == 24: # Mining Burst: Mining Laser Optimization: Mining Capacitor/Duration
|
||||||
textSections.append(f"{formatAmount(buffValue, 3, 0, 3, forceSign=True)}%")
|
textSections.append(f"{formatAmount(buffValue, 3, 0, 3, forceSign=True)}%")
|
||||||
tooltipSections.append("mining module duration & capacitor use")
|
tooltipSections.append("mining module duration & capacitor use")
|
||||||
elif buffId == 25: # Mining Burst: Mining Equipment Preservation: Crystal Volatility
|
elif buffId == 25: # Mining Burst: Mining Equipment Preservation: Crystal Volatility
|
||||||
textSections.append(f"{formatAmount(buffValue, 3, 0, 3, forceSign=True)}%")
|
textSections.append(f"{formatAmount(buffValue, 3, 0, 3, forceSign=True)}%")
|
||||||
tooltipSections.append("mining crystal volatility")
|
tooltipSections.append("mining crystal volatility")
|
||||||
|
elif buffId == 2464: # Expedition Burst: Probe Strength
|
||||||
|
textSections.append(f"{formatAmount(buffValue, 3, 0, 3, forceSign=True)}%")
|
||||||
|
tooltipSections.append("scan probe strength")
|
||||||
|
elif buffId == 2465: # Expedition Burst: Directional Scanner, Hacking and Salvager Range
|
||||||
|
textSections.append(f"{formatAmount(buffValue, 3, 0, 3, forceSign=True)}%")
|
||||||
|
tooltipSections.append("dscan, hacking & salvaging range")
|
||||||
|
elif buffId == 2466: # Expedition Burst: Maximum Scan Deviation Modifier
|
||||||
|
textSections.append(f"{formatAmount(buffValue, 3, 0, 3, forceSign=True)}%")
|
||||||
|
tooltipSections.append("scan probe deviation")
|
||||||
|
elif buffId == 2468: # Expedition Burst: Virus Coherence
|
||||||
|
textSections.append(f"{formatAmount(buffValue, 3, 0, 3, forceSign=True)}")
|
||||||
|
tooltipSections.append("virus coherence")
|
||||||
|
elif buffId == 2481: # Expedition Burst: Salvager duration bonus
|
||||||
|
textSections.append(f"{formatAmount(buffValue, 3, 0, 3, forceSign=True)}%")
|
||||||
|
tooltipSections.append("salvager cycle time")
|
||||||
|
elif buffId == 2516: # Mining Burst: Mining Crit Chance
|
||||||
|
textSections.append(f"{formatAmount(buffValue, 3, 0, 3, forceSign=True)}%")
|
||||||
|
tooltipSections.append("crit chance")
|
||||||
|
elif buffId == 2517: # Mining Burst: Mining Residue Chance Reduction
|
||||||
|
textSections.append(f"{formatAmount(buffValue, 3, 0, 3, forceSign=True)}%")
|
||||||
|
tooltipSections.append("waste chance")
|
||||||
if not textSections:
|
if not textSections:
|
||||||
return '', None
|
return '', None
|
||||||
text = ' | '.join(textSections)
|
text = ' | '.join(textSections)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
# noinspection PyPackageRequirements
|
# noinspection PyPackageRequirements
|
||||||
import wx
|
import wx
|
||||||
|
|
||||||
|
from eos.gamedata import Item
|
||||||
from eos.saveddata.cargo import Cargo
|
from eos.saveddata.cargo import Cargo
|
||||||
from eos.saveddata.drone import Drone
|
from eos.saveddata.drone import Drone
|
||||||
from eos.saveddata.fighter import Fighter
|
from eos.saveddata.fighter import Fighter
|
||||||
@@ -53,7 +54,14 @@ class Price(ViewColumn):
|
|||||||
self.imageId = fittingView.imageList.GetImageIndex("totalPrice_small", "gui")
|
self.imageId = fittingView.imageList.GetImageIndex("totalPrice_small", "gui")
|
||||||
|
|
||||||
def getText(self, stuff):
|
def getText(self, stuff):
|
||||||
if stuff.item is None or stuff.item.group.name == "Ship Modifiers":
|
if isinstance(stuff, Item):
|
||||||
|
item = stuff
|
||||||
|
else:
|
||||||
|
if not hasattr(stuff, "item") or stuff.item is None:
|
||||||
|
return ""
|
||||||
|
item = stuff.item
|
||||||
|
|
||||||
|
if item.group.name == "Ship Modifiers":
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
if hasattr(stuff, "isEmpty"):
|
if hasattr(stuff, "isEmpty"):
|
||||||
@@ -63,7 +71,7 @@ class Price(ViewColumn):
|
|||||||
if isinstance(stuff, Module) and stuff.isMutated:
|
if isinstance(stuff, Module) and stuff.isMutated:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
priceObj = stuff.item.price
|
priceObj = item.price
|
||||||
|
|
||||||
if not priceObj.isValid():
|
if not priceObj.isValid():
|
||||||
return False
|
return False
|
||||||
@@ -79,7 +87,11 @@ class Price(ViewColumn):
|
|||||||
|
|
||||||
display.SetItem(colItem)
|
display.SetItem(colItem)
|
||||||
|
|
||||||
sPrice.getPrices([mod.item], callback, waitforthread=True)
|
if isinstance(mod, Item):
|
||||||
|
item = mod
|
||||||
|
else:
|
||||||
|
item = mod.item
|
||||||
|
sPrice.getPrices([item], callback, waitforthread=True)
|
||||||
|
|
||||||
def getImageId(self, mod):
|
def getImageId(self, mod):
|
||||||
return -1
|
return -1
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ from gui.utils.staticHelpers import DragDropHelper
|
|||||||
from gui.utils.dark import isDark
|
from gui.utils.dark import isDark
|
||||||
from service.fit import Fit
|
from service.fit import Fit
|
||||||
from service.market import Market
|
from service.market import Market
|
||||||
from config import slotColourMap, slotColourMapDark, errColor, errColorDark
|
from config import get_slotColourMap, get_slotColourMapDark, get_errColor, get_errColorDark
|
||||||
from gui.fitCommands.helpers import getSimilarModPositions
|
from gui.fitCommands.helpers import getSimilarModPositions
|
||||||
|
|
||||||
pyfalog = Logger(__name__)
|
pyfalog = Logger(__name__)
|
||||||
@@ -497,11 +497,27 @@ class FittingView(d.Display):
|
|||||||
fit = Fit.getInstance().getFit(fitID)
|
fit = Fit.getInstance().getFit(fitID)
|
||||||
if mod in fit.modules:
|
if mod in fit.modules:
|
||||||
position = fit.modules.index(mod)
|
position = fit.modules.index(mod)
|
||||||
self.mainFrame.command.Submit(cmd.GuiCargoToLocalModuleCommand(
|
modifiers = wx.GetMouseState().GetModifiers()
|
||||||
fitID=fitID,
|
isCopy = modifiers == wx.MOD_CONTROL
|
||||||
cargoItemID=cargoItemID,
|
isBatch = modifiers == wx.MOD_SHIFT
|
||||||
modPosition=position,
|
if isBatch:
|
||||||
copy=wx.GetMouseState().GetModifiers() == wx.MOD_CONTROL))
|
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):
|
def swapItems(self, x, y, srcIdx):
|
||||||
"""Swap two modules in fitting window"""
|
"""Swap two modules in fitting window"""
|
||||||
@@ -668,6 +684,21 @@ class FittingView(d.Display):
|
|||||||
contexts.append(fullContext)
|
contexts.append(fullContext)
|
||||||
contexts.append(("fittingShip", _t("Ship") if not fit.isStructure else _t("Citadel")))
|
contexts.append(("fittingShip", _t("Ship") if not fit.isStructure else _t("Citadel")))
|
||||||
|
|
||||||
|
# Check if shift is held for direct skills menu access
|
||||||
|
if wx.GetKeyState(wx.WXK_SHIFT):
|
||||||
|
from gui.builtinContextMenus.skillAffectors import ChangeAffectingSkills
|
||||||
|
for fullContext in contexts:
|
||||||
|
srcContext = fullContext[0]
|
||||||
|
itemContext = fullContext[1] if len(fullContext) > 1 else None
|
||||||
|
skillsMenu = ChangeAffectingSkills()
|
||||||
|
if skillsMenu.display(self, srcContext, mainMod):
|
||||||
|
# On Windows, menu items need to be bound to the menu shown with PopupMenu
|
||||||
|
# We pass None as rootMenu so items are bound to their parent submenus
|
||||||
|
sub = skillsMenu.getSubMenu(self, srcContext, mainMod, None, 0, None)
|
||||||
|
if sub:
|
||||||
|
self.PopupMenu(sub)
|
||||||
|
return
|
||||||
|
|
||||||
menu = ContextMenu.getMenu(self, mainMod, selection, *contexts)
|
menu = ContextMenu.getMenu(self, mainMod, selection, *contexts)
|
||||||
self.PopupMenu(menu)
|
self.PopupMenu(menu)
|
||||||
|
|
||||||
@@ -735,9 +766,9 @@ class FittingView(d.Display):
|
|||||||
|
|
||||||
def slotColour(self, slot):
|
def slotColour(self, slot):
|
||||||
if isDark():
|
if isDark():
|
||||||
return slotColourMapDark.get(slot) or self.GetBackgroundColour()
|
return get_slotColourMapDark().get(slot) or self.GetBackgroundColour()
|
||||||
else:
|
else:
|
||||||
return slotColourMap.get(slot) or self.GetBackgroundColour()
|
return get_slotColourMap().get(slot) or self.GetBackgroundColour()
|
||||||
|
|
||||||
def refresh(self, stuff):
|
def refresh(self, stuff):
|
||||||
"""
|
"""
|
||||||
@@ -780,9 +811,8 @@ class FittingView(d.Display):
|
|||||||
del mod.restrictionOverridden
|
del mod.restrictionOverridden
|
||||||
hasRestrictionOverriden = not hasRestrictionOverriden
|
hasRestrictionOverriden = not hasRestrictionOverriden
|
||||||
|
|
||||||
|
|
||||||
if slotMap[mod.slot] or hasRestrictionOverriden: # Color too many modules as red
|
if slotMap[mod.slot] or hasRestrictionOverriden: # Color too many modules as red
|
||||||
self.SetItemBackgroundColour(i, errColorDark if isDark() else errColor)
|
self.SetItemBackgroundColour(i, get_errColorDark() if isDark() else get_errColor())
|
||||||
elif sFit.serviceFittingOptions["colorFitBySlot"]: # Color by slot it enabled
|
elif sFit.serviceFittingOptions["colorFitBySlot"]: # Color by slot it enabled
|
||||||
self.SetItemBackgroundColour(i, self.slotColour(mod.slot))
|
self.SetItemBackgroundColour(i, self.slotColour(mod.slot))
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import config
|
|||||||
import gui.globalEvents as GE
|
import gui.globalEvents as GE
|
||||||
import gui.mainFrame
|
import gui.mainFrame
|
||||||
from gui.bitmap_loader import BitmapLoader
|
from gui.bitmap_loader import BitmapLoader
|
||||||
from gui.utils.clipboard import toClipboard
|
from gui.utils.clipboard import toClipboard, fromClipboard
|
||||||
from service.character import Character
|
from service.character import Character
|
||||||
from service.fit import Fit
|
from service.fit import Fit
|
||||||
|
|
||||||
@@ -49,6 +49,10 @@ class CharacterSelection(wx.Panel):
|
|||||||
|
|
||||||
# cache current selection to fall back in case we choose to open char editor
|
# cache current selection to fall back in case we choose to open char editor
|
||||||
self.charCache = None
|
self.charCache = None
|
||||||
|
|
||||||
|
# history for Shift-Tab navigation
|
||||||
|
self.charHistory = []
|
||||||
|
self._updatingFromHistory = False
|
||||||
|
|
||||||
self.charChoice = wx.Choice(self)
|
self.charChoice = wx.Choice(self)
|
||||||
mainSizer.Add(self.charChoice, 1, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT | wx.LEFT, 3)
|
mainSizer.Add(self.charChoice, 1, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT | wx.LEFT, 3)
|
||||||
@@ -92,7 +96,7 @@ class CharacterSelection(wx.Panel):
|
|||||||
sFit = Fit.getInstance()
|
sFit = Fit.getInstance()
|
||||||
fit = sFit.getFit(self.mainFrame.getActiveFit())
|
fit = sFit.getFit(self.mainFrame.getActiveFit())
|
||||||
|
|
||||||
if not fit or not self.needsSkills:
|
if not fit:
|
||||||
return
|
return
|
||||||
|
|
||||||
pos = wx.GetMousePosition()
|
pos = wx.GetMousePosition()
|
||||||
@@ -100,14 +104,23 @@ class CharacterSelection(wx.Panel):
|
|||||||
|
|
||||||
menu = wx.Menu()
|
menu = wx.Menu()
|
||||||
|
|
||||||
grantItem = menu.Append(wx.ID_ANY, _t("Grant Missing Skills"))
|
if self.needsSkills:
|
||||||
self.Bind(wx.EVT_MENU, self.grantMissingSkills, grantItem)
|
grantItem = menu.Append(wx.ID_ANY, _t("Grant Missing Skills"))
|
||||||
|
self.Bind(wx.EVT_MENU, self.grantMissingSkills, grantItem)
|
||||||
|
|
||||||
exportItem = menu.Append(wx.ID_ANY, _t("Copy Missing Skills"))
|
exportItem = menu.Append(wx.ID_ANY, _t("Copy Missing Skills"))
|
||||||
self.Bind(wx.EVT_MENU, self.exportSkills, exportItem)
|
self.Bind(wx.EVT_MENU, self.exportSkills, exportItem)
|
||||||
|
|
||||||
exportItem = menu.Append(wx.ID_ANY, _t("Copy Missing Skills (EVEMon)"))
|
exportItem = menu.Append(wx.ID_ANY, _t("Copy Missing Skills (condensed)"))
|
||||||
self.Bind(wx.EVT_MENU, self.exportSkillsEveMon, exportItem)
|
self.Bind(wx.EVT_MENU, self.exportSkillsCondensed, exportItem)
|
||||||
|
|
||||||
|
exportItem = menu.Append(wx.ID_ANY, _t("Copy Missing Skills (EVEMon)"))
|
||||||
|
self.Bind(wx.EVT_MENU, self.exportSkillsEveMon, exportItem)
|
||||||
|
|
||||||
|
menu.AppendSeparator()
|
||||||
|
|
||||||
|
importItem = menu.Append(wx.ID_ANY, _t("Import Skills from Clipboard"))
|
||||||
|
self.Bind(wx.EVT_MENU, self.importSkillsFromClipboard, importItem)
|
||||||
|
|
||||||
self.PopupMenu(menu, pos)
|
self.PopupMenu(menu, pos)
|
||||||
|
|
||||||
@@ -186,6 +199,13 @@ class CharacterSelection(wx.Panel):
|
|||||||
sFit = Fit.getInstance()
|
sFit = Fit.getInstance()
|
||||||
sFit.changeChar(fitID, charID)
|
sFit.changeChar(fitID, charID)
|
||||||
self.charCache = self.charChoice.GetCurrentSelection()
|
self.charCache = self.charChoice.GetCurrentSelection()
|
||||||
|
|
||||||
|
if not self._updatingFromHistory and charID is not None:
|
||||||
|
currentChar = self.getActiveCharacter()
|
||||||
|
if currentChar is not None:
|
||||||
|
if not self.charHistory or self.charHistory[-1] != currentChar:
|
||||||
|
self.charHistory.append(currentChar)
|
||||||
|
|
||||||
wx.PostEvent(self.mainFrame, GE.FitChanged(fitIDs=(fitID,)))
|
wx.PostEvent(self.mainFrame, GE.FitChanged(fitIDs=(fitID,)))
|
||||||
|
|
||||||
def toggleRefreshButton(self):
|
def toggleRefreshButton(self):
|
||||||
@@ -207,6 +227,29 @@ class CharacterSelection(wx.Panel):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def selectPreviousChar(self):
|
||||||
|
currentChar = self.getActiveCharacter()
|
||||||
|
if currentChar is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.charHistory:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.charHistory and self.charHistory[-1] == currentChar:
|
||||||
|
self.charHistory.pop()
|
||||||
|
|
||||||
|
if not self.charHistory:
|
||||||
|
return
|
||||||
|
|
||||||
|
prevChar = self.charHistory.pop()
|
||||||
|
if currentChar != prevChar:
|
||||||
|
self.charHistory.append(currentChar)
|
||||||
|
|
||||||
|
self._updatingFromHistory = True
|
||||||
|
if self.selectChar(prevChar):
|
||||||
|
self.charChanged(None)
|
||||||
|
self._updatingFromHistory = False
|
||||||
|
|
||||||
def fitChanged(self, event):
|
def fitChanged(self, event):
|
||||||
"""
|
"""
|
||||||
@@ -219,7 +262,7 @@ class CharacterSelection(wx.Panel):
|
|||||||
self.charChoice.Enable(activeFitID is not None)
|
self.charChoice.Enable(activeFitID is not None)
|
||||||
choice = self.charChoice
|
choice = self.charChoice
|
||||||
sFit = Fit.getInstance()
|
sFit = Fit.getInstance()
|
||||||
currCharID = choice.GetClientData(choice.GetCurrentSelection())
|
currCharID = choice.GetClientData(choice.GetCurrentSelection()) if choice.GetCurrentSelection() != -1 else None
|
||||||
fit = sFit.getFit(activeFitID)
|
fit = sFit.getFit(activeFitID)
|
||||||
newCharID = fit.character.ID if fit is not None else None
|
newCharID = fit.character.ID if fit is not None else None
|
||||||
|
|
||||||
@@ -253,6 +296,9 @@ class CharacterSelection(wx.Panel):
|
|||||||
self.selectChar(sChar.all5ID())
|
self.selectChar(sChar.all5ID())
|
||||||
|
|
||||||
elif currCharID != newCharID:
|
elif currCharID != newCharID:
|
||||||
|
if currCharID is not None and not self._updatingFromHistory:
|
||||||
|
if not self.charHistory or self.charHistory[-1] != currCharID:
|
||||||
|
self.charHistory.append(currCharID)
|
||||||
self.selectChar(newCharID)
|
self.selectChar(newCharID)
|
||||||
if not fit.calculated:
|
if not fit.calculated:
|
||||||
self.charChanged(None)
|
self.charChanged(None)
|
||||||
@@ -268,6 +314,15 @@ class CharacterSelection(wx.Panel):
|
|||||||
|
|
||||||
toClipboard(list)
|
toClipboard(list)
|
||||||
|
|
||||||
|
def exportSkillsCondensed(self, evt):
|
||||||
|
skillsMap = self._buildSkillsTooltipSuperCondensed(self.reqs, skillsMap={})
|
||||||
|
|
||||||
|
list = ""
|
||||||
|
for key in sorted(skillsMap):
|
||||||
|
list += "%s %d\n" % (key, skillsMap[key][0])
|
||||||
|
|
||||||
|
toClipboard(list)
|
||||||
|
|
||||||
def exportSkillsEveMon(self, evt):
|
def exportSkillsEveMon(self, evt):
|
||||||
skillsMap = self._buildSkillsTooltipCondensed(self.reqs, skillsMap={})
|
skillsMap = self._buildSkillsTooltipCondensed(self.reqs, skillsMap={})
|
||||||
|
|
||||||
@@ -277,6 +332,83 @@ class CharacterSelection(wx.Panel):
|
|||||||
|
|
||||||
toClipboard(list)
|
toClipboard(list)
|
||||||
|
|
||||||
|
def importSkillsFromClipboard(self, evt):
|
||||||
|
charID = self.getActiveCharacter()
|
||||||
|
if charID is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
sChar = Character.getInstance()
|
||||||
|
char = sChar.getCharacter(charID)
|
||||||
|
|
||||||
|
text = fromClipboard()
|
||||||
|
if not text:
|
||||||
|
with wx.MessageDialog(self, _t("Clipboard is empty"), _t("Error"), wx.OK | wx.ICON_ERROR) as dlg:
|
||||||
|
dlg.ShowModal()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
lines = text.strip().splitlines()
|
||||||
|
imported = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
parts = line.rsplit(None, 1)
|
||||||
|
if len(parts) != 2:
|
||||||
|
errors.append(_t("Invalid format: {}").format(line))
|
||||||
|
continue
|
||||||
|
|
||||||
|
skillName = parts[0]
|
||||||
|
levelStr = parts[1]
|
||||||
|
|
||||||
|
try:
|
||||||
|
level = int(levelStr)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
level = roman.fromRoman(levelStr.upper())
|
||||||
|
except (roman.InvalidRomanNumeralError, ValueError):
|
||||||
|
errors.append(_t("Invalid level format: {}").format(line))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if level < 0 or level > 5:
|
||||||
|
errors.append(_t("Level must be between 0 and 5: {}").format(line))
|
||||||
|
continue
|
||||||
|
|
||||||
|
skill = char.getSkill(skillName)
|
||||||
|
sChar.changeLevel(charID, skill.item.ID, level)
|
||||||
|
imported += 1
|
||||||
|
|
||||||
|
except KeyError as e:
|
||||||
|
errors.append(_t("Skill not found: {}").format(skillName))
|
||||||
|
pyfalog.error("Skill not found: '{}'", skillName)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(_t("Error processing line '{}': {}").format(line, str(e)))
|
||||||
|
pyfalog.error("Error importing skill from line '{}': {}", line, e)
|
||||||
|
|
||||||
|
if imported > 0:
|
||||||
|
self.refreshCharacterList()
|
||||||
|
wx.PostEvent(self.mainFrame, GE.CharListUpdated())
|
||||||
|
fitID = self.mainFrame.getActiveFit()
|
||||||
|
if fitID is not None:
|
||||||
|
wx.PostEvent(self.mainFrame, GE.FitChanged(fitIDs=(fitID,)))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
errorMsg = _t("Imported {} skill(s). Errors:\n{}").format(imported, "\n".join(errors))
|
||||||
|
with wx.MessageDialog(self, errorMsg, _t("Import Skills"), wx.OK | wx.ICON_WARNING) as dlg:
|
||||||
|
dlg.ShowModal()
|
||||||
|
elif imported > 0:
|
||||||
|
with wx.MessageDialog(self, _t("Successfully imported {} skill(s)").format(imported), _t("Import Skills"), wx.OK) as dlg:
|
||||||
|
dlg.ShowModal()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
pyfalog.error("Error importing skills from clipboard: {}", e)
|
||||||
|
with wx.MessageDialog(self, _t("Error importing skills. Please check the log file."), _t("Error"), wx.OK | wx.ICON_ERROR) as dlg:
|
||||||
|
dlg.ShowModal()
|
||||||
|
|
||||||
def _buildSkillsTooltip(self, reqs, currItem="", tabulationLevel=0):
|
def _buildSkillsTooltip(self, reqs, currItem="", tabulationLevel=0):
|
||||||
tip = ""
|
tip = ""
|
||||||
sCharacter = Character.getInstance()
|
sCharacter = Character.getInstance()
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ from .gui.localModule.mutatedRevert import GuiRevertMutatedLocalModuleCommand
|
|||||||
from .gui.localModule.remove import GuiRemoveLocalModuleCommand
|
from .gui.localModule.remove import GuiRemoveLocalModuleCommand
|
||||||
from .gui.localModule.replace import GuiReplaceLocalModuleCommand
|
from .gui.localModule.replace import GuiReplaceLocalModuleCommand
|
||||||
from .gui.localModule.swap import GuiSwapLocalModulesCommand
|
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.cargoToLocalModule import GuiCargoToLocalModuleCommand
|
||||||
from .gui.localModuleCargo.localModuleToCargo import GuiLocalModuleToCargoCommand
|
from .gui.localModuleCargo.localModuleToCargo import GuiLocalModuleToCargoCommand
|
||||||
from .gui.projectedChangeProjectionRange import GuiChangeProjectedItemsProjectionRangeCommand
|
from .gui.projectedChangeProjectionRange import GuiChangeProjectedItemsProjectionRangeCommand
|
||||||
|
|||||||
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
@@ -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
|
||||||
@@ -346,20 +346,7 @@ class CargoInfo:
|
|||||||
return makeReprStr(self, ['itemID', 'amount'])
|
return makeReprStr(self, ['itemID', 'amount'])
|
||||||
|
|
||||||
|
|
||||||
def activeStateLimit(itemIdentity):
|
from service.port.active_state import activeStateLimit
|
||||||
item = Market.getInstance().getItem(itemIdentity)
|
|
||||||
if {
|
|
||||||
'moduleBonusAssaultDamageControl', 'moduleBonusIndustrialInvulnerability',
|
|
||||||
'microJumpDrive', 'microJumpPortalDrive', 'emergencyHullEnergizer',
|
|
||||||
'cynosuralGeneration', 'jumpPortalGeneration', 'jumpPortalGenerationBO',
|
|
||||||
'cloneJumpAccepting', 'cloakingWarpSafe', 'cloakingPrototype', 'cloaking',
|
|
||||||
'massEntanglerEffect5', 'electronicAttributeModifyOnline', 'targetPassively',
|
|
||||||
'cargoScan', 'shipScan', 'surveyScan', 'targetSpectrumBreakerBonus',
|
|
||||||
'interdictionNullifierBonus', 'warpCoreStabilizerActive',
|
|
||||||
'industrialItemCompression'
|
|
||||||
}.intersection(item.effects):
|
|
||||||
return FittingModuleState.ONLINE
|
|
||||||
return FittingModuleState.ACTIVE
|
|
||||||
|
|
||||||
|
|
||||||
def droneStackLimit(fit, itemIdentity):
|
def droneStackLimit(fit, itemIdentity):
|
||||||
|
|||||||
216
gui/fitDiffFrame.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Copyright (C) 2025
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
import re
|
||||||
|
|
||||||
|
from service.fit import Fit as svcFit
|
||||||
|
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."""
|
||||||
|
|
||||||
|
def __init__(self, parent, fitID):
|
||||||
|
super().__init__(
|
||||||
|
parent,
|
||||||
|
title=_t("Fit Diff"),
|
||||||
|
style=wx.DEFAULT_FRAME_STYLE | wx.RESIZE_BORDER,
|
||||||
|
size=(1000, 600)
|
||||||
|
)
|
||||||
|
self.parent = parent
|
||||||
|
self.fitID = fitID
|
||||||
|
self.sFit = svcFit.getInstance()
|
||||||
|
|
||||||
|
# EFT export options (same as CTRL-C)
|
||||||
|
self.eftOptions = {
|
||||||
|
PortEftOptions.LOADED_CHARGES: True,
|
||||||
|
PortEftOptions.MUTATIONS: True,
|
||||||
|
PortEftOptions.IMPLANTS: True,
|
||||||
|
PortEftOptions.BOOSTERS: True,
|
||||||
|
PortEftOptions.CARGO: True,
|
||||||
|
}
|
||||||
|
|
||||||
|
self.initUI()
|
||||||
|
self.Centre()
|
||||||
|
self.Show()
|
||||||
|
|
||||||
|
def initUI(self):
|
||||||
|
panel = wx.Panel(self)
|
||||||
|
mainSizer = wx.BoxSizer(wx.VERTICAL)
|
||||||
|
|
||||||
|
# Instructions and flip button at the top
|
||||||
|
topSizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||||
|
instructions = wx.StaticText(
|
||||||
|
panel,
|
||||||
|
label=_t("Paste fits in EFT format to compare")
|
||||||
|
)
|
||||||
|
topSizer.Add(instructions, 1, wx.ALL | wx.EXPAND, 5)
|
||||||
|
|
||||||
|
flipButton = wx.Button(panel, label=_t("Flip"))
|
||||||
|
flipButton.Bind(wx.EVT_BUTTON, self.onFlip)
|
||||||
|
topSizer.Add(flipButton, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 5)
|
||||||
|
|
||||||
|
mainSizer.Add(topSizer, 0, wx.EXPAND)
|
||||||
|
|
||||||
|
# Three panes: Fit 1 | Diff | Fit 2
|
||||||
|
panesSizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||||
|
|
||||||
|
# Pane 1: Fit 1 (editable)
|
||||||
|
fit1Box = wx.StaticBox(panel, label=_t("Fit 1"))
|
||||||
|
fit1Sizer = wx.StaticBoxSizer(fit1Box, wx.VERTICAL)
|
||||||
|
self.fit1Text = wx.TextCtrl(
|
||||||
|
panel,
|
||||||
|
style=wx.TE_MULTILINE | wx.TE_DONTWRAP
|
||||||
|
)
|
||||||
|
fit1Sizer.Add(self.fit1Text, 1, wx.EXPAND)
|
||||||
|
panesSizer.Add(fit1Sizer, 1, wx.ALL | wx.EXPAND, 5)
|
||||||
|
|
||||||
|
# Bind text changed event to update diff
|
||||||
|
self.fit1Text.Bind(wx.EVT_TEXT, self.onFitChanged)
|
||||||
|
|
||||||
|
# Pane 2: Diff (simple text format)
|
||||||
|
diffBox = wx.StaticBox(panel, label=_t("Differences"))
|
||||||
|
diffSizer = wx.StaticBoxSizer(diffBox, wx.VERTICAL)
|
||||||
|
self.diffText = wx.TextCtrl(
|
||||||
|
panel,
|
||||||
|
style=wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_DONTWRAP
|
||||||
|
)
|
||||||
|
diffSizer.Add(self.diffText, 1, wx.EXPAND)
|
||||||
|
panesSizer.Add(diffSizer, 1, wx.ALL | wx.EXPAND, 5)
|
||||||
|
|
||||||
|
# Pane 3: Fit 2 (user input)
|
||||||
|
fit2Box = wx.StaticBox(panel, label=_t("Fit 2"))
|
||||||
|
fit2Sizer = wx.StaticBoxSizer(fit2Box, wx.VERTICAL)
|
||||||
|
self.fit2Text = wx.TextCtrl(
|
||||||
|
panel,
|
||||||
|
style=wx.TE_MULTILINE | wx.TE_DONTWRAP
|
||||||
|
)
|
||||||
|
fit2Sizer.Add(self.fit2Text, 1, wx.EXPAND)
|
||||||
|
|
||||||
|
# Bind text changed event to update diff
|
||||||
|
self.fit2Text.Bind(wx.EVT_TEXT, self.onFitChanged)
|
||||||
|
|
||||||
|
panesSizer.Add(fit2Sizer, 1, wx.ALL | wx.EXPAND, 5)
|
||||||
|
|
||||||
|
mainSizer.Add(panesSizer, 1, wx.EXPAND | wx.ALL, 5)
|
||||||
|
|
||||||
|
panel.SetSizer(mainSizer)
|
||||||
|
|
||||||
|
# Load current fit into pane 1
|
||||||
|
self.loadFit1()
|
||||||
|
|
||||||
|
def loadFit1(self):
|
||||||
|
"""Load the current fit into pane 1 as EFT format."""
|
||||||
|
fit = self.sFit.getFit(self.fitID)
|
||||||
|
if fit:
|
||||||
|
eftText = exportEft(fit, self.eftOptions, callback=None)
|
||||||
|
self.fit1Text.SetValue(eftText)
|
||||||
|
|
||||||
|
def onFitChanged(self, event):
|
||||||
|
"""Handle text change in either fit pane - update diff."""
|
||||||
|
self.updateDiff()
|
||||||
|
event.Skip()
|
||||||
|
|
||||||
|
def onFlip(self, event):
|
||||||
|
"""Swap Fit 1 and Fit 2."""
|
||||||
|
fit1Value = self.fit1Text.GetValue()
|
||||||
|
fit2Value = self.fit2Text.GetValue()
|
||||||
|
self.fit1Text.SetValue(fit2Value)
|
||||||
|
self.fit2Text.SetValue(fit1Value)
|
||||||
|
self.updateDiff()
|
||||||
|
event.Skip()
|
||||||
|
|
||||||
|
def updateDiff(self):
|
||||||
|
"""Calculate and display the differences between the two fits."""
|
||||||
|
self.diffText.Clear()
|
||||||
|
|
||||||
|
fit1Text = self.fit1Text.GetValue().strip()
|
||||||
|
fit2Text = self.fit2Text.GetValue().strip()
|
||||||
|
|
||||||
|
if not fit1Text or not fit2Text:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Parse both fits
|
||||||
|
fit1 = self.parsePastedFit(fit1Text)
|
||||||
|
fit2 = self.parsePastedFit(fit2Text)
|
||||||
|
|
||||||
|
if fit1 is None:
|
||||||
|
self.diffText.SetValue(_t("Error: Fit 1 has invalid EFT format"))
|
||||||
|
return
|
||||||
|
if fit2 is None:
|
||||||
|
self.diffText.SetValue(_t("Error: Fit 2 has invalid EFT format"))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Calculate differences and format as simple text list
|
||||||
|
diffLines = self.calculateDiff(fit1, fit2)
|
||||||
|
self.diffText.SetValue('\n'.join(diffLines))
|
||||||
|
|
||||||
|
def parsePastedFit(self, text):
|
||||||
|
"""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_items, fit2_items):
|
||||||
|
"""Calculate items needed to transform fit1 into fit2.
|
||||||
|
|
||||||
|
Returns a list of strings showing additions and extra items.
|
||||||
|
"""
|
||||||
|
diffLines = []
|
||||||
|
|
||||||
|
all_items = set(fit1_items.keys()) | set(fit2_items.keys())
|
||||||
|
additions = []
|
||||||
|
extras = []
|
||||||
|
|
||||||
|
for item in sorted(all_items):
|
||||||
|
count1 = fit1_items.get(item, 0)
|
||||||
|
count2 = fit2_items.get(item, 0)
|
||||||
|
if count2 > count1:
|
||||||
|
additions.append(f"{item} x{count2 - count1}")
|
||||||
|
elif count1 > count2:
|
||||||
|
extras.append(f"{item} x-{count1 - count2}")
|
||||||
|
|
||||||
|
diffLines.extend(additions)
|
||||||
|
if additions and extras:
|
||||||
|
diffLines.extend(["", ""])
|
||||||
|
diffLines.extend(extras)
|
||||||
|
|
||||||
|
return diffLines
|
||||||
@@ -24,6 +24,7 @@ import config
|
|||||||
import gui.mainFrame
|
import gui.mainFrame
|
||||||
from eos.saveddata.drone import Drone
|
from eos.saveddata.drone import Drone
|
||||||
from eos.saveddata.module import Module
|
from eos.saveddata.module import Module
|
||||||
|
from eos.saveddata.ship import Ship
|
||||||
from gui.auxWindow import AuxiliaryFrame
|
from gui.auxWindow import AuxiliaryFrame
|
||||||
from gui.bitmap_loader import BitmapLoader
|
from gui.bitmap_loader import BitmapLoader
|
||||||
from gui.builtinItemStatsViews.itemAffectedBy import ItemAffectedBy
|
from gui.builtinItemStatsViews.itemAffectedBy import ItemAffectedBy
|
||||||
@@ -35,6 +36,7 @@ from gui.builtinItemStatsViews.itemEffects import ItemEffects
|
|||||||
from gui.builtinItemStatsViews.itemMutator import ItemMutatorPanel
|
from gui.builtinItemStatsViews.itemMutator import ItemMutatorPanel
|
||||||
from gui.builtinItemStatsViews.itemProperties import ItemProperties
|
from gui.builtinItemStatsViews.itemProperties import ItemProperties
|
||||||
from gui.builtinItemStatsViews.itemRequirements import ItemRequirements
|
from gui.builtinItemStatsViews.itemRequirements import ItemRequirements
|
||||||
|
from gui.builtinItemStatsViews.itemSkills import ItemSkills
|
||||||
from gui.builtinItemStatsViews.itemTraits import ItemTraits
|
from gui.builtinItemStatsViews.itemTraits import ItemTraits
|
||||||
from service.market import Market
|
from service.market import Market
|
||||||
|
|
||||||
@@ -156,6 +158,8 @@ class ItemStatsContainer(wx.Panel):
|
|||||||
def __init__(self, parent, stuff, item, context=None):
|
def __init__(self, parent, stuff, item, context=None):
|
||||||
wx.Panel.__init__(self, parent)
|
wx.Panel.__init__(self, parent)
|
||||||
sMkt = Market.getInstance()
|
sMkt = Market.getInstance()
|
||||||
|
self.stuff = stuff
|
||||||
|
self.context = context
|
||||||
|
|
||||||
mainSizer = wx.BoxSizer(wx.VERTICAL)
|
mainSizer = wx.BoxSizer(wx.VERTICAL)
|
||||||
|
|
||||||
@@ -196,6 +200,10 @@ class ItemStatsContainer(wx.Panel):
|
|||||||
self.affectedby = ItemAffectedBy(self.nbContainer, stuff, item)
|
self.affectedby = ItemAffectedBy(self.nbContainer, stuff, item)
|
||||||
self.nbContainer.AddPage(self.affectedby, _t("Affected by"))
|
self.nbContainer.AddPage(self.affectedby, _t("Affected by"))
|
||||||
|
|
||||||
|
if stuff is not None and isinstance(stuff, Ship):
|
||||||
|
self.skills = ItemSkills(self.nbContainer, stuff, item)
|
||||||
|
self.nbContainer.AddPage(self.skills, _t("Skills"))
|
||||||
|
|
||||||
if config.debug:
|
if config.debug:
|
||||||
self.properties = ItemProperties(self.nbContainer, stuff, item, context)
|
self.properties = ItemProperties(self.nbContainer, stuff, item, context)
|
||||||
self.nbContainer.AddPage(self.properties, _t("Properties"))
|
self.nbContainer.AddPage(self.properties, _t("Properties"))
|
||||||
|
|||||||
112
gui/mainFrame.py
@@ -18,6 +18,7 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import itertools
|
||||||
import os.path
|
import os.path
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -60,8 +61,12 @@ from gui.shipBrowser import ShipBrowser
|
|||||||
from gui.statsPane import StatsPane
|
from gui.statsPane import StatsPane
|
||||||
from gui.targetProfileEditor import TargetProfileEditor
|
from gui.targetProfileEditor import TargetProfileEditor
|
||||||
from gui.updateDialog import UpdateDialog
|
from gui.updateDialog import UpdateDialog
|
||||||
from gui.utils.clipboard import fromClipboard
|
from gui.utils.clipboard import fromClipboard, toClipboard
|
||||||
from gui.utils.progressHelper import ProgressHelper
|
from gui.utils.progressHelper import ProgressHelper
|
||||||
|
from eos.const import FittingSlot as es_Slot
|
||||||
|
from eos.saveddata.character import Skill
|
||||||
|
from eos.saveddata.fighter import Fighter as es_Fighter
|
||||||
|
from eos.saveddata.module import Module as es_Module
|
||||||
from service.character import Character
|
from service.character import Character
|
||||||
from service.esi import Esi
|
from service.esi import Esi
|
||||||
from service.fit import Fit
|
from service.fit import Fit
|
||||||
@@ -522,6 +527,8 @@ class MainFrame(wx.Frame):
|
|||||||
self.Bind(wx.EVT_MENU, self.backupToXml, id=menuBar.backupFitsId)
|
self.Bind(wx.EVT_MENU, self.backupToXml, id=menuBar.backupFitsId)
|
||||||
# Export skills needed
|
# Export skills needed
|
||||||
self.Bind(wx.EVT_MENU, self.exportSkillsNeeded, id=menuBar.exportSkillsNeededId)
|
self.Bind(wx.EVT_MENU, self.exportSkillsNeeded, id=menuBar.exportSkillsNeededId)
|
||||||
|
# Copy skills needed
|
||||||
|
self.Bind(wx.EVT_MENU, self.copySkillsNeeded, id=menuBar.copySkillsNeededId)
|
||||||
# Import character
|
# Import character
|
||||||
self.Bind(wx.EVT_MENU, self.importCharacter, id=menuBar.importCharacterId)
|
self.Bind(wx.EVT_MENU, self.importCharacter, id=menuBar.importCharacterId)
|
||||||
# Export HTML
|
# Export HTML
|
||||||
@@ -558,7 +565,8 @@ class MainFrame(wx.Frame):
|
|||||||
self.Bind(wx.EVT_MENU, self.toggleOverrides, id=menuBar.toggleOverridesId)
|
self.Bind(wx.EVT_MENU, self.toggleOverrides, id=menuBar.toggleOverridesId)
|
||||||
|
|
||||||
# Clipboard exports
|
# Clipboard exports
|
||||||
self.Bind(wx.EVT_MENU, self.exportToClipboard, id=wx.ID_COPY)
|
self.Bind(wx.EVT_MENU, self.exportToClipboardDirectEft, id=menuBar.copyDirectEftId)
|
||||||
|
self.Bind(wx.EVT_MENU, self.exportToClipboard, id=menuBar.copyWithDialogId)
|
||||||
|
|
||||||
# Fitting Restrictions
|
# Fitting Restrictions
|
||||||
self.Bind(wx.EVT_MENU, self.toggleIgnoreRestriction, id=menuBar.toggleIgnoreRestrictionID)
|
self.Bind(wx.EVT_MENU, self.toggleIgnoreRestriction, id=menuBar.toggleIgnoreRestrictionID)
|
||||||
@@ -571,6 +579,7 @@ class MainFrame(wx.Frame):
|
|||||||
toggleShipMarketId = wx.NewId()
|
toggleShipMarketId = wx.NewId()
|
||||||
ctabnext = wx.NewId()
|
ctabnext = wx.NewId()
|
||||||
ctabprev = wx.NewId()
|
ctabprev = wx.NewId()
|
||||||
|
charPrevId = wx.NewId()
|
||||||
|
|
||||||
# Close Page
|
# Close Page
|
||||||
self.Bind(wx.EVT_MENU, self.CloseCurrentPage, id=self.closePageId)
|
self.Bind(wx.EVT_MENU, self.CloseCurrentPage, id=self.closePageId)
|
||||||
@@ -580,6 +589,7 @@ class MainFrame(wx.Frame):
|
|||||||
self.Bind(wx.EVT_MENU, self.toggleShipMarket, id=toggleShipMarketId)
|
self.Bind(wx.EVT_MENU, self.toggleShipMarket, id=toggleShipMarketId)
|
||||||
self.Bind(wx.EVT_MENU, self.CTabNext, id=ctabnext)
|
self.Bind(wx.EVT_MENU, self.CTabNext, id=ctabnext)
|
||||||
self.Bind(wx.EVT_MENU, self.CTabPrev, id=ctabprev)
|
self.Bind(wx.EVT_MENU, self.CTabPrev, id=ctabprev)
|
||||||
|
self.Bind(wx.EVT_MENU, self.selectPreviousCharacter, id=charPrevId)
|
||||||
|
|
||||||
actb = [(wx.ACCEL_CTRL, ord('T'), self.addPageId),
|
actb = [(wx.ACCEL_CTRL, ord('T'), self.addPageId),
|
||||||
(wx.ACCEL_CMD, ord('T'), self.addPageId),
|
(wx.ACCEL_CMD, ord('T'), self.addPageId),
|
||||||
@@ -613,7 +623,19 @@ class MainFrame(wx.Frame):
|
|||||||
(wx.ACCEL_CMD, wx.WXK_PAGEDOWN, ctabnext),
|
(wx.ACCEL_CMD, wx.WXK_PAGEDOWN, ctabnext),
|
||||||
(wx.ACCEL_CMD, wx.WXK_PAGEUP, ctabprev),
|
(wx.ACCEL_CMD, wx.WXK_PAGEUP, ctabprev),
|
||||||
|
|
||||||
(wx.ACCEL_CMD | wx.ACCEL_SHIFT, ord("Z"), wx.ID_REDO)
|
(wx.ACCEL_CMD | wx.ACCEL_SHIFT, ord("Z"), wx.ID_REDO),
|
||||||
|
|
||||||
|
# Ctrl+Shift+C for copy with dialog (must come before Ctrl+C)
|
||||||
|
# Note: use lowercase 'c' because SHIFT is already in flags
|
||||||
|
(wx.ACCEL_CTRL | wx.ACCEL_SHIFT, ord('c'), menuBar.copyWithDialogId),
|
||||||
|
(wx.ACCEL_CMD | wx.ACCEL_SHIFT, ord('c'), menuBar.copyWithDialogId),
|
||||||
|
|
||||||
|
# Ctrl+C for direct EFT copy
|
||||||
|
(wx.ACCEL_CTRL, ord('c'), menuBar.copyDirectEftId),
|
||||||
|
(wx.ACCEL_CMD, ord('c'), menuBar.copyDirectEftId),
|
||||||
|
|
||||||
|
# Shift+Tab for previous character
|
||||||
|
(wx.ACCEL_SHIFT, wx.WXK_TAB, charPrevId)
|
||||||
]
|
]
|
||||||
|
|
||||||
# Ctrl/Cmd+# for addition pane selection
|
# Ctrl/Cmd+# for addition pane selection
|
||||||
@@ -739,6 +761,9 @@ class MainFrame(wx.Frame):
|
|||||||
|
|
||||||
def CTabPrev(self, event):
|
def CTabPrev(self, event):
|
||||||
self.fitMultiSwitch.PrevPage()
|
self.fitMultiSwitch.PrevPage()
|
||||||
|
|
||||||
|
def selectPreviousCharacter(self, event):
|
||||||
|
self.charSelection.selectPreviousChar()
|
||||||
|
|
||||||
def HAddPage(self, event):
|
def HAddPage(self, event):
|
||||||
self.fitMultiSwitch.AddPage()
|
self.fitMultiSwitch.AddPage()
|
||||||
@@ -798,6 +823,32 @@ class MainFrame(wx.Frame):
|
|||||||
else:
|
else:
|
||||||
self._openAfterImport(importData)
|
self._openAfterImport(importData)
|
||||||
|
|
||||||
|
def exportToClipboardDirectEft(self, event):
|
||||||
|
""" Copy fit to clipboard in EFT format without showing dialog """
|
||||||
|
from eos.db import getFit
|
||||||
|
from service.const import PortEftOptions
|
||||||
|
from service.settings import SettingsProvider
|
||||||
|
|
||||||
|
fit = getFit(self.getActiveFit())
|
||||||
|
if fit is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get the default EFT export options from settings
|
||||||
|
defaultOptions = {
|
||||||
|
PortEftOptions.LOADED_CHARGES: True,
|
||||||
|
PortEftOptions.MUTATIONS: True,
|
||||||
|
PortEftOptions.IMPLANTS: True,
|
||||||
|
PortEftOptions.BOOSTERS: True,
|
||||||
|
PortEftOptions.CARGO: True,
|
||||||
|
}
|
||||||
|
settings = SettingsProvider.getInstance().getSettings("pyfaExport", {"format": CopySelectDialog.copyFormatEft, "options": {CopySelectDialog.copyFormatEft: defaultOptions}})
|
||||||
|
options = settings["options"].get(CopySelectDialog.copyFormatEft, defaultOptions)
|
||||||
|
|
||||||
|
def copyToClipboard(text):
|
||||||
|
toClipboard(text)
|
||||||
|
|
||||||
|
Port.exportEft(fit, options, callback=copyToClipboard)
|
||||||
|
|
||||||
def exportToClipboard(self, event):
|
def exportToClipboard(self, event):
|
||||||
with CopySelectDialog(self) as dlg:
|
with CopySelectDialog(self) as dlg:
|
||||||
dlg.ShowModal()
|
dlg.ShowModal()
|
||||||
@@ -832,6 +883,61 @@ class MainFrame(wx.Frame):
|
|||||||
self.waitDialog = wx.BusyInfo(_t("Exporting skills needed..."), parent=self)
|
self.waitDialog = wx.BusyInfo(_t("Exporting skills needed..."), parent=self)
|
||||||
sCharacter.backupSkills(filePath, saveFmt, self.getActiveFit(), self.closeWaitDialog)
|
sCharacter.backupSkills(filePath, saveFmt, self.getActiveFit(), self.closeWaitDialog)
|
||||||
|
|
||||||
|
def copySkillsNeeded(self, event):
|
||||||
|
""" Copies skills used by the fit that the character has to clipboard """
|
||||||
|
activeFitID = self.getActiveFit()
|
||||||
|
if activeFitID is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
sFit = Fit.getInstance()
|
||||||
|
fit = sFit.getFit(activeFitID)
|
||||||
|
if fit is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not fit.calculated:
|
||||||
|
fit.calculate()
|
||||||
|
|
||||||
|
char = fit.character
|
||||||
|
skillsMap = {}
|
||||||
|
# for thing in itertools.chain(fit.modules, fit.drones, fit.fighters, [fit.ship], fit.appliedImplants, fit.boosters, fit.cargo):
|
||||||
|
for thing in itertools.chain(fit.modules, fit.drones, fit.fighters, fit.appliedImplants, fit.boosters, fit.cargo):
|
||||||
|
self._collectAffectingSkills(thing, char, skillsMap)
|
||||||
|
|
||||||
|
skillsList = ""
|
||||||
|
for skillName in sorted(skillsMap):
|
||||||
|
charLevel = skillsMap[skillName]
|
||||||
|
for level in range(1, charLevel + 1):
|
||||||
|
skillsList += "%s %d\n" % (skillName, level)
|
||||||
|
|
||||||
|
toClipboard(skillsList)
|
||||||
|
|
||||||
|
def _collectAffectingSkills(self, thing, char, skillsMap):
|
||||||
|
""" Collect skills that affect items in the fit that the character has """
|
||||||
|
for attr in ("item", "charge"):
|
||||||
|
if attr == "charge" and isinstance(thing, es_Fighter):
|
||||||
|
continue
|
||||||
|
subThing = getattr(thing, attr, None)
|
||||||
|
if subThing is None:
|
||||||
|
continue
|
||||||
|
if isinstance(thing, es_Fighter) and attr == "charge":
|
||||||
|
continue
|
||||||
|
|
||||||
|
if attr == "charge":
|
||||||
|
cont = getattr(thing, "chargeModifiedAttributes", None)
|
||||||
|
else:
|
||||||
|
cont = getattr(thing, "itemModifiedAttributes", None)
|
||||||
|
|
||||||
|
if cont is not None:
|
||||||
|
for attrName in cont.iterAfflictions():
|
||||||
|
for fit, afflictors in cont.getAfflictions(attrName).items():
|
||||||
|
for afflictor, operator, stackingGroup, preResAmount, postResAmount, used in afflictors:
|
||||||
|
if isinstance(afflictor, Skill) and afflictor.character == char:
|
||||||
|
skillName = afflictor.item.name
|
||||||
|
if skillName not in skillsMap:
|
||||||
|
skillsMap[skillName] = afflictor.level
|
||||||
|
elif skillsMap[skillName] < afflictor.level:
|
||||||
|
skillsMap[skillName] = afflictor.level
|
||||||
|
|
||||||
def fileImportDialog(self, event):
|
def fileImportDialog(self, event):
|
||||||
"""Handles importing single/multiple EVE XML / EFT cfg fit files"""
|
"""Handles importing single/multiple EVE XML / EFT cfg fit files"""
|
||||||
with wx.FileDialog(
|
with wx.FileDialog(
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class MainMenuBar(wx.MenuBar):
|
|||||||
self.graphFrameId = wx.NewId()
|
self.graphFrameId = wx.NewId()
|
||||||
self.backupFitsId = wx.NewId()
|
self.backupFitsId = wx.NewId()
|
||||||
self.exportSkillsNeededId = wx.NewId()
|
self.exportSkillsNeededId = wx.NewId()
|
||||||
|
self.copySkillsNeededId = wx.NewId()
|
||||||
self.importCharacterId = wx.NewId()
|
self.importCharacterId = wx.NewId()
|
||||||
self.exportHtmlId = wx.NewId()
|
self.exportHtmlId = wx.NewId()
|
||||||
self.wikiId = wx.NewId()
|
self.wikiId = wx.NewId()
|
||||||
@@ -57,6 +58,8 @@ class MainMenuBar(wx.MenuBar):
|
|||||||
self.toggleIgnoreRestrictionID = wx.NewId()
|
self.toggleIgnoreRestrictionID = wx.NewId()
|
||||||
self.devToolsId = wx.NewId()
|
self.devToolsId = wx.NewId()
|
||||||
self.optimizeFitPrice = wx.NewId()
|
self.optimizeFitPrice = wx.NewId()
|
||||||
|
self.copyWithDialogId = wx.NewId()
|
||||||
|
self.copyDirectEftId = wx.NewId()
|
||||||
|
|
||||||
self.mainFrame = mainFrame
|
self.mainFrame = mainFrame
|
||||||
wx.MenuBar.__init__(self)
|
wx.MenuBar.__init__(self)
|
||||||
@@ -84,7 +87,8 @@ class MainMenuBar(wx.MenuBar):
|
|||||||
fitMenu.Append(wx.ID_REDO, _t("&Redo") + "\tCTRL+Y", _t("Redo the most recent undone action"))
|
fitMenu.Append(wx.ID_REDO, _t("&Redo") + "\tCTRL+Y", _t("Redo the most recent undone action"))
|
||||||
|
|
||||||
fitMenu.AppendSeparator()
|
fitMenu.AppendSeparator()
|
||||||
fitMenu.Append(wx.ID_COPY, _t("&To Clipboard") + "\tCTRL+C", _t("Export a fit to the clipboard"))
|
fitMenu.Append(self.copyDirectEftId, _t("&To Clipboard (EFT)") + "\tCTRL+C", _t("Export a fit to the clipboard in EFT format"))
|
||||||
|
fitMenu.Append(self.copyWithDialogId, _t("&To Clipboard (Select Format)") + "\tCTRL+SHIFT+C", _t("Export a fit to the clipboard with format selection"))
|
||||||
fitMenu.Append(wx.ID_PASTE, _t("&From Clipboard") + "\tCTRL+V", _t("Import a fit from the clipboard"))
|
fitMenu.Append(wx.ID_PASTE, _t("&From Clipboard") + "\tCTRL+V", _t("Import a fit from the clipboard"))
|
||||||
|
|
||||||
fitMenu.AppendSeparator()
|
fitMenu.AppendSeparator()
|
||||||
@@ -117,6 +121,7 @@ class MainMenuBar(wx.MenuBar):
|
|||||||
characterMenu.AppendSeparator()
|
characterMenu.AppendSeparator()
|
||||||
characterMenu.Append(self.importCharacterId, _t("&Import Character File"), _t("Import characters into pyfa from file"))
|
characterMenu.Append(self.importCharacterId, _t("&Import Character File"), _t("Import characters into pyfa from file"))
|
||||||
characterMenu.Append(self.exportSkillsNeededId, _t("&Export Skills Needed"), _t("Export skills needed for this fitting"))
|
characterMenu.Append(self.exportSkillsNeededId, _t("&Export Skills Needed"), _t("Export skills needed for this fitting"))
|
||||||
|
characterMenu.Append(self.copySkillsNeededId, _t("&Copy Skills Needed"), _t("Copy skills needed for this fitting to clipboard"))
|
||||||
|
|
||||||
characterMenu.AppendSeparator()
|
characterMenu.AppendSeparator()
|
||||||
characterMenu.Append(self.ssoLoginId, _t("&Manage ESI Characters"))
|
characterMenu.Append(self.ssoLoginId, _t("&Manage ESI Characters"))
|
||||||
@@ -176,8 +181,10 @@ class MainMenuBar(wx.MenuBar):
|
|||||||
return
|
return
|
||||||
enable = activeFitID is not None
|
enable = activeFitID is not None
|
||||||
self.Enable(wx.ID_SAVEAS, enable)
|
self.Enable(wx.ID_SAVEAS, enable)
|
||||||
self.Enable(wx.ID_COPY, enable)
|
self.Enable(self.copyDirectEftId, enable)
|
||||||
|
self.Enable(self.copyWithDialogId, enable)
|
||||||
self.Enable(self.exportSkillsNeededId, enable)
|
self.Enable(self.exportSkillsNeededId, enable)
|
||||||
|
self.Enable(self.copySkillsNeededId, enable)
|
||||||
|
|
||||||
self.refreshUndo()
|
self.refreshUndo()
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class MarketBrowser(wx.Panel):
|
|||||||
self.settings = MarketPriceSettings.getInstance()
|
self.settings = MarketPriceSettings.getInstance()
|
||||||
self.__mode = 'normal'
|
self.__mode = 'normal'
|
||||||
self.__normalBtnMap = {}
|
self.__normalBtnMap = {}
|
||||||
|
self.__normalSlotBtnMap = {}
|
||||||
self.marketView = MarketTree(self.splitter, self)
|
self.marketView = MarketTree(self.splitter, self)
|
||||||
self.itemView = ItemView(self.splitter, self)
|
self.itemView = ItemView(self.splitter, self)
|
||||||
|
|
||||||
@@ -64,22 +65,61 @@ class MarketBrowser(wx.Panel):
|
|||||||
# Same fix as for search box on macs,
|
# Same fix as for search box on macs,
|
||||||
# need some pixels of extra space or everything clips and is ugly
|
# need some pixels of extra space or everything clips and is ugly
|
||||||
p = wx.Panel(self)
|
p = wx.Panel(self)
|
||||||
box = wx.BoxSizer(wx.HORIZONTAL)
|
vbox_panel = wx.BoxSizer(wx.VERTICAL)
|
||||||
p.SetSizer(box)
|
p.SetSizer(vbox_panel)
|
||||||
vbox.Add(p, 0, wx.EXPAND)
|
vbox.Add(p, 0, wx.EXPAND)
|
||||||
|
|
||||||
|
# First row: meta buttons
|
||||||
|
metaBox = wx.BoxSizer(wx.HORIZONTAL)
|
||||||
|
vbox_panel.Add(metaBox, 0, wx.EXPAND)
|
||||||
self.metaButtons = []
|
self.metaButtons = []
|
||||||
btn = None
|
btn = None
|
||||||
for name in list(self.sMkt.META_MAP.keys()):
|
for name in list(self.sMkt.META_MAP.keys()):
|
||||||
btn = MetaButton(p, wx.ID_ANY, name.capitalize(), style=wx.BU_EXACTFIT)
|
btn = MetaButton(p, wx.ID_ANY, name.capitalize(), style=wx.BU_EXACTFIT)
|
||||||
setattr(self, name, btn)
|
setattr(self, name, btn)
|
||||||
box.Add(btn, 1, wx.ALIGN_CENTER)
|
metaBox.Add(btn, 1, wx.ALIGN_CENTER)
|
||||||
btn.Bind(wx.EVT_TOGGLEBUTTON, self.toggleMetaButton)
|
btn.Bind(wx.EVT_TOGGLEBUTTON, self.toggleMetaButton)
|
||||||
btn.metaName = name
|
btn.metaName = name
|
||||||
self.metaButtons.append(btn)
|
self.metaButtons.append(btn)
|
||||||
|
|
||||||
|
# Second row: slot/fits filter buttons (BELOW meta buttons)
|
||||||
|
slotBox = wx.BoxSizer(wx.HORIZONTAL)
|
||||||
|
vbox_panel.Add(slotBox, 0, wx.EXPAND)
|
||||||
|
self.slotButtons = []
|
||||||
|
from eos.const import FittingSlot
|
||||||
|
|
||||||
|
# Fits button
|
||||||
|
fitsBtn = MetaButton(p, wx.ID_ANY, "Fits", style=wx.BU_EXACTFIT)
|
||||||
|
setattr(self, "fits", fitsBtn)
|
||||||
|
slotBox.Add(fitsBtn, 1, wx.ALIGN_CENTER)
|
||||||
|
fitsBtn.Bind(wx.EVT_TOGGLEBUTTON, self.toggleSlotButton)
|
||||||
|
fitsBtn.filterType = "fits"
|
||||||
|
# Fits button starts deselected (checkbox, off by default)
|
||||||
|
fitsBtn.setUserSelection(False)
|
||||||
|
self.slotButtons.append(fitsBtn)
|
||||||
|
|
||||||
|
# High, Med, Low, Rig buttons
|
||||||
|
slotMap = {
|
||||||
|
FittingSlot.HIGH: "High",
|
||||||
|
FittingSlot.MED: "Med",
|
||||||
|
FittingSlot.LOW: "Low",
|
||||||
|
FittingSlot.RIG: "Rig"
|
||||||
|
}
|
||||||
|
for slot, label in slotMap.items():
|
||||||
|
slotBtn = MetaButton(p, wx.ID_ANY, label, style=wx.BU_EXACTFIT)
|
||||||
|
setattr(self, "slot_%s" % label.lower(), slotBtn)
|
||||||
|
slotBox.Add(slotBtn, 1, wx.ALIGN_CENTER)
|
||||||
|
slotBtn.Bind(wx.EVT_TOGGLEBUTTON, self.toggleSlotButton)
|
||||||
|
slotBtn.filterType = "slot"
|
||||||
|
slotBtn.slotType = slot
|
||||||
|
# Slot buttons start deselected (unlike meta buttons which start selected)
|
||||||
|
slotBtn.setUserSelection(False)
|
||||||
|
self.slotButtons.append(slotBtn)
|
||||||
|
|
||||||
# Make itemview to set toggles according to list contents
|
# Make itemview to set toggles according to list contents
|
||||||
self.itemView.setToggles()
|
self.itemView.setToggles()
|
||||||
|
|
||||||
p.SetMinSize((wx.SIZE_AUTO_WIDTH, btn.GetSize()[1] + 5))
|
p.SetMinSize((wx.SIZE_AUTO_WIDTH, btn.GetSize()[1] * 2 + 10))
|
||||||
|
|
||||||
def toggleMetaButton(self, event):
|
def toggleMetaButton(self, event):
|
||||||
"""Process clicks on toggle buttons"""
|
"""Process clicks on toggle buttons"""
|
||||||
@@ -100,6 +140,21 @@ class MarketBrowser(wx.Panel):
|
|||||||
|
|
||||||
self.itemView.filterItemStore()
|
self.itemView.filterItemStore()
|
||||||
|
|
||||||
|
def toggleSlotButton(self, event):
|
||||||
|
"""Process clicks on slot/fits filter buttons"""
|
||||||
|
clickedBtn = event.EventObject
|
||||||
|
|
||||||
|
# All buttons (Fits, High, Med, Low, Rig) work as checkboxes (independent toggles)
|
||||||
|
clickedBtn.setUserSelection(clickedBtn.GetValue())
|
||||||
|
self.itemView.filterItemStore()
|
||||||
|
|
||||||
|
def getFitsFilter(self):
|
||||||
|
"""Check if Fits button is active"""
|
||||||
|
for btn in self.slotButtons:
|
||||||
|
if btn.filterType == "fits" and btn.userSelected:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def jump(self, item):
|
def jump(self, item):
|
||||||
self.mode = 'normal'
|
self.mode = 'normal'
|
||||||
self.marketView.jump(item)
|
self.marketView.jump(item)
|
||||||
@@ -141,6 +196,9 @@ class MarketBrowser(wx.Panel):
|
|||||||
self.__normalBtnMap.clear()
|
self.__normalBtnMap.clear()
|
||||||
for btn in self.metaButtons:
|
for btn in self.metaButtons:
|
||||||
self.__normalBtnMap[btn] = btn.userSelected
|
self.__normalBtnMap[btn] = btn.userSelected
|
||||||
|
self.__normalSlotBtnMap.clear()
|
||||||
|
for btn in self.slotButtons:
|
||||||
|
self.__normalSlotBtnMap[btn] = btn.userSelected
|
||||||
if newMode == 'search':
|
if newMode == 'search':
|
||||||
self.marketView.UnselectAll()
|
self.marketView.UnselectAll()
|
||||||
setting = self.settings.get('marketMGSearchMode')
|
setting = self.settings.get('marketMGSearchMode')
|
||||||
@@ -149,12 +207,16 @@ class MarketBrowser(wx.Panel):
|
|||||||
if newMode in ('search', 'recent', 'charges'):
|
if newMode in ('search', 'recent', 'charges'):
|
||||||
for btn in self.metaButtons:
|
for btn in self.metaButtons:
|
||||||
btn.setUserSelection(True)
|
btn.setUserSelection(True)
|
||||||
|
# Clear slot button selections when searching (search can return any item type)
|
||||||
|
for btn in self.slotButtons:
|
||||||
|
btn.setUserSelection(False)
|
||||||
if newMode == 'normal':
|
if newMode == 'normal':
|
||||||
for btn, state in self.__normalBtnMap.items():
|
for btn, state in self.__normalBtnMap.items():
|
||||||
btn.setUserSelection(state)
|
btn.setUserSelection(state)
|
||||||
|
for btn, state in self.__normalSlotBtnMap.items():
|
||||||
|
btn.setUserSelection(state)
|
||||||
# We turn on all meta buttons permanently
|
# We turn on all meta buttons permanently
|
||||||
if setting == 2:
|
if setting == 2:
|
||||||
for btn in self.metaButtons:
|
for btn in self.metaButtons:
|
||||||
btn.setUserSelection(True)
|
btn.setUserSelection(True)
|
||||||
self.__mode = newMode
|
self.__mode = newMode
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,68 @@
|
|||||||
# noinspection PyPackageRequirements
|
# noinspection PyPackageRequirements
|
||||||
import wx
|
import wx
|
||||||
|
from logbook import Logger
|
||||||
|
|
||||||
|
logger = Logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def toClipboard(text):
|
def toClipboard(text):
|
||||||
clip = wx.TheClipboard
|
"""
|
||||||
clip.Open()
|
Copy text to clipboard. Explicitly uses CLIPBOARD selection, not PRIMARY.
|
||||||
data = wx.TextDataObject(text)
|
|
||||||
clip.SetData(data)
|
On X11 systems, wxPython can confuse between PRIMARY and CLIPBOARD selections,
|
||||||
clip.Close()
|
causing "already open" errors. This function ensures we always use CLIPBOARD.
|
||||||
|
|
||||||
|
See: https://discuss.wxpython.org/t/wx-theclipboard-pasting-different-content-on-every-second-paste/35361
|
||||||
|
"""
|
||||||
|
clipboard = wx.TheClipboard
|
||||||
|
try:
|
||||||
|
# Explicitly use CLIPBOARD selection, not PRIMARY selection
|
||||||
|
# This prevents X11 confusion between the two clipboard types
|
||||||
|
clipboard.UsePrimarySelection(False)
|
||||||
|
|
||||||
|
if clipboard.Open():
|
||||||
|
try:
|
||||||
|
data = wx.TextDataObject(text)
|
||||||
|
clipboard.SetData(data)
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
clipboard.Close()
|
||||||
|
else:
|
||||||
|
logger.debug("Failed to open clipboard for writing")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Error writing to clipboard: {}", e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def fromClipboard():
|
def fromClipboard():
|
||||||
clip = wx.TheClipboard
|
"""
|
||||||
clip.Open()
|
Read text from clipboard. Explicitly uses CLIPBOARD selection, not PRIMARY.
|
||||||
data = wx.TextDataObject("")
|
|
||||||
if clip.GetData(data):
|
On X11 systems, wxPython can confuse between PRIMARY and CLIPBOARD selections,
|
||||||
clip.Close()
|
causing "already open" errors. This function ensures we always use CLIPBOARD.
|
||||||
return data.GetText()
|
|
||||||
else:
|
See: https://discuss.wxpython.org/t/wx-theclipboard-pasting-different-content-on-every-second-paste/35361
|
||||||
clip.Close()
|
"""
|
||||||
|
clipboard = wx.TheClipboard
|
||||||
|
try:
|
||||||
|
# Explicitly use CLIPBOARD selection, not PRIMARY selection
|
||||||
|
# This prevents X11 confusion between the two clipboard types
|
||||||
|
clipboard.UsePrimarySelection(False)
|
||||||
|
|
||||||
|
if clipboard.Open():
|
||||||
|
try:
|
||||||
|
data = wx.TextDataObject()
|
||||||
|
if clipboard.GetData(data):
|
||||||
|
return data.GetText()
|
||||||
|
else:
|
||||||
|
logger.debug("Clipboard open but no CLIPBOARD data available")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
clipboard.Close()
|
||||||
|
else:
|
||||||
|
logger.debug("Failed to open clipboard for reading")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Error reading from clipboard: {}", e)
|
||||||
return None
|
return None
|
||||||
|
|||||||
BIN
imgs/icons/10850@1x.png
Normal file
|
After Width: | Height: | Size: 582 B |
BIN
imgs/icons/10850@2x.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
imgs/icons/1546@1x.png
Normal file
|
After Width: | Height: | Size: 767 B |
BIN
imgs/icons/1546@2x.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
imgs/icons/24566@1x.png
Normal file
|
After Width: | Height: | Size: 812 B |
BIN
imgs/icons/24566@2x.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
imgs/icons/27053@1x.png
Normal file
|
After Width: | Height: | Size: 938 B |
BIN
imgs/icons/27053@2x.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
imgs/icons/27054@1x.png
Normal file
|
After Width: | Height: | Size: 928 B |
BIN
imgs/icons/27054@2x.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
imgs/icons/27055@1x.png
Normal file
|
After Width: | Height: | Size: 918 B |
BIN
imgs/icons/27055@2x.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
imgs/icons/27056@1x.png
Normal file
|
After Width: | Height: | Size: 912 B |
BIN
imgs/icons/27056@2x.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
imgs/icons/27058@1x.png
Normal file
|
After Width: | Height: | Size: 598 B |
BIN
imgs/icons/27139@1x.png
Normal file
|
After Width: | Height: | Size: 860 B |
BIN
imgs/icons/27139@2x.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
imgs/icons/27154@1x.png
Normal file
|
After Width: | Height: | Size: 782 B |
BIN
imgs/icons/27154@2x.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
imgs/icons/27198@1x.png
Normal file
|
After Width: | Height: | Size: 862 B |
BIN
imgs/icons/27198@2x.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
imgs/icons/27199@1x.png
Normal file
|
After Width: | Height: | Size: 863 B |
BIN
imgs/icons/27199@2x.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
imgs/icons/27200@1x.png
Normal file
|
After Width: | Height: | Size: 860 B |
BIN
imgs/icons/27200@2x.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
imgs/icons/27201@1x.png
Normal file
|
After Width: | Height: | Size: 878 B |
BIN
imgs/icons/27201@2x.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
imgs/icons/27202@1x.png
Normal file
|
After Width: | Height: | Size: 888 B |
BIN
imgs/icons/27202@2x.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
imgs/icons/27203@1x.png
Normal file
|
After Width: | Height: | Size: 876 B |
BIN
imgs/icons/27203@2x.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
imgs/icons/27204@1x.png
Normal file
|
After Width: | Height: | Size: 729 B |
BIN
imgs/icons/27204@2x.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
imgs/icons/27205@1x.png
Normal file
|
After Width: | Height: | Size: 811 B |
BIN
imgs/icons/27205@2x.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
imgs/icons/27206@1x.png
Normal file
|
After Width: | Height: | Size: 818 B |
BIN
imgs/icons/27206@2x.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
imgs/icons/27207@1x.png
Normal file
|
After Width: | Height: | Size: 817 B |
BIN
imgs/icons/27207@2x.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
imgs/icons/27208@1x.png
Normal file
|
After Width: | Height: | Size: 872 B |
BIN
imgs/icons/27208@2x.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
imgs/icons/27209@1x.png
Normal file
|
After Width: | Height: | Size: 884 B |
BIN
imgs/icons/27209@2x.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
imgs/icons/27210@1x.png
Normal file
|
After Width: | Height: | Size: 875 B |
BIN
imgs/icons/27210@2x.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
imgs/icons/27211@1x.png
Normal file
|
After Width: | Height: | Size: 840 B |
BIN
imgs/icons/27211@2x.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
imgs/icons/27212@1x.png
Normal file
|
After Width: | Height: | Size: 848 B |
BIN
imgs/icons/27212@2x.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
imgs/icons/27213@1x.png
Normal file
|
After Width: | Height: | Size: 867 B |
BIN
imgs/icons/27213@2x.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |