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