Compare commits

..

3 Commits

Author SHA1 Message Date
c39f970ef9 Merge pull request 'Added real timetable support' (#20) from add-real-timetable-support into main
All checks were successful
Continuous Integration / lint-and-security (push) Successful in 20s
Continuous Integration / tests-and-coverage (push) Successful in 24s
Reviewed-on: #20
2026-07-23 21:02:37 +02:00
36fc118da3 Merge branch 'main' into add-real-timetable-support
All checks were successful
Continuous Integration / lint-and-security (pull_request) Successful in 26s
Continuous Integration / tests-and-coverage (pull_request) Successful in 24s
2026-07-23 21:00:50 +02:00
3b54a722d6 Added real timetable support
All checks were successful
Continuous Integration / lint-and-security (pull_request) Successful in 33s
Continuous Integration / tests-and-coverage (pull_request) Successful in 26s
2026-07-23 20:59:28 +02:00
10 changed files with 561 additions and 21 deletions

View File

@@ -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). - **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 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. - **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 ## 🛠 Setup

View File

@@ -26,13 +26,16 @@ RUN cmake .. \
&& make -j$(nproc) install && make -j$(nproc) install
# Runtime stage # 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 \ RUN apt-get update && apt-get install -y \
libcurl3-gnutls \ libcurl3t64-gnutls \
liblzma5 \ liblzma5 \
liblzo2-2 \ liblzo2-2 \
libpng16-16 \ libpng16-16t64 \
libzstd1 \ libzstd1 \
zlib1g \ zlib1g \
ca-certificates \ ca-certificates \

View File

@@ -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/`. Save games are stored in `config/save/`.
## JGRPP Source ## 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: To update the server to a newer JGRPP version:
1. Update the `OpenTTD-patches` directory: 1. Update the `OpenTTD-patches` directory:
```bash ```bash
cd OpenTTD-patches && git pull && cd .. 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 ```bash
docker-compose up -d --build docker-compose up -d --build
``` ```

View File

@@ -0,0 +1,257 @@
From 73f6770eb3e0ec5be8da9e74fa0874fb6ba2d36d Mon Sep 17 00:00:00 2001
From: kovagoadi <kovagoadi@gmail.com>
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 <noreply@anthropic.com>
---
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

28
docker/patches/README.md Normal file
View File

@@ -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.

View File

@@ -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. **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) ## 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 ### 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. 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 ### 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. 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 ### 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. 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) ## Stream Encryption (AEAD)
Once `ServerEnableEncryption` is received, all subsequent packets use **XChaCha20-Poly1305** (Authenticated Encryption with Associated Data). Once `ServerEnableEncryption` is received, all subsequent packets use **XChaCha20-Poly1305** (Authenticated Encryption with Associated Data).

View File

@@ -1,20 +1,86 @@
# Vehicle Timetables: Usage Guide # Vehicle Timetables: Usage Guide
This guide covers `OpenTTDClient`'s timetable API: `change_timetable()`, `autofill_timetable()`, This guide covers the timetable API: writing via `OpenTTDClient`'s `change_timetable()`,
`set_timetable_start()`, `set_vehicle_on_time()`, and `get_vehicle_timetable()`. For wire-format `autofill_timetable()`, `set_timetable_start()`, `set_vehicle_on_time()`, and reading via
internals (packet layout, varuint encoding, command IDs), see [PROTOCOL.md](PROTOCOL.md#vehicle-timetables-game-port-docommands). `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. 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 An **awaitable** method on `OpenTTDAdminClient` (admin port, TCP 3977) that queries the real
admin-network calls. Join with `client.join_company(company_id=<id>, company_password=...)` timetable state from the running game via the AdminBridge GameScript. Unlike the observer
using the id of the company that owns the vehicle — spectators (`company_id=255`, the default) approach below, it works for timetables set **before** you connected and returns the game's
are rejected. Sending a command for a vehicle you don't own also fails. **actual** state, not the last requested change. No `join_company` needed — it's read-only and
2. **There is no "get timetable" query.** OpenTTD's protocol has no command that returns a sees every company's vehicles.
vehicle's current timetable. `get_vehicle_timetable()` works by passively watching the
```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=<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 `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 - 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. 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 - It reflects what was **requested**, not a confirmed result — the wire protocol has no

View File

@@ -347,6 +347,11 @@ class OpenTTDAdminClient:
self.on_console = None self.on_console = None
self.on_gamescript = 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): async def connect(self, admin_password="", secure=False):
"""Connect to the admin port and initiate handshake.""" """Connect to the admin port and initiate handshake."""
self._admin_password = admin_password self._admin_password = admin_password
@@ -376,6 +381,10 @@ class OpenTTDAdminClient:
def disconnect(self, source): def disconnect(self, source):
"""Library callback for when connection is lost.""" """Library callback for when connection is lost."""
self.log.info("Admin disconnected.") 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() self.shutdown_event.set()
async def quit(self): async def quit(self):
@@ -445,6 +454,39 @@ class OpenTTDAdminClient:
payload["company_id"] = company_id payload["company_id"] = company_id
await self.send_gamescript(payload) 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): async def send_gamescript(self, json_data):
"""Send a JSON string to the GameScript.""" """Send a JSON string to the GameScript."""
import json 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')}") 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): 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: if self.on_gamescript:
self.on_gamescript(kwargs.get('data')) self.on_gamescript(data)
else: 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_ServerDate(self, source, **kwargs): pass
async def receive_ServerFull(self, source, **kwargs): await self.quit() async def receive_ServerFull(self, source, **kwargs): await self.quit()

View File

@@ -187,3 +187,88 @@ async def test_admin_client_connect_and_actions(monkeypatch):
client._protocol = BadProtocol() client._protocol = BadProtocol()
await client.quit() await client.quit()
assert client.shutdown_event.is_set() 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"]

View File

@@ -429,6 +429,41 @@ async def test_e2e_admin_list_vehicles_specific_company(connected_admin):
assert "vehicles" in responses[-1] assert "vehicles" in responses[-1]
assert isinstance(responses[-1]["vehicles"], list) 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 --- # --- Protocol Public Functions ---