diff --git a/README.md b/README.md index 0929e17..fcc2a9e 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A high-performance, Object-Oriented Python client for OpenTTD servers, specifica - **Comprehensive Testing:** Robustly tested with unit, logic, and E2E tests (including 100% coverage for unit/logic tests). - **Vehicle Listing:** Query vehicle data via the Admin GameScript channel with `list_vehicles()`. - **Vehicle Timetables:** Read and modify a vehicle's timetable (`change_timetable()`, `autofill_timetable()`, `set_timetable_start()`, `set_vehicle_on_time()`, `get_vehicle_timetable()`) via real game-protocol commands. +- **Authoritative Timetable Reads:** `OpenTTDAdminClient.get_timetable()` fetches the real, current timetable of any vehicle from the running game (via a patched GameScript API + the AdminBridge GS) — no company join needed, works for timetables set before connecting. ## 🛠 Setup diff --git a/docker/Dockerfile b/docker/Dockerfile index b7c35b4..0c240bc 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -26,13 +26,16 @@ RUN cmake .. \ && make -j$(nproc) install # Runtime stage -FROM debian:bookworm-slim@sha256:7b140f374b289a7c2befc338f42ebe6441b7ea838a042bbd5acbfca6ec875818 +# Must track the builder's Debian release: the builder (debian:13/trixie) links +# against glibc 2.38+, so an older runtime (e.g. bookworm, glibc 2.36) cannot run +# the resulting binary. Package names use the trixie t64 spelling. +FROM debian:trixie-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd RUN apt-get update && apt-get install -y \ - libcurl3-gnutls \ + libcurl3t64-gnutls \ liblzma5 \ liblzo2-2 \ - libpng16-16 \ + libpng16-16t64 \ libzstd1 \ zlib1g \ ca-certificates \ diff --git a/docker/README.md b/docker/README.md index 9ee9a1f..fff7938 100644 --- a/docker/README.md +++ b/docker/README.md @@ -25,13 +25,18 @@ This setup builds OpenTTD with the JGR Patch Pack (JGRPP) from source and runs i Save games are stored in `config/save/`. ## JGRPP Source -The source code is cloned from the `jgrpp` branch of `https://github.com/JGRennison/OpenTTD-patches`. +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 +[patches/README.md](patches/README.md). After a fresh clone of the source, apply them with +`git -C OpenTTD-patches am ../patches/*.patch` before building. + To update the server to a newer JGRPP version: 1. Update the `OpenTTD-patches` directory: ```bash cd OpenTTD-patches && git pull && cd .. ``` -2. Rebuild the image: +2. Reapply (rebase if needed) the local patches from `patches/`. +3. Rebuild the image: ```bash docker-compose up -d --build ``` diff --git a/docker/patches/0001-Add-GameScript-API-timetable-getters-to-ScriptOrder.patch b/docker/patches/0001-Add-GameScript-API-timetable-getters-to-ScriptOrder.patch new file mode 100644 index 0000000..083287e --- /dev/null +++ b/docker/patches/0001-Add-GameScript-API-timetable-getters-to-ScriptOrder.patch @@ -0,0 +1,257 @@ +From 73f6770eb3e0ec5be8da9e74fa0874fb6ba2d36d Mon Sep 17 00:00:00 2001 +From: kovagoadi +Date: Sun, 19 Jul 2026 00:33:23 +0200 +Subject: [PATCH] Add GameScript API timetable getters to ScriptOrder + +Expose read-only timetable data to AI/GS scripts: per-order wait/travel +times, timetabled/fixed flags, leave type and max speed, plus per-vehicle +lateness, timetable start tick, current order time and total duration. + +Co-Authored-By: Claude Opus 4.8 +--- + src/script/api/script_order.cpp | 104 ++++++++++++++++++++++++++++ + src/script/api/script_order.hpp | 116 ++++++++++++++++++++++++++++++++ + 2 files changed, 220 insertions(+) + +diff --git a/src/script/api/script_order.cpp b/src/script/api/script_order.cpp +index 865df623f9..ee18f7d588 100644 +--- a/src/script/api/script_order.cpp ++++ b/src/script/api/script_order.cpp +@@ -718,3 +718,107 @@ static void _DoCommandReturnSetOrderFlags(class ScriptInstance &instance) + return ScriptMap::DistanceManhattan(origin_tile, dest_tile); + } + } ++ ++/* static */ SQInteger ScriptOrder::GetTimetableWaitTime(VehicleID vehicle_id, OrderPosition order_position) ++{ ++ if (!IsValidVehicleOrder(vehicle_id, order_position)) return -1; ++ ++ const Order *order = ::ResolveOrder(vehicle_id, order_position); ++ if (order == nullptr) return -1; ++ return order->GetWaitTime(); ++} ++ ++/* static */ SQInteger ScriptOrder::GetTimetableTravelTime(VehicleID vehicle_id, OrderPosition order_position) ++{ ++ if (!IsValidVehicleOrder(vehicle_id, order_position)) return -1; ++ ++ const Order *order = ::ResolveOrder(vehicle_id, order_position); ++ if (order == nullptr) return -1; ++ return order->GetTravelTime(); ++} ++ ++/* static */ bool ScriptOrder::IsWaitTimetabled(VehicleID vehicle_id, OrderPosition order_position) ++{ ++ if (!IsValidVehicleOrder(vehicle_id, order_position)) return false; ++ ++ const Order *order = ::ResolveOrder(vehicle_id, order_position); ++ if (order == nullptr) return false; ++ return order->IsWaitTimetabled(); ++} ++ ++/* static */ bool ScriptOrder::IsTravelTimetabled(VehicleID vehicle_id, OrderPosition order_position) ++{ ++ if (!IsValidVehicleOrder(vehicle_id, order_position)) return false; ++ ++ const Order *order = ::ResolveOrder(vehicle_id, order_position); ++ if (order == nullptr) return false; ++ return order->IsTravelTimetabled(); ++} ++ ++/* static */ bool ScriptOrder::IsWaitFixed(VehicleID vehicle_id, OrderPosition order_position) ++{ ++ if (!IsValidVehicleOrder(vehicle_id, order_position)) return false; ++ ++ const Order *order = ::ResolveOrder(vehicle_id, order_position); ++ if (order == nullptr) return false; ++ return order->IsWaitFixed(); ++} ++ ++/* static */ bool ScriptOrder::IsTravelFixed(VehicleID vehicle_id, OrderPosition order_position) ++{ ++ if (!IsValidVehicleOrder(vehicle_id, order_position)) return false; ++ ++ const Order *order = ::ResolveOrder(vehicle_id, order_position); ++ if (order == nullptr) return false; ++ return order->IsTravelFixed(); ++} ++ ++/* static */ SQInteger ScriptOrder::GetLeaveType(VehicleID vehicle_id, OrderPosition order_position) ++{ ++ if (!IsValidVehicleOrder(vehicle_id, order_position)) return -1; ++ ++ const Order *order = ::ResolveOrder(vehicle_id, order_position); ++ if (order == nullptr) return -1; ++ return order->GetLeaveType(); ++} ++ ++/* static */ SQInteger ScriptOrder::GetTimetableMaxSpeed(VehicleID vehicle_id, OrderPosition order_position) ++{ ++ if (!IsValidVehicleOrder(vehicle_id, order_position)) return -1; ++ ++ const Order *order = ::ResolveOrder(vehicle_id, order_position); ++ if (order == nullptr) return -1; ++ return order->GetMaxSpeed(); ++} ++ ++/* static */ SQInteger ScriptOrder::GetTimetableLateness(VehicleID vehicle_id) ++{ ++ if (!ScriptVehicle::IsPrimaryVehicle(vehicle_id)) return 0; ++ ++ return ::Vehicle::Get(vehicle_id)->lateness_counter; ++} ++ ++/* static */ SQInteger ScriptOrder::GetTimetableStartTick(VehicleID vehicle_id) ++{ ++ if (!ScriptVehicle::IsPrimaryVehicle(vehicle_id)) return -1; ++ ++ return ::Vehicle::Get(vehicle_id)->timetable_start.base(); ++} ++ ++/* static */ SQInteger ScriptOrder::GetCurrentOrderTime(VehicleID vehicle_id) ++{ ++ if (!ScriptVehicle::IsPrimaryVehicle(vehicle_id)) return -1; ++ ++ return ::Vehicle::Get(vehicle_id)->current_order_time; ++} ++ ++/* static */ SQInteger ScriptOrder::GetTimetableTotalDuration(VehicleID vehicle_id) ++{ ++ if (!ScriptVehicle::IsPrimaryVehicle(vehicle_id)) return -1; ++ ++ const Vehicle *v = ::Vehicle::Get(vehicle_id); ++ if (v->orders == nullptr) return -1; ++ Ticks duration = v->orders->GetTimetableTotalDuration(); ++ if (duration == INVALID_TICKS) return -1; ++ return duration; ++} +diff --git a/src/script/api/script_order.hpp b/src/script/api/script_order.hpp +index 81dc06cd7d..6c96b91b3a 100644 +--- a/src/script/api/script_order.hpp ++++ b/src/script/api/script_order.hpp +@@ -604,6 +604,122 @@ public: + * @see ScriptEngine::GetMaximumOrderDistance and ScriptVehicle::GetMaximumOrderDistance + */ + static SQInteger GetOrderDistance(ScriptVehicle::VehicleType vehicle_type, TileIndex origin_tile, TileIndex dest_tile); ++ ++ /** ++ * Gets the timetabled wait time of the given order for the given vehicle. ++ * @param vehicle_id The vehicle to get the timetable wait time for. ++ * @param order_position The order to get the timetable wait time for. ++ * @pre IsValidVehicleOrder(vehicle_id, order_position). ++ * @return The wait time of the order in ticks, or -1 when the order is invalid. ++ * @note The raw stored wait time is returned even if the wait time is not ++ * timetabled; use IsWaitTimetabled to check whether it is explicitly set. ++ */ ++ static SQInteger GetTimetableWaitTime(VehicleID vehicle_id, OrderPosition order_position); ++ ++ /** ++ * Gets the timetabled travel time of the given order for the given vehicle. ++ * @param vehicle_id The vehicle to get the timetable travel time for. ++ * @param order_position The order to get the timetable travel time for. ++ * @pre IsValidVehicleOrder(vehicle_id, order_position). ++ * @return The travel time of the order in ticks, or -1 when the order is invalid. ++ * @note The raw stored travel time is returned even if the travel time is not ++ * timetabled; use IsTravelTimetabled to check whether it is explicitly set. ++ */ ++ static SQInteger GetTimetableTravelTime(VehicleID vehicle_id, OrderPosition order_position); ++ ++ /** ++ * Checks whether the wait time of the given order is timetabled (explicitly set). ++ * @param vehicle_id The vehicle to check the order for. ++ * @param order_position The order to check. ++ * @pre IsValidVehicleOrder(vehicle_id, order_position). ++ * @return True if and only if the wait time is timetabled. ++ */ ++ static bool IsWaitTimetabled(VehicleID vehicle_id, OrderPosition order_position); ++ ++ /** ++ * Checks whether the travel time of the given order is timetabled (explicitly set). ++ * @param vehicle_id The vehicle to check the order for. ++ * @param order_position The order to check. ++ * @pre IsValidVehicleOrder(vehicle_id, order_position). ++ * @return True if and only if the travel time is timetabled. ++ */ ++ static bool IsTravelTimetabled(VehicleID vehicle_id, OrderPosition order_position); ++ ++ /** ++ * Checks whether the wait time of the given order is fixed (locked against autofill). ++ * @param vehicle_id The vehicle to check the order for. ++ * @param order_position The order to check. ++ * @pre IsValidVehicleOrder(vehicle_id, order_position). ++ * @return True if and only if the wait time is fixed. ++ */ ++ static bool IsWaitFixed(VehicleID vehicle_id, OrderPosition order_position); ++ ++ /** ++ * Checks whether the travel time of the given order is fixed (locked against autofill). ++ * @param vehicle_id The vehicle to check the order for. ++ * @param order_position The order to check. ++ * @pre IsValidVehicleOrder(vehicle_id, order_position). ++ * @return True if and only if the travel time is fixed. ++ */ ++ static bool IsTravelFixed(VehicleID vehicle_id, OrderPosition order_position); ++ ++ /** ++ * Gets the leave type of the given order for the given vehicle. ++ * @param vehicle_id The vehicle to get the leave type for. ++ * @param order_position The order to get the leave type for. ++ * @pre IsValidVehicleOrder(vehicle_id, order_position). ++ * @return The leave type of the order (0 = leave when timetabled, 1 = leave as ++ * soon as possible, 2 = leave early if any cargo fully loaded, 3 = leave early ++ * if all cargo fully loaded), or -1 when the order is invalid. ++ */ ++ static SQInteger GetLeaveType(VehicleID vehicle_id, OrderPosition order_position); ++ ++ /** ++ * Gets the timetabled maximum speed of the given order for the given vehicle. ++ * @param vehicle_id The vehicle to get the timetable max speed for. ++ * @param order_position The order to get the timetable max speed for. ++ * @pre IsValidVehicleOrder(vehicle_id, order_position). ++ * @return The maximum speed of the order (65535 when no speed cap is set), ++ * or -1 when the order is invalid. ++ */ ++ static SQInteger GetTimetableMaxSpeed(VehicleID vehicle_id, OrderPosition order_position); ++ ++ /** ++ * Gets the timetable lateness of the given vehicle. ++ * @param vehicle_id The vehicle to get the lateness for. ++ * @pre ScriptVehicle::IsPrimaryVehicle(vehicle_id). ++ * @return How many ticks the vehicle is late; negative values mean the vehicle ++ * is running early. Returns 0 when the vehicle is invalid, which is ++ * indistinguishable from an on-time vehicle; check the vehicle validity first. ++ */ ++ static SQInteger GetTimetableLateness(VehicleID vehicle_id); ++ ++ /** ++ * Gets the state tick at which the timetable of the given vehicle starts. ++ * @param vehicle_id The vehicle to get the timetable start tick for. ++ * @pre ScriptVehicle::IsPrimaryVehicle(vehicle_id). ++ * @return The absolute state tick the timetable starts at (0 when the ++ * timetable has not been started), or -1 when the vehicle is invalid. ++ */ ++ static SQInteger GetTimetableStartTick(VehicleID vehicle_id); ++ ++ /** ++ * Gets the number of ticks the given vehicle has spent on its current order. ++ * @param vehicle_id The vehicle to get the current order time for. ++ * @pre ScriptVehicle::IsPrimaryVehicle(vehicle_id). ++ * @return The number of ticks spent on the current order, or -1 when the ++ * vehicle is invalid. ++ */ ++ static SQInteger GetCurrentOrderTime(VehicleID vehicle_id); ++ ++ /** ++ * Gets the total duration of the timetable of the given vehicle. ++ * @param vehicle_id The vehicle to get the timetable duration for. ++ * @pre ScriptVehicle::IsPrimaryVehicle(vehicle_id). ++ * @return The total timetable duration in ticks, or -1 when the vehicle is ++ * invalid, has no orders, or the timetable is not complete. ++ */ ++ static SQInteger GetTimetableTotalDuration(VehicleID vehicle_id); + }; + DECLARE_ENUM_AS_BIT_SET(ScriptOrder::ScriptOrderFlags) + +-- +2.54.0 + diff --git a/docker/patches/README.md b/docker/patches/README.md new file mode 100644 index 0000000..5c59d7b --- /dev/null +++ b/docker/patches/README.md @@ -0,0 +1,28 @@ +# Local JGRPP patches + +The `docker/OpenTTD-patches/` directory is an **untracked** clone of +[JGRennison/OpenTTD-patches](https://github.com/JGRennison/OpenTTD-patches) checked out at tag +`jgrpp-0.71.1`. The patches in this directory are the local modifications this project needs on +top of that tag; they are the durable source of truth (the clone itself is not committed). + +Current patches: + +- `0001-Add-GameScript-API-timetable-getters-to-ScriptOrder.patch` — adds read-only timetable + getters (`GetTimetableWaitTime`, `GetTimetableTravelTime`, `IsWaitTimetabled`, + `IsTravelTimetabled`, `IsWaitFixed`, `IsTravelFixed`, `GetLeaveType`, `GetTimetableMaxSpeed`, + `GetTimetableLateness`, `GetTimetableStartTick`, `GetCurrentOrderTime`, + `GetTimetableTotalDuration`) to the `GSOrder` GameScript class. Required by the AdminBridge + GameScript's `get_timetable` command and the Python client's + `OpenTTDAdminClient.get_timetable()`. + +## Applying after a fresh clone + +```bash +git clone --branch jgrpp-0.71.1 https://github.com/JGRennison/OpenTTD-patches docker/OpenTTD-patches +git -C docker/OpenTTD-patches am ../patches/*.patch +``` + +Then build the image as usual (`docker-compose up -d --build` from `docker/`). + +If the clone is updated past `jgrpp-0.71.1`, `git am` may conflict — the patches were generated +against that tag and need rebasing in that case. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index a787473..1a4af33 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -31,8 +31,19 @@ The Admin Network has no native packet or `AdminUpdateType` for listing individu **Important:** the server only forwards `ServerGamescript` packets to admins that have subscribed with `update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)` (enforced server-side in `NetworkAdminGameScript`, which checks `update_frequency[ADMIN_UPDATE_GAMESCRIPT]`). Call `update_frequency()` for `Gamescript` before `list_vehicles()`, or the response is silently dropped. +When a `list_vehicles` request carries a `request_id` field, the AdminBridge GameScript echoes it back in the reply (backward compatible: absent otherwise). + +### Timetable Query +The stock GameScript API has no timetable getters, so this project patches the server (see `docker/patches/`) to add read-only getters to `GSOrder` (`GetTimetableWaitTime`, `GetTimetableTravelTime`, `IsWaitTimetabled`, `IsTravelTimetabled`, `IsWaitFixed`, `IsTravelFixed`, `GetLeaveType`, `GetTimetableMaxSpeed`, `GetTimetableLateness`, `GetTimetableStartTick`, `GetCurrentOrderTime`, `GetTimetableTotalDuration`). On top of that, `get_timetable()` sends a request over the same GameScript JSON channel as vehicle listing and awaits the correlated reply — an **authoritative snapshot** of the live game state, unlike the passive observer on the game port (see below). + +- **Request:** `{"command": "get_timetable", "vehicle_id": N, "request_id": X}` — `request_id` is a client-side monotonic counter used to match the reply to the awaiting caller. +- **Reply (success):** `{"command": "get_timetable", "vehicle_id": N, "request_id": X, "lateness": ..., "start_tick": ..., "current_order_time": ..., "total_duration": ..., "orders": [{"position", "wait_time", "travel_time", "wait_timetabled", "travel_timetabled", "wait_fixed", "travel_fixed", "leave_type", "max_speed"}, ...]}` (booleans encoded as 0/1). +- **Reply (error):** same envelope with an `"error"` field instead of the data: `"invalid_vehicle"` (no such vehicle) or `"response_too_large"` (the reply exceeded the admin packet size limit, possible with very many orders). `get_timetable()` raises `ValueError` for these. + +Replies carrying a `request_id` that matches a pending request resolve that request and are **not** delivered to the `on_gamescript` callback; all other `ServerGamescript` traffic reaches the callback unchanged. The same `update_frequency` subscription requirement applies (`get_timetable()` subscribes automatically on first use). Since GameScripts do not tick while the game is paused, a query against a paused server times out (`asyncio.TimeoutError`). + ## Vehicle Timetables (Game Port DoCommands) -Unlike vehicle listing, timetables have no GameScript API surface at all (no getters or setters exist in `GSOrder`/`GSVehicle`). 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). +Unlike vehicle listing, timetables have no stock GameScript API surface (this project adds read-only getters via a server patch — see "Timetable Query" above; writing still has none). 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). ### Command envelope Both `ClientCommand` and `ServerCommand` share this body: `company (uint8)`, `cmd (uint16 LE, index into the `Commands` enum)`, `error_msg (uint16 LE, StringID, use 0)`, `tile (uint32 LE, always 0 for these commands)`, `payload_len (uint16 LE)`, `payload (payload_len bytes)`, `callback (uint8, use 0)`, `callback_param (uint32 LE, only present if callback != 0)`. `ServerCommand` additionally appends `frame (uint32 LE)` and `my_cmd (uint8 bool)`, and is a **broadcast echo of the request** (no success/failure code) sent to every joined client, not just the sender. @@ -50,8 +61,8 @@ Both `ClientCommand` and `ServerCommand` share this body: `company (uint8)`, `cm ### Ownership requirement A command is rejected (and the client kicked) unless it's issued by the company that owns the target vehicle — join that company via `join_company()` with a real company id (not 255/spectator) before calling any timetable method. -### Reading timetables — no query command exists -There is no getter `DoCommand` for orders/timetables anywhere in the protocol. `get_vehicle_timetable()` works by passively decoding `ServerCommand` broadcasts (including the sender's own) as they arrive — it only reflects **changes made after the client joined**. A vehicle's pre-existing timetable (set before this client connected) is invisible until something changes it again; seeing it upfront would require parsing the `ORDR`/`VEHS` chunks of the initial savegame transfer (`ServerMapData`), which this client does not implement. +### Reading timetables — no query command exists on the game port +There is no getter `DoCommand` for orders/timetables anywhere in the protocol. `get_vehicle_timetable()` works by passively decoding `ServerCommand` broadcasts (including the sender's own) as they arrive — it only reflects **changes made after the client joined**. A vehicle's pre-existing timetable (set before this client connected) is invisible until something changes it again; seeing it upfront would require parsing the `ORDR`/`VEHS` chunks of the initial savegame transfer (`ServerMapData`), which this client does not implement. For an authoritative read, use the Admin Network's `get_timetable()` instead (see "Timetable Query" above). ## Stream Encryption (AEAD) Once `ServerEnableEncryption` is received, all subsequent packets use **XChaCha20-Poly1305** (Authenticated Encryption with Associated Data). diff --git a/docs/TIMETABLES.md b/docs/TIMETABLES.md index 51aed19..3d73e05 100644 --- a/docs/TIMETABLES.md +++ b/docs/TIMETABLES.md @@ -1,20 +1,86 @@ # Vehicle Timetables: Usage Guide -This guide covers `OpenTTDClient`'s timetable API: `change_timetable()`, `autofill_timetable()`, -`set_timetable_start()`, `set_vehicle_on_time()`, and `get_vehicle_timetable()`. For wire-format -internals (packet layout, varuint encoding, command IDs), see [PROTOCOL.md](PROTOCOL.md#vehicle-timetables-game-port-docommands). +This guide covers the timetable API: writing via `OpenTTDClient`'s `change_timetable()`, +`autofill_timetable()`, `set_timetable_start()`, `set_vehicle_on_time()`, and reading via +`OpenTTDAdminClient.get_timetable()` (authoritative, recommended) or `OpenTTDClient`'s +`get_vehicle_timetable()` (passive change observer). For wire-format internals (packet layout, +varuint encoding, command IDs), see [PROTOCOL.md](PROTOCOL.md#vehicle-timetables-game-port-docommands). This guide is about *how to call these methods and what their parameters mean*, with worked examples. -## The two things you must know before using this API +## Reading: `get_timetable()` — authoritative snapshot (recommended) -1. **You must join the vehicle's own company.** These are real game commands (`DoCommand`s), not - admin-network calls. Join with `client.join_company(company_id=, company_password=...)` - using the id of the company that owns the vehicle — spectators (`company_id=255`, the default) - are rejected. Sending a command for a vehicle you don't own also fails. -2. **There is no "get timetable" query.** OpenTTD's protocol has no command that returns a - vehicle's current timetable. `get_vehicle_timetable()` works by passively watching the +An **awaitable** method on `OpenTTDAdminClient` (admin port, TCP 3977) that queries the real +timetable state from the running game via the AdminBridge GameScript. Unlike the observer +approach below, it works for timetables set **before** you connected and returns the game's +**actual** state, not the last requested change. No `join_company` needed — it's read-only and +sees every company's vehicles. + +```python +import asyncio +from openttd import OpenTTDAdminClient + +async def main(): + admin = OpenTTDAdminClient("127.0.0.1", admin_name="TimetableReader") + await admin.connect(admin_password="asd") + await admin.joined.wait() + + data = await admin.get_timetable(7) + print(data) + + await admin.quit() + +asyncio.run(main()) +``` + +Returns a `dict` shaped like: + +```python +{ + "command": "get_timetable", + "vehicle_id": 7, + "lateness": 0, # ticks late; negative = running early + "start_tick": 1000000, # absolute StateTicks the timetable starts at; 0 = not started + "current_order_time": 42, # ticks spent on the current order so far + "total_duration": 5400, # full timetable round-trip in ticks; -1 = timetable incomplete + "orders": [ + { + "position": 0, + "wait_time": 120, # ticks (raw stored value) + "travel_time": 300, # ticks (raw stored value) + "wait_timetabled": 1, # 1 = wait time explicitly set, 0 = not timetabled + "travel_timetabled": 1, + "wait_fixed": 0, # 1 = locked against autofill + "travel_fixed": 0, + "leave_type": 0, # 0 normal, 1 leave early, 2 early if any cargo full, 3 early if all full + "max_speed": 65535, # order speed cap; 65535 = no cap + }, + # ... one entry per order position + ], +} +``` + +Errors: raises `ValueError` when the GameScript reports one (`invalid_vehicle` for a nonexistent +vehicle id; `response_too_large` if a very long order list overflows the admin packet limit), +`asyncio.TimeoutError` when no reply arrives within `timeout` (default 5.0s — note GameScripts +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. + +## Writing (and the legacy observer): things you must know + +1. **You must join the vehicle's own company to write.** The `change_timetable()` family are real + game commands (`DoCommand`s) on the game port, not admin-network calls. Join with + `client.join_company(company_id=, company_password=...)` using the id of the company that + owns the vehicle — spectators (`company_id=255`, the default) are rejected. Sending a command + for a vehicle you don't own also fails. +2. **`get_vehicle_timetable()` is a passive observer, not a query.** It watches the `ServerCommand` broadcasts the server sends to every joined client whenever *anyone* changes a - timetable. This means: + timetable. Use it for live change monitoring on the game port; prefer `get_timetable()` above + for reading actual state. Its limitations: - It only reflects changes made **after your client joined**. A vehicle's pre-existing timetable (set before you connected) is invisible until something changes it again. - It reflects what was **requested**, not a confirmed result — the wire protocol has no diff --git a/lib/openttd/client.py b/lib/openttd/client.py index 753bc33..bba8219 100644 --- a/lib/openttd/client.py +++ b/lib/openttd/client.py @@ -347,6 +347,11 @@ class OpenTTDAdminClient: self.on_console = None self.on_gamescript = None + # GameScript request/response correlation + self._gs_request_id = 0 + self._gs_futures = {} + self._gs_subscribed = False + async def connect(self, admin_password="", secure=False): """Connect to the admin port and initiate handshake.""" self._admin_password = admin_password @@ -376,6 +381,10 @@ class OpenTTDAdminClient: def disconnect(self, source): """Library callback for when connection is lost.""" self.log.info("Admin disconnected.") + for fut in self._gs_futures.values(): + if not fut.done(): + fut.set_exception(ConnectionError("admin disconnected")) + self._gs_futures.clear() self.shutdown_event.set() async def quit(self): @@ -445,6 +454,39 @@ class OpenTTDAdminClient: payload["company_id"] = company_id await self.send_gamescript(payload) + async def get_timetable(self, vehicle_id, timeout=5.0): + """Fetch an authoritative timetable snapshot for a vehicle via the AdminBridge GameScript. + + Unlike the game client's passive observer, this queries the real game state: it works for + timetables set before this client connected and reflects the actual (not requested) values. + Auto-subscribes to Gamescript updates on first use; if you manage update frequencies + yourself, ensure update_frequency(Gamescript, Automatic) is active before calling. + + Returns a dict with vehicle-level keys (lateness, start_tick, current_order_time, + total_duration) and an "orders" list of per-order dicts (position, wait_time, travel_time, + wait_timetabled, travel_timetabled, wait_fixed, travel_fixed, leave_type, max_speed). + + Raises asyncio.TimeoutError if no reply arrives (e.g. game paused, GS not loaded), + ValueError on a GameScript-reported error (invalid_vehicle, response_too_large), and + ConnectionError if the admin connection drops while waiting. + """ + from .protocol import AdminUpdateType, AdminUpdateFrequency + if not self._gs_subscribed: + await self.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic) + self._gs_subscribed = True + self._gs_request_id += 1 + rid = self._gs_request_id + fut = asyncio.get_running_loop().create_future() + self._gs_futures[rid] = fut + try: + await self.send_gamescript({"command": "get_timetable", "vehicle_id": vehicle_id, "request_id": rid}) + data = await asyncio.wait_for(fut, timeout) + finally: + self._gs_futures.pop(rid, None) + if "error" in data: + raise ValueError(f"get_timetable({vehicle_id}): {data['error']}") + return data + async def send_gamescript(self, json_data): """Send a JSON string to the GameScript.""" import json @@ -540,10 +582,17 @@ class OpenTTDAdminClient: self.log.info(f"Admin: Company {kwargs.get('company_id')} Stats: Vehicles={kwargs.get('vehicles')}, Stations={kwargs.get('stations')}") async def receive_ServerGamescript(self, source, **kwargs): + data = kwargs.get('data') + if isinstance(data, dict): + fut = self._gs_futures.get(data.get('request_id')) + if fut is not None: + if not fut.done(): + fut.set_result(data) + return if self.on_gamescript: - self.on_gamescript(kwargs.get('data')) + self.on_gamescript(data) else: - self.log.info(f"GAMESCRIPT: {kwargs.get('data')}") + self.log.info(f"GAMESCRIPT: {data}") async def receive_ServerDate(self, source, **kwargs): pass async def receive_ServerFull(self, source, **kwargs): await self.quit() diff --git a/tests/test_admin.py b/tests/test_admin.py index 09bc657..d01913e 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -187,3 +187,88 @@ async def test_admin_client_connect_and_actions(monkeypatch): client._protocol = BadProtocol() await client.quit() assert client.shutdown_event.is_set() + +@pytest.mark.asyncio +async def test_admin_get_timetable_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_timetable(5)) + 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_timetable", "vehicle_id": 5, "request_id": 1, + } + + response = {"command": "get_timetable", "vehicle_id": 5, "request_id": 1, + "lateness": 0, "start_tick": 0, "current_order_time": 3, + "total_duration": -1, "orders": []} + await client.receive_ServerGamescript(None, data=response) + assert await task == response + assert client._gs_futures == {} + + # Second call must not re-subscribe and must use a fresh request id. + task = asyncio.ensure_future(client.get_timetable(9)) + await asyncio.sleep(0) + assert len(proto.sent) == 3 + assert decode_gamescript_payload(proto.sent[2])["request_id"] == 2 + await client.receive_ServerGamescript(None, data={"request_id": 2, "orders": []}) + assert (await task)["orders"] == [] + +@pytest.mark.asyncio +async def test_admin_get_timetable_timeout(): + client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin") + client._protocol = MockProtocol() + client._transport = MockTransport() + + with pytest.raises(asyncio.TimeoutError): + await client.get_timetable(5, timeout=0.05) + assert client._gs_futures == {} + +@pytest.mark.asyncio +async def test_admin_get_timetable_error_response(): + client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin") + client._protocol = MockProtocol() + client._transport = MockTransport() + + task = asyncio.ensure_future(client.get_timetable(65535)) + await asyncio.sleep(0) + await client.receive_ServerGamescript( + None, data={"command": "get_timetable", "vehicle_id": 65535, + "request_id": 1, "error": "invalid_vehicle"}) + with pytest.raises(ValueError, match="invalid_vehicle"): + await task + assert client._gs_futures == {} + +@pytest.mark.asyncio +async def test_admin_get_timetable_disconnect_fails_pending(): + client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin") + client._protocol = MockProtocol() + client._transport = MockTransport() + + task = asyncio.ensure_future(client.get_timetable(5)) + await asyncio.sleep(0) + client.disconnect(None) + with pytest.raises(ConnectionError): + await task + assert client._gs_futures == {} + +@pytest.mark.asyncio +async def test_admin_gamescript_passthrough_unmatched(): + client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin") + client._protocol = MockProtocol() + client._transport = MockTransport() + + gs_events = [] + client.on_gamescript = lambda data: gs_events.append(data) + + # No request_id, unknown request_id, and non-dict payloads all pass through. + await client.receive_ServerGamescript(None, data={"vehicles": []}) + await client.receive_ServerGamescript(None, data={"request_id": 999, "orders": []}) + await client.receive_ServerGamescript(None, data="plain string") + assert gs_events == [{"vehicles": []}, {"request_id": 999, "orders": []}, "plain string"] diff --git a/tests/test_e2e.py b/tests/test_e2e.py index ccd6f7f..3c8bfb9 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -429,6 +429,41 @@ async def test_e2e_admin_list_vehicles_specific_company(connected_admin): assert "vehicles" in responses[-1] assert isinstance(responses[-1]["vehicles"], list) +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_e2e_admin_get_timetable_valid_vehicle(connected_admin): + # Public function: get_timetable() + # Input 1: a real vehicle id discovered via list_vehicles + responses = [] + connected_admin.on_gamescript = lambda data: responses.append(data) + await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic) + await connected_admin.list_vehicles() + await asyncio.sleep(0.5) + + assert len(responses) >= 1 and "vehicles" in responses[-1] + vehicles = responses[-1]["vehicles"] + if not vehicles: + pytest.skip("No vehicles on the test server to query a timetable for.") + vid = vehicles[0]["id"] + + data = await connected_admin.get_timetable(vid, timeout=10.0) + assert data["vehicle_id"] == vid + for key in ("lateness", "start_tick", "current_order_time", "total_duration", "orders"): + assert key in data + assert isinstance(data["orders"], list) + for order in data["orders"]: + for key in ("position", "wait_time", "travel_time", "wait_timetabled", + "travel_timetabled", "wait_fixed", "travel_fixed", "leave_type", "max_speed"): + assert key in order + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_e2e_admin_get_timetable_invalid_vehicle(connected_admin): + # Public function: get_timetable() + # Input 2: an id no vehicle can have -> GameScript reports invalid_vehicle + with pytest.raises(ValueError, match="invalid_vehicle"): + await connected_admin.get_timetable(65535, timeout=10.0) + # --- Protocol Public Functions ---