-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfleet.py
More file actions
2592 lines (2372 loc) · 145 KB
/
Copy pathfleet.py
File metadata and controls
2592 lines (2372 loc) · 145 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Fleet engine — run the whole fleet concurrently under ONE rate limiter.
The orchestration layer protoTrader-in-space uses to manage many ships toward a
goal (default: maximise credits/hour). Every ship's job is an async coroutine; the
orchestrator runs them all at once via asyncio over the *single* rate-limited
client (`client.call` self-paces to ~2 req/s) — so a fleet shares the one
per-account API budget correctly, which separate processes can't. Ships spend most
of their time waiting on travel/cooldowns, so the budget easily covers a fleet.
Jobs are deterministic (the proven travel→buy→deliver→fulfill / survey→extract
loops) — the LLM's role is strategy (which job for which ship), not clicking every
leg. See `autopilot()` in tools.py for the agent-facing entry point.
"""
from __future__ import annotations
import asyncio
import re
from . import client as C
from . import events as E
from . import roles as R
from . import tools as T
# Control surface (knobs + presets + per-ship pins + decision log) on the shared protoAgent
# SDK helpers — see knobs.py. Imports graph.sdk transitively, so fleet only loads at runtime
# (when the engine starts), not at plugin-register time. The engine reads KNOBS.get(...) live.
from .knobs import ( # noqa: F401
DLOG, KNOBS, apply_strategy, current_strategy, decisions, knobs,
overrides, set_knob, set_override,
)
# The engine signals "trade round trip completed" by a result string starting with this — the
# one place the success marker lives, so a caller (e.g. _trade_route_loop) checks against it
# rather than re-hardcoding the literal (keeps job_trade's message and its readers in sync).
_TRADE_OK = "traded"
# Waypoint → (x, y), refreshed from the window-open marketplace scan (coordinates are
# static within a reset). Feeds analysis.rank_routes' time-aware lane scoring and the
# ride-along picker's nearby-sink check WITHOUT threading coords through every loop
# signature — the ranker itself stays pure (the map is an argument there; this cache is
# just the engine-side plumbing). Missing entries only mean that route/sink falls back
# to the time-blind score / delivery-stop-only sinks, never an error.
_WP_COORDS: dict = {}
async def _ship(sym: str) -> dict:
return await C.call("GET", f"/my/ships/{sym}")
async def _credits() -> int:
a = await C.call("GET", "/my/agent")
return a["credits"]
async def _wait_arrival(sym: str, poll: float = 8.0) -> dict:
"""Block (paced) until the ship is no longer IN_TRANSIT."""
while True:
s = await _ship(sym)
if s["nav"]["status"] != "IN_TRANSIT":
return s
await asyncio.sleep(poll)
async def _wait_cooldown(sym: str, poll: float = 5.0) -> None:
while True:
s = await _ship(sym)
cd = s.get("cooldown", {}).get("remainingSeconds", 0)
if not cd:
return
await asyncio.sleep(min(cd + 1, poll if poll > cd else cd + 1))
async def _refuel_if_low(sym: str, frac: float = 0.6, *, log=None) -> None:
"""Top off a ship's fuel if it's below ``frac`` and the current waypoint sells
fuel. Keeps ships from trickling down to DRIFT-only — call on arrival anywhere."""
s = await _ship(sym)
fuel = s.get("fuel", {})
cap = fuel.get("capacity", 0)
if cap == 0 or fuel.get("current", 0) >= frac * cap: # probe (0 fuel) or already full enough
return
wp = s["nav"]["waypointSymbol"]
try:
m = await C.call("GET", f"/systems/{T._system_of(wp)}/waypoints/{wp}/market")
if any(g.get("symbol") == "FUEL" for g in m.get("tradeGoods", [])):
await C.call("POST", f"/my/ships/{sym}/dock")
r = await C.call("POST", f"/my/ships/{sym}/refuel")
if log:
log(f"{sym}: refueled → {r['fuel']['current']}/{r['fuel']['capacity']}")
except C.SpaceTradersError:
pass
async def travel_to(sym: str, dest: str, *, max_hops: int = 12, log=None) -> bool:
"""Get a ship parked at ``dest``, looping st_travel through any fuel stops, and
top off fuel on arrival wherever it's sold.
st_travel issues one hop (CRUISE / DRIFT / fuel-station detour); we wait out
each leg and call again until the ship is actually at the destination.
"""
for _ in range(max_hops):
s = await _ship(sym)
nav = s["nav"]
if nav["status"] != "IN_TRANSIT" and nav["waypointSymbol"] == dest:
await _refuel_if_low(sym, log=log) # arrived — top off if fuel is sold here
return True
if nav["status"] == "IN_TRANSIT":
await _wait_arrival(sym)
continue
out = await T.st_travel.ainvoke({"ship": sym, "destination": dest})
if log:
log(f"{sym}: {out.splitlines()[0]}")
if out.startswith("REFUSED"): # too far to auto-DRIFT — don't loop, let the job skip it
return False
await _wait_arrival(sym)
return False
async def run_fleet(jobs: dict, *, log=None) -> dict:
"""Run a {ship_symbol: coroutine} map concurrently; return {ship: result}.
One shared client → one rate budget. A job that raises is captured as an
``error: ...`` string so one ship's failure never sinks the fleet.
"""
results: dict[str, str] = {}
async def _run(sym: str, coro):
try:
results[sym] = await coro
except C.SpaceTradersError as e:
results[sym] = f"error: {e}"
except Exception as e: # noqa: BLE001 — isolate one ship's failure
results[sym] = f"error: {type(e).__name__}: {e}"
if log:
log(f"{sym} done: {results[sym]}")
await asyncio.gather(*[_run(s, c) for s, c in jobs.items()])
return results
# ── shared job primitives ────────────────────────────────────────────────────
async def _held(sym: str, good: str) -> int:
s = await _ship(sym)
return sum(i["units"] for i in s["cargo"]["inventory"] if i["symbol"] == good)
async def _buy(sym: str, good: str, target: int, *, max_price: float | None = None,
floor: int = 0, log=None) -> None:
"""Buy up to ``target`` units of ``good`` — bounded by three guards:
* profitability: if ``max_price`` is set and the live unit price exceeds it, buy NOTHING
(a buy above the ceiling is a guaranteed loss — a saturated market or over-priced
contract good);
* working capital: never commit more than ``max_spend_frac`` of current credits to this
buy, so one high-value good can't drain the treasury into cargo and crater us when the
sell leg doesn't realize (analysis.affordable_units — the documented ASSAULT_RIFLES crash);
* treasury floor: with ``floor`` set, no chunk may take LIVE credits below it. A
caller's budget is a snapshot, but the treasury is fleet-SHARED and run_fleet
spends it concurrently — only a per-chunk live check can actually hold a
guaranteed floor (the gate rung's max(reserve_floor, buy_buffer/2): sized off
a round-start budget, its chunks used to keep buying while a trade hauler's
own leg drained the same treasury mid-flight).
"""
from .analysis import affordable_units
await C.call("POST", f"/my/ships/{sym}/dock")
# Live unit price — needed for BOTH guards. Fetched once up front (a buy is one round trip,
# so the extra market read is cheap relative to the leg).
s = await _ship(sym)
wp = s["nav"]["waypointSymbol"]
try:
m = await C.call("GET", f"/systems/{T._system_of(wp)}/waypoints/{wp}/market")
price = next((g["purchasePrice"] for g in m.get("tradeGoods", []) if g["symbol"] == good), None)
except C.SpaceTradersError:
price = None
if max_price is not None and price is not None and price > max_price:
if log:
log(f"{sym}: SKIP buy {good} @ {price} > ceiling {max_price:.0f} (would lose money)")
return
frac = KNOBS.get("max_spend_frac")
if frac is None: # explicit: a missing knob disables the cap, NOT a 0.0 value
frac = 1.0
while await _held(sym, good) < target:
s = await _ship(sym)
room = s["cargo"]["capacity"] - s["cargo"]["units"]
want = min(target - await _held(sym, good), room, 20)
if price: # cash guards, re-checked against the LIVE (shared) treasury each chunk
credits_now = await _credits()
want = affordable_units(credits_now, price, room, want, max_spend_frac=frac)
if floor:
want = min(want, max(0, (credits_now - int(floor)) // int(price)))
if want <= 0:
if log and price:
log(f"{sym}: holding cash — {good} @ {price} exceeds the per-trade cap "
f"({frac:g}× credits) or the cash floor; bought {await _held(sym, good)}/{target}")
break
try:
r = await C.call("POST", f"/my/ships/{sym}/purchase",
json={"symbol": good, "units": want})
if log:
log(f"{sym}: bought {r['transaction']['units']}×{good} @ {r['transaction']['pricePerUnit']}")
except C.SpaceTradersError as e:
if log:
log(f"{sym}: buy stopped — {e}")
break
async def _sell(sym: str, good: str, units: int, *, log=None) -> tuple:
"""Sell ``units`` of ``good`` at the current docked market, CHUNKED to the market's
per-transaction volume limit (``tradeVolume``) — a low-limit good (e.g. CLOTHING cap 20)
would otherwise error [4604] when dumped all at once and strand the ship. Returns
``(sold, revenue)`` summed from THIS ship's transaction receipts — never a /my/agent
delta: the treasury is fleet-global and run_fleet sells concurrently, so a balance
bracket books other ships' sales as this ship's."""
s = await _ship(sym)
wp = s["nav"]["waypointSymbol"]
try:
m = await C.call("GET", f"/systems/{T._system_of(wp)}/waypoints/{wp}/market")
vol = next((g.get("tradeVolume", 20) for g in m.get("tradeGoods", []) if g["symbol"] == good), 20)
except C.SpaceTradersError:
vol = 20
vol = vol or 20
sold, revenue = 0, 0
while sold < units:
try:
r = await C.call("POST", f"/my/ships/{sym}/sell",
json={"symbol": good, "units": min(units - sold, vol)})
sold += r["transaction"]["units"]
revenue += r["transaction"]["totalPrice"]
if log:
log(f"{sym}: sold {r['transaction']['units']}×{good} @ {r['transaction']['pricePerUnit']}")
except C.SpaceTradersError as e:
if log:
log(f"{sym}: sell stopped at {sold}/{units} {good} — {e}")
break
return sold, revenue
async def _dump_except(sym: str, keep: str, *, log=None) -> None:
"""Sell (or jettison) everything that isn't ``keep`` to free the hold."""
try:
await C.call("POST", f"/my/ships/{sym}/dock")
except C.SpaceTradersError:
pass
s = await _ship(sym)
for it in list(s["cargo"]["inventory"]):
if it["symbol"] == keep:
continue
await _sell(sym, it["symbol"], it["units"], log=log) # chunked to the market limit
left = await _held(sym, it["symbol"])
if left > 0: # market won't buy it → jettison to free the hold
try:
await C.call("POST", f"/my/ships/{sym}/jettison",
json={"symbol": it["symbol"], "units": left})
except C.SpaceTradersError:
pass
# ── jobs ─────────────────────────────────────────────────────────────────────
async def _ride_along_load(sym: str, deliver_wp: str, exclude: str, *, log=None) -> dict | None:
"""Fill the hold's SPARE room with a ride-along good before a contract leg departs:
something THIS market exports that the delivery stop (or a market a trivial hop from
it) imports — the trip is already paid for, so an empty corner of the hold is margin
left on the table (analysis.ride_along_pick; v2.7 research round). Bounded by every
existing buy guard: max_price = the confirmed resale (never a loss leg), the
max_spend_frac working-capital cap inside ``_buy``, and reserve_floor as the hard
per-chunk cash floor. ``exclude`` is the CONTRACT good — extra units of it would
blend into the delivery count. Best-effort BY CONTRACT (in both senses): any failure
returns None and the contract flow proceeds exactly as if this feature didn't exist —
the ride-along must never block, delay, or fail the contract leg."""
try:
s = await _ship(sym)
room = s["cargo"]["capacity"] - s["cargo"]["units"]
if room <= 0:
return None
wp = s["nav"]["waypointSymbol"]
system = T._system_of(wp)
from . import prices as _P
from .analysis import ride_along_pick
m = await C.call("GET", f"/systems/{system}/waypoints/{wp}/market")
pick = ride_along_pick(
m.get("tradeGoods") or [],
_P.price_map(system, max_age=KNOBS.get("route_max_age") or 900.0),
deliver_wp, min_margin=KNOBS.get("min_margin") or 1, exclude=exclude,
coords=_WP_COORDS or None)
if not pick:
return None
prior = await _held(sym, pick["good"])
await _buy(sym, pick["good"], prior + room, max_price=pick["sell_price"],
floor=KNOBS.get("reserve_floor") or 0, log=log)
if await _held(sym, pick["good"]) <= prior:
return None # guards said no — plain contract flow
if log:
log(f"{sym}: ride-along {pick['good']} → {pick['sell_at']} "
f"(+{pick['margin']}/unit alongside the contract leg)")
return pick
except Exception as e: # noqa: BLE001 — opportunistic: degrade to the plain contract flow
if log:
log(f"{sym}: ride-along skipped ({e})")
return None
async def _ride_along_sell(sym: str, ride: dict, *, log=None) -> None:
"""Realize the ride-along AFTER the contract delivery landed (never before — the
contract leg owns the schedule). Best-effort like the load: an unreachable or
refusing sink just leaves the cargo aboard for the next buy stop's ``_dump_except``
to realize — never a wedge, never a jettison."""
try:
held = await _held(sym, ride["good"])
if held <= 0:
return
at = (await _ship(sym))["nav"]["waypointSymbol"]
if at != ride["sell_at"] and not await travel_to(sym, ride["sell_at"], log=log):
return
await C.call("POST", f"/my/ships/{sym}/dock")
await _sell(sym, ride["good"], held, log=log)
except Exception as e: # noqa: BLE001 — the ride-along must never fail the contract job
if log:
log(f"{sym}: ride-along sell skipped ({e})")
async def job_contract(sym: str, contract_id: str, *, log=None) -> str:
"""Work a procurement contract end to end: accept → buy → deliver → fulfill.
Spare hold room on the buy→deliver leg carries a RIDE-ALONG good when one clears
the margin/space/budget guards (v2.7) — sold after each delivery, and any
ride-along failure silently degrades to the plain contract flow."""
cs = await C.call("GET", "/my/contracts")
ct = next((x for x in cs if x["id"] == contract_id), None)
if not ct:
return f"contract {contract_id} not found"
if not ct["accepted"]:
await C.call("POST", f"/my/contracts/{contract_id}/accept")
dv = ct["terms"]["deliver"][0]
good, deliver_wp, req = dv["tradeSymbol"], dv["destinationSymbol"], dv["unitsRequired"]
system = T._system_of(deliver_wp)
buys, _ = await T._good_markets(system, good)
if not buys:
return f"no market sells {good} in {system} (needs mining) — skipped"
buy_wp = buys[0]
# Profitability ceiling: the contract pays (advance + fulfillment) over the
# required units. Buying a unit above that is a net loss — _buy refuses to.
pay = ct["terms"].get("payment", {})
pay_per_unit = (pay.get("onAccepted", 0) + pay.get("onFulfilled", 0)) / max(req, 1)
cap = (await _ship(sym))["cargo"]["capacity"]
ride = None # this leg's ride-along pick (loaded at the buy stop, sold post-delivery)
while True:
cs = await C.call("GET", "/my/contracts")
ct = next(x for x in cs if x["id"] == contract_id)
dv = ct["terms"]["deliver"][0]
done = dv["unitsFulfilled"]
if done >= req or ct["fulfilled"]:
break
# Buy: only purchase if we're actually AT the buy market (else we'd loop on a
# bad-state error). If already holding enough of the good, skip the buy leg.
if await _held(sym, good) < min(req - done, cap):
if not await travel_to(sym, buy_wp, log=log):
return f"could not reach buy market {buy_wp} for {good} (stuck at {done}/{req})"
await _dump_except(sym, good, log=log)
await _buy(sym, good, min(req - done, cap), max_price=pay_per_unit, log=log)
if await _held(sym, good) == 0:
return (f"skipped {good} contract at {done}/{req}: buy price exceeds the "
f"{pay_per_unit:.0f}/unit the contract pays (would lose money)")
# Spare room rides along (best-effort; guards inside) — AFTER the contract
# good is aboard, so the contract's own load is never squeezed.
ride = await _ride_along_load(sym, deliver_wp, good, log=log)
# Deliver: NEVER dock+deliver unless we actually reached the destination —
# otherwise the API rejects it ([4510] not at delivery waypoint) and the loop
# spins on the error (the recurring K93→J66 stall).
if not await travel_to(sym, deliver_wp, log=log):
return f"could not reach delivery waypoint {deliver_wp} (stuck at {done}/{req}, holding {good})"
await C.call("POST", f"/my/ships/{sym}/dock")
u = await _held(sym, good)
await C.call("POST", f"/my/contracts/{contract_id}/deliver",
json={"shipSymbol": sym, "tradeSymbol": good, "units": u})
if log:
log(f"{sym}: delivered {u}×{good}")
if ride: # contract leg done — NOW realize the ride-along
await _ride_along_sell(sym, ride, log=log)
ride = None
r = await C.call("POST", f"/my/contracts/{contract_id}/fulfill")
return f"fulfilled {contract_id} ({req} {good}); credits {r['agent']['credits']:,}"
async def job_mining(sym: str, asteroid: str, ore: str, sell_wp: str, *, log=None) -> str:
"""Fill the hold with an ore at an asteroid, haul it to a market, and sell."""
await travel_to(sym, asteroid, log=log)
await C.call("POST", f"/my/ships/{sym}/orbit")
s = await _ship(sym)
cap = s["cargo"]["capacity"]
dry = 0
while (await _ship(sym))["cargo"]["units"] < cap and dry < 4:
await _wait_cooldown(sym)
out = await T.st_extract.ainvoke({"ship": sym, "prefer": ore})
if "Error" in out:
break
if ore not in out:
# survey to try to target the ore
await T.st_survey.ainvoke({"ship": sym})
dry += 1
else:
dry = 0
await _dump_except(sym, ore, log=log)
held = await _held(sym, ore)
if held == 0:
return f"{asteroid} yielded no {ore} (mine elsewhere or buy it)"
await travel_to(sym, sell_wp, log=log)
await C.call("POST", f"/my/ships/{sym}/dock")
r = await C.call("POST", f"/my/ships/{sym}/sell", json={"symbol": ore, "units": held})
return f"mined+sold {held}×{ore} for {r['transaction']['totalPrice']:,} cr"
async def _good_price(wp: str, good: str) -> tuple:
"""(purchasePrice, sellPrice) for ``good`` at ``wp`` — needs a ship present; (None, None) if unknown."""
p, s, _ = await _good_quote(wp, good)
return (p, s)
async def _good_quote(wp: str, good: str) -> tuple:
"""(purchasePrice, sellPrice, tradeVolume) for ``good`` at ``wp`` — needs a ship present;
(None, None, None) if unknown. tradeVolume is the per-transaction cap the saturation
guard sizes a delivery against."""
try:
m = await C.call("GET", f"/systems/{T._system_of(wp)}/waypoints/{wp}/market")
g = next((x for x in m.get("tradeGoods", []) if x["symbol"] == good), None)
return (g.get("purchasePrice"), g.get("sellPrice"), g.get("tradeVolume")) if g else (None, None, None)
except C.SpaceTradersError:
return (None, None, None)
async def job_trade(sym: str, good: str, buy_wp: str, sell_wp: str, *,
expected_sell: float | None = None, sink_vol: int | None = None,
log=None) -> str:
"""One buy-low / sell-high round trip for a good.
Requires a CONFIRMED sink: ``expected_sell`` is the importer's sell price from the FRESH
price map (carried on the ranked route). Without it we never commit a buy — that's the
no-blind-dispatch rule that kills the dead-end loop (a recalled / since-moved sink whose
price can't be confirmed). It also bounds the buy: we never pay above the resale we have
evidence for. Sized so it doesn't crash the sink — buy at most
``sink_volume_mult × sink_vol`` (the importer's tradeVolume), so one delivery moves the
import price ~one tier-step instead of cratering it.
"""
cap = (await _ship(sym))["cargo"]["capacity"]
# Confirmed-sink guard: only trade a route whose sink we have a fresh sell price for. No
# price → don't buy blind (the route is recalled/stale or the sink moved).
if not expected_sell:
return f"skipped {good} trade: no confirmed sell price at sink {sell_wp} — won't buy blind"
if not await travel_to(sym, buy_wp, log=log): # too far to auto-DRIFT → skip, don't buy elsewhere
return f"skipped {good} trade: couldn't reach buy waypoint {buy_wp} (too far)"
# Profitability guard: the ship is AT buy_wp now, so its buy price is live/real — bail if the
# spread vs the confirmed sink price has closed since the route was ranked.
buy_price, _ = await _good_price(buy_wp, good)
if buy_price and expected_sell <= buy_price:
return (f"skipped {good} trade: buy {buy_price} ≥ confirmed sell {expected_sell:.0f} "
f"({buy_wp}→{sell_wp}) — no margin, would lose money")
target = cap
if sink_vol: # saturation cap — keep a delivery ≈ one tier-step
target = min(cap, max(1, round(KNOBS.get("sink_volume_mult") * sink_vol)))
await _dump_except(sym, good, log=log)
await _buy(sym, good, target, max_price=expected_sell, log=log)
held = await _held(sym, good)
if held == 0:
return f"could not buy {good} at {buy_wp} (price ≥ resale or below cash cap — guarded)"
if not await travel_to(sym, sell_wp, log=log): # bought but the sink is too far to auto-DRIFT
return f"bought {held}×{good} but couldn't reach sell waypoint {sell_wp} (too far) — holding"
await C.call("POST", f"/my/ships/{sym}/dock")
sold, _ = await _sell(sym, good, held, log=log)
E.emit("trade_executed", {"ship": sym, "good": good, "buy_at": buy_wp,
"sell_at": sell_wp, "units": sold})
return f"{_TRADE_OK} {sold}×{good} (of {held} hauled)"
async def job_scout(sym: str, market_waypoints: list, deadline: float, *, log=None) -> dict:
"""Visit markets and record live per-unit prices into the persistent price map (a
ship present unlocks them) — this is what fills the map the trade finder reasons over.
Stops at the window ``deadline`` so EVERY ship's job finishes together and the engine
loops to a fresh window promptly — otherwise a long, unbounded sweep strands the faster
contract/trade ships idle (they finish, then wait on the scouts). Resumes next window;
the price map persists, so coverage accrues across windows either way.
"""
from . import prices as _pricemem
seen: dict[str, dict] = {}
for wp in market_waypoints:
if _now() >= deadline:
break
if not await travel_to(sym, wp, log=log):
continue
await C.call("POST", f"/my/ships/{sym}/dock")
system = T._system_of(wp)
m = await C.call("GET", f"/systems/{system}/waypoints/{wp}/market")
tg = m.get("tradeGoods", [])
_pricemem.record_market(system, wp, tg)
seen[wp] = {g["symbol"]: (g["purchasePrice"], g["sellPrice"]) for g in tg}
return seen
# ── autopilot — drive the whole fleet toward an objective for a time window ──
def _now() -> float:
return asyncio.get_event_loop().time()
async def _cruise_reachable(system: str, frm: str, to: str, fuel_cap) -> bool:
"""Can a ship CRUISE from ``frm`` to ``to`` (fast) — directly within one tank, or via a fuel
station within CRUISE range to hop from? False ⇒ only a multi-hour DRIFT would reach it (the
leg that wedges a contract ship for hours, e.g. the J58 delivery). Fuel-free ships (probes)
are always reachable. Best-effort: on any lookup failure it returns True, so the guard never
false-declines a workable contract."""
if not fuel_cap:
return True
try:
if await T._distance(system, frm, to) <= fuel_cap:
return True # one CRUISE covers it
fstop = await T._nearest_fuel(system, frm)
except C.SpaceTradersError:
return True # can't tell → don't false-decline
if not fstop or fstop[0] == frm:
return False # no fuel station to break the leg up → long DRIFT only
return fstop[1] <= fuel_cap # a fuel station is within one CRUISE → can hop + continue
async def _contract_loop(sym: str, deadline: float, claimed: set, lock, *, log=None) -> str:
"""A cargo ship: claim/negotiate a procurement contract, work it, repeat."""
hq = (await C.call("GET", "/my/agent")).get("headquarters") # negotiate at a faction waypoint
n = 0
while _now() < deadline:
async with lock:
cs = await C.call("GET", "/my/contracts")
# Prefer an accepted, unfulfilled contract; else pick up an OFFERED one — a
# fresh agent STARTS with an offered contract, and the API refuses to
# negotiate a new one (4511) while an offer is pending, so we must ACCEPT the
# offer, not negotiate around it (the bug that parked the starter contract).
ct = (next((c for c in cs if c["accepted"] and not c["fulfilled"]
and c["id"] not in claimed), None)
or next((c for c in cs if not c["accepted"] and not c["fulfilled"]
and c["id"] not in claimed), None))
if ct is None:
try:
# Negotiate only while DOCKED at a faction waypoint — travel to HQ
# and dock first, or it errors "not docked".
if hq:
await travel_to(sym, hq, log=log)
try:
await C.call("POST", f"/my/ships/{sym}/dock")
except C.SpaceTradersError:
pass
ct = (await C.call("POST", f"/my/ships/{sym}/negotiate/contract"))["contract"]
except C.SpaceTradersError as e:
return f"{n} contract(s) done; no more available ({e})"
# Sourceability guard (offered + negotiated alike): don't accept a contract
# whose good no market in-system sells — it's un-fulfillable, and an accepted
# contract can't be cancelled, so it would block this ship until it expires.
ndv = ct["terms"]["deliver"][0]
g = ndv["tradeSymbol"]
buys, _ = await T._good_markets(T._system_of(ndv["destinationSymbol"]), g)
if not buys:
return (f"{n} done; declined an un-sourceable {g} contract "
f"(no market sells it in-system) — parking, not hauling dead weight")
# Reachability guard: decline a contract whose delivery is beyond ~one tank from
# the source. The ship CAN technically DRIFT there (1 fuel), but a 700u+ DRIFT
# takes HOURS and won't finish inside an engine window — it just wedges the only
# cargo ship on an un-fulfillable accepted contract (the J66/DRUGS trap). Work
# in-range contracts + supply-chain trade instead, and bank toward a longer-range
# hauler. (We tried value-aware accept of far lucrative ones — the occasional 169k
# win wasn't worth the repeated wedging; range is a SHIP problem, not a guard one.)
try:
deliver_wp = ndv["destinationSymbol"]
fuel_cap = (await _ship(sym))["fuel"].get("capacity") or 400
if not await _cruise_reachable(T._system_of(deliver_wp), buys[0], deliver_wp, fuel_cap):
return (f"{n} done; declined {g} contract — delivery {deliver_wp} is only "
f"reachable from the source ({buys[0]}) by a multi-hour DRIFT (no CRUISE "
f"route within fuel range). A far contract just wedges the ship for hours; "
f"working in-range trade + banking toward a longer-range hauler instead.")
except Exception: # noqa: BLE001 — the guard must never crash the loop
pass
cid = ct["id"]
if not ct["accepted"]:
try:
await C.call("POST", f"/my/contracts/{cid}/accept")
except C.SpaceTradersError as e:
return f"{n} contract(s) done; couldn't accept {g} contract ({e})"
claimed.add(cid)
if log:
log(f"{sym}: working contract {cid}")
res = await job_contract(sym, cid, log=log)
if log:
log(f"{sym}: {res}")
n += 1
if "skipped" in res or "could not" in res:
return f"{n-1} done; stuck: {res}"
return f"completed {n} contract(s)"
def _traded_symbols(m: dict) -> set:
"""Every good a market lists (live ``tradeGoods`` when a ship is present, plus the
static import/export/exchange lists) — a sell of anything else is a guaranteed 400."""
return {g["symbol"] for k in ("tradeGoods", "imports", "exports", "exchange")
for g in m.get(k) or []}
async def _sell_all_here(sym: str, *, log=None) -> tuple:
"""Sell every cargo good the current docked market actually TRADES, splitting what
remains by WHY it remains. The market's trade list is checked FIRST — a sell for an
unlisted good is a guaranteed 400, and a full hold of them replays it every run (the
CE5C trap: 28 straight rejected sells at a market that didn't buy the ore). Returns
``(credits_earned, refused, kept)``: ``refused`` = goods missing from the trade list
(never sellable HERE — recovery candidates); ``kept`` = goods the market lists but
didn't absorb this visit (saturation [4604] / a transient error) — those stay in the
hold, because a glut clears (saturation damping is documented-normal) and destroying
a listed good would turn every demand dip into cargo loss. A per-good failure logs
and moves on — an engine job must never crash the ops loop. An unreadable market
skips the pre-check (attempt everything) and every failure lands in ``kept`` — we
can't PROVE a market refuses a good without reading its trade list, and jettison
needs proof. The trade list is re-read at every stop, never cached — import lists
evolve with the economy, and a stale allowlist would re-arm the very trap this
check removes. ``credits_earned`` is summed from THIS ship's sell receipts, never
a /my/agent bracket: the loops' zero-progress strike-out rides on it, and the
treasury is fleet-global — with run_fleet selling concurrently, another ship's
sale landing inside the bracket would read as progress here and reset the
strike-out forever (the CE5C spin, back under normal fleet load)."""
s = await _ship(sym)
wp = s["nav"]["waypointSymbol"]
try:
m = await C.call("GET", f"/systems/{T._system_of(wp)}/waypoints/{wp}/market")
traded = _traded_symbols(m)
except C.SpaceTradersError:
traded = None
earned, refused, kept = 0, {}, {}
for it in list(s["cargo"]["inventory"]):
good, units = it["symbol"], it["units"]
if traded is not None and good not in traded:
refused[good] = units
if log:
log(f"{sym}: {wp} doesn't trade {good} — skipping sell ({units} held)")
continue
try:
_, revenue = await _sell(sym, good, units, log=log) # chunked to the market limit
earned += revenue
left = await _held(sym, good)
except C.SpaceTradersError as e:
left = units # can't confirm a sale → count the stack as still held
if log:
log(f"{sym}: sell {good} failed — {e}")
if left > 0:
kept[good] = left
return earned, refused, kept
async def _reroute_or_jettison(sym: str, market: str, refused: dict, *,
must_trade: set, allow_reroute: bool,
buyers: dict, log=None) -> tuple:
"""Bounded recovery when the assigned sell market's trade list REFUSED goods (the
CE5C trap). EVERY refused good first gets a buyer-existence scan (T._good_markets
— importers plus exchange, the same definition the assignment-time _markets_buying
pool uses), cached per window in ``buyers`` so no good is scanned twice: that scan
is the PROOF jettison requires. 'Reroute budget spent' alone must never destroy
cargo an in-system market provably takes — mixed tails are the live norm (the
incident hold was 15/15 mixed ore) and the tail can be the high-value part. Then
at most ONE side-trip per window to the nearest market buying the biggest refused
stack. A good whose scan PROVED no buyer anywhere is jettisoned — a hold of
unsellable ore blocks every future extract, so freeing it beats hoarding it. A
good with a known buyer, or an unproven scan (a transient failure anywhere in it
— a waypoints page OR a single market GET — is not evidence; it retries next
run), rides along in the hold instead; the
loop's zero-progress strike-out bounds the window if held goods wedge it shut.
Only ``refused`` goods are recovery candidates: a good the market LISTS is a sale
deferred, not a sale denied — the same gate applies AT the side-trip market, so a
[4604] glut there is spared too. Any failure ON the side-trip itself (drift-cap
REFUSED travel, a dock/sell error at the buyer — one exhausted 429 retry was
enough live) returns ok=False with NOTHING jettisoned: the failure happened at
the very market that buys the haul, and destroying it there would convert a
transient into total loss.
Returns ``(extra_credits, new_market | None, ok, spent)``. ``new_market`` proposes
moving the standing sell stop and is set ONLY when the side-trip market also trades
everything in ``must_trade`` (the yield goods the assigned market WAS buying) —
without that gate a 3-unit tail hijacks the stop and every later run strands the
primary ore the assigned market still buys. ``spent`` says the window's side-trip
was consumed — by the TRIP, not the scans: the ``buyers`` cache already stops the
re-scan rate leak (~a page of GETs per good per run) that motivated spending on
the attempt, so an attempt that never launched no longer burns the one trip.
ok=False ⇒ the side-trip failed or even jettison did, and the caller bails out for
the window exactly like the extract-rejection guard — never an unbounded retry
storm."""
extra, new_market, spent, deferred = 0, None, False, set()
system = T._system_of(market)
for good in refused:
if good in buyers:
continue
try:
_, sellers = await T._good_markets(system, good) # importers/exchangers pay
except C.SpaceTradersError:
continue # unproven — spared below, and the scan retries next run
buyers[good] = [w for w in sellers if w != market]
if allow_reroute:
# Biggest stack first — the one side-trip should recover the most cargo it can.
for good in sorted(refused, key=refused.get, reverse=True):
sellers = buyers.get(good) or []
if not sellers:
continue
target = sellers[0]
if len(sellers) > 1: # nearest buyer — don't cross the system for it
try:
dists = [(await T._distance(system, market, w), w) for w in sellers]
target = min(dists)[1]
except C.SpaceTradersError:
pass
spent = True
if log:
log(f"{sym}: {market} won't buy {good} — side-trip to {target} (buys it)")
if not await travel_to(sym, target, log=log):
if log:
log(f"{sym}: couldn't reach side-trip buyer {target} — "
f"stopping for the window (haul kept)")
return extra, None, False, spent
try:
await C.call("POST", f"/my/ships/{sym}/dock")
extra, _, t_kept = await _sell_all_here(sym, log=log)
deferred = set(t_kept) # listed HERE, just not absorbed — spare below
# Adopt the target as the standing stop only if it covers every
# good the assigned market was buying from this yield — else this
# stays a side-trip and the loop returns to the assigned market.
try:
tm = await C.call("GET",
f"/systems/{system}/waypoints/{target}/market")
if must_trade <= _traded_symbols(tm):
new_market = target
except C.SpaceTradersError:
pass
# Decision-logged, not just narrated: the DecisionLog tail is what the
# window's lesson synthesis distills, so the KB learns which markets
# refuse which goods — the narrative log dies with the window.
DLOG.record("sell-guard",
f"{sym}: {market} refused {good} — "
+ (f"sell stop moved to {target}" if new_market else
f"sold at {target}, stop stays {market} (it buys goods "
f"{target} doesn't)"))
except C.SpaceTradersError as e:
if log:
log(f"{sym}: reroute sell at {target} failed — {e} "
f"(stopping for the window, haul kept)")
return extra, None, False, spent
break # ONE trip, win or lose — bounded
ok = True
for good in list(refused):
if good in deferred:
# The side-trip market LISTS this good — the chunked sell just hit its
# demand limit ([4604]) partway. Same evidence gate as `kept` at the
# assigned stop: it rides along (when the stop moved here the next visit
# sells it; otherwise the next run's spent-budget pass settles it).
continue
left = await _held(sym, good)
if left <= 0:
continue
if good not in buyers or buyers[good]:
# A known in-system buyer — or no proof there isn't one — spares the good:
# jettison is reserved for scan-proven no-buyer cargo. It rides along; if
# held goods wedge the hold shut, the strike-out ends the window haul-intact.
if log:
why = (f"{buyers[good][0]} buys it" if buyers.get(good)
else "buyer scan unproven")
log(f"{sym}: holding {left}×{good} ({why}) — jettison needs a "
f"scan-proven no-buyer")
continue
try:
await C.call("POST", f"/my/ships/{sym}/jettison",
json={"symbol": good, "units": left})
DLOG.record("sell-guard",
f"{sym}: jettisoned {left}×{good} — {market} refused it and the "
f"buyer scan proved no in-system market takes it")
if log:
log(f"{sym}: jettisoned {left}×{good} — no in-system market buys it "
f"(freeing the hold so extraction can continue)")
except C.SpaceTradersError as e:
ok = False
if log:
log(f"{sym}: couldn't jettison {good} ({e})")
return extra, new_market, ok, spent
async def _sell_stop(sym: str, market: str, *, allow_reroute: bool, buyers: dict,
log=None) -> tuple:
"""Dock + sell the haul at ``market``; goods its trade list REFUSES enter the
bounded recovery — goods it lists but didn't absorb ride along for the next visit.
Shared by the mining and siphon loops (identical trap, identical fix). ``buyers``
is the loop's per-window buyer-scan cache. Returns
``(credits, new_market | None, ok, reroute_spent)``."""
await C.call("POST", f"/my/ships/{sym}/dock")
# Snapshot the yield BEFORE selling: the goods this market trades from it (sold or
# kept) are the ones a reroute target must also cover before the stop may move.
haul = {it["symbol"] for it in (await _ship(sym))["cargo"]["inventory"]}
earned, refused, kept = await _sell_all_here(sym, log=log)
if kept and log:
log(f"{sym}: {market} trades {', '.join(kept)} but didn't absorb it — "
f"keeping the tail for the next run")
if not refused:
return earned, None, True, False
extra, new_market, ok, spent = await _reroute_or_jettison(
sym, market, refused, must_trade=haul - set(refused),
allow_reroute=allow_reroute, buyers=buyers, log=log)
return earned + extra, new_market, ok, spent
# ── survey-fed mining (v2.7) ─────────────────────────────────────────────────────────────
# Surveyed extracts pull from the survey's deposit signature (typically a 2–3× value lift
# over blind extraction), so when a hull on the rock carries a MOUNT_SURVEYOR_* it charts
# the asteroid once per fill cycle and EVERY miner on that rock extracts against the best
# cached survey. The cache is per-ASTEROID, module-level: surveys are per-rock server
# objects, not per-ship, so one frigate's chart feeds the whole dig. Surveys expire and
# exhaust server-side — every survey-shaped failure degrades to a plain extract (the
# fall-back doctrine everywhere in this engine), never a bail; and a fleet whose surveyor
# hull isn't free (it may be the contract lead) just mines plain, exactly as before.
_ASTEROID_SURVEYS: dict[str, list] = {} # asteroid → cached survey objects (API shape)
_SURVEY_SIZE = {"SMALL": 0, "MODERATE": 1, "LARGE": 2}
def _survey_fresh(sv: dict) -> bool:
"""True while a survey's ``expiration`` is in the future. Unknown/unparseable dates
count as fresh — the API is the real arbiter, and a spent survey costs only one
rejected extract that immediately falls back to plain. Pure — host-free testable."""
exp = (sv or {}).get("expiration")
if not exp:
return True
try:
from datetime import datetime, timezone
return datetime.fromisoformat(exp.replace("Z", "+00:00")) > datetime.now(timezone.utc)
except ValueError:
return True
def _best_survey(asteroid: str) -> dict | None:
"""The best still-fresh cached survey for ``asteroid`` (largest deposit size wins),
pruning expired ones on the way. None ⇒ mine plain."""
fresh = [sv for sv in _ASTEROID_SURVEYS.get(asteroid) or [] if _survey_fresh(sv)]
_ASTEROID_SURVEYS[asteroid] = fresh
if not fresh:
return None
return max(fresh, key=lambda sv: _SURVEY_SIZE.get(sv.get("size"), 0))
def _drop_survey(asteroid: str, sv: dict) -> None:
"""Discard one survey the API rejected (expired/exhausted) so no extract replays it."""
_ASTEROID_SURVEYS[asteroid] = [x for x in _ASTEROID_SURVEYS.get(asteroid) or []
if x is not sv]
async def _refresh_surveys(sym: str, asteroid: str, *, log=None) -> None:
"""Chart the rock when the cache holds no fresh survey (called once per fill cycle —
a still-valid chart is never re-burned: the survey shares the extract cooldown, so a
redundant one costs a mining swing). Best-effort: any failure logs and leaves plain
extraction untouched — a survey is an optimizer, never a gate.
The survey POST inherits the extract path's ONE cooldown-skew retry (v2.7.1): the
survey shares the mining cooldown, so the same seconds-wide clock skew that costs an
extract a swing 409s the survey too — and without the retry the chart almost never
lands (LIVE: the first survey-fed dig 409'd every attempt, mining plain forever). On
that 409 alone, wait the server-stated remainder and retry once; every other
rejection stays best-effort log-and-continue."""
if _best_survey(asteroid) is not None:
return
for attempt in range(2):
try:
await _wait_cooldown(sym) # survey and extract share the one cooldown
d = await C.call("POST", f"/my/ships/{sym}/survey")
_ASTEROID_SURVEYS[asteroid] = list(d.get("surveys") or [])
if log:
log(f"{sym}: surveyed {asteroid} — {len(_ASTEROID_SURVEYS[asteroid])} site(s) "
f"charted for the diggers")
return
except C.SpaceTradersError as e:
if attempt == 0 and _is_cooldown_conflict(str(e)):
await asyncio.sleep(_conflict_wait_s(str(e))) # server's clock is authoritative
continue
if log:
log(f"{sym}: survey failed at {asteroid} ({e}) — mining plain this cycle")
return
async def _extract_once(sym: str, asteroid: str) -> str:
"""One extract swing — survey-targeted when a fresh chart for this rock is cached,
plain otherwise. Returns the same string shape ``st_extract`` does (``Error: …`` on a
rejection) so the loop's terminal guard reads both paths identically. A survey-shaped
rejection (expired/exhausted server-side — the API says which by naming the survey)
drops the dead chart and falls back to a PLAIN extract in the same swing: survey
trouble must never cost a mining window."""
sv = _best_survey(asteroid)
if sv is not None:
try:
d = await C.call("POST", f"/my/ships/{sym}/extract", json={"survey": sv})
y = d.get("extraction", {}).get("yield", {})
return (f"{sym} extracted {y.get('units', '?')}×{y.get('symbol', '?')} "
f"(survey-targeted)")
except C.SpaceTradersError as e:
if "survey" in str(e).lower(): # spent chart — same fallback as st_extract's
_drop_survey(asteroid, sv)
else:
return f"Error: {e}"
return await T.st_extract.ainvoke({"ship": sym})
def _is_cooldown_conflict(out: str) -> bool:
"""True iff a rejected extract/siphon is the 409 cooldown-skew shape — the server's
cooldown clock and ours disagree by a hair ([4000] "ship … is on cooldown", or a bare
HTTP 409 with no envelope). LIVE: five of these in one night, each one tripping the
bail-on-reject guard and killing the miner's whole window over a seconds-wide skew.
That guard exists for TERMINAL rejections (no mount / not an asteroid — the J58 spam),
so only this one transient class earns a bounded retry; every other rejection stays
terminal. Pure — host-free testable."""
t = out or ""
return "[4000]" in t or "HTTP 409" in t or "on cooldown" in t.lower()
def _conflict_wait_s(out: str, cap: float = 70.0) -> float:
"""Seconds to wait before the ONE cooldown-skew retry — the SERVER-stated remainder
parsed from the rejection itself ("… on cooldown for N second(s)"), plus a second of
slack. The whole point of the skew is that OUR cooldown read said ready while the
server's clock disagreed — so re-reading our side can wait ZERO and burn the single
retry on an instant second 409 (the same window-killer, one call later). The
rejection is the server's authoritative clock, so trust it first; ``_wait_cooldown``
still runs after as the idiomatic backstop. Capped (extract/survey cooldowns top out
around 70 s — a parse of some unrelated number must not stall the window); an
unparseable message → 0 (the _wait_cooldown re-read stays the only wait, exactly the
pre-parse behavior). Pure — host-free testable."""
m = re.search(r"(\d+(?:\.\d+)?)\s*second", out or "")
return min(float(m.group(1)) + 1.0, cap) if m else 0.0
async def _mining_loop(sym: str, deadline: float, asteroid: str, market: str, *, log=None) -> str:
"""A mining ship: fill the hold at an asteroid, sell at a market, repeat.
Guarded so an impossible job can't spam the rate budget: a ship with no mining mount
(a pin can force a laser-less hull here), a failed travel to the rock, or an extract the
API rejects all bail out cleanly instead of re-extracting wherever the ship sits — that
was the J58 case (a pinned light-hauler 'extracting' at a marketplace, looping on 3001).
The SELL side is guarded too (the CE5C trap — a market that doesn't buy the ore left the
hold full, so extraction skipped and the loop replayed sell-400s until the deadline):
goods the market's trade list refuses get ONE reroute to a buying market, a jettison only
when a per-good buyer scan PROVED no in-system market takes them, else a clean bail —
while listed-but-unabsorbed goods stay held for the next visit, and an unreachable sell
market ends the window with the haul intact (selling from the rock is the same 400-spam
trap, and cargo the market DOES buy must never be destroyed over travel). A zero-progress
strike-out closes the last spin path: a market that lists the ore but never absorbs it
(or one whose data can't be read) produces sell stops that neither earn nor shrink the
hold — three straight of those end the window, haul kept, instead of replaying rejected
sells until the deadline (the CE5C shape with the refusal check passed)."""
me = await _ship(sym)
if not R.can_mine(me):
if log:
log(f"{sym}: no mining mount — can't mine (pin a mining-capable ship instead)")
return f"{sym} has no mining laser — skipped mining"
surveyor = R.can_survey(me) # a MOUNT_SURVEYOR_* hull charts its rock for every digger
runs, earned, stalls = 0, 0, 0
rerouted = False # one sell-reroute per window — bounded recovery, not a wander
buyers: dict = {} # good → in-system seller wps: the window's buyer-scan proof cache
while _now() < deadline:
if not await travel_to(sym, asteroid, log=log): # never extract unless we REACHED the rock
if log:
log(f"{sym}: couldn't reach asteroid {asteroid} — skipping mining this window")
return f"could not reach {asteroid} — no mining"
await C.call("POST", f"/my/ships/{sym}/orbit")
if surveyor:
# Once per fill cycle: refresh the shared per-asteroid chart if none is fresh.
# Best-effort — a failed survey mines plain; a fleet with no free surveyor
# (the frigate may be on contract duty) never reaches here at all.
await _refresh_surveys(sym, asteroid, log=log)
cap = (await _ship(sym))["cargo"]["capacity"]
conflicted = False # one 409 (cooldown-skew) retry per streak — see _is_cooldown_conflict
while (await _ship(sym))["cargo"]["units"] < cap and _now() < deadline:
await _wait_cooldown(sym)
out = await _extract_once(sym, asteroid)
if "Error" in out:
if _is_cooldown_conflict(out) and not conflicted:
# Cooldown skew (LIVE: 5 windows killed in one night): wait out the
# SERVER-stated remainder from the rejection (our own cooldown read
# said ready — that's the skew), re-read as backstop, retry ONCE. A
# second consecutive rejection — 409 or otherwise — is terminal below.
conflicted = True
if log:
log(f"{sym}: extract hit cooldown skew (409) — waiting it out, one retry")
await asyncio.sleep(_conflict_wait_s(out))
await _wait_cooldown(sym)
continue
# The API rejected the extract (not an asteroid / no mount / etc.) — terminal,
# not transient: stop instead of retrying it every cooldown and burning budget.
if log:
log(f"{sym}: extract rejected ({out.strip()[:60]}) — stopping mining")
return f"{sym} can't extract at {asteroid}: {out.strip()[:80]}"
conflicted = False
if not await travel_to(sym, market, log=log):
# Never sell (or recover) without REACHING the marketplace: docked at the
# rock every sell is a 400 and the pre-check can't read a market, so the
# recovery would destroy a perfectly sellable haul — bail like the extract
# guard instead, cargo intact for the next window.
if log:
log(f"{sym}: couldn't reach sell market {market} — stopping mining (haul kept)")
return (f"could not reach sell market {market} — stopping mining "
f"({runs} run(s), +{earned:,} cr)")