Add scheduled dispatch support (edit + authoritative view) #22

Merged
kovagoadi merged 2 commits from claude/station-realtime-planned-data-0fc51a into main 2026-07-24 23:00:15 +02:00
11 changed files with 822 additions and 6 deletions
Showing only changes of commit 2eea541158 - Show all commits

View File

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

View File

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

View File

@@ -15,6 +15,14 @@ Current patches:
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

View File

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

View File

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

View File

@@ -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.")
@@ -578,6 +697,30 @@ class OpenTTDAdminClient:
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

View File

@@ -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
View File

@@ -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)}")

View File

@@ -383,6 +383,47 @@ async def test_admin_get_station_cargo_error_response():
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")

View File

@@ -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 ---
@@ -464,6 +578,14 @@ async def test_e2e_admin_get_timetable_invalid_vehicle(connected_admin):
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):

View File

@@ -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):