Compare commits
10
Commits
f7ca395a4f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24a244a07e | ||
|
|
e4dd22157b | ||
|
|
7335d9b954 | ||
|
|
355bef9d25 | ||
|
|
b2fa14b7ff | ||
|
|
f357653bbb | ||
|
|
f9eeae39ed | ||
|
|
aa9eda4b6d | ||
|
|
0641d25858 | ||
|
|
ba26b59c40 |
@@ -17,8 +17,9 @@ A high-performance, Object-Oriented Python client for OpenTTD servers, specifica
|
|||||||
- **Station Listing:** Enumerate stations via the Admin GameScript channel with `list_stations()`.
|
- **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).
|
- **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 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.
|
||||||
- **Version-checked Server Bridge:** the server-side AdminBridge GameScript that answers all of the above lives in [`gamescript/AdminBridge/`](gamescript/AdminBridge/README.md) and is mounted into the Docker server automatically. `get_bridge_version()` verifies at startup that the running bridge is new enough, so an outdated one fails by name instead of hanging every query.
|
- **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.
|
- **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.
|
||||||
|
- **Version-checked Server Bridge:** the server-side AdminBridge GameScript that answers all of the above lives in [`gamescript/AdminBridge/`](gamescript/AdminBridge/README.md) and is mounted into the Docker server automatically. `get_bridge_version()` verifies at startup that the running bridge is new enough, so an outdated one fails by name instead of hanging every query.
|
||||||
|
|
||||||
## 🛠 Setup
|
## 🛠 Setup
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM debian:13@sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9ffc4b1 AS builder
|
FROM debian:13@sha256:9cc080028c43b27d2074d63a5f9caf7166d731494965616c1a6d2827a004585c AS builder
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
build-essential \
|
build-essential \
|
||||||
@@ -29,7 +29,7 @@ RUN cmake .. \
|
|||||||
# Must track the builder's Debian release: the builder (debian:13/trixie) links
|
# Must track the builder's Debian release: the builder (debian:13/trixie) links
|
||||||
# against glibc 2.38+, so an older runtime (e.g. bookworm, glibc 2.36) cannot run
|
# against glibc 2.38+, so an older runtime (e.g. bookworm, glibc 2.36) cannot run
|
||||||
# the resulting binary. Package names use the trixie t64 spelling.
|
# the resulting binary. Package names use the trixie t64 spelling.
|
||||||
FROM debian:trixie-slim@sha256:d7e12182ce18b85b93007c1dedf31f2d29e01ccf3182cc4017c709b6259bc132
|
FROM debian:trixie-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
libcurl3t64-gnutls \
|
libcurl3t64-gnutls \
|
||||||
|
|||||||
@@ -92,6 +92,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`.
|
- **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.
|
- **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
|
### 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.
|
`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.
|
||||||
|
|
||||||
|
|||||||
@@ -769,6 +769,30 @@ class OpenTTDAdminClient:
|
|||||||
payload["via_station"] = via_station
|
payload["via_station"] = via_station
|
||||||
return await self._gs_query(payload, timeout, f"get_station_cargo({station_id}, {cargo_id})")
|
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):
|
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.
|
"""Fetch an authoritative snapshot of a vehicle's scheduled dispatch state via the AdminBridge GS.
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -67,17 +67,23 @@ async def run_admin():
|
|||||||
if stations and stations[-1]:
|
if stations and stations[-1]:
|
||||||
sid = stations[-1][0]["id"]
|
sid = stations[-1][0]["id"]
|
||||||
try:
|
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)
|
data = await admin.get_station(sid, timeout=10.0)
|
||||||
print(f"--- Station {sid} ({data.get('name')}) cargo: real-time waiting vs planned ---")
|
print(f"--- Station {sid} ({data.get('name')}) cargo: real-time waiting vs planned ---")
|
||||||
for cargo in data.get("cargo", []):
|
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']}")
|
f"planned={cargo['planned']} rating={cargo['rating']}")
|
||||||
|
|
||||||
# Break the first cargo down by source station and by next hop (routing destination).
|
# Break the first cargo down by source station and by next hop (routing destination).
|
||||||
if data.get("cargo"):
|
if data.get("cargo"):
|
||||||
cid = data["cargo"][0]["cargo_id"]
|
cid = data["cargo"][0]["cargo_id"]
|
||||||
flow = await admin.get_station_cargo(sid, cid, timeout=10.0)
|
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 source: {flow['waiting_by_from']}")
|
||||||
print(f" waiting by next hop: {flow['waiting_by_via']}")
|
print(f" waiting by next hop: {flow['waiting_by_via']}")
|
||||||
print(f" planned by source: {flow['planned_by_from']}")
|
print(f" planned by source: {flow['planned_by_from']}")
|
||||||
|
|||||||
@@ -393,6 +393,40 @@ async def test_admin_get_station_cargo_error_response():
|
|||||||
await task
|
await task
|
||||||
assert client._gs_futures == {}
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_admin_get_dispatch_request_and_response():
|
async def test_admin_get_dispatch_request_and_response():
|
||||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||||
|
|||||||
@@ -755,6 +755,44 @@ async def test_e2e_admin_get_station_cargo_invalid_cargo(connected_admin):
|
|||||||
with pytest.raises(ValueError, match="invalid_cargo"):
|
with pytest.raises(ValueError, match="invalid_cargo"):
|
||||||
await connected_admin.get_station_cargo(stations[0]["id"], 250, timeout=10.0)
|
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 ---
|
# --- Game Events ---
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import re
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from openttd.protocol import GS_BRIDGE_VERSION, GameEventType
|
from openttd.protocol import GS_BRIDGE_VERSION, GameEventType
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
@@ -68,7 +67,8 @@ def test_gamescript_implements_every_command_the_client_sends():
|
|||||||
"""Each command in a client payload must have a handler in the bridge's dispatch table.
|
"""Each command in a client payload must have a handler in the bridge's dispatch table.
|
||||||
|
|
||||||
A command the bridge does not know is silently dropped, so the caller only sees a timeout.
|
A command the bridge does not know is silently dropped, so the caller only sees a timeout.
|
||||||
The bridge may implement more than the client wraps (list_cargo currently has no method).
|
Checked one way round only: the bridge is allowed to implement more than the client wraps,
|
||||||
|
which is how list_cargo sat there answering nobody until a method was written for it.
|
||||||
"""
|
"""
|
||||||
commands = set(re.findall(r"^\s*(\w+)\s*=\s*{ handler",
|
commands = set(re.findall(r"^\s*(\w+)\s*=\s*{ handler",
|
||||||
_block(MAIN_NUT, "COMMANDS = {", "\n\t};"), re.MULTILINE))
|
_block(MAIN_NUT, "COMMANDS = {", "\n\t};"), re.MULTILINE))
|
||||||
|
|||||||
Reference in New Issue
Block a user