Add station listing and cargo queries to admin client
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:
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user