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]>
233 lines
11 KiB
Markdown
233 lines
11 KiB
Markdown
# 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.
|