Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,69 @@ export→import route refills forever. So `best_route` ranks by **margin × trad
(per-cycle throughput), not raw spread: a 10% route moving 60 units beats a 50% one capped
at 5. Cross-market `EXCHANGE` spreads are only a fallback when no export→import pair exists.

**Time-aware lane scoring (v2.7):** the fleet's real budget is *ship-time*, so when the
engine knows a route's endpoint coordinates (the window-open marketplace scan carries
them) the score becomes **net profit per second**: `net = margin × volume − fuel` over
`travel + stop overhead`, with a first-order CRUISE model (fuel ≈ distance;
travel ≈ distance × 25 / nominal hauler speed 30; 15 s fixed overhead per stop — the
constants are documented in `analysis.py`). A fat lane 400 units out no longer starves
credits/hr while a thinner lane 10 units away compounds six times as often. Every route
in a time-aware ranking scores in the same unit: one with an unmapped endpoint is scored
at a pessimistic nominal distance (400 units — an unproven endpoint can't claim to be
near), **not** its raw margin × volume — raw is credits per *visit* while mapped routes
score credits per *second*, and mixing the units would let any unknown-distance lane
structurally outrank every mapped one. Callers that pass no coordinates at all keep the
time-blind margin × volume ranking, so nothing regresses where the whole ranking is
coordinate-free. The engine's window-open marketplace scan is **paginated** (the API caps
a page at 20), so markets past page 1 get coordinates too instead of ranking as
unknown-distance forever.

### Research-round upgrades (v2.7) — ride-along, rotation, surveys

Three engine upgrades from the operator's external deep-research synthesis:

- **Contract ride-along** — contracts pay for the trip, so an empty corner of the hold
is margin left on the table. After the contract units are aboard (never before — the
contract's own load is never squeezed) and before the buy market is departed, spare
room carries a good the **current market exports** that the **delivery stop imports**
(or a market a trivial, coordinate-proven hop from it), picked at the best margin at
or above `min_margin` and bounded by every existing buy guard (`max_spend_frac`,
`reserve_floor` as a hard per-chunk floor, never above the confirmed resale). It's
sold right after the delivery lands. Strictly opportunistic: **any** ride-along
failure — no pick, a dead price store, a refused buy, an unreachable sink — degrades
to the plain contract flow; it can never block, delay, or fail the contract leg.
- **Proactive route rotation** (`route_rotate_cycles`, default 4, `0` = off) — repeated
deliveries walk a sink up the supply tiers, so waiting for the margin to collapse
(the strike hysteresis) means the last windows on a lane are the worst ones. The plan
now counts each hauler's **consecutive profitable windows on one route** and, at the
threshold, prefers swapping it onto the best unused fresh route — same no-steal rules
as any assignment (never a route another hauler holds or carries in), counter reset on
the swap. With no alternative the hauler **keeps its lane** — rotation never idles a
profitable ship, it just fires the first window an alternative appears. A vacated lane
**rests for the remainder of the window** — no later hauler may pick it up until the
next reconcile — so the saturated sink actually gets its breather instead of rotation
degenerating into a synchronized fleet-wide reshuffle (haulers assigned together hit
the threshold together; the staggered pickup also desyncs their counters).
- **Survey-fed mining** — when a hull on the rock carries a `MOUNT_SURVEYOR_*` (the
command frigate does), it charts the asteroid once per fill cycle and **every miner on
that rock extracts against the best cached survey** (largest deposit signature),
typically a 2–3× value lift over blind extraction. Surveys expire and exhaust
server-side: a survey-shaped rejection drops the dead chart and the same swing falls
back to a plain extract — survey trouble never costs a window. No free surveyor hull
(it may be on contract duty) ⇒ plain extraction, exactly as before.

Alongside these, the **extract-409 retry**: a rejected extract/siphon that is the
cooldown-skew shape (`[4000]`/HTTP 409 — the server's cooldown clock and ours disagree
by a hair; five windows died to it in one live night) waits out the **server-stated
remainder parsed from the rejection itself** (our own cooldown read saying "ready" is
exactly what the skew means, so re-reading it alone could burn the retry instantly),
re-reads the ship's cooldown as a backstop, and retries **once**. A second consecutive
rejection — or any other rejection class (wrong waypoint, no mount) — stays terminal, so
the bail-on-reject guard that stops budget-burning extract spam is not weakened. The v2.7 research round also
corrected the reinvest **price book** to observed yard prices (probe ≈ 28k, light hauler
≈ 393k) so affordability gates stop delaying compounding past the point the treasury can
actually pay.

### Capital deployment (v2.5) — the ladder, the gate rung, and when it holds cash

Surplus credits are **deployed, not hoarded** — and a watch trips if they ever sit idle:
Expand Down
141 changes: 134 additions & 7 deletions analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
60 units beats a 50% route capped at 5.
"""

# PEP 604 unions (`dict | None`) in the signatures below are evaluated at def time,
# so without this future import the module fails to IMPORT on Python 3.9 — and since
# tools.py/fleet.py/__init__.py all import from here, that takes the whole plugin down.
from __future__ import annotations


# Supply tiers, ordered cheapest-to-buy / most-saturated-to-sell-into.
_SUPPLY_ORDER = {"SCARCE": 0, "LIMITED": 1, "MODERATE": 2, "HIGH": 3, "ABUNDANT": 4}
Expand All @@ -25,8 +30,61 @@ def _supply_tier(supply) -> int:
return _SUPPLY_ORDER.get((supply or "").upper(), 2)


# ── time-aware lane scoring (v2.7) — net profit per SECOND, not per round trip ────────────
# margin × volume is throughput per VISIT, but a visit to a lane 400 units away costs ~6×
# the time of one 60 units away — the fleet's real budget is ship-time, so the research
# round's first finding was that a fat-but-far lane can starve credits/hr while a thinner
# near lane compounds. With waypoint coordinates the score becomes net-per-travel-second.
# First-order CRUISE model (the API's own shape), constants documented so the strategist
# can reason about them:
# * fuel burned ≈ distance (1 fuel per distance unit on CRUISE), costing about
# _FUEL_CR_PER_DIST credits per unit at typical FUEL market prices;
# * travel seconds ≈ distance × 25 / engine speed — scored at a NOMINAL hauler speed
# (_NOMINAL_SPEED, a light hauler's drive) since ranking is relative, not per-hull;
# * plus a fixed _STOP_OVERHEAD_S per stop (dock/undock/orbit + transaction pacing),
# _ROUTE_STOPS stops per leg (the buy end and the sell end).
# With no coordinate map at all the score falls back to the time-blind margin × volume —
# existing callers (and the price-map-only paths) rank exactly as before.
_NOMINAL_SPEED = 30.0 # nominal hauler engine speed (ION_DRIVE_II class)
_STOP_OVERHEAD_S = 15.0 # fixed seconds per stop (dock/undock + market round trip)
_ROUTE_STOPS = 2 # a lane has two ends — one stop overhead at each
_FUEL_CR_PER_DIST = 1.0 # ≈ credits of CRUISE fuel per distance unit
# Distance assumed for a route with a coordinate-UNKNOWN endpoint inside a time-aware
# ranking. The trap this guards: raw margin × volume is credits per VISIT (thousands)
# while the time-aware score is credits per SECOND (< ~100), so letting an unmapped
# route keep its raw base in a mixed ranking made ANY coord-unknown lane structurally
# dominate EVERY mapped one — the ranking systematically preferred unknown-distance
# lanes, the exact inversion of the feature. Instead the unmapped route is scored AS IF
# it were this far out: pessimistic, because an unproven endpoint can't claim to be
# near (the same doctrine as ride_along_pick's coordinate-proven sinks), but finite —
# a monster-throughput unmapped lane can still win, and the moment a probe maps the
# endpoint the real distance takes over. Falling back to the raw time-blind base is
# reserved for a WHOLE ranking without coordinates, where every score shares that unit.
_UNMAPPED_DIST = 400.0 # ≈ a far cross-system lane (the fat-but-far case from research)


def _lane_score(margin: int, volume: int, buy_wp: str, sell_wp: str,
coords: dict | None) -> float:
"""One route's ranking key. Time-blind (margin × volume) when the ranking carries no
coordinate map at all; net-per-second for EVERY route otherwise — the real distance
when both endpoints are mapped, the pessimistic ``_UNMAPPED_DIST`` when either is
not, so all scores in one ranking share one unit (see the constants above). Pure."""
base = margin * max(volume, 1)
if not coords:
return base
b, s = coords.get(buy_wp), coords.get(sell_wp)
if b is None or s is None:
dist = _UNMAPPED_DIST
else:
dist = ((b[0] - s[0]) ** 2 + (b[1] - s[1]) ** 2) ** 0.5
net = base - dist * _FUEL_CR_PER_DIST
secs = dist * 25.0 / _NOMINAL_SPEED + _ROUTE_STOPS * _STOP_OVERHEAD_S
return net / secs


def rank_routes(markets: list[dict], min_margin: int = 1,
sink_supply_cutoff: str = "ABUNDANT") -> list[dict]:
sink_supply_cutoff: str = "ABUNDANT",
coords: dict | None = None) -> list[dict]:
"""All profitable supply-chain routes, ranked best-first (same route shape as
``best_route``). ``sink_supply_cutoff`` is the saturation guard: an importer whose
supply is already AT OR ABOVE this tier is skipped as a sell target — it's saturated,
Expand All @@ -37,6 +95,12 @@ def rank_routes(markets: list[dict], min_margin: int = 1,
Returns ``[]`` if nothing clears ``min_margin``. Used by the engine to diversify
several haulers across the top-N routes instead of stacking them all on one (which
would saturate it); ``best_route`` returns just the top entry.

``coords`` is an optional ``{waypoint: (x, y)}`` map — when passed, EVERY route is
scored time-aware net-per-second (``_lane_score``): the real distance where both
endpoints are mapped, the pessimistic ``_UNMAPPED_DIST`` where either is not, so
the whole ranking sorts in one unit. With no map at all every route keeps the
time-blind margin × volume score — existing callers rank exactly as before.
"""
if not markets:
return []
Expand Down Expand Up @@ -84,7 +148,7 @@ def rank_routes(markets: list[dict], min_margin: int = 1,
"profit_per_unit": margin, "volume": buy_vol,
"buy_price": buy_price, "sell_price": sell_price,
"sink_volume": sink_vol, "kind": "export→import",
"score": margin * max(buy_vol, 1)})
"score": _lane_score(margin, buy_vol, buy_wp, sell_wp, coords)})
if primary:
return sorted(primary, key=lambda r: r["score"], reverse=True)

Expand All @@ -104,12 +168,14 @@ def rank_routes(markets: list[dict], min_margin: int = 1,
# the SINK is the sell waypoint — size the saturation cap
# against ITS tradeVolume, not the buy leg's.
"sink_volume": sell_vol, "kind": "exchange",
"score": margin * max(buy_vol, 1)})
"score": _lane_score(margin, buy_vol, buy_wp, sell_wp,
coords)})
return sorted(fallback, key=lambda r: r["score"], reverse=True)


def best_route(markets: list[dict], min_margin: int = 1,
sink_supply_cutoff: str = "ABUNDANT") -> dict:
sink_supply_cutoff: str = "ABUNDANT",
coords: dict | None = None) -> dict:
"""
Find the best SUPPLY-CHAIN trade route across a list of markets.

Expand Down Expand Up @@ -154,17 +220,78 @@ def best_route(markets: list[dict], min_margin: int = 1,
These don't refill the way an export/import pair does, so they're a last resort.

``sink_supply_cutoff`` skips importers already saturated at/above that supply
tier (see ``rank_routes``). This is the single best of the ranked routes; the
engine uses ``rank_routes`` directly to spread haulers across the top-N.
tier (see ``rank_routes``). ``coords`` (``{waypoint: (x, y)}``) opts the WHOLE
ranking into the time-aware net-per-second score — unmapped endpoints score at
the pessimistic ``_UNMAPPED_DIST``, never the incomparable raw base. This is the
single best of the ranked routes; the engine uses ``rank_routes`` directly to
spread haulers across the top-N.
"""
routes = rank_routes(markets, min_margin=min_margin, sink_supply_cutoff=sink_supply_cutoff)
routes = rank_routes(markets, min_margin=min_margin,
sink_supply_cutoff=sink_supply_cutoff, coords=coords)
return routes[0] if routes else {}


# Backward-compat alias — callers should prefer best_route.
best_arbitrage = best_route


def ride_along_pick(origin_goods: list, markets: list, deliver_wp: str, *,
min_margin: int = 1, exclude: str | None = None,
coords: dict | None = None, max_detour: float = 40.0) -> dict | None:
"""Pick a RIDE-ALONG good for a contract leg: something the CURRENT (docked) market
EXPORTS that the contract's DELIVERY stop imports, so the hold's spare room earns on
a trip the contract is paying for anyway (the research round's "never fly half-empty").

``origin_goods`` is the current market's live ``tradeGoods`` (the ship is docked, so
prices are real); only EXPORT listings with a purchase price qualify as buys — the
same cheap-and-refilling doctrine as the route ranker. Sinks come from the recorded
price map (``markets``, ``price_map`` shape): the delivery waypoint itself, plus any
market within ``max_detour`` of it when BOTH coordinates are known — the contract leg
must never grow a real detour, and an unmapped market can't prove it's nearby. Only
IMPORT listings with a recorded sell price qualify as sinks (no blind sells — the
engine's confirmed-sink rule). ``exclude`` drops the contract good itself: extra
units of it in the hold would blend into the delivery count and over-deliver.

Returns ``{good, sell_at, buy_price, sell_price, margin}`` for the best margin at or
above ``min_margin``, else None. Pure — no I/O, host-free testable.
"""
sinks: dict[str, tuple] = {} # good → (best sellPrice, waypoint)
dc = (coords or {}).get(deliver_wp)
for m in markets or []:
wp = m.get("waypointSymbol")
if not wp:
continue
if wp != deliver_wp:
c = (coords or {}).get(wp)
if dc is None or c is None:
continue # can't PROVE it's nearby → not a sink
if ((c[0] - dc[0]) ** 2 + (c[1] - dc[1]) ** 2) ** 0.5 > max_detour:
continue
for g in m.get("tradeGoods") or []:
if g.get("type") != "IMPORT" or g.get("sellPrice") is None:
continue
cur = sinks.get(g.get("symbol"))
if cur is None or g["sellPrice"] > cur[0]:
sinks[g["symbol"]] = (g["sellPrice"], wp)
best = None
for g in origin_goods or []:
sym = g.get("symbol")
if not sym or sym == exclude:
continue
if g.get("type") != "EXPORT" or g.get("purchasePrice") is None:
continue
hit = sinks.get(sym)
if hit is None:
continue
margin = int(hit[0] - g["purchasePrice"])
if margin < min_margin:
continue
if best is None or margin > best["margin"]:
best = {"good": sym, "sell_at": hit[1], "buy_price": g["purchasePrice"],
"sell_price": hit[0], "margin": margin}
return best


def affordable_units(credits: int, unit_price, room: int, vol_cap: int,
*, max_spend_frac: float = 0.5) -> int:
"""How many units to buy in ONE trade without draining the treasury.
Expand Down
Loading
Loading