Add game event support to the admin client
Continuous Integration / lint-and-security (pull_request) Successful in 41s
Continuous Integration / tests-and-coverage (pull_request) Successful in 28s

Everything on the admin GameScript channel so far has been request/reply.
This adds the other direction: subscribe_events() opens a push stream so a
bot can react to the game instead of polling it, consumed either by awaiting
wait_for_event() or via an on_event callback. Both see every event; an event
goes to at most one waiter, and unclaimed ones sit in a bounded buffer.

Sixteen kinds, from two sources. The engine raises no GameScript event for a
vehicle reaching a stop or cargo arriving, so vehicle_arrive, vehicle_depart
and cargo_waiting are synthesised by the bridge sampling state every
`interval` ticks and diffing against the previous sample -- which means a
stop shorter than the interval is never reported, and the first sample only
establishes a baseline. The rest (crashes, industries, towns, companies,
subsidies) are engine events forwarded verbatim. vehicle_lost,
vehicle_waiting_in_depot and vehicle_unprofitable are deliberately absent:
the engine raises those only for AI companies, so a GameScript can never
observe them.

The server-side half lives in the AdminBridge GameScript, which is not in
this repo -- docker/config is gitignored -- so it has to be updated
separately for any of this to work.

Also repoints the scheduled-dispatch E2E test at a dedicated vehicle
(DISPATCH_VEHICLE_ID). It had been silently skipping because vehicle 7
carries a hand-built annual dispatch schedule, which left eight dispatch
methods unverified end to end while check_public_calls.py reported them
green off static analysis of the call sites.

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
2026-08-31 18:14:40 +02:00
co-authored by Claude
parent 28f247799e
commit d87c779d94
10 changed files with 800 additions and 10 deletions
+147 -4
View File
@@ -3,6 +3,7 @@ import hashlib
import logging
import os
import uuid
from collections import deque
from typing import ClassVar
import monocypher
@@ -470,17 +471,17 @@ class OpenTTDClient:
class OpenTTDAdminClient:
"""High-level OpenTTD Admin client."""
def __init__(self, host, port=3977, admin_name="GeminiAdmin"):
def __init__(self, host, port=3977, admin_name="GeminiAdmin", event_buffer_size=256):
self.host = host
self.port = port
self.admin_name = admin_name
self.log = logging.getLogger(f"OTTDA-{admin_name}")
# State
self.encryption_enabled = False
self.joined = asyncio.Event()
self.shutdown_event = asyncio.Event()
# Internal crypto
self._admin_password = ""
self._session_key_send = None
@@ -488,17 +489,24 @@ class OpenTTDAdminClient:
self._encryption_nonce = None
self._send_aead = None
self._recv_aead = None
# Callbacks
self.on_chat = None
self.on_console = None
self.on_gamescript = None
self.on_event = None
# GameScript request/response correlation
self._gs_request_id = 0
self._gs_futures = {}
self._gs_subscribed = False
# Game events pushed by the AdminBridge GameScript. Events nobody is waiting for are
# kept here so a wait_for_event() call can still pick up something that arrived just
# before it; the deque bounds the memory a subscription nobody drains can cost.
self._event_buffer = deque(maxlen=event_buffer_size)
self._event_waiters = []
async def connect(self, admin_password="", secure=False):
"""Connect to the admin port and initiate handshake."""
self._admin_password = admin_password
@@ -532,6 +540,10 @@ class OpenTTDAdminClient:
if not fut.done():
fut.set_exception(ConnectionError("admin disconnected"))
self._gs_futures.clear()
for _, fut in self._event_waiters:
if not fut.done():
fut.set_exception(ConnectionError("admin disconnected"))
self._event_waiters.clear()
self.shutdown_event.set()
async def quit(self):
@@ -750,6 +762,131 @@ class OpenTTDAdminClient:
{"command": "get_dispatch", "vehicle_id": vehicle_id}, timeout,
f"get_dispatch({vehicle_id})")
# --- Game events ---
#
# Everything above is a request the caller makes; this is the other direction. The
# AdminBridge GameScript pushes events as they happen, so a bot can react to the game
# instead of polling it. Two kinds of thing arrive on the same channel: transitions the
# bridge synthesises by sampling game state on an interval (a vehicle reaching or leaving
# a stop, a station's waiting cargo changing), and the events the engine itself raises for
# a GameScript (crashes, industries opening, companies going bankrupt, ...). See
# GameEventType for the full catalogue.
#
# Consume them either by setting on_event (push) or by awaiting wait_for_event() (pull);
# both see every event, so the two can be mixed.
async def subscribe_events(self, events=None, interval=None, company_id=None, vehicles=None,
stations=None, cargo=None, min_cargo_delta=None,
include_cargo=None, timeout=5.0):
"""Ask the AdminBridge GameScript to start pushing game events, and wait for it to confirm.
Every argument narrows what gets sent; the defaults subscribe to every event kind for
every vehicle, station and cargo, which is the right starting point on a small map and
the wrong one on a large busy map (see the cost note below).
- events: which GameEventType kinds to receive (default: all of them).
- interval: ticks between state samples for the polled kinds (default 10). This is
the resolution of those events, not a delay: a stop shorter than `interval` can
begin and end between two samples and is then never reported at all.
- company_id: only report vehicles and stations owned by this company.
- vehicles / stations: only sample these ids, instead of every vehicle / station.
- cargo: only inspect these cargo types (for cargo_waiting and for the load reported
on vehicle events).
- min_cargo_delta: suppress cargo_waiting events whose amount moved by less than this
many units since the previous sample (default 1, i.e. report every change).
- include_cargo: set False to leave the per-cargo load off vehicle events.
Subscribing replaces any previous subscription and resets the bridge's baseline, so the
first sample after this call only records where everything already is — a vehicle that
was sitting at a station when you subscribed did not just arrive, and gets no event.
Cost: the bridge samples every watched vehicle and every watched station-cargo pair on
each interval, inside a GameScript's limited per-tick budget. On a large map prefer a
coarser interval and explicit vehicles/stations/cargo lists over the defaults.
Returns the confirmation dict: {"events": [accepted kinds], "interval": N}. Raises
asyncio.TimeoutError if the GameScript does not answer (e.g. game paused, GS not
loaded), ValueError on a rejected request (unknown_event, invalid_interval,
invalid_min_cargo_delta, invalid_cargo), and ConnectionError if the admin connection
drops while waiting.
"""
payload = {"command": "subscribe_events"}
if events is not None:
payload["events"] = [str(event) for event in events]
if interval is not None:
payload["interval"] = interval
if company_id is not None:
payload["company_id"] = company_id
if vehicles is not None:
payload["vehicles"] = list(vehicles)
if stations is not None:
payload["stations"] = list(stations)
if cargo is not None:
payload["cargo"] = list(cargo)
if min_cargo_delta is not None:
payload["min_cargo_delta"] = min_cargo_delta
if include_cargo is not None:
payload["include_cargo"] = bool(include_cargo)
return await self._gs_query(payload, timeout, "subscribe_events")
async def unsubscribe_events(self, timeout=5.0):
"""Stop the event stream and wait for the GameScript to confirm.
This also drops the bridge's sampling state, so a later subscribe_events() starts from
a fresh baseline. Events already delivered stay in this client's buffer; drain or ignore
them as you like.
"""
return await self._gs_query({"command": "unsubscribe_events"}, timeout,
"unsubscribe_events")
async def wait_for_event(self, kind=None, timeout=5.0):
"""Await the next game event, optionally of a specific kind (or any of several kinds).
`kind` is a GameEventType (or plain string), an iterable of them, or None for "any
event". Events that arrived earlier and were not taken by another waiter are buffered,
so this returns immediately when a matching one is already in hand; the oldest matching
event wins. An event is handed to at most one waiter, but the on_event callback (if set)
still sees every event regardless.
Returns the event dict, which always carries "event" (its GameEventType) and "tick"
(the game tick it was observed at) plus per-kind fields — see docs/EVENTS.md. Raises
asyncio.TimeoutError if nothing matching arrives in time (note that a subscription is
needed first — see subscribe_events()) and ConnectionError if the admin connection drops
while waiting.
"""
kinds = None
if kind is not None:
kinds = {str(kind)} if isinstance(kind, str) else {str(k) for k in kind}
for buffered in list(self._event_buffer):
if self._event_matches(buffered, kinds):
self._event_buffer.remove(buffered)
return buffered
fut = asyncio.get_running_loop().create_future()
waiter = (kinds, fut)
self._event_waiters.append(waiter)
try:
return await asyncio.wait_for(fut, timeout)
finally:
if waiter in self._event_waiters:
self._event_waiters.remove(waiter)
@staticmethod
def _event_matches(event, kinds):
return kinds is None or (isinstance(event, dict) and event.get("event") in kinds)
def _dispatch_event(self, event):
"""Hand one event to the longest-waiting matching waiter, else buffer it; then observe."""
for waiter in self._event_waiters:
kinds, fut = waiter
if not fut.done() and self._event_matches(event, kinds):
fut.set_result(event)
self._event_waiters.remove(waiter)
break
else:
self._event_buffer.append(event)
if self.on_event:
self.on_event(event)
async def send_gamescript(self, json_data):
"""Send a JSON string to the GameScript."""
import json
@@ -852,6 +989,12 @@ class OpenTTDAdminClient:
if not fut.done():
fut.set_result(data)
return
# Unsolicited event batch from the AdminBridge GameScript: fan it out to the
# event consumers rather than the generic GameScript callback.
if data.get('command') == 'events' and isinstance(data.get('events'), list):
for event in data['events']:
self._dispatch_event(event)
return
if self.on_gamescript:
self.on_gamescript(data)
else:
+35 -1
View File
@@ -1,5 +1,5 @@
import struct
from enum import IntEnum
from enum import IntEnum, StrEnum
import monocypher
from openttd_protocol.wire.exceptions import SocketClosed
@@ -220,6 +220,40 @@ class NetworkAuthenticationMethod(IntEnum):
X25519_PAKE = 1
X25519_AuthorizedKey = 2
class GameEventType(StrEnum):
"""Event kinds the AdminBridge GameScript can push over the Admin Network.
These are the values of the "event" field of each event dict, and what
OpenTTDAdminClient.subscribe_events() and wait_for_event() take. They are plain strings,
so a bare "vehicle_arrive" works everywhere a member does.
The first three are synthesised by the GameScript sampling game state on an interval,
because the engine raises no event for them; the rest are engine events forwarded as they
happen. VehicleLost, VehicleWaitingInDepot and VehicleUnprofitable have deliberately no
entry here: the engine only ever raises those for AI companies, never for a GameScript.
"""
# Polled: derived by diffing successive samples of the game state.
VehicleArrive = "vehicle_arrive"
VehicleDepart = "vehicle_depart"
CargoWaiting = "cargo_waiting"
# Forwarded straight from the engine's own GameScript events.
VehicleCrashed = "vehicle_crashed"
StationFirstVehicle = "station_first_vehicle"
IndustryOpen = "industry_open"
IndustryClose = "industry_close"
TownFounded = "town_founded"
CompanyNew = "company_new"
CompanyInTrouble = "company_in_trouble"
CompanyBankrupt = "company_bankrupt"
SubsidyOffer = "subsidy_offer"
SubsidyOfferExpired = "subsidy_offer_expired"
SubsidyAwarded = "subsidy_awarded"
SubsidyExpired = "subsidy_expired"
# Emitted by the bridge itself, never subscribed to: one poll produced more events than
# fit in the per-poll cap and `count` of them were discarded.
EventsDropped = "events_dropped"
class OpenTTDProtocol(TCPProtocol):
"""Low-level OpenTTD TCP protocol handler with encryption support."""
PacketType = PacketGameType