From 2eea541158a4556a5206dc3ce1ea4b525b1517bd Mon Sep 17 00:00:00 2001 From: kovagoadi Date: Fri, 24 Jul 2026 22:56:37 +0200 Subject: [PATCH] Add scheduled dispatch support (edit + authoritative view) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 2 + ...PI-scheduled-dispatch-getters-to-Scr.patch | 195 ++++++++++++++++++ docker/patches/README.md | 8 + docs/PROTOCOL.md | 34 ++- docs/TIMETABLES.md | 70 ++++++- lib/openttd/client.py | 143 +++++++++++++ lib/openttd/protocol.py | 35 ++++ main.py | 28 +++ tests/test_admin.py | 41 ++++ tests/test_e2e.py | 122 +++++++++++ tests/test_timetable.py | 150 ++++++++++++++ 11 files changed, 822 insertions(+), 6 deletions(-) create mode 100644 docker/patches/0002-Add-GameScript-API-scheduled-dispatch-getters-to-Scr.patch diff --git a/README.md b/README.md index 22ea7c9..8772783 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docker/patches/0002-Add-GameScript-API-scheduled-dispatch-getters-to-Scr.patch b/docker/patches/0002-Add-GameScript-API-scheduled-dispatch-getters-to-Scr.patch new file mode 100644 index 0000000..3c32005 --- /dev/null +++ b/docker/patches/0002-Add-GameScript-API-scheduled-dispatch-getters-to-Scr.patch @@ -0,0 +1,195 @@ +From 1e4bdcca84e956ece32d2d77dc8001bfd1d8e2f8 Mon Sep 17 00:00:00 2001 +From: kovagoadi +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 +--- + 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(schedule_index) >= v->orders->GetScheduledDispatchScheduleCount()) return nullptr; ++ return &v->orders->GetDispatchScheduleByIndex(static_cast(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 &slots = ds->GetScheduledDispatch(); ++ if (slot_index < 0 || static_cast(slot_index) >= slots.size()) return -1; ++ return slots[static_cast(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 &slots = ds->GetScheduledDispatch(); ++ if (slot_index < 0 || static_cast(slot_index) >= slots.size()) return -1; ++ return slots[static_cast(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 + diff --git a/docker/patches/README.md b/docker/patches/README.md index 5c59d7b..38ea6bc 100644 --- a/docker/patches/README.md +++ b/docker/patches/README.md @@ -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 diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 5170dd5..c71358d 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -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). diff --git a/docs/TIMETABLES.md b/docs/TIMETABLES.md index 3d73e05..6adea3e 100644 --- a/docs/TIMETABLES.md +++ b/docs/TIMETABLES.md @@ -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. diff --git a/lib/openttd/client.py b/lib/openttd/client.py index 5e7271f..6687597 100644 --- a/lib/openttd/client.py +++ b/lib/openttd/client.py @@ -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 diff --git a/lib/openttd/protocol.py b/lib/openttd/protocol.py index 9e0d6af..7449547 100644 --- a/lib/openttd/protocol.py +++ b/lib/openttd/protocol.py @@ -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 diff --git a/main.py b/main.py index d53302a..aac3c8d 100644 --- a/main.py +++ b/main.py @@ -21,6 +21,8 @@ COMPANY_PASSWORD = "asd123" # A vehicle owned by COMPANY_ID, used to demonstrate timetable get/set below. Set to a real # vehicle id to see it in action; leave as None to skip the demonstration. DEMO_VEHICLE_ID = 7 +# A station DEMO_VEHICLE_ID can legally serve, used to demonstrate add_order/remove_order. +DEMO_STATION_ID = 6 async def demo_timetable_workflow(client, vehicle_id): """A deliberately thorough walk-through of the timetable API: every ModifyTimetableFlags @@ -104,6 +106,32 @@ async def demo_timetable_workflow(client, vehicle_id): await asyncio.sleep(0.5) show("lateness reset (whole group)") + # 10. Add an order to the front of the list, then remove it again (net-zero, so the + # vehicle's route is left unchanged). Inserting before position 0 and deleting + # position 0 needs no knowledge of the existing order count. + print(f"--- Step 10: add then remove a 'go to station {DEMO_STATION_ID}' order ---") + await client.add_order(vehicle_id, DEMO_STATION_ID, before_position=0) + await asyncio.sleep(0.5) + print(" -> inserted a goto-station order at position 0") + await client.remove_order(vehicle_id, 0) + await asyncio.sleep(0.5) + print(" -> removed it again (route restored)") + + # 11. Scheduled dispatch: create a schedule with two departure slots, enable it, then tear it + # all down again so the vehicle is left as it started. Read it back with + # OpenTTDAdminClient.get_dispatch() (see main_admin.py); the game port has no dispatch read. + print("--- Step 11: scheduled dispatch create/enable, then clean up ---") + await client.add_dispatch_schedule(vehicle_id, start_tick=0, duration=3000) + await client.add_dispatch_slot(vehicle_id, 0, 500) + await client.add_dispatch_slot(vehicle_id, 0, 1500) + await client.set_scheduled_dispatch(vehicle_id, True) + await asyncio.sleep(0.5) + print(" -> created schedule 0 with 2 slots and enabled scheduled dispatch") + await client.set_scheduled_dispatch(vehicle_id, False) + await client.remove_dispatch_schedule(vehicle_id, 0) + await asyncio.sleep(0.5) + print(" -> disabled and removed the schedule (restored)") + print(f"=== Timetable demo finished. Final state for vehicle {vehicle_id}: ===") print(f" {client.get_vehicle_timetable(vehicle_id)}") diff --git a/tests/test_admin.py b/tests/test_admin.py index 64af09d..92318be 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -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") diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 85cffd0..e37ebbe 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -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): diff --git a/tests/test_timetable.py b/tests/test_timetable.py index 7c75d99..e04e25b 100644 --- a/tests/test_timetable.py +++ b/tests/test_timetable.py @@ -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):