Add game event support to the admin client
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:
@@ -16,6 +16,11 @@ The primary API for developers.
|
||||
- **Event-Driven:** Uses `asyncio.Event` (like `self.joined`) to signal state changes to the calling code.
|
||||
- **Callback System:** Provides hooks like `on_chat` to allow users to react to game events without modifying the core library.
|
||||
|
||||
### 3. `OpenTTDAdminClient` (Admin Network)
|
||||
Talks to the Admin port (TCP 3977) and, through the AdminBridge GameScript's JSON channel, to the running game itself.
|
||||
- **Correlated Queries:** `get_timetable()`, `get_station()`, `get_dispatch()` and friends all funnel through `_gs_query()`, which tags each request with a monotonic `request_id`, parks a future, and lets `receive_ServerGamescript` resolve it when the matching reply arrives.
|
||||
- **Push Events:** `subscribe_events()` opens the one stream that flows the other way. Event batches carry no `request_id`, so `receive_ServerGamescript` routes them to the event consumers instead: each event goes to the longest-waiting matching `wait_for_event()` caller, or into a bounded buffer if nobody is waiting, and to the `on_event` observer either way. See [EVENTS.md](EVENTS.md).
|
||||
|
||||
## Handshake Flow
|
||||
|
||||
1. **Connection:** TCP connection established to Port 3979.
|
||||
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
# Game Events: Usage Guide
|
||||
|
||||
Everything else in this library is a **request**: you ask the server something and it answers.
|
||||
Events are the other direction — the server tells you when something happens, so a bot can react
|
||||
to the game instead of polling it in a loop. This guide covers `OpenTTDAdminClient`'s
|
||||
`subscribe_events()`, `unsubscribe_events()`, `wait_for_event()` and the `on_event` callback.
|
||||
For the JSON that goes over the wire, see [PROTOCOL.md](PROTOCOL.md#game-events).
|
||||
|
||||
## Quick start
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from openttd import OpenTTDAdminClient
|
||||
from openttd.protocol import GameEventType
|
||||
|
||||
async def main():
|
||||
admin = OpenTTDAdminClient("127.0.0.1", admin_name="EventWatcher")
|
||||
await admin.connect(admin_password="asd", secure=True)
|
||||
await admin.joined.wait()
|
||||
|
||||
await admin.subscribe_events(
|
||||
events=[GameEventType.VehicleArrive, GameEventType.VehicleDepart],
|
||||
)
|
||||
|
||||
for _ in range(10):
|
||||
event = await admin.wait_for_event(timeout=60.0)
|
||||
print(f"vehicle {event['vehicle_id']} {event['event']} "
|
||||
f"at station {event['station_id']}")
|
||||
|
||||
await admin.unsubscribe_events()
|
||||
await admin.quit()
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## Two ways to consume events
|
||||
|
||||
Both see **every** event, so you can use either or mix them.
|
||||
|
||||
**Pull — `await admin.wait_for_event(kind=None, timeout=5.0)`.** Returns the next matching event.
|
||||
`kind` is a `GameEventType` (or plain string), an iterable of them, or `None` for "anything".
|
||||
Events that arrive while you are not waiting are buffered, so nothing is lost between two calls,
|
||||
and each event goes to at most one waiter. Raises `asyncio.TimeoutError` if nothing matching
|
||||
arrives in time, `ConnectionError` if the admin connection drops mid-wait.
|
||||
|
||||
```python
|
||||
# The next arrival or departure, whichever comes first.
|
||||
event = await admin.wait_for_event(
|
||||
{GameEventType.VehicleArrive, GameEventType.VehicleDepart}, timeout=30.0)
|
||||
```
|
||||
|
||||
**Push — `admin.on_event = handler`.** A plain callback (like `on_chat` / `on_console`), invoked
|
||||
with each event dict as it arrives. Use it for a long-running loop that reacts to everything:
|
||||
|
||||
```python
|
||||
def handle(event):
|
||||
if event["event"] == "cargo_waiting" and event["delta"] > 0:
|
||||
print(f"{event['delta']} units of cargo {event['cargo_id']} "
|
||||
f"arrived at station {event['station_id']}")
|
||||
|
||||
admin.on_event = handle
|
||||
await admin.subscribe_events(events=[GameEventType.CargoWaiting])
|
||||
await asyncio.sleep(600) # events arrive on the connection's own task
|
||||
```
|
||||
|
||||
The buffer behind `wait_for_event()` holds the last 256 unclaimed events by default
|
||||
(`OpenTTDAdminClient(..., event_buffer_size=N)`); past that the oldest are dropped, so a
|
||||
subscription nobody drains cannot grow without bound.
|
||||
|
||||
## Event kinds
|
||||
|
||||
Import them from `openttd.protocol` as `GameEventType`, or just use the string. They come in two
|
||||
flavours, which behave differently in ways worth knowing.
|
||||
|
||||
### Polled events — sampled, not instantaneous
|
||||
|
||||
The engine raises **no** event when a vehicle reaches a stop or when cargo shows up, so the
|
||||
AdminBridge GameScript synthesises these by sampling game state every `interval` ticks and
|
||||
reporting what changed since the previous sample.
|
||||
|
||||
| Kind | Fires when | Fields |
|
||||
|---|---|---|
|
||||
| `vehicle_arrive` | a vehicle starts loading/unloading at a station | `vehicle_id`, `station_id`, `owner`, `vehicle_type`, `order_position`, `cargo` |
|
||||
| `vehicle_depart` | a vehicle stops loading/unloading at a station | the same, plus `dwell` (ticks it stayed) |
|
||||
| `cargo_waiting` | a station's waiting amount of a cargo changes | `station_id`, `cargo_id`, `waiting` (new total), `delta` (signed change) |
|
||||
|
||||
`cargo` is a list of `{"cargo_id": n, "load": units}` for the cargo actually aboard (omit it with
|
||||
`include_cargo=False`). `vehicle_type` is `0` rail, `1` road, `2` water, `3` air.
|
||||
`order_position` is the index of the vehicle's current order, or `-1` if it has none.
|
||||
`delta` is negative when cargo was picked up rather than delivered.
|
||||
|
||||
Three consequences of sampling that you should design around:
|
||||
|
||||
- **Short stops can be missed entirely.** If a vehicle arrives and leaves between two samples,
|
||||
neither event exists. Lower `interval` to narrow the window; you cannot close it.
|
||||
- **The first sample only establishes a baseline.** A vehicle already sitting at a station when
|
||||
you subscribe did not just arrive and gets no `vehicle_arrive`. Its eventual `dwell` is counted
|
||||
from that first sample, not from its real arrival.
|
||||
- **`delta` is measured against the previous sample, not the previous event.** With a
|
||||
`min_cargo_delta` filter, suppressed changes still move the baseline, so the next reported
|
||||
`delta` covers everything since the last *sample*.
|
||||
|
||||
Two more edge cases in how "at a stop" is defined: it means *loading or unloading* at a station,
|
||||
so a vehicle passing through a station non-stop never registers, and a vehicle that is stopped by
|
||||
hand or breaks down at a platform reads as having left (and as arriving again when it resumes).
|
||||
|
||||
### Forwarded engine events — exact, as they happen
|
||||
|
||||
These the engine does raise, so they are reported the moment they occur, with no sampling
|
||||
involved and no `interval` dependence.
|
||||
|
||||
| Kind | Fields |
|
||||
|---|---|
|
||||
| `vehicle_crashed` | `vehicle_id`, `tile`, `reason`, `victims`, `owner` |
|
||||
| `station_first_vehicle` | `station_id`, `vehicle_id` |
|
||||
| `industry_open`, `industry_close` | `industry_id` |
|
||||
| `town_founded` | `town_id` |
|
||||
| `company_new`, `company_in_trouble`, `company_bankrupt` | `company_id` |
|
||||
| `subsidy_offer`, `subsidy_offer_expired`, `subsidy_awarded`, `subsidy_expired` | `subsidy_id` |
|
||||
|
||||
Every event of either flavour also carries `event` (its kind) and `tick` (the game tick it was
|
||||
observed at).
|
||||
|
||||
There is deliberately no `vehicle_lost`, `vehicle_waiting_in_depot` or `vehicle_unprofitable`:
|
||||
the engine raises those only for AI companies, never for a GameScript, so no bridge could
|
||||
forward them. Watch `vehicle_arrive`/`vehicle_depart` or poll `get_timetable()` instead.
|
||||
|
||||
### `events_dropped`
|
||||
|
||||
Not something you subscribe to — the bridge emits it when a single poll produced more events than
|
||||
its per-poll cap (200) and discarded `count` of them. Treat it as "your view has a hole in it":
|
||||
if you see it, your subscription is too broad or your interval too coarse for what the map is
|
||||
doing. Re-read state with `get_station()` / `get_timetable()` rather than trusting your
|
||||
event-derived view.
|
||||
|
||||
## `subscribe_events(...)`
|
||||
|
||||
Awaitable; returns the bridge's confirmation `{"events": [accepted kinds], "interval": N}`. Every
|
||||
argument narrows what you get, and all are optional.
|
||||
|
||||
| Parameter | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `events` | iterable | Which kinds to receive. Default: all of them. |
|
||||
| `interval` | `int` | Ticks between state samples for the polled kinds. Default `10`. This is their resolution, not a delay. |
|
||||
| `company_id` | `int` | Only report vehicles and stations owned by this company. |
|
||||
| `vehicles` | iterable | Only sample these vehicle ids instead of every vehicle. |
|
||||
| `stations` | iterable | Only sample these station ids instead of every station. |
|
||||
| `cargo` | iterable | Only inspect these cargo types (for `cargo_waiting`, and for the load on vehicle events). |
|
||||
| `min_cargo_delta` | `int` | Suppress `cargo_waiting` events smaller than this many units. Default `1` (report everything). |
|
||||
| `include_cargo` | `bool` | `False` leaves the per-cargo load off vehicle events. Default `True`. |
|
||||
| `timeout` | `float` | Seconds to wait for the confirmation. Default `5.0`. |
|
||||
|
||||
Subscribing **replaces** any previous subscription and resets the baseline. `unsubscribe_events()`
|
||||
stops the stream and drops the bridge's sampling state.
|
||||
|
||||
Errors: `ValueError` when the GameScript rejects the request (`unknown_event`,
|
||||
`invalid_interval`, `invalid_min_cargo_delta`, `invalid_cargo`), `asyncio.TimeoutError` when it
|
||||
does not answer (GameScripts don't run while the game is **paused**, so a paused server always
|
||||
times out), `ConnectionError` if the connection drops. The Gamescript update-frequency
|
||||
subscription the stream needs is set up automatically.
|
||||
|
||||
## Cost: subscribe narrowly
|
||||
|
||||
The bridge samples every watched vehicle and every watched station × cargo pair on each interval,
|
||||
inside a GameScript's limited per-tick opcode budget. The defaults — every kind, every vehicle,
|
||||
every station, every cargo, every 10 ticks — are fine on a small map and wasteful on a large busy
|
||||
one, where the poll can take longer than the interval it is trying to keep.
|
||||
|
||||
Narrow it to what you actually need:
|
||||
|
||||
```python
|
||||
# Watch two specific vehicles closely.
|
||||
await admin.subscribe_events(
|
||||
events=[GameEventType.VehicleArrive, GameEventType.VehicleDepart],
|
||||
vehicles=[7, 9], interval=2)
|
||||
|
||||
# Watch coal piling up at one station, ignoring changes under 10 units.
|
||||
await admin.subscribe_events(
|
||||
events=[GameEventType.CargoWaiting],
|
||||
stations=[3], cargo=[0], min_cargo_delta=10, interval=30)
|
||||
|
||||
# Only one company's traffic, without the per-vehicle cargo detail.
|
||||
await admin.subscribe_events(company_id=0, include_cargo=False)
|
||||
```
|
||||
|
||||
## Putting it together
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from openttd import OpenTTDAdminClient
|
||||
from openttd.protocol import GameEventType
|
||||
|
||||
async def watch_route(admin, vehicle_id):
|
||||
"""Report how long a vehicle spends at each stop on its route."""
|
||||
await admin.subscribe_events(
|
||||
events=[GameEventType.VehicleArrive, GameEventType.VehicleDepart],
|
||||
vehicles=[vehicle_id], interval=2)
|
||||
try:
|
||||
while True:
|
||||
event = await admin.wait_for_event(timeout=300.0)
|
||||
if event["event"] == "vehicle_arrive":
|
||||
print(f"arrived at station {event['station_id']} (order {event['order_position']})")
|
||||
else:
|
||||
loaded = sum(c["load"] for c in event["cargo"])
|
||||
print(f"left station {event['station_id']} after {event['dwell']} ticks "
|
||||
f"carrying {loaded} units")
|
||||
except asyncio.TimeoutError:
|
||||
print("vehicle went quiet")
|
||||
finally:
|
||||
await admin.unsubscribe_events()
|
||||
|
||||
async def main():
|
||||
admin = OpenTTDAdminClient("127.0.0.1", admin_name="RouteWatcher")
|
||||
await admin.connect(admin_password="asd", secure=True)
|
||||
await admin.joined.wait()
|
||||
await watch_route(admin, vehicle_id=7)
|
||||
await admin.quit()
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
The server must run the bundled AdminBridge GameScript (version 3 or newer). Events need **no**
|
||||
server patch — unlike `get_timetable()` and `get_dispatch()`, every getter involved is part of the
|
||||
stock GameScript API. Since GameScripts do not tick while the game is paused, no events are
|
||||
produced on a paused server.
|
||||
|
||||
## See also
|
||||
- [PROTOCOL.md — Game Events](PROTOCOL.md#game-events) for the request/reply JSON.
|
||||
- [TIMETABLES.md](TIMETABLES.md) for reading and editing what the events tell you changed.
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) for how the admin client fits together.
|
||||
@@ -69,6 +69,16 @@ Correlation, the `update_frequency` subscription requirement (auto-subscribed on
|
||||
- **Reply (success):** `{"command": "get_dispatch", "vehicle_id": N, "request_id": X, "enabled": 0|1, "schedules": [{"index", "duration", "start_tick", "delay", "reuse_slots", "slots": [{"offset", "flags"}, ...]}, ...]}`. `enabled` is whether scheduled dispatch is turned on for the vehicle; each schedule reports its `duration` (ticks), `start_tick`, `delay` (max allowed delay), `reuse_slots` (0/1) and its `slots` (each a departure `offset` within the duration plus a 16-bit `flags` word). These are the same schedules and slots edited by the game-port dispatch methods.
|
||||
- **Reply (error):** same envelope with an `"error"` field: `"invalid_vehicle"` or `"response_too_large"`. `get_dispatch()` raises `ValueError` for these.
|
||||
|
||||
### Game Events
|
||||
Every other GameScript command above is request/reply. `subscribe_events()` instead opens a **push** stream: the AdminBridge GameScript sends event batches over `ServerGamescript` as things happen, unsolicited and without a `request_id`. For the calling API and the semantics of each kind, see the [Game Events Usage Guide](EVENTS.md).
|
||||
|
||||
- **Subscribe request:** `{"command": "subscribe_events", "request_id": X}` plus any of the optional narrowing fields `"events"` (array of kind strings), `"interval"` (ticks between state samples, default 10), `"company_id"`, `"vehicles"`, `"stations"`, `"cargo"` (arrays of ids), `"min_cargo_delta"` (default 1) and `"include_cargo"` (bool, default true).
|
||||
- **Subscribe reply:** `{"command": "subscribe_events", "request_id": X, "events": [accepted kinds in catalogue order], "interval": N}`, or the same envelope with an `"error"` field: `"unknown_event"` (plus the offending `"event"`), `"invalid_interval"`, `"invalid_min_cargo_delta"`, or `"invalid_cargo"` (plus the offending `"cargo_id"`). `subscribe_events()` raises `ValueError` for these. Subscribing replaces any previous subscription and resets the bridge's sampling baseline.
|
||||
- **Unsubscribe:** `{"command": "unsubscribe_events", "request_id": X}` → `{"command": "unsubscribe_events", "request_id": X, "events": []}`. This also drops the sampling state.
|
||||
- **Event batch (unsolicited):** `{"command": "events", "events": [{"event": <kind>, "tick": T, ...}, ...]}`. Batches carry at most 24 events per packet, so one poll can produce several; a poll that generated more than 200 events is truncated and followed by a single `{"event": "events_dropped", "count": N}` entry. Because these batches carry no `request_id`, `receive_ServerGamescript` routes them to the event consumers (`on_event` / `wait_for_event()`) instead of the generic `on_gamescript` callback; every other GameScript payload reaches `on_gamescript` unchanged. The usual `update_frequency(Gamescript, Automatic)` subscription applies and is set up automatically by `subscribe_events()`.
|
||||
|
||||
Event kinds come from two sources. `vehicle_arrive`, `vehicle_depart` and `cargo_waiting` are **synthesised** by the bridge, because the engine raises no GameScript event for them: every `interval` ticks it samples `GSVehicle.GetState`/`GetLocation` for the watched vehicles and `GSStation.GetCargoWaiting` for the watched station-cargo pairs, and emits an event per change against the previous sample (so a stop shorter than the interval is never reported, and the first sample after subscribing only sets a baseline). All the remaining kinds — `vehicle_crashed`, `station_first_vehicle`, `industry_open`/`industry_close`, `town_founded`, `company_new`/`company_in_trouble`/`company_bankrupt` and the four `subsidy_*` kinds — are engine events forwarded verbatim from `GSEventController`. Only those the engine actually raises for a **deity** script are available: `ET_VEHICLE_LOST`, `ET_VEHICLE_WAITING_IN_DEPOT` and `ET_VEHICLE_UNPROFITABLE` are `@api ai` only and can never reach a GameScript, so the bridge does not offer them. Unlike the timetable and dispatch queries, none of this needs a server patch — every getter used is stock GameScript API.
|
||||
|
||||
## Vehicle Orders & Timetables (Game Port DoCommands)
|
||||
Unlike vehicle listing, a vehicle's order list, timetables and scheduled dispatch have no writable GameScript API surface (this project adds read-only timetable and dispatch getters via server patches — see "Timetable Query" and "Dispatch Query" above). Reading and modifying them requires real engine commands (`DoCommand`s) sent over the **game port** (TCP 3979) via `ClientCommand`/`ServerCommand` packets, not the Admin Network. This section covers the wire format; for how to call the methods and what each parameter means, see the [Vehicle Timetables Usage Guide](TIMETABLES.md).
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ The tests are located in the `tests/` directory:
|
||||
| [`test_admin.py`](file:///home/kovagoadi/openttd-client/tests/test_admin.py) | `OpenTTDAdminClient` | Tests admin client initialization, admin packet types, and basic protocol constants. |
|
||||
| [`test_protocol.py`](file:///home/kovagoadi/openttd-client/tests/test_protocol.py) | `OpenTTDProtocol` | Tests binary serialization, custom parsers, and stream encryption/decryption (XChaCha20-Poly1305). |
|
||||
| [`test_logic.py`](file:///home/kovagoadi/openttd-client/tests/test_logic.py) | `OpenTTDClient` | Tests client connection lifecycle, company joining flow, authentication, and state management. |
|
||||
| [`test_events.py`](file:///home/kovagoadi/openttd-client/tests/test_events.py) | Game Events | Tests event subscription encoding, the push/pull consumption paths (`on_event`, `wait_for_event()`), buffering and waiter lifecycle. |
|
||||
| [`test_coverage.py`](file:///home/kovagoadi/openttd-client/tests/test_coverage.py) | Coverage Helpers | Auxiliary unit tests targeting connection errors, fallback packet handlers, and missing passwords to ensure high test coverage. |
|
||||
| [`test_e2e.py`](file:///home/kovagoadi/openttd-client/tests/test_e2e.py) | Integration / E2E | Connects to a running local OpenTTD server (e.g., in Docker) to verify full socket interactions, stream cryptography, and keep-alive frames. |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user