Files
kovagoadiandClaude f7ca395a4f
Continuous Integration / lint-and-security (pull_request) Failing after 19s
Continuous Integration / tests-and-coverage (pull_request) Successful in 24s
Put the AdminBridge GameScript under version control
The last commit noted in passing that the server-side half of the admin
GameScript channel "is not in this repo -- docker/config is gitignored --
so it has to be updated separately for any of this to work." That was true
of all nine features the README documents: list_vehicles, list_stations,
list_cargo, get_timetable, get_station, get_station_cargo, get_dispatch and
the event stream all answer from 705 lines of Squirrel that no clone could
reproduce, no reviewer could see, and CI never touched.

The bridge now lives in gamescript/AdminBridge/ with its own README, the
same arrangement docker/patches/ uses for the local JGRPP patches, and
docker-compose.yml bind-mounts it read-only over the container's
game/AdminBridge. docker/config stays ignored -- it also holds savegames,
downloaded content and generated config -- so the copy under it is now
shadowed and can be deleted. main.nut is byte-identical to what was running,
apart from the version work below.

Adds a version handshake, because the channel gives no way to tell a stale
bridge from a hung one: a bridge that does not recognise a command drops it
silently, so a client ahead of the server sees nothing but timeouts. The
bridge now answers get_version with its protocol version plus its command
and event catalogues, and get_bridge_version() raises when that is below
GS_BRIDGE_VERSION. It is opt-in rather than checked on connect: GameScripts
do not tick while the game is paused, so an automatic check would refuse to
connect to a paused server. A bridge older than 4 predates get_version
itself and can only fail by timing out, so the E2E test catches that and
reports it by name instead.

tests/test_gamescript.py gives CI a foothold on the GameScript without a
Squirrel toolchain: it parses the .nut files and pins the protocol version
across info.nut, main.nut and protocol.py, the event catalogue against
GameEventType, and the command table against the commands client.py sends.
The version has to be declared three times because a GameScript cannot read
its own info.nut at runtime -- GSController.GetVersion() returns the OpenTTD
version, not the script's.

info.nut also gains MinVersionToLoad() { return 1; }. The engine defaults it
to GetVersion(), so without it this bump would orphan every savegame pinned
to version 3: the scanner finds no compatible script and falls back with a
warning. The bridge keeps no savegame state, so any version can take over.

HandleCommand now dispatches through the same table get_version reports,
rather than an if/else chain, so the catalogue a client feature-detects
against cannot drift from what is implemented.

Co-Authored-By: Claude <[email protected]>
2026-08-31 19:06:33 +02:00

235 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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) from
[`gamescript/AdminBridge/`](../gamescript/AdminBridge/README.md) — `get_bridge_version()` checks
that it is new enough, and it is worth calling once at startup. 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.