diff --git a/.gitignore b/.gitignore index 27c8a8e..7bf4675 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ venv __pycache__ -docker/config \ No newline at end of file +docker/config +.coverage +.pytest_cache \ No newline at end of file diff --git a/README.md b/README.md index 4ad00d8..95c9192 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A high-performance, Object-Oriented Python client for OpenTTD servers, specifica - **Station Listing:** Enumerate stations via the Admin GameScript channel with `list_stations()`. - **Station Cargo Snapshots:** `OpenTTDAdminClient.get_station()` returns a station's live per-cargo state from the running game — both the **real-time** amount waiting and the **planned** flow through the cargodist link graph — over the AdminBridge GS (stock GameScript API, no server patch needed). - **Cargo Flow Breakdown:** `OpenTTDAdminClient.get_station_cargo()` breaks one cargo type down by **source station** and **next hop** (routing destination) for both waiting (real-time) and planned amounts, with optional `from_station`/`via_station` filters. +- **Version-checked Server Bridge:** the server-side AdminBridge GameScript that answers all of the above lives in [`gamescript/AdminBridge/`](gamescript/AdminBridge/README.md) and is mounted into the Docker server automatically. `get_bridge_version()` verifies at startup that the running bridge is new enough, so an outdated one fails by name instead of hanging every query. - **Game Events:** react to the game instead of polling it — `subscribe_events()` streams events over the AdminBridge GS as they happen: a vehicle reaching or leaving a stop (`vehicle_arrive`/`vehicle_depart`, with dwell time and cargo aboard), a station's waiting cargo changing (`cargo_waiting`), plus crashes, industries opening/closing, towns, companies and subsidies. Consume them with `await wait_for_event()` or an `on_event` callback; filter by kind, company, vehicle, station or cargo. ## 🛠 Setup @@ -68,6 +69,8 @@ await client.joined.wait() - `main.py`: Main entry point and usage example. - `lib/openttd/`: Core package containing the protocol and client logic. +- `gamescript/AdminBridge/`: The server-side GameScript the admin client's GameScript channel talks to. +- `docker/`: Dedicated JGRPP server (Dockerfile, compose, and the local server patches in `docker/patches/`). - `docs/`: Extensive documentation on architecture, protocol, and contributing. - `tests/`: Comprehensive test suite (Logic, Protocol, E2E). diff --git a/docker/README.md b/docker/README.md index fff7938..018e880 100644 --- a/docker/README.md +++ b/docker/README.md @@ -24,6 +24,19 @@ This setup builds OpenTTD with the JGR Patch Pack (JGRPP) from source and runs i 4. **Save Games:** Save games are stored in `config/save/`. +## AdminBridge GameScript +`config/` is gitignored (it holds savegames, downloaded content and generated config), so the +AdminBridge GameScript — the server-side half of the Python client's admin GameScript channel — +is kept in [gamescript/AdminBridge/](../gamescript/AdminBridge/README.md) instead and bind-mounted +read-only over the container's `game/AdminBridge` by `docker-compose.yml`. Nothing to install: +`docker-compose up` serves the tracked copy. Edit it there, not under `config/game/`, which the +mount shadows. A GameScript is loaded at game start, so restart the server to pick up a change. + +Running the server outside this compose file? Copy the directory in by hand instead: +```bash +cp -r ../gamescript/AdminBridge ~/.local/share/openttd/game/ +``` + ## JGRPP Source The source code is cloned from the `jgrpp` branch of `https://github.com/JGRennison/OpenTTD-patches` (currently at tag `jgrpp-0.71.1`) and carries local patches from `patches/` — see diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 5ee2ab0..3d94154 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -9,6 +9,10 @@ services: - "3977:3977/tcp" volumes: - ./config:/home/openttd/.local/share/openttd + # The AdminBridge GameScript is served from its tracked source rather than from the + # (gitignored) config directory, so the server always runs the reviewed copy. Nested + # inside the mount above, and read-only: edit gamescript/AdminBridge/, not the container. + - ../gamescript/AdminBridge:/home/openttd/.local/share/openttd/game/AdminBridge:ro environment: - PUID=1000 - PGID=1000 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 48e6210..1f0f45f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -19,6 +19,7 @@ The primary API for developers. ### 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. +- **Two Halves:** the queries above only work because a matching GameScript is running on the server. That script is part of this project, in [`gamescript/AdminBridge/`](../gamescript/AdminBridge/README.md), and the client's `get_bridge_version()` checks the running copy is new enough for what it is about to send. - **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 diff --git a/docs/EVENTS.md b/docs/EVENTS.md index b5f9a5b..e569515 100644 --- a/docs/EVENTS.md +++ b/docs/EVENTS.md @@ -221,7 +221,9 @@ asyncio.run(main()) ## Requirements -The server must run the bundled AdminBridge GameScript (version 3 or newer). Events need **no** +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. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 1ebd993..08d7932 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -26,6 +26,36 @@ Similar to the Game Port, the Admin Network uses X25519 PAKE for secure authenti ### Update Frequencies Admins can subscribe to various updates (Date, Client Info, Company Info, etc.) at different frequencies (Poll, Daily, Weekly, Monthly, Quarterly, Annually, Automatic). +### The AdminBridge GameScript +Everything in this section rides on a companion GameScript running on the server — the other half +of the protocol, kept in [`gamescript/AdminBridge/`](../gamescript/AdminBridge/README.md). The +Admin Network itself offers no way to ask the game about individual vehicles, stations or orders; +what it does offer is an opaque JSON channel to whatever GameScript is loaded (`AdminGamescript` +out, `ServerGamescript` back), and this bridge is what gives that channel meaning. + +Note what the channel does **not** give you: a bridge that does not recognise a command drops it +silently. There is no "unknown command" reply, so a client talking to a bridge older than itself +sees nothing but timeouts. + +### Bridge Version +`get_bridge_version()` exists to turn that silence into an error. The bridge reports the version +of the JSON protocol it implements, and the client compares it against `GS_BRIDGE_VERSION` (the +version it was written against, in `protocol.py`). + +- **Request:** `{"command": "get_version", "request_id": X}`. +- **Reply:** `{"command": "get_version", "request_id": X, "version": N, "commands": [...], "events": [...]}` — + `commands` is every command name the bridge answers (sorted) and `events` every event kind it + can push, so a client can feature-detect a single command instead of comparing version numbers. +- **No reply at all** is the answer from a bridge older than version 4, which is when + `get_version` was added; `get_bridge_version()` surfaces that as `asyncio.TimeoutError`, the + same as a paused game or a server with no bridge loaded. + +The version covers the shape of the JSON protocol, not the implementation, and is declared in +three places that [`tests/test_gamescript.py`](../tests/test_gamescript.py) keeps in step: +`info.nut`'s `GetVersion()`, `main.nut`'s `BRIDGE_VERSION` and `protocol.py`'s +`GS_BRIDGE_VERSION`. The same test checks the bridge's event catalogue against `GameEventType` +and its command table against the commands the client actually sends. + ### Vehicle Listing The Admin Network has no native packet or `AdminUpdateType` for listing individual vehicles — `ServerCompanyStats` only reports aggregate per-company vehicle counts (trains/lorries/buses/planes/ships). To retrieve an actual vehicle list, this client sends a `list_vehicles` command over the GameScript JSON channel (`AdminGamescript`/`ServerGamescript`) via `list_vehicles()`. This requires a companion GameScript running server-side that understands the `list_vehicles` command and replies with vehicle data through `ServerGamescript`. diff --git a/docs/TESTING.md b/docs/TESTING.md index b1bd2d5..11382ea 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -15,6 +15,7 @@ The tests are located in the `tests/` directory: | [`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_gamescript.py`](file:///home/kovagoadi/openttd-client/tests/test_gamescript.py) | AdminBridge GameScript | Reads `gamescript/AdminBridge/*.nut` and checks it against the client: protocol version, event catalogue, command table, and that the docker setup serves the tracked copy. No Squirrel toolchain needed — see [`gamescript/AdminBridge/README.md`](file:///home/kovagoadi/openttd-client/gamescript/AdminBridge/README.md). | | [`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. | --- diff --git a/docs/TIMETABLES.md b/docs/TIMETABLES.md index 6adea3e..3bbfc0d 100644 --- a/docs/TIMETABLES.md +++ b/docs/TIMETABLES.md @@ -65,10 +65,11 @@ vehicle id; `response_too_large` if a very long order list overflows the admin p don't run while the game is **paused**, so a paused server always times out), and `ConnectionError` if the admin connection drops mid-query. -Requirements: the server must run the bundled AdminBridge GameScript **and** the patched JGRPP -build with the `GSOrder` timetable getters (both included in this repo's `docker/` setup — see -`docker/patches/README.md`). The Gamescript update-frequency subscription it needs is set up -automatically on first call. +Requirements: the server must run the bundled AdminBridge GameScript (from +`gamescript/AdminBridge/`, mounted into the container by this repo's `docker/` setup — see +[`gamescript/AdminBridge/README.md`](../gamescript/AdminBridge/README.md)) **and** the patched +JGRPP build with the `GSOrder` timetable getters (`docker/patches/README.md`). The Gamescript +update-frequency subscription it needs is set up automatically on first call. ## Writing (and the legacy observer): things you must know diff --git a/gamescript/AdminBridge/README.md b/gamescript/AdminBridge/README.md new file mode 100644 index 0000000..a558b36 --- /dev/null +++ b/gamescript/AdminBridge/README.md @@ -0,0 +1,88 @@ +# AdminBridge GameScript + +The server-side half of this project. Every `OpenTTDAdminClient` feature that goes over the +GameScript JSON channel — `list_vehicles`, `list_stations`, `list_cargo`, `get_timetable`, +`get_station`, `get_station_cargo`, `get_dispatch` and the `subscribe_events` push stream — is a +command this script answers. The Python client is only half the protocol; this is the other half, +and the two are documented together in [docs/PROTOCOL.md](../../docs/PROTOCOL.md). + +It is a **deity** GameScript: it joins no company, builds nothing, and only reads game state and +replies on the admin port via `GSAdmin.Send()`. + +- `info.nut` — the manifest OpenTTD's script scanner reads (name, version, API version). +- `main.nut` — the bridge itself: a command dispatch table, one handler per command, and the + event poller. + +## Why it lives here + +`docker/config/` is gitignored — it also holds savegames, downloaded content and generated +config — so a GameScript kept there is invisible to review and to CI, and a fresh clone cannot +reproduce the server side at all. This directory is the source of truth, the same arrangement +[docker/patches/](../../docker/patches/README.md) uses for the local JGRPP patches. + +`docker-compose.yml` bind-mounts this directory read-only over the container's +`game/AdminBridge`, so the server runs the tracked copy and nothing else: + +```yaml +- ../gamescript/AdminBridge:/home/openttd/.local/share/openttd/game/AdminBridge:ro +``` + +Editing the copy under `docker/config/game/AdminBridge/` therefore has no effect; that path is +shadowed by the mount. On a server not started through this compose file, copy the directory +into the OpenTTD data dir instead: + +```bash +cp -r gamescript/AdminBridge ~/.local/share/openttd/game/ +``` + +A GameScript is loaded when the game starts, so a change needs the server restarted (or the +game reloaded) before it takes effect — the running instance keeps the old code. + +## Protocol version + +`info.nut`'s `GetVersion()` is the version of the **JSON protocol**, not of the implementation: +bump it when a command, field or event kind changes shape, not for a refactor. Three places +carry it and all three must move together: + +| Where | What | +| :--- | :--- | +| `info.nut` → `GetVersion()` | what OpenTTD records in the savegame | +| `main.nut` → `BRIDGE_VERSION` | what the `get_version` command reports | +| `lib/openttd/protocol.py` → `GS_BRIDGE_VERSION` | what the client requires | + +A GameScript cannot read its own `info.nut` at runtime — `GSController.GetVersion()` returns the +*OpenTTD* version — hence the duplication. [`tests/test_gamescript.py`](../../tests/test_gamescript.py) +pins the three together, along with the event catalogue and the command list, so a half-finished +bump fails in CI rather than against a live server. + +`OpenTTDAdminClient.get_bridge_version()` is the client end of this: it asks the running bridge +and raises if it is too old, which is worth doing once at startup. Without it a stale bridge +gives no error at all — it does not recognise the command, so the client just waits out its +timeout. A bridge older than version 4 predates `get_version` itself and can only fail that way. + +## Requirements + +- **JGRPP**, patched with `docker/patches/` — `get_timetable` and `get_dispatch` call `GSOrder` + getters those patches add. Every other command uses stock GameScript API, so an unpatched + server still answers them. +- The admin client must subscribe with `update_frequency(Gamescript, Automatic)` or the server + drops every reply. The client does this automatically on first use. +- GameScripts do not tick while the game is **paused**, so a paused server answers nothing and + every query times out. + +## Which GameScript actually runs + +OpenTTD runs exactly **one** GameScript, and a savegame remembers the one it was played with by +name: loading it overrides `[game_scripts]` in `openttd.cfg`. So a savegame that pins some other +script leaves this bridge unloaded, and every command times out no matter what is mounted where. +The symptom to recognise is commands timing out *uniformly* — as opposed to a stale bridge, where +the older commands still answer and only newer ones hang. + +## Verifying a change + +`docker/config` being untracked used to mean this file could only be tested against a running +server. It still needs one for the real check — `pytest -m e2e` exercises every command end to +end, and [docs/TESTING.md](../../docs/TESTING.md) covers starting the server. Before that, +`pytest -m "not e2e" tests/test_gamescript.py` catches the drift a server would only reveal as a +timeout. Note that a syntax error anywhere in `main.nut` stops the whole bridge from loading, so +every command fails at once — check the server log (`docker compose logs`) for the compile error. diff --git a/gamescript/AdminBridge/info.nut b/gamescript/AdminBridge/info.nut new file mode 100644 index 0000000..451eef3 --- /dev/null +++ b/gamescript/AdminBridge/info.nut @@ -0,0 +1,19 @@ +class AdminBridge extends GSInfo { + function GetAuthor() { return "Gemini"; } + function GetName() { return "AdminBridge"; } + function GetShortName() { return "ADMB"; } + function GetDescription() { return "Answers JSON commands from the admin port on behalf of an admin client."; } + /* Bridge protocol version. Must equal BRIDGE_VERSION in main.nut, which is what the + * get_version command reports; tests/test_gamescript.py pins the two together and to + * openttd.protocol.GS_BRIDGE_VERSION. Bump it whenever the JSON protocol changes. */ + function GetVersion() { return 4; } + /* Any version of this script can take over from any older one: the bridge keeps no + * savegame state of its own (it has no Save()), so there is nothing to migrate. Without + * this, the default is GetVersion(), and loading a savegame that pinned an older version + * makes the engine fall back with a "no longer available" warning. */ + function MinVersionToLoad() { return 1; } + function GetAPIVersion() { return "1.10"; } + function GetDate() { return "2026-08-31"; } + function CreateInstance() { return "AdminBridge"; } +} +RegisterGS(AdminBridge()); diff --git a/gamescript/AdminBridge/main.nut b/gamescript/AdminBridge/main.nut new file mode 100644 index 0000000..945dfcb --- /dev/null +++ b/gamescript/AdminBridge/main.nut @@ -0,0 +1,722 @@ +class AdminBridge extends GSController { + /* --- Event subscription state (see HandleSubscribeEvents) --- */ + event_kinds = null; // table of subscribed kind -> true; null when not subscribed + event_order = null; // canonical kind ordering, so subscribe replies are stable + event_catalog = null; // table of every supported kind -> true, for validation + interval = 10; // ticks between state polls + sleep_ticks = 10; // ticks slept per main-loop iteration + last_poll = 0; // tick of the last state poll + seeded = false; // false until the first poll has recorded a state baseline + company_id = null; // optional owner filter for the polled vehicle/station events + watch_vehicles = null; // explicit vehicle ids to poll; null = every vehicle + watch_stations = null; // explicit station ids to poll; null = every station + cargo_ids = null; // cargo ids to inspect; set at subscribe time + min_cargo_delta = 1; // smallest waiting-amount change worth a cargo_waiting event + include_cargo = true; // attach each vehicle's load to arrive/depart events + vehicle_at = null; // vehicle id -> { station = id or -1, since = tick } + cargo_prev = null; // station id -> table of cargo id -> last seen waiting amount + + /* Cap on how many events one poll may emit. A first poll over a busy map can otherwise + * produce thousands at once; past the cap the rest are dropped and reported as such. */ + MAX_EVENTS_PER_POLL = 200; + /* Events per admin packet. Kept well under the packet size limit so a batch always fits. */ + EVENT_BATCH_SIZE = 24; + + /* Version of the JSON protocol spoken over the admin port, reported by get_version. + * A GameScript cannot read its own info.nut at runtime (GSController.GetVersion() returns + * the *OpenTTD* version), so this duplicates info.nut's GetVersion() and the two are pinned + * together — along with the client's openttd.protocol.GS_BRIDGE_VERSION — by + * tests/test_gamescript.py. Bump all three whenever the protocol changes. */ + BRIDGE_VERSION = 4; + + /* Every command the bridge answers: name -> handler method plus the request fields that + * handler needs. HandleCommand dispatches through this and get_version reports it, so the + * catalogue a client feature-detects against cannot drift from what is implemented. */ + COMMANDS = { + get_version = { handler = "HandleGetVersion", requires = [] }, + list_vehicles = { handler = "HandleListVehicles", requires = [] }, + list_stations = { handler = "HandleListStations", requires = [] }, + list_cargo = { handler = "HandleListCargo", requires = [] }, + get_timetable = { handler = "HandleGetTimetable", requires = ["vehicle_id"] }, + get_station = { handler = "HandleGetStation", requires = ["station_id"] }, + get_station_cargo = { handler = "HandleGetStationCargo", requires = ["station_id", "cargo_id"] }, + get_dispatch = { handler = "HandleGetDispatch", requires = ["vehicle_id"] }, + subscribe_events = { handler = "HandleSubscribeEvents", requires = [] }, + unsubscribe_events = { handler = "HandleUnsubscribeEvents", requires = [] }, + }; + + function InitEvents() { + this.ResetEventState(); + this.event_order = [ + /* Synthesised by polling game state (PollState). */ + "vehicle_arrive", "vehicle_depart", "cargo_waiting", + /* Native GameScript events, forwarded as they arrive (HandleNativeEvent). */ + "vehicle_crashed", "station_first_vehicle", + "industry_open", "industry_close", "town_founded", + "company_new", "company_in_trouble", "company_bankrupt", + "subsidy_offer", "subsidy_offer_expired", "subsidy_awarded", "subsidy_expired" + ]; + this.event_catalog = {}; + foreach (kind in this.event_order) this.event_catalog[kind] <- true; + } + + function Start() { + GSLog.Info("AdminBridge started."); + this.InitEvents(); + while (true) { + this.Sleep(this.sleep_ticks); + local pending = []; + local event = GSEventController.GetNextEvent(); + while (event != null) { + if (event.GetEventType() == GSEvent.ET_ADMIN_PORT) { + local admin_event = GSEventAdminPort.Convert(event); + local request = admin_event.GetObject(); + if (request != null && "command" in request) this.HandleCommand(request); + } else if (this.event_kinds != null) { + this.HandleNativeEvent(event, pending); + } + event = GSEventController.GetNextEvent(); + } + if (this.event_kinds != null) this.PollState(pending); + this.SendEventBatch(pending); + } + } + + function HandleCommand(request) { + if (!(request.command in this.COMMANDS)) return; + local spec = this.COMMANDS[request.command]; + /* A command missing the fields its handler needs is dropped rather than answered: + * there is nothing to answer *about*, and no request_id is guaranteed either. */ + foreach (field in spec.requires) { + if (!(field in request)) return; + } + this[spec.handler](request); + } + + /* Version handshake. A client expecting a newer bridge than the server runs would otherwise + * just time out on the first command this bridge does not know; asking here turns that into + * an answer. The two catalogues let a client feature-detect, which survives a bridge that + * gained commands out of version order better than comparing numbers does. */ + function HandleGetVersion(request) { + local commands = []; + foreach (name, _ in this.COMMANDS) commands.append(name); + commands.sort(); // table iteration order is arbitrary; keep the reply stable + local events = []; + foreach (kind in this.event_order) events.append(kind); + local reply = { command = "get_version", version = this.BRIDGE_VERSION, + commands = commands, events = events }; + if ("request_id" in request) reply.request_id <- request.request_id; + GSAdmin.Send(reply); + } + + /* Cargo id -> label ("PASS", "COAL", ...) so callers can name what a vehicle carries. + * Labels come from the NewGRF cargo table, so ids are not stable across games. */ + function HandleListCargo(request) { + local cargo = []; + foreach (c, _ in GSCargoList()) { + local label = ""; + try { label = GSCargo.GetCargoLabel(c); } catch (e) { label = ""; } + local freight = 0; + try { freight = GSCargo.IsFreight(c) ? 1 : 0; } catch (e) { freight = 0; } + cargo.append({ cargo_id = c, label = label, freight = freight }); + } + local reply = { command = "list_cargo", cargo = cargo }; + if ("request_id" in request) reply.request_id <- request.request_id; + GSAdmin.Send(reply); + } + + function HandleListVehicles(request) { + local vehicle_list = GSVehicleList(); + local vehicles = []; + foreach (v, _ in vehicle_list) { + if (!GSVehicle.IsValidVehicle(v)) continue; + local next_stop = "None"; + if (GSOrder.GetOrderCount(v) > 0) { + local dest_tile = GSOrder.GetOrderDestination(v, GSOrder.ORDER_CURRENT); + local station_id = GSStation.GetStationID(dest_tile); + if (GSStation.IsValidStation(station_id)) next_stop = GSStation.GetName(station_id); + } + /* Which cargoes this vehicle can actually carry. A vehicle (an articulated + * train especially) can have capacity for several cargo types, so report + * every one with a non-zero capacity rather than a single "cargo type". */ + local cargo = []; + foreach (c, _ in GSCargoList()) { + local cap = GSVehicle.GetCapacity(v, c); + if (cap > 0) cargo.append({ cargo_id = c, capacity = cap }); + } + vehicles.append({ + id = v, + age = GSVehicle.GetAge(v), + max_age = GSVehicle.GetMaxAge(v), + next_stop = next_stop, + type = GSVehicle.GetVehicleType(v), + owner = GSVehicle.GetOwner(v), + order_count = GSOrder.GetOrderCount(v), + cargo = cargo + }); + } + local reply = { vehicles = vehicles }; + if ("request_id" in request) reply.request_id <- request.request_id; + GSAdmin.Send(reply); + } + + function HandleGetTimetable(request) { + local v = request.vehicle_id; + local reply = { command = "get_timetable", vehicle_id = v }; + if ("request_id" in request) reply.request_id <- request.request_id; + if (!GSVehicle.IsValidVehicle(v)) { + reply.error <- "invalid_vehicle"; + GSAdmin.Send(reply); + return; + } + reply.lateness <- GSOrder.GetTimetableLateness(v); + reply.start_tick <- GSOrder.GetTimetableStartTick(v); + reply.current_order_time <- GSOrder.GetCurrentOrderTime(v); + reply.total_duration <- GSOrder.GetTimetableTotalDuration(v); + local orders = []; + local count = GSOrder.GetOrderCount(v); + for (local i = 0; i < count; i++) { + /* Resolve the order's destination station so callers can build a station + * graph (vertices = stations, edges = segments). Non-station orders + * (depots, waypoints, conditional) have no station: report -1. */ + local station_id = -1; + if (GSOrder.IsValidVehicleOrder(v, i) && GSOrder.IsGotoStationOrder(v, i)) { + local sid = GSStation.GetStationID(GSOrder.GetOrderDestination(v, i)); + if (GSStation.IsValidStation(sid)) station_id = sid; + } + orders.append({ + position = i, + station_id = station_id, + wait_time = GSOrder.GetTimetableWaitTime(v, i), + travel_time = GSOrder.GetTimetableTravelTime(v, i), + wait_timetabled = GSOrder.IsWaitTimetabled(v, i) ? 1 : 0, + travel_timetabled = GSOrder.IsTravelTimetabled(v, i) ? 1 : 0, + wait_fixed = GSOrder.IsWaitFixed(v, i) ? 1 : 0, + travel_fixed = GSOrder.IsTravelFixed(v, i) ? 1 : 0, + leave_type = GSOrder.GetLeaveType(v, i), + max_speed = GSOrder.GetTimetableMaxSpeed(v, i) + }); + } + reply.orders <- orders; + if (!GSAdmin.Send(reply)) { + /* Response exceeded the admin packet size limit; send a small error + * instead so the waiting client fails fast rather than timing out. */ + local fallback = { command = "get_timetable", vehicle_id = v, error = "response_too_large" }; + if ("request_id" in request) fallback.request_id <- request.request_id; + GSAdmin.Send(fallback); + } + } + + function HandleListStations(request) { + /* GameScripts run as a deity, so GSStationList lists every company's stations; + * an optional company_id filters to a single owner (the deity list ignores it). */ + local filter_owner = ("company_id" in request) ? request.company_id : null; + local station_list = GSStationList(GSStation.STATION_ANY); + local stations = []; + foreach (s, _ in station_list) { + if (!GSStation.IsValidStation(s)) continue; + local owner = GSStation.GetOwner(s); + if (filter_owner != null && owner != filter_owner) continue; + stations.append({ + id = s, + name = GSStation.GetName(s), + location = GSStation.GetLocation(s), + owner = owner + }); + } + local reply = { command = "list_stations", stations = stations }; + if ("request_id" in request) reply.request_id <- request.request_id; + GSAdmin.Send(reply); + } + + function HandleGetStation(request) { + local sid = request.station_id; + local reply = { command = "get_station", station_id = sid }; + if ("request_id" in request) reply.request_id <- request.request_id; + if (!GSStation.IsValidStation(sid)) { + reply.error <- "invalid_station"; + GSAdmin.Send(reply); + return; + } + reply.name <- GSStation.GetName(sid); + reply.location <- GSStation.GetLocation(sid); + reply.owner <- GSStation.GetOwner(sid); + local cargo = []; + local cargo_list = GSCargoList(); + foreach (c, _ in cargo_list) { + local waiting = GSStation.GetCargoWaiting(sid, c); + local planned = GSStation.GetCargoPlanned(sid, c); + local has_rating = GSStation.HasCargoRating(sid, c); + /* Skip cargo the station has never handled to keep the reply compact. */ + if (waiting <= 0 && planned <= 0 && !has_rating) continue; + cargo.append({ + cargo_id = c, + waiting = waiting, // real-time: units currently waiting + planned = planned, // planned: cargodist link-graph flow + rating = has_rating ? GSStation.GetCargoRating(sid, c) : null + }); + } + reply.cargo <- cargo; + if (!GSAdmin.Send(reply)) { + /* Response exceeded the admin packet size limit; send a small error + * instead so the waiting client fails fast rather than timing out. */ + local fallback = { command = "get_station", station_id = sid, error = "response_too_large" }; + if ("request_id" in request) fallback.request_id <- request.request_id; + GSAdmin.Send(fallback); + } + } + + /* Convert a GSList of station_id -> amount into an array of {station, amount}, + * dropping zero entries to keep the reply compact. */ + function CargoListToPairs(list) { + local pairs = []; + foreach (station, _ in list) { + local amount = list.GetValue(station); + if (amount <= 0) continue; + pairs.append({ station = station, amount = amount }); + } + return pairs; + } + + function HandleGetStationCargo(request) { + local sid = request.station_id; + local cargo = request.cargo_id; + local reply = { command = "get_station_cargo", station_id = sid, cargo_id = cargo }; + if ("request_id" in request) reply.request_id <- request.request_id; + if (!GSStation.IsValidStation(sid)) { + reply.error <- "invalid_station"; + GSAdmin.Send(reply); + return; + } + if (!GSCargo.IsValidCargo(cargo)) { + reply.error <- "invalid_cargo"; + GSAdmin.Send(reply); + return; + } + + /* Optional source (from) and next-hop (via) filters. STATION_INVALID is a legal value + * (deleted source / "via any"); any other non-station value is rejected. */ + local from = null; + local via = null; + if ("from_station" in request) { + from = request.from_station; + if (from != GSStation.STATION_INVALID && !GSStation.IsValidStation(from)) { + reply.error <- "invalid_from_station"; + GSAdmin.Send(reply); + return; + } + reply.from_station <- from; + } + if ("via_station" in request) { + via = request.via_station; + if (via != GSStation.STATION_INVALID && !GSStation.IsValidStation(via)) { + reply.error <- "invalid_via_station"; + GSAdmin.Send(reply); + return; + } + reply.via_station <- via; + } + + /* Totals, honouring whichever filters were supplied. */ + if (from != null && via != null) { + reply.waiting <- GSStation.GetCargoWaitingFromVia(sid, from, via, cargo); + reply.planned <- GSStation.GetCargoPlannedFromVia(sid, from, via, cargo); + } else if (from != null) { + reply.waiting <- GSStation.GetCargoWaitingFrom(sid, from, cargo); + reply.planned <- GSStation.GetCargoPlannedFrom(sid, from, cargo); + } else if (via != null) { + reply.waiting <- GSStation.GetCargoWaitingVia(sid, via, cargo); + reply.planned <- GSStation.GetCargoPlannedVia(sid, via, cargo); + } else { + reply.waiting <- GSStation.GetCargoWaiting(sid, cargo); + reply.planned <- GSStation.GetCargoPlanned(sid, cargo); + } + + /* Breakdown grouped by source station (restricted to the via filter if given). */ + local w_by_from = (via != null) + ? GSStationList_CargoWaitingViaByFrom(sid, cargo, via) + : GSStationList_CargoWaitingByFrom(sid, cargo); + local p_by_from = (via != null) + ? GSStationList_CargoPlannedViaByFrom(sid, cargo, via) + : GSStationList_CargoPlannedByFrom(sid, cargo); + reply.waiting_by_from <- this.CargoListToPairs(w_by_from); + reply.planned_by_from <- this.CargoListToPairs(p_by_from); + + /* Breakdown grouped by next hop (restricted to the from filter if given). */ + local w_by_via = (from != null) + ? GSStationList_CargoWaitingFromByVia(sid, cargo, from) + : GSStationList_CargoWaitingByVia(sid, cargo); + local p_by_via = (from != null) + ? GSStationList_CargoPlannedFromByVia(sid, cargo, from) + : GSStationList_CargoPlannedByVia(sid, cargo); + reply.waiting_by_via <- this.CargoListToPairs(w_by_via); + reply.planned_by_via <- this.CargoListToPairs(p_by_via); + + if (!GSAdmin.Send(reply)) { + /* Response exceeded the admin packet size limit; send a small error + * instead so the waiting client fails fast rather than timing out. */ + local fallback = { command = "get_station_cargo", station_id = sid, cargo_id = cargo, error = "response_too_large" }; + if ("request_id" in request) fallback.request_id <- request.request_id; + GSAdmin.Send(fallback); + } + } + + function HandleGetDispatch(request) { + local v = request.vehicle_id; + local reply = { command = "get_dispatch", vehicle_id = v }; + if ("request_id" in request) reply.request_id <- request.request_id; + + local count = GSOrder.GetScheduledDispatchScheduleCount(v); + if (count < 0) { + reply.error <- "invalid_vehicle"; + GSAdmin.Send(reply); + return; + } + reply.enabled <- GSOrder.IsScheduledDispatchEnabled(v); + local schedules = []; + for (local s = 0; s < count; s++) { + local slots = []; + local slot_count = GSOrder.GetScheduledDispatchSlotCount(v, s); + for (local k = 0; k < slot_count; k++) { + slots.append({ + offset = GSOrder.GetScheduledDispatchSlotOffset(v, s, k), + flags = GSOrder.GetScheduledDispatchSlotFlags(v, s, k) + }); + } + schedules.append({ + index = s, + duration = GSOrder.GetScheduledDispatchDuration(v, s), + start_tick = GSOrder.GetScheduledDispatchStartTick(v, s), + delay = GSOrder.GetScheduledDispatchDelay(v, s), + reuse_slots = GSOrder.GetScheduledDispatchReuseSlots(v, s), + slots = slots + }); + } + reply.schedules <- schedules; + if (!GSAdmin.Send(reply)) { + /* Response exceeded the admin packet size limit; send a small error + * instead so the waiting client fails fast rather than timing out. */ + local fallback = { command = "get_dispatch", vehicle_id = v, error = "response_too_large" }; + if ("request_id" in request) fallback.request_id <- request.request_id; + GSAdmin.Send(fallback); + } + } + + /* --- Events --- + * + * The engine has no GameScript event for "a vehicle reached a stop" or "cargo arrived", + * so those are synthesised here: every `interval` ticks the bridge samples the watched + * vehicles and stations and emits an event for each change against the previous sample. + * The handful of events the engine *does* raise for a deity script (crashes, industries, + * companies, ...) are forwarded straight through. Everything is pushed to the admin port + * unsolicited, batched as { command = "events", events = [...] }. */ + + function ResetEventState() { + this.event_kinds = null; + this.interval = 10; + this.sleep_ticks = 10; + this.last_poll = 0; + this.seeded = false; + this.company_id = null; + this.watch_vehicles = null; + this.watch_stations = null; + this.cargo_ids = null; + this.min_cargo_delta = 1; + this.include_cargo = true; + this.vehicle_at = {}; + this.cargo_prev = {}; + } + + /* Copy a request field that must be an array of integers, or null when absent. */ + function ReadIdList(request, key) { + if (!(key in request) || request[key] == null) return null; + local out = []; + foreach (id in request[key]) out.append(id); + return out; + } + + function HandleSubscribeEvents(request) { + local reply = { command = "subscribe_events" }; + if ("request_id" in request) reply.request_id <- request.request_id; + + /* Validate everything before touching the live subscription, so a rejected request + * leaves whatever was subscribed before running untouched. */ + local kinds = {}; + if ("events" in request && request.events != null) { + foreach (kind in request.events) { + if (!(kind in this.event_catalog)) { + reply.error <- "unknown_event"; + reply.event <- kind; + GSAdmin.Send(reply); + return; + } + kinds[kind] <- true; + } + } else { + foreach (kind in this.event_order) kinds[kind] <- true; + } + + local new_interval = ("interval" in request) ? request.interval : 10; + if (typeof new_interval != "integer" || new_interval < 1) { + reply.error <- "invalid_interval"; + GSAdmin.Send(reply); + return; + } + local new_min_delta = ("min_cargo_delta" in request) ? request.min_cargo_delta : 1; + if (typeof new_min_delta != "integer" || new_min_delta < 1) { + reply.error <- "invalid_min_cargo_delta"; + GSAdmin.Send(reply); + return; + } + + /* Cargo ids are validated up front because the amount getters below take them as a + * precondition; station and vehicle ids are not, since they can vanish mid-subscription + * anyway and are re-checked on every poll. */ + local new_cargo = this.ReadIdList(request, "cargo"); + if (new_cargo != null) { + foreach (c in new_cargo) { + if (!GSCargo.IsValidCargo(c)) { + reply.error <- "invalid_cargo"; + reply.cargo_id <- c; + GSAdmin.Send(reply); + return; + } + } + } else { + new_cargo = []; + foreach (c, _ in GSCargoList()) new_cargo.append(c); + } + + this.ResetEventState(); + this.event_kinds = kinds; + this.interval = new_interval; + this.sleep_ticks = new_interval < 10 ? new_interval : 10; + this.min_cargo_delta = new_min_delta; + this.cargo_ids = new_cargo; + this.watch_vehicles = this.ReadIdList(request, "vehicles"); + this.watch_stations = this.ReadIdList(request, "stations"); + if ("company_id" in request && request.company_id != null) this.company_id = request.company_id; + if ("include_cargo" in request) this.include_cargo = request.include_cargo ? true : false; + + local accepted = []; + foreach (kind in this.event_order) { + if (kind in this.event_kinds) accepted.append(kind); + } + reply.events <- accepted; + reply.interval <- this.interval; + GSAdmin.Send(reply); + } + + function HandleUnsubscribeEvents(request) { + this.ResetEventState(); + local reply = { command = "unsubscribe_events", events = [] }; + if ("request_id" in request) reply.request_id <- request.request_id; + GSAdmin.Send(reply); + } + + function Wants(kind) { + return this.event_kinds != null && (kind in this.event_kinds); + } + + /* Forward the events the engine raises for a deity GameScript. Vehicle "lost", "waiting in + * depot" and "unprofitable" are deliberately absent: the engine only ever raises those for + * AI companies, so a GameScript can never observe them. */ + function HandleNativeEvent(event, out) { + local type = event.GetEventType(); + local tick = this.GetTick(); + if (type == GSEvent.ET_VEHICLE_CRASHED) { + if (!this.Wants("vehicle_crashed")) return; + local e = GSEventVehicleCrashed.Convert(event); + out.append({ event = "vehicle_crashed", tick = tick, vehicle_id = e.GetVehicleID(), + tile = e.GetCrashSite(), reason = e.GetCrashReason(), + victims = e.GetVictims(), owner = e.GetVehicleOwner() }); + } else if (type == GSEvent.ET_STATION_FIRST_VEHICLE) { + if (!this.Wants("station_first_vehicle")) return; + local e = GSEventStationFirstVehicle.Convert(event); + out.append({ event = "station_first_vehicle", tick = tick, + station_id = e.GetStationID(), vehicle_id = e.GetVehicleID() }); + } else if (type == GSEvent.ET_INDUSTRY_OPEN) { + if (!this.Wants("industry_open")) return; + out.append({ event = "industry_open", tick = tick, + industry_id = GSEventIndustryOpen.Convert(event).GetIndustryID() }); + } else if (type == GSEvent.ET_INDUSTRY_CLOSE) { + if (!this.Wants("industry_close")) return; + out.append({ event = "industry_close", tick = tick, + industry_id = GSEventIndustryClose.Convert(event).GetIndustryID() }); + } else if (type == GSEvent.ET_TOWN_FOUNDED) { + if (!this.Wants("town_founded")) return; + out.append({ event = "town_founded", tick = tick, + town_id = GSEventTownFounded.Convert(event).GetTownID() }); + } else if (type == GSEvent.ET_COMPANY_NEW) { + if (!this.Wants("company_new")) return; + out.append({ event = "company_new", tick = tick, + company_id = GSEventCompanyNew.Convert(event).GetCompanyID() }); + } else if (type == GSEvent.ET_COMPANY_IN_TROUBLE) { + if (!this.Wants("company_in_trouble")) return; + out.append({ event = "company_in_trouble", tick = tick, + company_id = GSEventCompanyInTrouble.Convert(event).GetCompanyID() }); + } else if (type == GSEvent.ET_COMPANY_BANKRUPT) { + if (!this.Wants("company_bankrupt")) return; + out.append({ event = "company_bankrupt", tick = tick, + company_id = GSEventCompanyBankrupt.Convert(event).GetCompanyID() }); + } else if (type == GSEvent.ET_SUBSIDY_OFFER) { + if (!this.Wants("subsidy_offer")) return; + out.append({ event = "subsidy_offer", tick = tick, + subsidy_id = GSEventSubsidyOffer.Convert(event).GetSubsidyID() }); + } else if (type == GSEvent.ET_SUBSIDY_OFFER_EXPIRED) { + if (!this.Wants("subsidy_offer_expired")) return; + out.append({ event = "subsidy_offer_expired", tick = tick, + subsidy_id = GSEventSubsidyOfferExpired.Convert(event).GetSubsidyID() }); + } else if (type == GSEvent.ET_SUBSIDY_AWARDED) { + if (!this.Wants("subsidy_awarded")) return; + out.append({ event = "subsidy_awarded", tick = tick, + subsidy_id = GSEventSubsidyAwarded.Convert(event).GetSubsidyID() }); + } else if (type == GSEvent.ET_SUBSIDY_EXPIRED) { + if (!this.Wants("subsidy_expired")) return; + out.append({ event = "subsidy_expired", tick = tick, + subsidy_id = GSEventSubsidyExpired.Convert(event).GetSubsidyID() }); + } + } + + function PollState(out) { + local now = this.GetTick(); + if (this.seeded && now - this.last_poll < this.interval) return; + this.last_poll = now; + this.PollVehicles(now, out); + this.PollCargo(now, out); + /* The first poll only records where everything already is: a vehicle that was sitting + * at a station when the subscription started did not just arrive. */ + this.seeded = true; + } + + function WatchedVehicles() { + if (this.watch_vehicles != null) return this.watch_vehicles; + local out = []; + foreach (v, _ in GSVehicleList()) out.append(v); + return out; + } + + function WatchedStations() { + if (this.watch_stations != null) return this.watch_stations; + local out = []; + foreach (s, _ in GSStationList(GSStation.STATION_ANY)) out.append(s); + return out; + } + + /* Which station a vehicle is stopped at, or -1 when it is not loading at one. Note that a + * vehicle stopped by hand or broken down at a platform reports its own state instead, so it + * reads here as having left the station. */ + function VehicleStation(v) { + if (GSVehicle.GetState(v) != GSVehicle.VS_AT_STATION) return -1; + local sid = GSStation.GetStationID(GSVehicle.GetLocation(v)); + return GSStation.IsValidStation(sid) ? sid : -1; + } + + function PollVehicles(now, out) { + local want_arrive = this.Wants("vehicle_arrive"); + local want_depart = this.Wants("vehicle_depart"); + if (!want_arrive && !want_depart) return; + local live = {}; + foreach (v in this.WatchedVehicles()) { + if (!GSVehicle.IsValidVehicle(v)) continue; + if (this.company_id != null && GSVehicle.GetOwner(v) != this.company_id) continue; + live[v] <- true; + local at = this.VehicleStation(v); + local known = (v in this.vehicle_at) ? this.vehicle_at[v] : null; + local was = (known == null) ? -1 : known.station; + if (was == at) continue; + if (this.seeded) { + /* A vehicle that moves from one station straight to another in a single + * sampling window yields both a depart and an arrive, in that order. */ + if (was != -1 && want_depart) out.append(this.VehicleEvent("vehicle_depart", now, v, was, now - known.since)); + if (at != -1 && want_arrive) out.append(this.VehicleEvent("vehicle_arrive", now, v, at, 0)); + } + this.vehicle_at[v] <- { station = at, since = now }; + } + /* Forget vehicles that were sold or fell out of the filter, so the table cannot grow + * without bound over a long subscription. */ + local stale = []; + foreach (v, _ in this.vehicle_at) { + if (!(v in live)) stale.append(v); + } + foreach (v in stale) delete this.vehicle_at[v]; + } + + function VehicleEvent(kind, tick, v, sid, dwell) { + local ev = { + event = kind, + tick = tick, + vehicle_id = v, + station_id = sid, + owner = GSVehicle.GetOwner(v), + vehicle_type = GSVehicle.GetVehicleType(v), + order_position = GSOrder.ResolveOrderPosition(v, GSOrder.ORDER_CURRENT) + }; + /* How long the vehicle had been loading, in ticks. For a vehicle that was already at a + * station when the subscription started this counts from the first poll, not from the + * real arrival. */ + if (kind == "vehicle_depart") ev.dwell <- dwell; + if (this.include_cargo) ev.cargo <- this.VehicleCargo(v); + return ev; + } + + function VehicleCargo(v) { + local out = []; + foreach (c in this.cargo_ids) { + local load = GSVehicle.GetCargoLoad(v, c); + if (load > 0) out.append({ cargo_id = c, load = load }); + } + return out; + } + + function PollCargo(now, out) { + if (!this.Wants("cargo_waiting")) return; + local live = {}; + foreach (sid in this.WatchedStations()) { + if (!GSStation.IsValidStation(sid)) continue; + if (this.company_id != null && GSStation.GetOwner(sid) != this.company_id) continue; + live[sid] <- true; + if (!(sid in this.cargo_prev)) this.cargo_prev[sid] <- {}; + local prev = this.cargo_prev[sid]; + foreach (c in this.cargo_ids) { + local waiting = GSStation.GetCargoWaiting(sid, c); + local before = (c in prev) ? prev[c] : 0; + if (waiting == before) continue; + prev[c] <- waiting; + if (!this.seeded) continue; + local delta = waiting - before; + local magnitude = delta < 0 ? -delta : delta; + if (magnitude < this.min_cargo_delta) continue; + out.append({ event = "cargo_waiting", tick = now, station_id = sid, + cargo_id = c, waiting = waiting, delta = delta }); + } + } + local stale = []; + foreach (sid, _ in this.cargo_prev) { + if (!(sid in live)) stale.append(sid); + } + foreach (sid in stale) delete this.cargo_prev[sid]; + } + + function SendEventBatch(events) { + if (events.len() == 0) return; + local dropped = 0; + if (events.len() > this.MAX_EVENTS_PER_POLL) { + dropped = events.len() - this.MAX_EVENTS_PER_POLL; + events = events.slice(0, this.MAX_EVENTS_PER_POLL); + } + local sent = 0; + while (sent < events.len()) { + local end = sent + this.EVENT_BATCH_SIZE; + if (end > events.len()) end = events.len(); + GSAdmin.Send({ command = "events", events = events.slice(sent, end) }); + sent = end; + } + /* Tell the client its view has a hole in it rather than letting it silently miss + * transitions it is counting on. */ + if (dropped > 0) { + GSAdmin.Send({ command = "events", + events = [{ event = "events_dropped", tick = this.GetTick(), count = dropped }] }); + } + } +} diff --git a/lib/openttd/client.py b/lib/openttd/client.py index 4e09140..dea9abb 100644 --- a/lib/openttd/client.py +++ b/lib/openttd/client.py @@ -657,6 +657,37 @@ class OpenTTDAdminClient: raise ValueError(f"{context}: {data['error']}") return data + async def get_bridge_version(self, minimum=None, timeout=5.0): + """Ask the AdminBridge GameScript which protocol version it speaks, and check it is new enough. + + Every other GameScript method here needs a bridge that understands the command it sends. + Against an older bridge those commands are simply ignored and the caller waits out its + timeout with no explanation, so call this once after connecting to turn that into an + immediate, named failure. + + Returns a dict with "version" (the bridge's protocol version), "commands" (the command + names it answers) and "events" (the event kinds it can push) — the two catalogues allow + feature-detecting a single command instead of comparing version numbers. + + `minimum` defaults to GS_BRIDGE_VERSION, the version this client is written against; + pass 0 to read the version without requiring anything of it. + + Raises ValueError if the bridge is older than `minimum`, and asyncio.TimeoutError if it + does not answer at all — note that a bridge predating get_version itself (version 3 and + earlier) can only fail that second way, as can a paused game or a server with no bridge + loaded. ConnectionError if the admin connection drops while waiting. + """ + from .protocol import GS_BRIDGE_VERSION + if minimum is None: + minimum = GS_BRIDGE_VERSION + data = await self._gs_query({"command": "get_version"}, timeout, "get_version") + version = data.get("version", 0) + if version < minimum: + raise ValueError( + f"AdminBridge GameScript is version {version}, but at least {minimum} is " + f"required; update the server's copy from gamescript/AdminBridge/") + return data + async def get_timetable(self, vehicle_id, timeout=5.0): """Fetch an authoritative timetable snapshot for a vehicle via the AdminBridge GameScript. diff --git a/lib/openttd/protocol.py b/lib/openttd/protocol.py index 79c1b0b..b5a3579 100644 --- a/lib/openttd/protocol.py +++ b/lib/openttd/protocol.py @@ -220,6 +220,13 @@ class NetworkAuthenticationMethod(IntEnum): X25519_PAKE = 1 X25519_AuthorizedKey = 2 +# Version of the AdminBridge GameScript's JSON protocol this client is written against. The +# bridge reports its own version via get_version (OpenTTDAdminClient.get_bridge_version()), and +# an older one will not understand everything sent here. The bridge source lives in +# gamescript/AdminBridge/; tests/test_gamescript.py keeps this in step with the version declared +# there, so bumping one without the other fails in CI rather than at runtime. +GS_BRIDGE_VERSION = 4 + class GameEventType(StrEnum): """Event kinds the AdminBridge GameScript can push over the Admin Network. diff --git a/tests/test_admin.py b/tests/test_admin.py index 5d35cb9..6d118a0 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -5,7 +5,12 @@ import os import monocypher import pytest from openttd import OpenTTDAdminClient -from openttd.protocol import AdminUpdateFrequency, AdminUpdateType, PacketAdminType +from openttd.protocol import ( + GS_BRIDGE_VERSION, + AdminUpdateFrequency, + AdminUpdateType, + PacketAdminType, +) class FakeNetworkError(OSError): @@ -429,6 +434,58 @@ async def test_admin_get_dispatch_error_response(): await task assert client._gs_futures == {} +@pytest.mark.asyncio +async def test_admin_get_bridge_version_request_and_response(): + client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin") + proto = MockProtocol() + client._protocol = proto + client._transport = MockTransport() + + task = asyncio.ensure_future(client.get_bridge_version()) + await asyncio.sleep(0) # let the task send the request + + # First use auto-subscribes to Gamescript updates, then sends the query. + assert len(proto.sent) == 2 + assert proto.sent[0][2] == PacketAdminType.AdminUpdateFrequency + assert decode_gamescript_payload(proto.sent[1]) == { + "command": "get_version", "request_id": 1, + } + + response = {"command": "get_version", "request_id": 1, "version": GS_BRIDGE_VERSION, + "commands": ["get_timetable", "get_version"], + "events": ["vehicle_arrive", "vehicle_depart"]} + await client.receive_ServerGamescript(None, data=response) + assert await task == response + assert client._gs_futures == {} + +@pytest.mark.asyncio +async def test_admin_get_bridge_version_rejects_older_bridge(): + """A bridge that answers but is too old fails by name, not by timeout.""" + client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin") + client._protocol = MockProtocol() + client._transport = MockTransport() + + task = asyncio.ensure_future(client.get_bridge_version(minimum=GS_BRIDGE_VERSION + 1)) + await asyncio.sleep(0) + await client.receive_ServerGamescript( + None, data={"command": "get_version", "request_id": 1, "version": GS_BRIDGE_VERSION}) + with pytest.raises(ValueError, match=f"version {GS_BRIDGE_VERSION}"): + await task + assert client._gs_futures == {} + +@pytest.mark.asyncio +async def test_admin_get_bridge_version_minimum_zero_accepts_anything(): + """minimum=0 reads the version without requiring anything, even if the reply omits it.""" + client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin") + client._protocol = MockProtocol() + client._transport = MockTransport() + + task = asyncio.ensure_future(client.get_bridge_version(minimum=0)) + await asyncio.sleep(0) + await client.receive_ServerGamescript( + None, data={"command": "get_version", "request_id": 1, "commands": []}) + assert await task == {"command": "get_version", "request_id": 1, "commands": []} + @pytest.mark.asyncio async def test_admin_gamescript_passthrough_unmatched(): client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin") diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 437ae04..eae3fe1 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -11,6 +11,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'lib')) from openttd import OpenTTDAdminClient, OpenTTDClient from openttd.protocol import ( + GS_BRIDGE_VERSION, AdminUpdateFrequency, AdminUpdateType, GameEventType, @@ -518,6 +519,38 @@ async def test_e2e_admin_send_gamescript_multiple_inputs(connected_admin): await connected_admin.send_gamescript({"command": "ping", "sequence": 1}) await asyncio.sleep(0.5) +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_e2e_admin_get_bridge_version_supported(connected_admin): + # Public function: get_bridge_version() + # Input 1: the default minimum, i.e. the version this client is written against. + # + # Every GameScript test below this one fails as an unexplained timeout if the server runs a + # stale AdminBridge (or none at all), because a bridge that does not know a command simply + # drops it. This test is the one that says so out loud, so run it first when the GS tests + # start hanging. + try: + data = await connected_admin.get_bridge_version(timeout=10.0) + except asyncio.TimeoutError: + pytest.fail( + "The server's AdminBridge GameScript did not answer get_version. It is older than " + f"version {GS_BRIDGE_VERSION} (which introduced the command), not loaded at all, or " + "the game is paused. See gamescript/AdminBridge/README.md.") + + assert data["version"] >= GS_BRIDGE_VERSION + # The catalogues let a client feature-detect one command rather than compare versions. + assert "get_version" in data["commands"] + assert set(data["events"]) == {e.value for e in GameEventType} - {GameEventType.EventsDropped} + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_e2e_admin_get_bridge_version_minimum_zero(connected_admin): + # Public function: get_bridge_version() + # Input 2: minimum=0 -> read the version without requiring anything of it. + data = await connected_admin.get_bridge_version(minimum=0, timeout=10.0) + assert isinstance(data["version"], int) + assert "get_timetable" in data["commands"] + @pytest.mark.e2e @pytest.mark.asyncio async def test_e2e_admin_list_vehicles_all_companies(connected_admin): diff --git a/tests/test_gamescript.py b/tests/test_gamescript.py new file mode 100644 index 0000000..22ad1e3 --- /dev/null +++ b/tests/test_gamescript.py @@ -0,0 +1,89 @@ +"""Checks the checked-in AdminBridge GameScript against the client that talks to it. + +The bridge is Squirrel, and CI has no Squirrel toolchain, so these are not a substitute for +running it (see gamescript/AdminBridge/README.md). They cover the one class of breakage that +is invisible until a server is in front of you: the two halves of the protocol drifting apart +-- a version bumped on one side only, an event kind or a command the client uses that the +bridge does not implement, or the docker setup no longer serving the tracked copy. +""" + +import re +from pathlib import Path + +import pytest + +from openttd.protocol import GS_BRIDGE_VERSION, GameEventType + +ROOT = Path(__file__).resolve().parents[1] +GS_DIR = ROOT / "gamescript" / "AdminBridge" +INFO_NUT = (GS_DIR / "info.nut").read_text() +MAIN_NUT = (GS_DIR / "main.nut").read_text() + + +def _block(source, opening, closing): + """Return the text between `opening` and the next `closing`, e.g. an array or table body.""" + start = source.index(opening) + len(opening) + return source[start:source.index(closing, start)] + + +@pytest.mark.unit +def test_gamescript_version_matches_client(): + """info.nut, main.nut and the client must all name the same protocol version. + + A GameScript cannot read its own info.nut at runtime, so main.nut duplicates the version; + this is what keeps the copy honest, and what makes a bump that misses a file fail here. + """ + info_version = int(re.search(r"function GetVersion\(\)\s*{\s*return (\d+);", INFO_NUT).group(1)) + main_version = int(re.search(r"BRIDGE_VERSION = (\d+);", MAIN_NUT).group(1)) + assert info_version == main_version == GS_BRIDGE_VERSION + + +@pytest.mark.unit +def test_gamescript_can_load_older_savegames(): + """The bridge must stay loadable by savegames that pinned an older version of it. + + OpenTTD defaults MinVersionToLoad() to GetVersion(), so without an explicit override every + version bump orphans existing savegames: the engine finds no compatible script and falls + back with a warning. The bridge keeps no savegame state, so any version can take over. + """ + min_version = int(re.search(r"function MinVersionToLoad\(\)\s*{\s*return (\d+);", INFO_NUT).group(1)) + assert min_version <= GS_BRIDGE_VERSION + + +@pytest.mark.unit +def test_gamescript_event_catalogue_matches_client_enum(): + """Every kind the bridge can push has a GameEventType, and vice versa. + + GameEventType is what subscribe_events() validates against, so a kind in one list and not + the other is either an event nobody can subscribe to or a subscription the bridge rejects. + """ + catalogue = set(re.findall(r'"(\w+)"', _block(MAIN_NUT, "this.event_order = [", "];"))) + # EventsDropped is emitted by the bridge itself and never subscribed to, so it is + # deliberately absent from the subscribable catalogue. + assert catalogue == {e.value for e in GameEventType} - {GameEventType.EventsDropped} + + +@pytest.mark.unit +def test_gamescript_implements_every_command_the_client_sends(): + """Each command in a client payload must have a handler in the bridge's dispatch table. + + A command the bridge does not know is silently dropped, so the caller only sees a timeout. + The bridge may implement more than the client wraps (list_cargo currently has no method). + """ + commands = set(re.findall(r"^\s*(\w+)\s*=\s*{ handler", + _block(MAIN_NUT, "COMMANDS = {", "\n\t};"), re.MULTILINE)) + client_source = (ROOT / "lib" / "openttd" / "client.py").read_text() + sent = set(re.findall(r'{"command": "(\w+)"', client_source)) + assert sent, "no GameScript commands found in the client -- has the payload spelling changed?" + assert sent <= commands + + +@pytest.mark.unit +def test_docker_serves_the_tracked_gamescript(): + """The container must run the copy in git, not an untracked one under docker/config/. + + That mount is the whole reason the tracked copy stays honest: without it the server reads a + file nobody reviews, which is how the bridge went unversioned in the first place. + """ + compose = (ROOT / "docker" / "docker-compose.yml").read_text() + assert "../gamescript/AdminBridge:" in compose