Added real timetable support
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
28
docker/patches/README.md
Normal 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.
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user