Add list_cargo() to the admin client #29

Merged
kovagoadi merged 1 commits from claude/infallible-visvesvaraya-8d8c8a into main 2026-08-31 18:35:58 +02:00
6 changed files with 114 additions and 2 deletions
+1
View File
@@ -17,6 +17,7 @@ A high-performance, Object-Oriented Python client for OpenTTD servers, specifica
- **Station Listing:** Enumerate stations via the Admin GameScript channel with `list_stations()`.
- **Station Cargo Snapshots:** `OpenTTDAdminClient.get_station()` returns a station's live per-cargo state from the running game — both the **real-time** amount waiting and the **planned** flow through the cargodist link graph — over the AdminBridge GS (stock GameScript API, no server patch needed).
- **Cargo Flow Breakdown:** `OpenTTDAdminClient.get_station_cargo()` breaks one cargo type down by **source station** and **next hop** (routing destination) for both waiting (real-time) and planned amounts, with optional `from_station`/`via_station` filters.
- **Cargo Table:** `OpenTTDAdminClient.list_cargo()` names the bare `cargo_id`s the station queries and cargo events return — every cargo type in the running game with its label (`PASS`, `COAL`, …) and freight flag, read live so NewGRF-specific ids resolve correctly.
- **Game Events:** react to the game instead of polling it — `subscribe_events()` streams events over the AdminBridge GS as they happen: a vehicle reaching or leaving a stop (`vehicle_arrive`/`vehicle_depart`, with dwell time and cargo aboard), a station's waiting cargo changing (`cargo_waiting`), plus crashes, industries opening/closing, towns, companies and subsidies. Consume them with `await wait_for_event()` or an `on_event` callback; filter by kind, company, vehicle, station or cargo.
## 🛠 Setup
+9
View File
@@ -62,6 +62,15 @@ Correlation, the `update_frequency` subscription requirement (auto-subscribed on
- **Filters:** `via_station` restricts the query (and the `*_by_from` breakdowns) to cargo whose next hop is that station; `from_station` restricts it (and the `*_by_via` breakdowns) to cargo from that source; supplying both makes `waiting`/`planned` the exact source-and-next-hop amount. Pass `65535` to target `STATION_INVALID`.
- **Reply (error):** same envelope with an `"error"` field: `"invalid_station"`, `"invalid_cargo"`, `"invalid_from_station"`/`"invalid_via_station"` (a filter that is neither a valid station nor `STATION_INVALID`), or `"response_too_large"`. `get_station_cargo()` raises `ValueError` for these.
### Cargo Listing
Cargo appears in the replies above (and in the `cargo_waiting` / vehicle events) as a bare numeric `cargo_id`. Those ids index the cargo table the loaded NewGRFs build for the running game, so they are **not stable across games** — the same id can be coal in one save and grain in another. `list_cargo()` resolves them, sending a `list_cargo` command over the same GameScript JSON channel and awaiting the correlated reply. The GS reads the table with the stock `GSCargoList`, `GSCargo.GetCargoLabel` and `GSCargo.IsFreight`, so this needs no server patch.
- **Request:** `{"command": "list_cargo", "request_id": X}`.
- **Reply (success):** `{"command": "list_cargo", "request_id": X, "cargo": [{"cargo_id": N, "label": "COAL", "freight": 0|1}, ...]}` — one entry per cargo type in the game, in `GSCargoList` order rather than by id, so index the list by `cargo_id`. `label` is the four-character NewGRF cargo label (`GetCargoLabel`, underscore-padded: `OIL_`), or `""` if the GS could not read it; `freight` is 1 for freight cargo and 0 for the rest (passengers, mail, …).
- **Reply (error):** the GS defines no error for this command, so `list_cargo()` never raises `ValueError` — only `asyncio.TimeoutError` (paused game, GS not loaded) and `ConnectionError`.
Correlation and the `update_frequency` subscription requirement (auto-subscribed on first use) are as described for the Timetable Query above. Unlike `list_vehicles()`/`list_stations()` — which are fire-and-forget despite the similar name — this one is awaitable and its reply does **not** reach the `on_gamescript` callback.
### Dispatch Query
`get_dispatch()` fetches an authoritative snapshot of a vehicle's **scheduled dispatch** state over the GameScript JSON channel, the vehicle analogue of `get_timetable()` for JGRPP's scheduled dispatch feature. Like the timetable getters, the dispatch getters it relies on are added by a **server patch** (`docker/patches/0002-*`, adding `GSOrder.GetScheduledDispatch*` / `IsScheduledDispatchEnabled`), so it needs the patched JGRPP build. Correlation, the auto-subscribe, and the paused-game timeout behave exactly as for the Timetable Query.
+24
View File
@@ -738,6 +738,30 @@ class OpenTTDAdminClient:
payload["via_station"] = via_station
return await self._gs_query(payload, timeout, f"get_station_cargo({station_id}, {cargo_id})")
async def list_cargo(self, timeout=5.0):
"""Fetch the running game's cargo table via the AdminBridge GameScript, id to label.
Every other reply names a cargo by its bare numeric id — get_station()'s and
get_station_cargo()'s cargo_id, the cargo_waiting events, the per-cargo load on vehicle
events. Those ids index the cargo table the loaded NewGRFs build, so they mean different
things in different games and are not worth hardcoding; resolve them against this list
instead. Auto-subscribes to Gamescript updates on first use; if you manage update
frequencies yourself, ensure update_frequency(Gamescript, Automatic) is active before
calling.
Returns a dict with a "cargo" list holding every cargo type in the game, in no particular
order (index it by "cargo_id"; a cargo's position in the list is not its id). Each entry:
- "cargo_id": the id used by all the replies above
- "label": the cargo label ("PASS", "COAL", ...), or "" if the GameScript could not
read it
- "freight": 1 for freight cargo, 0 for the rest (passengers, mail, ...)
Raises asyncio.TimeoutError if no reply arrives (e.g. game paused, GS not loaded) and
ConnectionError if the admin connection drops while waiting. The GameScript reports no
error for this command, so unlike get_station() it never raises ValueError.
"""
return await self._gs_query({"command": "list_cargo"}, timeout, "list_cargo")
async def get_dispatch(self, vehicle_id, timeout=5.0):
"""Fetch an authoritative snapshot of a vehicle's scheduled dispatch state via the AdminBridge GS.
+8 -2
View File
@@ -67,17 +67,23 @@ async def run_admin():
if stations and stations[-1]:
sid = stations[-1][0]["id"]
try:
# Cargo ids come from the loaded NewGRFs, so resolve them to labels to print.
labels = {c["cargo_id"]: c["label"]
for c in (await admin.list_cargo(timeout=10.0))["cargo"]}
data = await admin.get_station(sid, timeout=10.0)
print(f"--- Station {sid} ({data.get('name')}) cargo: real-time waiting vs planned ---")
for cargo in data.get("cargo", []):
print(f" cargo {cargo['cargo_id']}: waiting={cargo['waiting']} "
print(f" {labels.get(cargo['cargo_id'], cargo['cargo_id'])}: "
f"waiting={cargo['waiting']} "
f"planned={cargo['planned']} rating={cargo['rating']}")
# Break the first cargo down by source station and by next hop (routing destination).
if data.get("cargo"):
cid = data["cargo"][0]["cargo_id"]
flow = await admin.get_station_cargo(sid, cid, timeout=10.0)
print(f"--- Station {sid} cargo {cid} flow breakdown (station 65535 = none/deleted) ---")
print(f"--- Station {sid} cargo {labels.get(cid, cid)} flow breakdown "
f"(station 65535 = none/deleted) ---")
print(f" waiting by source: {flow['waiting_by_from']}")
print(f" waiting by next hop: {flow['waiting_by_via']}")
print(f" planned by source: {flow['planned_by_from']}")
+34
View File
@@ -388,6 +388,40 @@ async def test_admin_get_station_cargo_error_response():
await task
assert client._gs_futures == {}
@pytest.mark.asyncio
async def test_admin_list_cargo_request_and_response():
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
proto = MockProtocol()
client._protocol = proto
client._transport = MockTransport()
task = asyncio.ensure_future(client.list_cargo())
await asyncio.sleep(0) # let the task send the request
# First use auto-subscribes to Gamescript updates, then sends the query.
assert len(proto.sent) == 2
assert proto.sent[0][2] == PacketAdminType.AdminUpdateFrequency
assert decode_gamescript_payload(proto.sent[1]) == {
"command": "list_cargo", "request_id": 1,
}
response = {"command": "list_cargo", "request_id": 1,
"cargo": [{"cargo_id": 0, "label": "PASS", "freight": 0},
{"cargo_id": 1, "label": "COAL", "freight": 1}]}
await client.receive_ServerGamescript(None, data=response)
assert await task == response
assert client._gs_futures == {}
@pytest.mark.asyncio
async def test_admin_list_cargo_timeout():
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
client._protocol = MockProtocol()
client._transport = MockTransport()
with pytest.raises(asyncio.TimeoutError):
await client.list_cargo(timeout=0.05)
assert client._gs_futures == {}
@pytest.mark.asyncio
async def test_admin_get_dispatch_request_and_response():
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
+38
View File
@@ -722,6 +722,44 @@ async def test_e2e_admin_get_station_cargo_invalid_cargo(connected_admin):
with pytest.raises(ValueError, match="invalid_cargo"):
await connected_admin.get_station_cargo(stations[0]["id"], 250, timeout=10.0)
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_admin_list_cargo_table(connected_admin):
# Public function: list_cargo()
# Input 1: an explicit timeout. Every game has a cargo table, so an empty list would be a bug.
data = await connected_admin.list_cargo(timeout=10.0)
assert isinstance(data["cargo"], list) and data["cargo"]
for cargo in data["cargo"]:
for key in ("cargo_id", "label", "freight"):
assert key in cargo
assert cargo["freight"] in (0, 1)
ids = [cargo["cargo_id"] for cargo in data["cargo"]]
assert len(ids) == len(set(ids))
# Any cargo set carries passengers as well as freight, so both kinds must show up.
assert any(cargo["freight"] == 0 for cargo in data["cargo"])
assert any(cargo["freight"] == 1 for cargo in data["cargo"])
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_admin_list_cargo_resolves_station_cargo_ids(connected_admin):
# Public function: list_cargo()
# Input 2: the default timeout. The point of the call: naming the bare ids get_station() returns.
responses = []
connected_admin.on_gamescript = lambda data: responses.append(data)
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
await connected_admin.list_stations()
await asyncio.sleep(0.5)
assert responses and "stations" in responses[-1]
stations = responses[-1]["stations"]
if not stations:
pytest.skip("No stations on the test server to query.")
labels = {cargo["cargo_id"]: cargo["label"] for cargo in (await connected_admin.list_cargo())["cargo"]}
detail = await connected_admin.get_station(stations[0]["id"], timeout=10.0)
for cargo in detail["cargo"]:
assert cargo["cargo_id"] in labels
# --- Game Events ---