Add scheduled dispatch support (edit + authoritative view)
Editing (game port, OpenTTDClient): a core of JGRPP's scheduled dispatch DoCommands — set_scheduled_dispatch (enable/disable), add/remove schedule, add/remove/clear slots, and set duration/start date. Adds the command IDs to protocol.py. Viewing (admin, OpenTTDAdminClient.get_dispatch): the GameScript API has no dispatch support, so a new server patch (docker/patches/0002-*) adds read-only GSOrder.GetScheduledDispatch* / IsScheduledDispatchEnabled getters, an AdminBridge GameScript get_dispatch handler exposes them, and get_dispatch() returns the live schedules and slots (mirrors get_timetable). Note: set_dispatch_start_date values are normalised by the engine relative to current game time, so they read back offset from the requested value. Includes unit + e2e tests, a demo in main.py, and protocol/timetable docs. The AdminBridge GameScript and the patched OpenTTD-patches clone live outside this repo; the 0002 patch file is the durable source for the latter. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,8 @@ A high-performance, Object-Oriented Python client for OpenTTD servers, specifica
|
|||||||
- **Comprehensive Testing:** Robustly tested with unit, logic, and E2E tests (including 100% coverage for unit/logic tests).
|
- **Comprehensive Testing:** Robustly tested with unit, logic, and E2E tests (including 100% coverage for unit/logic tests).
|
||||||
- **Vehicle Listing:** Query vehicle data via the Admin GameScript channel with `list_vehicles()`.
|
- **Vehicle Listing:** Query vehicle data via the Admin GameScript channel with `list_vehicles()`.
|
||||||
- **Vehicle Timetables:** Read and modify a vehicle's timetable (`change_timetable()`, `autofill_timetable()`, `set_timetable_start()`, `set_vehicle_on_time()`, `get_vehicle_timetable()`) via real game-protocol commands.
|
- **Vehicle Timetables:** Read and modify a vehicle's timetable (`change_timetable()`, `autofill_timetable()`, `set_timetable_start()`, `set_vehicle_on_time()`, `get_vehicle_timetable()`) via real game-protocol commands.
|
||||||
|
- **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.
|
- **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 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).
|
- **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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -15,6 +15,14 @@ Current patches:
|
|||||||
GameScript's `get_timetable` command and the Python client's
|
GameScript's `get_timetable` command and the Python client's
|
||||||
`OpenTTDAdminClient.get_timetable()`.
|
`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
|
## Applying after a fresh clone
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -62,24 +62,48 @@ Correlation, the `update_frequency` subscription requirement (auto-subscribed on
|
|||||||
- **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`.
|
- **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.
|
- **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.
|
||||||
|
|
||||||
## Vehicle Timetables (Game Port DoCommands)
|
### Dispatch Query
|
||||||
Unlike vehicle listing, timetables have no stock GameScript API surface (this project adds read-only getters via a server patch — see "Timetable Query" above; writing still has none). Reading and modifying them requires real engine commands (`DoCommand`s) sent over the **game port** (TCP 3979) via `ClientCommand`/`ServerCommand` packets, not the Admin Network. This section covers the wire format; for how to call the methods and what each parameter means, see the [Vehicle Timetables Usage Guide](TIMETABLES.md).
|
`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
|
### Command envelope
|
||||||
Both `ClientCommand` and `ServerCommand` share this body: `company (uint8)`, `cmd (uint16 LE, index into the `Commands` enum)`, `error_msg (uint16 LE, StringID, use 0)`, `tile (uint32 LE, always 0 for these commands)`, `payload_len (uint16 LE)`, `payload (payload_len bytes)`, `callback (uint8, use 0)`, `callback_param (uint32 LE, only present if callback != 0)`. `ServerCommand` additionally appends `frame (uint32 LE)` and `my_cmd (uint8 bool)`, and is a **broadcast echo of the request** (no success/failure code) sent to every joined client, not just the sender.
|
Both `ClientCommand` and `ServerCommand` share this body: `company (uint8)`, `cmd (uint16 LE, index into the `Commands` enum)`, `error_msg (uint16 LE, StringID, use 0)`, `tile (uint32 LE, always 0 for these commands)`, `payload_len (uint16 LE)`, `payload (payload_len bytes)`, `callback (uint8, use 0)`, `callback_param (uint32 LE, only present if callback != 0)`. `ServerCommand` additionally appends `frame (uint32 LE)` and `my_cmd (uint8 bool)`, and is a **broadcast echo of the request** (no success/failure code) sent to every joined client, not just the sender.
|
||||||
|
|
||||||
### 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 |
|
| 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)` |
|
| `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)` |
|
| `set_vehicle_on_time()` | 176 (`SetVehicleOnTime`) | `VehicleID (varuint), apply_to_group (uint8 bool)` |
|
||||||
| `autofill_timetable()` | 177 (`AutofillTimetable`) | `VehicleID (varuint), bool (uint8), bool (uint8)` |
|
| `autofill_timetable()` | 177 (`AutofillTimetable`) | `VehicleID (varuint), bool (uint8), bool (uint8)` |
|
||||||
| `set_timetable_start()` | 180 (`SetTimetableStart`) | `VehicleID (varuint), bool (uint8), StateTicks (signed varuint)` |
|
| `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
|
### 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 on the game port
|
### 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).
|
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).
|
||||||
|
|||||||
@@ -336,6 +336,74 @@ async def main():
|
|||||||
asyncio.run(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
|
## 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.
|
- [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 (
|
from .protocol import (
|
||||||
PacketGameType, OpenTTDProtocol, PacketAdminType, OpenTTDAdminProtocol, NetworkAuthenticationMethod,
|
PacketGameType, OpenTTDProtocol, PacketAdminType, OpenTTDAdminProtocol, NetworkAuthenticationMethod,
|
||||||
GameCommand, ModifyTimetableFlags, ModifyTimetableCtrlFlag,
|
GameCommand, ModifyTimetableFlags, ModifyTimetableCtrlFlag,
|
||||||
|
OrderType, OrderStopLocation, INVALID_VEH_ORDER_ID,
|
||||||
write_varuint, read_varuint, write_varuint_signed, read_varuint_signed
|
write_varuint, read_varuint, write_varuint_signed, read_varuint_signed
|
||||||
)
|
)
|
||||||
from .decorators import exclude_call_check
|
from .decorators import exclude_call_check
|
||||||
@@ -130,6 +131,124 @@ class OpenTTDClient:
|
|||||||
"""
|
"""
|
||||||
return self.vehicle_timetables.get(vehicle_id)
|
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):
|
def disconnect(self, source):
|
||||||
"""Library callback for when connection is lost."""
|
"""Library callback for when connection is lost."""
|
||||||
self.log.info("Disconnected.")
|
self.log.info("Disconnected.")
|
||||||
@@ -578,6 +697,30 @@ class OpenTTDAdminClient:
|
|||||||
payload["via_station"] = via_station
|
payload["via_station"] = via_station
|
||||||
return await self._gs_query(payload, timeout, f"get_station_cargo({station_id}, {cargo_id})")
|
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):
|
async def send_gamescript(self, json_data):
|
||||||
"""Send a JSON string to the GameScript."""
|
"""Send a JSON string to the GameScript."""
|
||||||
import json
|
import json
|
||||||
|
|||||||
@@ -48,10 +48,45 @@ def read_varuint_signed(data):
|
|||||||
return value, rest
|
return value, rest
|
||||||
|
|
||||||
class GameCommand(IntEnum):
|
class GameCommand(IntEnum):
|
||||||
|
DeleteOrder = 51
|
||||||
|
InsertOrder = 52
|
||||||
ChangeTimetable = 174
|
ChangeTimetable = 174
|
||||||
SetVehicleOnTime = 176
|
SetVehicleOnTime = 176
|
||||||
AutofillTimetable = 177
|
AutofillTimetable = 177
|
||||||
SetTimetableStart = 180
|
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):
|
class ModifyTimetableFlags(IntEnum):
|
||||||
WaitTime = 0
|
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
|
# 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.
|
# vehicle id to see it in action; leave as None to skip the demonstration.
|
||||||
DEMO_VEHICLE_ID = 7
|
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):
|
async def demo_timetable_workflow(client, vehicle_id):
|
||||||
"""A deliberately thorough walk-through of the timetable API: every ModifyTimetableFlags
|
"""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)
|
await asyncio.sleep(0.5)
|
||||||
show("lateness reset (whole group)")
|
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"=== Timetable demo finished. Final state for vehicle {vehicle_id}: ===")
|
||||||
print(f" {client.get_vehicle_timetable(vehicle_id)}")
|
print(f" {client.get_vehicle_timetable(vehicle_id)}")
|
||||||
|
|
||||||
|
|||||||
@@ -383,6 +383,47 @@ async def test_admin_get_station_cargo_error_response():
|
|||||||
await task
|
await task
|
||||||
assert client._gs_futures == {}
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_admin_gamescript_passthrough_unmatched():
|
async def test_admin_gamescript_passthrough_unmatched():
|
||||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ from openttd.protocol import (
|
|||||||
TIMETABLE_COMPANY_ID = 0
|
TIMETABLE_COMPANY_ID = 0
|
||||||
TIMETABLE_VEHICLE_ID = 7
|
TIMETABLE_VEHICLE_ID = 7
|
||||||
TIMETABLE_ORDER_POSITION = 0
|
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 ---
|
# --- 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
|
# Input 2: a vehicle id with no observed state
|
||||||
assert connected_owner_client.get_vehicle_timetable(999999) is None
|
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 ---
|
# --- Admin Client Public Functions ---
|
||||||
|
|
||||||
@@ -464,6 +578,14 @@ async def test_e2e_admin_get_timetable_invalid_vehicle(connected_admin):
|
|||||||
with pytest.raises(ValueError, match="invalid_vehicle"):
|
with pytest.raises(ValueError, match="invalid_vehicle"):
|
||||||
await connected_admin.get_timetable(65535, timeout=10.0)
|
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.e2e
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_e2e_admin_list_stations_all_companies(connected_admin):
|
async def test_e2e_admin_list_stations_all_companies(connected_admin):
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import pytest
|
|||||||
from openttd import OpenTTDClient
|
from openttd import OpenTTDClient
|
||||||
from openttd.protocol import (
|
from openttd.protocol import (
|
||||||
OpenTTDProtocol, GameCommand, ModifyTimetableFlags, ModifyTimetableCtrlFlag,
|
OpenTTDProtocol, GameCommand, ModifyTimetableFlags, ModifyTimetableCtrlFlag,
|
||||||
|
OrderType, OrderNonStopFlags, OrderStopLocation, INVALID_VEH_ORDER_ID,
|
||||||
write_varuint, read_varuint, write_varuint_signed, read_varuint_signed
|
write_varuint, read_varuint, write_varuint_signed, read_varuint_signed
|
||||||
)
|
)
|
||||||
from openttd_protocol.wire.read import read_uint8, read_uint16
|
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)
|
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 ---
|
# --- OpenTTDClient.receive_ServerCommand dispatch ---
|
||||||
|
|
||||||
async def feed_command(client, cmd, payload):
|
async def feed_command(client, cmd, payload):
|
||||||
|
|||||||
Reference in New Issue
Block a user