tradedangerous 12.7.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- py.typed +1 -0
- trade.py +49 -0
- tradedangerous/__init__.py +43 -0
- tradedangerous/cache.py +1381 -0
- tradedangerous/cli.py +136 -0
- tradedangerous/commands/TEMPLATE.py +74 -0
- tradedangerous/commands/__init__.py +244 -0
- tradedangerous/commands/buildcache_cmd.py +102 -0
- tradedangerous/commands/buy_cmd.py +427 -0
- tradedangerous/commands/commandenv.py +372 -0
- tradedangerous/commands/exceptions.py +94 -0
- tradedangerous/commands/export_cmd.py +150 -0
- tradedangerous/commands/import_cmd.py +222 -0
- tradedangerous/commands/local_cmd.py +243 -0
- tradedangerous/commands/market_cmd.py +207 -0
- tradedangerous/commands/nav_cmd.py +252 -0
- tradedangerous/commands/olddata_cmd.py +270 -0
- tradedangerous/commands/parsing.py +221 -0
- tradedangerous/commands/rares_cmd.py +298 -0
- tradedangerous/commands/run_cmd.py +1521 -0
- tradedangerous/commands/sell_cmd.py +262 -0
- tradedangerous/commands/shipvendor_cmd.py +60 -0
- tradedangerous/commands/station_cmd.py +68 -0
- tradedangerous/commands/trade_cmd.py +181 -0
- tradedangerous/commands/update_cmd.py +67 -0
- tradedangerous/corrections.py +55 -0
- tradedangerous/csvexport.py +234 -0
- tradedangerous/db/__init__.py +27 -0
- tradedangerous/db/adapter.py +192 -0
- tradedangerous/db/config.py +107 -0
- tradedangerous/db/engine.py +259 -0
- tradedangerous/db/lifecycle.py +332 -0
- tradedangerous/db/locks.py +208 -0
- tradedangerous/db/orm_models.py +500 -0
- tradedangerous/db/paths.py +113 -0
- tradedangerous/db/utils.py +661 -0
- tradedangerous/edscupdate.py +565 -0
- tradedangerous/edsmupdate.py +474 -0
- tradedangerous/formatting.py +210 -0
- tradedangerous/fs.py +156 -0
- tradedangerous/gui.py +1146 -0
- tradedangerous/mapping.py +133 -0
- tradedangerous/mfd/__init__.py +103 -0
- tradedangerous/mfd/saitek/__init__.py +3 -0
- tradedangerous/mfd/saitek/directoutput.py +678 -0
- tradedangerous/mfd/saitek/x52pro.py +195 -0
- tradedangerous/misc/checkpricebounds.py +287 -0
- tradedangerous/misc/clipboard.py +49 -0
- tradedangerous/misc/coord64.py +83 -0
- tradedangerous/misc/csvdialect.py +57 -0
- tradedangerous/misc/derp-sentinel.py +35 -0
- tradedangerous/misc/diff-system-csvs.py +159 -0
- tradedangerous/misc/eddb.py +81 -0
- tradedangerous/misc/eddn.py +349 -0
- tradedangerous/misc/edsc.py +437 -0
- tradedangerous/misc/edsm.py +121 -0
- tradedangerous/misc/importeddbstats.py +54 -0
- tradedangerous/misc/prices-json-exp.py +179 -0
- tradedangerous/misc/progress.py +194 -0
- tradedangerous/plugins/__init__.py +249 -0
- tradedangerous/plugins/edcd_plug.py +371 -0
- tradedangerous/plugins/eddblink_plug.py +861 -0
- tradedangerous/plugins/edmc_batch_plug.py +133 -0
- tradedangerous/plugins/spansh_plug.py +2647 -0
- tradedangerous/prices.py +211 -0
- tradedangerous/submit-distances.py +422 -0
- tradedangerous/templates/Added.csv +37 -0
- tradedangerous/templates/Category.csv +17 -0
- tradedangerous/templates/RareItem.csv +143 -0
- tradedangerous/templates/TradeDangerous.sql +338 -0
- tradedangerous/tools.py +40 -0
- tradedangerous/tradecalc.py +1302 -0
- tradedangerous/tradedb.py +2320 -0
- tradedangerous/tradeenv.py +313 -0
- tradedangerous/tradeenv.pyi +109 -0
- tradedangerous/tradeexcept.py +131 -0
- tradedangerous/tradeorm.py +183 -0
- tradedangerous/transfers.py +192 -0
- tradedangerous/utils.py +243 -0
- tradedangerous/version.py +16 -0
- tradedangerous-12.7.6.dist-info/METADATA +106 -0
- tradedangerous-12.7.6.dist-info/RECORD +87 -0
- tradedangerous-12.7.6.dist-info/WHEEL +5 -0
- tradedangerous-12.7.6.dist-info/entry_points.txt +3 -0
- tradedangerous-12.7.6.dist-info/licenses/LICENSE +373 -0
- tradedangerous-12.7.6.dist-info/top_level.txt +2 -0
- tradegui.py +24 -0
|
@@ -0,0 +1,1302 @@
|
|
|
1
|
+
# --------------------------------------------------------------------
|
|
2
|
+
# Copyright (C) Oliver 'kfsone' Smith 2014 <oliver@kfs.org>:
|
|
3
|
+
# Copyright (C) Bernd 'Gazelle' Gollesch 2016, 2017
|
|
4
|
+
# Copyright (C) Stefan 'Tromador' Morrell 2025
|
|
5
|
+
# Copyright (C) Jonathan 'eyeonus' Jones 2018 - 2025
|
|
6
|
+
#
|
|
7
|
+
# You are free to use, redistribute, or even print and eat a copy of
|
|
8
|
+
# this software so long as you include this copyright notice.
|
|
9
|
+
# I guarantee there is at least one bug neither of us knew about.
|
|
10
|
+
# --------------------------------------------------------------------
|
|
11
|
+
# TradeDangerous :: Modules :: Profit Calculator
|
|
12
|
+
#
|
|
13
|
+
# This module has been refactored from legacy SQLite raw SQL access
|
|
14
|
+
# to use SQLAlchemy ORM sessions. It retains the same API surface
|
|
15
|
+
# expected by other modules (mimicking legacy behaviour), but
|
|
16
|
+
# now queries ORM models instead of sqlite3 cursors.
|
|
17
|
+
|
|
18
|
+
"""
|
|
19
|
+
TradeCalc provides a class for calculating trade loads, hops or
|
|
20
|
+
routes, along with some amount of state.
|
|
21
|
+
|
|
22
|
+
The intent was for it to carry a larger amount of state but
|
|
23
|
+
much of that got moved into TradeEnv, so right now TradeCalc
|
|
24
|
+
looks a little odd.
|
|
25
|
+
|
|
26
|
+
Significant Functions:
|
|
27
|
+
|
|
28
|
+
Tradecalc.getBestHops
|
|
29
|
+
Finds the best "next hop"s given a set of routes.
|
|
30
|
+
|
|
31
|
+
Classes:
|
|
32
|
+
|
|
33
|
+
TradeCalc
|
|
34
|
+
Encapsulates the calculation functions and item-trades,
|
|
35
|
+
|
|
36
|
+
Route
|
|
37
|
+
Describes a sequence of trade hops.
|
|
38
|
+
|
|
39
|
+
TradeLoad
|
|
40
|
+
Describe a cargo load to be carried on a hop.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
######################################################################
|
|
44
|
+
# Imports
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
from collections import defaultdict
|
|
49
|
+
from typing import NamedTuple
|
|
50
|
+
import locale
|
|
51
|
+
import os
|
|
52
|
+
import re
|
|
53
|
+
import sys
|
|
54
|
+
import time
|
|
55
|
+
import typing
|
|
56
|
+
|
|
57
|
+
from sqlalchemy import text as _sa_text
|
|
58
|
+
|
|
59
|
+
from .tradedb import Item
|
|
60
|
+
from .tradeexcept import SimpleAbort, TradeException
|
|
61
|
+
# Legacy-style helpers (these remain expected by other modules)
|
|
62
|
+
from .tradedb import Trade, Destination, describeAge
|
|
63
|
+
|
|
64
|
+
# ORM models (SQLAlchemy)
|
|
65
|
+
from tradedangerous.db.utils import parse_ts # replaces legacy strftime('%s', modified)
|
|
66
|
+
|
|
67
|
+
if typing.TYPE_CHECKING:
|
|
68
|
+
from collections.abc import Callable, Iterable
|
|
69
|
+
from tradedangerous import TradeDB, TradeEnv
|
|
70
|
+
|
|
71
|
+
locale.setlocale(locale.LC_ALL, '')
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
######################################################################
|
|
75
|
+
# Exceptions
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class UserAbortedRun(SimpleAbort):
|
|
79
|
+
"""
|
|
80
|
+
UserAbortedRunError is raised when a user hits ctrl-c during a
|
|
81
|
+
route calculation after useful work has been done that we may
|
|
82
|
+
still want to report to the user.
|
|
83
|
+
|
|
84
|
+
If there is no useful work, then we should probably allow the
|
|
85
|
+
ctrl-c to just fall thru.
|
|
86
|
+
"""
|
|
87
|
+
def __str__(self) -> str:
|
|
88
|
+
return f"*** Ctrl+C: User aborted run: {super().__str__()}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class BadTimestampError(TradeException):
|
|
92
|
+
"""
|
|
93
|
+
Raised when a StationItem row has an invalid or unparsable timestamp.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(self, tdb, stationID, itemID, modified):
|
|
97
|
+
self.station = tdb.stationByID[stationID]
|
|
98
|
+
self.item = tdb.itemByID[itemID]
|
|
99
|
+
self.modified = modified
|
|
100
|
+
|
|
101
|
+
def __str__(self):
|
|
102
|
+
return (
|
|
103
|
+
"Error loading price data from the local db:\n"
|
|
104
|
+
f"{self.station.name()} has a StationItem entry for "
|
|
105
|
+
f"\"{self.item.name()}\" with an invalid modified timestamp: "
|
|
106
|
+
f"'{self.modified}'."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class NoHopsError(TradeException):
|
|
111
|
+
"""Raised when no possible hops can be generated within constraints."""
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
######################################################################
|
|
116
|
+
# TradeLoad (namedtuple wrapper)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TradeLoad(NamedTuple):
|
|
120
|
+
"""
|
|
121
|
+
Describes the manifest of items to be exchanged in a trade.
|
|
122
|
+
|
|
123
|
+
Attributes:
|
|
124
|
+
items : list of (item, qty) tuples tracking the load
|
|
125
|
+
gainCr : predicted total gain in credits
|
|
126
|
+
costCr : how much this load was bought for
|
|
127
|
+
units : total number of units across all items
|
|
128
|
+
"""
|
|
129
|
+
items: list[tuple[Item, int]]
|
|
130
|
+
gainCr: int
|
|
131
|
+
costCr: int
|
|
132
|
+
units: int
|
|
133
|
+
|
|
134
|
+
def __bool__(self):
|
|
135
|
+
return self.units > 0
|
|
136
|
+
|
|
137
|
+
def __lt__(self, rhs):
|
|
138
|
+
if self.gainCr < rhs.gainCr:
|
|
139
|
+
return True
|
|
140
|
+
if rhs.gainCr < self.gainCr:
|
|
141
|
+
return False
|
|
142
|
+
if self.units < rhs.units:
|
|
143
|
+
return True
|
|
144
|
+
if rhs.units < self.units:
|
|
145
|
+
return False
|
|
146
|
+
return self.costCr < rhs.costCr
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def gpt(self):
|
|
150
|
+
"""Gain per ton (credits per unit)."""
|
|
151
|
+
return self.gainCr / self.units if self.units else 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# A convenience empty load (used as sentinel in fitting algorithms).
|
|
155
|
+
emptyLoad = TradeLoad((), 0, 0, 0)
|
|
156
|
+
|
|
157
|
+
######################################################################
|
|
158
|
+
# Classes
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class Route:
|
|
162
|
+
"""
|
|
163
|
+
Describes a series of hops where a TradeLoad is picked up at
|
|
164
|
+
one station, the player travels via 0 or more hyperspace
|
|
165
|
+
jumps and docks at a second station where they unload.
|
|
166
|
+
|
|
167
|
+
Example:
|
|
168
|
+
10 Algae + 5 Hydrogen at Station A,
|
|
169
|
+
jump to System2, jump to System3,
|
|
170
|
+
dock at Station B, sell everything, buy gold,
|
|
171
|
+
jump to System4 and sell everything at Station X.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
__slots__ = ("route", "hops", "startCr", "gainCr", "jumps", "score")
|
|
175
|
+
|
|
176
|
+
def __init__(self, stations, hops, startCr, gainCr, jumps, score):
|
|
177
|
+
assert stations
|
|
178
|
+
self.route = stations
|
|
179
|
+
self.hops = hops
|
|
180
|
+
self.startCr = startCr
|
|
181
|
+
self.gainCr = gainCr
|
|
182
|
+
self.jumps = jumps
|
|
183
|
+
self.score = score
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def firstStation(self):
|
|
187
|
+
return self.route[0]
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def firstSystem(self):
|
|
191
|
+
return self.route[0].system
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def lastStation(self):
|
|
195
|
+
return self.route[-1]
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def lastSystem(self):
|
|
199
|
+
return self.route[-1].system
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def avggpt(self):
|
|
203
|
+
if self.hops:
|
|
204
|
+
return sum(hop.gpt for hop in self.hops) // len(self.hops)
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def gpt(self):
|
|
209
|
+
if self.hops:
|
|
210
|
+
return (
|
|
211
|
+
sum(hop.gainCr for hop in self.hops)
|
|
212
|
+
// sum(hop.units for hop in self.hops)
|
|
213
|
+
)
|
|
214
|
+
return 0
|
|
215
|
+
|
|
216
|
+
def plus(self, dst, hop, jumps, score):
|
|
217
|
+
return Route(
|
|
218
|
+
self.route + (dst,),
|
|
219
|
+
self.hops + (hop,),
|
|
220
|
+
self.startCr,
|
|
221
|
+
self.gainCr + hop[1],
|
|
222
|
+
self.jumps + (jumps,),
|
|
223
|
+
self.score + score,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def __lt__(self, rhs):
|
|
227
|
+
if self.score == rhs.score:
|
|
228
|
+
return len(self.jumps) < len(rhs.jumps)
|
|
229
|
+
return self.score > rhs.score
|
|
230
|
+
|
|
231
|
+
def __eq__(self, rhs):
|
|
232
|
+
return self.score == rhs.score and len(self.jumps) == len(rhs.jumps)
|
|
233
|
+
|
|
234
|
+
def debug_text(self, colorize: Callable[[str, str], str]) -> str:
|
|
235
|
+
lhs = colorize("cyan", self.firstStation.name())
|
|
236
|
+
rhs = colorize("blue", self.lastStation.name())
|
|
237
|
+
return f"{lhs} (#{self.firstStation.ID}) -> {rhs} (#{self.lastStation.ID})"
|
|
238
|
+
|
|
239
|
+
def text(self, colorize: Callable[[str, str], str]) -> str:
|
|
240
|
+
lhs = colorize("cyan", self.firstStation.name())
|
|
241
|
+
rhs = colorize("blue", self.lastStation.name())
|
|
242
|
+
return f"{lhs} -> {rhs}"
|
|
243
|
+
|
|
244
|
+
def detail(self, tdenv):
|
|
245
|
+
"""
|
|
246
|
+
Legacy helper used by run_cmd.render().
|
|
247
|
+
Renders this route using cmdenv/tdenv display settings.
|
|
248
|
+
|
|
249
|
+
Honors TD_NO_COLOR and tdenv.noColor to disable ANSI color codes.
|
|
250
|
+
"""
|
|
251
|
+
# TD_NO_COLOR disables color if set to anything truthy (except 0/false/no/off/"")
|
|
252
|
+
env_val = os.getenv("TD_NO_COLOR", "")
|
|
253
|
+
env_no_color = bool(env_val) and env_val.strip().lower() not in ("0", "", "false", "no", "off")
|
|
254
|
+
|
|
255
|
+
no_color = env_no_color or bool(getattr(tdenv, "noColor", False))
|
|
256
|
+
|
|
257
|
+
if no_color:
|
|
258
|
+
def colorize(_c, s):
|
|
259
|
+
return s
|
|
260
|
+
else:
|
|
261
|
+
_cz = getattr(tdenv, "colorize", None)
|
|
262
|
+
if callable(_cz):
|
|
263
|
+
def colorize(c, s):
|
|
264
|
+
return _cz(c, s)
|
|
265
|
+
else:
|
|
266
|
+
def colorize(_c, s):
|
|
267
|
+
return s
|
|
268
|
+
|
|
269
|
+
detail = int(getattr(tdenv, "detail", 0) or 0)
|
|
270
|
+
goalSystem = getattr(tdenv, "goalSystem", None)
|
|
271
|
+
credits = int(getattr(tdenv, "credits", 0) or 0)
|
|
272
|
+
|
|
273
|
+
return self.render(colorize, tdenv, detail=detail, goalSystem=goalSystem, credits=credits)
|
|
274
|
+
|
|
275
|
+
def render(self, colorize, tdenv, detail=0, goalSystem=None, credits=0):
|
|
276
|
+
"""
|
|
277
|
+
Produce a formatted string representation of this route.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
def genSubValues():
|
|
281
|
+
for hop in self.hops:
|
|
282
|
+
for tr, _ in hop[0]:
|
|
283
|
+
yield len(tr.name(detail))
|
|
284
|
+
|
|
285
|
+
longestNameLen = max(genSubValues(), default=0)
|
|
286
|
+
|
|
287
|
+
text = self.text(colorize)
|
|
288
|
+
if detail >= 1:
|
|
289
|
+
text += f" (score: {self.score:f})"
|
|
290
|
+
text += "\n"
|
|
291
|
+
|
|
292
|
+
jumpsFmt = " Jump {jumps}\n"
|
|
293
|
+
cruiseFmt = " Supercruise to {stn}\n"
|
|
294
|
+
distFmt = None
|
|
295
|
+
|
|
296
|
+
if detail > 1:
|
|
297
|
+
if detail > 2:
|
|
298
|
+
text += self.summary() + "\n"
|
|
299
|
+
if tdenv.maxJumpsPer > 1:
|
|
300
|
+
distFmt = " Direct: {dist:0.2f}ly, Trip: {trav:0.2f}ly\n"
|
|
301
|
+
|
|
302
|
+
hopFmt = (
|
|
303
|
+
" Load from " + colorize("cyan", "{station}") + ":\n{purchases}"
|
|
304
|
+
)
|
|
305
|
+
hopStepFmt = (
|
|
306
|
+
colorize("lightYellow", " {qty:>4}")
|
|
307
|
+
+ " x "
|
|
308
|
+
+ colorize("yellow", "{item:<{longestName}} ")
|
|
309
|
+
+ "{eacost:>8n}cr vs {easell:>8n}cr, "
|
|
310
|
+
"{age}"
|
|
311
|
+
)
|
|
312
|
+
if detail > 2:
|
|
313
|
+
hopStepFmt += ", total: {ttlcost:>10n}cr"
|
|
314
|
+
hopStepFmt += "\n"
|
|
315
|
+
|
|
316
|
+
if not tdenv.summary:
|
|
317
|
+
dockFmt = (
|
|
318
|
+
" Unload at "
|
|
319
|
+
+ colorize("lightBlue", "{station}")
|
|
320
|
+
+ " => Gain {gain:n}cr "
|
|
321
|
+
"({tongain:n}cr/ton) => {credits:n}cr\n"
|
|
322
|
+
)
|
|
323
|
+
else:
|
|
324
|
+
jumpsFmt = re.sub(" ", " ", jumpsFmt, re.M)
|
|
325
|
+
cruiseFmt = re.sub(" ", " ", cruiseFmt, re.M)
|
|
326
|
+
if distFmt:
|
|
327
|
+
distFmt = re.sub(" ", " ", distFmt, re.M)
|
|
328
|
+
hopFmt = "\n" + hopFmt
|
|
329
|
+
dockFmt = " Expect to gain {gain:n}cr ({tongain:n}cr/ton)\n"
|
|
330
|
+
|
|
331
|
+
footer = " " + "-" * 76 + "\n"
|
|
332
|
+
endFmt = (
|
|
333
|
+
"Finish at "
|
|
334
|
+
+ colorize("blue", "{station} ")
|
|
335
|
+
+ "gaining {gain:n}cr ({tongain:n}cr/ton) "
|
|
336
|
+
"=> est {credits:n}cr total\n"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
elif detail:
|
|
340
|
+
hopFmt = " Load from " + colorize("cyan", "{station}") + ":{purchases}\n"
|
|
341
|
+
hopStepFmt = (
|
|
342
|
+
colorize("lightYellow", " {qty}")
|
|
343
|
+
+ " x "
|
|
344
|
+
+ colorize("yellow", "{item}")
|
|
345
|
+
+ " (@{eacost}cr),"
|
|
346
|
+
)
|
|
347
|
+
footer = None
|
|
348
|
+
dockFmt = " Dock at " + colorize("lightBlue", "{station}\n")
|
|
349
|
+
endFmt = (
|
|
350
|
+
" Finish "
|
|
351
|
+
+ colorize("blue", "{station} ")
|
|
352
|
+
+ "+ {gain:n}cr ({tongain:n}cr/ton)"
|
|
353
|
+
"=> {credits:n}cr\n"
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
else:
|
|
357
|
+
hopFmt = colorize("cyan", " {station}:{purchases}\n")
|
|
358
|
+
hopStepFmt = (
|
|
359
|
+
colorize("lightYellow", " {qty}")
|
|
360
|
+
+ " x "
|
|
361
|
+
+ colorize("yellow", "{item}")
|
|
362
|
+
+ ","
|
|
363
|
+
)
|
|
364
|
+
footer = None
|
|
365
|
+
dockFmt = None
|
|
366
|
+
endFmt = colorize("blue", " {station}") + " +{gain:n}cr ({tongain:n}/ton)"
|
|
367
|
+
|
|
368
|
+
def jumpList(jumps):
|
|
369
|
+
text, last = "", None
|
|
370
|
+
travelled = 0.0
|
|
371
|
+
for jump in jumps:
|
|
372
|
+
if last:
|
|
373
|
+
dist = last.distanceTo(jump)
|
|
374
|
+
if dist:
|
|
375
|
+
if tdenv.detail:
|
|
376
|
+
text += f", {dist:.2f}ly -> "
|
|
377
|
+
else:
|
|
378
|
+
text += " -> "
|
|
379
|
+
else:
|
|
380
|
+
text += " >>> "
|
|
381
|
+
travelled += dist
|
|
382
|
+
text += jump.name()
|
|
383
|
+
last = jump
|
|
384
|
+
return travelled, text
|
|
385
|
+
|
|
386
|
+
if detail > 1:
|
|
387
|
+
|
|
388
|
+
def decorateStation(station):
|
|
389
|
+
details = []
|
|
390
|
+
if station.lsFromStar:
|
|
391
|
+
details.append(station.distFromStar(True))
|
|
392
|
+
if station.blackMarket != "?":
|
|
393
|
+
details.append("BMk:" + station.blackMarket)
|
|
394
|
+
if station.maxPadSize != "?":
|
|
395
|
+
details.append("Pad:" + station.maxPadSize)
|
|
396
|
+
if station.planetary != "?":
|
|
397
|
+
details.append("Plt:" + station.planetary)
|
|
398
|
+
if station.fleet != "?":
|
|
399
|
+
details.append("Flc:" + station.fleet)
|
|
400
|
+
if station.odyssey != "?":
|
|
401
|
+
details.append("Ody:" + station.odyssey)
|
|
402
|
+
if station.shipyard != "?":
|
|
403
|
+
details.append("Shp:" + station.shipyard)
|
|
404
|
+
if station.outfitting != "?":
|
|
405
|
+
details.append("Out:" + station.outfitting)
|
|
406
|
+
if station.refuel != "?":
|
|
407
|
+
details.append("Ref:" + station.refuel)
|
|
408
|
+
details = "{} ({})".format(
|
|
409
|
+
station.name(), ", ".join(details or ["no details"])
|
|
410
|
+
)
|
|
411
|
+
return details
|
|
412
|
+
|
|
413
|
+
else:
|
|
414
|
+
|
|
415
|
+
def decorateStation(station):
|
|
416
|
+
return station.name()
|
|
417
|
+
|
|
418
|
+
if detail and goalSystem:
|
|
419
|
+
|
|
420
|
+
def goalDistance(station):
|
|
421
|
+
return (
|
|
422
|
+
f" [Distance to {goalSystem.name()}: "
|
|
423
|
+
f"{station.system.distanceTo(goalSystem):.2f} ly]\n"
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
else:
|
|
427
|
+
|
|
428
|
+
def goalDistance(station):
|
|
429
|
+
return ""
|
|
430
|
+
|
|
431
|
+
gainCr = 0
|
|
432
|
+
for i, hop in enumerate(self.hops):
|
|
433
|
+
hopGainCr, hopTonnes = hop[1], 0
|
|
434
|
+
purchases = ""
|
|
435
|
+
for (trade, qty) in sorted(
|
|
436
|
+
hop[0],
|
|
437
|
+
key=lambda tradeOpt: tradeOpt[1] * tradeOpt[0].gainCr,
|
|
438
|
+
reverse=True,
|
|
439
|
+
):
|
|
440
|
+
if abs(trade.srcAge - trade.dstAge) <= (30 * 60):
|
|
441
|
+
age = max(trade.srcAge, trade.dstAge)
|
|
442
|
+
age = describeAge(age)
|
|
443
|
+
else:
|
|
444
|
+
srcAge = describeAge(trade.srcAge)
|
|
445
|
+
dstAge = describeAge(trade.dstAge)
|
|
446
|
+
age = f"{srcAge} vs {dstAge}"
|
|
447
|
+
|
|
448
|
+
purchases += hopStepFmt.format(
|
|
449
|
+
qty=qty,
|
|
450
|
+
item=trade.name(detail),
|
|
451
|
+
eacost=trade.costCr,
|
|
452
|
+
easell=trade.costCr + trade.gainCr,
|
|
453
|
+
ttlcost=trade.costCr * qty,
|
|
454
|
+
longestName=longestNameLen,
|
|
455
|
+
age=age,
|
|
456
|
+
)
|
|
457
|
+
hopTonnes += qty
|
|
458
|
+
|
|
459
|
+
text += goalDistance(self.route[i])
|
|
460
|
+
text += hopFmt.format(station=decorateStation(self.route[i]), purchases=purchases)
|
|
461
|
+
|
|
462
|
+
if tdenv.showJumps and jumpsFmt and self.jumps[i]:
|
|
463
|
+
startStn = self.route[i]
|
|
464
|
+
endStn = self.route[i + 1]
|
|
465
|
+
if startStn.system is not endStn.system:
|
|
466
|
+
fmt = jumpsFmt
|
|
467
|
+
travelled, jumps = jumpList(self.jumps[i])
|
|
468
|
+
else:
|
|
469
|
+
fmt = cruiseFmt
|
|
470
|
+
travelled, jumps = 0.0, f"{startStn.name()} >>> {endStn.name()}"
|
|
471
|
+
|
|
472
|
+
text += fmt.format(
|
|
473
|
+
jumps=jumps,
|
|
474
|
+
gain=hopGainCr,
|
|
475
|
+
tongain=hopGainCr / hopTonnes,
|
|
476
|
+
credits=credits + gainCr + hopGainCr,
|
|
477
|
+
stn=self.route[i + 1].dbname,
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
if travelled and distFmt and len(self.jumps[i]) > 2:
|
|
481
|
+
text += distFmt.format(
|
|
482
|
+
dist=startStn.system.distanceTo(endStn.system), trav=travelled
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
if dockFmt:
|
|
486
|
+
stn = self.route[i + 1]
|
|
487
|
+
text += dockFmt.format(
|
|
488
|
+
station=decorateStation(stn),
|
|
489
|
+
gain=hopGainCr,
|
|
490
|
+
tongain=hopGainCr / hopTonnes,
|
|
491
|
+
credits=credits + gainCr + hopGainCr,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
gainCr += hopGainCr
|
|
495
|
+
|
|
496
|
+
lastStation = self.lastStation
|
|
497
|
+
if lastStation.system is not goalSystem:
|
|
498
|
+
text += goalDistance(lastStation)
|
|
499
|
+
text += footer or ""
|
|
500
|
+
text += endFmt.format(
|
|
501
|
+
station=decorateStation(lastStation),
|
|
502
|
+
gain=gainCr,
|
|
503
|
+
credits=credits + gainCr,
|
|
504
|
+
tongain=self.gpt,
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
return text
|
|
508
|
+
|
|
509
|
+
def summary(self):
|
|
510
|
+
credits, hops, jumps = self.startCr, self.hops, self.jumps
|
|
511
|
+
ttlGainCr = sum(hop[1] for hop in hops)
|
|
512
|
+
numJumps = sum(
|
|
513
|
+
len(hopJumps) - 1 for hopJumps in jumps if hopJumps
|
|
514
|
+
)
|
|
515
|
+
return (
|
|
516
|
+
"Start CR: {start:10n}\n"
|
|
517
|
+
"Hops : {hops:10n}\n"
|
|
518
|
+
"Jumps : {jumps:10n}\n"
|
|
519
|
+
"Gain CR : {gain:10n}\n"
|
|
520
|
+
"Gain/Hop: {hopgain:10n}\n"
|
|
521
|
+
"Final CR: {final:10n}\n".format(
|
|
522
|
+
start=credits,
|
|
523
|
+
hops=len(hops),
|
|
524
|
+
jumps=numJumps,
|
|
525
|
+
gain=ttlGainCr,
|
|
526
|
+
hopgain=ttlGainCr // len(hops),
|
|
527
|
+
final=credits + ttlGainCr,
|
|
528
|
+
)
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def sigmoid(x: float | int) -> float:
|
|
533
|
+
# [eyeonus]:
|
|
534
|
+
# (Keep in mind all this ignores values of x<0.)
|
|
535
|
+
# The sigmoid: (1-(25(x-1))/(1+abs(25(x-1))))/4
|
|
536
|
+
# ranges between 0.5 and 0 with a drop around x=1,
|
|
537
|
+
# which makes it great for giving a boost to distances < 1Kls.
|
|
538
|
+
#
|
|
539
|
+
# The sigmoid: (-1-(50(x-4))/(1+abs(50(x-4))))/4
|
|
540
|
+
# ranges between 0 and -0.5 with a drop around x=4,
|
|
541
|
+
# making it great for penalizing distances > 4Kls.
|
|
542
|
+
#
|
|
543
|
+
# The curve: (-1+1/(x+1)^((x+1)/4))/2
|
|
544
|
+
# ranges between 0 and -0.5 in a smooth arc,
|
|
545
|
+
# which will be used for making distances
|
|
546
|
+
# closer to 4Kls get a slightly higher penalty
|
|
547
|
+
# then distances closer to 1Kls.
|
|
548
|
+
#
|
|
549
|
+
# Adding the three together creates a doubly-kinked curve
|
|
550
|
+
# that ranges from ~0.5 to -1.0, with drops around x=1 and x=4,
|
|
551
|
+
# which closely matches ksfone's intention without going into
|
|
552
|
+
# negative numbers and causing problems when we add it to
|
|
553
|
+
# the multiplier variable. ( 1 + -1 = 0 )
|
|
554
|
+
#
|
|
555
|
+
# You can see a graph of the formula here:
|
|
556
|
+
# https://goo.gl/sn1PqQ
|
|
557
|
+
# NOTE: The black curve is at a penalty of 0%,
|
|
558
|
+
# the red curve at a penalty of 100%, with intermediates at
|
|
559
|
+
# 25%, 50%, and 75%.
|
|
560
|
+
# The other colored lines show the penalty curves individually
|
|
561
|
+
# and the teal composite of all three.
|
|
562
|
+
return x / (1 + abs(x))
|
|
563
|
+
|
|
564
|
+
class TradeCalc:
|
|
565
|
+
"""
|
|
566
|
+
Container for accessing trade calculations with common properties.
|
|
567
|
+
"""
|
|
568
|
+
|
|
569
|
+
def __init__(
|
|
570
|
+
self,
|
|
571
|
+
tdb: TradeDB,
|
|
572
|
+
tdenv: TradeEnv | None = None,
|
|
573
|
+
fit: callable | None = None,
|
|
574
|
+
items: list[Item] | None = None,
|
|
575
|
+
restrict_station_ids: Iterable[int] = None
|
|
576
|
+
):
|
|
577
|
+
"""
|
|
578
|
+
Constructs the TradeCalc object and loads sell/buy data.
|
|
579
|
+
"""
|
|
580
|
+
|
|
581
|
+
if not tdenv:
|
|
582
|
+
tdenv = tdb.tdenv
|
|
583
|
+
self.tdb = tdb
|
|
584
|
+
self.tdenv = tdenv
|
|
585
|
+
self.aborted: bool = False
|
|
586
|
+
self.defaultFit = fit or self.simpleFit
|
|
587
|
+
if "BRUTE_FIT" in os.environ:
|
|
588
|
+
self.defaultFit = self.bruteForceFit
|
|
589
|
+
|
|
590
|
+
self._restrict_station_ids = tuple(restrict_station_ids) if restrict_station_ids else None
|
|
591
|
+
|
|
592
|
+
minSupply = self.tdenv.supply or 0
|
|
593
|
+
minDemand = self.tdenv.demand or 0
|
|
594
|
+
|
|
595
|
+
# ---------- Build optional item filter (avoidItems + specific items) ----------
|
|
596
|
+
itemFilter = None
|
|
597
|
+
if tdenv.avoidItems or items:
|
|
598
|
+
avoidItemIDs = {item.ID for item in tdenv.avoidItems}
|
|
599
|
+
loadItems = items or tdb.itemByID.values()
|
|
600
|
+
loadIDs = []
|
|
601
|
+
for item in loadItems:
|
|
602
|
+
ID = item if isinstance(item, int) else item.ID
|
|
603
|
+
if ID not in avoidItemIDs:
|
|
604
|
+
loadIDs.append(ID)
|
|
605
|
+
if not loadIDs:
|
|
606
|
+
raise TradeException("No items to load.")
|
|
607
|
+
itemFilter = loadIDs
|
|
608
|
+
|
|
609
|
+
# ---------- Maps and counters ----------
|
|
610
|
+
demand = self.stationsBuying = defaultdict(list)
|
|
611
|
+
supply = self.stationsSelling = defaultdict(list)
|
|
612
|
+
dmdCount = supCount = 0
|
|
613
|
+
nowS = int(time.time())
|
|
614
|
+
|
|
615
|
+
# ---------- Progress heartbeat (only with --progress) ----------
|
|
616
|
+
showProgress = bool(getattr(tdenv, "progress", False))
|
|
617
|
+
hb_interval = 0.5
|
|
618
|
+
last_hb = 0.0
|
|
619
|
+
spinner = ("|", "/", "-", "\\")
|
|
620
|
+
spin_i = 0
|
|
621
|
+
rows_seen = 0
|
|
622
|
+
|
|
623
|
+
def heartbeat():
|
|
624
|
+
nonlocal last_hb, spin_i
|
|
625
|
+
now = time.time()
|
|
626
|
+
if (now - last_hb) < hb_interval:
|
|
627
|
+
return
|
|
628
|
+
last_hb = now
|
|
629
|
+
s = spinner[spin_i]
|
|
630
|
+
spin_i = (spin_i + 1) % len(spinner)
|
|
631
|
+
sys.stdout.write(
|
|
632
|
+
f"\r\x1b[2K{s} Scanning market data… rows {rows_seen:n} kept: buys {dmdCount:n}, sells {supCount:n}"
|
|
633
|
+
)
|
|
634
|
+
sys.stdout.flush()
|
|
635
|
+
|
|
636
|
+
# ---------- Core/Engine path (NO Session; NO ORM entities) ----------
|
|
637
|
+
columns = (
|
|
638
|
+
"station_id, item_id, "
|
|
639
|
+
"CASE WHEN demand_units >= :mindemand THEN demand_price ELSE 0 END AS fx_demand_price, demand_units, demand_level, "
|
|
640
|
+
"CASE WHEN supply_units >= :minsupply THEN supply_price ELSE 0 END AS fx_supply_price, supply_units, supply_level, "
|
|
641
|
+
"modified"
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
where_clauses = ["fx_demand_price > 0 OR fx_supply_price > 0"]
|
|
645
|
+
params = {"mindemand": minDemand or 1, "minsupply": minSupply or 1}
|
|
646
|
+
|
|
647
|
+
# Age cutoff (if provided in env)
|
|
648
|
+
if tdenv.maxAge:
|
|
649
|
+
cutoffS = nowS - (tdenv.maxAge * 60 * 60 * 24)
|
|
650
|
+
if tdb.engine.dialect.name == "sqlite":
|
|
651
|
+
where_clauses.append("CAST(strftime('%s', modified) AS INTEGER) >= :cutoffS")
|
|
652
|
+
else:
|
|
653
|
+
where_clauses.append("UNIX_TIMESTAMP(modified) >= :cutoffS")
|
|
654
|
+
params["cutoffS"] = cutoffS
|
|
655
|
+
|
|
656
|
+
# Optional item filter — enumerate placeholders (SQLAlchemy text() won't expand tuples)
|
|
657
|
+
if itemFilter:
|
|
658
|
+
iid_placeholders = []
|
|
659
|
+
for i, iid in enumerate(itemFilter):
|
|
660
|
+
key = f"iid{i}"
|
|
661
|
+
params[key] = int(iid)
|
|
662
|
+
iid_placeholders.append(":" + key)
|
|
663
|
+
where_clauses.append(f"item_id IN ({', '.join(iid_placeholders)})")
|
|
664
|
+
|
|
665
|
+
# Optional station restriction for ultra-light preload
|
|
666
|
+
if self._restrict_station_ids:
|
|
667
|
+
sid_placeholders = []
|
|
668
|
+
for i, sid in enumerate(self._restrict_station_ids):
|
|
669
|
+
key = f"sid{i}"
|
|
670
|
+
params[key] = int(sid)
|
|
671
|
+
sid_placeholders.append(":" + key)
|
|
672
|
+
where_clauses.append(f"station_id IN ({', '.join(sid_placeholders)})")
|
|
673
|
+
|
|
674
|
+
sql = f"SELECT {columns} FROM StationItem"
|
|
675
|
+
if where_clauses:
|
|
676
|
+
sql += " WHERE " + " AND ".join(where_clauses)
|
|
677
|
+
|
|
678
|
+
tdenv.DEBUG1("query: {}", sql)
|
|
679
|
+
tdenv.DEBUG1("params: {}", params)
|
|
680
|
+
with tdb.engine.connect() as conn:
|
|
681
|
+
result = conn.execute(_sa_text(sql), params)
|
|
682
|
+
|
|
683
|
+
for (
|
|
684
|
+
stnID,
|
|
685
|
+
itmID,
|
|
686
|
+
d_price, d_units, d_level,
|
|
687
|
+
s_price, s_units, s_level,
|
|
688
|
+
modified,
|
|
689
|
+
) in result:
|
|
690
|
+
rows_seen += 1
|
|
691
|
+
# Compute legacy ageS from modified using parse_ts(.)
|
|
692
|
+
mod_dt = parse_ts(modified)
|
|
693
|
+
if not mod_dt:
|
|
694
|
+
if showProgress:
|
|
695
|
+
sys.stdout.write("\n")
|
|
696
|
+
sys.stdout.flush()
|
|
697
|
+
raise BadTimestampError(tdb, stnID, itmID, modified)
|
|
698
|
+
ageS = nowS - int(mod_dt.timestamp())
|
|
699
|
+
|
|
700
|
+
# Buying map (demand side)
|
|
701
|
+
if d_price and d_price > 0:
|
|
702
|
+
demand[stnID].append((itmID, d_price, d_units or 0, d_level, ageS))
|
|
703
|
+
dmdCount += 1
|
|
704
|
+
|
|
705
|
+
# Selling map (supply side)
|
|
706
|
+
if s_price and s_price > 0:
|
|
707
|
+
supply[stnID].append((itmID, s_price, s_units, s_level, ageS))
|
|
708
|
+
supCount += 1
|
|
709
|
+
|
|
710
|
+
# Calling 'time.time()' is *very* expensive, so only do it every so many rows
|
|
711
|
+
# but the == 1 means that we'll do it for the very first row too.
|
|
712
|
+
if showProgress and (rows_seen & 15) == 1: # fast modulo 16
|
|
713
|
+
heartbeat()
|
|
714
|
+
|
|
715
|
+
if showProgress:
|
|
716
|
+
sys.stdout.write("\n")
|
|
717
|
+
sys.stdout.flush()
|
|
718
|
+
|
|
719
|
+
self._buying_ids = set(self.stationsBuying.keys())
|
|
720
|
+
self._selling_ids = set(self.stationsSelling.keys())
|
|
721
|
+
self.eligible_station_ids = self._buying_ids & self._selling_ids
|
|
722
|
+
|
|
723
|
+
self._dst_buy_map = {}
|
|
724
|
+
|
|
725
|
+
tdenv.DEBUG1(
|
|
726
|
+
"Preload used Engine/Core (no ORM identity map). Rows kept: buys={}, sells={}",
|
|
727
|
+
dmdCount, supCount,
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
# ------------------------------------------------------------------
|
|
732
|
+
# Cargo fitting algorithms
|
|
733
|
+
# ------------------------------------------------------------------
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def bruteForceFit(self, items, credits, capacity, maxUnits): # pylint: disable=redefined-builtin
|
|
737
|
+
"""
|
|
738
|
+
Brute-force generation of all possible combinations of items.
|
|
739
|
+
This is provided to make it easy to validate the results of future
|
|
740
|
+
variants or optimizations of the fit algorithm.
|
|
741
|
+
"""
|
|
742
|
+
|
|
743
|
+
def _fitCombos(offset, cr, cap, level=1):
|
|
744
|
+
if cr <= 0 or cap <= 0:
|
|
745
|
+
return emptyLoad
|
|
746
|
+
while True:
|
|
747
|
+
if offset >= len(items):
|
|
748
|
+
return emptyLoad
|
|
749
|
+
item = items[offset]
|
|
750
|
+
offset += 1
|
|
751
|
+
|
|
752
|
+
itemCost = item.costCr
|
|
753
|
+
maxQty = min(maxUnits, cap, cr // itemCost)
|
|
754
|
+
|
|
755
|
+
if item.supply < maxQty and item.supply > 0:
|
|
756
|
+
maxQty = min(maxQty, item.supply)
|
|
757
|
+
|
|
758
|
+
if maxQty > 0:
|
|
759
|
+
break
|
|
760
|
+
|
|
761
|
+
bestLoad = _fitCombos(offset, cr, cap, level + 1)
|
|
762
|
+
itemGain = item.gainCr
|
|
763
|
+
|
|
764
|
+
for qty in range(1, maxQty + 1):
|
|
765
|
+
loadGain, loadCost = itemGain * qty, itemCost * qty
|
|
766
|
+
load = TradeLoad(((item, qty),), loadGain, loadCost, qty)
|
|
767
|
+
subLoad = _fitCombos(offset, cr - loadCost, cap - qty, level + 1)
|
|
768
|
+
combGain = loadGain + subLoad.gainCr
|
|
769
|
+
if combGain < bestLoad.gainCr:
|
|
770
|
+
continue
|
|
771
|
+
combCost = loadCost + subLoad.costCr
|
|
772
|
+
combUnits = qty + subLoad.units
|
|
773
|
+
if combGain == bestLoad.gainCr:
|
|
774
|
+
if combUnits > bestLoad.units:
|
|
775
|
+
continue
|
|
776
|
+
if combUnits == bestLoad.units:
|
|
777
|
+
if combCost >= bestLoad.costCr:
|
|
778
|
+
continue
|
|
779
|
+
bestLoad = TradeLoad(
|
|
780
|
+
load.items + subLoad.items, combGain, combCost, combUnits
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
return bestLoad
|
|
784
|
+
|
|
785
|
+
return _fitCombos(0, credits, capacity)
|
|
786
|
+
|
|
787
|
+
def fastFit(self, items, credits, capacity, maxUnits): # pylint: disable=redefined-builtin
|
|
788
|
+
"""
|
|
789
|
+
Best load calculator using a recursive knapsack-like
|
|
790
|
+
algorithm to find multiple loads and return the best.
|
|
791
|
+
[eyeonus] Left in for the masochists, as this becomes
|
|
792
|
+
horribly slow at stations with many items for sale.
|
|
793
|
+
As in iooks-like-the-program-has-frozen slow.
|
|
794
|
+
"""
|
|
795
|
+
|
|
796
|
+
def _fitCombos(offset, cr, cap):
|
|
797
|
+
"""
|
|
798
|
+
Starting from offset, consider a scenario where we
|
|
799
|
+
would purchase the maximum number of each item
|
|
800
|
+
given the cr+cap limitations. Then, assuming that
|
|
801
|
+
load, solve for the remaining cr+cap from the next
|
|
802
|
+
value of offset.
|
|
803
|
+
|
|
804
|
+
The "best fit" is not always the most profitable,
|
|
805
|
+
so we yield all the results and leave the caller
|
|
806
|
+
to determine which is actually most profitable.
|
|
807
|
+
"""
|
|
808
|
+
bestGainCr = -1
|
|
809
|
+
bestItem = None
|
|
810
|
+
bestQty = 0
|
|
811
|
+
bestCostCr = 0
|
|
812
|
+
bestSub = None
|
|
813
|
+
|
|
814
|
+
qtyCeil = min(maxUnits, cap)
|
|
815
|
+
|
|
816
|
+
for iNo in range(offset, len(items)):
|
|
817
|
+
item = items[iNo]
|
|
818
|
+
itemCostCr = item.costCr
|
|
819
|
+
maxQty = min(qtyCeil, cr // itemCostCr)
|
|
820
|
+
|
|
821
|
+
if maxQty <= 0:
|
|
822
|
+
continue
|
|
823
|
+
|
|
824
|
+
supply = item.supply
|
|
825
|
+
if supply <= 0:
|
|
826
|
+
continue
|
|
827
|
+
|
|
828
|
+
maxQty = min(maxQty, supply)
|
|
829
|
+
|
|
830
|
+
itemGainCr = item.gainCr
|
|
831
|
+
if maxQty == cap:
|
|
832
|
+
gain = itemGainCr * maxQty
|
|
833
|
+
if gain > bestGainCr:
|
|
834
|
+
cost = itemCostCr * maxQty
|
|
835
|
+
bestGainCr = gain
|
|
836
|
+
bestItem = item
|
|
837
|
+
bestQty = maxQty
|
|
838
|
+
bestCostCr = cost
|
|
839
|
+
bestSub = None
|
|
840
|
+
break
|
|
841
|
+
|
|
842
|
+
loadCostCr = maxQty * itemCostCr
|
|
843
|
+
loadGainCr = maxQty * itemGainCr
|
|
844
|
+
if loadGainCr > bestGainCr:
|
|
845
|
+
bestGainCr = loadGainCr
|
|
846
|
+
bestCostCr = loadCostCr
|
|
847
|
+
bestItem = item
|
|
848
|
+
bestQty = maxQty
|
|
849
|
+
bestSub = None
|
|
850
|
+
|
|
851
|
+
crLeft, capLeft = cr - loadCostCr, cap - maxQty
|
|
852
|
+
if crLeft > 0 and capLeft > 0:
|
|
853
|
+
subLoad = _fitCombos(iNo + 1, crLeft, capLeft)
|
|
854
|
+
if subLoad is emptyLoad:
|
|
855
|
+
continue
|
|
856
|
+
ttlGain = loadGainCr + subLoad.gainCr
|
|
857
|
+
if ttlGain < bestGainCr:
|
|
858
|
+
continue
|
|
859
|
+
ttlCost = loadCostCr + subLoad.costCr
|
|
860
|
+
if ttlGain == bestGainCr and ttlCost >= bestCostCr:
|
|
861
|
+
continue
|
|
862
|
+
bestGainCr = ttlGain
|
|
863
|
+
bestItem = item
|
|
864
|
+
bestQty = maxQty
|
|
865
|
+
bestCostCr = ttlCost
|
|
866
|
+
bestSub = subLoad
|
|
867
|
+
|
|
868
|
+
if not bestItem:
|
|
869
|
+
return emptyLoad
|
|
870
|
+
|
|
871
|
+
bestLoad = ((bestItem, bestQty),)
|
|
872
|
+
if bestSub:
|
|
873
|
+
bestLoad = bestLoad + bestSub.items
|
|
874
|
+
bestQty += bestSub.units
|
|
875
|
+
return TradeLoad(bestLoad, bestGainCr, bestCostCr, bestQty)
|
|
876
|
+
|
|
877
|
+
return _fitCombos(0, credits, capacity)
|
|
878
|
+
|
|
879
|
+
# Mark's test run, to spare searching back through the forum posts for it.
|
|
880
|
+
# python trade.py run --fr="Orang/Bessel Gateway" --cap=720 --cr=11b --ly=24.73 --empty=37.61 --pad=L --hops=2 --jum=3 --loop --summary -vv --progress
|
|
881
|
+
def simpleFit(self, items, credits, capacity, maxUnits): # pylint: disable=redefined-builtin
|
|
882
|
+
"""
|
|
883
|
+
Simplistic load calculator:
|
|
884
|
+
(The item list is sorted with highest profit margin items in front.)
|
|
885
|
+
Step 1: Fill hold with as much of item1 as possible based on the limiting
|
|
886
|
+
factors of hold size, supply amount, and available credits.
|
|
887
|
+
|
|
888
|
+
Step 2: If there is space in the hold and money available, repeat Step 1
|
|
889
|
+
with item2, item3, etc. until either the hold is filled
|
|
890
|
+
or the commander is too poor to buy more.
|
|
891
|
+
|
|
892
|
+
When amount of credits isn't a limiting factor, this should produce
|
|
893
|
+
the most profitable route ~99.7% of the time, and still be very
|
|
894
|
+
close to the most profitable the rest of the time.
|
|
895
|
+
(Very close = not enough less profit that anyone should care,
|
|
896
|
+
especially since this thing doesn't suffer slowdowns like fastFit.)
|
|
897
|
+
"""
|
|
898
|
+
|
|
899
|
+
n = 0
|
|
900
|
+
load = ()
|
|
901
|
+
gainCr = 0
|
|
902
|
+
costCr = 0
|
|
903
|
+
qty = 0
|
|
904
|
+
while n < len(items) and credits > 0 and capacity > 0:
|
|
905
|
+
qtyCeil = min(maxUnits, capacity)
|
|
906
|
+
|
|
907
|
+
item = items[n]
|
|
908
|
+
maxQty = min(qtyCeil, credits // item.costCr)
|
|
909
|
+
|
|
910
|
+
if maxQty > 0 and item.supply > 0:
|
|
911
|
+
maxQty = min(maxQty, item.supply)
|
|
912
|
+
|
|
913
|
+
loadCostCr = maxQty * item.costCr
|
|
914
|
+
loadGainCr = maxQty * item.gainCr
|
|
915
|
+
|
|
916
|
+
load = load + ((item, maxQty),)
|
|
917
|
+
qty += maxQty
|
|
918
|
+
capacity -= maxQty
|
|
919
|
+
|
|
920
|
+
gainCr += loadGainCr
|
|
921
|
+
costCr += loadCostCr
|
|
922
|
+
credits -= loadCostCr
|
|
923
|
+
|
|
924
|
+
n += 1
|
|
925
|
+
|
|
926
|
+
return TradeLoad(load, gainCr, costCr, qty)
|
|
927
|
+
|
|
928
|
+
# ------------------------------------------------------------------
|
|
929
|
+
# Trading methods
|
|
930
|
+
# ------------------------------------------------------------------
|
|
931
|
+
|
|
932
|
+
def getTrades(self, srcStation, dstStation, srcSelling=None):
|
|
933
|
+
"""
|
|
934
|
+
Returns the most profitable trading options from one station to another.
|
|
935
|
+
"""
|
|
936
|
+
if not srcSelling:
|
|
937
|
+
srcSelling = self.stationsSelling.get(srcStation.ID, None)
|
|
938
|
+
if not srcSelling:
|
|
939
|
+
self.tdenv.DEBUG2("^- source not selling anything")
|
|
940
|
+
return None
|
|
941
|
+
|
|
942
|
+
dstBuying = self.stationsBuying.get(dstStation.ID, None)
|
|
943
|
+
if not dstBuying:
|
|
944
|
+
self.tdenv.DEBUG2("^- dest not buying anything")
|
|
945
|
+
return None
|
|
946
|
+
|
|
947
|
+
minGainCr = max(1, self.tdenv.minGainPerTon or 1)
|
|
948
|
+
maxGainCr = max(minGainCr, self.tdenv.maxGainPerTon or sys.maxsize)
|
|
949
|
+
|
|
950
|
+
# ---- per-destination buy map cache (item_id -> buy tuple) ----
|
|
951
|
+
buy_map = self._dst_buy_map.get(dstStation.ID)
|
|
952
|
+
if buy_map is None:
|
|
953
|
+
# list -> dict once, re-used across many src comparisons
|
|
954
|
+
buy_map = {buy[0]: buy for buy in dstBuying}
|
|
955
|
+
self._dst_buy_map[dstStation.ID] = buy_map
|
|
956
|
+
getBuy = buy_map.get
|
|
957
|
+
|
|
958
|
+
itemIdx = self.tdb.itemByID
|
|
959
|
+
trading = []
|
|
960
|
+
append_trade = trading.append
|
|
961
|
+
|
|
962
|
+
for sell in srcSelling:
|
|
963
|
+
buy = getBuy(sell[0])
|
|
964
|
+
if not buy:
|
|
965
|
+
continue
|
|
966
|
+
gainCr = buy[1] - sell[1]
|
|
967
|
+
if minGainCr <= gainCr <= maxGainCr:
|
|
968
|
+
append_trade(
|
|
969
|
+
Trade(
|
|
970
|
+
itemIdx[sell[0]],
|
|
971
|
+
sell[1],
|
|
972
|
+
gainCr,
|
|
973
|
+
sell[2],
|
|
974
|
+
sell[3],
|
|
975
|
+
buy[2],
|
|
976
|
+
buy[3],
|
|
977
|
+
sell[4],
|
|
978
|
+
buy[4],
|
|
979
|
+
)
|
|
980
|
+
)
|
|
981
|
+
|
|
982
|
+
# Same final ordering as two successive sorts:
|
|
983
|
+
# primary: gainCr desc, tiebreak: costCr asc
|
|
984
|
+
trading.sort(key=lambda t: (-t.gainCr, t.costCr))
|
|
985
|
+
|
|
986
|
+
return trading
|
|
987
|
+
|
|
988
|
+
def getBestHops(self, routes, restrictTo=None):
|
|
989
|
+
"""
|
|
990
|
+
Given a list of routes, try all available next hops from each route.
|
|
991
|
+
Keeps only the best candidate per destination station for this hop.
|
|
992
|
+
"""
|
|
993
|
+
|
|
994
|
+
self.aborted = False
|
|
995
|
+
tdb = self.tdb
|
|
996
|
+
tdenv = self.tdenv
|
|
997
|
+
avoidPlaces = getattr(tdenv, "avoidPlaces", None) or ()
|
|
998
|
+
assert not restrictTo or isinstance(restrictTo, set)
|
|
999
|
+
maxJumpsPer = tdenv.maxJumpsPer
|
|
1000
|
+
maxLyPer = tdenv.maxLyPer
|
|
1001
|
+
maxPadSize = tdenv.padSize
|
|
1002
|
+
planetary = tdenv.planetary
|
|
1003
|
+
fleet = tdenv.fleet
|
|
1004
|
+
odyssey = tdenv.odyssey
|
|
1005
|
+
noPlanet = tdenv.noPlanet
|
|
1006
|
+
maxLsFromStar = tdenv.maxLs or float("inf")
|
|
1007
|
+
reqBlackMarket = getattr(tdenv, "blackMarket", False) or False
|
|
1008
|
+
maxAge = getattr(tdenv, "maxAge") or 0
|
|
1009
|
+
credits = tdenv.credits - (getattr(tdenv, "insurance", 0) or 0)
|
|
1010
|
+
fitFunction = self.defaultFit
|
|
1011
|
+
capacity = tdenv.capacity
|
|
1012
|
+
maxUnits = getattr(tdenv, "limit") or capacity
|
|
1013
|
+
|
|
1014
|
+
buying_ids = self._buying_ids
|
|
1015
|
+
|
|
1016
|
+
bestToDest = {}
|
|
1017
|
+
safetyMargin = 1.0 - tdenv.margin
|
|
1018
|
+
unique = tdenv.unique
|
|
1019
|
+
loopInt = getattr(tdenv, "loopInt", 0) or None
|
|
1020
|
+
|
|
1021
|
+
if tdenv.lsPenalty:
|
|
1022
|
+
lsPenalty = max(min(tdenv.lsPenalty / 100, 1), 0)
|
|
1023
|
+
else:
|
|
1024
|
+
lsPenalty = 0
|
|
1025
|
+
|
|
1026
|
+
goalSystem = tdenv.goalSystem
|
|
1027
|
+
uniquePath = None
|
|
1028
|
+
|
|
1029
|
+
# Build restriction set using duck typing to avoid class mismatches
|
|
1030
|
+
restrictStations = set()
|
|
1031
|
+
if restrictTo:
|
|
1032
|
+
for place in restrictTo:
|
|
1033
|
+
# Treat as a station if it has a .system but no .stations
|
|
1034
|
+
if hasattr(place, "system") and not hasattr(place, "stations"):
|
|
1035
|
+
restrictStations.add(place)
|
|
1036
|
+
# Treat as a system if it has .stations
|
|
1037
|
+
elif hasattr(place, "stations") and place.stations:
|
|
1038
|
+
restrictStations.update(place.stations)
|
|
1039
|
+
|
|
1040
|
+
# -----------------------
|
|
1041
|
+
# Spinner (stderr; only with --progress)
|
|
1042
|
+
# -----------------------
|
|
1043
|
+
heartbeat_enabled = bool(getattr(tdenv, "progress", False))
|
|
1044
|
+
hb_interval = 0.5
|
|
1045
|
+
last_hb = 0.0
|
|
1046
|
+
spinner = ("|", "/", "-", "\\")
|
|
1047
|
+
spin_i = 0
|
|
1048
|
+
total_origins = len(routes)
|
|
1049
|
+
best_seen_score = -1 # hop-global best hop score, nearest int
|
|
1050
|
+
|
|
1051
|
+
def heartbeat(origin_idx, dests_checked):
|
|
1052
|
+
nonlocal last_hb, spin_i
|
|
1053
|
+
now = time.time()
|
|
1054
|
+
if now - last_hb < hb_interval:
|
|
1055
|
+
return
|
|
1056
|
+
last_hb = now
|
|
1057
|
+
s = spinner[spin_i]
|
|
1058
|
+
spin_i = (spin_i + 1) % len(spinner)
|
|
1059
|
+
sys.stderr.write(
|
|
1060
|
+
f"\r\x1b[2K{s} origin {origin_idx}/{total_origins} destinations checked: {dests_checked:n} best score: {max(0, best_seen_score):n}"
|
|
1061
|
+
)
|
|
1062
|
+
sys.stderr.flush()
|
|
1063
|
+
|
|
1064
|
+
if tdenv.direct:
|
|
1065
|
+
if goalSystem and not restrictTo:
|
|
1066
|
+
restrictTo = (goalSystem,)
|
|
1067
|
+
# goalSystem is a system; take its stations
|
|
1068
|
+
restrictStations = set(goalSystem.stations or ())
|
|
1069
|
+
|
|
1070
|
+
# Respect --avoid on any prebuilt restriction set
|
|
1071
|
+
if avoidPlaces and restrictStations:
|
|
1072
|
+
restrictStations = {
|
|
1073
|
+
stn for stn in restrictStations
|
|
1074
|
+
if stn not in avoidPlaces and stn.system not in avoidPlaces
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
# **Fix:** when there is no explicit restriction (no --to / --towards),
|
|
1078
|
+
# iterate all eligible destinations rather than an empty set.
|
|
1079
|
+
if not restrictStations:
|
|
1080
|
+
restrictStations = {
|
|
1081
|
+
stn for stn in tdb.stationByID.values()
|
|
1082
|
+
if (stn not in avoidPlaces and stn.system not in avoidPlaces)
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
def station_iterator(srcStation, origin_idx):
|
|
1086
|
+
srcSys = srcStation.system
|
|
1087
|
+
srcDist = srcSys.distanceTo
|
|
1088
|
+
dests_seen = 0
|
|
1089
|
+
for stn in restrictStations:
|
|
1090
|
+
stnSys = stn.system
|
|
1091
|
+
if stn.ID not in buying_ids:
|
|
1092
|
+
continue
|
|
1093
|
+
dests_seen += 1
|
|
1094
|
+
if heartbeat_enabled and (dests_seen & 15) == 1: # fast modulo 16
|
|
1095
|
+
heartbeat(origin_idx, dests_seen)
|
|
1096
|
+
yield Destination(stnSys, stn, (srcSys, stnSys), srcDist(stnSys))
|
|
1097
|
+
if heartbeat_enabled:
|
|
1098
|
+
heartbeat(origin_idx, dests_seen)
|
|
1099
|
+
|
|
1100
|
+
else:
|
|
1101
|
+
getDestinations = tdb.getDestinations
|
|
1102
|
+
|
|
1103
|
+
def station_iterator(srcStation, origin_idx):
|
|
1104
|
+
dests_seen = 0
|
|
1105
|
+
for d in getDestinations(
|
|
1106
|
+
srcStation,
|
|
1107
|
+
maxJumps=maxJumpsPer,
|
|
1108
|
+
maxLyPer=maxLyPer,
|
|
1109
|
+
avoidPlaces=avoidPlaces,
|
|
1110
|
+
maxPadSize=maxPadSize,
|
|
1111
|
+
maxLsFromStar=maxLsFromStar,
|
|
1112
|
+
noPlanet=noPlanet,
|
|
1113
|
+
planetary=planetary,
|
|
1114
|
+
fleet=fleet,
|
|
1115
|
+
odyssey=odyssey,
|
|
1116
|
+
):
|
|
1117
|
+
if d.station.ID not in buying_ids:
|
|
1118
|
+
continue
|
|
1119
|
+
dests_seen += 1
|
|
1120
|
+
if heartbeat_enabled and (dests_seen & 15) == 1: # fast modulo 16
|
|
1121
|
+
heartbeat(origin_idx, dests_seen)
|
|
1122
|
+
yield d
|
|
1123
|
+
if heartbeat_enabled:
|
|
1124
|
+
heartbeat(origin_idx, dests_seen)
|
|
1125
|
+
|
|
1126
|
+
connections = 0
|
|
1127
|
+
getSelling = self.stationsSelling.get
|
|
1128
|
+
|
|
1129
|
+
for route_no, route in enumerate(routes):
|
|
1130
|
+
try:
|
|
1131
|
+
if tdenv.debug > 1: # route.debug_text can be expensive, so avoid evaluating it
|
|
1132
|
+
tdenv.DEBUG1("Route = {}", route.debug_text(lambda x, y: y))
|
|
1133
|
+
|
|
1134
|
+
srcStation = route.lastStation
|
|
1135
|
+
startCr = credits + int(route.gainCr * safetyMargin)
|
|
1136
|
+
|
|
1137
|
+
srcSelling = getSelling(srcStation.ID, None)
|
|
1138
|
+
if not srcSelling:
|
|
1139
|
+
tdenv.DEBUG1("Nothing sold at source - next.")
|
|
1140
|
+
if heartbeat_enabled:
|
|
1141
|
+
heartbeat(route_no + 1, 0)
|
|
1142
|
+
continue
|
|
1143
|
+
|
|
1144
|
+
srcSelling = tuple(values for values in srcSelling if values[1] <= startCr)
|
|
1145
|
+
if not srcSelling:
|
|
1146
|
+
tdenv.DEBUG1("Nothing affordable - next.")
|
|
1147
|
+
if heartbeat_enabled:
|
|
1148
|
+
heartbeat(route_no + 1, 0)
|
|
1149
|
+
continue
|
|
1150
|
+
|
|
1151
|
+
if goalSystem:
|
|
1152
|
+
origSystem = route.firstSystem
|
|
1153
|
+
srcSystem = srcStation.system
|
|
1154
|
+
srcDistTo = srcSystem.distanceTo
|
|
1155
|
+
goalDistTo = goalSystem.distanceTo
|
|
1156
|
+
origDistTo = origSystem.distanceTo
|
|
1157
|
+
srcGoalDist = srcDistTo(goalSystem)
|
|
1158
|
+
srcOrigDist = srcDistTo(origSystem)
|
|
1159
|
+
origGoalDist = origDistTo(goalSystem)
|
|
1160
|
+
|
|
1161
|
+
if unique:
|
|
1162
|
+
uniquePath = route.route
|
|
1163
|
+
elif loopInt:
|
|
1164
|
+
pos_from_end = 0 - loopInt
|
|
1165
|
+
uniquePath = route.route[pos_from_end:-1]
|
|
1166
|
+
|
|
1167
|
+
stations = (
|
|
1168
|
+
d
|
|
1169
|
+
for d in station_iterator(srcStation, route_no + 1)
|
|
1170
|
+
if (d.station != srcStation)
|
|
1171
|
+
and (d.station.blackMarket == "Y" if reqBlackMarket else True)
|
|
1172
|
+
and (d.station not in uniquePath if uniquePath else True)
|
|
1173
|
+
and (d.station in restrictStations if restrictStations else True)
|
|
1174
|
+
and (d.station.dataAge and d.station.dataAge <= maxAge if maxAge else True)
|
|
1175
|
+
and (
|
|
1176
|
+
(
|
|
1177
|
+
(d.system is not srcSystem)
|
|
1178
|
+
if bool(tdenv.unique)
|
|
1179
|
+
else (d.system is goalSystem or d.distLy < srcGoalDist)
|
|
1180
|
+
)
|
|
1181
|
+
if goalSystem
|
|
1182
|
+
else True
|
|
1183
|
+
)
|
|
1184
|
+
)
|
|
1185
|
+
|
|
1186
|
+
# Even when we don't log the line, we still have to produce the
|
|
1187
|
+
# parameters, and building the route list could be expensive,
|
|
1188
|
+
# so only pay the cost when we're actually logging.
|
|
1189
|
+
if tdenv.debug > 1:
|
|
1190
|
+
def annotate(dest):
|
|
1191
|
+
tdenv.DEBUG1(
|
|
1192
|
+
"destSys {}, destStn {}, jumps {}, distLy {}",
|
|
1193
|
+
dest.system.dbname,
|
|
1194
|
+
dest.station.dbname,
|
|
1195
|
+
"->".join(jump.text() for jump in dest.via),
|
|
1196
|
+
dest.distLy,
|
|
1197
|
+
)
|
|
1198
|
+
return dest
|
|
1199
|
+
|
|
1200
|
+
stations = (annotate(d) for d in stations)
|
|
1201
|
+
|
|
1202
|
+
for dest in stations:
|
|
1203
|
+
dstStation = dest.station
|
|
1204
|
+
connections += 1
|
|
1205
|
+
|
|
1206
|
+
items = self.getTrades(srcStation, dstStation, srcSelling)
|
|
1207
|
+
if not items:
|
|
1208
|
+
continue
|
|
1209
|
+
trade = fitFunction(items, startCr, capacity, maxUnits)
|
|
1210
|
+
|
|
1211
|
+
multiplier = 1.0
|
|
1212
|
+
# Calculate total K-lightseconds supercruise time.
|
|
1213
|
+
# This will amortize for the start/end stations
|
|
1214
|
+
dstSys = dest.system
|
|
1215
|
+
if goalSystem and dstSys is not goalSystem:
|
|
1216
|
+
# Biggest reward for shortening distance to goal
|
|
1217
|
+
dstGoalDist = goalDistTo(dstSys)
|
|
1218
|
+
# bias towards bigger reductions
|
|
1219
|
+
score = 5000 * origGoalDist / dstGoalDist
|
|
1220
|
+
# discourage moving back towards origin
|
|
1221
|
+
score += 50 * srcGoalDist / dstGoalDist
|
|
1222
|
+
# Gain per unit pays a small part
|
|
1223
|
+
if dstSys is not origSystem:
|
|
1224
|
+
score += 10 * (origDistTo(dstSys) - srcOrigDist)
|
|
1225
|
+
score += (trade.gainCr / trade.units) / 25
|
|
1226
|
+
else:
|
|
1227
|
+
score = trade.gainCr
|
|
1228
|
+
|
|
1229
|
+
if lsPenalty:
|
|
1230
|
+
# [kfsone] Only want 1dp
|
|
1231
|
+
# Produce a curve that favors distances under 1kls
|
|
1232
|
+
# positively, starts to penalize distances over 1k,
|
|
1233
|
+
# and after 4kls starts to penalize aggressively
|
|
1234
|
+
# http://goo.gl/Otj2XP
|
|
1235
|
+
|
|
1236
|
+
# [eyeonus] As aadler pointed out, this goes into negative
|
|
1237
|
+
# numbers, which causes problems.
|
|
1238
|
+
# penalty = ((cruiseKls ** 2) - cruiseKls) / 3
|
|
1239
|
+
# penalty *= lsPenalty
|
|
1240
|
+
# multiplier *= (1 - penalty)
|
|
1241
|
+
cruiseKls = int(dstStation.lsFromStar / 100) / 10
|
|
1242
|
+
boost = (1 - sigmoid(25 * (cruiseKls - 1))) / 4
|
|
1243
|
+
drop = (-1 - sigmoid(50 * (cruiseKls - 4))) / 4
|
|
1244
|
+
try:
|
|
1245
|
+
penalty = (-1 + 1 / (cruiseKls + 1) ** ((cruiseKls + 1) / 4)) / 2
|
|
1246
|
+
except OverflowError:
|
|
1247
|
+
penalty = -0.5
|
|
1248
|
+
multiplier += (penalty + boost + drop) * lsPenalty
|
|
1249
|
+
|
|
1250
|
+
score *= multiplier
|
|
1251
|
+
|
|
1252
|
+
# update hop-global best score (nearest int)
|
|
1253
|
+
try:
|
|
1254
|
+
si = int(round(score))
|
|
1255
|
+
except TypeError:
|
|
1256
|
+
si = int(score)
|
|
1257
|
+
if si > best_seen_score:
|
|
1258
|
+
best_seen_score = si
|
|
1259
|
+
|
|
1260
|
+
dstID = dstStation.ID
|
|
1261
|
+
try:
|
|
1262
|
+
btd = bestToDest[dstID]
|
|
1263
|
+
except KeyError:
|
|
1264
|
+
pass
|
|
1265
|
+
else:
|
|
1266
|
+
bestRoute = btd[1]
|
|
1267
|
+
bestScore = btd[5]
|
|
1268
|
+
bestTradeScore = bestRoute.score + bestScore
|
|
1269
|
+
newTradeScore = route.score + score
|
|
1270
|
+
if bestTradeScore > newTradeScore:
|
|
1271
|
+
continue
|
|
1272
|
+
if bestTradeScore == newTradeScore:
|
|
1273
|
+
bestLy = btd[4]
|
|
1274
|
+
if bestLy <= dest.distLy:
|
|
1275
|
+
continue
|
|
1276
|
+
|
|
1277
|
+
bestToDest[dstID] = (
|
|
1278
|
+
dstStation,
|
|
1279
|
+
route,
|
|
1280
|
+
trade,
|
|
1281
|
+
dest.via,
|
|
1282
|
+
dest.distLy,
|
|
1283
|
+
score,
|
|
1284
|
+
)
|
|
1285
|
+
except KeyboardInterrupt:
|
|
1286
|
+
self.aborted = True
|
|
1287
|
+
if not bestToDest:
|
|
1288
|
+
# Let the caller decide how to handle, explicitly
|
|
1289
|
+
raise
|
|
1290
|
+
break
|
|
1291
|
+
|
|
1292
|
+
if heartbeat_enabled:
|
|
1293
|
+
sys.stderr.write("\n")
|
|
1294
|
+
sys.stderr.flush()
|
|
1295
|
+
|
|
1296
|
+
if connections == 0:
|
|
1297
|
+
raise NoHopsError("No destinations could be reached within the constraints.")
|
|
1298
|
+
|
|
1299
|
+
return [
|
|
1300
|
+
route.plus(dst, trade, jumps, score)
|
|
1301
|
+
for (dst, route, trade, jumps, _, score) in bestToDest.values()
|
|
1302
|
+
]
|