Compare commits
17 Commits
71bef5a8a2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e352ba248 | |||
| 7fdd5f2fca | |||
| a9f5b2d5d9 | |||
| e925659e28 | |||
| 846d085e1a | |||
| f0ef4148b0 | |||
| 67e886f8d2 | |||
| 2eea541158 | |||
| af1a865fd6 | |||
| 81a4d9333d | |||
| c39f970ef9 | |||
| 36fc118da3 | |||
| 3b54a722d6 | |||
| 38cc5ae40c | |||
| dfb88523f1 | |||
| 5db0dab9bf | |||
| df9c7b3f06 |
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
lint-and-security:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
with:
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
tests-and-coverage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
|
||||
@@ -11,6 +11,12 @@ 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.
|
||||
- **Order Editing:** Add and remove a vehicle's orders (`add_order()` inserts a "go to station" stop, `remove_order()` deletes one) via real game-protocol commands.
|
||||
- **Scheduled Dispatch (JGRPP):** Edit a vehicle's dispatch schedules and departure slots over the game port (`add_dispatch_schedule()`, `add_dispatch_slot()`, `set_dispatch_duration()`, `set_scheduled_dispatch()`, and more), and read them back authoritatively with `OpenTTDAdminClient.get_dispatch()` (via a patched GameScript API + the AdminBridge GS).
|
||||
- **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.
|
||||
- **Station Listing:** Enumerate stations via the Admin GameScript channel with `list_stations()`.
|
||||
- **Station Cargo Snapshots:** `OpenTTDAdminClient.get_station()` returns a station's live per-cargo state from the running game — both the **real-time** amount waiting and the **planned** flow through the cargodist link graph — over the AdminBridge GS (stock GameScript API, no server patch needed).
|
||||
- **Cargo Flow Breakdown:** `OpenTTDAdminClient.get_station_cargo()` breaks one cargo type down by **source station** and **next hop** (routing destination) for both waiting (real-time) and planned amounts, with optional `from_station`/`via_station` filters.
|
||||
|
||||
## 🛠 Setup
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM debian:13@sha256:fac46bff2e02f51425b6e33b0e1169f55dfb053d83511ca28aa50c09fd5ed7a4 AS builder
|
||||
FROM debian:13@sha256:34cd9e9fd437c0a095ec39cb2e73422c9f30821b0d0848ed74fd0d43bae4d958 AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
@@ -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:3a39a0592364683e6bab97937b72cad5a8fa6dcbbee90edb3bb48c7f8e94f258
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libcurl3-gnutls \
|
||||
libcurl3t64-gnutls \
|
||||
liblzma5 \
|
||||
liblzo2-2 \
|
||||
libpng16-16 \
|
||||
libpng16-16t64 \
|
||||
libzstd1 \
|
||||
zlib1g \
|
||||
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/`.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
From 1e4bdcca84e956ece32d2d77dc8001bfd1d8e2f8 Mon Sep 17 00:00:00 2001
|
||||
From: kovagoadi <kovagoadi@gmail.com>
|
||||
Date: Fri, 24 Jul 2026 22:32:29 +0200
|
||||
Subject: [PATCH] Add GameScript API scheduled dispatch getters to ScriptOrder
|
||||
|
||||
Expose read-only scheduled dispatch data to AI/GS scripts: per-vehicle
|
||||
schedule count and enabled flag, per-schedule duration, start tick, max
|
||||
delay and slot re-use, and per-slot offset and flags. Enables the
|
||||
AdminBridge GameScript's get_dispatch command and the Python client's
|
||||
OpenTTDAdminClient.get_dispatch().
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||||
---
|
||||
src/script/api/script_order.cpp | 81 +++++++++++++++++++++++++++++++++
|
||||
src/script/api/script_order.hpp | 75 ++++++++++++++++++++++++++++++
|
||||
2 files changed, 156 insertions(+)
|
||||
|
||||
diff --git a/src/script/api/script_order.cpp b/src/script/api/script_order.cpp
|
||||
index ee18f7d588..3db4639f14 100644
|
||||
--- a/src/script/api/script_order.cpp
|
||||
+++ b/src/script/api/script_order.cpp
|
||||
@@ -822,3 +822,84 @@ static void _DoCommandReturnSetOrderFlags(class ScriptInstance &instance)
|
||||
if (duration == INVALID_TICKS) return -1;
|
||||
return duration;
|
||||
}
|
||||
+
|
||||
+/**
|
||||
+ * Resolve a scheduled dispatch schedule for a vehicle, or nullptr if the vehicle/schedule is invalid.
|
||||
+ */
|
||||
+static const DispatchSchedule *ResolveDispatchSchedule(VehicleID vehicle_id, SQInteger schedule_index)
|
||||
+{
|
||||
+ if (!ScriptVehicle::IsPrimaryVehicle(vehicle_id)) return nullptr;
|
||||
+ const Vehicle *v = ::Vehicle::Get(vehicle_id);
|
||||
+ if (v->orders == nullptr) return nullptr;
|
||||
+ if (schedule_index < 0 || static_cast<uint>(schedule_index) >= v->orders->GetScheduledDispatchScheduleCount()) return nullptr;
|
||||
+ return &v->orders->GetDispatchScheduleByIndex(static_cast<uint>(schedule_index));
|
||||
+}
|
||||
+
|
||||
+/* static */ SQInteger ScriptOrder::GetScheduledDispatchScheduleCount(VehicleID vehicle_id)
|
||||
+{
|
||||
+ if (!ScriptVehicle::IsPrimaryVehicle(vehicle_id)) return -1;
|
||||
+
|
||||
+ const Vehicle *v = ::Vehicle::Get(vehicle_id);
|
||||
+ if (v->orders == nullptr) return 0;
|
||||
+ return v->orders->GetScheduledDispatchScheduleCount();
|
||||
+}
|
||||
+
|
||||
+/* static */ SQInteger ScriptOrder::IsScheduledDispatchEnabled(VehicleID vehicle_id)
|
||||
+{
|
||||
+ if (!ScriptVehicle::IsPrimaryVehicle(vehicle_id)) return -1;
|
||||
+
|
||||
+ return ::Vehicle::Get(vehicle_id)->vehicle_flags.Test(VehicleFlag::ScheduledDispatch) ? 1 : 0;
|
||||
+}
|
||||
+
|
||||
+/* static */ SQInteger ScriptOrder::GetScheduledDispatchDuration(VehicleID vehicle_id, SQInteger schedule_index)
|
||||
+{
|
||||
+ const DispatchSchedule *ds = ::ResolveDispatchSchedule(vehicle_id, schedule_index);
|
||||
+ if (ds == nullptr) return -1;
|
||||
+ return ds->GetScheduledDispatchDuration();
|
||||
+}
|
||||
+
|
||||
+/* static */ SQInteger ScriptOrder::GetScheduledDispatchStartTick(VehicleID vehicle_id, SQInteger schedule_index)
|
||||
+{
|
||||
+ const DispatchSchedule *ds = ::ResolveDispatchSchedule(vehicle_id, schedule_index);
|
||||
+ if (ds == nullptr) return -1;
|
||||
+ return ds->GetScheduledDispatchStartTick().base();
|
||||
+}
|
||||
+
|
||||
+/* static */ SQInteger ScriptOrder::GetScheduledDispatchDelay(VehicleID vehicle_id, SQInteger schedule_index)
|
||||
+{
|
||||
+ const DispatchSchedule *ds = ::ResolveDispatchSchedule(vehicle_id, schedule_index);
|
||||
+ if (ds == nullptr) return -1;
|
||||
+ return ds->GetScheduledDispatchDelay();
|
||||
+}
|
||||
+
|
||||
+/* static */ SQInteger ScriptOrder::GetScheduledDispatchReuseSlots(VehicleID vehicle_id, SQInteger schedule_index)
|
||||
+{
|
||||
+ const DispatchSchedule *ds = ::ResolveDispatchSchedule(vehicle_id, schedule_index);
|
||||
+ if (ds == nullptr) return -1;
|
||||
+ return ds->GetScheduledDispatchReuseSlots() ? 1 : 0;
|
||||
+}
|
||||
+
|
||||
+/* static */ SQInteger ScriptOrder::GetScheduledDispatchSlotCount(VehicleID vehicle_id, SQInteger schedule_index)
|
||||
+{
|
||||
+ const DispatchSchedule *ds = ::ResolveDispatchSchedule(vehicle_id, schedule_index);
|
||||
+ if (ds == nullptr) return -1;
|
||||
+ return (SQInteger)ds->GetScheduledDispatch().size();
|
||||
+}
|
||||
+
|
||||
+/* static */ SQInteger ScriptOrder::GetScheduledDispatchSlotOffset(VehicleID vehicle_id, SQInteger schedule_index, SQInteger slot_index)
|
||||
+{
|
||||
+ const DispatchSchedule *ds = ::ResolveDispatchSchedule(vehicle_id, schedule_index);
|
||||
+ if (ds == nullptr) return -1;
|
||||
+ const std::vector<DispatchSlot> &slots = ds->GetScheduledDispatch();
|
||||
+ if (slot_index < 0 || static_cast<size_t>(slot_index) >= slots.size()) return -1;
|
||||
+ return slots[static_cast<size_t>(slot_index)].offset;
|
||||
+}
|
||||
+
|
||||
+/* static */ SQInteger ScriptOrder::GetScheduledDispatchSlotFlags(VehicleID vehicle_id, SQInteger schedule_index, SQInteger slot_index)
|
||||
+{
|
||||
+ const DispatchSchedule *ds = ::ResolveDispatchSchedule(vehicle_id, schedule_index);
|
||||
+ if (ds == nullptr) return -1;
|
||||
+ const std::vector<DispatchSlot> &slots = ds->GetScheduledDispatch();
|
||||
+ if (slot_index < 0 || static_cast<size_t>(slot_index) >= slots.size()) return -1;
|
||||
+ return slots[static_cast<size_t>(slot_index)].flags;
|
||||
+}
|
||||
diff --git a/src/script/api/script_order.hpp b/src/script/api/script_order.hpp
|
||||
index 6c96b91b3a..d0a7e41fa7 100644
|
||||
--- a/src/script/api/script_order.hpp
|
||||
+++ b/src/script/api/script_order.hpp
|
||||
@@ -720,6 +720,81 @@ public:
|
||||
* invalid, has no orders, or the timetable is not complete.
|
||||
*/
|
||||
static SQInteger GetTimetableTotalDuration(VehicleID vehicle_id);
|
||||
+
|
||||
+ /**
|
||||
+ * Gets the number of scheduled dispatch schedules of the given vehicle.
|
||||
+ * @param vehicle_id The vehicle to query.
|
||||
+ * @pre ScriptVehicle::IsPrimaryVehicle(vehicle_id).
|
||||
+ * @return The number of dispatch schedules (0 when the vehicle has no order list),
|
||||
+ * or -1 when the vehicle is invalid.
|
||||
+ */
|
||||
+ static SQInteger GetScheduledDispatchScheduleCount(VehicleID vehicle_id);
|
||||
+
|
||||
+ /**
|
||||
+ * Gets whether scheduled dispatch is enabled for the given vehicle.
|
||||
+ * @param vehicle_id The vehicle to query.
|
||||
+ * @pre ScriptVehicle::IsPrimaryVehicle(vehicle_id).
|
||||
+ * @return 1 if enabled, 0 if disabled, or -1 when the vehicle is invalid.
|
||||
+ */
|
||||
+ static SQInteger IsScheduledDispatchEnabled(VehicleID vehicle_id);
|
||||
+
|
||||
+ /**
|
||||
+ * Gets the duration in ticks of a dispatch schedule.
|
||||
+ * @param vehicle_id The vehicle to query.
|
||||
+ * @param schedule_index The dispatch schedule index.
|
||||
+ * @return The schedule duration in ticks, or -1 when the vehicle or schedule is invalid.
|
||||
+ */
|
||||
+ static SQInteger GetScheduledDispatchDuration(VehicleID vehicle_id, SQInteger schedule_index);
|
||||
+
|
||||
+ /**
|
||||
+ * Gets the start tick of a dispatch schedule.
|
||||
+ * @param vehicle_id The vehicle to query.
|
||||
+ * @param schedule_index The dispatch schedule index.
|
||||
+ * @return The absolute start state tick, or -1 when the vehicle or schedule is invalid.
|
||||
+ */
|
||||
+ static SQInteger GetScheduledDispatchStartTick(VehicleID vehicle_id, SQInteger schedule_index);
|
||||
+
|
||||
+ /**
|
||||
+ * Gets the maximum allowed delay of a dispatch schedule.
|
||||
+ * @param vehicle_id The vehicle to query.
|
||||
+ * @param schedule_index The dispatch schedule index.
|
||||
+ * @return The maximum delay in ticks, or -1 when the vehicle or schedule is invalid.
|
||||
+ */
|
||||
+ static SQInteger GetScheduledDispatchDelay(VehicleID vehicle_id, SQInteger schedule_index);
|
||||
+
|
||||
+ /**
|
||||
+ * Gets whether a dispatch schedule re-uses its dispatch slots.
|
||||
+ * @param vehicle_id The vehicle to query.
|
||||
+ * @param schedule_index The dispatch schedule index.
|
||||
+ * @return 1 if slots are re-used, 0 if not, or -1 when the vehicle or schedule is invalid.
|
||||
+ */
|
||||
+ static SQInteger GetScheduledDispatchReuseSlots(VehicleID vehicle_id, SQInteger schedule_index);
|
||||
+
|
||||
+ /**
|
||||
+ * Gets the number of departure slots in a dispatch schedule.
|
||||
+ * @param vehicle_id The vehicle to query.
|
||||
+ * @param schedule_index The dispatch schedule index.
|
||||
+ * @return The number of slots, or -1 when the vehicle or schedule is invalid.
|
||||
+ */
|
||||
+ static SQInteger GetScheduledDispatchSlotCount(VehicleID vehicle_id, SQInteger schedule_index);
|
||||
+
|
||||
+ /**
|
||||
+ * Gets the departure offset (in ticks, within the schedule duration) of a dispatch slot.
|
||||
+ * @param vehicle_id The vehicle to query.
|
||||
+ * @param schedule_index The dispatch schedule index.
|
||||
+ * @param slot_index The slot index within the schedule.
|
||||
+ * @return The slot offset, or -1 when the vehicle, schedule or slot is invalid.
|
||||
+ */
|
||||
+ static SQInteger GetScheduledDispatchSlotOffset(VehicleID vehicle_id, SQInteger schedule_index, SQInteger slot_index);
|
||||
+
|
||||
+ /**
|
||||
+ * Gets the flag word of a dispatch slot.
|
||||
+ * @param vehicle_id The vehicle to query.
|
||||
+ * @param schedule_index The dispatch schedule index.
|
||||
+ * @param slot_index The slot index within the schedule.
|
||||
+ * @return The slot flags, or -1 when the vehicle, schedule or slot is invalid.
|
||||
+ */
|
||||
+ static SQInteger GetScheduledDispatchSlotFlags(VehicleID vehicle_id, SQInteger schedule_index, SQInteger slot_index);
|
||||
};
|
||||
DECLARE_ENUM_AS_BIT_SET(ScriptOrder::ScriptOrderFlags)
|
||||
|
||||
--
|
||||
2.54.0
|
||||
|
||||
36
docker/patches/README.md
Normal file
36
docker/patches/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# 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()`.
|
||||
|
||||
- `0002-Add-GameScript-API-scheduled-dispatch-getters-to-Scr.patch` — adds read-only scheduled
|
||||
dispatch getters (`GetScheduledDispatchScheduleCount`, `IsScheduledDispatchEnabled`,
|
||||
`GetScheduledDispatchDuration`, `GetScheduledDispatchStartTick`, `GetScheduledDispatchDelay`,
|
||||
`GetScheduledDispatchReuseSlots`, `GetScheduledDispatchSlotCount`,
|
||||
`GetScheduledDispatchSlotOffset`, `GetScheduledDispatchSlotFlags`) to the `GSOrder` GameScript
|
||||
class. Required by the AdminBridge GameScript's `get_dispatch` command and the Python client's
|
||||
`OpenTTDAdminClient.get_dispatch()`.
|
||||
|
||||
## 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,27 +31,82 @@ 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.
|
||||
|
||||
## 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).
|
||||
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`).
|
||||
|
||||
### Station Listing
|
||||
Like vehicles, the Admin Network has no native packet for enumerating individual stations (`ServerCompanyStats` only reports an aggregate per-company station count). `list_stations()` sends a `list_stations` command over the same GameScript JSON channel and the companion AdminBridge GameScript replies with station data through `ServerGamescript` (`{"command": "list_stations", "stations": [{"id", "name", ...}, ...]}`). It is fire-and-forget, so the reply is delivered to the `on_gamescript` callback — subscribe to `Gamescript` updates first, exactly as for `list_vehicles()`. An optional `company_id` field scopes the list to one company.
|
||||
|
||||
### Station Query
|
||||
`get_station()` fetches an authoritative snapshot of one station's live cargo state over the same GameScript JSON channel, awaiting the correlated reply — the station analogue of `get_timetable()`. Unlike timetables, the getters it relies on (`GSStation.GetCargoWaiting`, `GetCargoPlanned`, `GetCargoRating`) are part of the **stock** GameScript API, so this needs no server patch. The per-cargo reply exposes both the **real-time** amount currently waiting and the **planned** amount routed through the station by the cargodist link graph.
|
||||
|
||||
- **Request:** `{"command": "get_station", "station_id": N, "request_id": X}` — `request_id` is the same client-side monotonic counter used by `get_timetable()`, matching the reply to the awaiting caller.
|
||||
- **Reply (success):** `{"command": "get_station", "station_id": N, "request_id": X, "name": ..., "location": <tile>, "owner": <company_id>, "cargo": [{"cargo_id", "waiting", "planned", "rating"}, ...]}` — `waiting` is the real-time units at the station (`GetCargoWaiting`), `planned` is the link-graph planned flow (`GetCargoPlanned`, 0 when cargo distribution is off for that cargo), and `rating` is the acceptance rating as a percentage (0-100, `GetCargoRating`) or `null` when the station has no rating for that cargo yet. Only cargo the station has handled appears.
|
||||
- **Reply (error):** same envelope with an `"error"` field instead of the data: `"invalid_station"` (no such station) or `"response_too_large"`. `get_station()` raises `ValueError` for these.
|
||||
|
||||
Correlation, the `update_frequency` subscription requirement (auto-subscribed on first use), and the paused-game timeout behave exactly as described for the Timetable Query above.
|
||||
|
||||
### Station Cargo Flow Breakdown
|
||||
`get_station_cargo()` drills into a single cargo type at one station and returns how its **waiting** (real-time) and **planned** amounts split across the cargo distribution (cargodist) link graph. Cargodist tags every unit with a **source** station (`from`, where it was first loaded) and a **next hop** (`via`, the next station it travels to toward its final destination). There is no per-station store of the *final* destination — the routing destination is the next hop — so the breakdown is offered along those two axes. The GS reads them with the stock `GSStation.GetCargoWaiting{From,Via,FromVia}` / `GetCargoPlanned{From,Via,FromVia}` scalars and the `GSStationList_Cargo{Waiting,Planned}By{From,Via}` (and `…ViaByFrom` / `…FromByVia`) list classes — again no server patch.
|
||||
|
||||
- **Request:** `{"command": "get_station_cargo", "station_id": N, "cargo_id": C, "request_id": X}`, optionally with `"from_station"` and/or `"via_station"` filters.
|
||||
- **Reply (success):** `{"command": "get_station_cargo", "station_id": N, "cargo_id": C, "request_id": X, "waiting": ..., "planned": ..., "waiting_by_from": [{"station", "amount"}, ...], "planned_by_from": [...], "waiting_by_via": [...], "planned_by_via": [...]}`. `waiting`/`planned` are the (filtered) totals; each `*_by_from` list groups by source station and each `*_by_via` list groups by next hop (zero-amount entries omitted). A `station` of `65535` (`STATION_INVALID`) means the source was deleted or — as a next hop — the cargo has no onward routing / is consumed here (also the only next hop for cargo using manual, non-cargodist distribution). Any supplied `from_station`/`via_station` filter is echoed back.
|
||||
- **Filters:** `via_station` restricts the query (and the `*_by_from` breakdowns) to cargo whose next hop is that station; `from_station` restricts it (and the `*_by_via` breakdowns) to cargo from that source; supplying both makes `waiting`/`planned` the exact source-and-next-hop amount. Pass `65535` to target `STATION_INVALID`.
|
||||
- **Reply (error):** same envelope with an `"error"` field: `"invalid_station"`, `"invalid_cargo"`, `"invalid_from_station"`/`"invalid_via_station"` (a filter that is neither a valid station nor `STATION_INVALID`), or `"response_too_large"`. `get_station_cargo()` raises `ValueError` for these.
|
||||
|
||||
### Dispatch Query
|
||||
`get_dispatch()` fetches an authoritative snapshot of a vehicle's **scheduled dispatch** state over the GameScript JSON channel, the vehicle analogue of `get_timetable()` for JGRPP's scheduled dispatch feature. Like the timetable getters, the dispatch getters it relies on are added by a **server patch** (`docker/patches/0002-*`, adding `GSOrder.GetScheduledDispatch*` / `IsScheduledDispatchEnabled`), so it needs the patched JGRPP build. Correlation, the auto-subscribe, and the paused-game timeout behave exactly as for the Timetable Query.
|
||||
|
||||
- **Request:** `{"command": "get_dispatch", "vehicle_id": N, "request_id": X}`.
|
||||
- **Reply (success):** `{"command": "get_dispatch", "vehicle_id": N, "request_id": X, "enabled": 0|1, "schedules": [{"index", "duration", "start_tick", "delay", "reuse_slots", "slots": [{"offset", "flags"}, ...]}, ...]}`. `enabled` is whether scheduled dispatch is turned on for the vehicle; each schedule reports its `duration` (ticks), `start_tick`, `delay` (max allowed delay), `reuse_slots` (0/1) and its `slots` (each a departure `offset` within the duration plus a 16-bit `flags` word). These are the same schedules and slots edited by the game-port dispatch methods.
|
||||
- **Reply (error):** same envelope with an `"error"` field: `"invalid_vehicle"` or `"response_too_large"`. `get_dispatch()` raises `ValueError` for these.
|
||||
|
||||
## Vehicle Orders & Timetables (Game Port DoCommands)
|
||||
Unlike vehicle listing, a vehicle's order list, timetables and scheduled dispatch have no writable GameScript API surface (this project adds read-only timetable and dispatch getters via server patches — see "Timetable Query" and "Dispatch Query" above). 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.
|
||||
|
||||
### Timetable command IDs and payload tuples
|
||||
### Payload integer encoding
|
||||
Command payload fields follow JGRPP's generic serialiser, which picks the wire width from the C++ type's **size**: types of ≤1 byte are sent as a fixed `uint8`, exactly 2 bytes as a fixed `uint16` (LE), and 4/8-byte types as a variable-length **varuint** (`write_varuint`/`read_varuint` in `protocol.py` — a UTF-8-like prefix encoding, not LEB128; signed fields use zigzag via `write_varuint_signed`/`read_varuint_signed`). This is why `VehicleID` (a 4-byte pool id) is a varuint while `VehicleOrderID` (a `uint16`) is a fixed `uint16`.
|
||||
|
||||
### Command IDs and payload tuples
|
||||
| Method | `cmd` | payload |
|
||||
|---|---|---|
|
||||
| `add_order()` | 52 (`InsertOrder`) | `VehicleID (varuint), sel_ord (uint16), order_type (uint8), order_flags (uint16), DestinationID (uint16)` |
|
||||
| `remove_order()` | 51 (`DeleteOrder`) | `VehicleID (varuint), VehicleOrderID (uint16)` |
|
||||
| `change_timetable()` | 174 (`ChangeTimetable`) | `VehicleID (varuint), VehicleOrderID (uint16), ModifyTimetableFlags (uint8), value (varuint), ModifyTimetableCtrlFlags (uint8)` |
|
||||
| `set_vehicle_on_time()` | 176 (`SetVehicleOnTime`) | `VehicleID (varuint), apply_to_group (uint8 bool)` |
|
||||
| `autofill_timetable()` | 177 (`AutofillTimetable`) | `VehicleID (varuint), bool (uint8), bool (uint8)` |
|
||||
| `set_timetable_start()` | 180 (`SetTimetableStart`) | `VehicleID (varuint), bool (uint8), StateTicks (signed varuint)` |
|
||||
| `set_scheduled_dispatch()` | 205 (`SchDispatch`) | `VehicleID (varuint), enabled (uint8 bool)` |
|
||||
| `add_dispatch_slot()` | 206 (`SchDispatchAdd`) | `VehicleID (varuint), schedule_index (varuint), offset (varuint), interval (varuint), extra_slots (varuint), slot_flags (uint16), route_id (uint8)` |
|
||||
| `remove_dispatch_slot()` | 207 (`SchDispatchRemove`) | `VehicleID (varuint), schedule_index (varuint), offset (varuint)` |
|
||||
| `set_dispatch_duration()` | 208 (`SchDispatchSetDuration`) | `VehicleID (varuint), schedule_index (varuint), duration (varuint)` |
|
||||
| `set_dispatch_start_date()` | 209 (`SchDispatchSetStartDate`) | `VehicleID (varuint), schedule_index (varuint), StateTicks (signed varuint)` |
|
||||
| `clear_dispatch_schedule()` | 213 (`SchDispatchClear`) | `VehicleID (varuint), schedule_index (varuint)` |
|
||||
| `add_dispatch_schedule()` | 214 (`SchDispatchAddNewSchedule`) | `VehicleID (varuint), StateTicks (signed varuint), duration (varuint)` |
|
||||
| `remove_dispatch_schedule()` | 215 (`SchDispatchRemoveSchedule`) | `VehicleID (varuint), schedule_index (varuint)` |
|
||||
|
||||
`VehicleID` and other 4/8-byte fields use OpenTTD's custom varuint scheme (`write_varuint`/`read_varuint` in `protocol.py`) — a UTF-8-like prefix encoding, not LEB128; signed fields (`StateTicks`) use zigzag on top of it (`write_varuint_signed`/`read_varuint_signed`).
|
||||
### Scheduled dispatch (JGRPP)
|
||||
A vehicle's order list can carry several **dispatch schedules**, each with a duration, a start tick and a set of departure **slots** (offsets within the duration). The methods above edit them over the game port (`add_dispatch_schedule()`/`remove_dispatch_schedule()` create and delete schedules; `add_dispatch_slot()`/`remove_dispatch_slot()`/`clear_dispatch_schedule()` manage a schedule's slots; `set_dispatch_duration()`/`set_dispatch_start_date()` adjust a schedule; `set_scheduled_dispatch()` toggles the feature for the vehicle). `add_dispatch_slot()` can add several evenly spaced slots at once via its `interval`/`extra_slots` parameters. The stock JGRPP command set covers ~22 dispatch commands (routes, departure tags, per-slot flags, adjust/swap/duplicate, …); the client implements this common core. There is no game-port read; for an authoritative view of the resulting schedules use the Admin Network's `get_dispatch()` (see "Dispatch Query" below).
|
||||
|
||||
### Adding & removing orders
|
||||
`add_order()` issues `CMD_INSERT_ORDER`, which appends a new order before `sel_ord` (pass `0xFFFF`/`INVALID_VEH_ORDER_ID` to append to the end). The client currently builds "go to station" orders only. The `order_type` byte is bit-packed: **bits 0-3** hold the `OrderType` (`1` = `OT_GOTO_STATION`), **bits 4-5** the `OrderStopLocation`, and **bits 6-7** the `OrderNonStopFlags`. The stop location defaults to `PlatformFarEnd` (`2`) because near-end/middle/through are **train-only** and the server rejects (`CMD_ERROR`, no state change) any other value for road vehicles, ships, or aircraft. `order_flags` is the 16-bit load/unload word (`0` = load-if-possible + unload-if-possible). `DestinationID` is the target `StationID`. `remove_order()` issues `CMD_DELETE_ORDER` for the order at a given position. Both are **broadcast** back as `ServerCommand` like any DoCommand; the client does not currently decode those echoes into observed order state, so verify results via the admin `get_timetable()` order count.
|
||||
|
||||
### 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 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 order or timetable method. A malformed or wrong-company packet is treated as illegal and the client is kicked; a well-formed command that merely fails validation (e.g. an order the vehicle can't serve) is silently dropped with no state change and no kick.
|
||||
|
||||
### 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).
|
||||
|
||||
@@ -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=<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=<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
|
||||
@@ -270,6 +336,74 @@ async def main():
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## Adding and removing orders
|
||||
|
||||
Beyond editing an order's timetable fields, you can change the order list itself. Both commands go
|
||||
over the game port and require being joined to the company that owns the vehicle (like the timetable
|
||||
methods above).
|
||||
|
||||
```python
|
||||
# Append a "go to station" order (station id 6) to the end of vehicle 7's order list.
|
||||
await client.add_order(7, 6)
|
||||
|
||||
# Insert one before position 0 instead of appending.
|
||||
await client.add_order(7, 6, before_position=0)
|
||||
|
||||
# Non-stop / stop-location can be customised (defaults suit every vehicle type).
|
||||
from openttd.protocol import OrderNonStopFlags
|
||||
await client.add_order(7, 6, non_stop=OrderNonStopFlags.NoStopAtIntermediate)
|
||||
|
||||
# Delete the order at a given position.
|
||||
await client.remove_order(7, 0)
|
||||
```
|
||||
|
||||
`add_order()` builds "go to station" orders. `stop_location` defaults to `PlatformFarEnd` because the
|
||||
other stop locations are train-only and rejected for road vehicles, ships and aircraft. There is no
|
||||
game-port query for the resulting order list; confirm changes with the admin `get_timetable()` order
|
||||
count (see [PROTOCOL.md](PROTOCOL.md#adding--removing-orders)).
|
||||
|
||||
## Scheduled dispatch (JGRPP)
|
||||
|
||||
Scheduled dispatch lets a vehicle depart on a fixed schedule of slots rather than purely by
|
||||
timetable. A vehicle's order list can hold several dispatch schedules, each with a duration, a start
|
||||
tick and a set of departure slots. The edit commands go over the game port and require being joined
|
||||
to the owning company; the authoritative read is on the admin client.
|
||||
|
||||
```python
|
||||
# Create a schedule (start tick 0, duration 3000 ticks) — it becomes the next schedule index.
|
||||
await client.add_dispatch_schedule(7, 0, 3000)
|
||||
|
||||
# Add departure slots at offsets 500 and 1500 within schedule 0's duration.
|
||||
await client.add_dispatch_slot(7, 0, 500)
|
||||
await client.add_dispatch_slot(7, 0, 1500)
|
||||
|
||||
# Add several evenly spaced slots at once: offset 0, then +250 three more times.
|
||||
await client.add_dispatch_slot(7, 0, 0, interval=250, extra_slots=3)
|
||||
|
||||
# Adjust the schedule, then turn scheduled dispatch on for the vehicle.
|
||||
await client.set_dispatch_duration(7, 0, 4000)
|
||||
await client.set_dispatch_start_date(7, 0, 1_000_000)
|
||||
await client.set_scheduled_dispatch(7, True)
|
||||
|
||||
# Remove a slot, clear a schedule's slots, or remove the whole schedule.
|
||||
await client.remove_dispatch_slot(7, 0, 1500)
|
||||
await client.clear_dispatch_schedule(7, 0)
|
||||
await client.remove_dispatch_schedule(7, 0)
|
||||
```
|
||||
|
||||
Read the live state back over the admin connection (requires the patched JGRPP build, see
|
||||
[docker/patches/README.md](../docker/patches/README.md)):
|
||||
|
||||
```python
|
||||
data = await admin.get_dispatch(7)
|
||||
# {"enabled": 1, "schedules": [{"index": 0, "duration": 4000, "start_tick": 1000000,
|
||||
# "delay": 0, "reuse_slots": 0, "slots": [{"offset": 500, "flags": 0}, ...]}]}
|
||||
```
|
||||
|
||||
The client implements a common core of the ~22 JGRPP dispatch commands; advanced operations
|
||||
(departure routes/tags, per-slot flags, adjust/swap/duplicate) are not wrapped yet.
|
||||
|
||||
## See also
|
||||
- [PROTOCOL.md — Vehicle Timetables](PROTOCOL.md#vehicle-timetables-game-port-docommands) for the underlying wire format.
|
||||
- [PROTOCOL.md — Vehicle Orders & Timetables](PROTOCOL.md#vehicle-orders--timetables-game-port-docommands) for the underlying wire format.
|
||||
- [PROTOCOL.md — Dispatch Query](PROTOCOL.md#dispatch-query) for the admin `get_dispatch()` read.
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) for how `OpenTTDClient` fits into the rest of the library.
|
||||
|
||||
@@ -9,6 +9,7 @@ from openttd_protocol.wire.read import read_uint8, read_uint16
|
||||
from .protocol import (
|
||||
PacketGameType, OpenTTDProtocol, PacketAdminType, OpenTTDAdminProtocol, NetworkAuthenticationMethod,
|
||||
GameCommand, ModifyTimetableFlags, ModifyTimetableCtrlFlag,
|
||||
OrderType, OrderStopLocation, INVALID_VEH_ORDER_ID,
|
||||
write_varuint, read_varuint, write_varuint_signed, read_varuint_signed
|
||||
)
|
||||
from .decorators import exclude_call_check
|
||||
@@ -130,6 +131,124 @@ class OpenTTDClient:
|
||||
"""
|
||||
return self.vehicle_timetables.get(vehicle_id)
|
||||
|
||||
async def add_order(self, vehicle_id, station_id, before_position=None, non_stop=0,
|
||||
stop_location=OrderStopLocation.PlatformFarEnd, order_flags=0):
|
||||
"""Insert a 'go to station' order into a vehicle's order list.
|
||||
|
||||
By default the new order is appended to the end of the list; pass before_position to insert it
|
||||
before an existing order at that index instead. non_stop is an OrderNonStopFlags value
|
||||
(0 = stop everywhere) and stop_location an OrderStopLocation value, both packed into the
|
||||
order's type byte; stop_location defaults to PlatformFarEnd because the near-end/middle/through
|
||||
values are train-only and the server rejects them for other vehicle types. order_flags is the
|
||||
16-bit load/unload flag word (0 = the game's defaults: load if possible, unload if possible).
|
||||
|
||||
Sent over the game port as a real DoCommand: it only succeeds when this client is joined to
|
||||
the company that owns the vehicle (see join_company()); a spectator is rejected and kicked.
|
||||
"""
|
||||
order_type = OrderType.GotoStation | ((stop_location & 0x3) << 4) | ((non_stop & 0x3) << 6)
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_uint16(payload, INVALID_VEH_ORDER_ID if before_position is None else before_position)
|
||||
write_uint8(payload, order_type)
|
||||
write_uint16(payload, order_flags)
|
||||
write_uint16(payload, station_id)
|
||||
await self._send_command(GameCommand.InsertOrder, payload)
|
||||
|
||||
async def remove_order(self, vehicle_id, order_position):
|
||||
"""Delete the order at order_position from a vehicle's order list.
|
||||
|
||||
Sent over the game port as a real DoCommand: like add_order(), it only succeeds when this
|
||||
client is joined to the company that owns the vehicle.
|
||||
"""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_uint16(payload, order_position)
|
||||
await self._send_command(GameCommand.DeleteOrder, payload)
|
||||
|
||||
# --- Scheduled dispatch (JGRPP) ---
|
||||
#
|
||||
# A vehicle's order list can hold several dispatch schedules, each with a duration, a start
|
||||
# tick and a set of departure slots (offsets within the duration). All of these are edited over
|
||||
# the game port and require being joined to the owning company. For an authoritative read of the
|
||||
# resulting schedules, use OpenTTDAdminClient.get_dispatch().
|
||||
|
||||
async def set_scheduled_dispatch(self, vehicle_id, enabled):
|
||||
"""Enable or disable scheduled dispatch for a vehicle (and every vehicle sharing its orders)."""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_uint8(payload, 1 if enabled else 0)
|
||||
await self._send_command(GameCommand.SchDispatch, payload)
|
||||
|
||||
async def add_dispatch_schedule(self, vehicle_id, start_tick, duration):
|
||||
"""Create a new dispatch schedule with the given start tick and duration (in ticks).
|
||||
|
||||
The schedule is appended to the vehicle's schedule set; its index is the previous schedule
|
||||
count (read it back with OpenTTDAdminClient.get_dispatch()). duration must be non-zero.
|
||||
"""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_varuint_signed(payload, start_tick)
|
||||
write_varuint(payload, duration)
|
||||
await self._send_command(GameCommand.SchDispatchAddNewSchedule, payload)
|
||||
|
||||
async def remove_dispatch_schedule(self, vehicle_id, schedule_index):
|
||||
"""Remove the dispatch schedule at schedule_index from a vehicle's schedule set."""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_varuint(payload, schedule_index)
|
||||
await self._send_command(GameCommand.SchDispatchRemoveSchedule, payload)
|
||||
|
||||
async def add_dispatch_slot(self, vehicle_id, schedule_index, offset, interval=0, extra_slots=0,
|
||||
slot_flags=0, route_id=0):
|
||||
"""Add one or more departure slots to a dispatch schedule.
|
||||
|
||||
offset is the slot's departure time as an offset (in ticks) within the schedule's duration.
|
||||
To add several evenly spaced slots in one command, pass extra_slots > 0 together with a
|
||||
non-zero interval: each extra slot is placed interval ticks after the previous one (wrapping
|
||||
around the duration). slot_flags is the 16-bit slot flag word and route_id an optional
|
||||
departure route id (both default to 0).
|
||||
"""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_varuint(payload, schedule_index)
|
||||
write_varuint(payload, offset)
|
||||
write_varuint(payload, interval)
|
||||
write_varuint(payload, extra_slots)
|
||||
write_uint16(payload, slot_flags)
|
||||
write_uint8(payload, route_id)
|
||||
await self._send_command(GameCommand.SchDispatchAdd, payload)
|
||||
|
||||
async def remove_dispatch_slot(self, vehicle_id, schedule_index, offset):
|
||||
"""Remove the departure slot at the given offset from a dispatch schedule."""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_varuint(payload, schedule_index)
|
||||
write_varuint(payload, offset)
|
||||
await self._send_command(GameCommand.SchDispatchRemove, payload)
|
||||
|
||||
async def clear_dispatch_schedule(self, vehicle_id, schedule_index):
|
||||
"""Remove every departure slot from a dispatch schedule (leaving the schedule itself)."""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_varuint(payload, schedule_index)
|
||||
await self._send_command(GameCommand.SchDispatchClear, payload)
|
||||
|
||||
async def set_dispatch_duration(self, vehicle_id, schedule_index, duration):
|
||||
"""Set the total duration (in ticks) of a dispatch schedule."""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_varuint(payload, schedule_index)
|
||||
write_varuint(payload, duration)
|
||||
await self._send_command(GameCommand.SchDispatchSetDuration, payload)
|
||||
|
||||
async def set_dispatch_start_date(self, vehicle_id, schedule_index, start_tick):
|
||||
"""Set the start tick of a dispatch schedule."""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_varuint(payload, schedule_index)
|
||||
write_varuint_signed(payload, start_tick)
|
||||
await self._send_command(GameCommand.SchDispatchSetStartDate, payload)
|
||||
|
||||
def disconnect(self, source):
|
||||
"""Library callback for when connection is lost."""
|
||||
self.log.info("Disconnected.")
|
||||
@@ -347,6 +466,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 +500,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 +573,154 @@ class OpenTTDAdminClient:
|
||||
payload["company_id"] = company_id
|
||||
await self.send_gamescript(payload)
|
||||
|
||||
async def list_stations(self, company_id=None):
|
||||
"""Request a list of stations via GameScript. company_id=None for all companies.
|
||||
|
||||
Like list_vehicles(), this is fire-and-forget: the AdminBridge GameScript replies with a
|
||||
{"stations": [...]} envelope delivered to the on_gamescript callback, so subscribe to
|
||||
Gamescript updates first (update_frequency(Gamescript, Automatic)) or the reply is dropped.
|
||||
For a station's live cargo detail (waiting vs planned), use get_station().
|
||||
"""
|
||||
payload = {"command": "list_stations"}
|
||||
if company_id is not None:
|
||||
payload["company_id"] = company_id
|
||||
await self.send_gamescript(payload)
|
||||
|
||||
async def _gs_query(self, payload, timeout, context):
|
||||
"""Send a GameScript request and await its correlated reply.
|
||||
|
||||
Assigns a fresh request_id, registers a future the ServerGamescript handler resolves when
|
||||
the matching reply arrives, and (on first use) subscribes to Gamescript updates so the
|
||||
server actually forwards the reply. `payload` is the request dict without request_id;
|
||||
`context` is a label used in the ValueError raised on a GameScript-reported error.
|
||||
|
||||
Raises asyncio.TimeoutError if no reply arrives within `timeout`, ValueError on an error
|
||||
reply, 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
|
||||
request = dict(payload)
|
||||
request["request_id"] = rid
|
||||
try:
|
||||
await self.send_gamescript(request)
|
||||
data = await asyncio.wait_for(fut, timeout)
|
||||
finally:
|
||||
self._gs_futures.pop(rid, None)
|
||||
if "error" in data:
|
||||
raise ValueError(f"{context}: {data['error']}")
|
||||
return data
|
||||
|
||||
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.
|
||||
"""
|
||||
return await self._gs_query(
|
||||
{"command": "get_timetable", "vehicle_id": vehicle_id}, timeout,
|
||||
f"get_timetable({vehicle_id})")
|
||||
|
||||
async def get_station(self, station_id, timeout=5.0):
|
||||
"""Fetch an authoritative snapshot of a station's live cargo state via the AdminBridge GameScript.
|
||||
|
||||
This queries the real game state (like get_timetable() does for vehicles): it works for any
|
||||
existing station regardless of when it was built or when this client connected. 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 station-level keys (name, location, owner) and a "cargo" list of per-cargo
|
||||
dicts. Each cargo dict carries both the real-time and the planned amounts:
|
||||
- "waiting": units currently sitting at the station (real-time, GSStation.GetCargoWaiting)
|
||||
- "planned": units planned to move through it per the cargodist link graph
|
||||
(GSStation.GetCargoPlanned); 0 when cargo distribution is not enabled for that cargo
|
||||
- "rating": the station's acceptance rating for the cargo as a percentage (0-100),
|
||||
or None if the station has no rating for that cargo yet
|
||||
Only cargo types the station has ever handled appear in the list.
|
||||
|
||||
Raises asyncio.TimeoutError if no reply arrives (e.g. game paused, GS not loaded),
|
||||
ValueError on a GameScript-reported error (invalid_station, response_too_large), and
|
||||
ConnectionError if the admin connection drops while waiting.
|
||||
"""
|
||||
return await self._gs_query(
|
||||
{"command": "get_station", "station_id": station_id}, timeout,
|
||||
f"get_station({station_id})")
|
||||
|
||||
async def get_station_cargo(self, station_id, cargo_id, from_station=None, via_station=None, timeout=5.0):
|
||||
"""Fetch a per-source / per-next-hop breakdown of one cargo at a station via the AdminBridge GS.
|
||||
|
||||
Where get_station() reports each cargo's totals, this drills into a single cargo type and
|
||||
shows how the waiting (real-time) and planned amounts split across the cargo distribution
|
||||
(cargodist) link graph. Cargodist tracks every unit by its source station (where it was
|
||||
first loaded) and its next hop (the next station it heads to on the way to its final
|
||||
destination); there is no separate "final destination" store, so the routing destination is
|
||||
the next hop ("via").
|
||||
|
||||
Returns a dict with the (optionally filtered) totals "waiting" and "planned", plus four
|
||||
breakdown lists, each a list of {"station": id, "amount": n} entries (zero amounts omitted):
|
||||
- "waiting_by_from" / "planned_by_from": grouped by source station
|
||||
- "waiting_by_via" / "planned_by_via": grouped by next hop (routing destination)
|
||||
A station id of 65535 (STATION_INVALID) marks cargo whose source was deleted or, as a next
|
||||
hop, cargo with no onward routing / to be consumed at this station (also the sole next hop
|
||||
for cargo types using manual, non-cargodist distribution).
|
||||
|
||||
Optional filters narrow the query:
|
||||
- from_station: only cargo originating at this source station.
|
||||
- via_station: only cargo whose next hop is this station.
|
||||
Passing from_station restricts the by_via breakdown to that source (and the totals to it);
|
||||
passing via_station restricts the by_from breakdown to that next hop; passing both makes the
|
||||
totals the exact source+next-hop amount. Pass 65535 for either to target STATION_INVALID.
|
||||
|
||||
Raises asyncio.TimeoutError if no reply arrives (e.g. game paused, GS not loaded),
|
||||
ValueError on a GameScript-reported error (invalid_station, invalid_cargo,
|
||||
response_too_large), and ConnectionError if the admin connection drops while waiting.
|
||||
"""
|
||||
payload = {"command": "get_station_cargo", "station_id": station_id, "cargo_id": cargo_id}
|
||||
if from_station is not None:
|
||||
payload["from_station"] = from_station
|
||||
if via_station is not None:
|
||||
payload["via_station"] = via_station
|
||||
return await self._gs_query(payload, timeout, f"get_station_cargo({station_id}, {cargo_id})")
|
||||
|
||||
async def get_dispatch(self, vehicle_id, timeout=5.0):
|
||||
"""Fetch an authoritative snapshot of a vehicle's scheduled dispatch state via the AdminBridge GS.
|
||||
|
||||
Scheduled dispatch (a JGRPP feature) lets a vehicle depart on a fixed schedule of slots rather
|
||||
than purely by timetable. This reads the live state (like get_timetable() does), so it works for
|
||||
schedules created before this client connected and reflects the real 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:
|
||||
- "enabled": 1 if scheduled dispatch is turned on for the vehicle, else 0
|
||||
- "schedules": a list of per-schedule dicts, each with "index", "duration" (ticks),
|
||||
"start_tick", "delay" (max allowed delay), "reuse_slots" (0/1), and "slots" — a list of
|
||||
{"offset", "flags"} departure slots (offset is ticks within the schedule duration).
|
||||
|
||||
These are the same schedules and slots edited by the game-port methods on OpenTTDClient
|
||||
(add_dispatch_schedule/add_dispatch_slot/...). 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.
|
||||
"""
|
||||
return await self._gs_query(
|
||||
{"command": "get_dispatch", "vehicle_id": vehicle_id}, timeout,
|
||||
f"get_dispatch({vehicle_id})")
|
||||
|
||||
async def send_gamescript(self, json_data):
|
||||
"""Send a JSON string to the GameScript."""
|
||||
import json
|
||||
@@ -540,10 +816,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()
|
||||
|
||||
@@ -48,10 +48,45 @@ def read_varuint_signed(data):
|
||||
return value, rest
|
||||
|
||||
class GameCommand(IntEnum):
|
||||
DeleteOrder = 51
|
||||
InsertOrder = 52
|
||||
ChangeTimetable = 174
|
||||
SetVehicleOnTime = 176
|
||||
AutofillTimetable = 177
|
||||
SetTimetableStart = 180
|
||||
# Scheduled dispatch (JGRPP)
|
||||
SchDispatch = 205
|
||||
SchDispatchAdd = 206
|
||||
SchDispatchRemove = 207
|
||||
SchDispatchSetDuration = 208
|
||||
SchDispatchSetStartDate = 209
|
||||
SchDispatchClear = 213
|
||||
SchDispatchAddNewSchedule = 214
|
||||
SchDispatchRemoveSchedule = 215
|
||||
|
||||
# Sentinel VehicleOrderID meaning "append to the end of the order list" for InsertOrder.
|
||||
INVALID_VEH_ORDER_ID = 0xFFFF
|
||||
|
||||
class OrderType(IntEnum):
|
||||
"""OrderType occupies bits 0-3 of an order's `type` byte (bits 6-7 hold OrderNonStopFlags)."""
|
||||
GotoStation = 1
|
||||
GotoDepot = 2
|
||||
GotoWaypoint = 6
|
||||
|
||||
class OrderNonStopFlags(IntEnum):
|
||||
"""Packed into bits 6-7 of an order's `type` byte."""
|
||||
StopEverywhere = 0
|
||||
NoStopAtIntermediate = 1
|
||||
NoStopAtDestination = 2
|
||||
NoStopAtAny = 3
|
||||
|
||||
class OrderStopLocation(IntEnum):
|
||||
"""Packed into bits 4-5 of an order's `type` byte. Near-end/middle/through are train-only;
|
||||
FarEnd is the only value the server accepts for every vehicle type, so it is the safe default."""
|
||||
PlatformNearEnd = 0
|
||||
PlatformMiddle = 1
|
||||
PlatformFarEnd = 2
|
||||
PlatformThrough = 3
|
||||
|
||||
class ModifyTimetableFlags(IntEnum):
|
||||
WaitTime = 0
|
||||
|
||||
28
main.py
28
main.py
@@ -21,6 +21,8 @@ COMPANY_PASSWORD = "asd123"
|
||||
# A vehicle owned by COMPANY_ID, used to demonstrate timetable get/set below. Set to a real
|
||||
# vehicle id to see it in action; leave as None to skip the demonstration.
|
||||
DEMO_VEHICLE_ID = 7
|
||||
# A station DEMO_VEHICLE_ID can legally serve, used to demonstrate add_order/remove_order.
|
||||
DEMO_STATION_ID = 6
|
||||
|
||||
async def demo_timetable_workflow(client, vehicle_id):
|
||||
"""A deliberately thorough walk-through of the timetable API: every ModifyTimetableFlags
|
||||
@@ -104,6 +106,32 @@ async def demo_timetable_workflow(client, vehicle_id):
|
||||
await asyncio.sleep(0.5)
|
||||
show("lateness reset (whole group)")
|
||||
|
||||
# 10. Add an order to the front of the list, then remove it again (net-zero, so the
|
||||
# vehicle's route is left unchanged). Inserting before position 0 and deleting
|
||||
# position 0 needs no knowledge of the existing order count.
|
||||
print(f"--- Step 10: add then remove a 'go to station {DEMO_STATION_ID}' order ---")
|
||||
await client.add_order(vehicle_id, DEMO_STATION_ID, before_position=0)
|
||||
await asyncio.sleep(0.5)
|
||||
print(" -> inserted a goto-station order at position 0")
|
||||
await client.remove_order(vehicle_id, 0)
|
||||
await asyncio.sleep(0.5)
|
||||
print(" -> removed it again (route restored)")
|
||||
|
||||
# 11. Scheduled dispatch: create a schedule with two departure slots, enable it, then tear it
|
||||
# all down again so the vehicle is left as it started. Read it back with
|
||||
# OpenTTDAdminClient.get_dispatch() (see main_admin.py); the game port has no dispatch read.
|
||||
print("--- Step 11: scheduled dispatch create/enable, then clean up ---")
|
||||
await client.add_dispatch_schedule(vehicle_id, start_tick=0, duration=3000)
|
||||
await client.add_dispatch_slot(vehicle_id, 0, 500)
|
||||
await client.add_dispatch_slot(vehicle_id, 0, 1500)
|
||||
await client.set_scheduled_dispatch(vehicle_id, True)
|
||||
await asyncio.sleep(0.5)
|
||||
print(" -> created schedule 0 with 2 slots and enabled scheduled dispatch")
|
||||
await client.set_scheduled_dispatch(vehicle_id, False)
|
||||
await client.remove_dispatch_schedule(vehicle_id, 0)
|
||||
await asyncio.sleep(0.5)
|
||||
print(" -> disabled and removed the schedule (restored)")
|
||||
|
||||
print(f"=== Timetable demo finished. Final state for vehicle {vehicle_id}: ===")
|
||||
print(f" {client.get_vehicle_timetable(vehicle_id)}")
|
||||
|
||||
|
||||
@@ -49,7 +49,42 @@ async def run_admin():
|
||||
|
||||
print("--- Requesting vehicle info via GameScript ---")
|
||||
await admin.list_vehicles()
|
||||
|
||||
|
||||
# Capture station-list replies (delivered to on_gamescript, like list_vehicles) while
|
||||
# still logging every other GameScript message.
|
||||
stations = []
|
||||
def gamescript_capture(data):
|
||||
if isinstance(data, dict) and "stations" in data:
|
||||
stations.append(data["stations"])
|
||||
gamescript_logger(data)
|
||||
admin.on_gamescript = gamescript_capture
|
||||
|
||||
print("--- Requesting station info via GameScript ---")
|
||||
await admin.list_stations()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Fetch one station's authoritative live cargo (real-time waiting + planned).
|
||||
if stations and stations[-1]:
|
||||
sid = stations[-1][0]["id"]
|
||||
try:
|
||||
data = await admin.get_station(sid, timeout=10.0)
|
||||
print(f"--- Station {sid} ({data.get('name')}) cargo: real-time waiting vs planned ---")
|
||||
for cargo in data.get("cargo", []):
|
||||
print(f" cargo {cargo['cargo_id']}: waiting={cargo['waiting']} "
|
||||
f"planned={cargo['planned']} rating={cargo['rating']}")
|
||||
|
||||
# Break the first cargo down by source station and by next hop (routing destination).
|
||||
if data.get("cargo"):
|
||||
cid = data["cargo"][0]["cargo_id"]
|
||||
flow = await admin.get_station_cargo(sid, cid, timeout=10.0)
|
||||
print(f"--- Station {sid} cargo {cid} flow breakdown (station 65535 = none/deleted) ---")
|
||||
print(f" waiting by source: {flow['waiting_by_from']}")
|
||||
print(f" waiting by next hop: {flow['waiting_by_via']}")
|
||||
print(f" planned by source: {flow['planned_by_from']}")
|
||||
print(f" planned by next hop: {flow['planned_by_via']}")
|
||||
except Exception as e:
|
||||
print(f"!!! station query failed: {e}")
|
||||
|
||||
await asyncio.sleep(5)
|
||||
print("--- Quitting ---")
|
||||
await admin.quit()
|
||||
|
||||
@@ -46,6 +46,20 @@ async def test_admin_list_vehicles():
|
||||
assert decode_gamescript_payload(proto.sent[0]) == {"command": "list_vehicles"}
|
||||
assert decode_gamescript_payload(proto.sent[1]) == {"command": "list_vehicles", "company_id": 2}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_list_stations():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
proto = MockProtocol()
|
||||
client._protocol = proto
|
||||
client._transport = MockTransport()
|
||||
|
||||
await client.list_stations()
|
||||
await client.list_stations(company_id=2)
|
||||
|
||||
assert len(proto.sent) == 2
|
||||
assert decode_gamescript_payload(proto.sent[0]) == {"command": "list_stations"}
|
||||
assert decode_gamescript_payload(proto.sent[1]) == {"command": "list_stations", "company_id": 2}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_client_connect_and_actions(monkeypatch):
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
@@ -187,3 +201,240 @@ 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_get_station_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_station(3))
|
||||
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_station", "station_id": 3, "request_id": 1,
|
||||
}
|
||||
|
||||
response = {"command": "get_station", "station_id": 3, "request_id": 1,
|
||||
"name": "Test Central", "location": 12345, "owner": 0,
|
||||
"cargo": [{"cargo_id": 0, "waiting": 42, "planned": 17, "rating": 71}]}
|
||||
await client.receive_ServerGamescript(None, data=response)
|
||||
assert await task == response
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_station_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_station(65535))
|
||||
await asyncio.sleep(0)
|
||||
await client.receive_ServerGamescript(
|
||||
None, data={"command": "get_station", "station_id": 65535,
|
||||
"request_id": 1, "error": "invalid_station"})
|
||||
with pytest.raises(ValueError, match="invalid_station"):
|
||||
await task
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_station_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_station(3, timeout=0.05)
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_station_cargo_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_station_cargo(3, 0))
|
||||
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_station_cargo", "station_id": 3, "cargo_id": 0, "request_id": 1,
|
||||
}
|
||||
|
||||
response = {"command": "get_station_cargo", "station_id": 3, "cargo_id": 0, "request_id": 1,
|
||||
"waiting": 60, "planned": 40,
|
||||
"waiting_by_from": [{"station": 5, "amount": 25}, {"station": 6, "amount": 35}],
|
||||
"planned_by_from": [{"station": 5, "amount": 40}],
|
||||
"waiting_by_via": [{"station": 7, "amount": 60}],
|
||||
"planned_by_via": [{"station": 7, "amount": 40}]}
|
||||
await client.receive_ServerGamescript(None, data=response)
|
||||
assert await task == response
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_station_cargo_with_filters_encoding():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
proto = MockProtocol()
|
||||
client._protocol = proto
|
||||
client._transport = MockTransport()
|
||||
client._gs_subscribed = True # skip the auto-subscribe so only the query is sent
|
||||
|
||||
task = asyncio.ensure_future(client.get_station_cargo(3, 0, from_station=6, via_station=7))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert len(proto.sent) == 1
|
||||
assert decode_gamescript_payload(proto.sent[0]) == {
|
||||
"command": "get_station_cargo", "station_id": 3, "cargo_id": 0,
|
||||
"from_station": 6, "via_station": 7, "request_id": 1,
|
||||
}
|
||||
await client.receive_ServerGamescript(
|
||||
None, data={"request_id": 1, "waiting": 12, "planned": 8})
|
||||
assert (await task)["waiting"] == 12
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_station_cargo_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_station_cargo(3, 999))
|
||||
await asyncio.sleep(0)
|
||||
await client.receive_ServerGamescript(
|
||||
None, data={"command": "get_station_cargo", "station_id": 3, "cargo_id": 999,
|
||||
"request_id": 1, "error": "invalid_cargo"})
|
||||
with pytest.raises(ValueError, match="invalid_cargo"):
|
||||
await task
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_dispatch_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_dispatch(7))
|
||||
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_dispatch", "vehicle_id": 7, "request_id": 1,
|
||||
}
|
||||
|
||||
response = {"command": "get_dispatch", "vehicle_id": 7, "request_id": 1,
|
||||
"enabled": 1,
|
||||
"schedules": [{"index": 0, "duration": 3000, "start_tick": 0, "delay": 0,
|
||||
"reuse_slots": 0,
|
||||
"slots": [{"offset": 500, "flags": 0}, {"offset": 1500, "flags": 0}]}]}
|
||||
await client.receive_ServerGamescript(None, data=response)
|
||||
assert await task == response
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_dispatch_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_dispatch(65535))
|
||||
await asyncio.sleep(0)
|
||||
await client.receive_ServerGamescript(
|
||||
None, data={"command": "get_dispatch", "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_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"]
|
||||
|
||||
@@ -23,6 +23,8 @@ from openttd.protocol import (
|
||||
TIMETABLE_COMPANY_ID = 0
|
||||
TIMETABLE_VEHICLE_ID = 7
|
||||
TIMETABLE_ORDER_POSITION = 0
|
||||
# A station TIMETABLE_VEHICLE_ID can legally serve, used for add_order/remove_order tests.
|
||||
ORDER_STATION_ID = 6
|
||||
|
||||
|
||||
# --- Pytest Fixtures ---
|
||||
@@ -257,6 +259,118 @@ async def test_e2e_client_get_vehicle_timetable_unknown_vehicle(connected_owner_
|
||||
# Input 2: a vehicle id with no observed state
|
||||
assert connected_owner_client.get_vehicle_timetable(999999) is None
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_client_add_and_remove_order(connected_owner_client, connected_admin):
|
||||
# Public functions: add_order(), remove_order()
|
||||
# Verified authoritatively via the admin get_timetable() order count. The test appends and
|
||||
# inserts an order, then removes both, leaving the vehicle's order list as it started.
|
||||
async def order_count():
|
||||
data = await connected_admin.get_timetable(TIMETABLE_VEHICLE_ID, timeout=10.0)
|
||||
return len(data["orders"])
|
||||
|
||||
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
|
||||
before = await order_count()
|
||||
|
||||
# add_order input 1: append a goto-station order to the end of the list.
|
||||
await connected_owner_client.add_order(TIMETABLE_VEHICLE_ID, ORDER_STATION_ID)
|
||||
await asyncio.sleep(1.0)
|
||||
assert not connected_owner_client.shutdown_event.is_set()
|
||||
assert await order_count() == before + 1
|
||||
|
||||
# add_order input 2: insert another before position 0.
|
||||
await connected_owner_client.add_order(TIMETABLE_VEHICLE_ID, ORDER_STATION_ID, before_position=0)
|
||||
await asyncio.sleep(1.0)
|
||||
assert await order_count() == before + 2
|
||||
|
||||
# remove_order input 1: delete the one just inserted at the front.
|
||||
await connected_owner_client.remove_order(TIMETABLE_VEHICLE_ID, 0)
|
||||
await asyncio.sleep(1.0)
|
||||
assert await order_count() == before + 1
|
||||
|
||||
# remove_order input 2: delete the appended order (now the last one) to restore the list.
|
||||
await connected_owner_client.remove_order(TIMETABLE_VEHICLE_ID, before)
|
||||
await asyncio.sleep(1.0)
|
||||
assert not connected_owner_client.shutdown_event.is_set()
|
||||
assert await order_count() == before
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_client_scheduled_dispatch_edit_and_view(connected_owner_client, connected_admin):
|
||||
# Public functions: set_scheduled_dispatch(), add_dispatch_schedule(), remove_dispatch_schedule(),
|
||||
# add_dispatch_slot(), remove_dispatch_slot(), clear_dispatch_schedule(), set_dispatch_duration(),
|
||||
# set_dispatch_start_date(), and get_dispatch(). Edits go over the game port and are read back
|
||||
# authoritatively via the admin get_dispatch(). The test leaves the vehicle with no schedules.
|
||||
veh = TIMETABLE_VEHICLE_ID
|
||||
owner = connected_owner_client
|
||||
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
|
||||
|
||||
async def dispatch():
|
||||
return await connected_admin.get_dispatch(veh, timeout=10.0)
|
||||
|
||||
start = await dispatch() # get_dispatch input 1: a valid vehicle
|
||||
assert "schedules" in start and isinstance(start["schedules"], list)
|
||||
if start["schedules"]:
|
||||
pytest.skip("Test vehicle already has dispatch schedules; expected a clean vehicle.")
|
||||
|
||||
# add_dispatch_schedule: two schedules (indices 0 and 1) with different start ticks/durations.
|
||||
await owner.add_dispatch_schedule(veh, 0, 3000)
|
||||
await asyncio.sleep(0.5)
|
||||
await owner.add_dispatch_schedule(veh, 1000, 2000)
|
||||
await asyncio.sleep(0.5)
|
||||
assert not owner.shutdown_event.is_set()
|
||||
data = await dispatch()
|
||||
assert len(data["schedules"]) == 2
|
||||
assert data["schedules"][0]["duration"] == 3000
|
||||
assert data["schedules"][1]["duration"] == 2000
|
||||
|
||||
# set_dispatch_duration / set_dispatch_start_date: two inputs each (schedule 0 and 1).
|
||||
await owner.set_dispatch_duration(veh, 0, 4000)
|
||||
await owner.set_dispatch_duration(veh, 1, 2500)
|
||||
await owner.set_dispatch_start_date(veh, 0, 1_000_000)
|
||||
await owner.set_dispatch_start_date(veh, 1, 2_000_000)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# add_dispatch_slot: two departure slots in schedule 0.
|
||||
await owner.add_dispatch_slot(veh, 0, 500)
|
||||
await owner.add_dispatch_slot(veh, 0, 1500)
|
||||
await asyncio.sleep(0.5)
|
||||
data = await dispatch()
|
||||
sched0 = data["schedules"][0]
|
||||
assert sched0["duration"] == 4000
|
||||
# The engine normalises the start tick relative to current game time (advancing it by whole
|
||||
# durations to sit near "now"), so it won't equal the requested value verbatim; just confirm
|
||||
# a start date was accepted and is reported as an integer.
|
||||
assert isinstance(sched0["start_tick"], int)
|
||||
assert {s["offset"] for s in sched0["slots"]} == {500, 1500}
|
||||
|
||||
# remove_dispatch_slot: two inputs (both slots of schedule 0).
|
||||
await owner.remove_dispatch_slot(veh, 0, 1500)
|
||||
await owner.remove_dispatch_slot(veh, 0, 500)
|
||||
await asyncio.sleep(0.5)
|
||||
assert (await dispatch())["schedules"][0]["slots"] == []
|
||||
|
||||
# set_scheduled_dispatch: enable then disable (two inputs), reading the flag back in between.
|
||||
await owner.set_scheduled_dispatch(veh, True)
|
||||
await asyncio.sleep(0.5)
|
||||
assert (await dispatch())["enabled"] == 1
|
||||
await owner.set_scheduled_dispatch(veh, False)
|
||||
await asyncio.sleep(0.5)
|
||||
assert (await dispatch())["enabled"] == 0
|
||||
|
||||
# clear_dispatch_schedule: two inputs (schedule 0 and 1).
|
||||
await owner.clear_dispatch_schedule(veh, 0)
|
||||
await owner.clear_dispatch_schedule(veh, 1)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# remove_dispatch_schedule: remove both (higher index first) to restore the vehicle.
|
||||
await owner.remove_dispatch_schedule(veh, 1)
|
||||
await asyncio.sleep(0.5)
|
||||
await owner.remove_dispatch_schedule(veh, 0)
|
||||
await asyncio.sleep(0.5)
|
||||
assert not owner.shutdown_event.is_set()
|
||||
assert (await dispatch())["schedules"] == [] # get_dispatch input 1 (restored state)
|
||||
|
||||
|
||||
# --- Admin Client Public Functions ---
|
||||
|
||||
@@ -429,6 +543,176 @@ 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)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_get_dispatch_invalid_vehicle(connected_admin):
|
||||
# Public function: get_dispatch()
|
||||
# Input 2: an id no vehicle can have -> GameScript reports invalid_vehicle
|
||||
with pytest.raises(ValueError, match="invalid_vehicle"):
|
||||
await connected_admin.get_dispatch(65535, timeout=10.0)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_list_stations_all_companies(connected_admin):
|
||||
# Public function: list_stations()
|
||||
# Input 1: all companies (no company_id)
|
||||
responses = []
|
||||
connected_admin.on_gamescript = lambda data: responses.append(data)
|
||||
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
|
||||
|
||||
await connected_admin.list_stations()
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
assert not connected_admin.shutdown_event.is_set()
|
||||
assert len(responses) >= 1
|
||||
assert "stations" in responses[-1]
|
||||
assert isinstance(responses[-1]["stations"], list)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_list_stations_specific_company(connected_admin):
|
||||
# Public function: list_stations()
|
||||
# Input 2: specific company_id
|
||||
responses = []
|
||||
connected_admin.on_gamescript = lambda data: responses.append(data)
|
||||
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
|
||||
|
||||
await connected_admin.list_stations(company_id=0)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
assert not connected_admin.shutdown_event.is_set()
|
||||
assert len(responses) >= 1
|
||||
assert "stations" in responses[-1]
|
||||
assert isinstance(responses[-1]["stations"], list)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_get_station_valid_station(connected_admin):
|
||||
# Public function: get_station()
|
||||
# Input 1: a real station id discovered via list_stations
|
||||
responses = []
|
||||
connected_admin.on_gamescript = lambda data: responses.append(data)
|
||||
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
|
||||
await connected_admin.list_stations()
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
assert len(responses) >= 1 and "stations" in responses[-1]
|
||||
stations = responses[-1]["stations"]
|
||||
if not stations:
|
||||
pytest.skip("No stations on the test server to query.")
|
||||
sid = stations[0]["id"]
|
||||
|
||||
data = await connected_admin.get_station(sid, timeout=10.0)
|
||||
assert data["station_id"] == sid
|
||||
assert "cargo" in data
|
||||
assert isinstance(data["cargo"], list)
|
||||
for cargo in data["cargo"]:
|
||||
for key in ("cargo_id", "waiting", "planned", "rating"):
|
||||
assert key in cargo
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_get_station_invalid_station(connected_admin):
|
||||
# Public function: get_station()
|
||||
# Input 2: an id no station can have -> GameScript reports invalid_station
|
||||
with pytest.raises(ValueError, match="invalid_station"):
|
||||
await connected_admin.get_station(65535, timeout=10.0)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_get_station_cargo_breakdown(connected_admin):
|
||||
# Public function: get_station_cargo()
|
||||
# Input 1: a real station + a cargo it has handled, discovered via list_stations/get_station
|
||||
responses = []
|
||||
connected_admin.on_gamescript = lambda data: responses.append(data)
|
||||
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
|
||||
await connected_admin.list_stations()
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
assert responses and "stations" in responses[-1]
|
||||
stations = responses[-1]["stations"]
|
||||
if not stations:
|
||||
pytest.skip("No stations on the test server to query.")
|
||||
|
||||
# Find a station/cargo pair that actually has cargo data.
|
||||
target = None
|
||||
for st in stations:
|
||||
detail = await connected_admin.get_station(st["id"], timeout=10.0)
|
||||
if detail["cargo"]:
|
||||
target = (st["id"], detail["cargo"][0]["cargo_id"])
|
||||
break
|
||||
if target is None:
|
||||
pytest.skip("No station with handled cargo to break down.")
|
||||
sid, cid = target
|
||||
|
||||
data = await connected_admin.get_station_cargo(sid, cid, timeout=10.0)
|
||||
assert data["station_id"] == sid and data["cargo_id"] == cid
|
||||
for key in ("waiting", "planned",
|
||||
"waiting_by_from", "planned_by_from", "waiting_by_via", "planned_by_via"):
|
||||
assert key in data
|
||||
for key in ("waiting_by_from", "planned_by_from", "waiting_by_via", "planned_by_via"):
|
||||
assert isinstance(data[key], list)
|
||||
for entry in data[key]:
|
||||
assert "station" in entry and "amount" in entry
|
||||
|
||||
# Input 2: the same query narrowed by a next-hop (via) filter is accepted and echoes it back.
|
||||
filtered = await connected_admin.get_station_cargo(sid, cid, via_station=sid, timeout=10.0)
|
||||
assert filtered["via_station"] == sid
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_get_station_cargo_invalid_cargo(connected_admin):
|
||||
# Public function: get_station_cargo()
|
||||
# A cargo id no cargo can have -> GameScript reports invalid_cargo. Needs a valid station.
|
||||
responses = []
|
||||
connected_admin.on_gamescript = lambda data: responses.append(data)
|
||||
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
|
||||
await connected_admin.list_stations()
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
assert responses and "stations" in responses[-1]
|
||||
stations = responses[-1]["stations"]
|
||||
if not stations:
|
||||
pytest.skip("No stations on the test server to query.")
|
||||
|
||||
with pytest.raises(ValueError, match="invalid_cargo"):
|
||||
await connected_admin.get_station_cargo(stations[0]["id"], 250, timeout=10.0)
|
||||
|
||||
|
||||
# --- Protocol Public Functions ---
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import pytest
|
||||
from openttd import OpenTTDClient
|
||||
from openttd.protocol import (
|
||||
OpenTTDProtocol, GameCommand, ModifyTimetableFlags, ModifyTimetableCtrlFlag,
|
||||
OrderType, OrderNonStopFlags, OrderStopLocation, INVALID_VEH_ORDER_ID,
|
||||
write_varuint, read_varuint, write_varuint_signed, read_varuint_signed
|
||||
)
|
||||
from openttd_protocol.wire.read import read_uint8, read_uint16
|
||||
@@ -180,6 +181,155 @@ async def test_client_set_vehicle_on_time_sends_expected_payload():
|
||||
assert (vehicle_id, apply_to_group) == (7, 1)
|
||||
|
||||
|
||||
# --- OpenTTDClient order add/remove commands ---
|
||||
|
||||
def _decode_insert_order_payload(payload):
|
||||
vehicle_id, rest = read_varuint(payload)
|
||||
sel_ord, rest = read_uint16(rest)
|
||||
order_type, rest = read_uint8(rest)
|
||||
order_flags, rest = read_uint16(rest)
|
||||
station, _ = read_uint16(rest)
|
||||
return vehicle_id, sel_ord, order_type, order_flags, station
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_add_order_appends_by_default():
|
||||
client = new_client()
|
||||
await client.add_order(7, 6)
|
||||
assert len(client._protocol.sent) == 1
|
||||
parsed = decode_sent_command(client._protocol.sent[0])
|
||||
assert parsed["cmd"] == GameCommand.InsertOrder
|
||||
assert parsed["company"] == 0
|
||||
vehicle_id, sel_ord, order_type, order_flags, station = _decode_insert_order_payload(parsed["payload"])
|
||||
assert vehicle_id == 7
|
||||
assert sel_ord == INVALID_VEH_ORDER_ID # append to the end
|
||||
# OT_GOTO_STATION in bits 0-3, far-end stop location in bits 4-5, stop-everywhere non-stop in 6-7
|
||||
assert order_type == (OrderType.GotoStation | (OrderStopLocation.PlatformFarEnd << 4))
|
||||
assert order_flags == 0
|
||||
assert station == 6
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_add_order_insert_position_and_nonstop():
|
||||
client = new_client()
|
||||
await client.add_order(7, 6, before_position=1, non_stop=OrderNonStopFlags.NoStopAtIntermediate)
|
||||
parsed = decode_sent_command(client._protocol.sent[0])
|
||||
vehicle_id, sel_ord, order_type, order_flags, station = _decode_insert_order_payload(parsed["payload"])
|
||||
assert sel_ord == 1 # insert before order position 1
|
||||
assert order_type == (OrderType.GotoStation
|
||||
| (OrderStopLocation.PlatformFarEnd << 4)
|
||||
| (OrderNonStopFlags.NoStopAtIntermediate << 6))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_remove_order_sends_expected_payload():
|
||||
client = new_client()
|
||||
await client.remove_order(7, 2)
|
||||
parsed = decode_sent_command(client._protocol.sent[0])
|
||||
assert parsed["cmd"] == GameCommand.DeleteOrder
|
||||
vehicle_id, rest = read_varuint(parsed["payload"])
|
||||
order_position, _ = read_uint16(rest)
|
||||
assert (vehicle_id, order_position) == (7, 2)
|
||||
|
||||
|
||||
# --- OpenTTDClient scheduled dispatch edit commands ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_set_scheduled_dispatch_payload():
|
||||
client = new_client()
|
||||
await client.set_scheduled_dispatch(7, True)
|
||||
parsed = decode_sent_command(client._protocol.sent[0])
|
||||
assert parsed["cmd"] == GameCommand.SchDispatch
|
||||
vehicle_id, rest = read_varuint(parsed["payload"])
|
||||
enabled, _ = read_uint8(rest)
|
||||
assert (vehicle_id, enabled) == (7, 1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_add_dispatch_schedule_payload():
|
||||
client = new_client()
|
||||
await client.add_dispatch_schedule(7, -1234, 3000)
|
||||
parsed = decode_sent_command(client._protocol.sent[0])
|
||||
assert parsed["cmd"] == GameCommand.SchDispatchAddNewSchedule
|
||||
vehicle_id, rest = read_varuint(parsed["payload"])
|
||||
start_tick, rest = read_varuint_signed(rest)
|
||||
duration, _ = read_varuint(rest)
|
||||
assert (vehicle_id, start_tick, duration) == (7, -1234, 3000)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_remove_dispatch_schedule_payload():
|
||||
client = new_client()
|
||||
await client.remove_dispatch_schedule(7, 2)
|
||||
parsed = decode_sent_command(client._protocol.sent[0])
|
||||
assert parsed["cmd"] == GameCommand.SchDispatchRemoveSchedule
|
||||
vehicle_id, rest = read_varuint(parsed["payload"])
|
||||
schedule_index, _ = read_varuint(rest)
|
||||
assert (vehicle_id, schedule_index) == (7, 2)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_add_dispatch_slot_payload_defaults_and_extras():
|
||||
client = new_client()
|
||||
# Defaults: single slot, no interval/extra/flags/route.
|
||||
await client.add_dispatch_slot(7, 1, 500)
|
||||
# Bulk: three extra slots spaced 250 ticks apart, with flags and route id.
|
||||
await client.add_dispatch_slot(7, 1, 500, interval=250, extra_slots=3, slot_flags=5, route_id=2)
|
||||
|
||||
def decode(payload):
|
||||
vehicle_id, rest = read_varuint(payload)
|
||||
schedule_index, rest = read_varuint(rest)
|
||||
offset, rest = read_varuint(rest)
|
||||
interval, rest = read_varuint(rest)
|
||||
extra_slots, rest = read_varuint(rest)
|
||||
slot_flags, rest = read_uint16(rest)
|
||||
route_id, _ = read_uint8(rest)
|
||||
return (vehicle_id, schedule_index, offset, interval, extra_slots, slot_flags, route_id)
|
||||
|
||||
p0 = decode_sent_command(client._protocol.sent[0])
|
||||
p1 = decode_sent_command(client._protocol.sent[1])
|
||||
assert p0["cmd"] == GameCommand.SchDispatchAdd
|
||||
assert decode(p0["payload"]) == (7, 1, 500, 0, 0, 0, 0)
|
||||
assert decode(p1["payload"]) == (7, 1, 500, 250, 3, 5, 2)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_remove_dispatch_slot_payload():
|
||||
client = new_client()
|
||||
await client.remove_dispatch_slot(7, 1, 500)
|
||||
parsed = decode_sent_command(client._protocol.sent[0])
|
||||
assert parsed["cmd"] == GameCommand.SchDispatchRemove
|
||||
vehicle_id, rest = read_varuint(parsed["payload"])
|
||||
schedule_index, rest = read_varuint(rest)
|
||||
offset, _ = read_varuint(rest)
|
||||
assert (vehicle_id, schedule_index, offset) == (7, 1, 500)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_clear_dispatch_schedule_payload():
|
||||
client = new_client()
|
||||
await client.clear_dispatch_schedule(7, 1)
|
||||
parsed = decode_sent_command(client._protocol.sent[0])
|
||||
assert parsed["cmd"] == GameCommand.SchDispatchClear
|
||||
vehicle_id, rest = read_varuint(parsed["payload"])
|
||||
schedule_index, _ = read_varuint(rest)
|
||||
assert (vehicle_id, schedule_index) == (7, 1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_set_dispatch_duration_payload():
|
||||
client = new_client()
|
||||
await client.set_dispatch_duration(7, 1, 4000)
|
||||
parsed = decode_sent_command(client._protocol.sent[0])
|
||||
assert parsed["cmd"] == GameCommand.SchDispatchSetDuration
|
||||
vehicle_id, rest = read_varuint(parsed["payload"])
|
||||
schedule_index, rest = read_varuint(rest)
|
||||
duration, _ = read_varuint(rest)
|
||||
assert (vehicle_id, schedule_index, duration) == (7, 1, 4000)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_set_dispatch_start_date_payload():
|
||||
client = new_client()
|
||||
await client.set_dispatch_start_date(7, 1, 1_000_000)
|
||||
parsed = decode_sent_command(client._protocol.sent[0])
|
||||
assert parsed["cmd"] == GameCommand.SchDispatchSetStartDate
|
||||
vehicle_id, rest = read_varuint(parsed["payload"])
|
||||
schedule_index, rest = read_varuint(rest)
|
||||
start_tick, _ = read_varuint_signed(rest)
|
||||
assert (vehicle_id, schedule_index, start_tick) == (7, 1, 1_000_000)
|
||||
|
||||
|
||||
# --- OpenTTDClient.receive_ServerCommand dispatch ---
|
||||
|
||||
async def feed_command(client, cmd, payload):
|
||||
|
||||
Reference in New Issue
Block a user