Compare commits

..

8 Commits

34 changed files with 848 additions and 83 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
.venv
.git
dist
build
__pycache__
*.pyc
.pytest_cache
*.zip
*.spec
imgs
dist_assets

11
Dockerfile Normal file
View 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"]

View File

@@ -23,8 +23,38 @@ rm -rf build dist
echo "Building binary with PyInstaller..." echo "Building binary with PyInstaller..."
uv run pyinstaller pyfa.spec 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 ""
echo "Build complete! Binary is located at: dist/pyfa/pyfa.exe" echo "Building Docker image..."
echo "You can run it with: dist/pyfa/pyfa.exe" 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

View File

@@ -1,7 +1,6 @@
import os import os
import sys import sys
import yaml import yaml
import wx
from logbook import CRITICAL, DEBUG, ERROR, FingersCrossedHandler, INFO, Logger, NestedSetup, NullHandler, \ from logbook import CRITICAL, DEBUG, ERROR, FingersCrossedHandler, INFO, Logger, NestedSetup, NullHandler, \
StreamHandler, TimedRotatingFileHandler, WARNING StreamHandler, TimedRotatingFileHandler, WARNING
@@ -67,20 +66,34 @@ LOGLEVEL_MAP = {
CATALOG = 'lang' CATALOG = 'lang'
slotColourMapDark = { def get_slotColourMapDark():
FittingSlot.LOW: wx.Colour(44, 36, 19), # yellow = low slots 24/13 import wx
FittingSlot.MED: wx.Colour(28, 39, 51), # blue = mid slots 8.1/9.5 return {
FittingSlot.HIGH: wx.Colour(53, 31, 34), # red = high slots 6.5/11.5 FittingSlot.LOW: wx.Colour(44, 36, 19), # yellow = low slots 24/13
FittingSlot.RIG: '', FittingSlot.MED: wx.Colour(28, 39, 51), # blue = mid slots 8.1/9.5
FittingSlot.SUBSYSTEM: ''} FittingSlot.HIGH: wx.Colour(53, 31, 34), # red = high slots 6.5/11.5
errColorDark = wx.Colour(70, 20, 20) FittingSlot.RIG: '',
slotColourMap = { FittingSlot.SUBSYSTEM: ''}
FittingSlot.LOW: wx.Colour(250, 235, 204), # yellow = low slots
FittingSlot.MED: wx.Colour(188, 215, 241), # blue = mid slots
FittingSlot.HIGH: wx.Colour(235, 204, 209), # red = high slots def get_errColorDark():
FittingSlot.RIG: '', import wx
FittingSlot.SUBSYSTEM: ''} return wx.Colour(70, 20, 20)
errColor = wx.Colour(204, 51, 51)
def get_slotColourMap():
import wx
return {
FittingSlot.LOW: wx.Colour(250, 235, 204), # yellow = low slots
FittingSlot.MED: wx.Colour(188, 215, 241), # blue = mid slots
FittingSlot.HIGH: wx.Colour(235, 204, 209), # red = high slots
FittingSlot.RIG: '',
FittingSlot.SUBSYSTEM: ''}
def get_errColor():
import wx
return wx.Colour(204, 51, 51)
def getClientSecret(): def getClientSecret():

5
docker-compose.yml Normal file
View File

@@ -0,0 +1,5 @@
services:
pyfa-server:
image: docker.site.quack-lab.dev/pyfa-server:latest
ports:
- "9123:9123"

View File

@@ -121,7 +121,12 @@ def _get_cycle_time_s(mod):
return avg_time_ms / 1000.0 return avg_time_ms / 1000.0
def get_first_burnout_samples(fit, rack_slot, max_time_s, iterations): 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. 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, Returns a list of burnout times (seconds). If no burnout happens before max_time_s,
@@ -178,7 +183,7 @@ def get_first_burnout_samples(fit, rack_slot, max_time_s, iterations):
samples = [] samples = []
for _ in range(iterations): for i in range(iterations):
hp = list(base_hp) hp = list(base_hp)
dead = [hp_val <= 0 for hp_val in hp] dead = [hp_val <= 0 for hp_val in hp]
online_counts = dict(base_online_counts) online_counts = dict(base_online_counts)
@@ -280,6 +285,11 @@ def get_first_burnout_samples(fit, rack_slot, max_time_s, iterations):
samples.append(sample_time) 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 _burnout_samples_cache[cache_key] = samples
return samples return samples

View File

@@ -20,7 +20,8 @@
from eos.const import FittingSlot from eos.const import FittingSlot
from graphs.data.base import SmoothPointGetter from graphs.data.base import SmoothPointGetter
from .calc import get_first_burnout_samples, get_rack_heat_value import wx
from .calc import get_first_burnout_samples, get_rack_heat_value, has_burnout_samples
class _BaseTime2RackHeatGetter(SmoothPointGetter): class _BaseTime2RackHeatGetter(SmoothPointGetter):
@@ -57,9 +58,48 @@ class _BaseTime2BurnoutCdfGetter(SmoothPointGetter):
fit = src.item fit = src.item
# Fixed simulation horizon so CDF does not depend on view range # Fixed simulation horizon so CDF does not depend on view range
max_sim_time = self.graph._limiters["time"](src, tgt)[1] max_sim_time = self.graph._limiters["time"](src, tgt)[1]
samples = get_first_burnout_samples( iterations = miscParams.get("iterations", self._iterations)
fit=fit, rack_slot=self.rack_slot, max_time_s=max_sim_time, 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 = [] xs = []
ys = [] ys = []
if not samples: if not samples:
@@ -83,8 +123,18 @@ class _BaseTime2BurnoutCdfGetter(SmoothPointGetter):
def getPoint(self, x, miscParams, src, tgt): def getPoint(self, x, miscParams, src, tgt):
fit = src.item fit = src.item
max_sim_time = self.graph._limiters["time"](src, tgt)[1] 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( samples = get_first_burnout_samples(
fit=fit, rack_slot=self.rack_slot, max_time_s=max_sim_time, iterations=self._iterations fit=fit,
rack_slot=self.rack_slot,
max_time_s=max_sim_time,
iterations=iterations,
) )
if not samples: if not samples:
return 0.0 return 0.0

View File

@@ -57,12 +57,12 @@ class FitHeatGraph(FitGraph):
XDef(handle="time", unit="s", label=_t("Time"), mainInput=("time", "s")), XDef(handle="time", unit="s", label=_t("Time"), mainInput=("time", "s")),
] ]
yDefs = [ yDefs = [
YDef(handle="rackHeatHi", unit="%", label=_t("High rack heat")),
YDef(handle="rackHeatMed", unit="%", label=_t("Mid rack heat")),
YDef(handle="rackHeatLow", unit="%", label=_t("Low rack heat")),
YDef(handle="burnoutCdfHi", unit=None, label=_t("High rack first-burnout CDF")), YDef(handle="burnoutCdfHi", unit=None, label=_t("High rack first-burnout CDF")),
YDef(handle="burnoutCdfMed", unit=None, label=_t("Mid 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="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 = [ inputs = [
Input( Input(
@@ -72,7 +72,15 @@ class FitHeatGraph(FitGraph):
iconID=1392, iconID=1392,
defaultValue=300, defaultValue=300,
defaultRange=(0, 120), defaultRange=(0, 120),
) ),
Input(
handle="iterations",
unit=None,
label=_t("Iterations"),
iconID=1392,
defaultValue=10000,
defaultRange=(100, 50000),
),
] ]
srcExtraCols = () srcExtraCols = ()

View File

@@ -5,9 +5,6 @@ import wx
import gui.mainFrame import gui.mainFrame
from gui.contextMenu import ContextMenuSingle from gui.contextMenu import ContextMenuSingle
from gui.fitCommands import (
GuiConvertMutatedLocalModuleCommand, GuiRevertMutatedLocalModuleCommand,
GuiConvertMutatedLocalDroneCommand, GuiRevertMutatedLocalDroneCommand)
from service.fit import Fit from service.fit import Fit
_t = wx.GetTranslation _t = wx.GetTranslation
@@ -65,6 +62,8 @@ class ChangeItemMutation(ContextMenuSingle):
return sub return sub
def handleMenu(self, event): def handleMenu(self, event):
from gui.fitCommands import (
GuiConvertMutatedLocalModuleCommand, GuiConvertMutatedLocalDroneCommand)
mutaplasmid, item = self.eventIDs[event.Id] mutaplasmid, item = self.eventIDs[event.Id]
fitID = self.mainFrame.getActiveFit() fitID = self.mainFrame.getActiveFit()
fit = Fit.getInstance().getFit(fitID) fit = Fit.getInstance().getFit(fitID)
@@ -78,6 +77,8 @@ class ChangeItemMutation(ContextMenuSingle):
fitID=fitID, position=position, mutaplasmid=mutaplasmid)) fitID=fitID, position=position, mutaplasmid=mutaplasmid))
def activate(self, callingWindow, fullContext, mainItem, i): def activate(self, callingWindow, fullContext, mainItem, i):
from gui.fitCommands import (
GuiRevertMutatedLocalModuleCommand, GuiRevertMutatedLocalDroneCommand)
fitID = self.mainFrame.getActiveFit() fitID = self.mainFrame.getActiveFit()
fit = Fit.getInstance().getFit(fitID) fit = Fit.getInstance().getFit(fitID)
if mainItem in fit.modules: if mainItem in fit.modules:

View File

@@ -3,7 +3,7 @@ from logbook import Logger
import gui.builtinMarketBrowser.pfSearchBox as SBox import gui.builtinMarketBrowser.pfSearchBox as SBox
import gui.globalEvents as GE import gui.globalEvents as GE
from config import slotColourMap, slotColourMapDark from config import get_slotColourMap, get_slotColourMapDark
from eos.saveddata.module import Module from eos.saveddata.module import Module
from eos.const import FittingSlot from eos.const import FittingSlot
from gui.builtinMarketBrowser.events import ItemSelected, RECENTLY_USED_MODULES, CHARGES_FOR_FIT from gui.builtinMarketBrowser.events import ItemSelected, RECENTLY_USED_MODULES, CHARGES_FOR_FIT
@@ -412,7 +412,7 @@ class ItemView(Display):
def columnBackground(self, colItem, item): def columnBackground(self, colItem, item):
if self.sFit.serviceFittingOptions["colorFitBySlot"]: if self.sFit.serviceFittingOptions["colorFitBySlot"]:
colorMap = slotColourMapDark if isDark() else slotColourMap colorMap = get_slotColourMapDark() if isDark() else get_slotColourMap()
return colorMap.get(Module.calculateSlot(item)) or self.GetBackgroundColour() return colorMap.get(Module.calculateSlot(item)) or self.GetBackgroundColour()
else: else:
return self.GetBackgroundColour() return self.GetBackgroundColour()

View File

@@ -42,7 +42,7 @@ from gui.utils.staticHelpers import DragDropHelper
from gui.utils.dark import isDark from gui.utils.dark import isDark
from service.fit import Fit from service.fit import Fit
from service.market import Market from service.market import Market
from config import slotColourMap, slotColourMapDark, errColor, errColorDark from config import get_slotColourMap, get_slotColourMapDark, get_errColor, get_errColorDark
from gui.fitCommands.helpers import getSimilarModPositions from gui.fitCommands.helpers import getSimilarModPositions
pyfalog = Logger(__name__) pyfalog = Logger(__name__)
@@ -766,9 +766,9 @@ class FittingView(d.Display):
def slotColour(self, slot): def slotColour(self, slot):
if isDark(): if isDark():
return slotColourMapDark.get(slot) or self.GetBackgroundColour() return get_slotColourMapDark().get(slot) or self.GetBackgroundColour()
else: else:
return slotColourMap.get(slot) or self.GetBackgroundColour() return get_slotColourMap().get(slot) or self.GetBackgroundColour()
def refresh(self, stuff): def refresh(self, stuff):
""" """
@@ -812,7 +812,7 @@ class FittingView(d.Display):
hasRestrictionOverriden = not hasRestrictionOverriden hasRestrictionOverriden = not hasRestrictionOverriden
if slotMap[mod.slot] or hasRestrictionOverriden: # Color too many modules as red if slotMap[mod.slot] or hasRestrictionOverriden: # Color too many modules as red
self.SetItemBackgroundColour(i, errColorDark if isDark() else errColor) self.SetItemBackgroundColour(i, get_errColorDark() if isDark() else get_errColor())
elif sFit.serviceFittingOptions["colorFitBySlot"]: # Color by slot it enabled elif sFit.serviceFittingOptions["colorFitBySlot"]: # Color by slot it enabled
self.SetItemBackgroundColour(i, self.slotColour(mod.slot)) self.SetItemBackgroundColour(i, self.slotColour(mod.slot))

View File

@@ -346,20 +346,7 @@ class CargoInfo:
return makeReprStr(self, ['itemID', 'amount']) return makeReprStr(self, ['itemID', 'amount'])
def activeStateLimit(itemIdentity): from service.port.active_state import activeStateLimit
item = Market.getInstance().getItem(itemIdentity)
if {
'moduleBonusAssaultDamageControl', 'moduleBonusIndustrialInvulnerability',
'microJumpDrive', 'microJumpPortalDrive', 'emergencyHullEnergizer',
'cynosuralGeneration', 'jumpPortalGeneration', 'jumpPortalGenerationBO',
'cloneJumpAccepting', 'cloakingWarpSafe', 'cloakingPrototype', 'cloaking',
'massEntanglerEffect5', 'electronicAttributeModifyOnline', 'targetPassively',
'cargoScan', 'shipScan', 'surveyScan', 'targetSpectrumBreakerBonus',
'interdictionNullifierBonus', 'warpCoreStabilizerActive',
'industrialItemCompression'
}.intersection(item.effects):
return FittingModuleState.ONLINE
return FittingModuleState.ACTIVE
def droneStackLimit(fit, itemIdentity): def droneStackLimit(fit, itemIdentity):

View File

@@ -79,8 +79,20 @@ a = Analysis(['pyfa.py'],
win_private_assemblies=False, win_private_assemblies=False,
cipher=block_cipher) 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( exe = EXE(
pyz, pyz,
@@ -96,6 +108,19 @@ exe = EXE(
contents_directory='app', 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( coll = COLLECT(
exe, exe,
a.binaries, a.binaries,
@@ -106,6 +131,16 @@ coll = COLLECT(
name='pyfa', 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': if platform.system() == 'Darwin':
info_plist = { info_plist = {
'NSHighResolutionCapable': 'True', 'NSHighResolutionCapable': 'True',

5
pyfa_server.py Normal file
View 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)

View File

@@ -22,3 +22,6 @@ dependencies = [
"sqlalchemy==1.4.50", "sqlalchemy==1.4.50",
"wxpython==4.2.1", "wxpython==4.2.1",
] ]
[project.optional-dependencies]
dev = ["pytest"]

View File

@@ -57,4 +57,32 @@ curl -X POST \
rm "${ZIP}" 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}" echo "Release complete! ${ZIP} uploaded to ${TAG}"

14
requirements-server.txt Normal file
View 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

418
scripts/pyfa_sim.py Normal file
View File

@@ -0,0 +1,418 @@
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
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:
class SimulateHandler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/simulate":
self.send_response(404)
self.end_headers()
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.send_response(400)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write(("Invalid JSON: %s" % exc).encode("utf-8"))
return
if not isinstance(payload, dict):
self.send_response(400)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write(b"Top-level JSON must be an object")
return
try:
output = compute_stats(payload, savepath)
except ValueError as e:
self.send_response(400)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write(str(e).encode("utf-8"))
return
except Exception as e:
import traceback
tb = traceback.format_exc()
sys.stderr.write(tb)
sys.stderr.flush()
self.send_response(500)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write((str(e) + "\n\n" + tb).encode("utf-8"))
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)

View File

@@ -28,12 +28,8 @@ from xml.etree import ElementTree
from xml.dom import minidom from xml.dom import minidom
import gzip import gzip
# noinspection PyPackageRequirements
import wx
import config import config
import eos.db import eos.db
from service.esi import Esi
from eos.saveddata.implant import Implant as es_Implant from eos.saveddata.implant import Implant as es_Implant
from eos.saveddata.character import Character as es_Character, Skill 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 from eos.saveddata.fighter import Fighter as es_Fighter
pyfalog = Logger(__name__) pyfalog = Logger(__name__)
_t = wx.GetTranslation
def _t(s):
import wx
return wx.GetTranslation(s)
class CharacterImportThread(threading.Thread): class CharacterImportThread(threading.Thread):
@@ -97,6 +98,7 @@ class CharacterImportThread(threading.Thread):
pyfalog.error(e) pyfalog.error(e)
continue continue
import wx
wx.CallAfter(self.callback) wx.CallAfter(self.callback)
def stop(self): def stop(self):
@@ -132,6 +134,7 @@ class SkillBackupThread(threading.Thread):
with open(path, mode='w', encoding='utf-8') as backupFile: with open(path, mode='w', encoding='utf-8') as backupFile:
backupFile.write(backupData) backupFile.write(backupData)
import wx
wx.CallAfter(self.callback) wx.CallAfter(self.callback)
def stop(self): def stop(self):
@@ -386,6 +389,7 @@ class Character:
def apiFetchCallback(self, guiCallback, e=None): def apiFetchCallback(self, guiCallback, e=None):
eos.db.commit() eos.db.commit()
import wx
wx.CallAfter(guiCallback, e) wx.CallAfter(guiCallback, e)
@staticmethod @staticmethod
@@ -501,6 +505,7 @@ class UpdateAPIThread(threading.Thread):
try: try:
char = eos.db.getCharacter(self.charID) char = eos.db.getCharacter(self.charID)
from service.esi import Esi
sEsi = Esi.getInstance() sEsi = Esi.getInstance()
sChar = Character.getInstance() sChar = Character.getInstance()
ssoChar = sChar.getSsoCharacter(char.ID) ssoChar = sChar.getSsoCharacter(char.ID)

View File

@@ -13,11 +13,9 @@ from service.const import EsiLoginMethod, EsiSsoMode
from eos.saveddata.ssocharacter import SsoCharacter from eos.saveddata.ssocharacter import SsoCharacter
from service.esiAccess import APIException, GenericSsoError from service.esiAccess import APIException, GenericSsoError
import gui.globalEvents as GE import gui.globalEvents as GE
from gui.ssoLogin import SsoLogin
from service.server import StoppableHTTPServer, AuthHandler from service.server import StoppableHTTPServer, AuthHandler
from service.settings import EsiSettings from service.settings import EsiSettings
from service.esiAccess import EsiAccess from service.esiAccess import EsiAccess
import gui.mainFrame
from requests import Session from requests import Session
@@ -140,6 +138,7 @@ class Esi(EsiAccess):
self.fittings_deleted.add(fittingID) self.fittings_deleted.add(fittingID)
def login(self): def login(self):
import gui.ssoLogin
start_server = self.settings.get('loginMode') == EsiLoginMethod.SERVER and self.server_base.supports_auto_login 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: with gui.ssoLogin.SsoLogin(self.server_base, start_server) as dlg:
if dlg.ShowModal() == wx.ID_OK: if dlg.ShowModal() == wx.ID_OK:

View File

@@ -22,7 +22,6 @@ import datetime
from time import time from time import time
from weakref import WeakSet from weakref import WeakSet
import wx
from logbook import Logger from logbook import Logger
import eos.db import eos.db
@@ -235,6 +234,7 @@ class Fit:
@classmethod @classmethod
def getCommandProcessor(cls, fitID): def getCommandProcessor(cls, fitID):
if fitID not in cls.processors: if fitID not in cls.processors:
import wx
cls.processors[fitID] = wx.CommandProcessor(maxCommands=100) cls.processors[fitID] = wx.CommandProcessor(maxCommands=100)
return cls.processors[fitID] return cls.processors[fitID]

View File

@@ -23,8 +23,6 @@ import threading
from collections import OrderedDict from collections import OrderedDict
from itertools import chain from itertools import chain
# noinspection PyPackageRequirements
import wx
from logbook import Logger from logbook import Logger
from sqlalchemy.sql import or_ from sqlalchemy.sql import or_
@@ -38,7 +36,12 @@ from service.settings import SettingsProvider
from utils.cjk import isStringCjk from utils.cjk import isStringCjk
pyfalog = Logger(__name__) 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 # Event which tells threads dependent on Market that it's initialized
mktRdy = threading.Event() mktRdy = threading.Event()
@@ -77,6 +80,7 @@ class ShipBrowserWorkerThread(threading.Thread):
set_ = sMkt.getShipList(id_) set_ = sMkt.getShipList(id_)
cache[id_] = set_ cache[id_] = set_
import wx
wx.CallAfter(callback, (id_, set_)) wx.CallAfter(callback, (id_, set_))
except (KeyboardInterrupt, SystemExit): except (KeyboardInterrupt, SystemExit):
raise raise
@@ -170,6 +174,7 @@ class SearchWorkerThread(threading.Thread):
for item in all_results: for item in all_results:
if sMkt.getPublicityByItem(item): if sMkt.getPublicityByItem(item):
item_IDs.add(item.ID) item_IDs.add(item.ID)
import wx
wx.CallAfter(callback, sorted(item_IDs)) wx.CallAfter(callback, sorted(item_IDs))
def scheduleSearch(self, text, callback, filterName=None): def scheduleSearch(self, text, callback, filterName=None):
@@ -268,7 +273,7 @@ class Market:
self.les_grp = types_Group() self.les_grp = types_Group()
self.les_grp.ID = -1 self.les_grp.ID = -1
self.les_grp.name = "Limited Issue Ships" 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 self.les_grp.published = True
ships = self.getCategory("Ship") ships = self.getCategory("Ship")
self.les_grp.category = ships self.les_grp.category = ships

View File

@@ -1,2 +1,8 @@
from .efs import EfsPort 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))

View 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

View File

@@ -31,7 +31,6 @@ from eos.saveddata.fighter import Fighter
from eos.saveddata.fit import Fit from eos.saveddata.fit import Fit
from eos.saveddata.module import Module from eos.saveddata.module import Module
from eos.saveddata.ship import Ship from eos.saveddata.ship import Ship
from gui.fitCommands.helpers import activeStateLimit
from service.const import PortDnaOptions from service.const import PortDnaOptions
from service.fit import Fit as svcFit from service.fit import Fit as svcFit
from service.market import Market from service.market import Market
@@ -80,6 +79,7 @@ def importDnaAlt(string, fitName=None):
return processImportInfo(info, fitName, "*") return processImportInfo(info, fitName, "*")
def processImportInfo(info, fitName, amountSeparator): def processImportInfo(info, fitName, amountSeparator):
from gui.fitCommands.helpers import activeStateLimit
sMkt = Market.getInstance() sMkt = Market.getInstance()
f = Fit() f = Fit()
try: try:

View File

@@ -16,10 +16,6 @@ from eos.effectHandlerHelpers import HandledList
from eos.db import gamedata_session, getCategory, getAttributeInfo, getGroup from eos.db import gamedata_session, getCategory, getAttributeInfo, getGroup
from eos.gamedata import Attribute, Effect, Group, Item, ItemEffect from eos.gamedata import Attribute, Effect, Group, Item, ItemEffect
from eos.utils.spoolSupport import SpoolType, SpoolOptions 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__) pyfalog = Logger(__name__)
@@ -68,6 +64,9 @@ class EfsPort:
if propID is None: if propID is None:
return 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 = CalcAddLocalModuleCommand(fitID, ModuleInfo(itemID=propID))
cmd.Do() cmd.Do()
if cmd.needsGuiRecalc: if cmd.needsGuiRecalc:
@@ -137,6 +136,7 @@ class EfsPort:
EfsPort.attrDirectMap(["reloadTime"], stats, mod) EfsPort.attrDirectMap(["reloadTime"], stats, mod)
c = mod.charge c = mod.charge
if c: if c:
from gui.fitCommands.calc.module.changeCharges import CalcChangeModuleChargesCommand
sFit.recalc(fit) sFit.recalc(fit)
CalcChangeModuleChargesCommand( CalcChangeModuleChargesCommand(
fit.ID, fit.ID,

View File

@@ -33,7 +33,6 @@ from eos.saveddata.fit import Fit
from eos.saveddata.implant import Implant from eos.saveddata.implant import Implant
from eos.saveddata.module import Module from eos.saveddata.module import Module
from eos.saveddata.ship import Ship from eos.saveddata.ship import Ship
from gui.fitCommands.helpers import activeStateLimit
from service.const import PortEftOptions from service.const import PortEftOptions
from service.fit import Fit as svcFit from service.fit import Fit as svcFit
from service.market import Market from service.market import Market
@@ -48,6 +47,8 @@ SLOT_ORDER = (FittingSlot.LOW, FittingSlot.MED, FittingSlot.HIGH, FittingSlot.RI
OFFLINE_SUFFIX = '/offline' OFFLINE_SUFFIX = '/offline'
NAME_CHARS = r'[^,/\[\]]' # Characters which are allowed to be used in name NAME_CHARS = r'[^,/\[\]]' # Characters which are allowed to be used in name
from service.port.active_state import activeStateLimit
class MutationExportData: class MutationExportData:

View File

@@ -31,7 +31,6 @@ from eos.saveddata.fighter import Fighter
from eos.saveddata.fit import Fit from eos.saveddata.fit import Fit
from eos.saveddata.module import Module from eos.saveddata.module import Module
from eos.saveddata.ship import Ship from eos.saveddata.ship import Ship
from gui.fitCommands.helpers import activeStateLimit
from service.fit import Fit as svcFit from service.fit import Fit as svcFit
from service.market import Market from service.market import Market
@@ -161,6 +160,7 @@ def exportESI(ofit, exportCharges, exportImplants, exportBoosters, callback):
def importESI(string): def importESI(string):
from gui.fitCommands.helpers import activeStateLimit
sMkt = Market.getInstance() sMkt = Market.getInstance()
fitobj = Fit() fitobj = Fit()

View File

@@ -38,7 +38,6 @@ from service.port.eft import (
isValidImplantImport, isValidBoosterImport) isValidImplantImport, isValidBoosterImport)
from service.port.esi import exportESI, importESI from service.port.esi import exportESI, importESI
from service.port.multibuy import exportMultiBuy from service.port.multibuy import exportMultiBuy
from service.port.shipstats import exportFitStats
from service.port.xml import importXml, exportXml from service.port.xml import importXml, exportXml
from service.port.muta import parseMutant, parseDynamicItemString, fetchDynamicItem from service.port.muta import parseMutant, parseDynamicItemString, fetchDynamicItem
@@ -195,10 +194,14 @@ class Port:
# TODO: catch the exception? # TODO: catch the exception?
# activeFit is reserved?, bufferStr is unicode? (assume only clipboard string? # activeFit is reserved?, bufferStr is unicode? (assume only clipboard string?
sFit = svcFit.getInstance() 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) importType, makesNewFits, importData = Port.importAuto(bufferStr, activeFit=activeFit)
if makesNewFits: 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.character = sFit.character
fit.damagePattern = sFit.pattern fit.damagePattern = sFit.pattern
fit.targetProfile = sFit.targetProfile fit.targetProfile = sFit.targetProfile
@@ -208,6 +211,7 @@ class Port:
useCharImplants = sFit.serviceFittingOptions["useCharacterImplantsByDefault"] useCharImplants = sFit.serviceFittingOptions["useCharacterImplantsByDefault"]
fit.implantLocation = ImplantLocation.CHARACTER if useCharImplants else ImplantLocation.FIT fit.implantLocation = ImplantLocation.CHARACTER if useCharImplants else ImplantLocation.FIT
db.save(fit) db.save(fit)
return importType, fits
return importType, importData return importType, importData
@classmethod @classmethod
@@ -341,4 +345,5 @@ class Port:
@staticmethod @staticmethod
def exportFitStats(fit, callback=None): def exportFitStats(fit, callback=None):
return exportFitStats(fit, callback=callback) from service.port.shipstats import exportFitStats as _exportFitStats
return _exportFitStats(fit, callback=callback)

View File

@@ -32,7 +32,6 @@ from eos.saveddata.fighter import Fighter
from eos.saveddata.fit import Fit from eos.saveddata.fit import Fit
from eos.saveddata.module import Module from eos.saveddata.module import Module
from eos.saveddata.ship import Ship from eos.saveddata.ship import Ship
from gui.fitCommands.helpers import activeStateLimit
from service.fit import Fit as svcFit from service.fit import Fit as svcFit
from service.market import Market from service.market import Market
from service.port.muta import renderMutantAttrs, parseMutantAttrs from service.port.muta import renderMutantAttrs, parseMutantAttrs
@@ -155,6 +154,7 @@ def _resolve_module(hardware, sMkt, b_localized):
def importXml(text, progress): def importXml(text, progress):
from gui.fitCommands.helpers import activeStateLimit
from .port import Port from .port import Port
sMkt = Market.getInstance() sMkt = Market.getInstance()
doc = xml.dom.minidom.parseString(text) doc = xml.dom.minidom.parseString(text)

View File

@@ -24,7 +24,6 @@ import timeit
from itertools import chain from itertools import chain
import math import math
import wx
from logbook import Logger from logbook import Logger
from eos import db from eos import db
@@ -51,6 +50,8 @@ class Price:
sources = {} sources = {}
def __init__(self): 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 # Start price fetcher
self.priceWorkerThread = PriceWorkerThread() self.priceWorkerThread = PriceWorkerThread()
self.priceWorkerThread.daemon = True self.priceWorkerThread.daemon = True
@@ -253,6 +254,7 @@ class PriceWorkerThread(threading.Thread):
if len(requests) > 0: if len(requests) > 0:
Price.fetchPrices(requests, fetchTimeout, validityOverride) Price.fetchPrices(requests, fetchTimeout, validityOverride)
import wx
wx.CallAfter(callback) wx.CallAfter(callback)
queue.task_done() queue.task_done()
@@ -273,7 +275,3 @@ class PriceWorkerThread(threading.Thread):
def stop(self): def stop(self):
self.running = False 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

View File

@@ -24,7 +24,6 @@ import urllib.error
import urllib.parse import urllib.parse
import json import json
from collections import namedtuple from collections import namedtuple
import wx
from logbook import Logger from logbook import Logger
@@ -582,6 +581,7 @@ class LocaleSettings:
@classmethod @classmethod
def supported_languages(cls): def supported_languages(cls):
"""Requires the application to be initialized, otherwise wx.Translation isn't set.""" """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()}"') 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)} return {x: wx.Locale.FindLanguageInfo(x) for x in wx.Translations.Get().GetAvailableTranslations(config.CATALOG)}

46
tests/test_pyfa_sim.py Normal file
View 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
View File

@@ -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" }, { 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]] [[package]]
name = "contourpy" name = "contourpy"
version = "1.3.3" 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" }, { 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]] [[package]]
name = "kiwisolver" name = "kiwisolver"
version = "1.4.9" 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" }, { 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]] [[package]]
name = "pyasn1" name = "pyasn1"
version = "0.6.1" version = "0.6.1"
@@ -634,6 +661,11 @@ dependencies = [
{ name = "wxpython" }, { name = "wxpython" },
] ]
[package.optional-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "beautifulsoup4", specifier = "==4.12.2" }, { name = "beautifulsoup4", specifier = "==4.12.2" },
@@ -643,6 +675,7 @@ requires-dist = [
{ name = "matplotlib", specifier = "==3.8.2" }, { name = "matplotlib", specifier = "==3.8.2" },
{ name = "numpy", specifier = "==1.26.2" }, { name = "numpy", specifier = "==1.26.2" },
{ name = "packaging", specifier = "==23.2" }, { name = "packaging", specifier = "==23.2" },
{ name = "pytest", marker = "extra == 'dev'" },
{ name = "python-dateutil", specifier = "==2.8.2" }, { name = "python-dateutil", specifier = "==2.8.2" },
{ name = "python-jose", specifier = "==3.3.0" }, { name = "python-jose", specifier = "==3.3.0" },
{ name = "pyyaml", specifier = "==6.0.1" }, { name = "pyyaml", specifier = "==6.0.1" },
@@ -653,6 +686,16 @@ requires-dist = [
{ name = "sqlalchemy", specifier = "==1.4.50" }, { name = "sqlalchemy", specifier = "==1.4.50" },
{ name = "wxpython", specifier = "==4.2.1" }, { 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]] [[package]]
name = "pyparsing" 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" }, { 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]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.8.2" version = "2.8.2"