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.
Files changed (87) hide show
  1. py.typed +1 -0
  2. trade.py +49 -0
  3. tradedangerous/__init__.py +43 -0
  4. tradedangerous/cache.py +1381 -0
  5. tradedangerous/cli.py +136 -0
  6. tradedangerous/commands/TEMPLATE.py +74 -0
  7. tradedangerous/commands/__init__.py +244 -0
  8. tradedangerous/commands/buildcache_cmd.py +102 -0
  9. tradedangerous/commands/buy_cmd.py +427 -0
  10. tradedangerous/commands/commandenv.py +372 -0
  11. tradedangerous/commands/exceptions.py +94 -0
  12. tradedangerous/commands/export_cmd.py +150 -0
  13. tradedangerous/commands/import_cmd.py +222 -0
  14. tradedangerous/commands/local_cmd.py +243 -0
  15. tradedangerous/commands/market_cmd.py +207 -0
  16. tradedangerous/commands/nav_cmd.py +252 -0
  17. tradedangerous/commands/olddata_cmd.py +270 -0
  18. tradedangerous/commands/parsing.py +221 -0
  19. tradedangerous/commands/rares_cmd.py +298 -0
  20. tradedangerous/commands/run_cmd.py +1521 -0
  21. tradedangerous/commands/sell_cmd.py +262 -0
  22. tradedangerous/commands/shipvendor_cmd.py +60 -0
  23. tradedangerous/commands/station_cmd.py +68 -0
  24. tradedangerous/commands/trade_cmd.py +181 -0
  25. tradedangerous/commands/update_cmd.py +67 -0
  26. tradedangerous/corrections.py +55 -0
  27. tradedangerous/csvexport.py +234 -0
  28. tradedangerous/db/__init__.py +27 -0
  29. tradedangerous/db/adapter.py +192 -0
  30. tradedangerous/db/config.py +107 -0
  31. tradedangerous/db/engine.py +259 -0
  32. tradedangerous/db/lifecycle.py +332 -0
  33. tradedangerous/db/locks.py +208 -0
  34. tradedangerous/db/orm_models.py +500 -0
  35. tradedangerous/db/paths.py +113 -0
  36. tradedangerous/db/utils.py +661 -0
  37. tradedangerous/edscupdate.py +565 -0
  38. tradedangerous/edsmupdate.py +474 -0
  39. tradedangerous/formatting.py +210 -0
  40. tradedangerous/fs.py +156 -0
  41. tradedangerous/gui.py +1146 -0
  42. tradedangerous/mapping.py +133 -0
  43. tradedangerous/mfd/__init__.py +103 -0
  44. tradedangerous/mfd/saitek/__init__.py +3 -0
  45. tradedangerous/mfd/saitek/directoutput.py +678 -0
  46. tradedangerous/mfd/saitek/x52pro.py +195 -0
  47. tradedangerous/misc/checkpricebounds.py +287 -0
  48. tradedangerous/misc/clipboard.py +49 -0
  49. tradedangerous/misc/coord64.py +83 -0
  50. tradedangerous/misc/csvdialect.py +57 -0
  51. tradedangerous/misc/derp-sentinel.py +35 -0
  52. tradedangerous/misc/diff-system-csvs.py +159 -0
  53. tradedangerous/misc/eddb.py +81 -0
  54. tradedangerous/misc/eddn.py +349 -0
  55. tradedangerous/misc/edsc.py +437 -0
  56. tradedangerous/misc/edsm.py +121 -0
  57. tradedangerous/misc/importeddbstats.py +54 -0
  58. tradedangerous/misc/prices-json-exp.py +179 -0
  59. tradedangerous/misc/progress.py +194 -0
  60. tradedangerous/plugins/__init__.py +249 -0
  61. tradedangerous/plugins/edcd_plug.py +371 -0
  62. tradedangerous/plugins/eddblink_plug.py +861 -0
  63. tradedangerous/plugins/edmc_batch_plug.py +133 -0
  64. tradedangerous/plugins/spansh_plug.py +2647 -0
  65. tradedangerous/prices.py +211 -0
  66. tradedangerous/submit-distances.py +422 -0
  67. tradedangerous/templates/Added.csv +37 -0
  68. tradedangerous/templates/Category.csv +17 -0
  69. tradedangerous/templates/RareItem.csv +143 -0
  70. tradedangerous/templates/TradeDangerous.sql +338 -0
  71. tradedangerous/tools.py +40 -0
  72. tradedangerous/tradecalc.py +1302 -0
  73. tradedangerous/tradedb.py +2320 -0
  74. tradedangerous/tradeenv.py +313 -0
  75. tradedangerous/tradeenv.pyi +109 -0
  76. tradedangerous/tradeexcept.py +131 -0
  77. tradedangerous/tradeorm.py +183 -0
  78. tradedangerous/transfers.py +192 -0
  79. tradedangerous/utils.py +243 -0
  80. tradedangerous/version.py +16 -0
  81. tradedangerous-12.7.6.dist-info/METADATA +106 -0
  82. tradedangerous-12.7.6.dist-info/RECORD +87 -0
  83. tradedangerous-12.7.6.dist-info/WHEEL +5 -0
  84. tradedangerous-12.7.6.dist-info/entry_points.txt +3 -0
  85. tradedangerous-12.7.6.dist-info/licenses/LICENSE +373 -0
  86. tradedangerous-12.7.6.dist-info/top_level.txt +2 -0
  87. tradegui.py +24 -0
@@ -0,0 +1,1521 @@
1
+ from __future__ import annotations
2
+ from itertools import chain
3
+ import math
4
+ import sys
5
+ import time
6
+ import typing
7
+
8
+ from tradedangerous.tradedb import describeAge, TradeDB, Station, System
9
+ from tradedangerous.tradecalc import NoHopsError, Route, TradeCalc, UserAbortedRun
10
+
11
+ from .commandenv import ResultRow
12
+ from .exceptions import CommandLineError, NoDataError
13
+ from .parsing import (
14
+ BlackMarketSwitch, FleetCarrierArgument, MutuallyExclusiveGroup,
15
+ NoPlanetSwitch, OdysseyArgument, PadSizeArgument, ParseArgument,
16
+ PlanetaryArgument,
17
+ )
18
+
19
+ if typing.TYPE_CHECKING:
20
+ from tradedangerous import TradeEnv
21
+
22
+
23
+ ######################################################################
24
+ # Parser config
25
+
26
+ help = 'Calculate best trade run.'
27
+ name = 'run'
28
+ epilog = None
29
+ usesTradeData = True
30
+
31
+ arguments = [
32
+ ParseArgument('--capacity',
33
+ help = 'Maximum capacity of cargo hold.',
34
+ metavar = 'N',
35
+ type = int,
36
+ ),
37
+ ParseArgument('--credits',
38
+ help = 'Starting credits.',
39
+ metavar = 'CR',
40
+ type = "credits",
41
+ ),
42
+ ]
43
+
44
+ switches = [
45
+ ParseArgument('--from', '-f',
46
+ help = 'Starting system/station.',
47
+ dest = 'starting',
48
+ metavar = 'STATION',
49
+ ),
50
+ MutuallyExclusiveGroup(
51
+ ParseArgument('--to', '-t',
52
+ help = 'Final system/station.',
53
+ dest = 'ending',
54
+ metavar = 'PLACE',
55
+ default = None,
56
+ ),
57
+ ParseArgument('--towards', '-T',
58
+ help = (
59
+ 'Choose a route that continually reduces the '
60
+ 'distance towards this system.'
61
+ ),
62
+ dest = 'goalSystem',
63
+ metavar = 'SYSTEM',
64
+ default = None,
65
+ ),
66
+ ParseArgument('--loop',
67
+ help = 'Return to the starting station.',
68
+ action = 'store_true',
69
+ default = False,
70
+ ),
71
+ ),
72
+ ParseArgument('--via',
73
+ help = 'Require specified systems/stations to be en-route.',
74
+ action = 'append',
75
+ metavar = 'PLACE[,PLACE,...]',
76
+ ),
77
+ ParseArgument('--avoid',
78
+ help = 'Exclude an item, system or station from trading. '
79
+ 'Partial matches allowed, '
80
+ 'e.g. "dom.App" or "domap" matches "Dom. Appliances".',
81
+ action = 'append',
82
+ ),
83
+ MutuallyExclusiveGroup(
84
+ ParseArgument('--direct',
85
+ help = "Assume destinations are reachable without worrying "
86
+ "about jumps.",
87
+ action = 'store_true',
88
+ ),
89
+ ParseArgument('--hops',
90
+ help = 'Number of hops (station-to-station) to run.',
91
+ default = 2,
92
+ type = int,
93
+ metavar = 'N',
94
+ ),
95
+ ),
96
+ ParseArgument('--jumps-per',
97
+ help = 'Maximum number of jumps (system-to-system) per hop.',
98
+ default = 2,
99
+ dest = 'maxJumpsPer',
100
+ metavar = 'N',
101
+ type = int,
102
+ ),
103
+ ParseArgument('--ly-per',
104
+ help = 'Maximum light years per jump.',
105
+ dest = 'maxLyPer',
106
+ metavar = 'N.NN',
107
+ type = float,
108
+ ),
109
+ ParseArgument('--empty-ly',
110
+ help = 'Maximum light years ship can jump when empty.',
111
+ dest = 'emptyLyPer',
112
+ metavar = 'N.NN',
113
+ type = float,
114
+ default = None,
115
+ ),
116
+ ParseArgument('--start-jumps', '-s',
117
+ help = 'Consider stations within this many jumps of the origin '
118
+ '(requires --from).',
119
+ dest = 'startJumps',
120
+ default = 0,
121
+ type = int,
122
+ ),
123
+ ParseArgument('--end-jumps', '-e',
124
+ help = 'Consider stations within this many jumps of the destination '
125
+ '(requires --to).',
126
+ dest = 'endJumps',
127
+ default = 0,
128
+ type = int,
129
+ ),
130
+ ParseArgument('--show-jumps', '-J',
131
+ help = 'Show detail of jumps between hops.',
132
+ dest = 'showJumps',
133
+ action = 'store_true',
134
+ ),
135
+ ParseArgument('--limit',
136
+ help = 'Maximum units of any one cargo item to buy (0: unlimited).',
137
+ metavar = 'N',
138
+ type = int,
139
+ ),
140
+ ParseArgument('--age', '--max-days-old', '-MD',
141
+ help = 'Maximum age (in days) of trade data to use.',
142
+ metavar = 'DAYS',
143
+ type = float,
144
+ dest = 'maxAge',
145
+ ),
146
+ PadSizeArgument(),
147
+ MutuallyExclusiveGroup(
148
+ NoPlanetSwitch(),
149
+ PlanetaryArgument(),
150
+ ),
151
+ FleetCarrierArgument(),
152
+ OdysseyArgument(),
153
+ BlackMarketSwitch(),
154
+ ParseArgument('--ls-penalty', '--lsp',
155
+ help = "Penalty per 1kls stations are from their stars.",
156
+ default = 12.5,
157
+ type = float,
158
+ dest = 'lsPenalty'
159
+ ),
160
+ ParseArgument('--ls-max',
161
+ help = 'Only consider stations upto this many ls from their star.',
162
+ metavar = 'LS',
163
+ dest = 'maxLs',
164
+ type = int,
165
+ default = 0,
166
+ ),
167
+ ParseArgument('--gain-per-ton', '--gpt',
168
+ help = 'Specify the minimum gain per ton of cargo',
169
+ dest = 'minGainPerTon',
170
+ type = "credits",
171
+ default = 1
172
+ ),
173
+ ParseArgument('--max-gain-per-ton', '--mgpt',
174
+ help = 'Specify the maximum gain per ton of cargo',
175
+ dest = 'maxGainPerTon',
176
+ type = "credits",
177
+ default = 0
178
+ ),
179
+ ParseArgument('--unique',
180
+ help = 'Only visit each station once.',
181
+ action = 'store_true',
182
+ default = False,
183
+ ),
184
+ ParseArgument('--loop-interval', '-li',
185
+ help = (
186
+ 'Require this many hops between visits to the same station. '
187
+ 'A value of 1 would be the default behavior, so a value of '
188
+ '2 is the minimum allowed.'
189
+ ),
190
+ type = int,
191
+ default = None,
192
+ dest = 'loopInt',
193
+ ),
194
+ ParseArgument('--margin',
195
+ help = 'Reduce gains made on each hop to provide a margin of error '
196
+ 'for market fluctuations (e.g: 0.25 reduces gains by 1/4). '
197
+ '0<: N<: 0.25.',
198
+ default = 0.00,
199
+ metavar = 'N.NN',
200
+ type = float,
201
+ ),
202
+ ParseArgument('--insurance',
203
+ help = 'Reserve at least this many credits to cover insurance.',
204
+ default = 0,
205
+ metavar = 'CR',
206
+ type = "credits",
207
+ ),
208
+ ParseArgument('--routes',
209
+ help = 'Maximum number of routes to show. DEFAULT: 1',
210
+ default = 1,
211
+ metavar = 'N',
212
+ type = int,
213
+ ),
214
+ ParseArgument('--max-routes',
215
+ help = 'At the end of each hop, limit the number of routes '
216
+ 'that continue to the next round to the top N '
217
+ 'highest scoring',
218
+ default = 0,
219
+ metavar = 'N',
220
+ type = int,
221
+ dest = 'maxRoutes',
222
+ ),
223
+ ParseArgument('--checklist',
224
+ help = 'Provide a checklist flow for the route.',
225
+ action = 'store_true',
226
+ default = False,
227
+ ),
228
+ ParseArgument('--x52-pro',
229
+ help = 'Enable experimental X52 Pro MFD output.',
230
+ action = 'store_true',
231
+ default = False,
232
+ dest = 'x52pro',
233
+ ),
234
+ ParseArgument('--prune-score',
235
+ help = "From the 3rd hop on, only consider routes which have at least this percentage of the current best route's score.",
236
+ dest = 'pruneScores',
237
+ type = float,
238
+ default = 0,
239
+ ),
240
+ ParseArgument('--prune-hops',
241
+ help = 'Changes which hop --prune-score takes effect from.',
242
+ default = 3,
243
+ type = int,
244
+ dest = 'pruneHops',
245
+ ),
246
+ ParseArgument('--progress', '-P',
247
+ help = 'Show hop progress',
248
+ default = False,
249
+ action = 'store_true',
250
+ ),
251
+ ParseArgument('--supply',
252
+ help = 'Only considers items which have at least this many units.',
253
+ default = None,
254
+ type = int,
255
+ ),
256
+ ParseArgument('--demand',
257
+ help = 'Only considers items which have at least this much demand.',
258
+ default = None,
259
+ type = int
260
+ ),
261
+ ParseArgument('--summary',
262
+ help = 'Summary layout of route instructions.',
263
+ action = 'store_true',
264
+ ),
265
+ ParseArgument('--shorten',
266
+ help = '(Requires --to) Find the shortest route with the best gpt.',
267
+ action = 'store_true',
268
+ ),
269
+ ]
270
+
271
+
272
+ ######################################################################
273
+ # Helpers
274
+
275
+ # Do some basic syntax validation before we waste time loading data.
276
+ def validateRunArgumentsFast(cmdenv):
277
+ """
278
+ Fast-fail argument checks that should run BEFORE any database
279
+ access or TradeCalc construction.
280
+ """
281
+ # --towards requires --from
282
+ if cmdenv.goalSystem and not getattr(cmdenv, "starting", None):
283
+ raise CommandLineError("--towards requires --from")
284
+
285
+ # --start-jumps requires --from
286
+ if cmdenv.startJumps and not getattr(cmdenv, "starting", None):
287
+ raise CommandLineError("--start-jumps requires --from")
288
+
289
+ # --end-jumps requires --to
290
+ if cmdenv.endJumps and not getattr(cmdenv, "ending", None):
291
+ raise CommandLineError("--end-jumps requires --to")
292
+
293
+ # --shorten only valid with --to
294
+ if cmdenv.shorten and not cmdenv.destPlace:
295
+ raise CommandLineError("--shorten only works with --to.")
296
+
297
+ class Checklist:
298
+ """
299
+ Class for encapsulating display of a route as a series of
300
+ steps to be 'checked off' as the user passes through them.
301
+ """
302
+
303
+ def __init__(self, tdb, cmdenv):
304
+ self.tdb = tdb
305
+ self.cmdenv = cmdenv
306
+ self.mfd = cmdenv.mfd
307
+
308
+ def doStep(self, action, detail = None, extra = None):
309
+ self.stepNo += 1
310
+ try:
311
+ self.mfd.display(
312
+ "#{} {}".format(self.stepNo, action),
313
+ detail or "",
314
+ extra or ""
315
+ )
316
+ except AttributeError:
317
+ pass
318
+ input(
319
+ " {:<3}: {}: "
320
+ .format(
321
+ self.stepNo,
322
+ " ".join(item for item in (action, detail, extra) if item)
323
+ )
324
+ )
325
+
326
+ def note(self, str, addBreak = True):
327
+ print("(i) {} (i){}".format(str, "\n" if addBreak else ""))
328
+
329
+ def run(self, route, cr):
330
+ mfd = self.mfd
331
+ stations, hops, jumps = route.route, route.hops, route.jumps
332
+ lastHopIdx = len(stations) - 1
333
+ gainCr = 0
334
+ self.stepNo = 0
335
+
336
+ heading = "(i) BEGINNING CHECKLIST FOR {} (i)".format(route.text(lambda x, y: y))
337
+ print(heading, "\n", '-' * len(heading), "\n\n", sep = '')
338
+
339
+ cmdenv = self.cmdenv
340
+ if cmdenv.detail:
341
+ print(route.summary())
342
+ print()
343
+
344
+ for idx in range(lastHopIdx):
345
+ hopNo = idx + 1
346
+ cur, nxt, hop = stations[idx], stations[idx + 1], hops[idx]
347
+ sortedTradeOptions = sorted(
348
+ hop[0],
349
+ key=lambda tradeOption: tradeOption[1] * tradeOption[0].gainCr,
350
+ reverse=True
351
+ )
352
+
353
+ # Tell them what they need to buy.
354
+ if cmdenv.detail:
355
+ self.note("HOP {} of {}".format(hopNo, lastHopIdx))
356
+
357
+ self.note("Buy at {}".format(cur.name()))
358
+ for (trade, qty) in sortedTradeOptions:
359
+ self.doStep(
360
+ 'Buy {:n} x'.format(qty),
361
+ trade.name(),
362
+ '@ {}cr / {} old'.format(
363
+ trade.costCr,
364
+ describeAge(trade.srcAge),
365
+ ))
366
+ if cmdenv.detail:
367
+ self.doStep('Refuel')
368
+ print()
369
+
370
+ # If there is a next hop, describe how to get there.
371
+ self.note(
372
+ "Fly {}"
373
+ .format(
374
+ " -> ".join(jump.name() for jump in jumps[idx])
375
+ )
376
+ )
377
+ if idx < len(hops) and jumps[idx]:
378
+ for jump in jumps[idx][1:]:
379
+ self.doStep('Jump to', jump.name())
380
+ if cmdenv.detail:
381
+ self.doStep('Dock at', nxt.text())
382
+ print()
383
+
384
+ self.note("Sell at {}".format(nxt.name()))
385
+ for (trade, qty) in sortedTradeOptions:
386
+ self.doStep(
387
+ 'Sell {:n} x'.format(qty),
388
+ trade.name(),
389
+ '@ {:n}cr / {} old'.format(
390
+ trade.costCr + trade.gainCr,
391
+ describeAge(trade.dstAge),
392
+ ))
393
+ print()
394
+
395
+ gainCr += hop[1]
396
+ if cmdenv.detail and gainCr > 0:
397
+ self.note("GAINED: {:n}cr, CREDITS: {:n}cr".format(
398
+ gainCr, cr + gainCr))
399
+
400
+ if hopNo < lastHopIdx:
401
+ print("\n--------------------------------------\n")
402
+
403
+ if mfd:
404
+ mfd.display('FINISHED',
405
+ "+{:n}cr".format(gainCr),
406
+ "={:n}cr".format(cr + gainCr))
407
+ mfd.attention(3)
408
+ from time import sleep
409
+ sleep(1.5)
410
+
411
+
412
+ def expandForJumps(tdb, cmdenv, calc, origin, jumps, srcName, purpose):
413
+ """
414
+ Find all the stations you could reach if you made a given
415
+ number of jumps away from the origin list.
416
+ """
417
+
418
+ assert jumps
419
+
420
+ maxLyPer = cmdenv.emptyLyPer or cmdenv.maxLyPer
421
+ avoidPlaces = cmdenv.avoidPlaces
422
+ cmdenv.DEBUG0(
423
+ "expanding {} reach from {} by {} jumps at {}ly per jump",
424
+ srcName,
425
+ origin.name(),
426
+ jumps,
427
+ maxLyPer,
428
+ )
429
+
430
+ # Correct behaviour: destinations (--to) require buying data; origins (--from) require selling data.
431
+ if srcName == "--to":
432
+ tradingList = calc.stationsBuying
433
+ elif srcName == "--from":
434
+ tradingList = calc.stationsSelling
435
+ else:
436
+ raise Exception("Unknown src")
437
+
438
+ # Ensure O(1) membership checks regardless of the underlying container type.
439
+ trading_ids = tradingList if isinstance(tradingList, set) else set(tradingList)
440
+
441
+ stations: set[Station] = set()
442
+ origins: set[System | Station] = {origin}
443
+ avoid: set[System | Station] = set(avoidPlaces)
444
+
445
+ for jump in range(jumps):
446
+ if not origins:
447
+ break
448
+ if getattr(cmdenv, "debug", False):
449
+ cmdenv.DEBUG1(
450
+ "Ring {}: {}",
451
+ jump,
452
+ [sys.dbname for sys in origins]
453
+ )
454
+ thisJump, origins = origins, set()
455
+ for system in thisJump:
456
+ avoid.add(system)
457
+ for stn in system.stations or ():
458
+ if stn.ID not in trading_ids:
459
+ if getattr(cmdenv, "debug", False):
460
+ cmdenv.DEBUG2(
461
+ "X {}/{} not in trading list",
462
+ stn.system.dbname, stn.dbname,
463
+ )
464
+ continue
465
+ if not checkStationSuitability(cmdenv, calc, stn):
466
+ if getattr(cmdenv, "debug", False):
467
+ cmdenv.DEBUG2(
468
+ "X {}/{} was not suitable",
469
+ stn.system.dbname, stn.dbname,
470
+ )
471
+ continue
472
+ if getattr(cmdenv, "debug", False):
473
+ cmdenv.DEBUG2(
474
+ "- {}/{} meets requirements",
475
+ stn.system.dbname, stn.dbname,
476
+ )
477
+ stations.add(stn)
478
+ for dest, dist in tdb.genSystemsInRange(system, maxLyPer):
479
+ if dest not in avoid:
480
+ origins.add(dest)
481
+
482
+ if getattr(cmdenv, "debug", False):
483
+ cmdenv.DEBUG0(
484
+ "Expanded {} stations: {}",
485
+ srcName,
486
+ [stn.name() for stn in stations]
487
+ )
488
+
489
+ if not stations:
490
+ if not cmdenv.emptyLyPer:
491
+ extra = (
492
+ "\nIf you are willing to make unladen jumps for the sake "
493
+ "of a better route, consider using --empty."
494
+ )
495
+ else:
496
+ extra = ""
497
+ raise CommandLineError(
498
+ "No {} stations with suitable trade data could be found "
499
+ "within {} {}ly jump{} of {} that meet all of your critera.{}"
500
+ .format(
501
+ purpose, maxLyPer,
502
+ jumps, "s" if jumps > 1 else "",
503
+ origin.name(),
504
+ extra,
505
+ )
506
+ )
507
+
508
+ stations = list(stations)
509
+ stations.sort(key=lambda stn: stn.ID)
510
+
511
+ return stations
512
+
513
+
514
+ def checkForEmptyStationList(category, focusPlace, stationList, jumps):
515
+ if stationList:
516
+ return
517
+ if jumps:
518
+ raise NoDataError(
519
+ "Local database has no price data for any "
520
+ "stations within {} jumps of {} ({})".format(
521
+ jumps,
522
+ focusPlace.name(),
523
+ category,
524
+ ))
525
+ if isinstance(focusPlace, System):
526
+ raise NoDataError(
527
+ "Local database either has no price data for "
528
+ "stations in {} ({}) or could not find any that "
529
+ "met your requirements (e.g. pad-size). "
530
+ "Check \"trade.py local -vv --ly 0 {}\"".format(
531
+ focusPlace.name(),
532
+ category,
533
+ focusPlace.name(),
534
+ ))
535
+ raise NoDataError(
536
+ "Local database has no price data for {} ({})".format(
537
+ focusPlace.name(),
538
+ category,
539
+ ))
540
+
541
+
542
+ def checkAnchorNotInVia(hops, anchorName, place, viaSet):
543
+ """
544
+ Ensure that '--to' or '--from' is not in the via set.
545
+ """
546
+
547
+ if hops != 2:
548
+ return
549
+ if isinstance(place, Station) and place in viaSet:
550
+ raise CommandLineError(
551
+ "{} used in {} and --via with only 2 hops".format(
552
+ place.name(),
553
+ anchorName,
554
+ ))
555
+
556
+
557
+ def checkStationSuitability(cmdenv, calc, station, src = None):
558
+ cmdenv.DEBUG2(
559
+ "checking {} (ls={}, bm={}, pad={}, plt={}, flc={}, ody={}, mkt={}, shp={}) "
560
+ "for {} suitability",
561
+ station.name(),
562
+ station.lsFromStar,
563
+ station.blackMarket,
564
+ station.maxPadSize,
565
+ station.planetary,
566
+ station.fleet,
567
+ station.odyssey,
568
+ station.market,
569
+ station.shipyard,
570
+ src or "any",
571
+ )
572
+
573
+ if station in cmdenv.avoidPlaces and src != "--from":
574
+ if src:
575
+ raise CommandLineError(
576
+ "{} station {} is marked to avoid"
577
+ .format(src, station.name())
578
+ )
579
+ return False
580
+ if station.system in cmdenv.avoidPlaces and src != "--from":
581
+ if src:
582
+ raise CommandLineError(
583
+ "{} station {} is in system listed in --avoid"
584
+ .format(src, station.name())
585
+ )
586
+ return False
587
+ if station.market == 'N':
588
+ if src:
589
+ raise CommandLineError(
590
+ "{} station {} is flagged as having no market".format(
591
+ src, station.name()
592
+ )
593
+ )
594
+ return False
595
+ if not station.itemCount:
596
+ if src:
597
+ raise NoDataError(
598
+ "No price data in local database "
599
+ "for {} station: {}".format(
600
+ src, station.name(),
601
+ ))
602
+ return False
603
+ if src != "--to" and station.ID not in calc.stationsSelling:
604
+ if src:
605
+ raise NoDataError(
606
+ "No buying prices at {}."
607
+ .format(station.name())
608
+ )
609
+ return False
610
+ if src != "--from" and station.ID not in calc.stationsBuying:
611
+ if src:
612
+ raise NoDataError(
613
+ "No selling prices at {}."
614
+ .format(station.name())
615
+ )
616
+ return False
617
+ mps = cmdenv.padSize
618
+ if mps and not station.checkPadSize(mps):
619
+ if src:
620
+ raise CommandLineError(
621
+ "{} station {} does not meet pad-size requirement.\n"
622
+ "You specified: {}, Current data for station: {} ({})\n"
623
+ "You can use \"trade.py station\" to correct this.".format(
624
+ src, station.name(),
625
+ mps, station.maxPadSize,
626
+ TradeDB.padSizesExt[station.maxPadSize],
627
+ ))
628
+ return False
629
+ pla = cmdenv.planetary
630
+ if pla and not station.checkPlanetary(pla):
631
+ if src:
632
+ raise CommandLineError(
633
+ "{} station {} does not meet planetary requirement.\n"
634
+ "You specified: {}, Current data for station: {} ({})\n"
635
+ "You can use \"trade.py station\" to correct this.".format(
636
+ src, station.name(),
637
+ pla, station.planetary,
638
+ TradeDB.planetStatesExt[station.planetary],
639
+ ))
640
+ return False
641
+ flc = cmdenv.fleet
642
+ if flc and not station.checkFleet(flc):
643
+ if src:
644
+ raise CommandLineError(
645
+ "{} station {} does not meet fleet carrier requirement.\n"
646
+ "You specified: {}, Current data for station: {} ({})\n"
647
+ "You can use \"trade.py station\" to correct this.".format(
648
+ src, station.name(),
649
+ flc, station.fleet,
650
+ TradeDB.fleetStatesExt[station.fleet],
651
+ ))
652
+ return False
653
+ ody = cmdenv.odyssey
654
+ if ody and not station.checkOdyssey(ody):
655
+ if src:
656
+ raise CommandLineError(
657
+ "{} station {} does not meet odyssey requirement.\n"
658
+ "You specified: {}, Current data for station: {} ({})\n"
659
+ "You can use \"trade.py station\" to correct this.".format(
660
+ src, station.name(),
661
+ ody, station.odyssey,
662
+ TradeDB.odysseyStatesExt[station.odyssey],
663
+ ))
664
+ return False
665
+ np = cmdenv.noPlanet
666
+ if np and station.planetary != 'N':
667
+ if src and src != "--from":
668
+ raise CommandLineError(
669
+ "{} station {} does not meet no-planet "
670
+ "requirement.".format(
671
+ src, station.name(),
672
+ ))
673
+ return False
674
+ bm = cmdenv.blackMarket
675
+ if bm and station.blackMarket != 'Y':
676
+ if src and src != "--from":
677
+ raise CommandLineError(
678
+ "{} station {} does not meet black-market "
679
+ "requirement.".format(
680
+ src, station.name(),
681
+ ))
682
+ return False
683
+ mls = cmdenv.maxLs
684
+ if mls and (station.lsFromStar <= 0 or station.lsFromStar > mls):
685
+ if src and src != "--from":
686
+ raise CommandLineError(
687
+ "{} station {} does not meet max-ls "
688
+ "requirement.".format(
689
+ src, station.name(),
690
+ ))
691
+ return False
692
+ maxAge, stnAge = cmdenv.maxAge, station.dataAge or float("inf")
693
+ if maxAge and stnAge > maxAge:
694
+ if src and src != "--from":
695
+ raise CommandLineError(
696
+ "{} station {} does not meet --age "
697
+ "requirement.".format(
698
+ src, station.name(),
699
+ ))
700
+ return False
701
+ return True
702
+
703
+
704
+ def filterStationSet(src, cmdenv, calc, stnList):
705
+ if not stnList:
706
+ return stnList
707
+ if getattr(cmdenv, "debug", False):
708
+ cmdenv.DEBUG0(
709
+ "filtering {} station list: {}",
710
+ src,
711
+ ",".join(station.name() for station in stnList),
712
+ )
713
+ stnList = tuple(
714
+ place for place in stnList
715
+ if isinstance(place, System) or checkStationSuitability(cmdenv, calc, place, src)
716
+ )
717
+ if not stnList:
718
+ # Preserve original message formatting (no behavior change)
719
+ raise CommandLineError("No {src} station met your criteria.")
720
+ return stnList
721
+
722
+ def checkOrigins(tdb, cmdenv, calc):
723
+ # Compute eligibility once: stations must both sell and buy (same as suitability with src=None).
724
+ eligible_ids = set(calc.stationsSelling) & set(calc.stationsBuying)
725
+
726
+ setattr(cmdenv, "_origin", cmdenv.origPlace) # store the original so we can overwrite it
727
+ if cmdenv.origPlace:
728
+ if cmdenv.startJumps and cmdenv.startJumps > 0:
729
+ cmdenv.origins = expandForJumps(
730
+ tdb, cmdenv, calc,
731
+ cmdenv.origPlace.system,
732
+ cmdenv.startJumps,
733
+ "--from", "starting",
734
+ )
735
+ cmdenv.origPlace = None
736
+ elif isinstance(cmdenv.origPlace, System):
737
+ cmdenv.DEBUG0("origPlace: System: {}", cmdenv.origPlace.name())
738
+ if not cmdenv.origPlace.stations:
739
+ raise CommandLineError(
740
+ "No stations at --from system, {}"
741
+ .format(cmdenv.origPlace.name())
742
+ )
743
+ cmdenv.origins = tuple(
744
+ station
745
+ for station in cmdenv.origPlace.stations
746
+ if station.ID in eligible_ids and checkStationSuitability(cmdenv, calc, station)
747
+ )
748
+ else:
749
+ cmdenv.DEBUG0("origPlace: Station: {}", cmdenv.origPlace.name())
750
+ checkStationSuitability(cmdenv, calc, cmdenv.origPlace, '--from')
751
+ cmdenv.origins = (cmdenv.origPlace,)
752
+ cmdenv.startStation = cmdenv.origPlace
753
+ checkForEmptyStationList(
754
+ "--from", cmdenv.origPlace,
755
+ cmdenv.origins, cmdenv.startJumps
756
+ )
757
+ else:
758
+ if cmdenv.startJumps:
759
+ raise CommandLineError("--start-jumps (-s) only works with --from")
760
+ cmdenv.DEBUG0("using all suitable origins")
761
+ cmdenv.origins = tuple(
762
+ station
763
+ for station in tdb.stationByID.values()
764
+ if station.ID in eligible_ids and checkStationSuitability(cmdenv, calc, station)
765
+ )
766
+
767
+ if not cmdenv.startJumps and isinstance(cmdenv.origPlace, System):
768
+ cmdenv.origins = filterStationSet(
769
+ '--from', cmdenv, calc, cmdenv.origins
770
+ )
771
+
772
+ cmdenv.origSystems = tuple(set(
773
+ stn.system for stn in cmdenv.origins
774
+ ))
775
+
776
+ def checkDestinations(tdb, cmdenv, calc):
777
+ cmdenv.destinations = None
778
+ showProgress = bool(getattr(cmdenv, "progress", False))
779
+ hb_interval = 0.5
780
+ last_hb = 0.0
781
+ spinner = ("|", "/", "-", "\\")
782
+ spin_i = 0
783
+
784
+ def heartbeat(seen, kept):
785
+ nonlocal last_hb, spin_i
786
+ if not showProgress:
787
+ return
788
+ now = time.time()
789
+ if (now - last_hb) < hb_interval:
790
+ return
791
+ last_hb = now
792
+ s = spinner[spin_i]
793
+ spin_i = (spin_i + 1) % len(spinner)
794
+ sys.stdout.write(
795
+ f"\r{s} Scanning stations… examined {seen:n} kept {kept:n}"
796
+ )
797
+ sys.stdout.flush()
798
+
799
+ if cmdenv.destPlace:
800
+ if cmdenv.endJumps and cmdenv.endJumps > 0:
801
+ cmdenv.destinations = expandForJumps(
802
+ tdb, cmdenv, calc,
803
+ cmdenv.destPlace.system,
804
+ cmdenv.endJumps,
805
+ "--to", "destination",
806
+ )
807
+ cmdenv.destPlace = None
808
+ elif isinstance(cmdenv.destPlace, Station):
809
+ cmdenv.DEBUG0("destPlace: Station: {}", cmdenv.destPlace.name())
810
+ # Single-station --to: hard fail if unsuitable.
811
+ checkStationSuitability(cmdenv, calc, cmdenv.destPlace, '--to')
812
+ cmdenv.destinations = (cmdenv.destPlace,)
813
+ else:
814
+ cmdenv.DEBUG0("destPlace: System: {}", cmdenv.destPlace.name())
815
+ # System --to: examine all stations, keeping only those that pass --to suitability.
816
+ dests, seen, kept = [], 0, 0
817
+ for station in cmdenv.destPlace.stations:
818
+ seen += 1
819
+ try:
820
+ ok = checkStationSuitability(cmdenv, calc, station, '--to')
821
+ except (CommandLineError, NoDataError):
822
+ ok = False
823
+ if ok:
824
+ dests.append(station)
825
+ kept += 1
826
+ heartbeat(seen, kept)
827
+ cmdenv.destinations = tuple(dests)
828
+ if showProgress:
829
+ sys.stdout.write("\n")
830
+ sys.stdout.flush()
831
+ checkForEmptyStationList(
832
+ "--to", cmdenv.destPlace,
833
+ cmdenv.destinations, cmdenv.endJumps
834
+ )
835
+ else:
836
+ if cmdenv.endJumps:
837
+ raise CommandLineError("--end-jumps (-e) only works with --to")
838
+ cmdenv.DEBUG0("Using all available destinations")
839
+ if cmdenv.goalSystem:
840
+ dest = tdb.lookupPlace(cmdenv.goalSystem)
841
+ cmdenv.goalSystem = dest.system
842
+
843
+ if cmdenv.origPlace and cmdenv.maxJumpsPer == 0:
844
+ stationSrc = chain.from_iterable(
845
+ system.stations for system in cmdenv.origSystems
846
+ )
847
+ else:
848
+ stationSrc = tdb.stationByID.values()
849
+
850
+ eligible_ids = set(calc.stationsSelling) & set(calc.stationsBuying)
851
+
852
+ dests, seen, kept = [], 0, 0
853
+ for station in stationSrc:
854
+ seen += 1
855
+ if station.ID in eligible_ids and checkStationSuitability(cmdenv, calc, station):
856
+ dests.append(station)
857
+ kept += 1
858
+ heartbeat(seen, kept)
859
+ cmdenv.destinations = tuple(dests)
860
+ if showProgress:
861
+ sys.stdout.write("\n")
862
+ sys.stdout.flush()
863
+
864
+ if not cmdenv.endJumps and isinstance(cmdenv.destPlace, System):
865
+ cmdenv.destinations = filterStationSet(
866
+ '--to', cmdenv, calc, cmdenv.destinations
867
+ )
868
+
869
+ cmdenv.destSystems = tuple(set(
870
+ stn.system for stn in cmdenv.destinations
871
+ ))
872
+
873
+
874
+ def validateRunArguments(tdb, cmdenv, calc):
875
+ """
876
+ Process arguments to the 'run' option.
877
+ """
878
+
879
+ if cmdenv.credits < 0:
880
+ raise CommandLineError("Invalid (negative) value for initial credits")
881
+ # I'm going to allow 0 credits as a future way of saying "just fly"
882
+
883
+ if cmdenv.routes < 1:
884
+ raise CommandLineError(
885
+ "Maximum routes has to be 1 or higher."
886
+ )
887
+ if cmdenv.routes > 1 and cmdenv.checklist:
888
+ raise CommandLineError(
889
+ "Checklist can only be applied to a single route."
890
+ )
891
+
892
+ if cmdenv.hops < 1:
893
+ raise CommandLineError("Minimum of 1 hop required")
894
+ if cmdenv.hops > 32:
895
+ raise CommandLineError("Too many hops without more optimization")
896
+
897
+ if cmdenv.maxJumpsPer < 0:
898
+ raise CommandLineError("Negative jumps: you're already there?")
899
+ if cmdenv.direct:
900
+ cmdenv.hops = 1
901
+ cmdenv.maxJumpsPer = cmdenv.maxLyPer = 10000
902
+
903
+ if cmdenv.capacity is None:
904
+ raise CommandLineError("Missing '--capacity'")
905
+ if cmdenv.maxLyPer is None and not cmdenv.direct:
906
+ raise CommandLineError("Missing '--ly-per'")
907
+ if cmdenv.capacity < 0:
908
+ raise CommandLineError("Invalid (negative) cargo capacity")
909
+ if cmdenv.capacity > 1500:
910
+ cmdenv.WARN("Capacity > 1500 not supported (you specified {})", cmdenv.capacity)
911
+ cmdenv.WARN("Forcing jumps per hop to 1.")
912
+ cmdenv.maxJumpsPer = 1
913
+ if cmdenv.hops > 2:
914
+ cmdenv.WARN("{} hops? Press [CTRL][C] to quit.", cmdenv.hops)
915
+ if not cmdenv.supply:
916
+ cmdenv.WARN("Please provide a '--supply' value.")
917
+ cmdenv.supply = cmdenv.capacity * 10
918
+ cmdenv.DEBUG0("'supply' minimum set to {}.", cmdenv.supply)
919
+ if not cmdenv.demand:
920
+ cmdenv.WARN("Please provide a '--demand' value.")
921
+ cmdenv.demand = cmdenv.capacity * 10
922
+ cmdenv.DEBUG0("'demand' minimum set to {}.", cmdenv.demand)
923
+ # raise CommandLineError(
924
+ # "Capacity > 1500 not supported (you specified {})"
925
+ # .format(cmdenv.capacity)
926
+ # )
927
+
928
+ if cmdenv.limit and cmdenv.limit > cmdenv.capacity:
929
+ raise CommandLineError("'limit' must be <= capacity")
930
+ if cmdenv.limit and cmdenv.limit < 0:
931
+ raise CommandLineError("'limit' can't be negative, silly")
932
+ cmdenv.maxUnits = cmdenv.limit if cmdenv.limit else cmdenv.capacity
933
+
934
+ if cmdenv.insurance:
935
+ arbitraryInsuranceBuffer = 42
936
+ if cmdenv.insurance >= (cmdenv.credits + arbitraryInsuranceBuffer):
937
+ raise CommandLineError("Insurance leaves no margin for trade")
938
+
939
+ if cmdenv.loop:
940
+ if cmdenv.unique:
941
+ raise CommandLineError("Cannot use --unique and --loop together")
942
+ if cmdenv.direct:
943
+ raise CommandLineError("Cannot use --direct and --loop together")
944
+
945
+ if cmdenv.loopInt:
946
+ if cmdenv.loopInt < 2:
947
+ raise CommandLineError(
948
+ "--loop-int must be 2 or higher to have any effect. "
949
+ )
950
+ if cmdenv.loopInt > cmdenv.hops and not cmdenv.unique:
951
+ cmdenv.NOTE("--loop-int > hops implies --unique")
952
+ cmdenv.unique = True
953
+
954
+ if cmdenv.shorten:
955
+ if cmdenv.loop:
956
+ raise CommandLineError(
957
+ "Cannot use --shorten and --loop together"
958
+ )
959
+ if not cmdenv.ending:
960
+ raise CommandLineError(
961
+ "--shorten only works with --to."
962
+ )
963
+
964
+ if cmdenv.goalSystem and not cmdenv.origPlace:
965
+ raise CommandLineError("--towards requires --from")
966
+
967
+ checkOrigins(tdb, cmdenv, calc)
968
+ checkDestinations(tdb, cmdenv, calc)
969
+
970
+ # If they're going --from and --to single systems, and they have
971
+ # specified zero jumps then it's futile to try anything.
972
+ if cmdenv.maxJumpsPer == 0 and not cmdenv.direct:
973
+ if len(cmdenv.origSystems) == 1 and len(cmdenv.destSystems) == 1:
974
+ if cmdenv.origSystems[0] != cmdenv.destSystems[0]:
975
+ raise CommandLineError(
976
+ "Could not find any connections that didn't require at "
977
+ "least one jump and --jumps 0 specified."
978
+ )
979
+
980
+ origins, destns = cmdenv.origins or (), cmdenv.destinations or ()
981
+
982
+ if cmdenv.hops == 1 and len(origins) == 1 and len(destns) == 1:
983
+ if origins == destns:
984
+ raise CommandLineError("Same to/from; more than one hop required.")
985
+
986
+ avoidSet = set(cmdenv.avoidPlaces or ())
987
+ viaSet = cmdenv.viaSet = set(cmdenv.viaPlaces)
988
+ cmdenv.DEBUG0("Via: {}", viaSet)
989
+ cmdenv.viaSet = filterStationSet('--via', cmdenv, calc, cmdenv.viaSet)
990
+ checkAnchorNotInVia(cmdenv.hops, "--from", cmdenv.origPlace, viaSet)
991
+ checkAnchorNotInVia(cmdenv.hops, "--to", cmdenv.destPlace, viaSet)
992
+
993
+ viaSystems = set()
994
+ for place in viaSet:
995
+ if place in avoidSet or place.system in avoidSet:
996
+ raise CommandLineError(
997
+ '"--via {}" conflicts with --avoid'
998
+ .format(place.name())
999
+ )
1000
+ if isinstance(place, Station):
1001
+ viaSystems.add(place.system)
1002
+ else:
1003
+ viaSystems.add(place)
1004
+
1005
+ if cmdenv.maxJumpsPer == 0 and viaSet and not cmdenv.direct:
1006
+ for via in viaSet:
1007
+ if via.system not in cmdenv.origSystems:
1008
+ raise CommandLineError(
1009
+ "--via {} unreachable with --jumps 0"
1010
+ .format(via.name())
1011
+ )
1012
+ cmdenv.origins = tuple(
1013
+ origin for origin in cmdenv.origins
1014
+ if origin.system in viaSystems
1015
+ )
1016
+ cmdenv.origSystems = tuple(
1017
+ origin.system for origin in cmdenv.origins
1018
+ )
1019
+ cmdenv.destinations = tuple(
1020
+ dest for dest in cmdenv.destinations
1021
+ if dest.system in viaSystems
1022
+ )
1023
+ cmdenv.destSystems = tuple(
1024
+ dest.system for dest in cmdenv.destinations
1025
+ )
1026
+
1027
+ # How many of the hops do not have pre-determined stations. For example,
1028
+ # when the user uses "--from", they pre-determine the starting station.
1029
+ fixedRoutePoints = 0
1030
+ if cmdenv.origPlace:
1031
+ fixedRoutePoints += 1
1032
+ if cmdenv.destPlace:
1033
+ fixedRoutePoints += 1
1034
+ totalRoutePoints = cmdenv.hops + 1
1035
+ adhocRoutePoints = totalRoutePoints - fixedRoutePoints
1036
+ if len(viaSystems) > adhocRoutePoints:
1037
+ raise CommandLineError(
1038
+ "Route is not long enough for the list of '--via' "
1039
+ "destinations you gave. Reduce the vias or try again "
1040
+ "with '--hops {}' or greater.\n".format(
1041
+ len(viaSet) + fixedRoutePoints - 1
1042
+ ))
1043
+ cmdenv.adhocHops = adhocRoutePoints - 1
1044
+
1045
+ if cmdenv.unique and cmdenv.hops >= len(tdb.stationByID):
1046
+ raise CommandLineError(
1047
+ "Requested unique trip with more hops than there are stations..."
1048
+ )
1049
+ if cmdenv.unique:
1050
+ # if there's only one start and stop...
1051
+ if len(origins) == 1 and len(destns) == 1:
1052
+ if origins[0] is destns[0]:
1053
+ raise CommandLineError("Can't have same from/to with --unique")
1054
+ if viaSet:
1055
+ if len(origins) == 1 and origins[0] in viaSet:
1056
+ raise CommandLineError("Can't have --from station in --via list with --unique")
1057
+ if len(destns) == 1 and destns[0] in viaSet:
1058
+ raise CommandLineError("Can't have --to station in --via list with --unique")
1059
+
1060
+ if cmdenv.mfd:
1061
+ cmdenv.mfd.display("Loading Trades")
1062
+
1063
+ if cmdenv.pruneScores and cmdenv.pruneHops:
1064
+ if cmdenv.pruneScores > 99:
1065
+ raise CommandLineError("--prune-score value percentile exceed 99.")
1066
+ if cmdenv.pruneHops < 2:
1067
+ raise CommandLineError("--prune-hops must 2 or more.")
1068
+ else:
1069
+ cmdenv.pruneScores = cmdenv.pruneHops = 0
1070
+
1071
+ ######################################################################
1072
+
1073
+
1074
+ def filterByVia(routes, viaSet, viaStartPos):
1075
+ if not routes:
1076
+ return ()
1077
+
1078
+ matchedRoutes = []
1079
+ partialRoutes = {}
1080
+ maxMet = 0
1081
+ for route in routes:
1082
+ met = 0
1083
+ for hop in route.route[viaStartPos:]:
1084
+ if hop in viaSet or hop.system in viaSet:
1085
+ met += 1
1086
+ if met > 0:
1087
+ if met >= len(viaSet):
1088
+ matchedRoutes.append(route)
1089
+ else:
1090
+ if met > maxMet:
1091
+ partialRoutes[met] = []
1092
+ if met >= maxMet:
1093
+ maxMet = met
1094
+ partialRoutes[met].append(route)
1095
+
1096
+ if matchedRoutes:
1097
+ return matchedRoutes, None
1098
+
1099
+ if not maxMet:
1100
+ raise NoDataError(
1101
+ "No routes were found which matched your 'via' selections."
1102
+ )
1103
+
1104
+ return partialRoutes[maxMet], (
1105
+ "SORRY: No runs visited all of your via destinations. "
1106
+ "Listing runs that matched at least {}.".format(
1107
+ maxMet
1108
+ )
1109
+ )
1110
+
1111
+
1112
+ def checkReachability(tdb, cmdenv):
1113
+ if cmdenv.direct:
1114
+ return
1115
+ srcSys, dstSys = cmdenv.origSystems, cmdenv.destSystems
1116
+ if len(srcSys) == 1 and len(dstSys) == 1:
1117
+ srcSys, dstSys = srcSys[0], dstSys[0]
1118
+ if srcSys != dstSys:
1119
+ maxLyPer = cmdenv.maxLyPer
1120
+ avoiding = tuple(
1121
+ avoid for avoid in cmdenv.avoidPlaces
1122
+ if isinstance(avoid, System)
1123
+ )
1124
+ route = tdb.getRoute(
1125
+ srcSys, dstSys, maxLyPer, avoiding,
1126
+ )
1127
+ if not route:
1128
+ raise CommandLineError(
1129
+ "No route between {} and {} with a {}ly/jump limit."
1130
+ .format(
1131
+ srcSys.name(), dstSys.name(),
1132
+ maxLyPer,
1133
+ )
1134
+ )
1135
+
1136
+ # Were there just not enough hops?
1137
+ jumpLimit = cmdenv.maxJumpsPer * cmdenv.hops
1138
+ routeJumps = len(route) - 1
1139
+ if jumpLimit < routeJumps:
1140
+ hopsRequired = math.ceil(routeJumps / cmdenv.maxJumpsPer)
1141
+ jumpsRequired = math.ceil(routeJumps / cmdenv.hops)
1142
+ raise CommandLineError(
1143
+ "Shortest route between {src} and {dst} at {jumply} "
1144
+ "ly per jump requires at least {minjumps} jumps. "
1145
+ "Your current settings (--hops {hops} --jumps {jumps}) "
1146
+ "allows a maximum of {jumplimit}.\n"
1147
+ "\n"
1148
+ "You may need --hops={althops} or --jumps={altjumps}.\n"
1149
+ "\n"
1150
+ "See also:\n"
1151
+ " --towards (aka -T),"
1152
+ " --start-jumps (-s),"
1153
+ " --end-jumps (-e),"
1154
+ " --direct.\n"
1155
+ .format(
1156
+ src = srcSys.name(),
1157
+ dst = dstSys.name(),
1158
+ jumply = cmdenv.maxLyPer,
1159
+ minjumps = routeJumps,
1160
+ hops = cmdenv.hops,
1161
+ jumps = cmdenv.maxJumpsPer,
1162
+ jumplimit = jumpLimit,
1163
+ althops = hopsRequired,
1164
+ altjumps = jumpsRequired,
1165
+ )
1166
+ )
1167
+
1168
+
1169
+ def routeFailedRestrictions(
1170
+ tdb, cmdenv, restrictTo, maxLs, hopNo
1171
+ ):
1172
+ """
1173
+ Generate exception text indicating we couldn't complete a
1174
+ route given the restrictions supplied. If the user has
1175
+ specified detail, check if there is a route at all.
1176
+ """
1177
+
1178
+ places = list(
1179
+ set(
1180
+ chain.from_iterable(
1181
+ (place,) if isinstance(place, Station) else place.stations
1182
+ for place in restrictTo
1183
+ )
1184
+ )
1185
+ )
1186
+ places.sort(key = lambda stn: stn.dbname)
1187
+
1188
+ dests = ", ".join(place.name() for place in places)
1189
+
1190
+ return (
1191
+ "SORRY: Could not find any routes that delivered a profit to "
1192
+ "{} at hop #{}\n"
1193
+ "You may need to add more hops to your route or adjust your "
1194
+ "filters/restrictions.\n"
1195
+ .format(
1196
+ dests, hopNo + 1
1197
+ )
1198
+ )
1199
+
1200
+
1201
+ def extraRouteProgress(routes):
1202
+ bestGain = max(routes, key = lambda route: route.gainCr).gainCr
1203
+ worstGain = min(routes, key = lambda route: route.gainCr).gainCr
1204
+ if bestGain != worstGain:
1205
+ gainText = "{:n}-{:n}cr gain".format(worstGain, bestGain)
1206
+ else:
1207
+ gainText = "{:n}cr gain".format(bestGain)
1208
+
1209
+ bestGPT = int(max(routes, key = lambda route: route.gpt).gpt)
1210
+ worstGPT = int(min(routes, key = lambda route: route.gpt).gpt)
1211
+ if bestGPT != worstGPT:
1212
+ gptText = "{:n}-{:n}cr/ton".format(worstGPT, bestGPT)
1213
+ else:
1214
+ gptText = "{:n}cr/ton".format(bestGPT)
1215
+
1216
+ return ".. {}, {}".format(gainText, gptText)
1217
+
1218
+ ######################################################################
1219
+ # Perform query and populate result set
1220
+
1221
+
1222
+ def run(results, cmdenv, tdb):
1223
+ cmdenv.DEBUG1("loading trades")
1224
+
1225
+ if tdb.tradingCount == 0:
1226
+ raise NoDataError("Database does not contain any profitable trades.")
1227
+
1228
+ # Always show a friendly heads-up before heavy work begins.
1229
+ print("Searching for quality trades. This may take a few minutes. Please be patient.", flush=True)
1230
+
1231
+ # Instantiate the calculator object
1232
+ calc = TradeCalc(tdb, cmdenv)
1233
+
1234
+ validateRunArguments(tdb, cmdenv, calc)
1235
+
1236
+ origPlace, viaSet = cmdenv.origPlace, cmdenv.viaSet
1237
+ stopStations = cmdenv.destinations
1238
+ goalSystem = cmdenv.goalSystem
1239
+ maxLs = cmdenv.maxLs
1240
+
1241
+ # seed the route table with starting places
1242
+ startCr = cmdenv.credits - cmdenv.insurance
1243
+ routes = [
1244
+ Route(
1245
+ stations = (src,),
1246
+ hops = (),
1247
+ jumps = (),
1248
+ startCr = startCr,
1249
+ gainCr = 0,
1250
+ score = 0,
1251
+ )
1252
+ for src in cmdenv.origins
1253
+ ]
1254
+
1255
+ numHops = cmdenv.hops
1256
+ lastHop = numHops - 1
1257
+ viaStartPos = 1 if origPlace else 0
1258
+
1259
+ cmdenv.DEBUG1("numHops {}, vias {}, adhocHops {}",
1260
+ numHops, len(viaSet), cmdenv.adhocHops)
1261
+
1262
+ results.summary = ResultRow()
1263
+ results.summary.exception = ""
1264
+
1265
+ if cmdenv.loop:
1266
+ routePickPred = lambda route: \
1267
+ route.lastStation is route.firstStation
1268
+ elif cmdenv.shorten:
1269
+ if not cmdenv.destPlace:
1270
+ routePickPred = lambda route: \
1271
+ route.lastStation is route.firstStation
1272
+ elif isinstance(cmdenv.destPlace, System):
1273
+ routePickPred = lambda route: \
1274
+ route.lastSystem is cmdenv.destPlace
1275
+ else:
1276
+ routePickPred = lambda route: \
1277
+ route.lastStation is cmdenv.destPlace
1278
+ else:
1279
+ routePickPred = None
1280
+
1281
+ pickedRoutes = []
1282
+
1283
+ pruneMod = cmdenv.pruneScores / 100
1284
+
1285
+ if cmdenv.loop:
1286
+ distancePruning = lambda rt, distLeft: \
1287
+ rt.lastSystem.distanceTo(rt.firstSystem) <= distLeft
1288
+ elif cmdenv.destPlace and not cmdenv.direct:
1289
+ distancePruning = lambda rt, distLeft: \
1290
+ any(
1291
+ stop for stop in stopSystems
1292
+ if rt.lastSystem.distanceTo(stop) <= distLeft
1293
+ )
1294
+ else:
1295
+ distancePruning = False
1296
+
1297
+ if distancePruning:
1298
+ maxHopDistLy = cmdenv.maxJumpsPer * cmdenv.maxLyPer
1299
+ if not cmdenv.loop:
1300
+ stopSystems = {stop.system for stop in stopStations}
1301
+
1302
+ for hopNo in range(numHops):
1303
+ restrictTo = None
1304
+ if hopNo == lastHop and stopStations:
1305
+ restrictTo = set(stopStations)
1306
+ manualRestriction = bool(cmdenv.destPlace)
1307
+ elif len(viaSet) > cmdenv.adhocHops:
1308
+ restrictTo = viaSet
1309
+ manualRestriction = True
1310
+
1311
+ if distancePruning:
1312
+ preCrop = len(routes)
1313
+ distLeft = maxHopDistLy * (numHops - hopNo)
1314
+ routes[:] = [rt for rt in routes if distancePruning(rt, distLeft)]
1315
+ if not routes:
1316
+ if pickedRoutes:
1317
+ break
1318
+ raise NoDataError(
1319
+ "No routes are in-range of any end stations at the end of hop {}"
1320
+ .format(hopNo)
1321
+ )
1322
+ if (pruned := preCrop - len(routes)):
1323
+ cmdenv.NOTE("Pruned {} origins too far from any end stations", pruned)
1324
+
1325
+ if hopNo >= 1 and (cmdenv.maxRoutes or pruneMod):
1326
+ routes.sort()
1327
+ if pruneMod and hopNo + 1 >= cmdenv.pruneHops and len(routes) > 10:
1328
+ crop = int(len(routes) * pruneMod)
1329
+ routes[:] = routes[:-crop]
1330
+ cmdenv.NOTE("Pruned {} origins", crop)
1331
+
1332
+ if cmdenv.maxRoutes and len(routes) > cmdenv.maxRoutes:
1333
+ routes[:] = routes[:cmdenv.maxRoutes]
1334
+
1335
+ if cmdenv.progress:
1336
+ extra = ""
1337
+ if hopNo > 0 and cmdenv.detail > 1:
1338
+ extra = extraRouteProgress(routes)
1339
+ print(
1340
+ "* Hop {:3n}: {:.>10n} origins {}"
1341
+ .format(hopNo + 1, len(routes), extra)
1342
+ )
1343
+ elif cmdenv.debug:
1344
+ cmdenv.DEBUG0("Hop {}...", hopNo + 1)
1345
+
1346
+ try:
1347
+ newRoutes = calc.getBestHops(routes, restrictTo = restrictTo)
1348
+
1349
+ except KeyboardInterrupt:
1350
+ cmdenv.DEBUG0("** Keyboard Interrupt")
1351
+ if hopNo == 0 or not routes:
1352
+ raise UserAbortedRun("before any routes calculated")
1353
+ # Until python 3.14 it's discouraged to break from an exception, so
1354
+ # lets make sure we don't mistake there being anything to process.
1355
+ calc.aborted = True
1356
+ newRoutes = []
1357
+
1358
+ except NoHopsError:
1359
+ if hopNo == 0 and len(cmdenv.origSystems) == 1:
1360
+ raise NoDataError(
1361
+ "Couldn't find any trading links within {} x {}ly jumps of {}."
1362
+ .format(
1363
+ cmdenv.maxJumpsPer,
1364
+ cmdenv.maxLyPer,
1365
+ cmdenv.origSystems[0].name(),
1366
+ )
1367
+ )
1368
+ raise NoDataError(
1369
+ "No routes had reachable trading links at hop #{}".format(hopNo + 1)
1370
+ )
1371
+
1372
+ if calc.aborted:
1373
+ cmdenv.DEBUG0("** User Aborted")
1374
+ break
1375
+
1376
+ if not newRoutes:
1377
+ assert not calc.aborted, "internal error"
1378
+ # First attempt to find a route is a special case because the current
1379
+ # route list is the source.
1380
+ if hopNo == 0:
1381
+ no_routes_on_first_hop(cmdenv, calc)
1382
+ # no return
1383
+
1384
+ # If we've already got some winners (e.g. on --shorten)
1385
+ if pickedRoutes:
1386
+ break
1387
+
1388
+ checkReachability(tdb, cmdenv)
1389
+
1390
+ if restrictTo and manualRestriction:
1391
+ results.summary.exception += routeFailedRestrictions(
1392
+ tdb, cmdenv, restrictTo, maxLs, hopNo
1393
+ )
1394
+ break
1395
+
1396
+ results.summary.exception += f"SORRY: Could not find profitable destinations beyond hop #{hopNo+1:n}\n"
1397
+ break
1398
+
1399
+ routes[:] = newRoutes
1400
+ if goalSystem:
1401
+ # Promote the winning route to the top of the list
1402
+ # while leaving the remainder of the list intact
1403
+ routes.sort(
1404
+ key = lambda route:
1405
+ 0 if route.lastSystem is goalSystem else 1
1406
+ )
1407
+ if routes[0].lastSystem is goalSystem:
1408
+ cmdenv.NOTE("Goal system reached!")
1409
+ routes = routes[:1]
1410
+ break
1411
+
1412
+ if calc.aborted:
1413
+ break
1414
+
1415
+ if routePickPred:
1416
+ pickedRoutes.extend(
1417
+ route for route in routes if routePickPred(route)
1418
+ )
1419
+
1420
+ if cmdenv.loop or cmdenv.shorten:
1421
+ cmdenv.DEBUG0("Using {} picked routes", len(pickedRoutes))
1422
+ routes = pickedRoutes
1423
+ # normalise the scores for fairness...
1424
+ for route in routes:
1425
+ cmdenv.DEBUG0(
1426
+ "{} hops, {} score, {} gpt",
1427
+ len(route.hops), route.score, route.gpt
1428
+ )
1429
+ route.score /= len(route.hops)
1430
+
1431
+ if not routes:
1432
+ if calc.aborted:
1433
+ raise UserAbortedRun("before any routes found")
1434
+ raise NoDataError(
1435
+ "No profitable trades matched your critera, or price data along the route is missing."
1436
+ )
1437
+
1438
+ if viaSet:
1439
+ routes, caution = filterByVia(routes, viaSet, viaStartPos)
1440
+ if caution:
1441
+ results.summary.exception += caution + "\n"
1442
+
1443
+ routes.sort()
1444
+ results.data = routes
1445
+
1446
+ if calc.aborted:
1447
+ results.summary.exception += str(UserAbortedRun("results may be incomplete or inaccurate")) + "\n"
1448
+
1449
+ return results
1450
+
1451
+
1452
+ def no_routes_on_first_hop(cmdenv: TradeEnv, calc: TradeCalc) -> None:
1453
+ """ handle the special case where run found no routes on the first hop. """
1454
+ # Is it because you ctrl-c'd?
1455
+ if calc.aborted:
1456
+ raise UserAbortedRun("during first hop before any routes found")
1457
+
1458
+ # The raw name they provide with --from is stored as cmdenv.starting, and resolved
1459
+ # to a System or Station in cmdenv.origPlace, however checkOrigins may set that to
1460
+ # None if we're doing --start-jumps to indicate there's no "single" origin. So we
1461
+ # saved a copy of it to cmdenv._origin.
1462
+ start_place = getattr(cmdenv, "_origin")
1463
+ if not start_place:
1464
+ # Ok, we were doing some kind of open-ended galaxy wide query
1465
+ raise NoDataError("Could not find any trade links in the galaxy with those criteria.")
1466
+
1467
+ # Find the system name - all "locations" have a system property including Systems.
1468
+ start_system = start_place.system.name()
1469
+
1470
+ # How far did you say you were willing to go?
1471
+ max_ly = cmdenv.maxJumpsPer * cmdenv.maxLyPer
1472
+
1473
+ errText = (
1474
+ f"No suitable and profitable buyers found at/relative to {start_place}.\n"
1475
+ "\n"
1476
+ "You may want to try:\n"
1477
+ f" {sys.argv[0]} local \"{start_system}\" --ly {max_ly} -vv --stations --trading"
1478
+ )
1479
+
1480
+ # If they had specified a station, give them a little extra help.
1481
+ if isinstance(start_place, Station):
1482
+ errText += (
1483
+ "\n"
1484
+ "or:\n"
1485
+ f" {sys.argv[0]} market \"{start_place}\" --sell -vv"
1486
+ )
1487
+
1488
+ raise NoDataError(errText)
1489
+
1490
+
1491
+ ######################################################################
1492
+ # Transform result set into output
1493
+
1494
+
1495
+ def render(results, cmdenv, tdb):
1496
+ if (exception := results.summary.exception.strip()):
1497
+ style = ""
1498
+ lines = exception.split("\n")
1499
+ max_line_len = max(len(line) for line in lines)
1500
+ if cmdenv.color:
1501
+ style = "yellow on grey15" # yellow on a darkish background, so we're sure it's not on a light background
1502
+ # Pad all the lines to the same length
1503
+ exception = "\n".join(f"{line:{max_line_len}s}" for line in lines)
1504
+
1505
+ # TODO: should use a rich panel when --color is set
1506
+ cmdenv.console.print('#' * max_line_len, style=style)
1507
+ cmdenv.console.print(exception, style=style)
1508
+ cmdenv.console.print('#' * max_line_len, style=style)
1509
+ # Ring the console bell and add a blank line
1510
+ cmdenv.console.print("\a")
1511
+
1512
+ routes = results.data
1513
+
1514
+ for i in range(min(len(routes), cmdenv.routes)):
1515
+ print(routes[i].detail(cmdenv))
1516
+
1517
+ # User wants to be guided through the route.
1518
+ if cmdenv.checklist:
1519
+ assert cmdenv.routes == 1
1520
+ cl = Checklist(tdb, cmdenv)
1521
+ cl.run(routes[0], cmdenv.credits)