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
62 changes: 62 additions & 0 deletions fleet.py
Original file line number Diff line number Diff line change
Expand Up @@ -1158,15 +1158,77 @@ async def _best_trade_route(system: str, market_wps: list, rank: int = 0) -> dic
return ranked[min(rank, len(ranked) - 1)]


async def _clear_stranded_hold(sym: str, system: str, *, log=None) -> bool:
"""Free a trader parked with cargo no local market bought — the stranded-full gap that
left a hauler dead ~1h live (issue #55). Called ONLY from _trade_loop's no-route branch,
so it never pre-empts a real run. Sells the biggest stranded stack at the NEAREST in-system
market that buys it, accepting sub-margin — freeing the ship beats hoarding dead capital.
Guards: never act mid-flight; NEVER liquidate contract-deliver cargo (a stuck contract lead
reaches _trade_loop still holding its haul — /my/contracts is the authoritative signal, and
an unreadable list protects EVERYTHING, fail-safe); an unproven buyer scan (T._good_markets
raises) or a provably-empty buyer set leaves the cargo aboard — #55 SELLS stranded cargo, it
never jettisons (that proof-gated destruction is the mining loop's job, not this). At most one
side-trip per window. Returns True iff it sold, so the caller can look for a route again."""
try:
s = await _ship(sym)
except C.SpaceTradersError:
return False
if s["nav"]["status"] == "IN_TRANSIT":
return False
here = s["nav"]["waypointSymbol"]
held = {i["symbol"]: i["units"] for i in s["cargo"]["inventory"] if i["units"] > 0}
if not held:
return False
try:
contracts = await C.call("GET", "/my/contracts")
except C.SpaceTradersError:
return False # can't prove it isn't contract cargo → protect everything
protected = {d.get("tradeSymbol") for c in contracts
if c.get("accepted") and not c.get("fulfilled")
for d in (c.get("terms", {}).get("deliver") or [])}
# Biggest non-protected stack first — the one side-trip should recover the most cargo.
for good in sorted(held, key=held.get, reverse=True):
if good in protected:
continue
try:
_, sellers = await T._good_markets(system, good) # importers/exchangers pay
except C.SpaceTradersError:
continue # unproven scan — leave it aboard, retry next window
sellers = [w for w in sellers if w != here] # 'here' already refused it
if not sellers:
continue # proven no in-system buyer — hold it (never jettison)
target = sellers[0]
if len(sellers) > 1: # nearest — don't cross the system
try:
dists = [(await T._distance(system, here, w), w) for w in sellers]
target = min(dists)[1]
except C.SpaceTradersError:
pass
if log:
log(f"{sym}: stranded {held[good]}×{good} at {here} — repositioning to {target} to "
f"sell (issue #55)")
if not await travel_to(sym, target, log=log):
continue # too far to auto-DRIFT — leave aboard, try again next window
await C.call("POST", f"/my/ships/{sym}/dock")
sold, _ = await _sell(sym, good, held[good], log=log)
return bool(sold)
return False


async def _trade_loop(sym: str, deadline: float, system: str, market_wps: list,
*, rank: int = 0, log=None) -> str:
"""A cargo ship: run a profitable arbitrage route (its diversify ``rank``), re-evaluating
each round so it adapts when a market saturates. job_trade is spread-guarded + sized to
the sink's tradeVolume (no loss legs, no self-crash)."""
runs = 0
cleared = False # one stranded-hold rescue per window (issue #55)
while _now() < deadline:
route = await _best_trade_route(system, market_wps, rank=rank)
if not route:
if not cleared: # nothing profitable AND maybe parked full — free it
cleared = True
if await _clear_stranded_hold(sym, system, log=log):
continue # hold freed — re-check for a route we can now buy
await asyncio.sleep(15) # wait for probes to scout a profitable spread
continue
res = await job_trade(sym, route["good"], route["buy_at"], route["sell_at"],
Expand Down
2 changes: 1 addition & 1 deletion protoagent.plugin.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
id: spacetraders
name: SpaceTraders
version: 2.9.2
version: 2.9.3
description: >-
Play the live SpaceTraders v2 API (https://spacetraders.io) — a persistent,
shared galactic economy. A FULL-BUNDLE plugin (ADR 0027): 30 fleet/market/mining/
Expand Down
227 changes: 227 additions & 0 deletions test_stranded_cargo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
"""Stranded-cargo fallback (issue #55, fleet._clear_stranded_hold).

The live trap (2026-07-05): a trader parked FULL of a good the local market doesn't buy
sat idle ~1h burning nothing but earning nothing — _trade_loop's no-route branch just
slept. The fix repositions it to the nearest in-system market that BUYS the good and sells
(sub-margin OK — freeing the ship beats hoarding dead capital). These prove the guards:
never touch contract-deliver cargo (an unreadable contract list protects EVERYTHING),
sell only where a buyer is PROVEN reachable (an unproven or empty buyer scan holds the
cargo — this fallback SELLS, it never jettisons), and act at most once per window, never
mid-flight.
"""

from __future__ import annotations

import asyncio
import importlib
from pathlib import Path

import pytest

testkit = pytest.importorskip("graph.plugins.testkit")
ROOT = Path(__file__).resolve().parent

SHIP = "PROTOTRADER-1"
SYS = "X1-BG81"
HERE = "X1-BG81-B6" # where it's stranded — sells only fuel, does NOT buy the good
BUYER = "X1-BG81-B7" # the in-system market that buys the good
GOOD = "AMMONIA_ICE"

BUYER_MKT = {"tradeGoods": [{"symbol": GOOD, "tradeVolume": 40, "sellPrice": 30}],
"imports": [{"symbol": GOOD}], "exports": [], "exchange": []}
FUEL_MKT = {"tradeGoods": [{"symbol": "FUEL", "tradeVolume": 100}],
"imports": [{"symbol": "FUEL"}], "exports": [], "exchange": []}


@pytest.fixture(scope="module")
def mods():
pkg = testkit.load_plugin(ROOT, "spacetraders")
fleet = importlib.import_module(f"{pkg.__name__}.fleet")
client = importlib.import_module(f"{pkg.__name__}.client")
return fleet, client


class FakeGame:
"""Stateful API stand-in: cargo mutates on sell, a sell against a good the current
market doesn't list raises (the live 400), /my/contracts is configurable (list, or a
raise), and a hard call budget turns any regression into a FAILURE, not a hang."""

def __init__(self, cargo, *, err, contracts=None, contracts_raise=False,
status="IN_ORBIT", markets=None):
self.wp = HERE
self.status = status
self.cargo = dict(cargo)
self.err = err
self.contracts = contracts if contracts is not None else []
self.contracts_raise = contracts_raise
self.markets = markets if markets is not None else {HERE: FUEL_MKT, BUYER: BUYER_MKT}
self.credits = 10_000
self.calls: list = []
self.sells: list = []
self.jettisons: list = []

def ship(self):
inv = [{"symbol": g, "units": u} for g, u in self.cargo.items() if u > 0]
return {"symbol": SHIP,
"nav": {"status": self.status, "waypointSymbol": self.wp, "systemSymbol": SYS},
"cargo": {"capacity": 40, "units": sum(self.cargo.values()), "inventory": inv},
"fuel": {"capacity": 400, "current": 400}}

def _traded(self, wp):
m = self.markets.get(wp) or {}
return {g["symbol"] for k in ("tradeGoods", "imports", "exports", "exchange")
for g in m.get(k) or []}

async def call(self, method, path, **kw):
self.calls.append((method, path))
if len(self.calls) > 200:
raise RuntimeError("unbounded API loop — the guard regressed")
if path == "/my/agent":
return {"credits": self.credits}
if path == "/my/contracts":
if self.contracts_raise:
raise self.err("[500] contracts unavailable")
return self.contracts
if path.endswith(("/dock", "/orbit")):
return {}
if path.endswith("/sell"):
good, units = kw["json"]["symbol"], kw["json"]["units"]
if good not in self._traded(self.wp):
raise self.err(f"[400] Market {self.wp} does not trade {good}")
take = min(units, self.cargo.get(good, 0))
self.cargo[good] = self.cargo.get(good, 0) - take
self.credits += take * 30
self.sells.append((self.wp, good, take))
return {"transaction": {"units": take, "pricePerUnit": 30, "totalPrice": take * 30}}
if path.endswith("/jettison"):
self.jettisons.append((kw["json"]["symbol"], kw["json"]["units"]))
self.cargo.pop(kw["json"]["symbol"], None)
return {}
if path.endswith("/market"):
m = self.markets.get(path.rsplit("/", 2)[-2])
if m is None:
raise self.err("[404] no market here")
return m
if path.startswith("/my/ships") and method == "GET":
return self.ship()
return {}


def _wire(fleet, monkeypatch, game, *, sellers=(BUYER,), scan_raise=False):
"""Patch the client call, teleporting travel (records the itinerary), and the
T._good_markets/_distance buyer-lookup seam."""
monkeypatch.setattr(fleet.C, "call", game.call)
dests: list = []

async def travel(sym, dest, *, max_hops=12, log=None):
dests.append(dest)
game.wp = dest
return True

async def good_markets(system, good):
if scan_raise:
raise game.err("[500] buyer scan incomplete")
return [], list(sellers)

async def distance(system, a, b):
return 1.0

monkeypatch.setattr(fleet, "travel_to", travel)
monkeypatch.setattr(fleet.T, "_good_markets", good_markets)
monkeypatch.setattr(fleet.T, "_distance", distance)
return dests


def _clear(fleet):
return asyncio.run(fleet._clear_stranded_hold(SHIP, SYS))


def test_stranded_trader_repositions_and_sells(mods, monkeypatch):
fleet, client = mods
game = FakeGame({GOOD: 40}, err=client.SpaceTradersError)
dests = _wire(fleet, monkeypatch, game, sellers=(BUYER,))
assert _clear(fleet) is True
assert dests == [BUYER] # one side-trip to the buyer
assert game.sells == [(BUYER, GOOD, 40)] # sold the whole stranded stack there
assert game.jettisons == [] # never destroys cargo


def test_contract_cargo_is_protected(mods, monkeypatch):
# The undelivered contract good CAN reach _trade_loop (a stuck contract lead falls back
# to trade still holding it) — liquidating it would destroy real value.
fleet, client = mods
contracts = [{"accepted": True, "fulfilled": False,
"terms": {"deliver": [{"tradeSymbol": GOOD}]}}]
game = FakeGame({GOOD: 40}, contracts=contracts, err=client.SpaceTradersError)
dests = _wire(fleet, monkeypatch, game, sellers=(BUYER,))
assert _clear(fleet) is False
assert dests == [] and game.sells == []


def test_no_buyer_leaves_cargo_aboard(mods, monkeypatch):
# Buyer scan came back CLEAN but empty — proven no in-system taker. Hold it; don't jettison.
fleet, client = mods
game = FakeGame({GOOD: 40}, err=client.SpaceTradersError)
dests = _wire(fleet, monkeypatch, game, sellers=())
assert _clear(fleet) is False
assert dests == [] and game.sells == [] and game.jettisons == []


def test_unproven_scan_holds_cargo(mods, monkeypatch):
# The buyer scan RAISED (a page/market GET was lost) — not proof of no buyer. Hold, retry later.
fleet, client = mods
game = FakeGame({GOOD: 40}, err=client.SpaceTradersError)
dests = _wire(fleet, monkeypatch, game, scan_raise=True)
assert _clear(fleet) is False
assert dests == [] and game.sells == []


def test_in_transit_is_a_noop(mods, monkeypatch):
fleet, client = mods
game = FakeGame({GOOD: 40}, status="IN_TRANSIT", err=client.SpaceTradersError)
dests = _wire(fleet, monkeypatch, game, sellers=(BUYER,))
assert _clear(fleet) is False
assert dests == [] and game.sells == []
assert ("GET", "/my/contracts") not in game.calls # bailed before touching contracts


def test_unreadable_contracts_protects_everything(mods, monkeypatch):
# Can't prove the hold isn't contract cargo → protect it all (fail safe).
fleet, client = mods
game = FakeGame({GOOD: 40}, contracts_raise=True, err=client.SpaceTradersError)
dests = _wire(fleet, monkeypatch, game, sellers=(BUYER,))
assert _clear(fleet) is False
assert dests == [] and game.sells == []


def test_empty_hold_is_a_noop(mods, monkeypatch):
fleet, client = mods
game = FakeGame({}, err=client.SpaceTradersError)
dests = _wire(fleet, monkeypatch, game, sellers=(BUYER,))
assert _clear(fleet) is False
assert dests == []


def test_trade_loop_attempts_rescue_once_per_window(mods, monkeypatch):
# Wiring: with no route, _trade_loop calls the rescue EXACTLY once (not every 15s spin).
fleet, _client = mods
calls = {"n": 0}

async def no_route(system, market_wps, *, rank=0):
return None

async def spy(sym, system, *, log=None):
calls["n"] += 1
return False

async def no_sleep(_s):
return None

it = iter((0.0, 1.0, 2.0, 100.0))
monkeypatch.setattr(fleet, "_best_trade_route", no_route)
monkeypatch.setattr(fleet, "_clear_stranded_hold", spy)
monkeypatch.setattr(fleet.asyncio, "sleep", no_sleep)
monkeypatch.setattr(fleet, "_now", lambda: next(it, 100.0))
res = asyncio.run(fleet._trade_loop(SHIP, 50.0, SYS, [BUYER]))
assert calls["n"] == 1
assert "idled" in res
Loading