Add station listing and cargo queries to admin client
All checks were successful
Continuous Integration / lint-and-security (pull_request) Successful in 20s
Continuous Integration / tests-and-coverage (pull_request) Successful in 24s

Extends the AdminBridge GameScript JSON channel (the same relay used by
list_vehicles/get_timetable) with station support:

- list_stations(): enumerate stations, fire-and-forget like list_vehicles().
- get_station(): authoritative per-cargo snapshot of a station's live state,
  with both the real-time waiting amount (GSStation.GetCargoWaiting) and the
  planned cargodist link-graph flow (GetCargoPlanned), plus rating.
- get_station_cargo(): break one cargo type down by source station and by
  next hop (the cargodist routing destination) for both waiting and planned
  amounts, with optional from_station/via_station filters.

All three use stock GameScript API (no server patch, unlike timetables).
Refactors the shared GS request/reply correlation out of get_timetable and
get_station into a _gs_query() helper. Companion handlers must be added to
the server-side AdminBridge GameScript (not tracked in this repo).

Includes unit + e2e tests, a worked demo in main_admin.py, and protocol docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 21:48:20 +02:00
parent c39f970ef9
commit 81a4d9333d
6 changed files with 418 additions and 17 deletions

View File

@@ -454,6 +454,49 @@ class OpenTTDAdminClient:
payload["company_id"] = company_id
await self.send_gamescript(payload)
async def list_stations(self, company_id=None):
"""Request a list of stations via GameScript. company_id=None for all companies.
Like list_vehicles(), this is fire-and-forget: the AdminBridge GameScript replies with a
{"stations": [...]} envelope delivered to the on_gamescript callback, so subscribe to
Gamescript updates first (update_frequency(Gamescript, Automatic)) or the reply is dropped.
For a station's live cargo detail (waiting vs planned), use get_station().
"""
payload = {"command": "list_stations"}
if company_id is not None:
payload["company_id"] = company_id
await self.send_gamescript(payload)
async def _gs_query(self, payload, timeout, context):
"""Send a GameScript request and await its correlated reply.
Assigns a fresh request_id, registers a future the ServerGamescript handler resolves when
the matching reply arrives, and (on first use) subscribes to Gamescript updates so the
server actually forwards the reply. `payload` is the request dict without request_id;
`context` is a label used in the ValueError raised on a GameScript-reported error.
Raises asyncio.TimeoutError if no reply arrives within `timeout`, ValueError on an error
reply, and ConnectionError if the admin connection drops while waiting.
"""
from .protocol import AdminUpdateType, AdminUpdateFrequency
if not self._gs_subscribed:
await self.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
self._gs_subscribed = True
self._gs_request_id += 1
rid = self._gs_request_id
fut = asyncio.get_running_loop().create_future()
self._gs_futures[rid] = fut
request = dict(payload)
request["request_id"] = rid
try:
await self.send_gamescript(request)
data = await asyncio.wait_for(fut, timeout)
finally:
self._gs_futures.pop(rid, None)
if "error" in data:
raise ValueError(f"{context}: {data['error']}")
return data
async def get_timetable(self, vehicle_id, timeout=5.0):
"""Fetch an authoritative timetable snapshot for a vehicle via the AdminBridge GameScript.
@@ -470,22 +513,70 @@ class OpenTTDAdminClient:
ValueError on a GameScript-reported error (invalid_vehicle, response_too_large), and
ConnectionError if the admin connection drops while waiting.
"""
from .protocol import AdminUpdateType, AdminUpdateFrequency
if not self._gs_subscribed:
await self.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
self._gs_subscribed = True
self._gs_request_id += 1
rid = self._gs_request_id
fut = asyncio.get_running_loop().create_future()
self._gs_futures[rid] = fut
try:
await self.send_gamescript({"command": "get_timetable", "vehicle_id": vehicle_id, "request_id": rid})
data = await asyncio.wait_for(fut, timeout)
finally:
self._gs_futures.pop(rid, None)
if "error" in data:
raise ValueError(f"get_timetable({vehicle_id}): {data['error']}")
return data
return await self._gs_query(
{"command": "get_timetable", "vehicle_id": vehicle_id}, timeout,
f"get_timetable({vehicle_id})")
async def get_station(self, station_id, timeout=5.0):
"""Fetch an authoritative snapshot of a station's live cargo state via the AdminBridge GameScript.
This queries the real game state (like get_timetable() does for vehicles): it works for any
existing station regardless of when it was built or when this client connected. 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 station-level keys (name, location, owner) and a "cargo" list of per-cargo
dicts. Each cargo dict carries both the real-time and the planned amounts:
- "waiting": units currently sitting at the station (real-time, GSStation.GetCargoWaiting)
- "planned": units planned to move through it per the cargodist link graph
(GSStation.GetCargoPlanned); 0 when cargo distribution is not enabled for that cargo
- "rating": the station's acceptance rating for the cargo as a percentage (0-100),
or None if the station has no rating for that cargo yet
Only cargo types the station has ever handled appear in the list.
Raises asyncio.TimeoutError if no reply arrives (e.g. game paused, GS not loaded),
ValueError on a GameScript-reported error (invalid_station, response_too_large), and
ConnectionError if the admin connection drops while waiting.
"""
return await self._gs_query(
{"command": "get_station", "station_id": station_id}, timeout,
f"get_station({station_id})")
async def get_station_cargo(self, station_id, cargo_id, from_station=None, via_station=None, timeout=5.0):
"""Fetch a per-source / per-next-hop breakdown of one cargo at a station via the AdminBridge GS.
Where get_station() reports each cargo's totals, this drills into a single cargo type and
shows how the waiting (real-time) and planned amounts split across the cargo distribution
(cargodist) link graph. Cargodist tracks every unit by its source station (where it was
first loaded) and its next hop (the next station it heads to on the way to its final
destination); there is no separate "final destination" store, so the routing destination is
the next hop ("via").
Returns a dict with the (optionally filtered) totals "waiting" and "planned", plus four
breakdown lists, each a list of {"station": id, "amount": n} entries (zero amounts omitted):
- "waiting_by_from" / "planned_by_from": grouped by source station
- "waiting_by_via" / "planned_by_via": grouped by next hop (routing destination)
A station id of 65535 (STATION_INVALID) marks cargo whose source was deleted or, as a next
hop, cargo with no onward routing / to be consumed at this station (also the sole next hop
for cargo types using manual, non-cargodist distribution).
Optional filters narrow the query:
- from_station: only cargo originating at this source station.
- via_station: only cargo whose next hop is this station.
Passing from_station restricts the by_via breakdown to that source (and the totals to it);
passing via_station restricts the by_from breakdown to that next hop; passing both makes the
totals the exact source+next-hop amount. Pass 65535 for either to target STATION_INVALID.
Raises asyncio.TimeoutError if no reply arrives (e.g. game paused, GS not loaded),
ValueError on a GameScript-reported error (invalid_station, invalid_cargo,
response_too_large), and ConnectionError if the admin connection drops while waiting.
"""
payload = {"command": "get_station_cargo", "station_id": station_id, "cargo_id": cargo_id}
if from_station is not None:
payload["from_station"] = from_station
if via_station is not None:
payload["via_station"] = via_station
return await self._gs_query(payload, timeout, f"get_station_cargo({station_id}, {cargo_id})")
async def send_gamescript(self, json_data):
"""Send a JSON string to the GameScript."""