Compare commits
29 Commits
v2.65.2.10
...
v2.65.2.34
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b9c51db04 | |||
| 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 |
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
.venv
|
||||
.git
|
||||
dist
|
||||
build
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
*.zip
|
||||
*.spec
|
||||
imgs
|
||||
dist_assets
|
||||
11
Dockerfile
Normal file
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"]
|
||||
1
Pyfa-Mod
1
Pyfa-Mod
Submodule Pyfa-Mod deleted from ccebbf9708
36
build.sh
36
build.sh
@@ -23,8 +23,38 @@ rm -rf build dist
|
||||
echo "Building binary with PyInstaller..."
|
||||
uv run pyinstaller pyfa.spec
|
||||
|
||||
cp oleacc* dist/pyfa/
|
||||
# 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 "Build complete! Binary is located at: dist/pyfa/pyfa.exe"
|
||||
echo "You can run it with: dist/pyfa/pyfa.exe"
|
||||
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
43
config.py
@@ -1,7 +1,6 @@
|
||||
import os
|
||||
import sys
|
||||
import yaml
|
||||
import wx
|
||||
|
||||
from logbook import CRITICAL, DEBUG, ERROR, FingersCrossedHandler, INFO, Logger, NestedSetup, NullHandler, \
|
||||
StreamHandler, TimedRotatingFileHandler, WARNING
|
||||
@@ -67,20 +66,34 @@ LOGLEVEL_MAP = {
|
||||
CATALOG = 'lang'
|
||||
|
||||
|
||||
slotColourMapDark = {
|
||||
FittingSlot.LOW: wx.Colour(44, 36, 19), # yellow = low slots 24/13
|
||||
FittingSlot.MED: wx.Colour(28, 39, 51), # blue = mid slots 8.1/9.5
|
||||
FittingSlot.HIGH: wx.Colour(53, 31, 34), # red = high slots 6.5/11.5
|
||||
FittingSlot.RIG: '',
|
||||
FittingSlot.SUBSYSTEM: ''}
|
||||
errColorDark = wx.Colour(70, 20, 20)
|
||||
slotColourMap = {
|
||||
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: ''}
|
||||
errColor = wx.Colour(204, 51, 51)
|
||||
def get_slotColourMapDark():
|
||||
import wx
|
||||
return {
|
||||
FittingSlot.LOW: wx.Colour(44, 36, 19), # yellow = low slots 24/13
|
||||
FittingSlot.MED: wx.Colour(28, 39, 51), # blue = mid slots 8.1/9.5
|
||||
FittingSlot.HIGH: wx.Colour(53, 31, 34), # red = high slots 6.5/11.5
|
||||
FittingSlot.RIG: '',
|
||||
FittingSlot.SUBSYSTEM: ''}
|
||||
|
||||
|
||||
def get_errColorDark():
|
||||
import wx
|
||||
return wx.Colour(70, 20, 20)
|
||||
|
||||
|
||||
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():
|
||||
|
||||
5
docker-compose.yml
Normal file
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"
|
||||
@@ -18,13 +18,14 @@
|
||||
# =============================================================================
|
||||
|
||||
|
||||
from . import fitDamageStats
|
||||
from . import fitEwarStats
|
||||
from . import fitRemoteReps
|
||||
from . import fitShieldRegen
|
||||
from . import fitCapacitor
|
||||
from . import fitMobility
|
||||
from . import fitWarpTime
|
||||
from . import fitLockTime
|
||||
from . import fitDamageStats as fitDamageStats
|
||||
from . import fitEwarStats as fitEwarStats
|
||||
from . import fitRemoteReps as fitRemoteReps
|
||||
from . import fitShieldRegen as fitShieldRegen
|
||||
from . import fitCapacitor as fitCapacitor
|
||||
from . import fitMobility as fitMobility
|
||||
from . import fitWarpTime as fitWarpTime
|
||||
from . import fitLockTime as fitLockTime
|
||||
from . import fitHeat as fitHeat
|
||||
# Hidden graphs, available via ctrl-alt-g
|
||||
from . import fitEcmBurstScanresDamps
|
||||
from . import fitEcmBurstScanresDamps as fitEcmBurstScanresDamps
|
||||
|
||||
@@ -332,3 +332,78 @@ class Distance2TpStrGetter(SmoothPointGetter):
|
||||
strMult = calculateMultiplier(strMults)
|
||||
strength = (strMult - 1) * 100
|
||||
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
|
||||
|
||||
from graphs.data.base import FitGraph, Input, XDef, YDef
|
||||
from .getter import (Distance2DampStrLockRangeGetter, Distance2EcmStrMaxGetter, Distance2GdStrRangeGetter, Distance2NeutingStrGetter, Distance2TdStrOptimalGetter,
|
||||
Distance2TpStrGetter, Distance2WebbingStrGetter)
|
||||
from .getter import (Distance2DampStrLockRangeGetter, Distance2EcmStrMaxGetter, Distance2GdStrRangeGetter, Distance2JamChanceGetter, Distance2NeutingStrGetter,
|
||||
Distance2TdStrOptimalGetter, Distance2TpStrGetter, Distance2WebbingStrGetter)
|
||||
|
||||
_t = wx.GetTranslation
|
||||
|
||||
@@ -31,11 +31,13 @@ class FitEwarStatsGraph(FitGraph):
|
||||
# UI stuff
|
||||
internalName = 'ewarStatsGraph'
|
||||
name = _t('Electronic Warfare Stats')
|
||||
hasTargets = True
|
||||
xDefs = [XDef(handle='distance', unit='km', label=_t('Distance'), mainInput=('distance', 'km'))]
|
||||
yDefs = [
|
||||
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='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='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')),
|
||||
@@ -53,6 +55,7 @@ class FitEwarStatsGraph(FitGraph):
|
||||
('distance', 'neutStr'): Distance2NeutingStrGetter,
|
||||
('distance', 'webStr'): Distance2WebbingStrGetter,
|
||||
('distance', 'ecmStrMax'): Distance2EcmStrMaxGetter,
|
||||
('distance', 'jamChance'): Distance2JamChanceGetter,
|
||||
('distance', 'dampStrLockRange'): Distance2DampStrLockRangeGetter,
|
||||
('distance', 'tdStrOptimal'): Distance2TdStrOptimalGetter,
|
||||
('distance', 'gdStrRange'): Distance2GdStrRangeGetter,
|
||||
|
||||
25
graphs/data/fitHeat/__init__.py
Normal file
25
graphs/data/fitHeat/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# =============================================================================
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of pyfa.
|
||||
#
|
||||
# pyfa is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# pyfa is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with pyfa. If not, see <http://www.gnu.org/licenses/>.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
from .graph import FitHeatGraph
|
||||
|
||||
|
||||
FitHeatGraph.register()
|
||||
|
||||
295
graphs/data/fitHeat/calc.py
Normal file
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
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
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:
|
||||
dstCargoItemID = None
|
||||
|
||||
self.mainFrame.command.Submit(cmd.GuiLocalModuleToCargoCommand(
|
||||
fitID=self.mainFrame.getActiveFit(),
|
||||
modPosition=modIdx,
|
||||
cargoItemID=dstCargoItemID,
|
||||
copy=wx.GetMouseState().GetModifiers() == wx.MOD_CONTROL))
|
||||
modifiers = wx.GetMouseState().GetModifiers()
|
||||
isCopy = modifiers == wx.MOD_CONTROL
|
||||
isBatch = modifiers == wx.MOD_SHIFT
|
||||
if isBatch:
|
||||
self.mainFrame.command.Submit(
|
||||
cmd.GuiBatchLocalModuleToCargoCommand(
|
||||
fitID=self.mainFrame.getActiveFit(), modPosition=modIdx, copy=isCopy
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.mainFrame.command.Submit(
|
||||
cmd.GuiLocalModuleToCargoCommand(
|
||||
fitID=self.mainFrame.getActiveFit(),
|
||||
modPosition=modIdx,
|
||||
cargoItemID=dstCargoItemID,
|
||||
copy=isCopy,
|
||||
)
|
||||
)
|
||||
|
||||
def fitChanged(self, event):
|
||||
event.Skip()
|
||||
|
||||
@@ -18,6 +18,7 @@ from gui.builtinContextMenus import resistMode
|
||||
from gui.builtinContextMenus.targetProfile import editor
|
||||
# Item info
|
||||
from gui.builtinContextMenus import itemStats
|
||||
from gui.builtinContextMenus import fitDiff
|
||||
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 fitPilotSecurity # Not really an item info but want to keep it here
|
||||
|
||||
48
gui/builtinContextMenus/fitDiff.py
Normal file
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
|
||||
from gui.contextMenu import ContextMenuSingle
|
||||
from gui.fitCommands import (
|
||||
GuiConvertMutatedLocalModuleCommand, GuiRevertMutatedLocalModuleCommand,
|
||||
GuiConvertMutatedLocalDroneCommand, GuiRevertMutatedLocalDroneCommand)
|
||||
from service.fit import Fit
|
||||
|
||||
_t = wx.GetTranslation
|
||||
@@ -65,6 +62,8 @@ class ChangeItemMutation(ContextMenuSingle):
|
||||
return sub
|
||||
|
||||
def handleMenu(self, event):
|
||||
from gui.fitCommands import (
|
||||
GuiConvertMutatedLocalModuleCommand, GuiConvertMutatedLocalDroneCommand)
|
||||
mutaplasmid, item = self.eventIDs[event.Id]
|
||||
fitID = self.mainFrame.getActiveFit()
|
||||
fit = Fit.getInstance().getFit(fitID)
|
||||
@@ -78,6 +77,8 @@ class ChangeItemMutation(ContextMenuSingle):
|
||||
fitID=fitID, position=position, mutaplasmid=mutaplasmid))
|
||||
|
||||
def activate(self, callingWindow, fullContext, mainItem, i):
|
||||
from gui.fitCommands import (
|
||||
GuiRevertMutatedLocalModuleCommand, GuiRevertMutatedLocalDroneCommand)
|
||||
fitID = self.mainFrame.getActiveFit()
|
||||
fit = Fit.getInstance().getFit(fitID)
|
||||
if mainItem in fit.modules:
|
||||
|
||||
@@ -79,7 +79,7 @@ class ChangeAffectingSkills(ContextMenuSingle):
|
||||
label = _t("Level %s") % i
|
||||
|
||||
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)
|
||||
rootMenu.Bind(wx.EVT_MENU, self.handleSkillChange, menuItem)
|
||||
return menuItem
|
||||
@@ -89,6 +89,40 @@ class ChangeAffectingSkills(ContextMenuSingle):
|
||||
self.skillIds = {}
|
||||
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:
|
||||
skillItem = wx.MenuItem(sub, ContextMenuSingle.nextID(), skill.item.name)
|
||||
grandSub = wx.Menu()
|
||||
@@ -99,7 +133,7 @@ class ChangeAffectingSkills(ContextMenuSingle):
|
||||
skillItem.SetBitmap(bitmap)
|
||||
|
||||
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)
|
||||
if (not skill.learned and i == -1) or (skill.learned and skill.level == i):
|
||||
levelItem.Check(True)
|
||||
@@ -108,9 +142,24 @@ class ChangeAffectingSkills(ContextMenuSingle):
|
||||
return sub
|
||||
|
||||
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()
|
||||
self.sFit.changeChar(fitID, self.charID)
|
||||
|
||||
|
||||
@@ -9,6 +9,46 @@ from gui.utils.numberFormatter import formatAmount
|
||||
|
||||
_t = wx.GetTranslation
|
||||
|
||||
# Mapping of repair/transfer amount attributes to their duration attribute and display name
|
||||
PER_SECOND_ATTRIBUTES = {
|
||||
"armorDamageAmount": {
|
||||
"durationAttr": "duration",
|
||||
"displayName": "Armor Hitpoints Repaired per second",
|
||||
"unit": "HP/s"
|
||||
},
|
||||
"shieldBonus": {
|
||||
"durationAttr": "duration",
|
||||
"displayName": "Shield Hitpoints Repaired per second",
|
||||
"unit": "HP/s"
|
||||
},
|
||||
"powerTransferAmount": {
|
||||
"durationAttr": "duration",
|
||||
"displayName": "Capacitor Transferred per second",
|
||||
"unit": "GJ/s"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class PerSecondAttributeInfo:
|
||||
"""Helper class to store info about computed per-second attributes"""
|
||||
def __init__(self, displayName, unit):
|
||||
self.displayName = displayName
|
||||
self.unit = PerSecondUnit(unit)
|
||||
|
||||
|
||||
class PerSecondUnit:
|
||||
"""Helper class to mimic the Unit class for per-second attributes"""
|
||||
def __init__(self, displayName):
|
||||
self.displayName = displayName
|
||||
self.name = ""
|
||||
|
||||
|
||||
class PerSecondAttributeValue:
|
||||
"""Helper class to store computed per-second attribute values"""
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
self.info = None # Will be set when adding to attrs
|
||||
|
||||
|
||||
def defaultSort(item):
|
||||
return (item.metaLevel or 0, item.name)
|
||||
@@ -36,8 +76,12 @@ class ItemCompare(wx.Panel):
|
||||
self.item = item
|
||||
self.items = sorted(items, key=defaultSort)
|
||||
self.attrs = {}
|
||||
self.computedAttrs = {} # Store computed per-second attributes
|
||||
self.HighlightOn = wx.Colour(255, 255, 0, wx.ALPHA_OPAQUE)
|
||||
self.highlightedNames = []
|
||||
self.bangBuckColumn = None # Store the column selected for bang/buck calculation
|
||||
self.bangBuckColumnName = None # Store the display name of the selected column
|
||||
self.columnHighlightColour = wx.Colour(173, 216, 230, wx.ALPHA_OPAQUE) # Light blue for column highlight
|
||||
|
||||
# get a dict of attrName: attrInfo of all unique attributes across all items
|
||||
for item in self.items:
|
||||
@@ -45,23 +89,66 @@ class ItemCompare(wx.Panel):
|
||||
if item.attributes[attr].info.displayName:
|
||||
self.attrs[attr] = item.attributes[attr].info
|
||||
|
||||
# Compute per-second attributes for items that have both the amount and duration
|
||||
for perSecondKey, config in PER_SECOND_ATTRIBUTES.items():
|
||||
amountAttr = perSecondKey
|
||||
durationAttr = config["durationAttr"]
|
||||
perSecondAttrName = f"{perSecondKey}_per_second"
|
||||
|
||||
# Check if any item has both attributes
|
||||
hasPerSecondAttr = False
|
||||
for item in self.items:
|
||||
if amountAttr in item.attributes and durationAttr in item.attributes:
|
||||
hasPerSecondAttr = True
|
||||
break
|
||||
|
||||
if hasPerSecondAttr:
|
||||
# Add the per-second attribute info to attrs
|
||||
perSecondInfo = PerSecondAttributeInfo(config["displayName"], config["unit"])
|
||||
self.attrs[perSecondAttrName] = perSecondInfo
|
||||
self.computedAttrs[perSecondAttrName] = {
|
||||
"amountAttr": amountAttr,
|
||||
"durationAttr": durationAttr
|
||||
}
|
||||
|
||||
# Process attributes for items and find ones that differ
|
||||
for attr in list(self.attrs.keys()):
|
||||
value = None
|
||||
|
||||
for item in self.items:
|
||||
# we can automatically break here if this item doesn't have the attribute,
|
||||
# as that means at least one item did
|
||||
if attr not in item.attributes:
|
||||
break
|
||||
# Check if this is a computed attribute
|
||||
if attr in self.computedAttrs:
|
||||
computed = self.computedAttrs[attr]
|
||||
amountAttr = computed["amountAttr"]
|
||||
durationAttr = computed["durationAttr"]
|
||||
|
||||
# this is the first attribute for the item set, set the initial value
|
||||
if value is None:
|
||||
value = item.attributes[attr].value
|
||||
continue
|
||||
# Item needs both attributes to compute per-second value
|
||||
if amountAttr not in item.attributes or durationAttr not in item.attributes:
|
||||
break
|
||||
|
||||
if attr not in item.attributes or item.attributes[attr].value != value:
|
||||
break
|
||||
# Calculate per-second value
|
||||
amountValue = item.attributes[amountAttr].value
|
||||
durationValue = item.attributes[durationAttr].value
|
||||
# Duration is in milliseconds, convert to seconds
|
||||
perSecondValue = amountValue / (durationValue / 1000.0) if durationValue > 0 else 0
|
||||
|
||||
if value is None:
|
||||
value = perSecondValue
|
||||
continue
|
||||
|
||||
if perSecondValue != value:
|
||||
break
|
||||
else:
|
||||
# Regular attribute handling
|
||||
if attr not in item.attributes:
|
||||
break
|
||||
|
||||
if value is None:
|
||||
value = item.attributes[attr].value
|
||||
continue
|
||||
|
||||
if item.attributes[attr].value != value:
|
||||
break
|
||||
else:
|
||||
# attribute values were all the same, delete
|
||||
del self.attrs[attr]
|
||||
@@ -89,6 +176,7 @@ class ItemCompare(wx.Panel):
|
||||
|
||||
self.toggleViewBtn.Bind(wx.EVT_TOGGLEBUTTON, self.ToggleViewMode)
|
||||
self.Bind(wx.EVT_LIST_COL_CLICK, self.SortCompareCols)
|
||||
self.Bind(wx.EVT_LIST_COL_RIGHT_CLICK, self.OnColumnRightClick)
|
||||
|
||||
self.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.HighlightRow)
|
||||
|
||||
@@ -105,6 +193,23 @@ class ItemCompare(wx.Panel):
|
||||
self.Thaw()
|
||||
event.Skip()
|
||||
|
||||
def OnColumnRightClick(self, event):
|
||||
column = event.GetColumn()
|
||||
# Column 0 is "Item", column len(self.attrs) + 1 is "Price", len(self.attrs) + 2 is "Buck/bang"
|
||||
# Only allow selecting attribute columns (1 to len(self.attrs))
|
||||
if 1 <= column <= len(self.attrs):
|
||||
# If clicking the same column, deselect it
|
||||
if self.bangBuckColumn == column:
|
||||
self.bangBuckColumn = None
|
||||
self.bangBuckColumnName = None
|
||||
else:
|
||||
self.bangBuckColumn = column
|
||||
# Get the display name of the selected column
|
||||
attr_key = list(self.attrs.keys())[column - 1]
|
||||
self.bangBuckColumnName = self.attrs[attr_key].displayName if self.attrs[attr_key].displayName else attr_key
|
||||
self.UpdateList()
|
||||
event.Skip()
|
||||
|
||||
def SortCompareCols(self, event):
|
||||
self.Freeze()
|
||||
self.paramList.ClearAll()
|
||||
@@ -148,12 +253,32 @@ class ItemCompare(wx.Panel):
|
||||
# Remember to reduce by 1, because the attrs array
|
||||
# starts at 0 while the list has the item name as column 0.
|
||||
attr = str(list(self.attrs.keys())[sort - 1])
|
||||
func = lambda _val: _val.attributes[attr].value if attr in _val.attributes else 0.0
|
||||
# Handle computed attributes for sorting
|
||||
if attr in self.computedAttrs:
|
||||
computed = self.computedAttrs[attr]
|
||||
amountAttr = computed["amountAttr"]
|
||||
durationAttr = computed["durationAttr"]
|
||||
func = lambda _val: (_val.attributes[amountAttr].value / (_val.attributes[durationAttr].value / 1000.0)) if (amountAttr in _val.attributes and durationAttr in _val.attributes and _val.attributes[durationAttr].value > 0) else 0.0
|
||||
else:
|
||||
func = lambda _val: _val.attributes[attr].value if attr in _val.attributes else 0.0
|
||||
# Clicked on a column that's not part of our array (price most likely)
|
||||
except IndexError:
|
||||
# Price
|
||||
if sort == len(self.attrs) + 1:
|
||||
func = lambda i: i.price.price if i.price.price != 0 else float("Inf")
|
||||
# Buck/bang
|
||||
elif sort == len(self.attrs) + 2:
|
||||
if self.bangBuckColumn is not None:
|
||||
attr_key = list(self.attrs.keys())[self.bangBuckColumn - 1]
|
||||
if attr_key in self.computedAttrs:
|
||||
computed = self.computedAttrs[attr_key]
|
||||
amountAttr = computed["amountAttr"]
|
||||
durationAttr = computed["durationAttr"]
|
||||
func = lambda i: (i.price.price / (i.attributes[amountAttr].value / (i.attributes[durationAttr].value / 1000.0)) if (amountAttr in i.attributes and durationAttr in i.attributes and i.attributes[durationAttr].value > 0 and (i.attributes[amountAttr].value / (i.attributes[durationAttr].value / 1000.0)) > 0) else float("Inf"))
|
||||
else:
|
||||
func = lambda i: (i.price.price / i.attributes[attr_key].value if (attr_key in i.attributes and i.attributes[attr_key].value > 0) else float("Inf"))
|
||||
else:
|
||||
func = defaultSort
|
||||
# Something else
|
||||
else:
|
||||
self.sortReverse = False
|
||||
@@ -166,18 +291,49 @@ class ItemCompare(wx.Panel):
|
||||
|
||||
for i, attr in enumerate(self.attrs.keys()):
|
||||
name = self.attrs[attr].displayName if self.attrs[attr].displayName else attr
|
||||
# Add indicator if this column is selected for bang/buck calculation
|
||||
if self.bangBuckColumn == i + 1:
|
||||
name = "► " + name
|
||||
self.paramList.InsertColumn(i + 1, name)
|
||||
self.paramList.SetColumnWidth(i + 1, 120)
|
||||
|
||||
self.paramList.InsertColumn(len(self.attrs) + 1, _t("Price"))
|
||||
self.paramList.SetColumnWidth(len(self.attrs) + 1, 60)
|
||||
|
||||
# Add Buck/bang column header
|
||||
buckBangHeader = _t("Buck/bang")
|
||||
if self.bangBuckColumnName:
|
||||
buckBangHeader = _t("Buck/bang ({})").format(self.bangBuckColumnName)
|
||||
self.paramList.InsertColumn(len(self.attrs) + 2, buckBangHeader)
|
||||
self.paramList.SetColumnWidth(len(self.attrs) + 2, 80)
|
||||
|
||||
toHighlight = []
|
||||
|
||||
for item in self.items:
|
||||
i = self.paramList.InsertItem(self.paramList.GetItemCount(), item.name)
|
||||
for x, attr in enumerate(self.attrs.keys()):
|
||||
if attr in item.attributes:
|
||||
# Handle computed attributes
|
||||
if attr in self.computedAttrs:
|
||||
computed = self.computedAttrs[attr]
|
||||
amountAttr = computed["amountAttr"]
|
||||
durationAttr = computed["durationAttr"]
|
||||
|
||||
# Item needs both attributes to display per-second value
|
||||
if amountAttr in item.attributes and durationAttr in item.attributes:
|
||||
amountValue = item.attributes[amountAttr].value
|
||||
durationValue = item.attributes[durationAttr].value
|
||||
# Duration is in milliseconds, convert to seconds
|
||||
perSecondValue = amountValue / (durationValue / 1000.0) if durationValue > 0 else 0
|
||||
|
||||
info = self.attrs[attr]
|
||||
if self.toggleView == 1:
|
||||
valueUnit = formatAmount(perSecondValue, 3, 0, 0) + " " + info.unit.displayName
|
||||
else:
|
||||
valueUnit = str(perSecondValue)
|
||||
|
||||
self.paramList.SetItem(i, x + 1, valueUnit)
|
||||
# else: leave cell empty
|
||||
elif attr in item.attributes:
|
||||
info = self.attrs[attr]
|
||||
value = item.attributes[attr].value
|
||||
if self.toggleView != 1:
|
||||
@@ -191,6 +347,27 @@ class ItemCompare(wx.Panel):
|
||||
|
||||
# Add prices
|
||||
self.paramList.SetItem(i, len(self.attrs) + 1, formatAmount(item.price.price, 3, 3, 9, currency=True) if item.price.price else "")
|
||||
|
||||
# Add buck/bang values
|
||||
if self.bangBuckColumn is not None and item.price.price and item.price.price > 0:
|
||||
attr_key = list(self.attrs.keys())[self.bangBuckColumn - 1]
|
||||
if attr_key in self.computedAttrs:
|
||||
computed = self.computedAttrs[attr_key]
|
||||
amountAttr = computed["amountAttr"]
|
||||
durationAttr = computed["durationAttr"]
|
||||
if amountAttr in item.attributes and durationAttr in item.attributes:
|
||||
amountValue = item.attributes[amountAttr].value
|
||||
durationValue = item.attributes[durationAttr].value
|
||||
perSecondValue = amountValue / (durationValue / 1000.0) if durationValue > 0 else 0
|
||||
if perSecondValue > 0:
|
||||
buckBangValue = item.price.price / perSecondValue
|
||||
self.paramList.SetItem(i, len(self.attrs) + 2, formatAmount(buckBangValue, 3, 3, 9, currency=True))
|
||||
elif attr_key in item.attributes:
|
||||
attrValue = item.attributes[attr_key].value
|
||||
if attrValue > 0:
|
||||
buckBangValue = item.price.price / attrValue
|
||||
self.paramList.SetItem(i, len(self.attrs) + 2, formatAmount(buckBangValue, 3, 3, 9, currency=True))
|
||||
|
||||
if item.name in self.highlightedNames:
|
||||
toHighlight.append(i)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from logbook import Logger
|
||||
|
||||
import gui.builtinMarketBrowser.pfSearchBox as SBox
|
||||
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.const import FittingSlot
|
||||
from gui.builtinMarketBrowser.events import ItemSelected, RECENTLY_USED_MODULES, CHARGES_FOR_FIT
|
||||
@@ -412,7 +412,7 @@ class ItemView(Display):
|
||||
|
||||
def columnBackground(self, colItem, item):
|
||||
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()
|
||||
else:
|
||||
return self.GetBackgroundColour()
|
||||
|
||||
@@ -42,7 +42,7 @@ from gui.utils.staticHelpers import DragDropHelper
|
||||
from gui.utils.dark import isDark
|
||||
from service.fit import Fit
|
||||
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
|
||||
|
||||
pyfalog = Logger(__name__)
|
||||
@@ -497,11 +497,27 @@ class FittingView(d.Display):
|
||||
fit = Fit.getInstance().getFit(fitID)
|
||||
if mod in fit.modules:
|
||||
position = fit.modules.index(mod)
|
||||
self.mainFrame.command.Submit(cmd.GuiCargoToLocalModuleCommand(
|
||||
fitID=fitID,
|
||||
cargoItemID=cargoItemID,
|
||||
modPosition=position,
|
||||
copy=wx.GetMouseState().GetModifiers() == wx.MOD_CONTROL))
|
||||
modifiers = wx.GetMouseState().GetModifiers()
|
||||
isCopy = modifiers == wx.MOD_CONTROL
|
||||
isBatch = modifiers == wx.MOD_SHIFT
|
||||
if isBatch:
|
||||
self.mainFrame.command.Submit(
|
||||
cmd.GuiBatchCargoToLocalModuleCommand(
|
||||
fitID=fitID,
|
||||
cargoItemID=cargoItemID,
|
||||
targetPosition=position,
|
||||
copy=isCopy,
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.mainFrame.command.Submit(
|
||||
cmd.GuiCargoToLocalModuleCommand(
|
||||
fitID=fitID,
|
||||
cargoItemID=cargoItemID,
|
||||
modPosition=position,
|
||||
copy=isCopy,
|
||||
)
|
||||
)
|
||||
|
||||
def swapItems(self, x, y, srcIdx):
|
||||
"""Swap two modules in fitting window"""
|
||||
@@ -668,6 +684,21 @@ class FittingView(d.Display):
|
||||
contexts.append(fullContext)
|
||||
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)
|
||||
self.PopupMenu(menu)
|
||||
|
||||
@@ -735,9 +766,9 @@ class FittingView(d.Display):
|
||||
|
||||
def slotColour(self, slot):
|
||||
if isDark():
|
||||
return slotColourMapDark.get(slot) or self.GetBackgroundColour()
|
||||
return get_slotColourMapDark().get(slot) or self.GetBackgroundColour()
|
||||
else:
|
||||
return slotColourMap.get(slot) or self.GetBackgroundColour()
|
||||
return get_slotColourMap().get(slot) or self.GetBackgroundColour()
|
||||
|
||||
def refresh(self, stuff):
|
||||
"""
|
||||
@@ -780,9 +811,8 @@ class FittingView(d.Display):
|
||||
del mod.restrictionOverridden
|
||||
hasRestrictionOverriden = not hasRestrictionOverriden
|
||||
|
||||
|
||||
if slotMap[mod.slot] or hasRestrictionOverriden: # Color too many modules as red
|
||||
self.SetItemBackgroundColour(i, errColorDark if isDark() else errColor)
|
||||
self.SetItemBackgroundColour(i, get_errColorDark() if isDark() else get_errColor())
|
||||
elif sFit.serviceFittingOptions["colorFitBySlot"]: # Color by slot it enabled
|
||||
self.SetItemBackgroundColour(i, self.slotColour(mod.slot))
|
||||
|
||||
|
||||
@@ -59,6 +59,12 @@ from .gui.localModule.mutatedRevert import GuiRevertMutatedLocalModuleCommand
|
||||
from .gui.localModule.remove import GuiRemoveLocalModuleCommand
|
||||
from .gui.localModule.replace import GuiReplaceLocalModuleCommand
|
||||
from .gui.localModule.swap import GuiSwapLocalModulesCommand
|
||||
from .gui.localModuleCargo.batchCargoToLocalModule import (
|
||||
GuiBatchCargoToLocalModuleCommand,
|
||||
)
|
||||
from .gui.localModuleCargo.batchLocalModuleToCargo import (
|
||||
GuiBatchLocalModuleToCargoCommand,
|
||||
)
|
||||
from .gui.localModuleCargo.cargoToLocalModule import GuiCargoToLocalModuleCommand
|
||||
from .gui.localModuleCargo.localModuleToCargo import GuiLocalModuleToCargoCommand
|
||||
from .gui.projectedChangeProjectionRange import GuiChangeProjectedItemsProjectionRangeCommand
|
||||
|
||||
325
gui/fitCommands/gui/localModuleCargo/batchCargoToLocalModule.py
Normal file
325
gui/fitCommands/gui/localModuleCargo/batchCargoToLocalModule.py
Normal file
@@ -0,0 +1,325 @@
|
||||
import wx
|
||||
|
||||
import eos.db
|
||||
import gui.mainFrame
|
||||
from gui import globalEvents as GE
|
||||
from gui.fitCommands.calc.cargo.add import CalcAddCargoCommand
|
||||
from gui.fitCommands.calc.cargo.remove import CalcRemoveCargoCommand
|
||||
from gui.fitCommands.calc.module.changeCharges import CalcChangeModuleChargesCommand
|
||||
from gui.fitCommands.calc.module.localReplace import CalcReplaceLocalModuleCommand
|
||||
from gui.fitCommands.helpers import (
|
||||
CargoInfo,
|
||||
InternalCommandHistory,
|
||||
ModuleInfo,
|
||||
restoreRemovedDummies,
|
||||
)
|
||||
from service.fit import Fit
|
||||
|
||||
|
||||
class GuiBatchCargoToLocalModuleCommand(wx.Command):
|
||||
def __init__(self, fitID, cargoItemID, targetPosition, copy):
|
||||
wx.Command.__init__(self, True, "Batch Cargo to Local Modules")
|
||||
self.internalHistory = InternalCommandHistory()
|
||||
self.fitID = fitID
|
||||
self.srcCargoItemID = cargoItemID
|
||||
self.targetPosition = targetPosition
|
||||
self.copy = copy
|
||||
self.replacedModItemIDs = []
|
||||
self.savedRemovedDummies = None
|
||||
|
||||
def Do(self):
|
||||
sFit = Fit.getInstance()
|
||||
fit = sFit.getFit(self.fitID)
|
||||
if fit is None:
|
||||
return False
|
||||
|
||||
srcCargo = next((c for c in fit.cargo if c.itemID == self.srcCargoItemID), None)
|
||||
if srcCargo is None:
|
||||
return False
|
||||
|
||||
if srcCargo.item.isCharge:
|
||||
return self._handleCharges(fit, srcCargo)
|
||||
|
||||
if not srcCargo.item.isModule:
|
||||
return False
|
||||
|
||||
if self.targetPosition >= len(fit.modules):
|
||||
return False
|
||||
|
||||
targetMod = fit.modules[self.targetPosition]
|
||||
|
||||
if targetMod.isEmpty:
|
||||
return self._fillEmptySlots(fit, srcCargo, targetMod.slot)
|
||||
else:
|
||||
return self._replaceSimilarModules(fit, srcCargo, targetMod)
|
||||
|
||||
def _getSimilarModulePositions(self, fit, targetMod):
|
||||
targetItemID = targetMod.itemID
|
||||
matchingPositions = []
|
||||
for position, mod in enumerate(fit.modules):
|
||||
if mod.isEmpty:
|
||||
continue
|
||||
if mod.itemID == targetItemID:
|
||||
matchingPositions.append(position)
|
||||
return matchingPositions
|
||||
|
||||
def _replaceSimilarModules(self, fit, srcCargo, targetMod):
|
||||
availableAmount = srcCargo.amount if not self.copy else float("inf")
|
||||
|
||||
matchingPositions = self._getSimilarModulePositions(fit, targetMod)
|
||||
|
||||
if not matchingPositions:
|
||||
return False
|
||||
|
||||
positionsToReplace = matchingPositions[: int(availableAmount)]
|
||||
|
||||
if not positionsToReplace:
|
||||
return False
|
||||
|
||||
self.replacedModItemIDs = []
|
||||
commands = []
|
||||
cargoToRemove = 0
|
||||
|
||||
for position in positionsToReplace:
|
||||
mod = fit.modules[position]
|
||||
if mod.isEmpty:
|
||||
continue
|
||||
dstModItemID = mod.itemID
|
||||
|
||||
newModInfo = ModuleInfo.fromModule(mod, unmutate=True)
|
||||
newModInfo.itemID = self.srcCargoItemID
|
||||
newCargoModItemID = ModuleInfo.fromModule(mod, unmutate=True).itemID
|
||||
|
||||
commands.append(
|
||||
CalcAddCargoCommand(
|
||||
fitID=self.fitID,
|
||||
cargoInfo=CargoInfo(itemID=newCargoModItemID, amount=1),
|
||||
)
|
||||
)
|
||||
cmdReplace = CalcReplaceLocalModuleCommand(
|
||||
fitID=self.fitID,
|
||||
position=position,
|
||||
newModInfo=newModInfo,
|
||||
unloadInvalidCharges=True,
|
||||
)
|
||||
commands.append(cmdReplace)
|
||||
self.replacedModItemIDs.append(dstModItemID)
|
||||
cargoToRemove += 1
|
||||
|
||||
if not self.copy and cargoToRemove > 0:
|
||||
commands.insert(
|
||||
0,
|
||||
CalcRemoveCargoCommand(
|
||||
fitID=self.fitID,
|
||||
cargoInfo=CargoInfo(
|
||||
itemID=self.srcCargoItemID, amount=cargoToRemove
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
success = self.internalHistory.submitBatch(*commands)
|
||||
|
||||
if not success:
|
||||
self.internalHistory.undoAll()
|
||||
return False
|
||||
|
||||
eos.db.flush()
|
||||
sFit = Fit.getInstance()
|
||||
sFit.recalc(self.fitID)
|
||||
self.savedRemovedDummies = sFit.fill(self.fitID)
|
||||
eos.db.commit()
|
||||
|
||||
events = []
|
||||
for removedModItemID in self.replacedModItemIDs:
|
||||
events.append(
|
||||
GE.FitChanged(
|
||||
fitIDs=(self.fitID,), action="moddel", typeID=removedModItemID
|
||||
)
|
||||
)
|
||||
if self.srcCargoItemID is not None:
|
||||
events.append(
|
||||
GE.FitChanged(
|
||||
fitIDs=(self.fitID,), action="modadd", typeID=self.srcCargoItemID
|
||||
)
|
||||
)
|
||||
if not events:
|
||||
events.append(GE.FitChanged(fitIDs=(self.fitID,)))
|
||||
for event in events:
|
||||
wx.PostEvent(gui.mainFrame.MainFrame.getInstance(), event)
|
||||
|
||||
return True
|
||||
|
||||
def _fillEmptySlots(self, fit, srcCargo, targetSlot):
|
||||
availableAmount = srcCargo.amount if not self.copy else float("inf")
|
||||
|
||||
emptyPositions = []
|
||||
for position, mod in enumerate(fit.modules):
|
||||
if mod.isEmpty and mod.slot == targetSlot:
|
||||
emptyPositions.append(position)
|
||||
|
||||
if not emptyPositions:
|
||||
return False
|
||||
|
||||
positionsToFill = emptyPositions[: int(availableAmount)]
|
||||
|
||||
if not positionsToFill:
|
||||
return False
|
||||
|
||||
commands = []
|
||||
cargoToRemove = 0
|
||||
|
||||
for position in positionsToFill:
|
||||
newModInfo = ModuleInfo(itemID=self.srcCargoItemID)
|
||||
cmdReplace = CalcReplaceLocalModuleCommand(
|
||||
fitID=self.fitID,
|
||||
position=position,
|
||||
newModInfo=newModInfo,
|
||||
unloadInvalidCharges=True,
|
||||
)
|
||||
commands.append(cmdReplace)
|
||||
cargoToRemove += 1
|
||||
|
||||
if not self.copy and cargoToRemove > 0:
|
||||
commands.insert(
|
||||
0,
|
||||
CalcRemoveCargoCommand(
|
||||
fitID=self.fitID,
|
||||
cargoInfo=CargoInfo(
|
||||
itemID=self.srcCargoItemID, amount=cargoToRemove
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
success = self.internalHistory.submitBatch(*commands)
|
||||
|
||||
if not success:
|
||||
self.internalHistory.undoAll()
|
||||
return False
|
||||
|
||||
eos.db.flush()
|
||||
sFit = Fit.getInstance()
|
||||
sFit.recalc(self.fitID)
|
||||
self.savedRemovedDummies = sFit.fill(self.fitID)
|
||||
eos.db.commit()
|
||||
|
||||
events = []
|
||||
if self.srcCargoItemID is not None:
|
||||
events.append(
|
||||
GE.FitChanged(
|
||||
fitIDs=(self.fitID,), action="modadd", typeID=self.srcCargoItemID
|
||||
)
|
||||
)
|
||||
if not events:
|
||||
events.append(GE.FitChanged(fitIDs=(self.fitID,)))
|
||||
for event in events:
|
||||
wx.PostEvent(gui.mainFrame.MainFrame.getInstance(), event)
|
||||
|
||||
return True
|
||||
|
||||
def _handleCharges(self, fit, srcCargo):
|
||||
availableAmount = srcCargo.amount if not self.copy else float("inf")
|
||||
|
||||
targetMod = fit.modules[self.targetPosition]
|
||||
if targetMod.isEmpty:
|
||||
return False
|
||||
|
||||
targetItemID = targetMod.itemID
|
||||
matchingPositions = []
|
||||
for position, mod in enumerate(fit.modules):
|
||||
if mod.isEmpty:
|
||||
continue
|
||||
if mod.itemID == targetItemID:
|
||||
matchingPositions.append(position)
|
||||
|
||||
if not matchingPositions:
|
||||
return False
|
||||
|
||||
positionsToReplace = matchingPositions[: int(availableAmount)]
|
||||
if not positionsToReplace:
|
||||
return False
|
||||
|
||||
commands = []
|
||||
chargeMap = {}
|
||||
totalChargesNeeded = 0
|
||||
|
||||
for position in positionsToReplace:
|
||||
mod = fit.modules[position]
|
||||
if mod.isEmpty:
|
||||
continue
|
||||
|
||||
oldChargeID = mod.chargeID
|
||||
oldChargeAmount = mod.numCharges
|
||||
newChargeAmount = mod.getNumCharges(srcCargo.item)
|
||||
|
||||
if oldChargeID is not None and oldChargeID != srcCargo.itemID:
|
||||
commands.append(
|
||||
CalcAddCargoCommand(
|
||||
fitID=self.fitID,
|
||||
cargoInfo=CargoInfo(itemID=oldChargeID, amount=oldChargeAmount),
|
||||
)
|
||||
)
|
||||
|
||||
chargeMap[position] = srcCargo.itemID
|
||||
totalChargesNeeded += newChargeAmount
|
||||
|
||||
if not self.copy and totalChargesNeeded > 0:
|
||||
commands.append(
|
||||
CalcRemoveCargoCommand(
|
||||
fitID=self.fitID,
|
||||
cargoInfo=CargoInfo(
|
||||
itemID=srcCargo.itemID, amount=totalChargesNeeded
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
commands.append(
|
||||
CalcChangeModuleChargesCommand(
|
||||
fitID=self.fitID, projected=False, chargeMap=chargeMap
|
||||
)
|
||||
)
|
||||
|
||||
success = self.internalHistory.submitBatch(*commands)
|
||||
|
||||
if not success:
|
||||
self.internalHistory.undoAll()
|
||||
return False
|
||||
|
||||
eos.db.flush()
|
||||
sFit = Fit.getInstance()
|
||||
sFit.recalc(self.fitID)
|
||||
self.savedRemovedDummies = sFit.fill(self.fitID)
|
||||
eos.db.commit()
|
||||
|
||||
events = [GE.FitChanged(fitIDs=(self.fitID,))]
|
||||
for event in events:
|
||||
wx.PostEvent(gui.mainFrame.MainFrame.getInstance(), event)
|
||||
|
||||
return True
|
||||
|
||||
def Undo(self):
|
||||
sFit = Fit.getInstance()
|
||||
fit = sFit.getFit(self.fitID)
|
||||
restoreRemovedDummies(fit, self.savedRemovedDummies)
|
||||
success = self.internalHistory.undoAll()
|
||||
eos.db.flush()
|
||||
sFit.recalc(self.fitID)
|
||||
sFit.fill(self.fitID)
|
||||
eos.db.commit()
|
||||
events = []
|
||||
if self.srcCargoItemID is not None:
|
||||
events.append(
|
||||
GE.FitChanged(
|
||||
fitIDs=(self.fitID,), action="moddel", typeID=self.srcCargoItemID
|
||||
)
|
||||
)
|
||||
for removedModItemID in self.replacedModItemIDs:
|
||||
events.append(
|
||||
GE.FitChanged(
|
||||
fitIDs=(self.fitID,), action="modadd", typeID=removedModItemID
|
||||
)
|
||||
)
|
||||
if not events:
|
||||
events.append(GE.FitChanged(fitIDs=(self.fitID,)))
|
||||
for event in events:
|
||||
wx.PostEvent(gui.mainFrame.MainFrame.getInstance(), event)
|
||||
return success
|
||||
167
gui/fitCommands/gui/localModuleCargo/batchLocalModuleToCargo.py
Normal file
167
gui/fitCommands/gui/localModuleCargo/batchLocalModuleToCargo.py
Normal file
@@ -0,0 +1,167 @@
|
||||
import wx
|
||||
|
||||
import eos.db
|
||||
import gui.mainFrame
|
||||
from gui import globalEvents as GE
|
||||
from gui.fitCommands.calc.cargo.add import CalcAddCargoCommand
|
||||
from gui.fitCommands.calc.module.localRemove import CalcRemoveLocalModulesCommand
|
||||
from gui.fitCommands.helpers import (
|
||||
CargoInfo,
|
||||
InternalCommandHistory,
|
||||
ModuleInfo,
|
||||
restoreRemovedDummies,
|
||||
)
|
||||
from service.fit import Fit
|
||||
|
||||
|
||||
class GuiBatchLocalModuleToCargoCommand(wx.Command):
|
||||
def __init__(self, fitID, modPosition, copy):
|
||||
wx.Command.__init__(self, True, "Batch Local Module to Cargo")
|
||||
self.internalHistory = InternalCommandHistory()
|
||||
self.fitID = fitID
|
||||
self.srcModPosition = modPosition
|
||||
self.copy = copy
|
||||
self.removedModItemIDs = []
|
||||
self.savedRemovedDummies = None
|
||||
|
||||
def Do(self):
|
||||
fit = Fit.getInstance().getFit(self.fitID)
|
||||
srcMod = fit.modules[self.srcModPosition]
|
||||
if srcMod.isEmpty:
|
||||
return False
|
||||
|
||||
if srcMod.chargeID is not None:
|
||||
return self._unloadCharges(fit, srcMod)
|
||||
else:
|
||||
return self._moveModulesToCargo(fit, srcMod)
|
||||
|
||||
def _getSimilarModulePositions(self, fit, targetMod):
|
||||
targetItemID = targetMod.itemID
|
||||
matchingPositions = []
|
||||
for position, mod in enumerate(fit.modules):
|
||||
if mod.isEmpty:
|
||||
continue
|
||||
if mod.itemID == targetItemID:
|
||||
matchingPositions.append(position)
|
||||
return matchingPositions
|
||||
|
||||
def _unloadCharges(self, fit, srcMod):
|
||||
matchingPositions = self._getSimilarModulePositions(fit, srcMod)
|
||||
if not matchingPositions:
|
||||
return False
|
||||
|
||||
commands = []
|
||||
for position in matchingPositions:
|
||||
mod = fit.modules[position]
|
||||
if mod.isEmpty:
|
||||
continue
|
||||
if mod.chargeID is not None:
|
||||
commands.append(
|
||||
CalcAddCargoCommand(
|
||||
fitID=self.fitID,
|
||||
cargoInfo=CargoInfo(itemID=mod.chargeID, amount=mod.numCharges),
|
||||
)
|
||||
)
|
||||
|
||||
if not self.copy:
|
||||
from gui.fitCommands.calc.module.changeCharges import (
|
||||
CalcChangeModuleChargesCommand,
|
||||
)
|
||||
|
||||
chargeMap = {pos: None for pos in matchingPositions}
|
||||
commands.append(
|
||||
CalcChangeModuleChargesCommand(
|
||||
fitID=self.fitID, projected=False, chargeMap=chargeMap
|
||||
)
|
||||
)
|
||||
|
||||
success = self.internalHistory.submitBatch(*commands)
|
||||
if not success:
|
||||
self.internalHistory.undoAll()
|
||||
return False
|
||||
|
||||
eos.db.flush()
|
||||
sFit = Fit.getInstance()
|
||||
sFit.recalc(self.fitID)
|
||||
self.savedRemovedDummies = sFit.fill(self.fitID)
|
||||
eos.db.commit()
|
||||
|
||||
events = [GE.FitChanged(fitIDs=(self.fitID,))]
|
||||
for event in events:
|
||||
wx.PostEvent(gui.mainFrame.MainFrame.getInstance(), event)
|
||||
return success
|
||||
|
||||
def _moveModulesToCargo(self, fit, srcMod):
|
||||
matchingPositions = self._getSimilarModulePositions(fit, srcMod)
|
||||
if not matchingPositions:
|
||||
return False
|
||||
|
||||
commands = []
|
||||
for position in matchingPositions:
|
||||
mod = fit.modules[position]
|
||||
if mod.isEmpty:
|
||||
continue
|
||||
|
||||
commands.append(
|
||||
CalcAddCargoCommand(
|
||||
fitID=self.fitID,
|
||||
cargoInfo=CargoInfo(
|
||||
itemID=ModuleInfo.fromModule(mod, unmutate=True).itemID,
|
||||
amount=1,
|
||||
),
|
||||
)
|
||||
)
|
||||
self.removedModItemIDs.append(mod.itemID)
|
||||
|
||||
if not self.copy:
|
||||
commands.append(
|
||||
CalcRemoveLocalModulesCommand(
|
||||
fitID=self.fitID, positions=matchingPositions
|
||||
)
|
||||
)
|
||||
|
||||
success = self.internalHistory.submitBatch(*commands)
|
||||
if not success:
|
||||
self.internalHistory.undoAll()
|
||||
return False
|
||||
|
||||
eos.db.flush()
|
||||
sFit = Fit.getInstance()
|
||||
sFit.recalc(self.fitID)
|
||||
self.savedRemovedDummies = sFit.fill(self.fitID)
|
||||
eos.db.commit()
|
||||
|
||||
events = []
|
||||
for removedModItemID in self.removedModItemIDs:
|
||||
events.append(
|
||||
GE.FitChanged(
|
||||
fitIDs=(self.fitID,), action="moddel", typeID=removedModItemID
|
||||
)
|
||||
)
|
||||
if not events:
|
||||
events.append(GE.FitChanged(fitIDs=(self.fitID,)))
|
||||
for event in events:
|
||||
wx.PostEvent(gui.mainFrame.MainFrame.getInstance(), event)
|
||||
return success
|
||||
|
||||
def Undo(self):
|
||||
sFit = Fit.getInstance()
|
||||
fit = sFit.getFit(self.fitID)
|
||||
restoreRemovedDummies(fit, self.savedRemovedDummies)
|
||||
success = self.internalHistory.undoAll()
|
||||
eos.db.flush()
|
||||
sFit.recalc(self.fitID)
|
||||
sFit.fill(self.fitID)
|
||||
eos.db.commit()
|
||||
events = []
|
||||
for removedModItemID in self.removedModItemIDs:
|
||||
events.append(
|
||||
GE.FitChanged(
|
||||
fitIDs=(self.fitID,), action="modadd", typeID=removedModItemID
|
||||
)
|
||||
)
|
||||
if not events:
|
||||
events.append(GE.FitChanged(fitIDs=(self.fitID,)))
|
||||
for event in events:
|
||||
wx.PostEvent(gui.mainFrame.MainFrame.getInstance(), event)
|
||||
return success
|
||||
@@ -346,20 +346,7 @@ class CargoInfo:
|
||||
return makeReprStr(self, ['itemID', 'amount'])
|
||||
|
||||
|
||||
def activeStateLimit(itemIdentity):
|
||||
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
|
||||
from service.port.active_state import activeStateLimit
|
||||
|
||||
|
||||
def droneStackLimit(fit, itemIdentity):
|
||||
|
||||
216
gui/fitDiffFrame.py
Normal file
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
|
||||
@@ -565,7 +565,8 @@ class MainFrame(wx.Frame):
|
||||
self.Bind(wx.EVT_MENU, self.toggleOverrides, id=menuBar.toggleOverridesId)
|
||||
|
||||
# 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
|
||||
self.Bind(wx.EVT_MENU, self.toggleIgnoreRestriction, id=menuBar.toggleIgnoreRestrictionID)
|
||||
@@ -623,7 +624,16 @@ class MainFrame(wx.Frame):
|
||||
(wx.ACCEL_CMD, wx.WXK_PAGEUP, ctabprev),
|
||||
|
||||
(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)
|
||||
]
|
||||
@@ -813,6 +823,32 @@ class MainFrame(wx.Frame):
|
||||
else:
|
||||
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):
|
||||
with CopySelectDialog(self) as dlg:
|
||||
dlg.ShowModal()
|
||||
|
||||
@@ -58,6 +58,8 @@ class MainMenuBar(wx.MenuBar):
|
||||
self.toggleIgnoreRestrictionID = wx.NewId()
|
||||
self.devToolsId = wx.NewId()
|
||||
self.optimizeFitPrice = wx.NewId()
|
||||
self.copyWithDialogId = wx.NewId()
|
||||
self.copyDirectEftId = wx.NewId()
|
||||
|
||||
self.mainFrame = mainFrame
|
||||
wx.MenuBar.__init__(self)
|
||||
@@ -85,7 +87,8 @@ class MainMenuBar(wx.MenuBar):
|
||||
fitMenu.Append(wx.ID_REDO, _t("&Redo") + "\tCTRL+Y", _t("Redo the most recent undone action"))
|
||||
|
||||
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.AppendSeparator()
|
||||
@@ -178,7 +181,8 @@ class MainMenuBar(wx.MenuBar):
|
||||
return
|
||||
enable = activeFitID is not None
|
||||
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.copySkillsNeededId, enable)
|
||||
|
||||
|
||||
37
pyfa.spec
37
pyfa.spec
@@ -79,8 +79,20 @@ a = Analysis(['pyfa.py'],
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher)
|
||||
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
a_server = Analysis(['pyfa_server.py'],
|
||||
pathex=pathex,
|
||||
binaries=[],
|
||||
datas=added_files,
|
||||
hiddenimports=import_these,
|
||||
hookspath=['dist_assets/pyinstaller_hooks'],
|
||||
runtime_hooks=[],
|
||||
excludes=['Tkinter'],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher)
|
||||
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
pyz_server = PYZ(a_server.pure, a_server.zipped_data, cipher=block_cipher)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
@@ -96,6 +108,19 @@ exe = EXE(
|
||||
contents_directory='app',
|
||||
)
|
||||
|
||||
# Sim server. POST /simulate on port 9123.
|
||||
exe_server = EXE(
|
||||
pyz_server,
|
||||
a_server.scripts,
|
||||
exclude_binaries=True,
|
||||
name='pyfa-server',
|
||||
debug=debug,
|
||||
strip=False,
|
||||
upx=upx,
|
||||
console=True,
|
||||
contents_directory='app',
|
||||
)
|
||||
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
@@ -106,6 +131,16 @@ coll = COLLECT(
|
||||
name='pyfa',
|
||||
)
|
||||
|
||||
coll_server = COLLECT(
|
||||
exe_server,
|
||||
a_server.binaries,
|
||||
a_server.zipfiles,
|
||||
a_server.datas,
|
||||
strip=False,
|
||||
upx=upx,
|
||||
name='pyfa_server',
|
||||
)
|
||||
|
||||
if platform.system() == 'Darwin':
|
||||
info_plist = {
|
||||
'NSHighResolutionCapable': 'True',
|
||||
|
||||
5
pyfa_server.py
Normal file
5
pyfa_server.py
Normal file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# Sim server. POST /simulate with JSON body.
|
||||
from scripts.pyfa_sim import _run_http_server
|
||||
|
||||
_run_http_server(9123, None)
|
||||
@@ -22,3 +22,6 @@ dependencies = [
|
||||
"sqlalchemy==1.4.50",
|
||||
"wxpython==4.2.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest"]
|
||||
|
||||
28
release.sh
28
release.sh
@@ -57,4 +57,32 @@ curl -X POST \
|
||||
|
||||
rm "${ZIP}"
|
||||
|
||||
# Push Docker image
|
||||
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 "Pushing Docker images..."
|
||||
docker push "${IMAGE_BASE}:${COMMIT_SHA}"
|
||||
docker push "${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 push "${IMAGE_BASE}:${tag}"
|
||||
done <<< "$TAGS"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Docker image pushed 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
|
||||
|
||||
echo ""
|
||||
echo "Release complete! ${ZIP} uploaded to ${TAG}"
|
||||
|
||||
14
requirements-server.txt
Normal file
14
requirements-server.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
logbook==1.7.0.post0
|
||||
numpy==1.26.2
|
||||
matplotlib==3.8.2
|
||||
python-dateutil==2.8.2
|
||||
requests==2.31.0
|
||||
sqlalchemy==1.4.50
|
||||
cryptography==42.0.4
|
||||
markdown2==2.4.11
|
||||
packaging==23.2
|
||||
roman==4.1
|
||||
beautifulsoup4==4.12.2
|
||||
pyyaml==6.0.1
|
||||
python-jose==3.3.0
|
||||
requests-cache==1.1.1
|
||||
449
scripts/pyfa_sim.py
Normal file
449
scripts/pyfa_sim.py
Normal file
@@ -0,0 +1,449 @@
|
||||
import json
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
import config
|
||||
import eos.config
|
||||
|
||||
from eos.const import FittingHardpoint
|
||||
from eos.utils.spoolSupport import SpoolOptions, SpoolType
|
||||
|
||||
# POST /simulate request and response shape; included in 400 response body
|
||||
SIMULATE_SCHEMA = {
|
||||
"request": {
|
||||
"fit": "string (required). EFT or multi-line text export of a single fit, e.g. [ShipName, FitName] followed by modules/drones/etc.",
|
||||
"projected_fits": "array (optional). Each element: { \"fit\": string, \"count\": positive integer }.",
|
||||
"command_fits": "array (optional). Each element: { \"fit\": string }.",
|
||||
},
|
||||
"response_200": {
|
||||
"fit": {"id": "int", "name": "string", "ship_type": "string"},
|
||||
"resources": {"hardpoints": {}, "drones": {}, "fighters": {}, "calibration": {}, "powergrid": {}, "cpu": {}, "cargo": {}},
|
||||
"defense": {"hp": {}, "ehp": {}, "resonance": {}, "tank": {}, "effective_tank": {}, "sustainable_tank": {}, "effective_sustainable_tank": {}},
|
||||
"capacitor": {"capacity": {}, "recharge": {}, "use": {}, "delta": {}, "stable": {}, "state": {}, "neutralizer_resistance": {}},
|
||||
"firepower": {"weapon_dps": {}, "drone_dps": {}, "total_dps": {}, "weapon_volley": {}, "drone_volley": {}, "total_volley": {}, "weapons": []},
|
||||
"remote_reps_outgoing": {"current": {}, "pre_spool": {}, "full_spool": {}},
|
||||
"targeting_misc": {"targets_max": {}, "target_range": {}, "scan_resolution": {}, "scan_strength": {}, "scan_type": {}, "jam_chance": {}, "drone_control_range": {}, "speed": {}, "align_time": {}, "signature_radius": {}, "warp_speed": {}, "max_warp_distance": {}, "probe_size": {}, "cargo_capacity": {}, "cargo_used": {}},
|
||||
"price": {"ship": {}, "fittings": {}, "drones_and_fighters": {}, "cargo": {}, "character": {}, "total": {}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _init_pyfa(savepath: str | None) -> None:
|
||||
config.debug = False
|
||||
config.loggingLevel = config.LOGLEVEL_MAP["error"]
|
||||
config.defPaths(savepath)
|
||||
config.defLogging()
|
||||
import eos.db # noqa: F401
|
||||
eos.db.saveddata_meta.create_all() # type: ignore[name-defined]
|
||||
import eos.events # noqa: F401
|
||||
from service import prefetch # noqa: F401
|
||||
|
||||
|
||||
def _parse_drone_markers(text: str) -> tuple[str, set[str]]:
|
||||
active_names: set[str] = set()
|
||||
cleaned_lines: list[str] = []
|
||||
for raw_line in text.splitlines():
|
||||
line = raw_line.rstrip()
|
||||
if " xx" in line:
|
||||
idx = line.find(" xx")
|
||||
marker_segment = line[idx + 1 :].strip()
|
||||
|
||||
if marker_segment == "xx":
|
||||
payload = line[:idx].rstrip()
|
||||
parts = payload.split(" x", 1)
|
||||
if len(parts) == 2:
|
||||
type_name = parts[0].strip()
|
||||
if type_name:
|
||||
active_names.add(type_name)
|
||||
line = payload
|
||||
|
||||
cleaned_lines.append(line)
|
||||
|
||||
return "\n".join(cleaned_lines), active_names
|
||||
|
||||
|
||||
def _import_single_fit(raw_text: str) -> tuple[object, set[str]]:
|
||||
from service.port.port import Port
|
||||
|
||||
cleaned_text, active_names = _parse_drone_markers(raw_text)
|
||||
import_type, import_data = Port.importFitFromBuffer(cleaned_text)
|
||||
|
||||
if not import_data or len(import_data) != 1:
|
||||
raise ValueError("Expected exactly one fit in input; got %d" % (len(import_data) if import_data else 0))
|
||||
|
||||
fit = import_data[0]
|
||||
|
||||
if active_names:
|
||||
for drone in fit.drones:
|
||||
if getattr(drone.item, "typeName", None) in active_names:
|
||||
drone.amountActive = drone.amount
|
||||
for fighter in fit.fighters:
|
||||
if getattr(fighter.item, "typeName", None) in active_names:
|
||||
fighter.amount = fighter.amount
|
||||
|
||||
return fit, active_names
|
||||
|
||||
|
||||
def _add_projected_fit(s_fit, target_fit, projected_fit, amount: int) -> None:
|
||||
fit = s_fit.getFit(target_fit.ID)
|
||||
projected = s_fit.getFit(projected_fit.ID, projected=True)
|
||||
if projected is None:
|
||||
raise ValueError("Projected fit %s is not available" % projected_fit.ID)
|
||||
|
||||
if projected in fit.projectedFits and projected.ID in fit.projectedFitDict:
|
||||
projection_info = projected.getProjectionInfo(fit.ID)
|
||||
if projection_info is None:
|
||||
raise ValueError("Projection info missing for projected fit %s" % projected_fit.ID)
|
||||
else:
|
||||
fit.projectedFitDict[projected.ID] = projected
|
||||
eos.db.saveddata_session.flush()
|
||||
eos.db.saveddata_session.refresh(projected)
|
||||
projection_info = projected.getProjectionInfo(fit.ID)
|
||||
if projection_info is None:
|
||||
raise ValueError("Projection info missing after linking projected fit %s" % projected_fit.ID)
|
||||
|
||||
projection_info.amount = amount
|
||||
projection_info.active = True
|
||||
|
||||
|
||||
def _add_command_fit(s_fit, target_fit, command_fit) -> None:
|
||||
fit = s_fit.getFit(target_fit.ID)
|
||||
command = s_fit.getFit(command_fit.ID)
|
||||
if command is None:
|
||||
raise ValueError("Command fit %s is not available" % command_fit.ID)
|
||||
|
||||
if command in fit.commandFits or command.ID in fit.commandFitDict:
|
||||
return
|
||||
|
||||
fit.commandFitDict[command.ID] = command
|
||||
eos.db.saveddata_session.flush()
|
||||
eos.db.saveddata_session.refresh(command)
|
||||
info = command.getCommandInfo(fit.ID)
|
||||
if info is None:
|
||||
raise ValueError("Command info missing for command fit %s" % command_fit.ID)
|
||||
info.active = True
|
||||
|
||||
|
||||
def _collect_resources(fit) -> dict:
|
||||
ship = fit.ship
|
||||
resources: dict = {}
|
||||
|
||||
resources["hardpoints"] = {
|
||||
"turret": {
|
||||
"used": fit.getHardpointsUsed(FittingHardpoint.TURRET),
|
||||
"total": ship.getModifiedItemAttr("turretSlotsLeft"),
|
||||
},
|
||||
"launcher": {
|
||||
"used": fit.getHardpointsUsed(FittingHardpoint.MISSILE),
|
||||
"total": ship.getModifiedItemAttr("launcherSlotsLeft"),
|
||||
},
|
||||
}
|
||||
|
||||
resources["drones"] = {
|
||||
"active": fit.activeDrones,
|
||||
"max_active": fit.extraAttributes.get("maxActiveDrones"),
|
||||
"bay_used": fit.droneBayUsed,
|
||||
"bay_capacity": ship.getModifiedItemAttr("droneCapacity"),
|
||||
"bandwidth_used": fit.droneBandwidthUsed,
|
||||
"bandwidth_capacity": ship.getModifiedItemAttr("droneBandwidth"),
|
||||
}
|
||||
|
||||
resources["fighters"] = {
|
||||
"tubes_used": fit.fighterTubesUsed,
|
||||
"tubes_total": fit.fighterTubesTotal,
|
||||
"bay_used": fit.fighterBayUsed,
|
||||
"bay_capacity": ship.getModifiedItemAttr("fighterCapacity"),
|
||||
}
|
||||
|
||||
resources["calibration"] = {
|
||||
"used": fit.calibrationUsed,
|
||||
"total": ship.getModifiedItemAttr("upgradeCapacity"),
|
||||
}
|
||||
|
||||
resources["powergrid"] = {
|
||||
"used": fit.pgUsed,
|
||||
"output": ship.getModifiedItemAttr("powerOutput"),
|
||||
}
|
||||
|
||||
resources["cpu"] = {
|
||||
"used": fit.cpuUsed,
|
||||
"output": ship.getModifiedItemAttr("cpuOutput"),
|
||||
}
|
||||
|
||||
resources["cargo"] = {
|
||||
"used": fit.cargoBayUsed,
|
||||
"capacity": ship.getModifiedItemAttr("capacity"),
|
||||
}
|
||||
|
||||
return resources
|
||||
|
||||
|
||||
def _collect_defense(fit) -> dict:
|
||||
ship = fit.ship
|
||||
|
||||
def _res(attr_name):
|
||||
return ship.getModifiedItemAttr(attr_name)
|
||||
|
||||
resonance = {
|
||||
"armor": {"em": _res("armorEmDamageResonance"), "exp": _res("armorExplosiveDamageResonance"), "kin": _res("armorKineticDamageResonance"), "therm": _res("armorThermalDamageResonance")},
|
||||
"shield": {"em": _res("shieldEmDamageResonance"), "exp": _res("shieldExplosiveDamageResonance"), "kin": _res("shieldKineticDamageResonance"), "therm": _res("shieldThermalDamageResonance")},
|
||||
"hull": {"em": _res("emDamageResonance"), "exp": _res("explosiveDamageResonance"), "kin": _res("kineticDamageResonance"), "therm": _res("thermalDamageResonance")},
|
||||
}
|
||||
defense: dict = {}
|
||||
defense["hp"] = fit.hp
|
||||
defense["ehp"] = fit.ehp
|
||||
defense["resonance"] = resonance
|
||||
defense["tank"] = fit.tank
|
||||
defense["effective_tank"] = fit.effectiveTank
|
||||
defense["sustainable_tank"] = fit.sustainableTank
|
||||
defense["effective_sustainable_tank"] = fit.effectiveSustainableTank
|
||||
return defense
|
||||
|
||||
|
||||
def _collect_capacitor(fit) -> dict:
|
||||
ship = fit.ship
|
||||
cap: dict = {}
|
||||
cap["capacity"] = ship.getModifiedItemAttr("capacitorCapacity")
|
||||
cap["recharge"] = fit.capRecharge
|
||||
cap["use"] = fit.capUsed
|
||||
cap["delta"] = fit.capDelta
|
||||
cap["stable"] = fit.capStable
|
||||
cap["state"] = fit.capState
|
||||
cap["neutralizer_resistance"] = ship.getModifiedItemAttr("energyWarfareResistance", 1)
|
||||
return cap
|
||||
|
||||
|
||||
def _collect_firepower(fit) -> dict:
|
||||
default_spool = eos.config.settings["globalDefaultSpoolupPercentage"]
|
||||
spool = SpoolOptions(SpoolType.SPOOL_SCALE, default_spool, False)
|
||||
wdps = fit.getWeaponDps(spoolOptions=spool)
|
||||
ddps = fit.getDroneDps()
|
||||
wvol = fit.getWeaponVolley(spoolOptions=spool)
|
||||
dvol = fit.getDroneVolley()
|
||||
return {
|
||||
"weapon_dps": wdps.total,
|
||||
"drone_dps": ddps.total,
|
||||
"total_dps": fit.getTotalDps(spoolOptions=spool).total,
|
||||
"weapon_volley": wvol.total,
|
||||
"drone_volley": dvol.total,
|
||||
"total_volley": fit.getTotalVolley(spoolOptions=spool).total,
|
||||
"weapons": [],
|
||||
}
|
||||
|
||||
|
||||
def _collect_remote_reps(fit) -> dict:
|
||||
default_spool = eos.config.settings["globalDefaultSpoolupPercentage"]
|
||||
pre = fit.getRemoteReps(spoolOptions=SpoolOptions(SpoolType.SPOOL_SCALE, 0, True))
|
||||
full = fit.getRemoteReps(spoolOptions=SpoolOptions(SpoolType.SPOOL_SCALE, 1, True))
|
||||
current = fit.getRemoteReps(spoolOptions=SpoolOptions(SpoolType.SPOOL_SCALE, default_spool, False))
|
||||
return {
|
||||
"current": {
|
||||
"capacitor": current.capacitor,
|
||||
"shield": current.shield,
|
||||
"armor": current.armor,
|
||||
"hull": current.hull,
|
||||
},
|
||||
"pre_spool": {
|
||||
"capacitor": pre.capacitor,
|
||||
"shield": pre.shield,
|
||||
"armor": pre.armor,
|
||||
"hull": pre.hull,
|
||||
},
|
||||
"full_spool": {
|
||||
"capacitor": full.capacitor,
|
||||
"shield": full.shield,
|
||||
"armor": full.armor,
|
||||
"hull": full.hull,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _collect_targeting_misc(fit) -> dict:
|
||||
ship = fit.ship
|
||||
misc: dict = {}
|
||||
misc["targets_max"] = fit.maxTargets
|
||||
misc["target_range"] = fit.maxTargetRange
|
||||
misc["scan_resolution"] = ship.getModifiedItemAttr("scanResolution")
|
||||
misc["scan_strength"] = fit.scanStrength
|
||||
misc["scan_type"] = fit.scanType
|
||||
misc["jam_chance"] = fit.jamChance
|
||||
misc["drone_control_range"] = fit.extraAttributes.get("droneControlRange")
|
||||
misc["speed"] = fit.maxSpeed
|
||||
misc["align_time"] = fit.alignTime
|
||||
misc["signature_radius"] = ship.getModifiedItemAttr("signatureRadius")
|
||||
misc["warp_speed"] = fit.warpSpeed
|
||||
misc["max_warp_distance"] = fit.maxWarpDistance
|
||||
misc["probe_size"] = fit.probeSize
|
||||
misc["cargo_capacity"] = ship.getModifiedItemAttr("capacity")
|
||||
misc["cargo_used"] = fit.cargoBayUsed
|
||||
return misc
|
||||
|
||||
|
||||
def _collect_price(fit) -> dict:
|
||||
ship_price = 0.0
|
||||
module_price = 0.0
|
||||
drone_price = 0.0
|
||||
fighter_price = 0.0
|
||||
cargo_price = 0.0
|
||||
booster_price = 0.0
|
||||
implant_price = 0.0
|
||||
|
||||
if fit:
|
||||
ship_price = getattr(fit.ship.item.price, "price", 0.0)
|
||||
|
||||
for module in fit.modules:
|
||||
if not module.isEmpty:
|
||||
module_price += getattr(module.item.price, "price", 0.0)
|
||||
|
||||
for drone in fit.drones:
|
||||
drone_price += getattr(drone.item.price, "price", 0.0) * drone.amount
|
||||
|
||||
for fighter in fit.fighters:
|
||||
fighter_price += getattr(fighter.item.price, "price", 0.0) * fighter.amount
|
||||
|
||||
for cargo in fit.cargo:
|
||||
cargo_price += getattr(cargo.item.price, "price", 0.0) * cargo.amount
|
||||
|
||||
for booster in fit.boosters:
|
||||
booster_price += getattr(booster.item.price, "price", 0.0)
|
||||
|
||||
for implant in fit.appliedImplants:
|
||||
implant_price += getattr(implant.item.price, "price", 0.0)
|
||||
|
||||
total_price = ship_price + module_price + drone_price + fighter_price + cargo_price + booster_price + implant_price
|
||||
|
||||
return {
|
||||
"ship": ship_price,
|
||||
"fittings": module_price,
|
||||
"drones_and_fighters": drone_price + fighter_price,
|
||||
"cargo": cargo_price,
|
||||
"character": booster_price + implant_price,
|
||||
"total": total_price,
|
||||
}
|
||||
|
||||
|
||||
def _build_output(main_fit) -> dict:
|
||||
return {
|
||||
"fit": {
|
||||
"id": main_fit.ID,
|
||||
"name": main_fit.name,
|
||||
"ship_type": main_fit.ship.item.typeName,
|
||||
},
|
||||
"resources": _collect_resources(main_fit),
|
||||
"defense": _collect_defense(main_fit),
|
||||
"capacitor": _collect_capacitor(main_fit),
|
||||
"firepower": _collect_firepower(main_fit),
|
||||
"remote_reps_outgoing": _collect_remote_reps(main_fit),
|
||||
"targeting_misc": _collect_targeting_misc(main_fit),
|
||||
"price": _collect_price(main_fit),
|
||||
}
|
||||
|
||||
|
||||
def compute_stats(payload: dict, savepath: str | None = None) -> dict:
|
||||
if "fit" not in payload or not isinstance(payload["fit"], str):
|
||||
raise ValueError("Payload must contain a 'fit' field with EFT/text export")
|
||||
_init_pyfa(savepath)
|
||||
from service.fit import Fit as FitService
|
||||
s_fit = FitService.getInstance()
|
||||
if s_fit.character is None:
|
||||
from eos.saveddata.character import Character as saveddata_Character
|
||||
s_fit.character = saveddata_Character.getAll5()
|
||||
main_fit, _ = _import_single_fit(payload["fit"])
|
||||
projected_defs = payload.get("projected_fits", [])
|
||||
command_defs = payload.get("command_fits", [])
|
||||
projected_fits: list[tuple[object, int]] = []
|
||||
for entry in projected_defs:
|
||||
if not isinstance(entry, dict):
|
||||
raise ValueError("Each projected_fits entry must be an object")
|
||||
fit_text = entry.get("fit")
|
||||
count = entry.get("count")
|
||||
if not isinstance(fit_text, str):
|
||||
raise ValueError("Each projected_fits entry must contain a string 'fit'")
|
||||
if not isinstance(count, int) or count <= 0:
|
||||
raise ValueError("Each projected_fits entry must contain a positive integer 'count'")
|
||||
pf, _ = _import_single_fit(fit_text)
|
||||
projected_fits.append((pf, count))
|
||||
command_fits: list[object] = []
|
||||
for entry in command_defs:
|
||||
if not isinstance(entry, dict):
|
||||
raise ValueError("Each command_fits entry must be an object")
|
||||
fit_text = entry.get("fit")
|
||||
if not isinstance(fit_text, str):
|
||||
raise ValueError("Each command_fits entry must contain a string 'fit'")
|
||||
cf, _ = _import_single_fit(fit_text)
|
||||
command_fits.append(cf)
|
||||
for pf, count in projected_fits:
|
||||
_add_projected_fit(s_fit, main_fit, pf, count)
|
||||
for cf in command_fits:
|
||||
_add_command_fit(s_fit, main_fit, cf)
|
||||
s_fit.recalc(main_fit)
|
||||
s_fit.fill(main_fit)
|
||||
return _build_output(main_fit)
|
||||
|
||||
|
||||
def _run_http_server(port: int, savepath: str | None) -> None:
|
||||
def send_error_json(code: int, msg: str, traceback_str: str | None = None):
|
||||
body = {"error": msg, "schema": SIMULATE_SCHEMA}
|
||||
if traceback_str:
|
||||
body["traceback"] = traceback_str
|
||||
return json.dumps(body, indent=2) + "\n"
|
||||
|
||||
class SimulateHandler(BaseHTTPRequestHandler):
|
||||
def send_error(self, code: int, message: str | None = None, explain: str | None = None):
|
||||
msg = message or explain or "Error"
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(send_error_json(code, msg).encode("utf-8"))
|
||||
|
||||
def _reply_error(self, code: int, msg: str, traceback_str: str | None = None):
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(send_error_json(code, msg, traceback_str).encode("utf-8"))
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/simulate":
|
||||
self._reply_error(405, "Method not allowed. Use POST.")
|
||||
else:
|
||||
self._reply_error(404, "Not found. POST /simulate with JSON body.")
|
||||
|
||||
def do_POST(self):
|
||||
if self.path != "/simulate":
|
||||
self._reply_error(404, "Not found. POST /simulate with JSON body.")
|
||||
return
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_length).decode("utf-8")
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError as exc:
|
||||
self._reply_error(400, "Invalid JSON: %s" % exc)
|
||||
return
|
||||
if not isinstance(payload, dict):
|
||||
self._reply_error(400, "Top-level JSON must be an object")
|
||||
return
|
||||
try:
|
||||
output = compute_stats(payload, savepath)
|
||||
except ValueError as e:
|
||||
self._reply_error(400, str(e))
|
||||
return
|
||||
except Exception as e:
|
||||
import traceback
|
||||
tb = traceback.format_exc()
|
||||
sys.stderr.write(tb)
|
||||
sys.stderr.flush()
|
||||
self._reply_error(500, str(e), tb)
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(output, indent=2, sort_keys=True).encode("utf-8"))
|
||||
self.wfile.write(b"\n")
|
||||
|
||||
with HTTPServer(("", port), SimulateHandler) as httpd:
|
||||
print("POST /simulate on http://127.0.0.1:%s" % port, file=sys.stderr, flush=True)
|
||||
httpd.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_run_http_server(9123, None)
|
||||
@@ -28,12 +28,8 @@ from xml.etree import ElementTree
|
||||
from xml.dom import minidom
|
||||
import gzip
|
||||
|
||||
# noinspection PyPackageRequirements
|
||||
import wx
|
||||
|
||||
import config
|
||||
import eos.db
|
||||
from service.esi import Esi
|
||||
|
||||
from eos.saveddata.implant import Implant as es_Implant
|
||||
from eos.saveddata.character import Character as es_Character, Skill
|
||||
@@ -42,7 +38,12 @@ from eos.const import FittingSlot as es_Slot
|
||||
from eos.saveddata.fighter import Fighter as es_Fighter
|
||||
|
||||
pyfalog = Logger(__name__)
|
||||
_t = wx.GetTranslation
|
||||
|
||||
|
||||
def _t(s):
|
||||
import wx
|
||||
return wx.GetTranslation(s)
|
||||
|
||||
|
||||
class CharacterImportThread(threading.Thread):
|
||||
|
||||
@@ -97,6 +98,7 @@ class CharacterImportThread(threading.Thread):
|
||||
pyfalog.error(e)
|
||||
continue
|
||||
|
||||
import wx
|
||||
wx.CallAfter(self.callback)
|
||||
|
||||
def stop(self):
|
||||
@@ -132,6 +134,7 @@ class SkillBackupThread(threading.Thread):
|
||||
with open(path, mode='w', encoding='utf-8') as backupFile:
|
||||
backupFile.write(backupData)
|
||||
|
||||
import wx
|
||||
wx.CallAfter(self.callback)
|
||||
|
||||
def stop(self):
|
||||
@@ -386,6 +389,7 @@ class Character:
|
||||
|
||||
def apiFetchCallback(self, guiCallback, e=None):
|
||||
eos.db.commit()
|
||||
import wx
|
||||
wx.CallAfter(guiCallback, e)
|
||||
|
||||
@staticmethod
|
||||
@@ -501,6 +505,7 @@ class UpdateAPIThread(threading.Thread):
|
||||
try:
|
||||
char = eos.db.getCharacter(self.charID)
|
||||
|
||||
from service.esi import Esi
|
||||
sEsi = Esi.getInstance()
|
||||
sChar = Character.getInstance()
|
||||
ssoChar = sChar.getSsoCharacter(char.ID)
|
||||
|
||||
@@ -13,11 +13,9 @@ from service.const import EsiLoginMethod, EsiSsoMode
|
||||
from eos.saveddata.ssocharacter import SsoCharacter
|
||||
from service.esiAccess import APIException, GenericSsoError
|
||||
import gui.globalEvents as GE
|
||||
from gui.ssoLogin import SsoLogin
|
||||
from service.server import StoppableHTTPServer, AuthHandler
|
||||
from service.settings import EsiSettings
|
||||
from service.esiAccess import EsiAccess
|
||||
import gui.mainFrame
|
||||
|
||||
from requests import Session
|
||||
|
||||
@@ -140,6 +138,7 @@ class Esi(EsiAccess):
|
||||
self.fittings_deleted.add(fittingID)
|
||||
|
||||
def login(self):
|
||||
import gui.ssoLogin
|
||||
start_server = self.settings.get('loginMode') == EsiLoginMethod.SERVER and self.server_base.supports_auto_login
|
||||
with gui.ssoLogin.SsoLogin(self.server_base, start_server) as dlg:
|
||||
if dlg.ShowModal() == wx.ID_OK:
|
||||
|
||||
@@ -22,7 +22,6 @@ import datetime
|
||||
from time import time
|
||||
from weakref import WeakSet
|
||||
|
||||
import wx
|
||||
from logbook import Logger
|
||||
|
||||
import eos.db
|
||||
@@ -235,6 +234,7 @@ class Fit:
|
||||
@classmethod
|
||||
def getCommandProcessor(cls, fitID):
|
||||
if fitID not in cls.processors:
|
||||
import wx
|
||||
cls.processors[fitID] = wx.CommandProcessor(maxCommands=100)
|
||||
return cls.processors[fitID]
|
||||
|
||||
|
||||
@@ -23,8 +23,6 @@ import threading
|
||||
from collections import OrderedDict
|
||||
from itertools import chain
|
||||
|
||||
# noinspection PyPackageRequirements
|
||||
import wx
|
||||
from logbook import Logger
|
||||
from sqlalchemy.sql import or_
|
||||
|
||||
@@ -38,7 +36,12 @@ from service.settings import SettingsProvider
|
||||
from utils.cjk import isStringCjk
|
||||
|
||||
pyfalog = Logger(__name__)
|
||||
_t = wx.GetTranslation
|
||||
|
||||
|
||||
def _t(s):
|
||||
import wx
|
||||
return wx.GetTranslation(s)
|
||||
|
||||
|
||||
# Event which tells threads dependent on Market that it's initialized
|
||||
mktRdy = threading.Event()
|
||||
@@ -77,6 +80,7 @@ class ShipBrowserWorkerThread(threading.Thread):
|
||||
set_ = sMkt.getShipList(id_)
|
||||
cache[id_] = set_
|
||||
|
||||
import wx
|
||||
wx.CallAfter(callback, (id_, set_))
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
raise
|
||||
@@ -170,6 +174,7 @@ class SearchWorkerThread(threading.Thread):
|
||||
for item in all_results:
|
||||
if sMkt.getPublicityByItem(item):
|
||||
item_IDs.add(item.ID)
|
||||
import wx
|
||||
wx.CallAfter(callback, sorted(item_IDs))
|
||||
|
||||
def scheduleSearch(self, text, callback, filterName=None):
|
||||
@@ -268,7 +273,7 @@ class Market:
|
||||
self.les_grp = types_Group()
|
||||
self.les_grp.ID = -1
|
||||
self.les_grp.name = "Limited Issue Ships"
|
||||
self.les_grp.displayName = _t("Limited Issue Ships")
|
||||
self.les_grp.displayName = "Limited Issue Ships"
|
||||
self.les_grp.published = True
|
||||
ships = self.getCategory("Ship")
|
||||
self.les_grp.category = ships
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
from .efs import EfsPort
|
||||
from .port import Port
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
if name == "Port":
|
||||
from service.port.port import Port
|
||||
return Port
|
||||
raise AttributeError("module %r has no attribute %r" % (__name__, name))
|
||||
|
||||
12
service/port/active_state.py
Normal file
12
service/port/active_state.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# Module-state logic shared by port (EFT import) and gui (fit commands).
|
||||
# Uses gamedata (item type + attributes), no hardcoded effect names.
|
||||
|
||||
from eos.const import FittingModuleState
|
||||
from service.market import Market
|
||||
|
||||
|
||||
def activeStateLimit(itemIdentity):
|
||||
item = Market.getInstance().getItem(itemIdentity)
|
||||
if not item.isType("active") or item.getAttribute("activationBlocked", 0) > 0:
|
||||
return FittingModuleState.ONLINE
|
||||
return FittingModuleState.ACTIVE
|
||||
@@ -31,7 +31,6 @@ from eos.saveddata.fighter import Fighter
|
||||
from eos.saveddata.fit import Fit
|
||||
from eos.saveddata.module import Module
|
||||
from eos.saveddata.ship import Ship
|
||||
from gui.fitCommands.helpers import activeStateLimit
|
||||
from service.const import PortDnaOptions
|
||||
from service.fit import Fit as svcFit
|
||||
from service.market import Market
|
||||
@@ -80,6 +79,7 @@ def importDnaAlt(string, fitName=None):
|
||||
return processImportInfo(info, fitName, "*")
|
||||
|
||||
def processImportInfo(info, fitName, amountSeparator):
|
||||
from gui.fitCommands.helpers import activeStateLimit
|
||||
sMkt = Market.getInstance()
|
||||
f = Fit()
|
||||
try:
|
||||
|
||||
@@ -16,10 +16,6 @@ from eos.effectHandlerHelpers import HandledList
|
||||
from eos.db import gamedata_session, getCategory, getAttributeInfo, getGroup
|
||||
from eos.gamedata import Attribute, Effect, Group, Item, ItemEffect
|
||||
from eos.utils.spoolSupport import SpoolType, SpoolOptions
|
||||
from gui.fitCommands.calc.module.localAdd import CalcAddLocalModuleCommand
|
||||
from gui.fitCommands.calc.module.localRemove import CalcRemoveLocalModulesCommand
|
||||
from gui.fitCommands.calc.module.changeCharges import CalcChangeModuleChargesCommand
|
||||
from gui.fitCommands.helpers import ModuleInfo
|
||||
|
||||
|
||||
pyfalog = Logger(__name__)
|
||||
@@ -68,6 +64,9 @@ class EfsPort:
|
||||
|
||||
if propID is None:
|
||||
return None
|
||||
from gui.fitCommands.calc.module.localAdd import CalcAddLocalModuleCommand
|
||||
from gui.fitCommands.calc.module.localRemove import CalcRemoveLocalModulesCommand
|
||||
from gui.fitCommands.helpers import ModuleInfo
|
||||
cmd = CalcAddLocalModuleCommand(fitID, ModuleInfo(itemID=propID))
|
||||
cmd.Do()
|
||||
if cmd.needsGuiRecalc:
|
||||
@@ -137,6 +136,7 @@ class EfsPort:
|
||||
EfsPort.attrDirectMap(["reloadTime"], stats, mod)
|
||||
c = mod.charge
|
||||
if c:
|
||||
from gui.fitCommands.calc.module.changeCharges import CalcChangeModuleChargesCommand
|
||||
sFit.recalc(fit)
|
||||
CalcChangeModuleChargesCommand(
|
||||
fit.ID,
|
||||
|
||||
@@ -33,7 +33,6 @@ from eos.saveddata.fit import Fit
|
||||
from eos.saveddata.implant import Implant
|
||||
from eos.saveddata.module import Module
|
||||
from eos.saveddata.ship import Ship
|
||||
from gui.fitCommands.helpers import activeStateLimit
|
||||
from service.const import PortEftOptions
|
||||
from service.fit import Fit as svcFit
|
||||
from service.market import Market
|
||||
@@ -48,6 +47,8 @@ SLOT_ORDER = (FittingSlot.LOW, FittingSlot.MED, FittingSlot.HIGH, FittingSlot.RI
|
||||
OFFLINE_SUFFIX = '/offline'
|
||||
NAME_CHARS = r'[^,/\[\]]' # Characters which are allowed to be used in name
|
||||
|
||||
from service.port.active_state import activeStateLimit
|
||||
|
||||
|
||||
class MutationExportData:
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ from eos.saveddata.fighter import Fighter
|
||||
from eos.saveddata.fit import Fit
|
||||
from eos.saveddata.module import Module
|
||||
from eos.saveddata.ship import Ship
|
||||
from gui.fitCommands.helpers import activeStateLimit
|
||||
from service.fit import Fit as svcFit
|
||||
from service.market import Market
|
||||
|
||||
@@ -161,6 +160,7 @@ def exportESI(ofit, exportCharges, exportImplants, exportBoosters, callback):
|
||||
|
||||
|
||||
def importESI(string):
|
||||
from gui.fitCommands.helpers import activeStateLimit
|
||||
|
||||
sMkt = Market.getInstance()
|
||||
fitobj = Fit()
|
||||
|
||||
@@ -38,7 +38,6 @@ from service.port.eft import (
|
||||
isValidImplantImport, isValidBoosterImport)
|
||||
from service.port.esi import exportESI, importESI
|
||||
from service.port.multibuy import exportMultiBuy
|
||||
from service.port.shipstats import exportFitStats
|
||||
from service.port.xml import importXml, exportXml
|
||||
from service.port.muta import parseMutant, parseDynamicItemString, fetchDynamicItem
|
||||
|
||||
@@ -195,10 +194,14 @@ class Port:
|
||||
# TODO: catch the exception?
|
||||
# activeFit is reserved?, bufferStr is unicode? (assume only clipboard string?
|
||||
sFit = svcFit.getInstance()
|
||||
if sFit.character is None:
|
||||
from eos.saveddata.character import Character as saveddata_Character
|
||||
sFit.character = saveddata_Character.getAll5()
|
||||
importType, makesNewFits, importData = Port.importAuto(bufferStr, activeFit=activeFit)
|
||||
|
||||
if makesNewFits:
|
||||
for fit in importData:
|
||||
fits = [f for f in importData if f is not None]
|
||||
for fit in fits:
|
||||
fit.character = sFit.character
|
||||
fit.damagePattern = sFit.pattern
|
||||
fit.targetProfile = sFit.targetProfile
|
||||
@@ -208,6 +211,7 @@ class Port:
|
||||
useCharImplants = sFit.serviceFittingOptions["useCharacterImplantsByDefault"]
|
||||
fit.implantLocation = ImplantLocation.CHARACTER if useCharImplants else ImplantLocation.FIT
|
||||
db.save(fit)
|
||||
return importType, fits
|
||||
return importType, importData
|
||||
|
||||
@classmethod
|
||||
@@ -341,4 +345,5 @@ class Port:
|
||||
|
||||
@staticmethod
|
||||
def exportFitStats(fit, callback=None):
|
||||
return exportFitStats(fit, callback=callback)
|
||||
from service.port.shipstats import exportFitStats as _exportFitStats
|
||||
return _exportFitStats(fit, callback=callback)
|
||||
|
||||
@@ -32,7 +32,6 @@ from eos.saveddata.fighter import Fighter
|
||||
from eos.saveddata.fit import Fit
|
||||
from eos.saveddata.module import Module
|
||||
from eos.saveddata.ship import Ship
|
||||
from gui.fitCommands.helpers import activeStateLimit
|
||||
from service.fit import Fit as svcFit
|
||||
from service.market import Market
|
||||
from service.port.muta import renderMutantAttrs, parseMutantAttrs
|
||||
@@ -155,6 +154,7 @@ def _resolve_module(hardware, sMkt, b_localized):
|
||||
|
||||
|
||||
def importXml(text, progress):
|
||||
from gui.fitCommands.helpers import activeStateLimit
|
||||
from .port import Port
|
||||
sMkt = Market.getInstance()
|
||||
doc = xml.dom.minidom.parseString(text)
|
||||
|
||||
@@ -24,7 +24,6 @@ import timeit
|
||||
from itertools import chain
|
||||
|
||||
import math
|
||||
import wx
|
||||
from logbook import Logger
|
||||
|
||||
from eos import db
|
||||
@@ -51,6 +50,8 @@ class Price:
|
||||
sources = {}
|
||||
|
||||
def __init__(self):
|
||||
# Import market sources so they register; avoid at module level so server path never loads them
|
||||
from service.marketSources import evemarketdata, fuzzwork, cevemarket, evetycoon # noqa: F401
|
||||
# Start price fetcher
|
||||
self.priceWorkerThread = PriceWorkerThread()
|
||||
self.priceWorkerThread.daemon = True
|
||||
@@ -253,6 +254,7 @@ class PriceWorkerThread(threading.Thread):
|
||||
if len(requests) > 0:
|
||||
Price.fetchPrices(requests, fetchTimeout, validityOverride)
|
||||
|
||||
import wx
|
||||
wx.CallAfter(callback)
|
||||
queue.task_done()
|
||||
|
||||
@@ -273,7 +275,3 @@ class PriceWorkerThread(threading.Thread):
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
|
||||
|
||||
# Import market sources only to initialize price source modules, they register on their own
|
||||
from service.marketSources import evemarketdata, fuzzwork, cevemarket, evetycoon # noqa: E402
|
||||
|
||||
@@ -24,7 +24,6 @@ import urllib.error
|
||||
import urllib.parse
|
||||
import json
|
||||
from collections import namedtuple
|
||||
import wx
|
||||
|
||||
from logbook import Logger
|
||||
|
||||
@@ -582,6 +581,7 @@ class LocaleSettings:
|
||||
@classmethod
|
||||
def supported_languages(cls):
|
||||
"""Requires the application to be initialized, otherwise wx.Translation isn't set."""
|
||||
import wx
|
||||
pyfalog.info(f'using "{config.CATALOG}" to fetch languages, relatively base path "{os.getcwd()}"')
|
||||
return {x: wx.Locale.FindLanguageInfo(x) for x in wx.Translations.Get().GetAvailableTranslations(config.CATALOG)}
|
||||
|
||||
|
||||
46
tests/test_pyfa_sim.py
Normal file
46
tests/test_pyfa_sim.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.append(os.path.realpath(os.path.join(script_dir, "..")))
|
||||
|
||||
from scripts import pyfa_sim # noqa: E402
|
||||
|
||||
|
||||
ISHTAR_SPIDER_FIT = """[Ishtar, Spider]
|
||||
|
||||
Capacitor Power Relay II
|
||||
Drone Damage Amplifier II
|
||||
Explosive Armor Hardener II
|
||||
Multispectrum Energized Membrane II
|
||||
Reactive Armor Hardener
|
||||
Shadow Serpentis EM Armor Hardener
|
||||
|
||||
Cap Recharger II
|
||||
Omnidirectional Tracking Link II, Tracking Speed Script
|
||||
Medium Compact Pb-Acid Cap Battery
|
||||
Republic Fleet Large Cap Battery
|
||||
|
||||
Medium Remote Armor Repairer II
|
||||
Medium Remote Armor Repairer II
|
||||
Medium Remote Armor Repairer II
|
||||
Medium Remote Armor Repairer II
|
||||
|
||||
Medium Explosive Armor Reinforcer II
|
||||
Medium Thermal Armor Reinforcer II
|
||||
|
||||
|
||||
Valkyrie II x5
|
||||
Berserker II x5
|
||||
"""
|
||||
|
||||
|
||||
def test_ishtar_spider_remote_armor_reps():
|
||||
payload = {"fit": ISHTAR_SPIDER_FIT}
|
||||
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
|
||||
data = pyfa_sim.compute_stats(payload, tmp)
|
||||
armor_rps = data["remote_reps_outgoing"]["current"]["armor"]
|
||||
assert armor_rps is not None
|
||||
assert int(round(armor_rps)) == 171
|
||||
59
uv.lock
generated
59
uv.lock
generated
@@ -159,6 +159,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "contourpy"
|
||||
version = "1.3.3"
|
||||
@@ -369,6 +378,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kiwisolver"
|
||||
version = "1.4.9"
|
||||
@@ -593,6 +611,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.1"
|
||||
@@ -634,6 +661,11 @@ dependencies = [
|
||||
{ name = "wxpython" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "beautifulsoup4", specifier = "==4.12.2" },
|
||||
@@ -643,6 +675,7 @@ requires-dist = [
|
||||
{ name = "matplotlib", specifier = "==3.8.2" },
|
||||
{ name = "numpy", specifier = "==1.26.2" },
|
||||
{ name = "packaging", specifier = "==23.2" },
|
||||
{ name = "pytest", marker = "extra == 'dev'" },
|
||||
{ name = "python-dateutil", specifier = "==2.8.2" },
|
||||
{ name = "python-jose", specifier = "==3.3.0" },
|
||||
{ name = "pyyaml", specifier = "==6.0.1" },
|
||||
@@ -653,6 +686,16 @@ requires-dist = [
|
||||
{ name = "sqlalchemy", specifier = "==1.4.50" },
|
||||
{ name = "wxpython", specifier = "==4.2.1" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyparsing"
|
||||
@@ -663,6 +706,22 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/40/2614036cdd416452f5bf98ec037f38a1afb17f327cb8e6b652d4729e0af8/pyparsing-3.3.1-py3-none-any.whl", hash = "sha256:023b5e7e5520ad96642e2c6db4cb683d3970bd640cdf7115049a6e9c3682df82", size = 121793, upload-time = "2025-12-23T03:14:02.103Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.8.2"
|
||||
|
||||
Reference in New Issue
Block a user