Compare commits
55
Commits
fc30def3e7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24a244a07e | ||
|
|
e4dd22157b | ||
|
|
7335d9b954 | ||
|
|
355bef9d25 | ||
|
|
b2fa14b7ff | ||
|
|
f357653bbb | ||
|
|
f9eeae39ed | ||
|
|
aa9eda4b6d | ||
|
|
f7ca395a4f | ||
|
|
0641d25858 | ||
|
|
ba26b59c40 | ||
|
|
90a07392cf | ||
|
|
d87c779d94 | ||
|
|
28f247799e | ||
|
|
6734c13963 | ||
|
|
54f8a06862 | ||
|
|
3641bf383e | ||
|
|
db7fb8e09a | ||
|
|
25953bea06 | ||
|
|
c5876b270a | ||
|
|
1a2ea538e8 | ||
|
|
8e352ba248 | ||
|
|
7fdd5f2fca | ||
|
|
a9f5b2d5d9 | ||
|
|
e925659e28 | ||
|
|
846d085e1a | ||
|
|
f0ef4148b0 | ||
|
|
67e886f8d2 | ||
|
|
2eea541158 | ||
|
|
af1a865fd6 | ||
|
|
81a4d9333d | ||
|
|
c39f970ef9 | ||
|
|
36fc118da3 | ||
|
|
3b54a722d6 | ||
|
|
38cc5ae40c | ||
|
|
dfb88523f1 | ||
|
|
5db0dab9bf | ||
|
|
71bef5a8a2 | ||
|
|
df9c7b3f06 | ||
|
|
954663e80c | ||
|
|
6a28e4acff | ||
|
|
b33869334a | ||
|
|
fa1741f95b | ||
|
|
0a8271d57c | ||
|
|
309f1da762 | ||
|
|
2526b8aec4 | ||
|
|
38e0604cf3 | ||
|
|
d87f26a8ef | ||
|
|
d0c4d9218d | ||
|
|
ab8ab7390e | ||
|
|
eb507b9f11 | ||
|
|
2d5e57998b | ||
|
|
aea5cf124c | ||
|
|
5d5329d197 | ||
|
|
17d68fa984 |
@@ -10,9 +10,9 @@ jobs:
|
||||
lint-and-security:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
@@ -32,10 +32,10 @@ jobs:
|
||||
tests-and-coverage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
venv
|
||||
__pycache__
|
||||
docker/config
|
||||
.coverage
|
||||
.pytest_cache
|
||||
@@ -9,6 +9,17 @@ A high-performance, Object-Oriented Python client for OpenTTD servers, specifica
|
||||
- **Modular Design:** Separates low-level binary protocol handling from high-level game logic.
|
||||
- **State Management:** Handles the full join sequence including Map download and synchronization.
|
||||
- **Comprehensive Testing:** Robustly tested with unit, logic, and E2E tests (including 100% coverage for unit/logic tests).
|
||||
- **Vehicle Listing:** Query vehicle data via the Admin GameScript channel with `list_vehicles()`.
|
||||
- **Vehicle Timetables:** Read and modify a vehicle's timetable (`change_timetable()`, `autofill_timetable()`, `set_timetable_start()`, `set_vehicle_on_time()`, `get_vehicle_timetable()`) via real game-protocol commands.
|
||||
- **Order Editing:** Add and remove a vehicle's orders (`add_order()` inserts a "go to station" stop, `remove_order()` deletes one) via real game-protocol commands.
|
||||
- **Scheduled Dispatch (JGRPP):** Edit a vehicle's dispatch schedules and departure slots over the game port (`add_dispatch_schedule()`, `add_dispatch_slot()`, `set_dispatch_duration()`, `set_scheduled_dispatch()`, and more), and read them back authoritatively with `OpenTTDAdminClient.get_dispatch()` (via a patched GameScript API + the AdminBridge GS).
|
||||
- **Authoritative Timetable Reads:** `OpenTTDAdminClient.get_timetable()` fetches the real, current timetable of any vehicle from the running game (via a patched GameScript API + the AdminBridge GS) — no company join needed, works for timetables set before connecting.
|
||||
- **Station Listing:** Enumerate stations via the Admin GameScript channel with `list_stations()`.
|
||||
- **Station Cargo Snapshots:** `OpenTTDAdminClient.get_station()` returns a station's live per-cargo state from the running game — both the **real-time** amount waiting and the **planned** flow through the cargodist link graph — over the AdminBridge GS (stock GameScript API, no server patch needed).
|
||||
- **Cargo Flow Breakdown:** `OpenTTDAdminClient.get_station_cargo()` breaks one cargo type down by **source station** and **next hop** (routing destination) for both waiting (real-time) and planned amounts, with optional `from_station`/`via_station` filters.
|
||||
- **Cargo Table:** `OpenTTDAdminClient.list_cargo()` names the bare `cargo_id`s the station queries and cargo events return — every cargo type in the running game with its label (`PASS`, `COAL`, …) and freight flag, read live so NewGRF-specific ids resolve correctly.
|
||||
- **Game Events:** react to the game instead of polling it — `subscribe_events()` streams events over the AdminBridge GS as they happen: a vehicle reaching or leaving a stop (`vehicle_arrive`/`vehicle_depart`, with dwell time and cargo aboard), a station's waiting cargo changing (`cargo_waiting`), plus crashes, industries opening/closing, towns, companies and subsidies. Consume them with `await wait_for_event()` or an `on_event` callback; filter by kind, company, vehicle, station or cargo.
|
||||
- **Version-checked Server Bridge:** the server-side AdminBridge GameScript that answers all of the above lives in [`gamescript/AdminBridge/`](gamescript/AdminBridge/README.md) and is mounted into the Docker server automatically. `get_bridge_version()` verifies at startup that the running bridge is new enough, so an outdated one fails by name instead of hanging every query.
|
||||
|
||||
## 🛠 Setup
|
||||
|
||||
@@ -59,6 +70,8 @@ await client.joined.wait()
|
||||
|
||||
- `main.py`: Main entry point and usage example.
|
||||
- `lib/openttd/`: Core package containing the protocol and client logic.
|
||||
- `gamescript/AdminBridge/`: The server-side GameScript the admin client's GameScript channel talks to.
|
||||
- `docker/`: Dedicated JGRPP server (Dockerfile, compose, and the local server patches in `docker/patches/`).
|
||||
- `docs/`: Extensive documentation on architecture, protocol, and contributing.
|
||||
- `tests/`: Comprehensive test suite (Logic, Protocol, E2E).
|
||||
|
||||
@@ -72,4 +85,6 @@ For detailed instructions on E2E testing and coverage reports, see the [Testing
|
||||
## 📜 Documentation
|
||||
- [Architecture & Design](docs/ARCHITECTURE.md)
|
||||
- [Protocol Internals (PAKE/Encryption)](docs/PROTOCOL.md)
|
||||
- [Vehicle Timetables Usage Guide](docs/TIMETABLES.md)
|
||||
- [Game Events Usage Guide](docs/EVENTS.md)
|
||||
- [Contributor Guide](docs/CONTRIBUTING.md)
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
import ast
|
||||
import inspect
|
||||
import sys
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add lib and tests to path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'lib'))
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from openttd import OpenTTDClient, OpenTTDAdminClient
|
||||
from openttd.protocol import OpenTTDProtocol, OpenTTDAdminProtocol
|
||||
import tests.test_e2e as test_e2e
|
||||
from openttd import OpenTTDAdminClient, OpenTTDClient
|
||||
from openttd.protocol import OpenTTDAdminProtocol, OpenTTDProtocol
|
||||
|
||||
from tests import test_e2e
|
||||
|
||||
# 1. Gather public functions dynamically at runtime using reflection
|
||||
classes = [OpenTTDClient, OpenTTDAdminClient, OpenTTDProtocol, OpenTTDAdminProtocol]
|
||||
|
||||
+7
-4
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM debian:bookworm AS builder
|
||||
FROM debian:13@sha256:9cc080028c43b27d2074d63a5f9caf7166d731494965616c1a6d2827a004585c AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
@@ -26,13 +26,16 @@ RUN cmake .. \
|
||||
&& make -j$(nproc) install
|
||||
|
||||
# Runtime stage
|
||||
FROM debian:bookworm-slim
|
||||
# Must track the builder's Debian release: the builder (debian:13/trixie) links
|
||||
# against glibc 2.38+, so an older runtime (e.g. bookworm, glibc 2.36) cannot run
|
||||
# the resulting binary. Package names use the trixie t64 spelling.
|
||||
FROM debian:trixie-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libcurl3-gnutls \
|
||||
libcurl3t64-gnutls \
|
||||
liblzma5 \
|
||||
liblzo2-2 \
|
||||
libpng16-16 \
|
||||
libpng16-16t64 \
|
||||
libzstd1 \
|
||||
zlib1g \
|
||||
ca-certificates \
|
||||
|
||||
+20
-2
@@ -24,14 +24,32 @@ This setup builds OpenTTD with the JGR Patch Pack (JGRPP) from source and runs i
|
||||
4. **Save Games:**
|
||||
Save games are stored in `config/save/`.
|
||||
|
||||
## AdminBridge GameScript
|
||||
`config/` is gitignored (it holds savegames, downloaded content and generated config), so the
|
||||
AdminBridge GameScript — the server-side half of the Python client's admin GameScript channel —
|
||||
is kept in [gamescript/AdminBridge/](../gamescript/AdminBridge/README.md) instead and bind-mounted
|
||||
read-only over the container's `game/AdminBridge` by `docker-compose.yml`. Nothing to install:
|
||||
`docker-compose up` serves the tracked copy. Edit it there, not under `config/game/`, which the
|
||||
mount shadows. A GameScript is loaded at game start, so restart the server to pick up a change.
|
||||
|
||||
Running the server outside this compose file? Copy the directory in by hand instead:
|
||||
```bash
|
||||
cp -r ../gamescript/AdminBridge ~/.local/share/openttd/game/
|
||||
```
|
||||
|
||||
## JGRPP Source
|
||||
The source code is cloned from the `jgrpp` branch of `https://github.com/JGRennison/OpenTTD-patches`.
|
||||
The source code is cloned from the `jgrpp` branch of `https://github.com/JGRennison/OpenTTD-patches`
|
||||
(currently at tag `jgrpp-0.71.1`) and carries local patches from `patches/` — see
|
||||
[patches/README.md](patches/README.md). After a fresh clone of the source, apply them with
|
||||
`git -C OpenTTD-patches am ../patches/*.patch` before building.
|
||||
|
||||
To update the server to a newer JGRPP version:
|
||||
1. Update the `OpenTTD-patches` directory:
|
||||
```bash
|
||||
cd OpenTTD-patches && git pull && cd ..
|
||||
```
|
||||
2. Rebuild the image:
|
||||
2. Reapply (rebase if needed) the local patches from `patches/`.
|
||||
3. Rebuild the image:
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
@@ -9,6 +9,10 @@ services:
|
||||
- "3977:3977/tcp"
|
||||
volumes:
|
||||
- ./config:/home/openttd/.local/share/openttd
|
||||
# The AdminBridge GameScript is served from its tracked source rather than from the
|
||||
# (gitignored) config directory, so the server always runs the reviewed copy. Nested
|
||||
# inside the mount above, and read-only: edit gamescript/AdminBridge/, not the container.
|
||||
- ../gamescript/AdminBridge:/home/openttd/.local/share/openttd/game/AdminBridge:ro
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
From 73f6770eb3e0ec5be8da9e74fa0874fb6ba2d36d Mon Sep 17 00:00:00 2001
|
||||
From: kovagoadi <[email protected]>
|
||||
Date: Sun, 19 Jul 2026 00:33:23 +0200
|
||||
Subject: [PATCH] Add GameScript API timetable getters to ScriptOrder
|
||||
|
||||
Expose read-only timetable data to AI/GS scripts: per-order wait/travel
|
||||
times, timetabled/fixed flags, leave type and max speed, plus per-vehicle
|
||||
lateness, timetable start tick, current order time and total duration.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
||||
---
|
||||
src/script/api/script_order.cpp | 104 ++++++++++++++++++++++++++++
|
||||
src/script/api/script_order.hpp | 116 ++++++++++++++++++++++++++++++++
|
||||
2 files changed, 220 insertions(+)
|
||||
|
||||
diff --git a/src/script/api/script_order.cpp b/src/script/api/script_order.cpp
|
||||
index 865df623f9..ee18f7d588 100644
|
||||
--- a/src/script/api/script_order.cpp
|
||||
+++ b/src/script/api/script_order.cpp
|
||||
@@ -718,3 +718,107 @@ static void _DoCommandReturnSetOrderFlags(class ScriptInstance &instance)
|
||||
return ScriptMap::DistanceManhattan(origin_tile, dest_tile);
|
||||
}
|
||||
}
|
||||
+
|
||||
+/* static */ SQInteger ScriptOrder::GetTimetableWaitTime(VehicleID vehicle_id, OrderPosition order_position)
|
||||
+{
|
||||
+ if (!IsValidVehicleOrder(vehicle_id, order_position)) return -1;
|
||||
+
|
||||
+ const Order *order = ::ResolveOrder(vehicle_id, order_position);
|
||||
+ if (order == nullptr) return -1;
|
||||
+ return order->GetWaitTime();
|
||||
+}
|
||||
+
|
||||
+/* static */ SQInteger ScriptOrder::GetTimetableTravelTime(VehicleID vehicle_id, OrderPosition order_position)
|
||||
+{
|
||||
+ if (!IsValidVehicleOrder(vehicle_id, order_position)) return -1;
|
||||
+
|
||||
+ const Order *order = ::ResolveOrder(vehicle_id, order_position);
|
||||
+ if (order == nullptr) return -1;
|
||||
+ return order->GetTravelTime();
|
||||
+}
|
||||
+
|
||||
+/* static */ bool ScriptOrder::IsWaitTimetabled(VehicleID vehicle_id, OrderPosition order_position)
|
||||
+{
|
||||
+ if (!IsValidVehicleOrder(vehicle_id, order_position)) return false;
|
||||
+
|
||||
+ const Order *order = ::ResolveOrder(vehicle_id, order_position);
|
||||
+ if (order == nullptr) return false;
|
||||
+ return order->IsWaitTimetabled();
|
||||
+}
|
||||
+
|
||||
+/* static */ bool ScriptOrder::IsTravelTimetabled(VehicleID vehicle_id, OrderPosition order_position)
|
||||
+{
|
||||
+ if (!IsValidVehicleOrder(vehicle_id, order_position)) return false;
|
||||
+
|
||||
+ const Order *order = ::ResolveOrder(vehicle_id, order_position);
|
||||
+ if (order == nullptr) return false;
|
||||
+ return order->IsTravelTimetabled();
|
||||
+}
|
||||
+
|
||||
+/* static */ bool ScriptOrder::IsWaitFixed(VehicleID vehicle_id, OrderPosition order_position)
|
||||
+{
|
||||
+ if (!IsValidVehicleOrder(vehicle_id, order_position)) return false;
|
||||
+
|
||||
+ const Order *order = ::ResolveOrder(vehicle_id, order_position);
|
||||
+ if (order == nullptr) return false;
|
||||
+ return order->IsWaitFixed();
|
||||
+}
|
||||
+
|
||||
+/* static */ bool ScriptOrder::IsTravelFixed(VehicleID vehicle_id, OrderPosition order_position)
|
||||
+{
|
||||
+ if (!IsValidVehicleOrder(vehicle_id, order_position)) return false;
|
||||
+
|
||||
+ const Order *order = ::ResolveOrder(vehicle_id, order_position);
|
||||
+ if (order == nullptr) return false;
|
||||
+ return order->IsTravelFixed();
|
||||
+}
|
||||
+
|
||||
+/* static */ SQInteger ScriptOrder::GetLeaveType(VehicleID vehicle_id, OrderPosition order_position)
|
||||
+{
|
||||
+ if (!IsValidVehicleOrder(vehicle_id, order_position)) return -1;
|
||||
+
|
||||
+ const Order *order = ::ResolveOrder(vehicle_id, order_position);
|
||||
+ if (order == nullptr) return -1;
|
||||
+ return order->GetLeaveType();
|
||||
+}
|
||||
+
|
||||
+/* static */ SQInteger ScriptOrder::GetTimetableMaxSpeed(VehicleID vehicle_id, OrderPosition order_position)
|
||||
+{
|
||||
+ if (!IsValidVehicleOrder(vehicle_id, order_position)) return -1;
|
||||
+
|
||||
+ const Order *order = ::ResolveOrder(vehicle_id, order_position);
|
||||
+ if (order == nullptr) return -1;
|
||||
+ return order->GetMaxSpeed();
|
||||
+}
|
||||
+
|
||||
+/* static */ SQInteger ScriptOrder::GetTimetableLateness(VehicleID vehicle_id)
|
||||
+{
|
||||
+ if (!ScriptVehicle::IsPrimaryVehicle(vehicle_id)) return 0;
|
||||
+
|
||||
+ return ::Vehicle::Get(vehicle_id)->lateness_counter;
|
||||
+}
|
||||
+
|
||||
+/* static */ SQInteger ScriptOrder::GetTimetableStartTick(VehicleID vehicle_id)
|
||||
+{
|
||||
+ if (!ScriptVehicle::IsPrimaryVehicle(vehicle_id)) return -1;
|
||||
+
|
||||
+ return ::Vehicle::Get(vehicle_id)->timetable_start.base();
|
||||
+}
|
||||
+
|
||||
+/* static */ SQInteger ScriptOrder::GetCurrentOrderTime(VehicleID vehicle_id)
|
||||
+{
|
||||
+ if (!ScriptVehicle::IsPrimaryVehicle(vehicle_id)) return -1;
|
||||
+
|
||||
+ return ::Vehicle::Get(vehicle_id)->current_order_time;
|
||||
+}
|
||||
+
|
||||
+/* static */ SQInteger ScriptOrder::GetTimetableTotalDuration(VehicleID vehicle_id)
|
||||
+{
|
||||
+ if (!ScriptVehicle::IsPrimaryVehicle(vehicle_id)) return -1;
|
||||
+
|
||||
+ const Vehicle *v = ::Vehicle::Get(vehicle_id);
|
||||
+ if (v->orders == nullptr) return -1;
|
||||
+ Ticks duration = v->orders->GetTimetableTotalDuration();
|
||||
+ if (duration == INVALID_TICKS) return -1;
|
||||
+ return duration;
|
||||
+}
|
||||
diff --git a/src/script/api/script_order.hpp b/src/script/api/script_order.hpp
|
||||
index 81dc06cd7d..6c96b91b3a 100644
|
||||
--- a/src/script/api/script_order.hpp
|
||||
+++ b/src/script/api/script_order.hpp
|
||||
@@ -604,6 +604,122 @@ public:
|
||||
* @see ScriptEngine::GetMaximumOrderDistance and ScriptVehicle::GetMaximumOrderDistance
|
||||
*/
|
||||
static SQInteger GetOrderDistance(ScriptVehicle::VehicleType vehicle_type, TileIndex origin_tile, TileIndex dest_tile);
|
||||
+
|
||||
+ /**
|
||||
+ * Gets the timetabled wait time of the given order for the given vehicle.
|
||||
+ * @param vehicle_id The vehicle to get the timetable wait time for.
|
||||
+ * @param order_position The order to get the timetable wait time for.
|
||||
+ * @pre IsValidVehicleOrder(vehicle_id, order_position).
|
||||
+ * @return The wait time of the order in ticks, or -1 when the order is invalid.
|
||||
+ * @note The raw stored wait time is returned even if the wait time is not
|
||||
+ * timetabled; use IsWaitTimetabled to check whether it is explicitly set.
|
||||
+ */
|
||||
+ static SQInteger GetTimetableWaitTime(VehicleID vehicle_id, OrderPosition order_position);
|
||||
+
|
||||
+ /**
|
||||
+ * Gets the timetabled travel time of the given order for the given vehicle.
|
||||
+ * @param vehicle_id The vehicle to get the timetable travel time for.
|
||||
+ * @param order_position The order to get the timetable travel time for.
|
||||
+ * @pre IsValidVehicleOrder(vehicle_id, order_position).
|
||||
+ * @return The travel time of the order in ticks, or -1 when the order is invalid.
|
||||
+ * @note The raw stored travel time is returned even if the travel time is not
|
||||
+ * timetabled; use IsTravelTimetabled to check whether it is explicitly set.
|
||||
+ */
|
||||
+ static SQInteger GetTimetableTravelTime(VehicleID vehicle_id, OrderPosition order_position);
|
||||
+
|
||||
+ /**
|
||||
+ * Checks whether the wait time of the given order is timetabled (explicitly set).
|
||||
+ * @param vehicle_id The vehicle to check the order for.
|
||||
+ * @param order_position The order to check.
|
||||
+ * @pre IsValidVehicleOrder(vehicle_id, order_position).
|
||||
+ * @return True if and only if the wait time is timetabled.
|
||||
+ */
|
||||
+ static bool IsWaitTimetabled(VehicleID vehicle_id, OrderPosition order_position);
|
||||
+
|
||||
+ /**
|
||||
+ * Checks whether the travel time of the given order is timetabled (explicitly set).
|
||||
+ * @param vehicle_id The vehicle to check the order for.
|
||||
+ * @param order_position The order to check.
|
||||
+ * @pre IsValidVehicleOrder(vehicle_id, order_position).
|
||||
+ * @return True if and only if the travel time is timetabled.
|
||||
+ */
|
||||
+ static bool IsTravelTimetabled(VehicleID vehicle_id, OrderPosition order_position);
|
||||
+
|
||||
+ /**
|
||||
+ * Checks whether the wait time of the given order is fixed (locked against autofill).
|
||||
+ * @param vehicle_id The vehicle to check the order for.
|
||||
+ * @param order_position The order to check.
|
||||
+ * @pre IsValidVehicleOrder(vehicle_id, order_position).
|
||||
+ * @return True if and only if the wait time is fixed.
|
||||
+ */
|
||||
+ static bool IsWaitFixed(VehicleID vehicle_id, OrderPosition order_position);
|
||||
+
|
||||
+ /**
|
||||
+ * Checks whether the travel time of the given order is fixed (locked against autofill).
|
||||
+ * @param vehicle_id The vehicle to check the order for.
|
||||
+ * @param order_position The order to check.
|
||||
+ * @pre IsValidVehicleOrder(vehicle_id, order_position).
|
||||
+ * @return True if and only if the travel time is fixed.
|
||||
+ */
|
||||
+ static bool IsTravelFixed(VehicleID vehicle_id, OrderPosition order_position);
|
||||
+
|
||||
+ /**
|
||||
+ * Gets the leave type of the given order for the given vehicle.
|
||||
+ * @param vehicle_id The vehicle to get the leave type for.
|
||||
+ * @param order_position The order to get the leave type for.
|
||||
+ * @pre IsValidVehicleOrder(vehicle_id, order_position).
|
||||
+ * @return The leave type of the order (0 = leave when timetabled, 1 = leave as
|
||||
+ * soon as possible, 2 = leave early if any cargo fully loaded, 3 = leave early
|
||||
+ * if all cargo fully loaded), or -1 when the order is invalid.
|
||||
+ */
|
||||
+ static SQInteger GetLeaveType(VehicleID vehicle_id, OrderPosition order_position);
|
||||
+
|
||||
+ /**
|
||||
+ * Gets the timetabled maximum speed of the given order for the given vehicle.
|
||||
+ * @param vehicle_id The vehicle to get the timetable max speed for.
|
||||
+ * @param order_position The order to get the timetable max speed for.
|
||||
+ * @pre IsValidVehicleOrder(vehicle_id, order_position).
|
||||
+ * @return The maximum speed of the order (65535 when no speed cap is set),
|
||||
+ * or -1 when the order is invalid.
|
||||
+ */
|
||||
+ static SQInteger GetTimetableMaxSpeed(VehicleID vehicle_id, OrderPosition order_position);
|
||||
+
|
||||
+ /**
|
||||
+ * Gets the timetable lateness of the given vehicle.
|
||||
+ * @param vehicle_id The vehicle to get the lateness for.
|
||||
+ * @pre ScriptVehicle::IsPrimaryVehicle(vehicle_id).
|
||||
+ * @return How many ticks the vehicle is late; negative values mean the vehicle
|
||||
+ * is running early. Returns 0 when the vehicle is invalid, which is
|
||||
+ * indistinguishable from an on-time vehicle; check the vehicle validity first.
|
||||
+ */
|
||||
+ static SQInteger GetTimetableLateness(VehicleID vehicle_id);
|
||||
+
|
||||
+ /**
|
||||
+ * Gets the state tick at which the timetable of the given vehicle starts.
|
||||
+ * @param vehicle_id The vehicle to get the timetable start tick for.
|
||||
+ * @pre ScriptVehicle::IsPrimaryVehicle(vehicle_id).
|
||||
+ * @return The absolute state tick the timetable starts at (0 when the
|
||||
+ * timetable has not been started), or -1 when the vehicle is invalid.
|
||||
+ */
|
||||
+ static SQInteger GetTimetableStartTick(VehicleID vehicle_id);
|
||||
+
|
||||
+ /**
|
||||
+ * Gets the number of ticks the given vehicle has spent on its current order.
|
||||
+ * @param vehicle_id The vehicle to get the current order time for.
|
||||
+ * @pre ScriptVehicle::IsPrimaryVehicle(vehicle_id).
|
||||
+ * @return The number of ticks spent on the current order, or -1 when the
|
||||
+ * vehicle is invalid.
|
||||
+ */
|
||||
+ static SQInteger GetCurrentOrderTime(VehicleID vehicle_id);
|
||||
+
|
||||
+ /**
|
||||
+ * Gets the total duration of the timetable of the given vehicle.
|
||||
+ * @param vehicle_id The vehicle to get the timetable duration for.
|
||||
+ * @pre ScriptVehicle::IsPrimaryVehicle(vehicle_id).
|
||||
+ * @return The total timetable duration in ticks, or -1 when the vehicle is
|
||||
+ * invalid, has no orders, or the timetable is not complete.
|
||||
+ */
|
||||
+ static SQInteger GetTimetableTotalDuration(VehicleID vehicle_id);
|
||||
};
|
||||
DECLARE_ENUM_AS_BIT_SET(ScriptOrder::ScriptOrderFlags)
|
||||
|
||||
--
|
||||
2.54.0
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
From 1e4bdcca84e956ece32d2d77dc8001bfd1d8e2f8 Mon Sep 17 00:00:00 2001
|
||||
From: kovagoadi <[email protected]>
|
||||
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 <[email protected]>
|
||||
---
|
||||
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
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Local JGRPP patches
|
||||
|
||||
The `docker/OpenTTD-patches/` directory is an **untracked** clone of
|
||||
[JGRennison/OpenTTD-patches](https://github.com/JGRennison/OpenTTD-patches) checked out at tag
|
||||
`jgrpp-0.71.1`. The patches in this directory are the local modifications this project needs on
|
||||
top of that tag; they are the durable source of truth (the clone itself is not committed).
|
||||
|
||||
Current patches:
|
||||
|
||||
- `0001-Add-GameScript-API-timetable-getters-to-ScriptOrder.patch` — adds read-only timetable
|
||||
getters (`GetTimetableWaitTime`, `GetTimetableTravelTime`, `IsWaitTimetabled`,
|
||||
`IsTravelTimetabled`, `IsWaitFixed`, `IsTravelFixed`, `GetLeaveType`, `GetTimetableMaxSpeed`,
|
||||
`GetTimetableLateness`, `GetTimetableStartTick`, `GetCurrentOrderTime`,
|
||||
`GetTimetableTotalDuration`) to the `GSOrder` GameScript class. Required by the AdminBridge
|
||||
GameScript's `get_timetable` command and the Python client's
|
||||
`OpenTTDAdminClient.get_timetable()`.
|
||||
|
||||
- `0002-Add-GameScript-API-scheduled-dispatch-getters-to-Scr.patch` — adds read-only scheduled
|
||||
dispatch getters (`GetScheduledDispatchScheduleCount`, `IsScheduledDispatchEnabled`,
|
||||
`GetScheduledDispatchDuration`, `GetScheduledDispatchStartTick`, `GetScheduledDispatchDelay`,
|
||||
`GetScheduledDispatchReuseSlots`, `GetScheduledDispatchSlotCount`,
|
||||
`GetScheduledDispatchSlotOffset`, `GetScheduledDispatchSlotFlags`) to the `GSOrder` GameScript
|
||||
class. Required by the AdminBridge GameScript's `get_dispatch` command and the Python client's
|
||||
`OpenTTDAdminClient.get_dispatch()`.
|
||||
|
||||
## Applying after a fresh clone
|
||||
|
||||
```bash
|
||||
git clone --branch jgrpp-0.71.1 https://github.com/JGRennison/OpenTTD-patches docker/OpenTTD-patches
|
||||
git -C docker/OpenTTD-patches am ../patches/*.patch
|
||||
```
|
||||
|
||||
Then build the image as usual (`docker-compose up -d --build` from `docker/`).
|
||||
|
||||
If the clone is updated past `jgrpp-0.71.1`, `git am` may conflict — the patches were generated
|
||||
against that tag and need rebasing in that case.
|
||||
@@ -16,6 +16,12 @@ The primary API for developers.
|
||||
- **Event-Driven:** Uses `asyncio.Event` (like `self.joined`) to signal state changes to the calling code.
|
||||
- **Callback System:** Provides hooks like `on_chat` to allow users to react to game events without modifying the core library.
|
||||
|
||||
### 3. `OpenTTDAdminClient` (Admin Network)
|
||||
Talks to the Admin port (TCP 3977) and, through the AdminBridge GameScript's JSON channel, to the running game itself.
|
||||
- **Correlated Queries:** `get_timetable()`, `get_station()`, `get_dispatch()` and friends all funnel through `_gs_query()`, which tags each request with a monotonic `request_id`, parks a future, and lets `receive_ServerGamescript` resolve it when the matching reply arrives.
|
||||
- **Two Halves:** the queries above only work because a matching GameScript is running on the server. That script is part of this project, in [`gamescript/AdminBridge/`](../gamescript/AdminBridge/README.md), and the client's `get_bridge_version()` checks the running copy is new enough for what it is about to send.
|
||||
- **Push Events:** `subscribe_events()` opens the one stream that flows the other way. Event batches carry no `request_id`, so `receive_ServerGamescript` routes them to the event consumers instead: each event goes to the longest-waiting matching `wait_for_event()` caller, or into a bounded buffer if nobody is waiting, and to the `on_event` observer either way. See [EVENTS.md](EVENTS.md).
|
||||
|
||||
## Handshake Flow
|
||||
|
||||
1. **Connection:** TCP connection established to Port 3979.
|
||||
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
# Game Events: Usage Guide
|
||||
|
||||
Everything else in this library is a **request**: you ask the server something and it answers.
|
||||
Events are the other direction — the server tells you when something happens, so a bot can react
|
||||
to the game instead of polling it in a loop. This guide covers `OpenTTDAdminClient`'s
|
||||
`subscribe_events()`, `unsubscribe_events()`, `wait_for_event()` and the `on_event` callback.
|
||||
For the JSON that goes over the wire, see [PROTOCOL.md](PROTOCOL.md#game-events).
|
||||
|
||||
## Quick start
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from openttd import OpenTTDAdminClient
|
||||
from openttd.protocol import GameEventType
|
||||
|
||||
async def main():
|
||||
admin = OpenTTDAdminClient("127.0.0.1", admin_name="EventWatcher")
|
||||
await admin.connect(admin_password="asd", secure=True)
|
||||
await admin.joined.wait()
|
||||
|
||||
await admin.subscribe_events(
|
||||
events=[GameEventType.VehicleArrive, GameEventType.VehicleDepart],
|
||||
)
|
||||
|
||||
for _ in range(10):
|
||||
event = await admin.wait_for_event(timeout=60.0)
|
||||
print(f"vehicle {event['vehicle_id']} {event['event']} "
|
||||
f"at station {event['station_id']}")
|
||||
|
||||
await admin.unsubscribe_events()
|
||||
await admin.quit()
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## Two ways to consume events
|
||||
|
||||
Both see **every** event, so you can use either or mix them.
|
||||
|
||||
**Pull — `await admin.wait_for_event(kind=None, timeout=5.0)`.** Returns the next matching event.
|
||||
`kind` is a `GameEventType` (or plain string), an iterable of them, or `None` for "anything".
|
||||
Events that arrive while you are not waiting are buffered, so nothing is lost between two calls,
|
||||
and each event goes to at most one waiter. Raises `asyncio.TimeoutError` if nothing matching
|
||||
arrives in time, `ConnectionError` if the admin connection drops mid-wait.
|
||||
|
||||
```python
|
||||
# The next arrival or departure, whichever comes first.
|
||||
event = await admin.wait_for_event(
|
||||
{GameEventType.VehicleArrive, GameEventType.VehicleDepart}, timeout=30.0)
|
||||
```
|
||||
|
||||
**Push — `admin.on_event = handler`.** A plain callback (like `on_chat` / `on_console`), invoked
|
||||
with each event dict as it arrives. Use it for a long-running loop that reacts to everything:
|
||||
|
||||
```python
|
||||
def handle(event):
|
||||
if event["event"] == "cargo_waiting" and event["delta"] > 0:
|
||||
print(f"{event['delta']} units of cargo {event['cargo_id']} "
|
||||
f"arrived at station {event['station_id']}")
|
||||
|
||||
admin.on_event = handle
|
||||
await admin.subscribe_events(events=[GameEventType.CargoWaiting])
|
||||
await asyncio.sleep(600) # events arrive on the connection's own task
|
||||
```
|
||||
|
||||
The buffer behind `wait_for_event()` holds the last 256 unclaimed events by default
|
||||
(`OpenTTDAdminClient(..., event_buffer_size=N)`); past that the oldest are dropped, so a
|
||||
subscription nobody drains cannot grow without bound.
|
||||
|
||||
## Event kinds
|
||||
|
||||
Import them from `openttd.protocol` as `GameEventType`, or just use the string. They come in two
|
||||
flavours, which behave differently in ways worth knowing.
|
||||
|
||||
### Polled events — sampled, not instantaneous
|
||||
|
||||
The engine raises **no** event when a vehicle reaches a stop or when cargo shows up, so the
|
||||
AdminBridge GameScript synthesises these by sampling game state every `interval` ticks and
|
||||
reporting what changed since the previous sample.
|
||||
|
||||
| Kind | Fires when | Fields |
|
||||
|---|---|---|
|
||||
| `vehicle_arrive` | a vehicle starts loading/unloading at a station | `vehicle_id`, `station_id`, `owner`, `vehicle_type`, `order_position`, `cargo` |
|
||||
| `vehicle_depart` | a vehicle stops loading/unloading at a station | the same, plus `dwell` (ticks it stayed) |
|
||||
| `cargo_waiting` | a station's waiting amount of a cargo changes | `station_id`, `cargo_id`, `waiting` (new total), `delta` (signed change) |
|
||||
|
||||
`cargo` is a list of `{"cargo_id": n, "load": units}` for the cargo actually aboard (omit it with
|
||||
`include_cargo=False`). `vehicle_type` is `0` rail, `1` road, `2` water, `3` air.
|
||||
`order_position` is the index of the vehicle's current order, or `-1` if it has none.
|
||||
`delta` is negative when cargo was picked up rather than delivered.
|
||||
|
||||
Three consequences of sampling that you should design around:
|
||||
|
||||
- **Short stops can be missed entirely.** If a vehicle arrives and leaves between two samples,
|
||||
neither event exists. Lower `interval` to narrow the window; you cannot close it.
|
||||
- **The first sample only establishes a baseline.** A vehicle already sitting at a station when
|
||||
you subscribe did not just arrive and gets no `vehicle_arrive`. Its eventual `dwell` is counted
|
||||
from that first sample, not from its real arrival.
|
||||
- **`delta` is measured against the previous sample, not the previous event.** With a
|
||||
`min_cargo_delta` filter, suppressed changes still move the baseline, so the next reported
|
||||
`delta` covers everything since the last *sample*.
|
||||
|
||||
Two more edge cases in how "at a stop" is defined: it means *loading or unloading* at a station,
|
||||
so a vehicle passing through a station non-stop never registers, and a vehicle that is stopped by
|
||||
hand or breaks down at a platform reads as having left (and as arriving again when it resumes).
|
||||
|
||||
### Forwarded engine events — exact, as they happen
|
||||
|
||||
These the engine does raise, so they are reported the moment they occur, with no sampling
|
||||
involved and no `interval` dependence.
|
||||
|
||||
| Kind | Fields |
|
||||
|---|---|
|
||||
| `vehicle_crashed` | `vehicle_id`, `tile`, `reason`, `victims`, `owner` |
|
||||
| `station_first_vehicle` | `station_id`, `vehicle_id` |
|
||||
| `industry_open`, `industry_close` | `industry_id` |
|
||||
| `town_founded` | `town_id` |
|
||||
| `company_new`, `company_in_trouble`, `company_bankrupt` | `company_id` |
|
||||
| `subsidy_offer`, `subsidy_offer_expired`, `subsidy_awarded`, `subsidy_expired` | `subsidy_id` |
|
||||
|
||||
Every event of either flavour also carries `event` (its kind) and `tick` (the game tick it was
|
||||
observed at).
|
||||
|
||||
There is deliberately no `vehicle_lost`, `vehicle_waiting_in_depot` or `vehicle_unprofitable`:
|
||||
the engine raises those only for AI companies, never for a GameScript, so no bridge could
|
||||
forward them. Watch `vehicle_arrive`/`vehicle_depart` or poll `get_timetable()` instead.
|
||||
|
||||
### `events_dropped`
|
||||
|
||||
Not something you subscribe to — the bridge emits it when a single poll produced more events than
|
||||
its per-poll cap (200) and discarded `count` of them. Treat it as "your view has a hole in it":
|
||||
if you see it, your subscription is too broad or your interval too coarse for what the map is
|
||||
doing. Re-read state with `get_station()` / `get_timetable()` rather than trusting your
|
||||
event-derived view.
|
||||
|
||||
## `subscribe_events(...)`
|
||||
|
||||
Awaitable; returns the bridge's confirmation `{"events": [accepted kinds], "interval": N}`. Every
|
||||
argument narrows what you get, and all are optional.
|
||||
|
||||
| Parameter | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `events` | iterable | Which kinds to receive. Default: all of them. |
|
||||
| `interval` | `int` | Ticks between state samples for the polled kinds. Default `10`. This is their resolution, not a delay. |
|
||||
| `company_id` | `int` | Only report vehicles and stations owned by this company. |
|
||||
| `vehicles` | iterable | Only sample these vehicle ids instead of every vehicle. |
|
||||
| `stations` | iterable | Only sample these station ids instead of every station. |
|
||||
| `cargo` | iterable | Only inspect these cargo types (for `cargo_waiting`, and for the load on vehicle events). |
|
||||
| `min_cargo_delta` | `int` | Suppress `cargo_waiting` events smaller than this many units. Default `1` (report everything). |
|
||||
| `include_cargo` | `bool` | `False` leaves the per-cargo load off vehicle events. Default `True`. |
|
||||
| `timeout` | `float` | Seconds to wait for the confirmation. Default `5.0`. |
|
||||
|
||||
Subscribing **replaces** any previous subscription and resets the baseline. `unsubscribe_events()`
|
||||
stops the stream and drops the bridge's sampling state.
|
||||
|
||||
Errors: `ValueError` when the GameScript rejects the request (`unknown_event`,
|
||||
`invalid_interval`, `invalid_min_cargo_delta`, `invalid_cargo`), `asyncio.TimeoutError` when it
|
||||
does not answer (GameScripts don't run while the game is **paused**, so a paused server always
|
||||
times out), `ConnectionError` if the connection drops. The Gamescript update-frequency
|
||||
subscription the stream needs is set up automatically.
|
||||
|
||||
## Cost: subscribe narrowly
|
||||
|
||||
The bridge samples every watched vehicle and every watched station × cargo pair on each interval,
|
||||
inside a GameScript's limited per-tick opcode budget. The defaults — every kind, every vehicle,
|
||||
every station, every cargo, every 10 ticks — are fine on a small map and wasteful on a large busy
|
||||
one, where the poll can take longer than the interval it is trying to keep.
|
||||
|
||||
Narrow it to what you actually need:
|
||||
|
||||
```python
|
||||
# Watch two specific vehicles closely.
|
||||
await admin.subscribe_events(
|
||||
events=[GameEventType.VehicleArrive, GameEventType.VehicleDepart],
|
||||
vehicles=[7, 9], interval=2)
|
||||
|
||||
# Watch coal piling up at one station, ignoring changes under 10 units.
|
||||
await admin.subscribe_events(
|
||||
events=[GameEventType.CargoWaiting],
|
||||
stations=[3], cargo=[0], min_cargo_delta=10, interval=30)
|
||||
|
||||
# Only one company's traffic, without the per-vehicle cargo detail.
|
||||
await admin.subscribe_events(company_id=0, include_cargo=False)
|
||||
```
|
||||
|
||||
## Putting it together
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from openttd import OpenTTDAdminClient
|
||||
from openttd.protocol import GameEventType
|
||||
|
||||
async def watch_route(admin, vehicle_id):
|
||||
"""Report how long a vehicle spends at each stop on its route."""
|
||||
await admin.subscribe_events(
|
||||
events=[GameEventType.VehicleArrive, GameEventType.VehicleDepart],
|
||||
vehicles=[vehicle_id], interval=2)
|
||||
try:
|
||||
while True:
|
||||
event = await admin.wait_for_event(timeout=300.0)
|
||||
if event["event"] == "vehicle_arrive":
|
||||
print(f"arrived at station {event['station_id']} (order {event['order_position']})")
|
||||
else:
|
||||
loaded = sum(c["load"] for c in event["cargo"])
|
||||
print(f"left station {event['station_id']} after {event['dwell']} ticks "
|
||||
f"carrying {loaded} units")
|
||||
except asyncio.TimeoutError:
|
||||
print("vehicle went quiet")
|
||||
finally:
|
||||
await admin.unsubscribe_events()
|
||||
|
||||
async def main():
|
||||
admin = OpenTTDAdminClient("127.0.0.1", admin_name="RouteWatcher")
|
||||
await admin.connect(admin_password="asd", secure=True)
|
||||
await admin.joined.wait()
|
||||
await watch_route(admin, vehicle_id=7)
|
||||
await admin.quit()
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
The server must run the bundled AdminBridge GameScript (version 3 or newer) from
|
||||
[`gamescript/AdminBridge/`](../gamescript/AdminBridge/README.md) — `get_bridge_version()` checks
|
||||
that it is new enough, and it is worth calling once at startup. Events need **no**
|
||||
server patch — unlike `get_timetable()` and `get_dispatch()`, every getter involved is part of the
|
||||
stock GameScript API. Since GameScripts do not tick while the game is paused, no events are
|
||||
produced on a paused server.
|
||||
|
||||
## See also
|
||||
- [PROTOCOL.md — Game Events](PROTOCOL.md#game-events) for the request/reply JSON.
|
||||
- [TIMETABLES.md](TIMETABLES.md) for reading and editing what the events tell you changed.
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) for how the admin client fits together.
|
||||
@@ -26,6 +26,137 @@ Similar to the Game Port, the Admin Network uses X25519 PAKE for secure authenti
|
||||
### Update Frequencies
|
||||
Admins can subscribe to various updates (Date, Client Info, Company Info, etc.) at different frequencies (Poll, Daily, Weekly, Monthly, Quarterly, Annually, Automatic).
|
||||
|
||||
### The AdminBridge GameScript
|
||||
Everything in this section rides on a companion GameScript running on the server — the other half
|
||||
of the protocol, kept in [`gamescript/AdminBridge/`](../gamescript/AdminBridge/README.md). The
|
||||
Admin Network itself offers no way to ask the game about individual vehicles, stations or orders;
|
||||
what it does offer is an opaque JSON channel to whatever GameScript is loaded (`AdminGamescript`
|
||||
out, `ServerGamescript` back), and this bridge is what gives that channel meaning.
|
||||
|
||||
Note what the channel does **not** give you: a bridge that does not recognise a command drops it
|
||||
silently. There is no "unknown command" reply, so a client talking to a bridge older than itself
|
||||
sees nothing but timeouts.
|
||||
|
||||
### Bridge Version
|
||||
`get_bridge_version()` exists to turn that silence into an error. The bridge reports the version
|
||||
of the JSON protocol it implements, and the client compares it against `GS_BRIDGE_VERSION` (the
|
||||
version it was written against, in `protocol.py`).
|
||||
|
||||
- **Request:** `{"command": "get_version", "request_id": X}`.
|
||||
- **Reply:** `{"command": "get_version", "request_id": X, "version": N, "commands": [...], "events": [...]}` —
|
||||
`commands` is every command name the bridge answers (sorted) and `events` every event kind it
|
||||
can push, so a client can feature-detect a single command instead of comparing version numbers.
|
||||
- **No reply at all** is the answer from a bridge older than version 4, which is when
|
||||
`get_version` was added; `get_bridge_version()` surfaces that as `asyncio.TimeoutError`, the
|
||||
same as a paused game or a server with no bridge loaded.
|
||||
|
||||
The version covers the shape of the JSON protocol, not the implementation, and is declared in
|
||||
three places that [`tests/test_gamescript.py`](../tests/test_gamescript.py) keeps in step:
|
||||
`info.nut`'s `GetVersion()`, `main.nut`'s `BRIDGE_VERSION` and `protocol.py`'s
|
||||
`GS_BRIDGE_VERSION`. The same test checks the bridge's event catalogue against `GameEventType`
|
||||
and its command table against the commands the client actually sends.
|
||||
|
||||
### Vehicle Listing
|
||||
The Admin Network has no native packet or `AdminUpdateType` for listing individual vehicles — `ServerCompanyStats` only reports aggregate per-company vehicle counts (trains/lorries/buses/planes/ships). To retrieve an actual vehicle list, this client sends a `list_vehicles` command over the GameScript JSON channel (`AdminGamescript`/`ServerGamescript`) via `list_vehicles()`. This requires a companion GameScript running server-side that understands the `list_vehicles` command and replies with vehicle data through `ServerGamescript`.
|
||||
|
||||
**Important:** the server only forwards `ServerGamescript` packets to admins that have subscribed with `update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)` (enforced server-side in `NetworkAdminGameScript`, which checks `update_frequency[ADMIN_UPDATE_GAMESCRIPT]`). Call `update_frequency()` for `Gamescript` before `list_vehicles()`, or the response is silently dropped.
|
||||
|
||||
When a `list_vehicles` request carries a `request_id` field, the AdminBridge GameScript echoes it back in the reply (backward compatible: absent otherwise).
|
||||
|
||||
### Timetable Query
|
||||
The stock GameScript API has no timetable getters, so this project patches the server (see `docker/patches/`) to add read-only getters to `GSOrder` (`GetTimetableWaitTime`, `GetTimetableTravelTime`, `IsWaitTimetabled`, `IsTravelTimetabled`, `IsWaitFixed`, `IsTravelFixed`, `GetLeaveType`, `GetTimetableMaxSpeed`, `GetTimetableLateness`, `GetTimetableStartTick`, `GetCurrentOrderTime`, `GetTimetableTotalDuration`). On top of that, `get_timetable()` sends a request over the same GameScript JSON channel as vehicle listing and awaits the correlated reply — an **authoritative snapshot** of the live game state, unlike the passive observer on the game port (see below).
|
||||
|
||||
- **Request:** `{"command": "get_timetable", "vehicle_id": N, "request_id": X}` — `request_id` is a client-side monotonic counter used to match the reply to the awaiting caller.
|
||||
- **Reply (success):** `{"command": "get_timetable", "vehicle_id": N, "request_id": X, "lateness": ..., "start_tick": ..., "current_order_time": ..., "total_duration": ..., "orders": [{"position", "wait_time", "travel_time", "wait_timetabled", "travel_timetabled", "wait_fixed", "travel_fixed", "leave_type", "max_speed"}, ...]}` (booleans encoded as 0/1).
|
||||
- **Reply (error):** same envelope with an `"error"` field instead of the data: `"invalid_vehicle"` (no such vehicle) or `"response_too_large"` (the reply exceeded the admin packet size limit, possible with very many orders). `get_timetable()` raises `ValueError` for these.
|
||||
|
||||
Replies carrying a `request_id` that matches a pending request resolve that request and are **not** delivered to the `on_gamescript` callback; all other `ServerGamescript` traffic reaches the callback unchanged. The same `update_frequency` subscription requirement applies (`get_timetable()` subscribes automatically on first use). Since GameScripts do not tick while the game is paused, a query against a paused server times out (`asyncio.TimeoutError`).
|
||||
|
||||
### Station Listing
|
||||
Like vehicles, the Admin Network has no native packet for enumerating individual stations (`ServerCompanyStats` only reports an aggregate per-company station count). `list_stations()` sends a `list_stations` command over the same GameScript JSON channel and the companion AdminBridge GameScript replies with station data through `ServerGamescript` (`{"command": "list_stations", "stations": [{"id", "name", ...}, ...]}`). It is fire-and-forget, so the reply is delivered to the `on_gamescript` callback — subscribe to `Gamescript` updates first, exactly as for `list_vehicles()`. An optional `company_id` field scopes the list to one company.
|
||||
|
||||
### Station Query
|
||||
`get_station()` fetches an authoritative snapshot of one station's live cargo state over the same GameScript JSON channel, awaiting the correlated reply — the station analogue of `get_timetable()`. Unlike timetables, the getters it relies on (`GSStation.GetCargoWaiting`, `GetCargoPlanned`, `GetCargoRating`) are part of the **stock** GameScript API, so this needs no server patch. The per-cargo reply exposes both the **real-time** amount currently waiting and the **planned** amount routed through the station by the cargodist link graph.
|
||||
|
||||
- **Request:** `{"command": "get_station", "station_id": N, "request_id": X}` — `request_id` is the same client-side monotonic counter used by `get_timetable()`, matching the reply to the awaiting caller.
|
||||
- **Reply (success):** `{"command": "get_station", "station_id": N, "request_id": X, "name": ..., "location": <tile>, "owner": <company_id>, "cargo": [{"cargo_id", "waiting", "planned", "rating"}, ...]}` — `waiting` is the real-time units at the station (`GetCargoWaiting`), `planned` is the link-graph planned flow (`GetCargoPlanned`, 0 when cargo distribution is off for that cargo), and `rating` is the acceptance rating as a percentage (0-100, `GetCargoRating`) or `null` when the station has no rating for that cargo yet. Only cargo the station has handled appears.
|
||||
- **Reply (error):** same envelope with an `"error"` field instead of the data: `"invalid_station"` (no such station) or `"response_too_large"`. `get_station()` raises `ValueError` for these.
|
||||
|
||||
Correlation, the `update_frequency` subscription requirement (auto-subscribed on first use), and the paused-game timeout behave exactly as described for the Timetable Query above.
|
||||
|
||||
### Station Cargo Flow Breakdown
|
||||
`get_station_cargo()` drills into a single cargo type at one station and returns how its **waiting** (real-time) and **planned** amounts split across the cargo distribution (cargodist) link graph. Cargodist tags every unit with a **source** station (`from`, where it was first loaded) and a **next hop** (`via`, the next station it travels to toward its final destination). There is no per-station store of the *final* destination — the routing destination is the next hop — so the breakdown is offered along those two axes. The GS reads them with the stock `GSStation.GetCargoWaiting{From,Via,FromVia}` / `GetCargoPlanned{From,Via,FromVia}` scalars and the `GSStationList_Cargo{Waiting,Planned}By{From,Via}` (and `…ViaByFrom` / `…FromByVia`) list classes — again no server patch.
|
||||
|
||||
- **Request:** `{"command": "get_station_cargo", "station_id": N, "cargo_id": C, "request_id": X}`, optionally with `"from_station"` and/or `"via_station"` filters.
|
||||
- **Reply (success):** `{"command": "get_station_cargo", "station_id": N, "cargo_id": C, "request_id": X, "waiting": ..., "planned": ..., "waiting_by_from": [{"station", "amount"}, ...], "planned_by_from": [...], "waiting_by_via": [...], "planned_by_via": [...]}`. `waiting`/`planned` are the (filtered) totals; each `*_by_from` list groups by source station and each `*_by_via` list groups by next hop (zero-amount entries omitted). A `station` of `65535` (`STATION_INVALID`) means the source was deleted or — as a next hop — the cargo has no onward routing / is consumed here (also the only next hop for cargo using manual, non-cargodist distribution). Any supplied `from_station`/`via_station` filter is echoed back.
|
||||
- **Filters:** `via_station` restricts the query (and the `*_by_from` breakdowns) to cargo whose next hop is that station; `from_station` restricts it (and the `*_by_via` breakdowns) to cargo from that source; supplying both makes `waiting`/`planned` the exact source-and-next-hop amount. Pass `65535` to target `STATION_INVALID`.
|
||||
- **Reply (error):** same envelope with an `"error"` field: `"invalid_station"`, `"invalid_cargo"`, `"invalid_from_station"`/`"invalid_via_station"` (a filter that is neither a valid station nor `STATION_INVALID`), or `"response_too_large"`. `get_station_cargo()` raises `ValueError` for these.
|
||||
|
||||
### Cargo Listing
|
||||
Cargo appears in the replies above (and in the `cargo_waiting` / vehicle events) as a bare numeric `cargo_id`. Those ids index the cargo table the loaded NewGRFs build for the running game, so they are **not stable across games** — the same id can be coal in one save and grain in another. `list_cargo()` resolves them, sending a `list_cargo` command over the same GameScript JSON channel and awaiting the correlated reply. The GS reads the table with the stock `GSCargoList`, `GSCargo.GetCargoLabel` and `GSCargo.IsFreight`, so this needs no server patch.
|
||||
|
||||
- **Request:** `{"command": "list_cargo", "request_id": X}`.
|
||||
- **Reply (success):** `{"command": "list_cargo", "request_id": X, "cargo": [{"cargo_id": N, "label": "COAL", "freight": 0|1}, ...]}` — one entry per cargo type in the game, in `GSCargoList` order rather than by id, so index the list by `cargo_id`. `label` is the four-character NewGRF cargo label (`GetCargoLabel`, underscore-padded: `OIL_`), or `""` if the GS could not read it; `freight` is 1 for freight cargo and 0 for the rest (passengers, mail, …).
|
||||
- **Reply (error):** the GS defines no error for this command, so `list_cargo()` never raises `ValueError` — only `asyncio.TimeoutError` (paused game, GS not loaded) and `ConnectionError`.
|
||||
|
||||
Correlation and the `update_frequency` subscription requirement (auto-subscribed on first use) are as described for the Timetable Query above. Unlike `list_vehicles()`/`list_stations()` — which are fire-and-forget despite the similar name — this one is awaitable and its reply does **not** reach the `on_gamescript` callback.
|
||||
|
||||
### 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.
|
||||
|
||||
### Game Events
|
||||
Every other GameScript command above is request/reply. `subscribe_events()` instead opens a **push** stream: the AdminBridge GameScript sends event batches over `ServerGamescript` as things happen, unsolicited and without a `request_id`. For the calling API and the semantics of each kind, see the [Game Events Usage Guide](EVENTS.md).
|
||||
|
||||
- **Subscribe request:** `{"command": "subscribe_events", "request_id": X}` plus any of the optional narrowing fields `"events"` (array of kind strings), `"interval"` (ticks between state samples, default 10), `"company_id"`, `"vehicles"`, `"stations"`, `"cargo"` (arrays of ids), `"min_cargo_delta"` (default 1) and `"include_cargo"` (bool, default true).
|
||||
- **Subscribe reply:** `{"command": "subscribe_events", "request_id": X, "events": [accepted kinds in catalogue order], "interval": N}`, or the same envelope with an `"error"` field: `"unknown_event"` (plus the offending `"event"`), `"invalid_interval"`, `"invalid_min_cargo_delta"`, or `"invalid_cargo"` (plus the offending `"cargo_id"`). `subscribe_events()` raises `ValueError` for these. Subscribing replaces any previous subscription and resets the bridge's sampling baseline.
|
||||
- **Unsubscribe:** `{"command": "unsubscribe_events", "request_id": X}` → `{"command": "unsubscribe_events", "request_id": X, "events": []}`. This also drops the sampling state.
|
||||
- **Event batch (unsolicited):** `{"command": "events", "events": [{"event": <kind>, "tick": T, ...}, ...]}`. Batches carry at most 24 events per packet, so one poll can produce several; a poll that generated more than 200 events is truncated and followed by a single `{"event": "events_dropped", "count": N}` entry. Because these batches carry no `request_id`, `receive_ServerGamescript` routes them to the event consumers (`on_event` / `wait_for_event()`) instead of the generic `on_gamescript` callback; every other GameScript payload reaches `on_gamescript` unchanged. The usual `update_frequency(Gamescript, Automatic)` subscription applies and is set up automatically by `subscribe_events()`.
|
||||
|
||||
Event kinds come from two sources. `vehicle_arrive`, `vehicle_depart` and `cargo_waiting` are **synthesised** by the bridge, because the engine raises no GameScript event for them: every `interval` ticks it samples `GSVehicle.GetState`/`GetLocation` for the watched vehicles and `GSStation.GetCargoWaiting` for the watched station-cargo pairs, and emits an event per change against the previous sample (so a stop shorter than the interval is never reported, and the first sample after subscribing only sets a baseline). All the remaining kinds — `vehicle_crashed`, `station_first_vehicle`, `industry_open`/`industry_close`, `town_founded`, `company_new`/`company_in_trouble`/`company_bankrupt` and the four `subsidy_*` kinds — are engine events forwarded verbatim from `GSEventController`. Only those the engine actually raises for a **deity** script are available: `ET_VEHICLE_LOST`, `ET_VEHICLE_WAITING_IN_DEPOT` and `ET_VEHICLE_UNPROFITABLE` are `@api ai` only and can never reach a GameScript, so the bridge does not offer them. Unlike the timetable and dispatch queries, none of this needs a server patch — every getter used is stock GameScript API.
|
||||
|
||||
## Vehicle Orders & Timetables (Game Port DoCommands)
|
||||
Unlike vehicle listing, a vehicle's order list, timetables and scheduled dispatch have no writable GameScript API surface (this project adds read-only timetable and dispatch getters via server patches — see "Timetable Query" and "Dispatch Query" above). Reading and modifying them requires real engine commands (`DoCommand`s) sent over the **game port** (TCP 3979) via `ClientCommand`/`ServerCommand` packets, not the Admin Network. This section covers the wire format; for how to call the methods and what each parameter means, see the [Vehicle Timetables Usage Guide](TIMETABLES.md).
|
||||
|
||||
### Command envelope
|
||||
Both `ClientCommand` and `ServerCommand` share this body: `company (uint8)`, `cmd (uint16 LE, index into the `Commands` enum)`, `error_msg (uint16 LE, StringID, use 0)`, `tile (uint32 LE, always 0 for these commands)`, `payload_len (uint16 LE)`, `payload (payload_len bytes)`, `callback (uint8, use 0)`, `callback_param (uint32 LE, only present if callback != 0)`. `ServerCommand` additionally appends `frame (uint32 LE)` and `my_cmd (uint8 bool)`, and is a **broadcast echo of the request** (no success/failure code) sent to every joined client, not just the sender.
|
||||
|
||||
### Payload integer encoding
|
||||
Command payload fields follow JGRPP's generic serialiser, which picks the wire width from the C++ type's **size**: types of ≤1 byte are sent as a fixed `uint8`, exactly 2 bytes as a fixed `uint16` (LE), and 4/8-byte types as a variable-length **varuint** (`write_varuint`/`read_varuint` in `protocol.py` — a UTF-8-like prefix encoding, not LEB128; signed fields use zigzag via `write_varuint_signed`/`read_varuint_signed`). This is why `VehicleID` (a 4-byte pool id) is a varuint while `VehicleOrderID` (a `uint16`) is a fixed `uint16`.
|
||||
|
||||
### Command IDs and payload tuples
|
||||
| Method | `cmd` | payload |
|
||||
|---|---|---|
|
||||
| `add_order()` | 52 (`InsertOrder`) | `VehicleID (varuint), sel_ord (uint16), order_type (uint8), order_flags (uint16), DestinationID (uint16)` |
|
||||
| `remove_order()` | 51 (`DeleteOrder`) | `VehicleID (varuint), VehicleOrderID (uint16)` |
|
||||
| `change_timetable()` | 174 (`ChangeTimetable`) | `VehicleID (varuint), VehicleOrderID (uint16), ModifyTimetableFlags (uint8), value (varuint), ModifyTimetableCtrlFlags (uint8)` |
|
||||
| `set_vehicle_on_time()` | 176 (`SetVehicleOnTime`) | `VehicleID (varuint), apply_to_group (uint8 bool)` |
|
||||
| `autofill_timetable()` | 177 (`AutofillTimetable`) | `VehicleID (varuint), bool (uint8), bool (uint8)` |
|
||||
| `set_timetable_start()` | 180 (`SetTimetableStart`) | `VehicleID (varuint), bool (uint8), StateTicks (signed varuint)` |
|
||||
| `set_scheduled_dispatch()` | 205 (`SchDispatch`) | `VehicleID (varuint), enabled (uint8 bool)` |
|
||||
| `add_dispatch_slot()` | 206 (`SchDispatchAdd`) | `VehicleID (varuint), schedule_index (varuint), offset (varuint), interval (varuint), extra_slots (varuint), slot_flags (uint16), route_id (uint8)` |
|
||||
| `remove_dispatch_slot()` | 207 (`SchDispatchRemove`) | `VehicleID (varuint), schedule_index (varuint), offset (varuint)` |
|
||||
| `set_dispatch_duration()` | 208 (`SchDispatchSetDuration`) | `VehicleID (varuint), schedule_index (varuint), duration (varuint)` |
|
||||
| `set_dispatch_start_date()` | 209 (`SchDispatchSetStartDate`) | `VehicleID (varuint), schedule_index (varuint), StateTicks (signed varuint)` |
|
||||
| `clear_dispatch_schedule()` | 213 (`SchDispatchClear`) | `VehicleID (varuint), schedule_index (varuint)` |
|
||||
| `add_dispatch_schedule()` | 214 (`SchDispatchAddNewSchedule`) | `VehicleID (varuint), StateTicks (signed varuint), duration (varuint)` |
|
||||
| `remove_dispatch_schedule()` | 215 (`SchDispatchRemoveSchedule`) | `VehicleID (varuint), schedule_index (varuint)` |
|
||||
|
||||
### Scheduled dispatch (JGRPP)
|
||||
A vehicle's order list can carry several **dispatch schedules**, each with a duration, a start tick and a set of departure **slots** (offsets within the duration). The methods above edit them over the game port (`add_dispatch_schedule()`/`remove_dispatch_schedule()` create and delete schedules; `add_dispatch_slot()`/`remove_dispatch_slot()`/`clear_dispatch_schedule()` manage a schedule's slots; `set_dispatch_duration()`/`set_dispatch_start_date()` adjust a schedule; `set_scheduled_dispatch()` toggles the feature for the vehicle). `add_dispatch_slot()` can add several evenly spaced slots at once via its `interval`/`extra_slots` parameters. The stock JGRPP command set covers ~22 dispatch commands (routes, departure tags, per-slot flags, adjust/swap/duplicate, …); the client implements this common core. There is no game-port read; for an authoritative view of the resulting schedules use the Admin Network's `get_dispatch()` (see "Dispatch Query" below).
|
||||
|
||||
### Adding & removing orders
|
||||
`add_order()` issues `CMD_INSERT_ORDER`, which appends a new order before `sel_ord` (pass `0xFFFF`/`INVALID_VEH_ORDER_ID` to append to the end). The client currently builds "go to station" orders only. The `order_type` byte is bit-packed: **bits 0-3** hold the `OrderType` (`1` = `OT_GOTO_STATION`), **bits 4-5** the `OrderStopLocation`, and **bits 6-7** the `OrderNonStopFlags`. The stop location defaults to `PlatformFarEnd` (`2`) because near-end/middle/through are **train-only** and the server rejects (`CMD_ERROR`, no state change) any other value for road vehicles, ships, or aircraft. `order_flags` is the 16-bit load/unload word (`0` = load-if-possible + unload-if-possible). `DestinationID` is the target `StationID`. `remove_order()` issues `CMD_DELETE_ORDER` for the order at a given position. Both are **broadcast** back as `ServerCommand` like any DoCommand; the client does not currently decode those echoes into observed order state, so verify results via the admin `get_timetable()` order count.
|
||||
|
||||
### Ownership requirement
|
||||
A command is rejected unless it's issued by the company that owns the target vehicle — join that company via `join_company()` with a real company id (not 255/spectator) before calling any order or timetable method. A malformed or wrong-company packet is treated as illegal and the client is kicked; a well-formed command that merely fails validation (e.g. an order the vehicle can't serve) is silently dropped with no state change and no kick.
|
||||
|
||||
### Reading timetables — no query command exists on the game port
|
||||
There is no getter `DoCommand` for orders/timetables anywhere in the protocol. `get_vehicle_timetable()` works by passively decoding `ServerCommand` broadcasts (including the sender's own) as they arrive — it only reflects **changes made after the client joined**. A vehicle's pre-existing timetable (set before this client connected) is invisible until something changes it again; seeing it upfront would require parsing the `ORDR`/`VEHS` chunks of the initial savegame transfer (`ServerMapData`), which this client does not implement. For an authoritative read, use the Admin Network's `get_timetable()` instead (see "Timetable Query" above).
|
||||
|
||||
## Stream Encryption (AEAD)
|
||||
Once `ServerEnableEncryption` is received, all subsequent packets use **XChaCha20-Poly1305** (Authenticated Encryption with Associated Data).
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ The tests are located in the `tests/` directory:
|
||||
| [`test_admin.py`](file:///home/kovagoadi/openttd-client/tests/test_admin.py) | `OpenTTDAdminClient` | Tests admin client initialization, admin packet types, and basic protocol constants. |
|
||||
| [`test_protocol.py`](file:///home/kovagoadi/openttd-client/tests/test_protocol.py) | `OpenTTDProtocol` | Tests binary serialization, custom parsers, and stream encryption/decryption (XChaCha20-Poly1305). |
|
||||
| [`test_logic.py`](file:///home/kovagoadi/openttd-client/tests/test_logic.py) | `OpenTTDClient` | Tests client connection lifecycle, company joining flow, authentication, and state management. |
|
||||
| [`test_events.py`](file:///home/kovagoadi/openttd-client/tests/test_events.py) | Game Events | Tests event subscription encoding, the push/pull consumption paths (`on_event`, `wait_for_event()`), buffering and waiter lifecycle. |
|
||||
| [`test_coverage.py`](file:///home/kovagoadi/openttd-client/tests/test_coverage.py) | Coverage Helpers | Auxiliary unit tests targeting connection errors, fallback packet handlers, and missing passwords to ensure high test coverage. |
|
||||
| [`test_gamescript.py`](file:///home/kovagoadi/openttd-client/tests/test_gamescript.py) | AdminBridge GameScript | Reads `gamescript/AdminBridge/*.nut` and checks it against the client: protocol version, event catalogue, command table, and that the docker setup serves the tracked copy. No Squirrel toolchain needed — see [`gamescript/AdminBridge/README.md`](file:///home/kovagoadi/openttd-client/gamescript/AdminBridge/README.md). |
|
||||
| [`test_e2e.py`](file:///home/kovagoadi/openttd-client/tests/test_e2e.py) | Integration / E2E | Connects to a running local OpenTTD server (e.g., in Docker) to verify full socket interactions, stream cryptography, and keep-alive frames. |
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
# Vehicle Timetables: Usage Guide
|
||||
|
||||
This guide covers the timetable API: writing via `OpenTTDClient`'s `change_timetable()`,
|
||||
`autofill_timetable()`, `set_timetable_start()`, `set_vehicle_on_time()`, and reading via
|
||||
`OpenTTDAdminClient.get_timetable()` (authoritative, recommended) or `OpenTTDClient`'s
|
||||
`get_vehicle_timetable()` (passive change observer). For wire-format internals (packet layout,
|
||||
varuint encoding, command IDs), see [PROTOCOL.md](PROTOCOL.md#vehicle-timetables-game-port-docommands).
|
||||
This guide is about *how to call these methods and what their parameters mean*, with worked examples.
|
||||
|
||||
## Reading: `get_timetable()` — authoritative snapshot (recommended)
|
||||
|
||||
An **awaitable** method on `OpenTTDAdminClient` (admin port, TCP 3977) that queries the real
|
||||
timetable state from the running game via the AdminBridge GameScript. Unlike the observer
|
||||
approach below, it works for timetables set **before** you connected and returns the game's
|
||||
**actual** state, not the last requested change. No `join_company` needed — it's read-only and
|
||||
sees every company's vehicles.
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from openttd import OpenTTDAdminClient
|
||||
|
||||
async def main():
|
||||
admin = OpenTTDAdminClient("127.0.0.1", admin_name="TimetableReader")
|
||||
await admin.connect(admin_password="asd")
|
||||
await admin.joined.wait()
|
||||
|
||||
data = await admin.get_timetable(7)
|
||||
print(data)
|
||||
|
||||
await admin.quit()
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
Returns a `dict` shaped like:
|
||||
|
||||
```python
|
||||
{
|
||||
"command": "get_timetable",
|
||||
"vehicle_id": 7,
|
||||
"lateness": 0, # ticks late; negative = running early
|
||||
"start_tick": 1000000, # absolute StateTicks the timetable starts at; 0 = not started
|
||||
"current_order_time": 42, # ticks spent on the current order so far
|
||||
"total_duration": 5400, # full timetable round-trip in ticks; -1 = timetable incomplete
|
||||
"orders": [
|
||||
{
|
||||
"position": 0,
|
||||
"wait_time": 120, # ticks (raw stored value)
|
||||
"travel_time": 300, # ticks (raw stored value)
|
||||
"wait_timetabled": 1, # 1 = wait time explicitly set, 0 = not timetabled
|
||||
"travel_timetabled": 1,
|
||||
"wait_fixed": 0, # 1 = locked against autofill
|
||||
"travel_fixed": 0,
|
||||
"leave_type": 0, # 0 normal, 1 leave early, 2 early if any cargo full, 3 early if all full
|
||||
"max_speed": 65535, # order speed cap; 65535 = no cap
|
||||
},
|
||||
# ... one entry per order position
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
Errors: raises `ValueError` when the GameScript reports one (`invalid_vehicle` for a nonexistent
|
||||
vehicle id; `response_too_large` if a very long order list overflows the admin packet limit),
|
||||
`asyncio.TimeoutError` when no reply arrives within `timeout` (default 5.0s — note GameScripts
|
||||
don't run while the game is **paused**, so a paused server always times out), and
|
||||
`ConnectionError` if the admin connection drops mid-query.
|
||||
|
||||
Requirements: the server must run the bundled AdminBridge GameScript (from
|
||||
`gamescript/AdminBridge/`, mounted into the container by this repo's `docker/` setup — see
|
||||
[`gamescript/AdminBridge/README.md`](../gamescript/AdminBridge/README.md)) **and** the patched
|
||||
JGRPP build with the `GSOrder` timetable getters (`docker/patches/README.md`). The Gamescript
|
||||
update-frequency subscription it needs is set up automatically on first call.
|
||||
|
||||
## Writing (and the legacy observer): things you must know
|
||||
|
||||
1. **You must join the vehicle's own company to write.** The `change_timetable()` family are real
|
||||
game commands (`DoCommand`s) on the game port, not admin-network calls. Join with
|
||||
`client.join_company(company_id=<id>, company_password=...)` using the id of the company that
|
||||
owns the vehicle — spectators (`company_id=255`, the default) are rejected. Sending a command
|
||||
for a vehicle you don't own also fails.
|
||||
2. **`get_vehicle_timetable()` is a passive observer, not a query.** It watches the
|
||||
`ServerCommand` broadcasts the server sends to every joined client whenever *anyone* changes a
|
||||
timetable. Use it for live change monitoring on the game port; prefer `get_timetable()` above
|
||||
for reading actual state. Its limitations:
|
||||
- It only reflects changes made **after your client joined**. A vehicle's pre-existing
|
||||
timetable (set before you connected) is invisible until something changes it again.
|
||||
- It reflects what was **requested**, not a confirmed result — the wire protocol has no
|
||||
success/failure code, so a command that the server silently rejects (wrong owner, invalid
|
||||
order, etc.) still updates your local view as if it succeeded.
|
||||
|
||||
## Quick start
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from openttd import OpenTTDClient
|
||||
from openttd.protocol import ModifyTimetableFlags
|
||||
|
||||
async def main():
|
||||
client = OpenTTDClient(host="127.0.0.1", username="TimetableBot")
|
||||
await client.connect(server_password="asd")
|
||||
|
||||
# Must be a real company you own vehicles in -- not 255 (spectator).
|
||||
await client.join_company(company_id=0, company_password="")
|
||||
await client.joined.wait()
|
||||
|
||||
# Set order 0's wait time to 120 ticks for vehicle 7.
|
||||
await client.change_timetable(7, 0, ModifyTimetableFlags.WaitTime, 120)
|
||||
|
||||
# Give the broadcast a moment to round-trip back to us.
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
print(client.get_vehicle_timetable(7))
|
||||
# -> {'orders': {0: {'wait_time': 120}}}
|
||||
|
||||
await client.quit()
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## `change_timetable(vehicle_id, order_position, flag, value, clear_field=False)`
|
||||
|
||||
Changes one field of one order's timetable entry. This is the general-purpose "edit a cell in the
|
||||
Timetable window" command — every other kind of edit (wait time, travel time, max speed, fixed
|
||||
flags, leave type, dispatch schedule assignment) goes through this one method, distinguished by
|
||||
`flag`.
|
||||
|
||||
| Parameter | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `vehicle_id` | `int` | The `VehicleID` whose order list you're editing. You must own the company this vehicle belongs to. |
|
||||
| `order_position` | `int` | Zero-based index into the vehicle's order list (order 0, order 1, ...). Must be a valid, existing order — you can't create orders with this method, only edit existing ones. |
|
||||
| `flag` | `ModifyTimetableFlags` | Which field of the order to change (see table below). Import from `openttd.protocol`. |
|
||||
| `value` | `int` | The new value. **Its meaning depends entirely on `flag`** — see below. |
|
||||
| `clear_field` | `bool` | Only meaningful when `flag` is `WaitTime` or `TravelTime`. See "Clearing a field" below. Default `False`. |
|
||||
|
||||
### `ModifyTimetableFlags` values and what `value` means for each
|
||||
|
||||
| Flag | What it changes | `value` meaning |
|
||||
|---|---|---|
|
||||
| `ModifyTimetableFlags.WaitTime` | How long the vehicle waits at this order (e.g. at a station) | Wait time **in game ticks** |
|
||||
| `ModifyTimetableFlags.TravelTime` | How long the vehicle takes to travel to this order | Travel time **in game ticks** |
|
||||
| `ModifyTimetableFlags.TravelSpeed` | The order's max speed cap | Max speed in the order's internal speed unit (the same number shown in the Timetable window's speed column). Pass `0` to **remove** the speed cap entirely (no clamp) |
|
||||
| `ModifyTimetableFlags.SetWaitFixed` | Whether the wait time is "fixed" (locked, so autofill won't overwrite it) | `1` to fix, `0` to unfix |
|
||||
| `ModifyTimetableFlags.SetTravelFixed` | Whether the travel time is "fixed" (locked) | `1` to fix, `0` to unfix |
|
||||
| `ModifyTimetableFlags.SetLeaveType` | When the vehicle is allowed to leave this order early | `0` = normal (leave when timetabled), `1` = leave as soon as possible, `2` = leave early if any cargo is fully loaded, `3` = leave early if all cargo is fully loaded |
|
||||
| `ModifyTimetableFlags.AssignSchedule` | Which scheduled-dispatch schedule this order is tied to | A schedule index (`0`, `1`, ...), or `0xFFFFFFFF` (4294967295) to unassign (no schedule) |
|
||||
|
||||
A "tick" is the game's base simulation unit; how much real time it represents depends on the
|
||||
server's day-length setting, so there's no fixed ticks-per-second conversion you can rely on
|
||||
across servers.
|
||||
|
||||
### Clearing a field
|
||||
|
||||
`clear_field=True` only makes sense with `flag=WaitTime` or `flag=TravelTime`, and **you must also
|
||||
pass `value=0`** — the server rejects the command (silently, as always — you'll only notice because
|
||||
`get_vehicle_timetable()` won't show the change you expected) if `clear_field=True` and `value != 0`.
|
||||
Clearing is different from just setting the time to `0`:
|
||||
- `change_timetable(v, 0, ModifyTimetableFlags.WaitTime, 0)` — sets wait time to exactly 0 ticks,
|
||||
but the order is still considered "timetabled" (has an explicit time).
|
||||
- `change_timetable(v, 0, ModifyTimetableFlags.WaitTime, 0, clear_field=True)` — removes the
|
||||
timetabled wait time entirely (back to "no time set").
|
||||
|
||||
### Examples
|
||||
|
||||
```python
|
||||
from openttd.protocol import ModifyTimetableFlags
|
||||
|
||||
# Set order 0's wait time to 120 ticks.
|
||||
await client.change_timetable(7, 0, ModifyTimetableFlags.WaitTime, 120)
|
||||
|
||||
# Set order 1's travel time to 300 ticks.
|
||||
await client.change_timetable(7, 1, ModifyTimetableFlags.TravelTime, 300)
|
||||
|
||||
# Cap order 0's speed at 80 (speed units), then remove the cap again.
|
||||
await client.change_timetable(7, 0, ModifyTimetableFlags.TravelSpeed, 80)
|
||||
await client.change_timetable(7, 0, ModifyTimetableFlags.TravelSpeed, 0) # 0 = no cap
|
||||
|
||||
# Lock order 0's wait time so autofill won't touch it.
|
||||
await client.change_timetable(7, 0, ModifyTimetableFlags.SetWaitFixed, 1)
|
||||
|
||||
# Let the vehicle leave order 2 as soon as it's loaded, instead of waiting for the timetabled time.
|
||||
await client.change_timetable(7, 2, ModifyTimetableFlags.SetLeaveType, 1) # OLT_LEAVE_EARLY
|
||||
|
||||
# Assign order 0 to scheduled-dispatch schedule 0, then unassign it.
|
||||
await client.change_timetable(7, 0, ModifyTimetableFlags.AssignSchedule, 0)
|
||||
await client.change_timetable(7, 0, ModifyTimetableFlags.AssignSchedule, 0xFFFFFFFF)
|
||||
|
||||
# Clear order 0's wait time back to "not timetabled".
|
||||
await client.change_timetable(7, 0, ModifyTimetableFlags.WaitTime, 0, clear_field=True)
|
||||
```
|
||||
|
||||
## `autofill_timetable(vehicle_id, autofill=True, preserve_wait_time=False)`
|
||||
|
||||
Turns the "Autofill timetable" feature on or off for a vehicle. While autofill is active, the game
|
||||
fills in wait/travel times automatically as the vehicle completes each order, instead of you
|
||||
setting them manually with `change_timetable()`.
|
||||
|
||||
| Parameter | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `vehicle_id` | `int` | The vehicle to enable/disable autofill for. |
|
||||
| `autofill` | `bool` | `True` to start autofilling (also clears the "timetable has started" state — enabling autofill is how you (re)start building a timetable from scratch). `False` to stop. Default `True`. |
|
||||
| `preserve_wait_time` | `bool` | Only relevant when `autofill=True`. If `True`, autofill only *increases* existing wait times, never shortens them, instead of overwriting them outright. Default `False`. |
|
||||
|
||||
### Examples
|
||||
|
||||
```python
|
||||
# Start autofilling vehicle 7's timetable from scratch.
|
||||
await client.autofill_timetable(7, autofill=True, preserve_wait_time=False)
|
||||
|
||||
# Start autofilling, but never shrink wait times the vehicle already has set.
|
||||
await client.autofill_timetable(7, autofill=True, preserve_wait_time=True)
|
||||
|
||||
# Stop autofilling once you're happy with the result.
|
||||
await client.autofill_timetable(7, autofill=False)
|
||||
```
|
||||
|
||||
## `set_timetable_start(vehicle_id, timetable_all, start_date)`
|
||||
|
||||
Sets when a vehicle's timetable begins running.
|
||||
|
||||
| Parameter | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `vehicle_id` | `int` | The vehicle whose timetable start to set. |
|
||||
| `timetable_all` | `bool` | `True` to apply this start date to every vehicle that shares this vehicle's order list (a "vehicle group" running the same route); `False` to affect only this one vehicle. |
|
||||
| `start_date` | `int` | An **absolute `StateTicks` value** — OpenTTD's internal tick counter that always advances at the same rate regardless of day-length settings. It is *not* a calendar date and *not* relative to "now". |
|
||||
|
||||
**About `start_date`:** this library doesn't currently expose "what is the current `StateTicks`
|
||||
value" anywhere (the admin `ServerDate` packet reports a calendar date, which is a different,
|
||||
day-length-dependent counter). In practice you'll usually either: read a `timetable_start` value
|
||||
already observed via `get_vehicle_timetable()` on another vehicle in the same group and reuse it,
|
||||
or coordinate the value out-of-band (e.g. from an in-game GameScript, or a known baseline) rather
|
||||
than computing "now" purely from this client.
|
||||
|
||||
### Examples
|
||||
|
||||
```python
|
||||
# Start vehicle 7's own timetable at StateTicks 1_000_000.
|
||||
await client.set_timetable_start(7, timetable_all=False, start_date=1_000_000)
|
||||
|
||||
# Start the timetable for every vehicle sharing vehicle 7's orders, all at the same tick.
|
||||
await client.set_timetable_start(7, timetable_all=True, start_date=1_000_000)
|
||||
```
|
||||
|
||||
## `set_vehicle_on_time(vehicle_id, apply_to_group=False)`
|
||||
|
||||
Resets a vehicle's **lateness counter to zero** (marks it on-time). This command can only reduce
|
||||
lateness to zero — there is no way to use it to mark a vehicle as *late*.
|
||||
|
||||
| Parameter | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `vehicle_id` | `int` | The vehicle to reset lateness for. |
|
||||
| `apply_to_group` | `bool` | `False` (default): reset only this vehicle. `True`: reset lateness for every vehicle sharing this vehicle's order list, by the same amount (so their relative spacing is preserved), instead of just this one. |
|
||||
|
||||
Note: if `apply_to_group=False` and the vehicle's timetable hasn't been started yet (see
|
||||
`set_timetable_start()`), the server rejects the command — but since there's no success/failure
|
||||
signal on the wire, you won't see an error, `get_vehicle_timetable()` will just show the request
|
||||
was made without the underlying lateness actually having changed.
|
||||
|
||||
### Examples
|
||||
|
||||
```python
|
||||
# Reset lateness for just this vehicle.
|
||||
await client.set_vehicle_on_time(7, apply_to_group=False)
|
||||
|
||||
# Reset lateness for the whole group of vehicles sharing vehicle 7's orders.
|
||||
await client.set_vehicle_on_time(7, apply_to_group=True)
|
||||
```
|
||||
|
||||
## `get_vehicle_timetable(vehicle_id)`
|
||||
|
||||
A **synchronous** method (no `await`, no network round-trip) that returns whatever this client has
|
||||
locally observed about a vehicle's timetable so far, or `None` if nothing has been observed for
|
||||
that vehicle id yet.
|
||||
|
||||
```python
|
||||
entry = client.get_vehicle_timetable(7)
|
||||
```
|
||||
|
||||
Returns either `None`, or a `dict` shaped like:
|
||||
|
||||
```python
|
||||
{
|
||||
"orders": {
|
||||
0: {"wait_time": 120, "wait_time_fixed": True},
|
||||
2: {"travel_time": 300, "leave_type": 1},
|
||||
# only order positions that have been touched by an observed change_timetable() appear here
|
||||
},
|
||||
"autofill": True, # present after an observed autofill_timetable()
|
||||
"autofill_preserve_wait_time": False,
|
||||
"timetable_start": 1000000, # present after an observed set_timetable_start()
|
||||
"timetable_all": False,
|
||||
"on_time_apply_to_group": False, # present after an observed set_vehicle_on_time()
|
||||
}
|
||||
```
|
||||
|
||||
Every top-level key is optional and only appears once the corresponding change has actually been
|
||||
observed — a freshly-joined client that hasn't seen any broadcasts yet for a vehicle returns `None`
|
||||
for it, and a vehicle that's only had its wait time changed won't have an `"autofill"` key at all.
|
||||
Per-order fields inside `"orders"` follow the same rule: only fields that have been explicitly set
|
||||
via `change_timetable()` appear; a cleared field (`clear_field=True`) is stored as `None` rather
|
||||
than being removed, so you can distinguish "never touched" (key absent) from "explicitly cleared"
|
||||
(key present, value `None`).
|
||||
|
||||
## Putting it together
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from openttd import OpenTTDClient
|
||||
from openttd.protocol import ModifyTimetableFlags
|
||||
|
||||
async def build_timetable(client, vehicle_id):
|
||||
# 1. Let autofill do a first pass, preserving anything already set.
|
||||
await client.autofill_timetable(vehicle_id, autofill=True, preserve_wait_time=True)
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
# 2. Manually lock in the wait time for a specific order once you're happy with it.
|
||||
await client.change_timetable(vehicle_id, 0, ModifyTimetableFlags.WaitTime, 90)
|
||||
await client.change_timetable(vehicle_id, 0, ModifyTimetableFlags.SetWaitFixed, 1)
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
# 3. Turn autofill off and start the timetable running for the whole group.
|
||||
await client.autofill_timetable(vehicle_id, autofill=False)
|
||||
await client.set_timetable_start(vehicle_id, timetable_all=True, start_date=1_000_000)
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
print(client.get_vehicle_timetable(vehicle_id))
|
||||
|
||||
async def main():
|
||||
client = OpenTTDClient(host="127.0.0.1", username="TimetableBot")
|
||||
await client.connect(server_password="asd")
|
||||
await client.join_company(company_id=0, company_password="")
|
||||
await client.joined.wait()
|
||||
|
||||
await build_timetable(client, vehicle_id=7)
|
||||
|
||||
await client.quit()
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## Adding and removing orders
|
||||
|
||||
Beyond editing an order's timetable fields, you can change the order list itself. Both commands go
|
||||
over the game port and require being joined to the company that owns the vehicle (like the timetable
|
||||
methods above).
|
||||
|
||||
```python
|
||||
# Append a "go to station" order (station id 6) to the end of vehicle 7's order list.
|
||||
await client.add_order(7, 6)
|
||||
|
||||
# Insert one before position 0 instead of appending.
|
||||
await client.add_order(7, 6, before_position=0)
|
||||
|
||||
# Non-stop / stop-location can be customised (defaults suit every vehicle type).
|
||||
from openttd.protocol import OrderNonStopFlags
|
||||
await client.add_order(7, 6, non_stop=OrderNonStopFlags.NoStopAtIntermediate)
|
||||
|
||||
# Delete the order at a given position.
|
||||
await client.remove_order(7, 0)
|
||||
```
|
||||
|
||||
`add_order()` builds "go to station" orders. `stop_location` defaults to `PlatformFarEnd` because the
|
||||
other stop locations are train-only and rejected for road vehicles, ships and aircraft. There is no
|
||||
game-port query for the resulting order list; confirm changes with the admin `get_timetable()` order
|
||||
count (see [PROTOCOL.md](PROTOCOL.md#adding--removing-orders)).
|
||||
|
||||
## Scheduled dispatch (JGRPP)
|
||||
|
||||
Scheduled dispatch lets a vehicle depart on a fixed schedule of slots rather than purely by
|
||||
timetable. A vehicle's order list can hold several dispatch schedules, each with a duration, a start
|
||||
tick and a set of departure slots. The edit commands go over the game port and require being joined
|
||||
to the owning company; the authoritative read is on the admin client.
|
||||
|
||||
```python
|
||||
# Create a schedule (start tick 0, duration 3000 ticks) — it becomes the next schedule index.
|
||||
await client.add_dispatch_schedule(7, 0, 3000)
|
||||
|
||||
# Add departure slots at offsets 500 and 1500 within schedule 0's duration.
|
||||
await client.add_dispatch_slot(7, 0, 500)
|
||||
await client.add_dispatch_slot(7, 0, 1500)
|
||||
|
||||
# Add several evenly spaced slots at once: offset 0, then +250 three more times.
|
||||
await client.add_dispatch_slot(7, 0, 0, interval=250, extra_slots=3)
|
||||
|
||||
# Adjust the schedule, then turn scheduled dispatch on for the vehicle.
|
||||
await client.set_dispatch_duration(7, 0, 4000)
|
||||
await client.set_dispatch_start_date(7, 0, 1_000_000)
|
||||
await client.set_scheduled_dispatch(7, True)
|
||||
|
||||
# Remove a slot, clear a schedule's slots, or remove the whole schedule.
|
||||
await client.remove_dispatch_slot(7, 0, 1500)
|
||||
await client.clear_dispatch_schedule(7, 0)
|
||||
await client.remove_dispatch_schedule(7, 0)
|
||||
```
|
||||
|
||||
Read the live state back over the admin connection (requires the patched JGRPP build, see
|
||||
[docker/patches/README.md](../docker/patches/README.md)):
|
||||
|
||||
```python
|
||||
data = await admin.get_dispatch(7)
|
||||
# {"enabled": 1, "schedules": [{"index": 0, "duration": 4000, "start_tick": 1000000,
|
||||
# "delay": 0, "reuse_slots": 0, "slots": [{"offset": 500, "flags": 0}, ...]}]}
|
||||
```
|
||||
|
||||
The client implements a common core of the ~22 JGRPP dispatch commands; advanced operations
|
||||
(departure routes/tags, per-slot flags, adjust/swap/duplicate) are not wrapped yet.
|
||||
|
||||
## See also
|
||||
- [PROTOCOL.md — Vehicle 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.
|
||||
@@ -0,0 +1,88 @@
|
||||
# AdminBridge GameScript
|
||||
|
||||
The server-side half of this project. Every `OpenTTDAdminClient` feature that goes over the
|
||||
GameScript JSON channel — `list_vehicles`, `list_stations`, `list_cargo`, `get_timetable`,
|
||||
`get_station`, `get_station_cargo`, `get_dispatch` and the `subscribe_events` push stream — is a
|
||||
command this script answers. The Python client is only half the protocol; this is the other half,
|
||||
and the two are documented together in [docs/PROTOCOL.md](../../docs/PROTOCOL.md).
|
||||
|
||||
It is a **deity** GameScript: it joins no company, builds nothing, and only reads game state and
|
||||
replies on the admin port via `GSAdmin.Send()`.
|
||||
|
||||
- `info.nut` — the manifest OpenTTD's script scanner reads (name, version, API version).
|
||||
- `main.nut` — the bridge itself: a command dispatch table, one handler per command, and the
|
||||
event poller.
|
||||
|
||||
## Why it lives here
|
||||
|
||||
`docker/config/` is gitignored — it also holds savegames, downloaded content and generated
|
||||
config — so a GameScript kept there is invisible to review and to CI, and a fresh clone cannot
|
||||
reproduce the server side at all. This directory is the source of truth, the same arrangement
|
||||
[docker/patches/](../../docker/patches/README.md) uses for the local JGRPP patches.
|
||||
|
||||
`docker-compose.yml` bind-mounts this directory read-only over the container's
|
||||
`game/AdminBridge`, so the server runs the tracked copy and nothing else:
|
||||
|
||||
```yaml
|
||||
- ../gamescript/AdminBridge:/home/openttd/.local/share/openttd/game/AdminBridge:ro
|
||||
```
|
||||
|
||||
Editing the copy under `docker/config/game/AdminBridge/` therefore has no effect; that path is
|
||||
shadowed by the mount. On a server not started through this compose file, copy the directory
|
||||
into the OpenTTD data dir instead:
|
||||
|
||||
```bash
|
||||
cp -r gamescript/AdminBridge ~/.local/share/openttd/game/
|
||||
```
|
||||
|
||||
A GameScript is loaded when the game starts, so a change needs the server restarted (or the
|
||||
game reloaded) before it takes effect — the running instance keeps the old code.
|
||||
|
||||
## Protocol version
|
||||
|
||||
`info.nut`'s `GetVersion()` is the version of the **JSON protocol**, not of the implementation:
|
||||
bump it when a command, field or event kind changes shape, not for a refactor. Three places
|
||||
carry it and all three must move together:
|
||||
|
||||
| Where | What |
|
||||
| :--- | :--- |
|
||||
| `info.nut` → `GetVersion()` | what OpenTTD records in the savegame |
|
||||
| `main.nut` → `BRIDGE_VERSION` | what the `get_version` command reports |
|
||||
| `lib/openttd/protocol.py` → `GS_BRIDGE_VERSION` | what the client requires |
|
||||
|
||||
A GameScript cannot read its own `info.nut` at runtime — `GSController.GetVersion()` returns the
|
||||
*OpenTTD* version — hence the duplication. [`tests/test_gamescript.py`](../../tests/test_gamescript.py)
|
||||
pins the three together, along with the event catalogue and the command list, so a half-finished
|
||||
bump fails in CI rather than against a live server.
|
||||
|
||||
`OpenTTDAdminClient.get_bridge_version()` is the client end of this: it asks the running bridge
|
||||
and raises if it is too old, which is worth doing once at startup. Without it a stale bridge
|
||||
gives no error at all — it does not recognise the command, so the client just waits out its
|
||||
timeout. A bridge older than version 4 predates `get_version` itself and can only fail that way.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **JGRPP**, patched with `docker/patches/` — `get_timetable` and `get_dispatch` call `GSOrder`
|
||||
getters those patches add. Every other command uses stock GameScript API, so an unpatched
|
||||
server still answers them.
|
||||
- The admin client must subscribe with `update_frequency(Gamescript, Automatic)` or the server
|
||||
drops every reply. The client does this automatically on first use.
|
||||
- GameScripts do not tick while the game is **paused**, so a paused server answers nothing and
|
||||
every query times out.
|
||||
|
||||
## Which GameScript actually runs
|
||||
|
||||
OpenTTD runs exactly **one** GameScript, and a savegame remembers the one it was played with by
|
||||
name: loading it overrides `[game_scripts]` in `openttd.cfg`. So a savegame that pins some other
|
||||
script leaves this bridge unloaded, and every command times out no matter what is mounted where.
|
||||
The symptom to recognise is commands timing out *uniformly* — as opposed to a stale bridge, where
|
||||
the older commands still answer and only newer ones hang.
|
||||
|
||||
## Verifying a change
|
||||
|
||||
`docker/config` being untracked used to mean this file could only be tested against a running
|
||||
server. It still needs one for the real check — `pytest -m e2e` exercises every command end to
|
||||
end, and [docs/TESTING.md](../../docs/TESTING.md) covers starting the server. Before that,
|
||||
`pytest -m "not e2e" tests/test_gamescript.py` catches the drift a server would only reveal as a
|
||||
timeout. Note that a syntax error anywhere in `main.nut` stops the whole bridge from loading, so
|
||||
every command fails at once — check the server log (`docker compose logs`) for the compile error.
|
||||
@@ -0,0 +1,19 @@
|
||||
class AdminBridge extends GSInfo {
|
||||
function GetAuthor() { return "Gemini"; }
|
||||
function GetName() { return "AdminBridge"; }
|
||||
function GetShortName() { return "ADMB"; }
|
||||
function GetDescription() { return "Answers JSON commands from the admin port on behalf of an admin client."; }
|
||||
/* Bridge protocol version. Must equal BRIDGE_VERSION in main.nut, which is what the
|
||||
* get_version command reports; tests/test_gamescript.py pins the two together and to
|
||||
* openttd.protocol.GS_BRIDGE_VERSION. Bump it whenever the JSON protocol changes. */
|
||||
function GetVersion() { return 4; }
|
||||
/* Any version of this script can take over from any older one: the bridge keeps no
|
||||
* savegame state of its own (it has no Save()), so there is nothing to migrate. Without
|
||||
* this, the default is GetVersion(), and loading a savegame that pinned an older version
|
||||
* makes the engine fall back with a "no longer available" warning. */
|
||||
function MinVersionToLoad() { return 1; }
|
||||
function GetAPIVersion() { return "1.10"; }
|
||||
function GetDate() { return "2026-08-31"; }
|
||||
function CreateInstance() { return "AdminBridge"; }
|
||||
}
|
||||
RegisterGS(AdminBridge());
|
||||
@@ -0,0 +1,722 @@
|
||||
class AdminBridge extends GSController {
|
||||
/* --- Event subscription state (see HandleSubscribeEvents) --- */
|
||||
event_kinds = null; // table of subscribed kind -> true; null when not subscribed
|
||||
event_order = null; // canonical kind ordering, so subscribe replies are stable
|
||||
event_catalog = null; // table of every supported kind -> true, for validation
|
||||
interval = 10; // ticks between state polls
|
||||
sleep_ticks = 10; // ticks slept per main-loop iteration
|
||||
last_poll = 0; // tick of the last state poll
|
||||
seeded = false; // false until the first poll has recorded a state baseline
|
||||
company_id = null; // optional owner filter for the polled vehicle/station events
|
||||
watch_vehicles = null; // explicit vehicle ids to poll; null = every vehicle
|
||||
watch_stations = null; // explicit station ids to poll; null = every station
|
||||
cargo_ids = null; // cargo ids to inspect; set at subscribe time
|
||||
min_cargo_delta = 1; // smallest waiting-amount change worth a cargo_waiting event
|
||||
include_cargo = true; // attach each vehicle's load to arrive/depart events
|
||||
vehicle_at = null; // vehicle id -> { station = id or -1, since = tick }
|
||||
cargo_prev = null; // station id -> table of cargo id -> last seen waiting amount
|
||||
|
||||
/* Cap on how many events one poll may emit. A first poll over a busy map can otherwise
|
||||
* produce thousands at once; past the cap the rest are dropped and reported as such. */
|
||||
MAX_EVENTS_PER_POLL = 200;
|
||||
/* Events per admin packet. Kept well under the packet size limit so a batch always fits. */
|
||||
EVENT_BATCH_SIZE = 24;
|
||||
|
||||
/* Version of the JSON protocol spoken over the admin port, reported by get_version.
|
||||
* A GameScript cannot read its own info.nut at runtime (GSController.GetVersion() returns
|
||||
* the *OpenTTD* version), so this duplicates info.nut's GetVersion() and the two are pinned
|
||||
* together — along with the client's openttd.protocol.GS_BRIDGE_VERSION — by
|
||||
* tests/test_gamescript.py. Bump all three whenever the protocol changes. */
|
||||
BRIDGE_VERSION = 4;
|
||||
|
||||
/* Every command the bridge answers: name -> handler method plus the request fields that
|
||||
* handler needs. HandleCommand dispatches through this and get_version reports it, so the
|
||||
* catalogue a client feature-detects against cannot drift from what is implemented. */
|
||||
COMMANDS = {
|
||||
get_version = { handler = "HandleGetVersion", requires = [] },
|
||||
list_vehicles = { handler = "HandleListVehicles", requires = [] },
|
||||
list_stations = { handler = "HandleListStations", requires = [] },
|
||||
list_cargo = { handler = "HandleListCargo", requires = [] },
|
||||
get_timetable = { handler = "HandleGetTimetable", requires = ["vehicle_id"] },
|
||||
get_station = { handler = "HandleGetStation", requires = ["station_id"] },
|
||||
get_station_cargo = { handler = "HandleGetStationCargo", requires = ["station_id", "cargo_id"] },
|
||||
get_dispatch = { handler = "HandleGetDispatch", requires = ["vehicle_id"] },
|
||||
subscribe_events = { handler = "HandleSubscribeEvents", requires = [] },
|
||||
unsubscribe_events = { handler = "HandleUnsubscribeEvents", requires = [] },
|
||||
};
|
||||
|
||||
function InitEvents() {
|
||||
this.ResetEventState();
|
||||
this.event_order = [
|
||||
/* Synthesised by polling game state (PollState). */
|
||||
"vehicle_arrive", "vehicle_depart", "cargo_waiting",
|
||||
/* Native GameScript events, forwarded as they arrive (HandleNativeEvent). */
|
||||
"vehicle_crashed", "station_first_vehicle",
|
||||
"industry_open", "industry_close", "town_founded",
|
||||
"company_new", "company_in_trouble", "company_bankrupt",
|
||||
"subsidy_offer", "subsidy_offer_expired", "subsidy_awarded", "subsidy_expired"
|
||||
];
|
||||
this.event_catalog = {};
|
||||
foreach (kind in this.event_order) this.event_catalog[kind] <- true;
|
||||
}
|
||||
|
||||
function Start() {
|
||||
GSLog.Info("AdminBridge started.");
|
||||
this.InitEvents();
|
||||
while (true) {
|
||||
this.Sleep(this.sleep_ticks);
|
||||
local pending = [];
|
||||
local event = GSEventController.GetNextEvent();
|
||||
while (event != null) {
|
||||
if (event.GetEventType() == GSEvent.ET_ADMIN_PORT) {
|
||||
local admin_event = GSEventAdminPort.Convert(event);
|
||||
local request = admin_event.GetObject();
|
||||
if (request != null && "command" in request) this.HandleCommand(request);
|
||||
} else if (this.event_kinds != null) {
|
||||
this.HandleNativeEvent(event, pending);
|
||||
}
|
||||
event = GSEventController.GetNextEvent();
|
||||
}
|
||||
if (this.event_kinds != null) this.PollState(pending);
|
||||
this.SendEventBatch(pending);
|
||||
}
|
||||
}
|
||||
|
||||
function HandleCommand(request) {
|
||||
if (!(request.command in this.COMMANDS)) return;
|
||||
local spec = this.COMMANDS[request.command];
|
||||
/* A command missing the fields its handler needs is dropped rather than answered:
|
||||
* there is nothing to answer *about*, and no request_id is guaranteed either. */
|
||||
foreach (field in spec.requires) {
|
||||
if (!(field in request)) return;
|
||||
}
|
||||
this[spec.handler](request);
|
||||
}
|
||||
|
||||
/* Version handshake. A client expecting a newer bridge than the server runs would otherwise
|
||||
* just time out on the first command this bridge does not know; asking here turns that into
|
||||
* an answer. The two catalogues let a client feature-detect, which survives a bridge that
|
||||
* gained commands out of version order better than comparing numbers does. */
|
||||
function HandleGetVersion(request) {
|
||||
local commands = [];
|
||||
foreach (name, _ in this.COMMANDS) commands.append(name);
|
||||
commands.sort(); // table iteration order is arbitrary; keep the reply stable
|
||||
local events = [];
|
||||
foreach (kind in this.event_order) events.append(kind);
|
||||
local reply = { command = "get_version", version = this.BRIDGE_VERSION,
|
||||
commands = commands, events = events };
|
||||
if ("request_id" in request) reply.request_id <- request.request_id;
|
||||
GSAdmin.Send(reply);
|
||||
}
|
||||
|
||||
/* Cargo id -> label ("PASS", "COAL", ...) so callers can name what a vehicle carries.
|
||||
* Labels come from the NewGRF cargo table, so ids are not stable across games. */
|
||||
function HandleListCargo(request) {
|
||||
local cargo = [];
|
||||
foreach (c, _ in GSCargoList()) {
|
||||
local label = "";
|
||||
try { label = GSCargo.GetCargoLabel(c); } catch (e) { label = ""; }
|
||||
local freight = 0;
|
||||
try { freight = GSCargo.IsFreight(c) ? 1 : 0; } catch (e) { freight = 0; }
|
||||
cargo.append({ cargo_id = c, label = label, freight = freight });
|
||||
}
|
||||
local reply = { command = "list_cargo", cargo = cargo };
|
||||
if ("request_id" in request) reply.request_id <- request.request_id;
|
||||
GSAdmin.Send(reply);
|
||||
}
|
||||
|
||||
function HandleListVehicles(request) {
|
||||
local vehicle_list = GSVehicleList();
|
||||
local vehicles = [];
|
||||
foreach (v, _ in vehicle_list) {
|
||||
if (!GSVehicle.IsValidVehicle(v)) continue;
|
||||
local next_stop = "None";
|
||||
if (GSOrder.GetOrderCount(v) > 0) {
|
||||
local dest_tile = GSOrder.GetOrderDestination(v, GSOrder.ORDER_CURRENT);
|
||||
local station_id = GSStation.GetStationID(dest_tile);
|
||||
if (GSStation.IsValidStation(station_id)) next_stop = GSStation.GetName(station_id);
|
||||
}
|
||||
/* Which cargoes this vehicle can actually carry. A vehicle (an articulated
|
||||
* train especially) can have capacity for several cargo types, so report
|
||||
* every one with a non-zero capacity rather than a single "cargo type". */
|
||||
local cargo = [];
|
||||
foreach (c, _ in GSCargoList()) {
|
||||
local cap = GSVehicle.GetCapacity(v, c);
|
||||
if (cap > 0) cargo.append({ cargo_id = c, capacity = cap });
|
||||
}
|
||||
vehicles.append({
|
||||
id = v,
|
||||
age = GSVehicle.GetAge(v),
|
||||
max_age = GSVehicle.GetMaxAge(v),
|
||||
next_stop = next_stop,
|
||||
type = GSVehicle.GetVehicleType(v),
|
||||
owner = GSVehicle.GetOwner(v),
|
||||
order_count = GSOrder.GetOrderCount(v),
|
||||
cargo = cargo
|
||||
});
|
||||
}
|
||||
local reply = { vehicles = vehicles };
|
||||
if ("request_id" in request) reply.request_id <- request.request_id;
|
||||
GSAdmin.Send(reply);
|
||||
}
|
||||
|
||||
function HandleGetTimetable(request) {
|
||||
local v = request.vehicle_id;
|
||||
local reply = { command = "get_timetable", vehicle_id = v };
|
||||
if ("request_id" in request) reply.request_id <- request.request_id;
|
||||
if (!GSVehicle.IsValidVehicle(v)) {
|
||||
reply.error <- "invalid_vehicle";
|
||||
GSAdmin.Send(reply);
|
||||
return;
|
||||
}
|
||||
reply.lateness <- GSOrder.GetTimetableLateness(v);
|
||||
reply.start_tick <- GSOrder.GetTimetableStartTick(v);
|
||||
reply.current_order_time <- GSOrder.GetCurrentOrderTime(v);
|
||||
reply.total_duration <- GSOrder.GetTimetableTotalDuration(v);
|
||||
local orders = [];
|
||||
local count = GSOrder.GetOrderCount(v);
|
||||
for (local i = 0; i < count; i++) {
|
||||
/* Resolve the order's destination station so callers can build a station
|
||||
* graph (vertices = stations, edges = segments). Non-station orders
|
||||
* (depots, waypoints, conditional) have no station: report -1. */
|
||||
local station_id = -1;
|
||||
if (GSOrder.IsValidVehicleOrder(v, i) && GSOrder.IsGotoStationOrder(v, i)) {
|
||||
local sid = GSStation.GetStationID(GSOrder.GetOrderDestination(v, i));
|
||||
if (GSStation.IsValidStation(sid)) station_id = sid;
|
||||
}
|
||||
orders.append({
|
||||
position = i,
|
||||
station_id = station_id,
|
||||
wait_time = GSOrder.GetTimetableWaitTime(v, i),
|
||||
travel_time = GSOrder.GetTimetableTravelTime(v, i),
|
||||
wait_timetabled = GSOrder.IsWaitTimetabled(v, i) ? 1 : 0,
|
||||
travel_timetabled = GSOrder.IsTravelTimetabled(v, i) ? 1 : 0,
|
||||
wait_fixed = GSOrder.IsWaitFixed(v, i) ? 1 : 0,
|
||||
travel_fixed = GSOrder.IsTravelFixed(v, i) ? 1 : 0,
|
||||
leave_type = GSOrder.GetLeaveType(v, i),
|
||||
max_speed = GSOrder.GetTimetableMaxSpeed(v, i)
|
||||
});
|
||||
}
|
||||
reply.orders <- orders;
|
||||
if (!GSAdmin.Send(reply)) {
|
||||
/* Response exceeded the admin packet size limit; send a small error
|
||||
* instead so the waiting client fails fast rather than timing out. */
|
||||
local fallback = { command = "get_timetable", vehicle_id = v, error = "response_too_large" };
|
||||
if ("request_id" in request) fallback.request_id <- request.request_id;
|
||||
GSAdmin.Send(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
function HandleListStations(request) {
|
||||
/* GameScripts run as a deity, so GSStationList lists every company's stations;
|
||||
* an optional company_id filters to a single owner (the deity list ignores it). */
|
||||
local filter_owner = ("company_id" in request) ? request.company_id : null;
|
||||
local station_list = GSStationList(GSStation.STATION_ANY);
|
||||
local stations = [];
|
||||
foreach (s, _ in station_list) {
|
||||
if (!GSStation.IsValidStation(s)) continue;
|
||||
local owner = GSStation.GetOwner(s);
|
||||
if (filter_owner != null && owner != filter_owner) continue;
|
||||
stations.append({
|
||||
id = s,
|
||||
name = GSStation.GetName(s),
|
||||
location = GSStation.GetLocation(s),
|
||||
owner = owner
|
||||
});
|
||||
}
|
||||
local reply = { command = "list_stations", stations = stations };
|
||||
if ("request_id" in request) reply.request_id <- request.request_id;
|
||||
GSAdmin.Send(reply);
|
||||
}
|
||||
|
||||
function HandleGetStation(request) {
|
||||
local sid = request.station_id;
|
||||
local reply = { command = "get_station", station_id = sid };
|
||||
if ("request_id" in request) reply.request_id <- request.request_id;
|
||||
if (!GSStation.IsValidStation(sid)) {
|
||||
reply.error <- "invalid_station";
|
||||
GSAdmin.Send(reply);
|
||||
return;
|
||||
}
|
||||
reply.name <- GSStation.GetName(sid);
|
||||
reply.location <- GSStation.GetLocation(sid);
|
||||
reply.owner <- GSStation.GetOwner(sid);
|
||||
local cargo = [];
|
||||
local cargo_list = GSCargoList();
|
||||
foreach (c, _ in cargo_list) {
|
||||
local waiting = GSStation.GetCargoWaiting(sid, c);
|
||||
local planned = GSStation.GetCargoPlanned(sid, c);
|
||||
local has_rating = GSStation.HasCargoRating(sid, c);
|
||||
/* Skip cargo the station has never handled to keep the reply compact. */
|
||||
if (waiting <= 0 && planned <= 0 && !has_rating) continue;
|
||||
cargo.append({
|
||||
cargo_id = c,
|
||||
waiting = waiting, // real-time: units currently waiting
|
||||
planned = planned, // planned: cargodist link-graph flow
|
||||
rating = has_rating ? GSStation.GetCargoRating(sid, c) : null
|
||||
});
|
||||
}
|
||||
reply.cargo <- cargo;
|
||||
if (!GSAdmin.Send(reply)) {
|
||||
/* Response exceeded the admin packet size limit; send a small error
|
||||
* instead so the waiting client fails fast rather than timing out. */
|
||||
local fallback = { command = "get_station", station_id = sid, error = "response_too_large" };
|
||||
if ("request_id" in request) fallback.request_id <- request.request_id;
|
||||
GSAdmin.Send(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
/* Convert a GSList of station_id -> amount into an array of {station, amount},
|
||||
* dropping zero entries to keep the reply compact. */
|
||||
function CargoListToPairs(list) {
|
||||
local pairs = [];
|
||||
foreach (station, _ in list) {
|
||||
local amount = list.GetValue(station);
|
||||
if (amount <= 0) continue;
|
||||
pairs.append({ station = station, amount = amount });
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
function HandleGetStationCargo(request) {
|
||||
local sid = request.station_id;
|
||||
local cargo = request.cargo_id;
|
||||
local reply = { command = "get_station_cargo", station_id = sid, cargo_id = cargo };
|
||||
if ("request_id" in request) reply.request_id <- request.request_id;
|
||||
if (!GSStation.IsValidStation(sid)) {
|
||||
reply.error <- "invalid_station";
|
||||
GSAdmin.Send(reply);
|
||||
return;
|
||||
}
|
||||
if (!GSCargo.IsValidCargo(cargo)) {
|
||||
reply.error <- "invalid_cargo";
|
||||
GSAdmin.Send(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Optional source (from) and next-hop (via) filters. STATION_INVALID is a legal value
|
||||
* (deleted source / "via any"); any other non-station value is rejected. */
|
||||
local from = null;
|
||||
local via = null;
|
||||
if ("from_station" in request) {
|
||||
from = request.from_station;
|
||||
if (from != GSStation.STATION_INVALID && !GSStation.IsValidStation(from)) {
|
||||
reply.error <- "invalid_from_station";
|
||||
GSAdmin.Send(reply);
|
||||
return;
|
||||
}
|
||||
reply.from_station <- from;
|
||||
}
|
||||
if ("via_station" in request) {
|
||||
via = request.via_station;
|
||||
if (via != GSStation.STATION_INVALID && !GSStation.IsValidStation(via)) {
|
||||
reply.error <- "invalid_via_station";
|
||||
GSAdmin.Send(reply);
|
||||
return;
|
||||
}
|
||||
reply.via_station <- via;
|
||||
}
|
||||
|
||||
/* Totals, honouring whichever filters were supplied. */
|
||||
if (from != null && via != null) {
|
||||
reply.waiting <- GSStation.GetCargoWaitingFromVia(sid, from, via, cargo);
|
||||
reply.planned <- GSStation.GetCargoPlannedFromVia(sid, from, via, cargo);
|
||||
} else if (from != null) {
|
||||
reply.waiting <- GSStation.GetCargoWaitingFrom(sid, from, cargo);
|
||||
reply.planned <- GSStation.GetCargoPlannedFrom(sid, from, cargo);
|
||||
} else if (via != null) {
|
||||
reply.waiting <- GSStation.GetCargoWaitingVia(sid, via, cargo);
|
||||
reply.planned <- GSStation.GetCargoPlannedVia(sid, via, cargo);
|
||||
} else {
|
||||
reply.waiting <- GSStation.GetCargoWaiting(sid, cargo);
|
||||
reply.planned <- GSStation.GetCargoPlanned(sid, cargo);
|
||||
}
|
||||
|
||||
/* Breakdown grouped by source station (restricted to the via filter if given). */
|
||||
local w_by_from = (via != null)
|
||||
? GSStationList_CargoWaitingViaByFrom(sid, cargo, via)
|
||||
: GSStationList_CargoWaitingByFrom(sid, cargo);
|
||||
local p_by_from = (via != null)
|
||||
? GSStationList_CargoPlannedViaByFrom(sid, cargo, via)
|
||||
: GSStationList_CargoPlannedByFrom(sid, cargo);
|
||||
reply.waiting_by_from <- this.CargoListToPairs(w_by_from);
|
||||
reply.planned_by_from <- this.CargoListToPairs(p_by_from);
|
||||
|
||||
/* Breakdown grouped by next hop (restricted to the from filter if given). */
|
||||
local w_by_via = (from != null)
|
||||
? GSStationList_CargoWaitingFromByVia(sid, cargo, from)
|
||||
: GSStationList_CargoWaitingByVia(sid, cargo);
|
||||
local p_by_via = (from != null)
|
||||
? GSStationList_CargoPlannedFromByVia(sid, cargo, from)
|
||||
: GSStationList_CargoPlannedByVia(sid, cargo);
|
||||
reply.waiting_by_via <- this.CargoListToPairs(w_by_via);
|
||||
reply.planned_by_via <- this.CargoListToPairs(p_by_via);
|
||||
|
||||
if (!GSAdmin.Send(reply)) {
|
||||
/* Response exceeded the admin packet size limit; send a small error
|
||||
* instead so the waiting client fails fast rather than timing out. */
|
||||
local fallback = { command = "get_station_cargo", station_id = sid, cargo_id = cargo, error = "response_too_large" };
|
||||
if ("request_id" in request) fallback.request_id <- request.request_id;
|
||||
GSAdmin.Send(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
function HandleGetDispatch(request) {
|
||||
local v = request.vehicle_id;
|
||||
local reply = { command = "get_dispatch", vehicle_id = v };
|
||||
if ("request_id" in request) reply.request_id <- request.request_id;
|
||||
|
||||
local count = GSOrder.GetScheduledDispatchScheduleCount(v);
|
||||
if (count < 0) {
|
||||
reply.error <- "invalid_vehicle";
|
||||
GSAdmin.Send(reply);
|
||||
return;
|
||||
}
|
||||
reply.enabled <- GSOrder.IsScheduledDispatchEnabled(v);
|
||||
local schedules = [];
|
||||
for (local s = 0; s < count; s++) {
|
||||
local slots = [];
|
||||
local slot_count = GSOrder.GetScheduledDispatchSlotCount(v, s);
|
||||
for (local k = 0; k < slot_count; k++) {
|
||||
slots.append({
|
||||
offset = GSOrder.GetScheduledDispatchSlotOffset(v, s, k),
|
||||
flags = GSOrder.GetScheduledDispatchSlotFlags(v, s, k)
|
||||
});
|
||||
}
|
||||
schedules.append({
|
||||
index = s,
|
||||
duration = GSOrder.GetScheduledDispatchDuration(v, s),
|
||||
start_tick = GSOrder.GetScheduledDispatchStartTick(v, s),
|
||||
delay = GSOrder.GetScheduledDispatchDelay(v, s),
|
||||
reuse_slots = GSOrder.GetScheduledDispatchReuseSlots(v, s),
|
||||
slots = slots
|
||||
});
|
||||
}
|
||||
reply.schedules <- schedules;
|
||||
if (!GSAdmin.Send(reply)) {
|
||||
/* Response exceeded the admin packet size limit; send a small error
|
||||
* instead so the waiting client fails fast rather than timing out. */
|
||||
local fallback = { command = "get_dispatch", vehicle_id = v, error = "response_too_large" };
|
||||
if ("request_id" in request) fallback.request_id <- request.request_id;
|
||||
GSAdmin.Send(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Events ---
|
||||
*
|
||||
* The engine has no GameScript event for "a vehicle reached a stop" or "cargo arrived",
|
||||
* so those are synthesised here: every `interval` ticks the bridge samples the watched
|
||||
* vehicles and stations and emits an event for each change against the previous sample.
|
||||
* The handful of events the engine *does* raise for a deity script (crashes, industries,
|
||||
* companies, ...) are forwarded straight through. Everything is pushed to the admin port
|
||||
* unsolicited, batched as { command = "events", events = [...] }. */
|
||||
|
||||
function ResetEventState() {
|
||||
this.event_kinds = null;
|
||||
this.interval = 10;
|
||||
this.sleep_ticks = 10;
|
||||
this.last_poll = 0;
|
||||
this.seeded = false;
|
||||
this.company_id = null;
|
||||
this.watch_vehicles = null;
|
||||
this.watch_stations = null;
|
||||
this.cargo_ids = null;
|
||||
this.min_cargo_delta = 1;
|
||||
this.include_cargo = true;
|
||||
this.vehicle_at = {};
|
||||
this.cargo_prev = {};
|
||||
}
|
||||
|
||||
/* Copy a request field that must be an array of integers, or null when absent. */
|
||||
function ReadIdList(request, key) {
|
||||
if (!(key in request) || request[key] == null) return null;
|
||||
local out = [];
|
||||
foreach (id in request[key]) out.append(id);
|
||||
return out;
|
||||
}
|
||||
|
||||
function HandleSubscribeEvents(request) {
|
||||
local reply = { command = "subscribe_events" };
|
||||
if ("request_id" in request) reply.request_id <- request.request_id;
|
||||
|
||||
/* Validate everything before touching the live subscription, so a rejected request
|
||||
* leaves whatever was subscribed before running untouched. */
|
||||
local kinds = {};
|
||||
if ("events" in request && request.events != null) {
|
||||
foreach (kind in request.events) {
|
||||
if (!(kind in this.event_catalog)) {
|
||||
reply.error <- "unknown_event";
|
||||
reply.event <- kind;
|
||||
GSAdmin.Send(reply);
|
||||
return;
|
||||
}
|
||||
kinds[kind] <- true;
|
||||
}
|
||||
} else {
|
||||
foreach (kind in this.event_order) kinds[kind] <- true;
|
||||
}
|
||||
|
||||
local new_interval = ("interval" in request) ? request.interval : 10;
|
||||
if (typeof new_interval != "integer" || new_interval < 1) {
|
||||
reply.error <- "invalid_interval";
|
||||
GSAdmin.Send(reply);
|
||||
return;
|
||||
}
|
||||
local new_min_delta = ("min_cargo_delta" in request) ? request.min_cargo_delta : 1;
|
||||
if (typeof new_min_delta != "integer" || new_min_delta < 1) {
|
||||
reply.error <- "invalid_min_cargo_delta";
|
||||
GSAdmin.Send(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Cargo ids are validated up front because the amount getters below take them as a
|
||||
* precondition; station and vehicle ids are not, since they can vanish mid-subscription
|
||||
* anyway and are re-checked on every poll. */
|
||||
local new_cargo = this.ReadIdList(request, "cargo");
|
||||
if (new_cargo != null) {
|
||||
foreach (c in new_cargo) {
|
||||
if (!GSCargo.IsValidCargo(c)) {
|
||||
reply.error <- "invalid_cargo";
|
||||
reply.cargo_id <- c;
|
||||
GSAdmin.Send(reply);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
new_cargo = [];
|
||||
foreach (c, _ in GSCargoList()) new_cargo.append(c);
|
||||
}
|
||||
|
||||
this.ResetEventState();
|
||||
this.event_kinds = kinds;
|
||||
this.interval = new_interval;
|
||||
this.sleep_ticks = new_interval < 10 ? new_interval : 10;
|
||||
this.min_cargo_delta = new_min_delta;
|
||||
this.cargo_ids = new_cargo;
|
||||
this.watch_vehicles = this.ReadIdList(request, "vehicles");
|
||||
this.watch_stations = this.ReadIdList(request, "stations");
|
||||
if ("company_id" in request && request.company_id != null) this.company_id = request.company_id;
|
||||
if ("include_cargo" in request) this.include_cargo = request.include_cargo ? true : false;
|
||||
|
||||
local accepted = [];
|
||||
foreach (kind in this.event_order) {
|
||||
if (kind in this.event_kinds) accepted.append(kind);
|
||||
}
|
||||
reply.events <- accepted;
|
||||
reply.interval <- this.interval;
|
||||
GSAdmin.Send(reply);
|
||||
}
|
||||
|
||||
function HandleUnsubscribeEvents(request) {
|
||||
this.ResetEventState();
|
||||
local reply = { command = "unsubscribe_events", events = [] };
|
||||
if ("request_id" in request) reply.request_id <- request.request_id;
|
||||
GSAdmin.Send(reply);
|
||||
}
|
||||
|
||||
function Wants(kind) {
|
||||
return this.event_kinds != null && (kind in this.event_kinds);
|
||||
}
|
||||
|
||||
/* Forward the events the engine raises for a deity GameScript. Vehicle "lost", "waiting in
|
||||
* depot" and "unprofitable" are deliberately absent: the engine only ever raises those for
|
||||
* AI companies, so a GameScript can never observe them. */
|
||||
function HandleNativeEvent(event, out) {
|
||||
local type = event.GetEventType();
|
||||
local tick = this.GetTick();
|
||||
if (type == GSEvent.ET_VEHICLE_CRASHED) {
|
||||
if (!this.Wants("vehicle_crashed")) return;
|
||||
local e = GSEventVehicleCrashed.Convert(event);
|
||||
out.append({ event = "vehicle_crashed", tick = tick, vehicle_id = e.GetVehicleID(),
|
||||
tile = e.GetCrashSite(), reason = e.GetCrashReason(),
|
||||
victims = e.GetVictims(), owner = e.GetVehicleOwner() });
|
||||
} else if (type == GSEvent.ET_STATION_FIRST_VEHICLE) {
|
||||
if (!this.Wants("station_first_vehicle")) return;
|
||||
local e = GSEventStationFirstVehicle.Convert(event);
|
||||
out.append({ event = "station_first_vehicle", tick = tick,
|
||||
station_id = e.GetStationID(), vehicle_id = e.GetVehicleID() });
|
||||
} else if (type == GSEvent.ET_INDUSTRY_OPEN) {
|
||||
if (!this.Wants("industry_open")) return;
|
||||
out.append({ event = "industry_open", tick = tick,
|
||||
industry_id = GSEventIndustryOpen.Convert(event).GetIndustryID() });
|
||||
} else if (type == GSEvent.ET_INDUSTRY_CLOSE) {
|
||||
if (!this.Wants("industry_close")) return;
|
||||
out.append({ event = "industry_close", tick = tick,
|
||||
industry_id = GSEventIndustryClose.Convert(event).GetIndustryID() });
|
||||
} else if (type == GSEvent.ET_TOWN_FOUNDED) {
|
||||
if (!this.Wants("town_founded")) return;
|
||||
out.append({ event = "town_founded", tick = tick,
|
||||
town_id = GSEventTownFounded.Convert(event).GetTownID() });
|
||||
} else if (type == GSEvent.ET_COMPANY_NEW) {
|
||||
if (!this.Wants("company_new")) return;
|
||||
out.append({ event = "company_new", tick = tick,
|
||||
company_id = GSEventCompanyNew.Convert(event).GetCompanyID() });
|
||||
} else if (type == GSEvent.ET_COMPANY_IN_TROUBLE) {
|
||||
if (!this.Wants("company_in_trouble")) return;
|
||||
out.append({ event = "company_in_trouble", tick = tick,
|
||||
company_id = GSEventCompanyInTrouble.Convert(event).GetCompanyID() });
|
||||
} else if (type == GSEvent.ET_COMPANY_BANKRUPT) {
|
||||
if (!this.Wants("company_bankrupt")) return;
|
||||
out.append({ event = "company_bankrupt", tick = tick,
|
||||
company_id = GSEventCompanyBankrupt.Convert(event).GetCompanyID() });
|
||||
} else if (type == GSEvent.ET_SUBSIDY_OFFER) {
|
||||
if (!this.Wants("subsidy_offer")) return;
|
||||
out.append({ event = "subsidy_offer", tick = tick,
|
||||
subsidy_id = GSEventSubsidyOffer.Convert(event).GetSubsidyID() });
|
||||
} else if (type == GSEvent.ET_SUBSIDY_OFFER_EXPIRED) {
|
||||
if (!this.Wants("subsidy_offer_expired")) return;
|
||||
out.append({ event = "subsidy_offer_expired", tick = tick,
|
||||
subsidy_id = GSEventSubsidyOfferExpired.Convert(event).GetSubsidyID() });
|
||||
} else if (type == GSEvent.ET_SUBSIDY_AWARDED) {
|
||||
if (!this.Wants("subsidy_awarded")) return;
|
||||
out.append({ event = "subsidy_awarded", tick = tick,
|
||||
subsidy_id = GSEventSubsidyAwarded.Convert(event).GetSubsidyID() });
|
||||
} else if (type == GSEvent.ET_SUBSIDY_EXPIRED) {
|
||||
if (!this.Wants("subsidy_expired")) return;
|
||||
out.append({ event = "subsidy_expired", tick = tick,
|
||||
subsidy_id = GSEventSubsidyExpired.Convert(event).GetSubsidyID() });
|
||||
}
|
||||
}
|
||||
|
||||
function PollState(out) {
|
||||
local now = this.GetTick();
|
||||
if (this.seeded && now - this.last_poll < this.interval) return;
|
||||
this.last_poll = now;
|
||||
this.PollVehicles(now, out);
|
||||
this.PollCargo(now, out);
|
||||
/* The first poll only records where everything already is: a vehicle that was sitting
|
||||
* at a station when the subscription started did not just arrive. */
|
||||
this.seeded = true;
|
||||
}
|
||||
|
||||
function WatchedVehicles() {
|
||||
if (this.watch_vehicles != null) return this.watch_vehicles;
|
||||
local out = [];
|
||||
foreach (v, _ in GSVehicleList()) out.append(v);
|
||||
return out;
|
||||
}
|
||||
|
||||
function WatchedStations() {
|
||||
if (this.watch_stations != null) return this.watch_stations;
|
||||
local out = [];
|
||||
foreach (s, _ in GSStationList(GSStation.STATION_ANY)) out.append(s);
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Which station a vehicle is stopped at, or -1 when it is not loading at one. Note that a
|
||||
* vehicle stopped by hand or broken down at a platform reports its own state instead, so it
|
||||
* reads here as having left the station. */
|
||||
function VehicleStation(v) {
|
||||
if (GSVehicle.GetState(v) != GSVehicle.VS_AT_STATION) return -1;
|
||||
local sid = GSStation.GetStationID(GSVehicle.GetLocation(v));
|
||||
return GSStation.IsValidStation(sid) ? sid : -1;
|
||||
}
|
||||
|
||||
function PollVehicles(now, out) {
|
||||
local want_arrive = this.Wants("vehicle_arrive");
|
||||
local want_depart = this.Wants("vehicle_depart");
|
||||
if (!want_arrive && !want_depart) return;
|
||||
local live = {};
|
||||
foreach (v in this.WatchedVehicles()) {
|
||||
if (!GSVehicle.IsValidVehicle(v)) continue;
|
||||
if (this.company_id != null && GSVehicle.GetOwner(v) != this.company_id) continue;
|
||||
live[v] <- true;
|
||||
local at = this.VehicleStation(v);
|
||||
local known = (v in this.vehicle_at) ? this.vehicle_at[v] : null;
|
||||
local was = (known == null) ? -1 : known.station;
|
||||
if (was == at) continue;
|
||||
if (this.seeded) {
|
||||
/* A vehicle that moves from one station straight to another in a single
|
||||
* sampling window yields both a depart and an arrive, in that order. */
|
||||
if (was != -1 && want_depart) out.append(this.VehicleEvent("vehicle_depart", now, v, was, now - known.since));
|
||||
if (at != -1 && want_arrive) out.append(this.VehicleEvent("vehicle_arrive", now, v, at, 0));
|
||||
}
|
||||
this.vehicle_at[v] <- { station = at, since = now };
|
||||
}
|
||||
/* Forget vehicles that were sold or fell out of the filter, so the table cannot grow
|
||||
* without bound over a long subscription. */
|
||||
local stale = [];
|
||||
foreach (v, _ in this.vehicle_at) {
|
||||
if (!(v in live)) stale.append(v);
|
||||
}
|
||||
foreach (v in stale) delete this.vehicle_at[v];
|
||||
}
|
||||
|
||||
function VehicleEvent(kind, tick, v, sid, dwell) {
|
||||
local ev = {
|
||||
event = kind,
|
||||
tick = tick,
|
||||
vehicle_id = v,
|
||||
station_id = sid,
|
||||
owner = GSVehicle.GetOwner(v),
|
||||
vehicle_type = GSVehicle.GetVehicleType(v),
|
||||
order_position = GSOrder.ResolveOrderPosition(v, GSOrder.ORDER_CURRENT)
|
||||
};
|
||||
/* How long the vehicle had been loading, in ticks. For a vehicle that was already at a
|
||||
* station when the subscription started this counts from the first poll, not from the
|
||||
* real arrival. */
|
||||
if (kind == "vehicle_depart") ev.dwell <- dwell;
|
||||
if (this.include_cargo) ev.cargo <- this.VehicleCargo(v);
|
||||
return ev;
|
||||
}
|
||||
|
||||
function VehicleCargo(v) {
|
||||
local out = [];
|
||||
foreach (c in this.cargo_ids) {
|
||||
local load = GSVehicle.GetCargoLoad(v, c);
|
||||
if (load > 0) out.append({ cargo_id = c, load = load });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function PollCargo(now, out) {
|
||||
if (!this.Wants("cargo_waiting")) return;
|
||||
local live = {};
|
||||
foreach (sid in this.WatchedStations()) {
|
||||
if (!GSStation.IsValidStation(sid)) continue;
|
||||
if (this.company_id != null && GSStation.GetOwner(sid) != this.company_id) continue;
|
||||
live[sid] <- true;
|
||||
if (!(sid in this.cargo_prev)) this.cargo_prev[sid] <- {};
|
||||
local prev = this.cargo_prev[sid];
|
||||
foreach (c in this.cargo_ids) {
|
||||
local waiting = GSStation.GetCargoWaiting(sid, c);
|
||||
local before = (c in prev) ? prev[c] : 0;
|
||||
if (waiting == before) continue;
|
||||
prev[c] <- waiting;
|
||||
if (!this.seeded) continue;
|
||||
local delta = waiting - before;
|
||||
local magnitude = delta < 0 ? -delta : delta;
|
||||
if (magnitude < this.min_cargo_delta) continue;
|
||||
out.append({ event = "cargo_waiting", tick = now, station_id = sid,
|
||||
cargo_id = c, waiting = waiting, delta = delta });
|
||||
}
|
||||
}
|
||||
local stale = [];
|
||||
foreach (sid, _ in this.cargo_prev) {
|
||||
if (!(sid in live)) stale.append(sid);
|
||||
}
|
||||
foreach (sid in stale) delete this.cargo_prev[sid];
|
||||
}
|
||||
|
||||
function SendEventBatch(events) {
|
||||
if (events.len() == 0) return;
|
||||
local dropped = 0;
|
||||
if (events.len() > this.MAX_EVENTS_PER_POLL) {
|
||||
dropped = events.len() - this.MAX_EVENTS_PER_POLL;
|
||||
events = events.slice(0, this.MAX_EVENTS_PER_POLL);
|
||||
}
|
||||
local sent = 0;
|
||||
while (sent < events.len()) {
|
||||
local end = sent + this.EVENT_BATCH_SIZE;
|
||||
if (end > events.len()) end = events.len();
|
||||
GSAdmin.Send({ command = "events", events = events.slice(sent, end) });
|
||||
sent = end;
|
||||
}
|
||||
/* Tell the client its view has a hole in it rather than letting it silently miss
|
||||
* transitions it is counting on. */
|
||||
if (dropped > 0) {
|
||||
GSAdmin.Send({ command = "events",
|
||||
events = [{ event = "events_dropped", tick = this.GetTick(), count = dropped }] });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
from .client import OpenTTDAdminClient, OpenTTDClient
|
||||
from .decorators import exclude_call_check
|
||||
from .client import OpenTTDClient, OpenTTDAdminClient
|
||||
|
||||
__all__ = ['OpenTTDClient', 'OpenTTDAdminClient', 'exclude_call_check']
|
||||
__all__ = ['OpenTTDAdminClient', 'OpenTTDClient', 'exclude_call_check']
|
||||
|
||||
+647
-14
@@ -1,12 +1,43 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
import monocypher
|
||||
import os
|
||||
import hashlib
|
||||
from openttd_protocol.wire.write import write_init, write_string, write_uint8, write_uint16, write_uint32, write_presend, SEND_TCP_MTU
|
||||
from .protocol import PacketGameType, OpenTTDProtocol, PacketAdminType, OpenTTDAdminProtocol, NetworkAuthenticationMethod
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from collections import deque
|
||||
from typing import ClassVar
|
||||
|
||||
import monocypher
|
||||
from openttd_protocol.wire.exceptions import SocketClosed
|
||||
from openttd_protocol.wire.read import read_uint8, read_uint16
|
||||
from openttd_protocol.wire.write import (
|
||||
SEND_TCP_MTU,
|
||||
write_init,
|
||||
write_presend,
|
||||
write_string,
|
||||
write_uint8,
|
||||
write_uint16,
|
||||
write_uint32,
|
||||
)
|
||||
|
||||
from .decorators import exclude_call_check
|
||||
from .protocol import (
|
||||
INVALID_VEH_ORDER_ID,
|
||||
GameCommand,
|
||||
ModifyTimetableCtrlFlag,
|
||||
ModifyTimetableFlags,
|
||||
NetworkAuthenticationMethod,
|
||||
OpenTTDAdminProtocol,
|
||||
OpenTTDProtocol,
|
||||
OrderStopLocation,
|
||||
OrderType,
|
||||
PacketAdminType,
|
||||
PacketGameType,
|
||||
read_varuint,
|
||||
read_varuint_signed,
|
||||
write_varuint,
|
||||
write_varuint_signed,
|
||||
)
|
||||
|
||||
|
||||
class OpenTTDClient:
|
||||
"""High-level OpenTTD client for easy integration."""
|
||||
@@ -22,6 +53,7 @@ class OpenTTDClient:
|
||||
self.joined = asyncio.Event()
|
||||
self.shutdown_event = asyncio.Event()
|
||||
self.client_id = None
|
||||
self.vehicle_timetables = {}
|
||||
|
||||
# Internal crypto
|
||||
self._server_password = ""
|
||||
@@ -62,6 +94,186 @@ class OpenTTDClient:
|
||||
else:
|
||||
self.log.warning("Already joined.")
|
||||
|
||||
async def _send_command(self, cmd, payload, tile=0, error_msg=0, callback=0):
|
||||
"""Send a DoCommand over the game protocol (ClientCommand packet)."""
|
||||
d = write_init(PacketGameType.ClientCommand)
|
||||
write_uint8(d, self._target_company)
|
||||
write_uint16(d, cmd)
|
||||
write_uint16(d, error_msg)
|
||||
write_uint32(d, tile)
|
||||
write_uint16(d, len(payload))
|
||||
d.extend(payload)
|
||||
write_uint8(d, callback)
|
||||
if callback != 0:
|
||||
write_uint32(d, 0)
|
||||
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
|
||||
|
||||
async def change_timetable(self, vehicle_id, order_position, flag, value, clear_field=False):
|
||||
"""Change a single order's timetable field (wait/travel time, fixed flags, leave type, ...)."""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_uint16(payload, order_position)
|
||||
write_uint8(payload, flag)
|
||||
write_varuint(payload, value)
|
||||
write_uint8(payload, ModifyTimetableCtrlFlag.ClearField if clear_field else 0)
|
||||
await self._send_command(GameCommand.ChangeTimetable, payload)
|
||||
|
||||
async def autofill_timetable(self, vehicle_id, autofill=True, preserve_wait_time=False):
|
||||
"""Enable or disable timetable autofill for a vehicle."""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_uint8(payload, 1 if autofill else 0)
|
||||
write_uint8(payload, 1 if preserve_wait_time else 0)
|
||||
await self._send_command(GameCommand.AutofillTimetable, payload)
|
||||
|
||||
async def set_timetable_start(self, vehicle_id, timetable_all, start_date):
|
||||
"""Set the timetable start date for a vehicle (or all vehicles sharing its orders)."""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_uint8(payload, 1 if timetable_all else 0)
|
||||
write_varuint_signed(payload, start_date)
|
||||
await self._send_command(GameCommand.SetTimetableStart, payload)
|
||||
|
||||
async def set_vehicle_on_time(self, vehicle_id, apply_to_group=False):
|
||||
"""Reset a vehicle's lateness counter to make it on-time.
|
||||
|
||||
This command can only reset lateness to zero; there is no way to mark a vehicle as
|
||||
late. If apply_to_group is True, every vehicle sharing this vehicle's order list has
|
||||
its lateness reduced by the same amount instead of just this one vehicle. The vehicle's
|
||||
timetable must already be running (see set_timetable_start()) or the server rejects
|
||||
the command when apply_to_group is False.
|
||||
"""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_uint8(payload, 1 if apply_to_group else 0)
|
||||
await self._send_command(GameCommand.SetVehicleOnTime, payload)
|
||||
|
||||
def get_vehicle_timetable(self, vehicle_id):
|
||||
"""Return the locally observed timetable state for a vehicle, or None if nothing has been observed.
|
||||
|
||||
This is a local read with no network round-trip: there is no query command for timetable data in
|
||||
the OpenTTD protocol, so this only reflects ServerCommand broadcasts seen since the client joined.
|
||||
"""
|
||||
return self.vehicle_timetables.get(vehicle_id)
|
||||
|
||||
async def add_order(self, vehicle_id, station_id, before_position=None, non_stop=0,
|
||||
stop_location=OrderStopLocation.PlatformFarEnd, order_flags=0):
|
||||
"""Insert a 'go to station' order into a vehicle's order list.
|
||||
|
||||
By default the new order is appended to the end of the list; pass before_position to insert it
|
||||
before an existing order at that index instead. non_stop is an OrderNonStopFlags value
|
||||
(0 = stop everywhere) and stop_location an OrderStopLocation value, both packed into the
|
||||
order's type byte; stop_location defaults to PlatformFarEnd because the near-end/middle/through
|
||||
values are train-only and the server rejects them for other vehicle types. order_flags is the
|
||||
16-bit load/unload flag word (0 = the game's defaults: load if possible, unload if possible).
|
||||
|
||||
Sent over the game port as a real DoCommand: it only succeeds when this client is joined to
|
||||
the company that owns the vehicle (see join_company()); a spectator is rejected and kicked.
|
||||
"""
|
||||
order_type = OrderType.GotoStation | ((stop_location & 0x3) << 4) | ((non_stop & 0x3) << 6)
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_uint16(payload, INVALID_VEH_ORDER_ID if before_position is None else before_position)
|
||||
write_uint8(payload, order_type)
|
||||
write_uint16(payload, order_flags)
|
||||
write_uint16(payload, station_id)
|
||||
await self._send_command(GameCommand.InsertOrder, payload)
|
||||
|
||||
async def remove_order(self, vehicle_id, order_position):
|
||||
"""Delete the order at order_position from a vehicle's order list.
|
||||
|
||||
Sent over the game port as a real DoCommand: like add_order(), it only succeeds when this
|
||||
client is joined to the company that owns the vehicle.
|
||||
"""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_uint16(payload, order_position)
|
||||
await self._send_command(GameCommand.DeleteOrder, payload)
|
||||
|
||||
# --- Scheduled dispatch (JGRPP) ---
|
||||
#
|
||||
# A vehicle's order list can hold several dispatch schedules, each with a duration, a start
|
||||
# tick and a set of departure slots (offsets within the duration). All of these are edited over
|
||||
# the game port and require being joined to the owning company. For an authoritative read of the
|
||||
# resulting schedules, use OpenTTDAdminClient.get_dispatch().
|
||||
|
||||
async def set_scheduled_dispatch(self, vehicle_id, enabled):
|
||||
"""Enable or disable scheduled dispatch for a vehicle (and every vehicle sharing its orders)."""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_uint8(payload, 1 if enabled else 0)
|
||||
await self._send_command(GameCommand.SchDispatch, payload)
|
||||
|
||||
async def add_dispatch_schedule(self, vehicle_id, start_tick, duration):
|
||||
"""Create a new dispatch schedule with the given start tick and duration (in ticks).
|
||||
|
||||
The schedule is appended to the vehicle's schedule set; its index is the previous schedule
|
||||
count (read it back with OpenTTDAdminClient.get_dispatch()). duration must be non-zero.
|
||||
"""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_varuint_signed(payload, start_tick)
|
||||
write_varuint(payload, duration)
|
||||
await self._send_command(GameCommand.SchDispatchAddNewSchedule, payload)
|
||||
|
||||
async def remove_dispatch_schedule(self, vehicle_id, schedule_index):
|
||||
"""Remove the dispatch schedule at schedule_index from a vehicle's schedule set."""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_varuint(payload, schedule_index)
|
||||
await self._send_command(GameCommand.SchDispatchRemoveSchedule, payload)
|
||||
|
||||
async def add_dispatch_slot(self, vehicle_id, schedule_index, offset, interval=0, extra_slots=0,
|
||||
slot_flags=0, route_id=0):
|
||||
"""Add one or more departure slots to a dispatch schedule.
|
||||
|
||||
offset is the slot's departure time as an offset (in ticks) within the schedule's duration.
|
||||
To add several evenly spaced slots in one command, pass extra_slots > 0 together with a
|
||||
non-zero interval: each extra slot is placed interval ticks after the previous one (wrapping
|
||||
around the duration). slot_flags is the 16-bit slot flag word and route_id an optional
|
||||
departure route id (both default to 0).
|
||||
"""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_varuint(payload, schedule_index)
|
||||
write_varuint(payload, offset)
|
||||
write_varuint(payload, interval)
|
||||
write_varuint(payload, extra_slots)
|
||||
write_uint16(payload, slot_flags)
|
||||
write_uint8(payload, route_id)
|
||||
await self._send_command(GameCommand.SchDispatchAdd, payload)
|
||||
|
||||
async def remove_dispatch_slot(self, vehicle_id, schedule_index, offset):
|
||||
"""Remove the departure slot at the given offset from a dispatch schedule."""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_varuint(payload, schedule_index)
|
||||
write_varuint(payload, offset)
|
||||
await self._send_command(GameCommand.SchDispatchRemove, payload)
|
||||
|
||||
async def clear_dispatch_schedule(self, vehicle_id, schedule_index):
|
||||
"""Remove every departure slot from a dispatch schedule (leaving the schedule itself)."""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_varuint(payload, schedule_index)
|
||||
await self._send_command(GameCommand.SchDispatchClear, payload)
|
||||
|
||||
async def set_dispatch_duration(self, vehicle_id, schedule_index, duration):
|
||||
"""Set the total duration (in ticks) of a dispatch schedule."""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_varuint(payload, schedule_index)
|
||||
write_varuint(payload, duration)
|
||||
await self._send_command(GameCommand.SchDispatchSetDuration, payload)
|
||||
|
||||
async def set_dispatch_start_date(self, vehicle_id, schedule_index, start_tick):
|
||||
"""Set the start tick of a dispatch schedule."""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_varuint(payload, schedule_index)
|
||||
write_varuint_signed(payload, start_tick)
|
||||
await self._send_command(GameCommand.SchDispatchSetStartDate, payload)
|
||||
|
||||
def disconnect(self, source):
|
||||
"""Library callback for when connection is lost."""
|
||||
self.log.info("Disconnected.")
|
||||
@@ -74,8 +286,9 @@ class OpenTTDClient:
|
||||
try:
|
||||
d = write_init(PacketGameType.ClientQuit)
|
||||
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
|
||||
except Exception:
|
||||
pass
|
||||
except (OSError, SocketClosed) as e:
|
||||
# Best-effort courtesy packet: the socket may already be gone.
|
||||
self.log.debug(f"Could not send quit packet: {e}")
|
||||
self._transport.close()
|
||||
self.shutdown_event.set()
|
||||
|
||||
@@ -199,7 +412,58 @@ class OpenTTDClient:
|
||||
async def receive_ServerMapData(self, source, **kwargs): pass
|
||||
async def receive_ServerConfigurationUpdate(self, source, **kwargs): pass
|
||||
async def receive_ServerExternalChat(self, source, **kwargs): pass
|
||||
async def receive_ServerCommand(self, source, **kwargs): pass
|
||||
_TIMETABLE_FIELD_BY_FLAG: ClassVar[dict[ModifyTimetableFlags, str]] = {
|
||||
ModifyTimetableFlags.WaitTime: "wait_time",
|
||||
ModifyTimetableFlags.TravelTime: "travel_time",
|
||||
ModifyTimetableFlags.TravelSpeed: "travel_speed",
|
||||
ModifyTimetableFlags.SetWaitFixed: "wait_time_fixed",
|
||||
ModifyTimetableFlags.SetTravelFixed: "travel_time_fixed",
|
||||
ModifyTimetableFlags.SetLeaveType: "leave_type",
|
||||
ModifyTimetableFlags.AssignSchedule: "assigned_schedule",
|
||||
}
|
||||
_TIMETABLE_BOOL_FLAGS: ClassVar[set[ModifyTimetableFlags]] = {
|
||||
ModifyTimetableFlags.SetWaitFixed,
|
||||
ModifyTimetableFlags.SetTravelFixed,
|
||||
}
|
||||
|
||||
async def receive_ServerCommand(self, source, cmd, payload, **kwargs):
|
||||
if cmd == GameCommand.ChangeTimetable:
|
||||
vehicle_id, rest = read_varuint(payload)
|
||||
order_position, rest = read_uint16(rest)
|
||||
flag, rest = read_uint8(rest)
|
||||
value, rest = read_varuint(rest)
|
||||
ctrl_flags, _ = read_uint8(rest)
|
||||
entry = self.vehicle_timetables.setdefault(vehicle_id, {"orders": {}})
|
||||
order = entry["orders"].setdefault(order_position, {})
|
||||
field = self._TIMETABLE_FIELD_BY_FLAG.get(flag)
|
||||
if field:
|
||||
cleared = bool(ctrl_flags & ModifyTimetableCtrlFlag.ClearField)
|
||||
if cleared:
|
||||
order[field] = None
|
||||
elif flag in self._TIMETABLE_BOOL_FLAGS:
|
||||
order[field] = bool(value)
|
||||
else:
|
||||
order[field] = value
|
||||
elif cmd == GameCommand.AutofillTimetable:
|
||||
vehicle_id, rest = read_varuint(payload)
|
||||
autofill, rest = read_uint8(rest)
|
||||
preserve_wait_time, _ = read_uint8(rest)
|
||||
entry = self.vehicle_timetables.setdefault(vehicle_id, {"orders": {}})
|
||||
entry["autofill"] = bool(autofill)
|
||||
entry["autofill_preserve_wait_time"] = bool(preserve_wait_time)
|
||||
elif cmd == GameCommand.SetTimetableStart:
|
||||
vehicle_id, rest = read_varuint(payload)
|
||||
timetable_all, rest = read_uint8(rest)
|
||||
start_date, _ = read_varuint_signed(rest)
|
||||
entry = self.vehicle_timetables.setdefault(vehicle_id, {"orders": {}})
|
||||
entry["timetable_all"] = bool(timetable_all)
|
||||
entry["timetable_start"] = start_date
|
||||
elif cmd == GameCommand.SetVehicleOnTime:
|
||||
vehicle_id, rest = read_varuint(payload)
|
||||
apply_to_group, _ = read_uint8(rest)
|
||||
entry = self.vehicle_timetables.setdefault(vehicle_id, {"orders": {}})
|
||||
entry["on_time_apply_to_group"] = bool(apply_to_group)
|
||||
|
||||
async def receive_ServerFull(self, source, **kwargs): pass
|
||||
async def receive_ServerBanned(self, source, **kwargs): pass
|
||||
async def receive_ClientAck(self, source, **kwargs): pass
|
||||
@@ -207,7 +471,7 @@ class OpenTTDClient:
|
||||
|
||||
class OpenTTDAdminClient:
|
||||
"""High-level OpenTTD Admin client."""
|
||||
def __init__(self, host, port=3977, admin_name="GeminiAdmin"):
|
||||
def __init__(self, host, port=3977, admin_name="GeminiAdmin", event_buffer_size=256):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.admin_name = admin_name
|
||||
@@ -230,6 +494,18 @@ class OpenTTDAdminClient:
|
||||
self.on_chat = None
|
||||
self.on_console = None
|
||||
self.on_gamescript = None
|
||||
self.on_event = None
|
||||
|
||||
# GameScript request/response correlation
|
||||
self._gs_request_id = 0
|
||||
self._gs_futures = {}
|
||||
self._gs_subscribed = False
|
||||
|
||||
# Game events pushed by the AdminBridge GameScript. Events nobody is waiting for are
|
||||
# kept here so a wait_for_event() call can still pick up something that arrived just
|
||||
# before it; the deque bounds the memory a subscription nobody drains can cost.
|
||||
self._event_buffer = deque(maxlen=event_buffer_size)
|
||||
self._event_waiters = []
|
||||
|
||||
async def connect(self, admin_password="", secure=False):
|
||||
"""Connect to the admin port and initiate handshake."""
|
||||
@@ -260,6 +536,14 @@ class OpenTTDAdminClient:
|
||||
def disconnect(self, source):
|
||||
"""Library callback for when connection is lost."""
|
||||
self.log.info("Admin disconnected.")
|
||||
for fut in self._gs_futures.values():
|
||||
if not fut.done():
|
||||
fut.set_exception(ConnectionError("admin disconnected"))
|
||||
self._gs_futures.clear()
|
||||
for _, fut in self._event_waiters:
|
||||
if not fut.done():
|
||||
fut.set_exception(ConnectionError("admin disconnected"))
|
||||
self._event_waiters.clear()
|
||||
self.shutdown_event.set()
|
||||
|
||||
async def quit(self):
|
||||
@@ -268,8 +552,9 @@ class OpenTTDAdminClient:
|
||||
try:
|
||||
d = write_init(PacketAdminType.AdminQuit)
|
||||
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
|
||||
except Exception:
|
||||
pass
|
||||
except (OSError, SocketClosed) as e:
|
||||
# Best-effort courtesy packet: the socket may already be gone.
|
||||
self.log.debug(f"Could not send admin quit packet: {e}")
|
||||
self._transport.close()
|
||||
self.shutdown_event.set()
|
||||
|
||||
@@ -322,6 +607,341 @@ class OpenTTDAdminClient:
|
||||
from .protocol import AdminUpdateType
|
||||
await self.poll(AdminUpdateType.CompanyStats, company_id)
|
||||
|
||||
async def list_vehicles(self, company_id=None):
|
||||
"""Request a list of vehicles via GameScript. company_id=None for all companies."""
|
||||
payload = {"command": "list_vehicles"}
|
||||
if company_id is not None:
|
||||
payload["company_id"] = company_id
|
||||
await self.send_gamescript(payload)
|
||||
|
||||
async def list_stations(self, company_id=None):
|
||||
"""Request a list of stations via GameScript. company_id=None for all companies.
|
||||
|
||||
Like list_vehicles(), this is fire-and-forget: the AdminBridge GameScript replies with a
|
||||
{"stations": [...]} envelope delivered to the on_gamescript callback, so subscribe to
|
||||
Gamescript updates first (update_frequency(Gamescript, Automatic)) or the reply is dropped.
|
||||
For a station's live cargo detail (waiting vs planned), use get_station().
|
||||
"""
|
||||
payload = {"command": "list_stations"}
|
||||
if company_id is not None:
|
||||
payload["company_id"] = company_id
|
||||
await self.send_gamescript(payload)
|
||||
|
||||
async def _gs_query(self, payload, timeout, context):
|
||||
"""Send a GameScript request and await its correlated reply.
|
||||
|
||||
Assigns a fresh request_id, registers a future the ServerGamescript handler resolves when
|
||||
the matching reply arrives, and (on first use) subscribes to Gamescript updates so the
|
||||
server actually forwards the reply. `payload` is the request dict without request_id;
|
||||
`context` is a label used in the ValueError raised on a GameScript-reported error.
|
||||
|
||||
Raises asyncio.TimeoutError if no reply arrives within `timeout`, ValueError on an error
|
||||
reply, and ConnectionError if the admin connection drops while waiting.
|
||||
"""
|
||||
from .protocol import AdminUpdateFrequency, AdminUpdateType
|
||||
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_bridge_version(self, minimum=None, timeout=5.0):
|
||||
"""Ask the AdminBridge GameScript which protocol version it speaks, and check it is new enough.
|
||||
|
||||
Every other GameScript method here needs a bridge that understands the command it sends.
|
||||
Against an older bridge those commands are simply ignored and the caller waits out its
|
||||
timeout with no explanation, so call this once after connecting to turn that into an
|
||||
immediate, named failure.
|
||||
|
||||
Returns a dict with "version" (the bridge's protocol version), "commands" (the command
|
||||
names it answers) and "events" (the event kinds it can push) — the two catalogues allow
|
||||
feature-detecting a single command instead of comparing version numbers.
|
||||
|
||||
`minimum` defaults to GS_BRIDGE_VERSION, the version this client is written against;
|
||||
pass 0 to read the version without requiring anything of it.
|
||||
|
||||
Raises ValueError if the bridge is older than `minimum`, and asyncio.TimeoutError if it
|
||||
does not answer at all — note that a bridge predating get_version itself (version 3 and
|
||||
earlier) can only fail that second way, as can a paused game or a server with no bridge
|
||||
loaded. ConnectionError if the admin connection drops while waiting.
|
||||
"""
|
||||
from .protocol import GS_BRIDGE_VERSION
|
||||
if minimum is None:
|
||||
minimum = GS_BRIDGE_VERSION
|
||||
data = await self._gs_query({"command": "get_version"}, timeout, "get_version")
|
||||
version = data.get("version", 0)
|
||||
if version < minimum:
|
||||
raise ValueError(
|
||||
f"AdminBridge GameScript is version {version}, but at least {minimum} is "
|
||||
f"required; update the server's copy from gamescript/AdminBridge/")
|
||||
return data
|
||||
|
||||
async def get_timetable(self, vehicle_id, timeout=5.0):
|
||||
"""Fetch an authoritative timetable snapshot for a vehicle via the AdminBridge GameScript.
|
||||
|
||||
Unlike the game client's passive observer, this queries the real game state: it works for
|
||||
timetables set before this client connected and reflects the actual (not requested) values.
|
||||
Auto-subscribes to Gamescript updates on first use; if you manage update frequencies
|
||||
yourself, ensure update_frequency(Gamescript, Automatic) is active before calling.
|
||||
|
||||
Returns a dict with vehicle-level keys (lateness, start_tick, current_order_time,
|
||||
total_duration) and an "orders" list of per-order dicts (position, wait_time, travel_time,
|
||||
wait_timetabled, travel_timetabled, wait_fixed, travel_fixed, leave_type, max_speed).
|
||||
|
||||
Raises asyncio.TimeoutError if no reply arrives (e.g. game paused, GS not loaded),
|
||||
ValueError on a GameScript-reported error (invalid_vehicle, response_too_large), and
|
||||
ConnectionError if the admin connection drops while waiting.
|
||||
"""
|
||||
return await self._gs_query(
|
||||
{"command": "get_timetable", "vehicle_id": vehicle_id}, timeout,
|
||||
f"get_timetable({vehicle_id})")
|
||||
|
||||
async def get_station(self, station_id, timeout=5.0):
|
||||
"""Fetch an authoritative snapshot of a station's live cargo state via the AdminBridge GameScript.
|
||||
|
||||
This queries the real game state (like get_timetable() does for vehicles): it works for any
|
||||
existing station regardless of when it was built or when this client connected. Auto-subscribes
|
||||
to Gamescript updates on first use; if you manage update frequencies yourself, ensure
|
||||
update_frequency(Gamescript, Automatic) is active before calling.
|
||||
|
||||
Returns a dict with station-level keys (name, location, owner) and a "cargo" list of per-cargo
|
||||
dicts. Each cargo dict carries both the real-time and the planned amounts:
|
||||
- "waiting": units currently sitting at the station (real-time, GSStation.GetCargoWaiting)
|
||||
- "planned": units planned to move through it per the cargodist link graph
|
||||
(GSStation.GetCargoPlanned); 0 when cargo distribution is not enabled for that cargo
|
||||
- "rating": the station's acceptance rating for the cargo as a percentage (0-100),
|
||||
or None if the station has no rating for that cargo yet
|
||||
Only cargo types the station has ever handled appear in the list.
|
||||
|
||||
Raises asyncio.TimeoutError if no reply arrives (e.g. game paused, GS not loaded),
|
||||
ValueError on a GameScript-reported error (invalid_station, response_too_large), and
|
||||
ConnectionError if the admin connection drops while waiting.
|
||||
"""
|
||||
return await self._gs_query(
|
||||
{"command": "get_station", "station_id": station_id}, timeout,
|
||||
f"get_station({station_id})")
|
||||
|
||||
async def get_station_cargo(self, station_id, cargo_id, from_station=None, via_station=None, timeout=5.0):
|
||||
"""Fetch a per-source / per-next-hop breakdown of one cargo at a station via the AdminBridge GS.
|
||||
|
||||
Where get_station() reports each cargo's totals, this drills into a single cargo type and
|
||||
shows how the waiting (real-time) and planned amounts split across the cargo distribution
|
||||
(cargodist) link graph. Cargodist tracks every unit by its source station (where it was
|
||||
first loaded) and its next hop (the next station it heads to on the way to its final
|
||||
destination); there is no separate "final destination" store, so the routing destination is
|
||||
the next hop ("via").
|
||||
|
||||
Returns a dict with the (optionally filtered) totals "waiting" and "planned", plus four
|
||||
breakdown lists, each a list of {"station": id, "amount": n} entries (zero amounts omitted):
|
||||
- "waiting_by_from" / "planned_by_from": grouped by source station
|
||||
- "waiting_by_via" / "planned_by_via": grouped by next hop (routing destination)
|
||||
A station id of 65535 (STATION_INVALID) marks cargo whose source was deleted or, as a next
|
||||
hop, cargo with no onward routing / to be consumed at this station (also the sole next hop
|
||||
for cargo types using manual, non-cargodist distribution).
|
||||
|
||||
Optional filters narrow the query:
|
||||
- from_station: only cargo originating at this source station.
|
||||
- via_station: only cargo whose next hop is this station.
|
||||
Passing from_station restricts the by_via breakdown to that source (and the totals to it);
|
||||
passing via_station restricts the by_from breakdown to that next hop; passing both makes the
|
||||
totals the exact source+next-hop amount. Pass 65535 for either to target STATION_INVALID.
|
||||
|
||||
Raises asyncio.TimeoutError if no reply arrives (e.g. game paused, GS not loaded),
|
||||
ValueError on a GameScript-reported error (invalid_station, invalid_cargo,
|
||||
response_too_large), and ConnectionError if the admin connection drops while waiting.
|
||||
"""
|
||||
payload = {"command": "get_station_cargo", "station_id": station_id, "cargo_id": cargo_id}
|
||||
if from_station is not None:
|
||||
payload["from_station"] = from_station
|
||||
if via_station is not None:
|
||||
payload["via_station"] = via_station
|
||||
return await self._gs_query(payload, timeout, f"get_station_cargo({station_id}, {cargo_id})")
|
||||
|
||||
async def list_cargo(self, timeout=5.0):
|
||||
"""Fetch the running game's cargo table via the AdminBridge GameScript, id to label.
|
||||
|
||||
Every other reply names a cargo by its bare numeric id — get_station()'s and
|
||||
get_station_cargo()'s cargo_id, the cargo_waiting events, the per-cargo load on vehicle
|
||||
events. Those ids index the cargo table the loaded NewGRFs build, so they mean different
|
||||
things in different games and are not worth hardcoding; resolve them against this list
|
||||
instead. 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 a "cargo" list holding every cargo type in the game, in no particular
|
||||
order (index it by "cargo_id"; a cargo's position in the list is not its id). Each entry:
|
||||
- "cargo_id": the id used by all the replies above
|
||||
- "label": the cargo label ("PASS", "COAL", ...), or "" if the GameScript could not
|
||||
read it
|
||||
- "freight": 1 for freight cargo, 0 for the rest (passengers, mail, ...)
|
||||
|
||||
Raises asyncio.TimeoutError if no reply arrives (e.g. game paused, GS not loaded) and
|
||||
ConnectionError if the admin connection drops while waiting. The GameScript reports no
|
||||
error for this command, so unlike get_station() it never raises ValueError.
|
||||
"""
|
||||
return await self._gs_query({"command": "list_cargo"}, timeout, "list_cargo")
|
||||
|
||||
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})")
|
||||
|
||||
# --- Game events ---
|
||||
#
|
||||
# Everything above is a request the caller makes; this is the other direction. The
|
||||
# AdminBridge GameScript pushes events as they happen, so a bot can react to the game
|
||||
# instead of polling it. Two kinds of thing arrive on the same channel: transitions the
|
||||
# bridge synthesises by sampling game state on an interval (a vehicle reaching or leaving
|
||||
# a stop, a station's waiting cargo changing), and the events the engine itself raises for
|
||||
# a GameScript (crashes, industries opening, companies going bankrupt, ...). See
|
||||
# GameEventType for the full catalogue.
|
||||
#
|
||||
# Consume them either by setting on_event (push) or by awaiting wait_for_event() (pull);
|
||||
# both see every event, so the two can be mixed.
|
||||
|
||||
async def subscribe_events(self, events=None, interval=None, company_id=None, vehicles=None,
|
||||
stations=None, cargo=None, min_cargo_delta=None,
|
||||
include_cargo=None, timeout=5.0):
|
||||
"""Ask the AdminBridge GameScript to start pushing game events, and wait for it to confirm.
|
||||
|
||||
Every argument narrows what gets sent; the defaults subscribe to every event kind for
|
||||
every vehicle, station and cargo, which is the right starting point on a small map and
|
||||
the wrong one on a large busy map (see the cost note below).
|
||||
|
||||
- events: which GameEventType kinds to receive (default: all of them).
|
||||
- interval: ticks between state samples for the polled kinds (default 10). This is
|
||||
the resolution of those events, not a delay: a stop shorter than `interval` can
|
||||
begin and end between two samples and is then never reported at all.
|
||||
- company_id: only report vehicles and stations owned by this company.
|
||||
- vehicles / stations: only sample these ids, instead of every vehicle / station.
|
||||
- cargo: only inspect these cargo types (for cargo_waiting and for the load reported
|
||||
on vehicle events).
|
||||
- min_cargo_delta: suppress cargo_waiting events whose amount moved by less than this
|
||||
many units since the previous sample (default 1, i.e. report every change).
|
||||
- include_cargo: set False to leave the per-cargo load off vehicle events.
|
||||
|
||||
Subscribing replaces any previous subscription and resets the bridge's baseline, so the
|
||||
first sample after this call only records where everything already is — a vehicle that
|
||||
was sitting at a station when you subscribed did not just arrive, and gets no event.
|
||||
|
||||
Cost: the bridge samples every watched vehicle and every watched station-cargo pair on
|
||||
each interval, inside a GameScript's limited per-tick budget. On a large map prefer a
|
||||
coarser interval and explicit vehicles/stations/cargo lists over the defaults.
|
||||
|
||||
Returns the confirmation dict: {"events": [accepted kinds], "interval": N}. Raises
|
||||
asyncio.TimeoutError if the GameScript does not answer (e.g. game paused, GS not
|
||||
loaded), ValueError on a rejected request (unknown_event, invalid_interval,
|
||||
invalid_min_cargo_delta, invalid_cargo), and ConnectionError if the admin connection
|
||||
drops while waiting.
|
||||
"""
|
||||
payload = {"command": "subscribe_events"}
|
||||
if events is not None:
|
||||
payload["events"] = [str(event) for event in events]
|
||||
if interval is not None:
|
||||
payload["interval"] = interval
|
||||
if company_id is not None:
|
||||
payload["company_id"] = company_id
|
||||
if vehicles is not None:
|
||||
payload["vehicles"] = list(vehicles)
|
||||
if stations is not None:
|
||||
payload["stations"] = list(stations)
|
||||
if cargo is not None:
|
||||
payload["cargo"] = list(cargo)
|
||||
if min_cargo_delta is not None:
|
||||
payload["min_cargo_delta"] = min_cargo_delta
|
||||
if include_cargo is not None:
|
||||
payload["include_cargo"] = bool(include_cargo)
|
||||
return await self._gs_query(payload, timeout, "subscribe_events")
|
||||
|
||||
async def unsubscribe_events(self, timeout=5.0):
|
||||
"""Stop the event stream and wait for the GameScript to confirm.
|
||||
|
||||
This also drops the bridge's sampling state, so a later subscribe_events() starts from
|
||||
a fresh baseline. Events already delivered stay in this client's buffer; drain or ignore
|
||||
them as you like.
|
||||
"""
|
||||
return await self._gs_query({"command": "unsubscribe_events"}, timeout,
|
||||
"unsubscribe_events")
|
||||
|
||||
async def wait_for_event(self, kind=None, timeout=5.0):
|
||||
"""Await the next game event, optionally of a specific kind (or any of several kinds).
|
||||
|
||||
`kind` is a GameEventType (or plain string), an iterable of them, or None for "any
|
||||
event". Events that arrived earlier and were not taken by another waiter are buffered,
|
||||
so this returns immediately when a matching one is already in hand; the oldest matching
|
||||
event wins. An event is handed to at most one waiter, but the on_event callback (if set)
|
||||
still sees every event regardless.
|
||||
|
||||
Returns the event dict, which always carries "event" (its GameEventType) and "tick"
|
||||
(the game tick it was observed at) plus per-kind fields — see docs/EVENTS.md. Raises
|
||||
asyncio.TimeoutError if nothing matching arrives in time (note that a subscription is
|
||||
needed first — see subscribe_events()) and ConnectionError if the admin connection drops
|
||||
while waiting.
|
||||
"""
|
||||
kinds = None
|
||||
if kind is not None:
|
||||
kinds = {str(kind)} if isinstance(kind, str) else {str(k) for k in kind}
|
||||
for buffered in list(self._event_buffer):
|
||||
if self._event_matches(buffered, kinds):
|
||||
self._event_buffer.remove(buffered)
|
||||
return buffered
|
||||
fut = asyncio.get_running_loop().create_future()
|
||||
waiter = (kinds, fut)
|
||||
self._event_waiters.append(waiter)
|
||||
try:
|
||||
return await asyncio.wait_for(fut, timeout)
|
||||
finally:
|
||||
if waiter in self._event_waiters:
|
||||
self._event_waiters.remove(waiter)
|
||||
|
||||
@staticmethod
|
||||
def _event_matches(event, kinds):
|
||||
return kinds is None or (isinstance(event, dict) and event.get("event") in kinds)
|
||||
|
||||
def _dispatch_event(self, event):
|
||||
"""Hand one event to the longest-waiting matching waiter, else buffer it; then observe."""
|
||||
for waiter in self._event_waiters:
|
||||
kinds, fut = waiter
|
||||
if not fut.done() and self._event_matches(event, kinds):
|
||||
fut.set_result(event)
|
||||
self._event_waiters.remove(waiter)
|
||||
break
|
||||
else:
|
||||
self._event_buffer.append(event)
|
||||
if self.on_event:
|
||||
self.on_event(event)
|
||||
|
||||
async def send_gamescript(self, json_data):
|
||||
"""Send a JSON string to the GameScript."""
|
||||
import json
|
||||
@@ -417,10 +1037,23 @@ class OpenTTDAdminClient:
|
||||
self.log.info(f"Admin: Company {kwargs.get('company_id')} Stats: Vehicles={kwargs.get('vehicles')}, Stations={kwargs.get('stations')}")
|
||||
|
||||
async def receive_ServerGamescript(self, source, **kwargs):
|
||||
data = kwargs.get('data')
|
||||
if isinstance(data, dict):
|
||||
fut = self._gs_futures.get(data.get('request_id'))
|
||||
if fut is not None:
|
||||
if not fut.done():
|
||||
fut.set_result(data)
|
||||
return
|
||||
# Unsolicited event batch from the AdminBridge GameScript: fan it out to the
|
||||
# event consumers rather than the generic GameScript callback.
|
||||
if data.get('command') == 'events' and isinstance(data.get('events'), list):
|
||||
for event in data['events']:
|
||||
self._dispatch_event(event)
|
||||
return
|
||||
if self.on_gamescript:
|
||||
self.on_gamescript(kwargs.get('data'))
|
||||
self.on_gamescript(data)
|
||||
else:
|
||||
self.log.info(f"GAMESCRIPT: {kwargs.get('data')}")
|
||||
self.log.info(f"GAMESCRIPT: {data}")
|
||||
|
||||
async def receive_ServerDate(self, source, **kwargs): pass
|
||||
async def receive_ServerFull(self, source, **kwargs): await self.quit()
|
||||
|
||||
+165
-9
@@ -1,9 +1,106 @@
|
||||
import struct
|
||||
from enum import IntEnum, StrEnum
|
||||
|
||||
import monocypher
|
||||
from enum import IntEnum
|
||||
from openttd_protocol.wire.tcp import TCPProtocol
|
||||
from openttd_protocol.wire.read import read_uint8, read_string, read_uint16, read_uint32
|
||||
from openttd_protocol.wire.exceptions import SocketClosed
|
||||
from openttd_protocol.wire.read import read_string, read_uint8, read_uint16, read_uint32
|
||||
from openttd_protocol.wire.tcp import TCPProtocol
|
||||
|
||||
|
||||
def write_varuint(buffer, value):
|
||||
"""Encode a non-negative integer using OpenTTD's UTF-8-like varuint scheme."""
|
||||
if value < 0:
|
||||
raise ValueError("write_varuint requires a non-negative value")
|
||||
thresholds = [1 << 7, 1 << 14, 1 << 21, 1 << 28, 1 << 35, 1 << 42, 1 << 49, 1 << 56]
|
||||
for extra, limit in enumerate(thresholds):
|
||||
if value < limit:
|
||||
header_ones = (0xFF << (8 - extra)) & 0xFF
|
||||
header = header_ones | (value >> (extra * 8))
|
||||
buffer.append(header)
|
||||
for i in range(extra - 1, -1, -1):
|
||||
buffer.append((value >> (i * 8)) & 0xFF)
|
||||
return
|
||||
buffer.append(0xFF)
|
||||
for i in range(7, -1, -1):
|
||||
buffer.append((value >> (i * 8)) & 0xFF)
|
||||
|
||||
def read_varuint(data):
|
||||
"""Decode a varuint written by write_varuint. Returns (value, rest)."""
|
||||
header = data[0]
|
||||
mask = 0x80
|
||||
extra = 0
|
||||
while header & mask:
|
||||
extra += 1
|
||||
mask >>= 1
|
||||
value = header & (0x7F >> extra)
|
||||
rest = data[1:]
|
||||
for i in range(extra):
|
||||
value = (value << 8) | rest[i]
|
||||
return value, rest[extra:]
|
||||
|
||||
def write_varuint_signed(buffer, value):
|
||||
"""Encode a signed integer using zigzag + write_varuint."""
|
||||
zigzag = (value << 1) ^ (-1 if value < 0 else 0)
|
||||
write_varuint(buffer, zigzag)
|
||||
|
||||
def read_varuint_signed(data):
|
||||
"""Decode a signed varuint written by write_varuint_signed. Returns (value, rest)."""
|
||||
zigzag, rest = read_varuint(data)
|
||||
value = (zigzag >> 1) ^ -(zigzag & 1)
|
||||
return value, rest
|
||||
|
||||
class GameCommand(IntEnum):
|
||||
DeleteOrder = 51
|
||||
InsertOrder = 52
|
||||
ChangeTimetable = 174
|
||||
SetVehicleOnTime = 176
|
||||
AutofillTimetable = 177
|
||||
SetTimetableStart = 180
|
||||
# Scheduled dispatch (JGRPP)
|
||||
SchDispatch = 205
|
||||
SchDispatchAdd = 206
|
||||
SchDispatchRemove = 207
|
||||
SchDispatchSetDuration = 208
|
||||
SchDispatchSetStartDate = 209
|
||||
SchDispatchClear = 213
|
||||
SchDispatchAddNewSchedule = 214
|
||||
SchDispatchRemoveSchedule = 215
|
||||
|
||||
# Sentinel VehicleOrderID meaning "append to the end of the order list" for InsertOrder.
|
||||
INVALID_VEH_ORDER_ID = 0xFFFF
|
||||
|
||||
class OrderType(IntEnum):
|
||||
"""OrderType occupies bits 0-3 of an order's `type` byte (bits 6-7 hold OrderNonStopFlags)."""
|
||||
GotoStation = 1
|
||||
GotoDepot = 2
|
||||
GotoWaypoint = 6
|
||||
|
||||
class OrderNonStopFlags(IntEnum):
|
||||
"""Packed into bits 6-7 of an order's `type` byte."""
|
||||
StopEverywhere = 0
|
||||
NoStopAtIntermediate = 1
|
||||
NoStopAtDestination = 2
|
||||
NoStopAtAny = 3
|
||||
|
||||
class OrderStopLocation(IntEnum):
|
||||
"""Packed into bits 4-5 of an order's `type` byte. Near-end/middle/through are train-only;
|
||||
FarEnd is the only value the server accepts for every vehicle type, so it is the safe default."""
|
||||
PlatformNearEnd = 0
|
||||
PlatformMiddle = 1
|
||||
PlatformFarEnd = 2
|
||||
PlatformThrough = 3
|
||||
|
||||
class ModifyTimetableFlags(IntEnum):
|
||||
WaitTime = 0
|
||||
TravelTime = 1
|
||||
TravelSpeed = 2
|
||||
SetWaitFixed = 3
|
||||
SetTravelFixed = 4
|
||||
SetLeaveType = 5
|
||||
AssignSchedule = 6
|
||||
|
||||
class ModifyTimetableCtrlFlag(IntEnum):
|
||||
ClearField = 1 << 0
|
||||
|
||||
class PacketGameType(IntEnum):
|
||||
ServerFull = 0
|
||||
@@ -123,6 +220,47 @@ class NetworkAuthenticationMethod(IntEnum):
|
||||
X25519_PAKE = 1
|
||||
X25519_AuthorizedKey = 2
|
||||
|
||||
# Version of the AdminBridge GameScript's JSON protocol this client is written against. The
|
||||
# bridge reports its own version via get_version (OpenTTDAdminClient.get_bridge_version()), and
|
||||
# an older one will not understand everything sent here. The bridge source lives in
|
||||
# gamescript/AdminBridge/; tests/test_gamescript.py keeps this in step with the version declared
|
||||
# there, so bumping one without the other fails in CI rather than at runtime.
|
||||
GS_BRIDGE_VERSION = 4
|
||||
|
||||
class GameEventType(StrEnum):
|
||||
"""Event kinds the AdminBridge GameScript can push over the Admin Network.
|
||||
|
||||
These are the values of the "event" field of each event dict, and what
|
||||
OpenTTDAdminClient.subscribe_events() and wait_for_event() take. They are plain strings,
|
||||
so a bare "vehicle_arrive" works everywhere a member does.
|
||||
|
||||
The first three are synthesised by the GameScript sampling game state on an interval,
|
||||
because the engine raises no event for them; the rest are engine events forwarded as they
|
||||
happen. VehicleLost, VehicleWaitingInDepot and VehicleUnprofitable have deliberately no
|
||||
entry here: the engine only ever raises those for AI companies, never for a GameScript.
|
||||
"""
|
||||
|
||||
# Polled: derived by diffing successive samples of the game state.
|
||||
VehicleArrive = "vehicle_arrive"
|
||||
VehicleDepart = "vehicle_depart"
|
||||
CargoWaiting = "cargo_waiting"
|
||||
# Forwarded straight from the engine's own GameScript events.
|
||||
VehicleCrashed = "vehicle_crashed"
|
||||
StationFirstVehicle = "station_first_vehicle"
|
||||
IndustryOpen = "industry_open"
|
||||
IndustryClose = "industry_close"
|
||||
TownFounded = "town_founded"
|
||||
CompanyNew = "company_new"
|
||||
CompanyInTrouble = "company_in_trouble"
|
||||
CompanyBankrupt = "company_bankrupt"
|
||||
SubsidyOffer = "subsidy_offer"
|
||||
SubsidyOfferExpired = "subsidy_offer_expired"
|
||||
SubsidyAwarded = "subsidy_awarded"
|
||||
SubsidyExpired = "subsidy_expired"
|
||||
# Emitted by the bridge itself, never subscribed to: one poll produced more events than
|
||||
# fit in the per-poll cap and `count` of them were discarded.
|
||||
EventsDropped = "events_dropped"
|
||||
|
||||
class OpenTTDProtocol(TCPProtocol):
|
||||
"""Low-level OpenTTD TCP protocol handler with encryption support."""
|
||||
PacketType = PacketGameType
|
||||
@@ -137,21 +275,21 @@ class OpenTTDProtocol(TCPProtocol):
|
||||
if self.handler.encryption_enabled:
|
||||
if not self.handler._recv_aead:
|
||||
self.handler._recv_aead = monocypher.IncrementalAuthenticatedEncryption(self.handler._session_key_recv, self.handler._encryption_nonce)
|
||||
length, rest = read_uint16(data)
|
||||
_, rest = read_uint16(data)
|
||||
payload = self.handler._recv_aead.unlock(bytes(rest[:16]), bytes(rest[16:]))
|
||||
if payload is None:
|
||||
raise SocketClosed("Decryption failed")
|
||||
data = memoryview(struct.pack("<H", len(payload) + 2) + payload)
|
||||
|
||||
return super().receive_packet(source, data)
|
||||
except Exception:
|
||||
except Exception: # noqa: BLE001 - untrusted wire data: any decode failure must degrade to a no-op packet rather than kill the connection
|
||||
return PacketGameType.ServerUnused, {}
|
||||
|
||||
async def send_packet(self, data):
|
||||
if self.handler.encryption_enabled:
|
||||
if not self.handler._send_aead:
|
||||
self.handler._send_aead = monocypher.IncrementalAuthenticatedEncryption(self.handler._session_key_send, self.handler._encryption_nonce)
|
||||
length, payload = read_uint16(memoryview(data))
|
||||
_, payload = read_uint16(memoryview(data))
|
||||
mac, ciphertext = self.handler._send_aead.lock(payload.tobytes())
|
||||
data = struct.pack("<H", 18 + len(ciphertext)) + mac + ciphertext
|
||||
|
||||
@@ -192,7 +330,7 @@ class OpenTTDProtocol(TCPProtocol):
|
||||
@staticmethod
|
||||
def receive_ServerFrame(source, data):
|
||||
f, data = read_uint32(data)
|
||||
max_f, data = read_uint32(data)
|
||||
_, data = read_uint32(data)
|
||||
token = 0
|
||||
if len(data) > 0:
|
||||
if len(data) >= 13:
|
||||
@@ -229,7 +367,25 @@ class OpenTTDProtocol(TCPProtocol):
|
||||
@staticmethod
|
||||
def receive_ServerExternalChat(source, data): return {}
|
||||
@staticmethod
|
||||
def receive_ServerCommand(source, data): return {}
|
||||
def receive_ServerCommand(source, data):
|
||||
company, data = read_uint8(data)
|
||||
cmd, data = read_uint16(data)
|
||||
error_msg, data = read_uint16(data)
|
||||
tile, data = read_uint32(data)
|
||||
payload_len, data = read_uint16(data)
|
||||
payload = data[:payload_len]
|
||||
data = data[payload_len:]
|
||||
callback, data = read_uint8(data)
|
||||
callback_param = 0
|
||||
if callback != 0:
|
||||
callback_param, data = read_uint32(data)
|
||||
frame, data = read_uint32(data)
|
||||
my_cmd, _ = read_uint8(data)
|
||||
return {
|
||||
"company": company, "cmd": cmd, "error_msg": error_msg, "tile": tile,
|
||||
"payload": payload, "callback": callback, "callback_param": callback_param,
|
||||
"frame": frame, "my_cmd": bool(my_cmd)
|
||||
}
|
||||
@staticmethod
|
||||
def receive_ServerFull(source, data): return {}
|
||||
@staticmethod
|
||||
@@ -462,7 +618,7 @@ class OpenTTDAdminProtocol(OpenTTDProtocol):
|
||||
import json
|
||||
try:
|
||||
return {"data": json.loads(json_str)}
|
||||
except Exception:
|
||||
except json.JSONDecodeError:
|
||||
return {"raw_data": json_str}
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add the lib directory to sys.path so we can import the openttd package
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), 'lib'))
|
||||
|
||||
from openttd import OpenTTDClient
|
||||
from openttd.protocol import ModifyTimetableFlags
|
||||
|
||||
# Configuration
|
||||
SERVER_HOST = "127.0.0.1"
|
||||
@@ -17,6 +18,123 @@ SERVER_PASSWORD = "asd"
|
||||
COMPANY_ID = 0 # "Én transport"
|
||||
COMPANY_PASSWORD = "asd123"
|
||||
|
||||
# A vehicle owned by COMPANY_ID, used to demonstrate timetable get/set below. Set to a real
|
||||
# vehicle id to see it in action; leave as None to skip the demonstration.
|
||||
DEMO_VEHICLE_ID = 7
|
||||
# A station DEMO_VEHICLE_ID can legally serve, used to demonstrate add_order/remove_order.
|
||||
DEMO_STATION_ID = 6
|
||||
|
||||
async def demo_timetable_workflow(client, vehicle_id):
|
||||
"""A deliberately thorough walk-through of the timetable API: every ModifyTimetableFlags
|
||||
variant, clear_field, autofill, timetable start, and lateness reset, on a vehicle assumed
|
||||
to have at least two orders (positions 0 and 1)."""
|
||||
ORDER_A, ORDER_B = 0, 1
|
||||
|
||||
def show(label):
|
||||
print(f" -> [{label}] {client.get_vehicle_timetable(vehicle_id)}")
|
||||
|
||||
print(f"=== Timetable demo starting for vehicle {vehicle_id} ===")
|
||||
|
||||
# 1. Wait/travel times, in game ticks.
|
||||
print("--- Step 1: set wait/travel times ---")
|
||||
await client.change_timetable(vehicle_id, ORDER_A, ModifyTimetableFlags.WaitTime, 90)
|
||||
await client.change_timetable(vehicle_id, ORDER_A, ModifyTimetableFlags.TravelTime, 240)
|
||||
await client.change_timetable(vehicle_id, ORDER_B, ModifyTimetableFlags.WaitTime, 45)
|
||||
await asyncio.sleep(0.5)
|
||||
show("wait/travel times set")
|
||||
|
||||
# 2. Lock order A's wait time so autofill won't overwrite it later.
|
||||
print("--- Step 2: fix order A's wait time ---")
|
||||
await client.change_timetable(vehicle_id, ORDER_A, ModifyTimetableFlags.SetWaitFixed, 1)
|
||||
await asyncio.sleep(0.5)
|
||||
show("order A wait time fixed")
|
||||
|
||||
# 3. Cap order B's speed, then remove the cap again (0 = uncapped).
|
||||
print("--- Step 3: cap and uncap order B's speed ---")
|
||||
await client.change_timetable(vehicle_id, ORDER_B, ModifyTimetableFlags.TravelSpeed, 80)
|
||||
await asyncio.sleep(0.5)
|
||||
show("order B speed capped at 80")
|
||||
await client.change_timetable(vehicle_id, ORDER_B, ModifyTimetableFlags.TravelSpeed, 0)
|
||||
await asyncio.sleep(0.5)
|
||||
show("order B speed cap removed")
|
||||
|
||||
# 4. Let the vehicle leave order B early once any cargo is fully loaded.
|
||||
print("--- Step 4: change order B's leave type ---")
|
||||
await client.change_timetable(vehicle_id, ORDER_B, ModifyTimetableFlags.SetLeaveType, 2)
|
||||
await asyncio.sleep(0.5)
|
||||
show("order B leave type: leave early if any cargo full")
|
||||
|
||||
# 5. Assign order A to scheduled-dispatch schedule 0, then unassign it again.
|
||||
print("--- Step 5: assign and unassign a dispatch schedule ---")
|
||||
await client.change_timetable(vehicle_id, ORDER_A, ModifyTimetableFlags.AssignSchedule, 0)
|
||||
await asyncio.sleep(0.5)
|
||||
show("order A assigned to dispatch schedule 0")
|
||||
await client.change_timetable(vehicle_id, ORDER_A, ModifyTimetableFlags.AssignSchedule, 0xFFFFFFFF)
|
||||
await asyncio.sleep(0.5)
|
||||
show("order A unassigned from dispatch schedule")
|
||||
|
||||
# 6. Clear order B's wait time entirely (distinct from setting it to 0).
|
||||
print("--- Step 6: clear order B's wait time ---")
|
||||
await client.change_timetable(vehicle_id, ORDER_B, ModifyTimetableFlags.WaitTime, 0, clear_field=True)
|
||||
await asyncio.sleep(0.5)
|
||||
show("order B wait time cleared")
|
||||
|
||||
# 7. Autofill: start it preserving existing (fixed) wait times, then turn it off again.
|
||||
print("--- Step 7: toggle autofill ---")
|
||||
await client.autofill_timetable(vehicle_id, autofill=True, preserve_wait_time=True)
|
||||
await asyncio.sleep(0.5)
|
||||
show("autofill enabled (preserving wait times)")
|
||||
await client.autofill_timetable(vehicle_id, autofill=False)
|
||||
await asyncio.sleep(0.5)
|
||||
show("autofill disabled")
|
||||
|
||||
# 8. Start the timetable for this vehicle only, then restart it for the whole group.
|
||||
print("--- Step 8: set timetable start ---")
|
||||
await client.set_timetable_start(vehicle_id, timetable_all=False, start_date=1_000_000)
|
||||
await asyncio.sleep(0.5)
|
||||
show("timetable started (this vehicle only)")
|
||||
await client.set_timetable_start(vehicle_id, timetable_all=True, start_date=1_500_000)
|
||||
await asyncio.sleep(0.5)
|
||||
show("timetable restarted (whole group)")
|
||||
|
||||
# 9. Reset lateness for this vehicle, then for the whole group sharing its orders.
|
||||
print("--- Step 9: reset lateness ---")
|
||||
await client.set_vehicle_on_time(vehicle_id, apply_to_group=False)
|
||||
await asyncio.sleep(0.5)
|
||||
show("lateness reset (this vehicle only)")
|
||||
await client.set_vehicle_on_time(vehicle_id, apply_to_group=True)
|
||||
await asyncio.sleep(0.5)
|
||||
show("lateness reset (whole group)")
|
||||
|
||||
# 10. Add an order to the front of the list, then remove it again (net-zero, so the
|
||||
# vehicle's route is left unchanged). Inserting before position 0 and deleting
|
||||
# position 0 needs no knowledge of the existing order count.
|
||||
print(f"--- Step 10: add then remove a 'go to station {DEMO_STATION_ID}' order ---")
|
||||
await client.add_order(vehicle_id, DEMO_STATION_ID, before_position=0)
|
||||
await asyncio.sleep(0.5)
|
||||
print(" -> inserted a goto-station order at position 0")
|
||||
await client.remove_order(vehicle_id, 0)
|
||||
await asyncio.sleep(0.5)
|
||||
print(" -> removed it again (route restored)")
|
||||
|
||||
# 11. Scheduled dispatch: create a schedule with two departure slots, enable it, then tear it
|
||||
# all down again so the vehicle is left as it started. Read it back with
|
||||
# OpenTTDAdminClient.get_dispatch() (see main_admin.py); the game port has no dispatch read.
|
||||
print("--- Step 11: scheduled dispatch create/enable, then clean up ---")
|
||||
await client.add_dispatch_schedule(vehicle_id, start_tick=0, duration=3000)
|
||||
await client.add_dispatch_slot(vehicle_id, 0, 500)
|
||||
await client.add_dispatch_slot(vehicle_id, 0, 1500)
|
||||
await client.set_scheduled_dispatch(vehicle_id, True)
|
||||
await asyncio.sleep(0.5)
|
||||
print(" -> created schedule 0 with 2 slots and enabled scheduled dispatch")
|
||||
await client.set_scheduled_dispatch(vehicle_id, False)
|
||||
await client.remove_dispatch_schedule(vehicle_id, 0)
|
||||
await asyncio.sleep(0.5)
|
||||
print(" -> disabled and removed the schedule (restored)")
|
||||
|
||||
print(f"=== Timetable demo finished. Final state for vehicle {vehicle_id}: ===")
|
||||
print(f" {client.get_vehicle_timetable(vehicle_id)}")
|
||||
|
||||
async def run_client():
|
||||
# 1. Initialize high-level client
|
||||
username = sys.argv[1] if len(sys.argv) > 1 else "Modular_Joiner"
|
||||
@@ -41,7 +159,11 @@ async def run_client():
|
||||
await client.joined.wait()
|
||||
print(f"--- Successfully joined! Client ID: {client.client_id} ---")
|
||||
|
||||
# 6. Lifecycle management
|
||||
# 6. Timetable demonstration (requires DEMO_VEHICLE_ID to be owned by COMPANY_ID)
|
||||
if DEMO_VEHICLE_ID is not None:
|
||||
await demo_timetable_workflow(client, DEMO_VEHICLE_ID)
|
||||
|
||||
# 7. Lifecycle management
|
||||
# We wait for either a manual shutdown signal or a 10s timeout
|
||||
try:
|
||||
await asyncio.wait_for(client.shutdown_event.wait(), timeout=10.0)
|
||||
@@ -49,7 +171,7 @@ async def run_client():
|
||||
print("--- Finished 10s stay, exiting gracefully ---")
|
||||
await client.quit()
|
||||
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: BLE001 - top-level demo handler: report any failure instead of dumping a traceback
|
||||
print(f"!!! Error: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+65
-5
@@ -1,13 +1,13 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add the lib directory to sys.path so we can import the openttd package
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), 'lib'))
|
||||
|
||||
from openttd import OpenTTDAdminClient
|
||||
from openttd.protocol import AdminUpdateType, AdminUpdateFrequency
|
||||
from openttd.protocol import AdminUpdateFrequency, AdminUpdateType, GameEventType
|
||||
|
||||
# Configuration
|
||||
SERVER_HOST = "127.0.0.1"
|
||||
@@ -48,13 +48,73 @@ async def run_admin():
|
||||
await admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
|
||||
|
||||
print("--- Requesting vehicle info via GameScript ---")
|
||||
await admin.send_gamescript({"command": "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:
|
||||
# Cargo ids come from the loaded NewGRFs, so resolve them to labels to print.
|
||||
labels = {c["cargo_id"]: c["label"]
|
||||
for c in (await admin.list_cargo(timeout=10.0))["cargo"]}
|
||||
|
||||
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" {labels.get(cargo['cargo_id'], cargo['cargo_id'])}: "
|
||||
f"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 {labels.get(cid, cid)} flow breakdown "
|
||||
f"(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: # noqa: BLE001 - demo script: one failed station query should not abort the walk
|
||||
print(f"!!! station query failed: {e}")
|
||||
|
||||
# Watch the game live: vehicles reaching/leaving stops and cargo piling up at stations.
|
||||
print("--- Subscribing to game events ---")
|
||||
try:
|
||||
accepted = await admin.subscribe_events(
|
||||
events=[GameEventType.VehicleArrive, GameEventType.VehicleDepart,
|
||||
GameEventType.CargoWaiting],
|
||||
interval=5, timeout=10.0)
|
||||
print(f" subscribed to {accepted['events']} every {accepted['interval']} ticks")
|
||||
|
||||
for _ in range(5):
|
||||
try:
|
||||
event = await admin.wait_for_event(timeout=15.0)
|
||||
except asyncio.TimeoutError:
|
||||
print(" (nothing happened -- is the server paused or idle?)")
|
||||
break
|
||||
print(f">>> [EVENT] {event}")
|
||||
await admin.unsubscribe_events()
|
||||
except Exception as e: # noqa: BLE001 - demo script: report and carry on to a clean quit
|
||||
print(f"!!! event subscription failed: {e}")
|
||||
|
||||
await asyncio.sleep(5)
|
||||
print("--- Quitting ---")
|
||||
await admin.quit()
|
||||
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: BLE001 - top-level demo handler: report any failure instead of dumping a traceback
|
||||
print(f"!!! Error: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
import pytest
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def server_config():
|
||||
"""Provides server connection parameters from environment variables with defaults."""
|
||||
|
||||
+370
-4
@@ -1,9 +1,24 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
import monocypher
|
||||
import pytest
|
||||
from openttd import OpenTTDAdminClient
|
||||
from openttd.protocol import PacketAdminType, AdminUpdateType, AdminUpdateFrequency
|
||||
from openttd.protocol import (
|
||||
GS_BRIDGE_VERSION,
|
||||
AdminUpdateFrequency,
|
||||
AdminUpdateType,
|
||||
PacketAdminType,
|
||||
)
|
||||
|
||||
|
||||
class FakeNetworkError(OSError):
|
||||
"""Stand-in for a socket-level failure, so it matches the client's narrowed handlers."""
|
||||
|
||||
def decode_gamescript_payload(packet):
|
||||
"""Decode the JSON payload of an AdminGamescript packet (2-byte length + 1-byte type + string)."""
|
||||
return json.loads(packet[3:].split(b"\x00")[0])
|
||||
|
||||
class MockTransport:
|
||||
def __init__(self):
|
||||
@@ -27,6 +42,34 @@ def test_admin_packet_types():
|
||||
assert PacketAdminType.ServerWelcome == 104
|
||||
assert PacketAdminType.ServerAuthRequest == 128
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_list_vehicles():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
proto = MockProtocol()
|
||||
client._protocol = proto
|
||||
client._transport = MockTransport()
|
||||
|
||||
await client.list_vehicles()
|
||||
await client.list_vehicles(company_id=2)
|
||||
|
||||
assert len(proto.sent) == 2
|
||||
assert decode_gamescript_payload(proto.sent[0]) == {"command": "list_vehicles"}
|
||||
assert decode_gamescript_payload(proto.sent[1]) == {"command": "list_vehicles", "company_id": 2}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_list_stations():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
proto = MockProtocol()
|
||||
client._protocol = proto
|
||||
client._transport = MockTransport()
|
||||
|
||||
await client.list_stations()
|
||||
await client.list_stations(company_id=2)
|
||||
|
||||
assert len(proto.sent) == 2
|
||||
assert decode_gamescript_payload(proto.sent[0]) == {"command": "list_stations"}
|
||||
assert decode_gamescript_payload(proto.sent[1]) == {"command": "list_stations", "company_id": 2}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_client_connect_and_actions(monkeypatch):
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
@@ -51,7 +94,7 @@ async def test_admin_client_connect_and_actions(monkeypatch):
|
||||
|
||||
# 3. Connect Exception
|
||||
async def mock_fail(*args, **kwargs):
|
||||
raise Exception("Connection Failed")
|
||||
raise FakeNetworkError("Connection Failed")
|
||||
monkeypatch.setattr(asyncio.get_running_loop(), "create_connection", mock_fail)
|
||||
with pytest.raises(Exception, match="Connection Failed"):
|
||||
await client.connect()
|
||||
@@ -163,8 +206,331 @@ async def test_admin_client_connect_and_actions(monkeypatch):
|
||||
# 8. Quit Exception
|
||||
class BadProtocol:
|
||||
async def send_packet(self, data):
|
||||
raise Exception("Fail")
|
||||
raise FakeNetworkError("Fail")
|
||||
client._transport = MockTransport()
|
||||
client._protocol = BadProtocol()
|
||||
await client.quit()
|
||||
assert client.shutdown_event.is_set()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_timetable_request_and_response():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
proto = MockProtocol()
|
||||
client._protocol = proto
|
||||
client._transport = MockTransport()
|
||||
|
||||
task = asyncio.ensure_future(client.get_timetable(5))
|
||||
await asyncio.sleep(0) # let the task send the request
|
||||
|
||||
# First use auto-subscribes to Gamescript updates, then sends the query.
|
||||
assert len(proto.sent) == 2
|
||||
assert proto.sent[0][2] == PacketAdminType.AdminUpdateFrequency
|
||||
assert decode_gamescript_payload(proto.sent[1]) == {
|
||||
"command": "get_timetable", "vehicle_id": 5, "request_id": 1,
|
||||
}
|
||||
|
||||
response = {"command": "get_timetable", "vehicle_id": 5, "request_id": 1,
|
||||
"lateness": 0, "start_tick": 0, "current_order_time": 3,
|
||||
"total_duration": -1, "orders": []}
|
||||
await client.receive_ServerGamescript(None, data=response)
|
||||
assert await task == response
|
||||
assert client._gs_futures == {}
|
||||
|
||||
# Second call must not re-subscribe and must use a fresh request id.
|
||||
task = asyncio.ensure_future(client.get_timetable(9))
|
||||
await asyncio.sleep(0)
|
||||
assert len(proto.sent) == 3
|
||||
assert decode_gamescript_payload(proto.sent[2])["request_id"] == 2
|
||||
await client.receive_ServerGamescript(None, data={"request_id": 2, "orders": []})
|
||||
assert (await task)["orders"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_timetable_timeout():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
client._protocol = MockProtocol()
|
||||
client._transport = MockTransport()
|
||||
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await client.get_timetable(5, timeout=0.05)
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_timetable_error_response():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
client._protocol = MockProtocol()
|
||||
client._transport = MockTransport()
|
||||
|
||||
task = asyncio.ensure_future(client.get_timetable(65535))
|
||||
await asyncio.sleep(0)
|
||||
await client.receive_ServerGamescript(
|
||||
None, data={"command": "get_timetable", "vehicle_id": 65535,
|
||||
"request_id": 1, "error": "invalid_vehicle"})
|
||||
with pytest.raises(ValueError, match="invalid_vehicle"):
|
||||
await task
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_timetable_disconnect_fails_pending():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
client._protocol = MockProtocol()
|
||||
client._transport = MockTransport()
|
||||
|
||||
task = asyncio.ensure_future(client.get_timetable(5))
|
||||
await asyncio.sleep(0)
|
||||
client.disconnect(None)
|
||||
with pytest.raises(ConnectionError):
|
||||
await task
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_station_request_and_response():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
proto = MockProtocol()
|
||||
client._protocol = proto
|
||||
client._transport = MockTransport()
|
||||
|
||||
task = asyncio.ensure_future(client.get_station(3))
|
||||
await asyncio.sleep(0) # let the task send the request
|
||||
|
||||
# First use auto-subscribes to Gamescript updates, then sends the query.
|
||||
assert len(proto.sent) == 2
|
||||
assert proto.sent[0][2] == PacketAdminType.AdminUpdateFrequency
|
||||
assert decode_gamescript_payload(proto.sent[1]) == {
|
||||
"command": "get_station", "station_id": 3, "request_id": 1,
|
||||
}
|
||||
|
||||
response = {"command": "get_station", "station_id": 3, "request_id": 1,
|
||||
"name": "Test Central", "location": 12345, "owner": 0,
|
||||
"cargo": [{"cargo_id": 0, "waiting": 42, "planned": 17, "rating": 71}]}
|
||||
await client.receive_ServerGamescript(None, data=response)
|
||||
assert await task == response
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_station_error_response():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
client._protocol = MockProtocol()
|
||||
client._transport = MockTransport()
|
||||
|
||||
task = asyncio.ensure_future(client.get_station(65535))
|
||||
await asyncio.sleep(0)
|
||||
await client.receive_ServerGamescript(
|
||||
None, data={"command": "get_station", "station_id": 65535,
|
||||
"request_id": 1, "error": "invalid_station"})
|
||||
with pytest.raises(ValueError, match="invalid_station"):
|
||||
await task
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_station_timeout():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
client._protocol = MockProtocol()
|
||||
client._transport = MockTransport()
|
||||
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await client.get_station(3, timeout=0.05)
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_station_cargo_request_and_response():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
proto = MockProtocol()
|
||||
client._protocol = proto
|
||||
client._transport = MockTransport()
|
||||
|
||||
task = asyncio.ensure_future(client.get_station_cargo(3, 0))
|
||||
await asyncio.sleep(0) # let the task send the request
|
||||
|
||||
# First use auto-subscribes to Gamescript updates, then sends the query.
|
||||
assert len(proto.sent) == 2
|
||||
assert proto.sent[0][2] == PacketAdminType.AdminUpdateFrequency
|
||||
assert decode_gamescript_payload(proto.sent[1]) == {
|
||||
"command": "get_station_cargo", "station_id": 3, "cargo_id": 0, "request_id": 1,
|
||||
}
|
||||
|
||||
response = {"command": "get_station_cargo", "station_id": 3, "cargo_id": 0, "request_id": 1,
|
||||
"waiting": 60, "planned": 40,
|
||||
"waiting_by_from": [{"station": 5, "amount": 25}, {"station": 6, "amount": 35}],
|
||||
"planned_by_from": [{"station": 5, "amount": 40}],
|
||||
"waiting_by_via": [{"station": 7, "amount": 60}],
|
||||
"planned_by_via": [{"station": 7, "amount": 40}]}
|
||||
await client.receive_ServerGamescript(None, data=response)
|
||||
assert await task == response
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_station_cargo_with_filters_encoding():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
proto = MockProtocol()
|
||||
client._protocol = proto
|
||||
client._transport = MockTransport()
|
||||
client._gs_subscribed = True # skip the auto-subscribe so only the query is sent
|
||||
|
||||
task = asyncio.ensure_future(client.get_station_cargo(3, 0, from_station=6, via_station=7))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert len(proto.sent) == 1
|
||||
assert decode_gamescript_payload(proto.sent[0]) == {
|
||||
"command": "get_station_cargo", "station_id": 3, "cargo_id": 0,
|
||||
"from_station": 6, "via_station": 7, "request_id": 1,
|
||||
}
|
||||
await client.receive_ServerGamescript(
|
||||
None, data={"request_id": 1, "waiting": 12, "planned": 8})
|
||||
assert (await task)["waiting"] == 12
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_station_cargo_error_response():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
client._protocol = MockProtocol()
|
||||
client._transport = MockTransport()
|
||||
|
||||
task = asyncio.ensure_future(client.get_station_cargo(3, 999))
|
||||
await asyncio.sleep(0)
|
||||
await client.receive_ServerGamescript(
|
||||
None, data={"command": "get_station_cargo", "station_id": 3, "cargo_id": 999,
|
||||
"request_id": 1, "error": "invalid_cargo"})
|
||||
with pytest.raises(ValueError, match="invalid_cargo"):
|
||||
await task
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_list_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.list_cargo())
|
||||
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": "list_cargo", "request_id": 1,
|
||||
}
|
||||
|
||||
response = {"command": "list_cargo", "request_id": 1,
|
||||
"cargo": [{"cargo_id": 0, "label": "PASS", "freight": 0},
|
||||
{"cargo_id": 1, "label": "COAL", "freight": 1}]}
|
||||
await client.receive_ServerGamescript(None, data=response)
|
||||
assert await task == response
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_list_cargo_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.list_cargo(timeout=0.05)
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_dispatch_request_and_response():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
proto = MockProtocol()
|
||||
client._protocol = proto
|
||||
client._transport = MockTransport()
|
||||
|
||||
task = asyncio.ensure_future(client.get_dispatch(7))
|
||||
await asyncio.sleep(0) # let the task send the request
|
||||
|
||||
# First use auto-subscribes to Gamescript updates, then sends the query.
|
||||
assert len(proto.sent) == 2
|
||||
assert proto.sent[0][2] == PacketAdminType.AdminUpdateFrequency
|
||||
assert decode_gamescript_payload(proto.sent[1]) == {
|
||||
"command": "get_dispatch", "vehicle_id": 7, "request_id": 1,
|
||||
}
|
||||
|
||||
response = {"command": "get_dispatch", "vehicle_id": 7, "request_id": 1,
|
||||
"enabled": 1,
|
||||
"schedules": [{"index": 0, "duration": 3000, "start_tick": 0, "delay": 0,
|
||||
"reuse_slots": 0,
|
||||
"slots": [{"offset": 500, "flags": 0}, {"offset": 1500, "flags": 0}]}]}
|
||||
await client.receive_ServerGamescript(None, data=response)
|
||||
assert await task == response
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_dispatch_error_response():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
client._protocol = MockProtocol()
|
||||
client._transport = MockTransport()
|
||||
|
||||
task = asyncio.ensure_future(client.get_dispatch(65535))
|
||||
await asyncio.sleep(0)
|
||||
await client.receive_ServerGamescript(
|
||||
None, data={"command": "get_dispatch", "vehicle_id": 65535,
|
||||
"request_id": 1, "error": "invalid_vehicle"})
|
||||
with pytest.raises(ValueError, match="invalid_vehicle"):
|
||||
await task
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_bridge_version_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_bridge_version())
|
||||
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_version", "request_id": 1,
|
||||
}
|
||||
|
||||
response = {"command": "get_version", "request_id": 1, "version": GS_BRIDGE_VERSION,
|
||||
"commands": ["get_timetable", "get_version"],
|
||||
"events": ["vehicle_arrive", "vehicle_depart"]}
|
||||
await client.receive_ServerGamescript(None, data=response)
|
||||
assert await task == response
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_bridge_version_rejects_older_bridge():
|
||||
"""A bridge that answers but is too old fails by name, not by timeout."""
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
client._protocol = MockProtocol()
|
||||
client._transport = MockTransport()
|
||||
|
||||
task = asyncio.ensure_future(client.get_bridge_version(minimum=GS_BRIDGE_VERSION + 1))
|
||||
await asyncio.sleep(0)
|
||||
await client.receive_ServerGamescript(
|
||||
None, data={"command": "get_version", "request_id": 1, "version": GS_BRIDGE_VERSION})
|
||||
with pytest.raises(ValueError, match=f"version {GS_BRIDGE_VERSION}"):
|
||||
await task
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_bridge_version_minimum_zero_accepts_anything():
|
||||
"""minimum=0 reads the version without requiring anything, even if the reply omits it."""
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
client._protocol = MockProtocol()
|
||||
client._transport = MockTransport()
|
||||
|
||||
task = asyncio.ensure_future(client.get_bridge_version(minimum=0))
|
||||
await asyncio.sleep(0)
|
||||
await client.receive_ServerGamescript(
|
||||
None, data={"command": "get_version", "request_id": 1, "commands": []})
|
||||
assert await task == {"command": "get_version", "request_id": 1, "commands": []}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_gamescript_passthrough_unmatched():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
client._protocol = MockProtocol()
|
||||
client._transport = MockTransport()
|
||||
|
||||
gs_events = []
|
||||
client.on_gamescript = lambda data: gs_events.append(data)
|
||||
|
||||
# No request_id, unknown request_id, and non-dict payloads all pass through.
|
||||
await client.receive_ServerGamescript(None, data={"vehicles": []})
|
||||
await client.receive_ServerGamescript(None, data={"request_id": 999, "orders": []})
|
||||
await client.receive_ServerGamescript(None, data="plain string")
|
||||
assert gs_events == [{"vehicles": []}, {"request_id": 999, "orders": []}, "plain string"]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
import struct
|
||||
from openttd.protocol import OpenTTDProtocol, PacketGameType
|
||||
|
||||
import pytest
|
||||
from openttd.client import OpenTTDClient
|
||||
from openttd.protocol import OpenTTDProtocol, PacketGameType
|
||||
|
||||
|
||||
class MockTransport:
|
||||
def __init__(self): self._closing = False
|
||||
@@ -10,16 +12,19 @@ class MockTransport:
|
||||
def close(self): self._closing = True
|
||||
def write(self, data): return len(data)
|
||||
|
||||
class FakeNetworkError(OSError):
|
||||
"""Stand-in for a socket-level failure, so it matches the client's narrowed handlers."""
|
||||
|
||||
class MockProtocol:
|
||||
async def send_packet(self, data):
|
||||
raise Exception("Send failed")
|
||||
raise FakeNetworkError("Send failed")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_connect_exception(monkeypatch):
|
||||
# Coverage for client.py:51-53
|
||||
client = OpenTTDClient(host="127.0.0.1")
|
||||
async def mock_fail(*args, **kwargs):
|
||||
raise Exception("Async Failure")
|
||||
raise FakeNetworkError("Async Failure")
|
||||
monkeypatch.setattr(asyncio.get_running_loop(), "create_connection", mock_fail)
|
||||
with pytest.raises(Exception, match="Async Failure"):
|
||||
await client.connect()
|
||||
|
||||
+622
-10
@@ -1,21 +1,41 @@
|
||||
import asyncio
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import sys
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
# Add lib to path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'lib'))
|
||||
|
||||
from openttd import OpenTTDClient, OpenTTDAdminClient
|
||||
from openttd import OpenTTDAdminClient, OpenTTDClient
|
||||
from openttd.protocol import (
|
||||
OpenTTDProtocol,
|
||||
OpenTTDAdminProtocol,
|
||||
AdminUpdateType,
|
||||
GS_BRIDGE_VERSION,
|
||||
AdminUpdateFrequency,
|
||||
PacketGameType
|
||||
AdminUpdateType,
|
||||
GameEventType,
|
||||
ModifyTimetableFlags,
|
||||
OpenTTDAdminProtocol,
|
||||
OpenTTDProtocol,
|
||||
PacketGameType,
|
||||
)
|
||||
|
||||
# These identify a vehicle/order that already exists in the local dev server's persisted
|
||||
# save (company 0, unprotected, owns vehicle 7 with 2 orders) -- required for the timetable
|
||||
# command tests below, since DoCommands are rejected unless issued by the owning company.
|
||||
TIMETABLE_COMPANY_ID = 0
|
||||
TIMETABLE_VEHICLE_ID = 7
|
||||
TIMETABLE_ORDER_POSITION = 0
|
||||
# A station TIMETABLE_VEHICLE_ID can legally serve, used for add_order/remove_order tests.
|
||||
ORDER_STATION_ID = 6
|
||||
# A second vehicle of the same company, dedicated to the scheduled-dispatch test, which owns and
|
||||
# overwrites this vehicle's dispatch state. It must have its own order list -- dispatch schedules
|
||||
# live on the order list, so pointing this at a vehicle that *shares* orders with another would
|
||||
# silently rewrite that other vehicle's schedules too. (Cloning a vehicle without sharing orders
|
||||
# gives an independent list, but copies the source's schedules along with it.)
|
||||
DISPATCH_VEHICLE_ID = 14
|
||||
|
||||
|
||||
# --- Pytest Fixtures ---
|
||||
|
||||
@@ -52,6 +72,22 @@ async def connected_client(server_config):
|
||||
if hasattr(client, '_transport') and not client.shutdown_event.is_set():
|
||||
await client.quit()
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def connected_owner_client(server_config):
|
||||
"""Fixture to yield a client joined to TIMETABLE_COMPANY_ID (owns a real vehicle for command tests)."""
|
||||
client_name = f"E2E_Owner_{random.randint(1000, 9999)}"
|
||||
client = OpenTTDClient(
|
||||
host=server_config["host"],
|
||||
port=server_config["game_port"],
|
||||
username=client_name
|
||||
)
|
||||
await client.connect(server_password=server_config["password"])
|
||||
await client.join_company(company_id=TIMETABLE_COMPANY_ID, company_password="")
|
||||
await asyncio.wait_for(client.joined.wait(), timeout=15.0)
|
||||
yield client
|
||||
if hasattr(client, '_transport') and not client.shutdown_event.is_set():
|
||||
await client.quit()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# --- End-to-End Tests (Covering all public functions with multiple inputs) ---
|
||||
@@ -129,6 +165,222 @@ async def test_e2e_client_quit_and_disconnect_multiple_inputs(server_config):
|
||||
# Input 2: quit already inactive client
|
||||
await client2.quit()
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_client_change_timetable_wait_time(connected_owner_client):
|
||||
# Public function: change_timetable()
|
||||
# Input 1: set wait time
|
||||
await connected_owner_client.change_timetable(TIMETABLE_VEHICLE_ID, TIMETABLE_ORDER_POSITION, ModifyTimetableFlags.WaitTime, 42)
|
||||
await asyncio.sleep(1.0)
|
||||
assert not connected_owner_client.shutdown_event.is_set()
|
||||
entry = connected_owner_client.get_vehicle_timetable(TIMETABLE_VEHICLE_ID)
|
||||
assert entry["orders"][TIMETABLE_ORDER_POSITION]["wait_time"] == 42
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_client_change_timetable_travel_time(connected_owner_client):
|
||||
# Public function: change_timetable()
|
||||
# Input 2: set travel time
|
||||
await connected_owner_client.change_timetable(TIMETABLE_VEHICLE_ID, TIMETABLE_ORDER_POSITION, ModifyTimetableFlags.TravelTime, 99)
|
||||
await asyncio.sleep(1.0)
|
||||
assert not connected_owner_client.shutdown_event.is_set()
|
||||
entry = connected_owner_client.get_vehicle_timetable(TIMETABLE_VEHICLE_ID)
|
||||
assert entry["orders"][TIMETABLE_ORDER_POSITION]["travel_time"] == 99
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_client_autofill_timetable_enable(connected_owner_client):
|
||||
# Public function: autofill_timetable()
|
||||
# Input 1: enable autofill
|
||||
await connected_owner_client.autofill_timetable(TIMETABLE_VEHICLE_ID, autofill=True, preserve_wait_time=False)
|
||||
await asyncio.sleep(1.0)
|
||||
assert not connected_owner_client.shutdown_event.is_set()
|
||||
assert connected_owner_client.get_vehicle_timetable(TIMETABLE_VEHICLE_ID)["autofill"] is True
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_client_autofill_timetable_disable(connected_owner_client):
|
||||
# Public function: autofill_timetable()
|
||||
# Input 2: disable autofill, preserve wait time
|
||||
await connected_owner_client.autofill_timetable(TIMETABLE_VEHICLE_ID, autofill=False, preserve_wait_time=True)
|
||||
await asyncio.sleep(1.0)
|
||||
assert not connected_owner_client.shutdown_event.is_set()
|
||||
entry = connected_owner_client.get_vehicle_timetable(TIMETABLE_VEHICLE_ID)
|
||||
assert entry["autofill"] is False
|
||||
assert entry["autofill_preserve_wait_time"] is True
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_client_set_timetable_start_single_vehicle(connected_owner_client):
|
||||
# Public function: set_timetable_start()
|
||||
# Input 1: this vehicle only
|
||||
await connected_owner_client.set_timetable_start(TIMETABLE_VEHICLE_ID, False, 500000)
|
||||
await asyncio.sleep(1.0)
|
||||
assert not connected_owner_client.shutdown_event.is_set()
|
||||
entry = connected_owner_client.get_vehicle_timetable(TIMETABLE_VEHICLE_ID)
|
||||
assert entry["timetable_start"] == 500000
|
||||
assert entry["timetable_all"] is False
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_client_set_timetable_start_all_shared(connected_owner_client):
|
||||
# Public function: set_timetable_start()
|
||||
# Input 2: all vehicles sharing this order list
|
||||
await connected_owner_client.set_timetable_start(TIMETABLE_VEHICLE_ID, True, 600000)
|
||||
await asyncio.sleep(1.0)
|
||||
assert not connected_owner_client.shutdown_event.is_set()
|
||||
entry = connected_owner_client.get_vehicle_timetable(TIMETABLE_VEHICLE_ID)
|
||||
assert entry["timetable_start"] == 600000
|
||||
assert entry["timetable_all"] is True
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_client_set_vehicle_on_time_single_vehicle(connected_owner_client):
|
||||
# Public function: set_vehicle_on_time()
|
||||
# Input 1: reset lateness for this vehicle only
|
||||
await connected_owner_client.set_vehicle_on_time(TIMETABLE_VEHICLE_ID, apply_to_group=False)
|
||||
await asyncio.sleep(1.0)
|
||||
assert not connected_owner_client.shutdown_event.is_set()
|
||||
assert connected_owner_client.get_vehicle_timetable(TIMETABLE_VEHICLE_ID)["on_time_apply_to_group"] is False
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_client_set_vehicle_on_time_apply_to_group(connected_owner_client):
|
||||
# Public function: set_vehicle_on_time()
|
||||
# Input 2: reset lateness for every vehicle sharing these orders
|
||||
await connected_owner_client.set_vehicle_on_time(TIMETABLE_VEHICLE_ID, apply_to_group=True)
|
||||
await asyncio.sleep(1.0)
|
||||
assert not connected_owner_client.shutdown_event.is_set()
|
||||
assert connected_owner_client.get_vehicle_timetable(TIMETABLE_VEHICLE_ID)["on_time_apply_to_group"] is True
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_client_get_vehicle_timetable_after_change(connected_owner_client):
|
||||
# Public function: get_vehicle_timetable()
|
||||
# Input 1: a vehicle with observed state
|
||||
await connected_owner_client.change_timetable(TIMETABLE_VEHICLE_ID, TIMETABLE_ORDER_POSITION, ModifyTimetableFlags.WaitTime, 15)
|
||||
await asyncio.sleep(1.0)
|
||||
assert connected_owner_client.get_vehicle_timetable(TIMETABLE_VEHICLE_ID) is not None
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_client_get_vehicle_timetable_unknown_vehicle(connected_owner_client):
|
||||
# Public function: get_vehicle_timetable()
|
||||
# Input 2: a vehicle id with no observed state
|
||||
assert connected_owner_client.get_vehicle_timetable(999999) is None
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_client_add_and_remove_order(connected_owner_client, connected_admin):
|
||||
# Public functions: add_order(), remove_order()
|
||||
# Verified authoritatively via the admin get_timetable() order count. The test appends and
|
||||
# inserts an order, then removes both, leaving the vehicle's order list as it started.
|
||||
async def order_count():
|
||||
data = await connected_admin.get_timetable(TIMETABLE_VEHICLE_ID, timeout=10.0)
|
||||
return len(data["orders"])
|
||||
|
||||
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
|
||||
before = await order_count()
|
||||
|
||||
# add_order input 1: append a goto-station order to the end of the list.
|
||||
await connected_owner_client.add_order(TIMETABLE_VEHICLE_ID, ORDER_STATION_ID)
|
||||
await asyncio.sleep(1.0)
|
||||
assert not connected_owner_client.shutdown_event.is_set()
|
||||
assert await order_count() == before + 1
|
||||
|
||||
# add_order input 2: insert another before position 0.
|
||||
await connected_owner_client.add_order(TIMETABLE_VEHICLE_ID, ORDER_STATION_ID, before_position=0)
|
||||
await asyncio.sleep(1.0)
|
||||
assert await order_count() == before + 2
|
||||
|
||||
# remove_order input 1: delete the one just inserted at the front.
|
||||
await connected_owner_client.remove_order(TIMETABLE_VEHICLE_ID, 0)
|
||||
await asyncio.sleep(1.0)
|
||||
assert await order_count() == before + 1
|
||||
|
||||
# remove_order input 2: delete the appended order (now the last one) to restore the list.
|
||||
await connected_owner_client.remove_order(TIMETABLE_VEHICLE_ID, before)
|
||||
await asyncio.sleep(1.0)
|
||||
assert not connected_owner_client.shutdown_event.is_set()
|
||||
assert await order_count() == before
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_client_scheduled_dispatch_edit_and_view(connected_owner_client, connected_admin):
|
||||
# Public functions: set_scheduled_dispatch(), add_dispatch_schedule(), remove_dispatch_schedule(),
|
||||
# add_dispatch_slot(), remove_dispatch_slot(), clear_dispatch_schedule(), set_dispatch_duration(),
|
||||
# set_dispatch_start_date(), and get_dispatch(). Edits go over the game port and are read back
|
||||
# authoritatively via the admin get_dispatch(). The test leaves the vehicle with no schedules.
|
||||
veh = DISPATCH_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)
|
||||
|
||||
# The assertions below address schedules by absolute index, so DISPATCH_VEHICLE_ID must start
|
||||
# with none of its own; the test restores that state on the way out.
|
||||
start = await dispatch() # get_dispatch input 1: a valid vehicle
|
||||
assert "schedules" in start and isinstance(start["schedules"], list)
|
||||
|
||||
# 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 ---
|
||||
|
||||
@@ -267,6 +519,366 @@ async def test_e2e_admin_send_gamescript_multiple_inputs(connected_admin):
|
||||
await connected_admin.send_gamescript({"command": "ping", "sequence": 1})
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_get_bridge_version_supported(connected_admin):
|
||||
# Public function: get_bridge_version()
|
||||
# Input 1: the default minimum, i.e. the version this client is written against.
|
||||
#
|
||||
# Every GameScript test below this one fails as an unexplained timeout if the server runs a
|
||||
# stale AdminBridge (or none at all), because a bridge that does not know a command simply
|
||||
# drops it. This test is the one that says so out loud, so run it first when the GS tests
|
||||
# start hanging.
|
||||
try:
|
||||
data = await connected_admin.get_bridge_version(timeout=10.0)
|
||||
except asyncio.TimeoutError:
|
||||
pytest.fail(
|
||||
"The server's AdminBridge GameScript did not answer get_version. It is older than "
|
||||
f"version {GS_BRIDGE_VERSION} (which introduced the command), not loaded at all, or "
|
||||
"the game is paused. See gamescript/AdminBridge/README.md.")
|
||||
|
||||
assert data["version"] >= GS_BRIDGE_VERSION
|
||||
# The catalogues let a client feature-detect one command rather than compare versions.
|
||||
assert "get_version" in data["commands"]
|
||||
assert set(data["events"]) == {e.value for e in GameEventType} - {GameEventType.EventsDropped}
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_get_bridge_version_minimum_zero(connected_admin):
|
||||
# Public function: get_bridge_version()
|
||||
# Input 2: minimum=0 -> read the version without requiring anything of it.
|
||||
data = await connected_admin.get_bridge_version(minimum=0, timeout=10.0)
|
||||
assert isinstance(data["version"], int)
|
||||
assert "get_timetable" in data["commands"]
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_list_vehicles_all_companies(connected_admin):
|
||||
# Public function: list_vehicles()
|
||||
# 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_vehicles()
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
assert not connected_admin.shutdown_event.is_set()
|
||||
assert len(responses) >= 1
|
||||
assert "vehicles" in responses[-1]
|
||||
assert isinstance(responses[-1]["vehicles"], list)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_list_vehicles_specific_company(connected_admin):
|
||||
# Public function: list_vehicles()
|
||||
# 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_vehicles(company_id=0)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
assert not connected_admin.shutdown_event.is_set()
|
||||
assert len(responses) >= 1
|
||||
assert "vehicles" in responses[-1]
|
||||
assert isinstance(responses[-1]["vehicles"], list)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_get_timetable_valid_vehicle(connected_admin):
|
||||
# Public function: get_timetable()
|
||||
# Input 1: a real vehicle id discovered via list_vehicles
|
||||
responses = []
|
||||
connected_admin.on_gamescript = lambda data: responses.append(data)
|
||||
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
|
||||
await connected_admin.list_vehicles()
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
assert len(responses) >= 1 and "vehicles" in responses[-1]
|
||||
vehicles = responses[-1]["vehicles"]
|
||||
if not vehicles:
|
||||
pytest.skip("No vehicles on the test server to query a timetable for.")
|
||||
vid = vehicles[0]["id"]
|
||||
|
||||
data = await connected_admin.get_timetable(vid, timeout=10.0)
|
||||
assert data["vehicle_id"] == vid
|
||||
for key in ("lateness", "start_tick", "current_order_time", "total_duration", "orders"):
|
||||
assert key in data
|
||||
assert isinstance(data["orders"], list)
|
||||
for order in data["orders"]:
|
||||
for key in ("position", "wait_time", "travel_time", "wait_timetabled",
|
||||
"travel_timetabled", "wait_fixed", "travel_fixed", "leave_type", "max_speed"):
|
||||
assert key in order
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_get_timetable_invalid_vehicle(connected_admin):
|
||||
# Public function: get_timetable()
|
||||
# Input 2: an id no vehicle can have -> GameScript reports invalid_vehicle
|
||||
with pytest.raises(ValueError, match="invalid_vehicle"):
|
||||
await connected_admin.get_timetable(65535, timeout=10.0)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_get_dispatch_invalid_vehicle(connected_admin):
|
||||
# Public function: get_dispatch()
|
||||
# Input 2: an id no vehicle can have -> GameScript reports invalid_vehicle
|
||||
with pytest.raises(ValueError, match="invalid_vehicle"):
|
||||
await connected_admin.get_dispatch(65535, timeout=10.0)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_list_stations_all_companies(connected_admin):
|
||||
# Public function: list_stations()
|
||||
# Input 1: all companies (no company_id)
|
||||
responses = []
|
||||
connected_admin.on_gamescript = lambda data: responses.append(data)
|
||||
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
|
||||
|
||||
await connected_admin.list_stations()
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
assert not connected_admin.shutdown_event.is_set()
|
||||
assert len(responses) >= 1
|
||||
assert "stations" in responses[-1]
|
||||
assert isinstance(responses[-1]["stations"], list)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_list_stations_specific_company(connected_admin):
|
||||
# Public function: list_stations()
|
||||
# Input 2: specific company_id
|
||||
responses = []
|
||||
connected_admin.on_gamescript = lambda data: responses.append(data)
|
||||
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
|
||||
|
||||
await connected_admin.list_stations(company_id=0)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
assert not connected_admin.shutdown_event.is_set()
|
||||
assert len(responses) >= 1
|
||||
assert "stations" in responses[-1]
|
||||
assert isinstance(responses[-1]["stations"], list)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_get_station_valid_station(connected_admin):
|
||||
# Public function: get_station()
|
||||
# Input 1: a real station id discovered via list_stations
|
||||
responses = []
|
||||
connected_admin.on_gamescript = lambda data: responses.append(data)
|
||||
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
|
||||
await connected_admin.list_stations()
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
assert len(responses) >= 1 and "stations" in responses[-1]
|
||||
stations = responses[-1]["stations"]
|
||||
if not stations:
|
||||
pytest.skip("No stations on the test server to query.")
|
||||
sid = stations[0]["id"]
|
||||
|
||||
data = await connected_admin.get_station(sid, timeout=10.0)
|
||||
assert data["station_id"] == sid
|
||||
assert "cargo" in data
|
||||
assert isinstance(data["cargo"], list)
|
||||
for cargo in data["cargo"]:
|
||||
for key in ("cargo_id", "waiting", "planned", "rating"):
|
||||
assert key in cargo
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_get_station_invalid_station(connected_admin):
|
||||
# Public function: get_station()
|
||||
# Input 2: an id no station can have -> GameScript reports invalid_station
|
||||
with pytest.raises(ValueError, match="invalid_station"):
|
||||
await connected_admin.get_station(65535, timeout=10.0)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_get_station_cargo_breakdown(connected_admin):
|
||||
# Public function: get_station_cargo()
|
||||
# Input 1: a real station + a cargo it has handled, discovered via list_stations/get_station
|
||||
responses = []
|
||||
connected_admin.on_gamescript = lambda data: responses.append(data)
|
||||
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
|
||||
await connected_admin.list_stations()
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
assert responses and "stations" in responses[-1]
|
||||
stations = responses[-1]["stations"]
|
||||
if not stations:
|
||||
pytest.skip("No stations on the test server to query.")
|
||||
|
||||
# Find a station/cargo pair that actually has cargo data.
|
||||
target = None
|
||||
for st in stations:
|
||||
detail = await connected_admin.get_station(st["id"], timeout=10.0)
|
||||
if detail["cargo"]:
|
||||
target = (st["id"], detail["cargo"][0]["cargo_id"])
|
||||
break
|
||||
if target is None:
|
||||
pytest.skip("No station with handled cargo to break down.")
|
||||
sid, cid = target
|
||||
|
||||
data = await connected_admin.get_station_cargo(sid, cid, timeout=10.0)
|
||||
assert data["station_id"] == sid and data["cargo_id"] == cid
|
||||
for key in ("waiting", "planned",
|
||||
"waiting_by_from", "planned_by_from", "waiting_by_via", "planned_by_via"):
|
||||
assert key in data
|
||||
for key in ("waiting_by_from", "planned_by_from", "waiting_by_via", "planned_by_via"):
|
||||
assert isinstance(data[key], list)
|
||||
for entry in data[key]:
|
||||
assert "station" in entry and "amount" in entry
|
||||
|
||||
# Input 2: the same query narrowed by a next-hop (via) filter is accepted and echoes it back.
|
||||
filtered = await connected_admin.get_station_cargo(sid, cid, via_station=sid, timeout=10.0)
|
||||
assert filtered["via_station"] == sid
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_get_station_cargo_invalid_cargo(connected_admin):
|
||||
# Public function: get_station_cargo()
|
||||
# A cargo id no cargo can have -> GameScript reports invalid_cargo. Needs a valid station.
|
||||
responses = []
|
||||
connected_admin.on_gamescript = lambda data: responses.append(data)
|
||||
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
|
||||
await connected_admin.list_stations()
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
assert responses and "stations" in responses[-1]
|
||||
stations = responses[-1]["stations"]
|
||||
if not stations:
|
||||
pytest.skip("No stations on the test server to query.")
|
||||
|
||||
with pytest.raises(ValueError, match="invalid_cargo"):
|
||||
await connected_admin.get_station_cargo(stations[0]["id"], 250, timeout=10.0)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_list_cargo_table(connected_admin):
|
||||
# Public function: list_cargo()
|
||||
# Input 1: an explicit timeout. Every game has a cargo table, so an empty list would be a bug.
|
||||
data = await connected_admin.list_cargo(timeout=10.0)
|
||||
assert isinstance(data["cargo"], list) and data["cargo"]
|
||||
for cargo in data["cargo"]:
|
||||
for key in ("cargo_id", "label", "freight"):
|
||||
assert key in cargo
|
||||
assert cargo["freight"] in (0, 1)
|
||||
ids = [cargo["cargo_id"] for cargo in data["cargo"]]
|
||||
assert len(ids) == len(set(ids))
|
||||
# Any cargo set carries passengers as well as freight, so both kinds must show up.
|
||||
assert any(cargo["freight"] == 0 for cargo in data["cargo"])
|
||||
assert any(cargo["freight"] == 1 for cargo in data["cargo"])
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_list_cargo_resolves_station_cargo_ids(connected_admin):
|
||||
# Public function: list_cargo()
|
||||
# Input 2: the default timeout. The point of the call: naming the bare ids get_station() returns.
|
||||
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.")
|
||||
|
||||
labels = {cargo["cargo_id"]: cargo["label"] for cargo in (await connected_admin.list_cargo())["cargo"]}
|
||||
detail = await connected_admin.get_station(stations[0]["id"], timeout=10.0)
|
||||
for cargo in detail["cargo"]:
|
||||
assert cargo["cargo_id"] in labels
|
||||
|
||||
|
||||
# --- Game Events ---
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_subscribe_events_all_kinds(connected_admin):
|
||||
# Public functions: subscribe_events(), unsubscribe_events()
|
||||
# Input 1: no arguments -> every event kind, default interval
|
||||
data = await connected_admin.subscribe_events(timeout=10.0)
|
||||
assert isinstance(data["events"], list)
|
||||
assert "vehicle_arrive" in data["events"] and "cargo_waiting" in data["events"]
|
||||
assert data["interval"] == 10
|
||||
|
||||
stopped = await connected_admin.unsubscribe_events()
|
||||
assert stopped["events"] == []
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_subscribe_events_filtered(connected_admin):
|
||||
# Public functions: subscribe_events(), unsubscribe_events()
|
||||
# Input 2: a narrowed subscription -> only the requested kinds come back
|
||||
data = await connected_admin.subscribe_events(
|
||||
events=[GameEventType.VehicleArrive, GameEventType.CargoWaiting],
|
||||
interval=5, company_id=0, min_cargo_delta=2, include_cargo=False, timeout=10.0)
|
||||
assert sorted(data["events"]) == ["cargo_waiting", "vehicle_arrive"]
|
||||
assert data["interval"] == 5
|
||||
|
||||
await connected_admin.unsubscribe_events(timeout=10.0)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_subscribe_events_unknown_kind(connected_admin):
|
||||
# Public function: subscribe_events()
|
||||
# A kind the GameScript does not know -> it reports unknown_event
|
||||
with pytest.raises(ValueError, match="unknown_event"):
|
||||
await connected_admin.subscribe_events(events=["definitely_not_an_event"], timeout=10.0)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_wait_for_event_vehicle_reaches_a_stop(connected_admin):
|
||||
# Public function: wait_for_event()
|
||||
# Input 1: filtered by kind. Needs traffic on the server, so a quiet map skips.
|
||||
await connected_admin.subscribe_events(
|
||||
events=[GameEventType.VehicleArrive, GameEventType.VehicleDepart],
|
||||
interval=2, timeout=10.0)
|
||||
try:
|
||||
event = await connected_admin.wait_for_event(
|
||||
{GameEventType.VehicleArrive, GameEventType.VehicleDepart}, timeout=60.0)
|
||||
except asyncio.TimeoutError:
|
||||
pytest.skip("No vehicle reached or left a stop on the test server within the timeout.")
|
||||
finally:
|
||||
await connected_admin.unsubscribe_events()
|
||||
|
||||
assert event["event"] in ("vehicle_arrive", "vehicle_depart")
|
||||
for key in ("tick", "vehicle_id", "station_id", "owner", "vehicle_type", "order_position"):
|
||||
assert key in event
|
||||
if event["event"] == "vehicle_depart":
|
||||
assert event["dwell"] >= 0
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_wait_for_event_any_kind(connected_admin):
|
||||
# Public function: wait_for_event()
|
||||
# Input 2: no kind filter -> whatever the game produces first
|
||||
await connected_admin.subscribe_events(interval=2, timeout=10.0)
|
||||
try:
|
||||
event = await connected_admin.wait_for_event(timeout=60.0)
|
||||
except asyncio.TimeoutError:
|
||||
pytest.skip("Nothing happened on the test server within the timeout.")
|
||||
finally:
|
||||
await connected_admin.unsubscribe_events()
|
||||
|
||||
assert "event" in event and "tick" in event
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_unsubscribe_events_stops_the_stream(connected_admin):
|
||||
# Public function: unsubscribe_events()
|
||||
# After unsubscribing the bridge must go quiet, so a fresh wait times out.
|
||||
await connected_admin.subscribe_events(interval=2, timeout=10.0)
|
||||
await connected_admin.unsubscribe_events(timeout=10.0)
|
||||
|
||||
connected_admin._event_buffer.clear() # drop anything delivered before we unsubscribed
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await connected_admin.wait_for_event(timeout=5.0)
|
||||
|
||||
|
||||
# --- Protocol Public Functions ---
|
||||
|
||||
@@ -292,11 +904,11 @@ async def test_e2e_protocol_public_functions_multiple_inputs(server_config):
|
||||
|
||||
# 2. Test receive_packet() with multiple inputs
|
||||
# Input 1: Valid packet structure (length >= 3)
|
||||
res_type1, res_data1 = proto_game.receive_packet(None, memoryview(b"\x03\x00\x05")) # type 5 is ServerUnused
|
||||
res_type1, _ = proto_game.receive_packet(None, memoryview(b"\x03\x00\x05")) # type 5 is ServerUnused
|
||||
assert res_type1 == PacketGameType.ServerUnused
|
||||
|
||||
# Input 2: Invalid/short packet structure (length < 3)
|
||||
res_type2, res_data2 = proto_game.receive_packet(None, memoryview(b"\x01"))
|
||||
res_type2, _ = proto_game.receive_packet(None, memoryview(b"\x01"))
|
||||
assert res_type2 == PacketGameType.ServerUnused # Falls back to ServerUnused on error
|
||||
|
||||
# 3. Test send_packet() with multiple inputs (using mock transports)
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from openttd import OpenTTDAdminClient
|
||||
from openttd.protocol import GameEventType, PacketAdminType
|
||||
|
||||
|
||||
class MockTransport:
|
||||
def is_closing(self):
|
||||
return False
|
||||
def close(self):
|
||||
pass
|
||||
def write(self, data):
|
||||
return len(data)
|
||||
|
||||
class MockProtocol:
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
async def send_packet(self, data):
|
||||
self.sent.append(data)
|
||||
return len(data)
|
||||
|
||||
def decode_gamescript_payload(packet):
|
||||
"""Decode the JSON payload of an AdminGamescript packet (2-byte length + 1-byte type + string)."""
|
||||
return json.loads(packet[3:].split(b"\x00")[0])
|
||||
|
||||
def make_admin(subscribed=True, **kwargs):
|
||||
"""An admin client wired to mock transports, with the Gamescript auto-subscribe already done
|
||||
unless a test wants to observe it."""
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin", **kwargs)
|
||||
client._protocol = MockProtocol()
|
||||
client._transport = MockTransport()
|
||||
client._gs_subscribed = subscribed
|
||||
return client
|
||||
|
||||
def events_packet(*events):
|
||||
"""The envelope the AdminBridge GameScript pushes event batches in."""
|
||||
return {"command": "events", "events": list(events)}
|
||||
|
||||
def arrival(vehicle_id=7, station_id=3, tick=100):
|
||||
return {"event": "vehicle_arrive", "tick": tick, "vehicle_id": vehicle_id,
|
||||
"station_id": station_id, "owner": 0, "vehicle_type": 0, "order_position": 0,
|
||||
"cargo": [{"cargo_id": 0, "load": 12}]}
|
||||
|
||||
|
||||
# --- Event kinds ---
|
||||
|
||||
def test_event_type_values_are_the_wire_strings():
|
||||
assert GameEventType.VehicleArrive == "vehicle_arrive"
|
||||
assert GameEventType.CargoWaiting == "cargo_waiting"
|
||||
# Members are plain strings, so they can be used interchangeably with literals.
|
||||
assert str(GameEventType.VehicleDepart) == "vehicle_depart"
|
||||
|
||||
|
||||
# --- Subscribing ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_events_defaults_send_only_the_command():
|
||||
client = make_admin(subscribed=False)
|
||||
|
||||
task = asyncio.ensure_future(client.subscribe_events())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# First use auto-subscribes to Gamescript updates, then sends the request.
|
||||
assert len(client._protocol.sent) == 2
|
||||
assert client._protocol.sent[0][2] == PacketAdminType.AdminUpdateFrequency
|
||||
assert decode_gamescript_payload(client._protocol.sent[1]) == {
|
||||
"command": "subscribe_events", "request_id": 1,
|
||||
}
|
||||
|
||||
reply = {"command": "subscribe_events", "request_id": 1,
|
||||
"events": ["vehicle_arrive", "vehicle_depart"], "interval": 10}
|
||||
await client.receive_ServerGamescript(None, data=reply)
|
||||
assert await task == reply
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_events_encodes_every_filter():
|
||||
client = make_admin()
|
||||
|
||||
task = asyncio.ensure_future(client.subscribe_events(
|
||||
events=[GameEventType.VehicleArrive, "cargo_waiting"], interval=25, company_id=0,
|
||||
vehicles=(7, 9), stations=(3,), cargo=[0, 1], min_cargo_delta=5, include_cargo=False))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert decode_gamescript_payload(client._protocol.sent[0]) == {
|
||||
"command": "subscribe_events", "request_id": 1,
|
||||
"events": ["vehicle_arrive", "cargo_waiting"], "interval": 25, "company_id": 0,
|
||||
"vehicles": [7, 9], "stations": [3], "cargo": [0, 1],
|
||||
"min_cargo_delta": 5, "include_cargo": False,
|
||||
}
|
||||
await client.receive_ServerGamescript(
|
||||
None, data={"request_id": 1, "events": ["vehicle_arrive", "cargo_waiting"]})
|
||||
assert (await task)["events"] == ["vehicle_arrive", "cargo_waiting"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_events_rejected_request_raises():
|
||||
client = make_admin()
|
||||
|
||||
task = asyncio.ensure_future(client.subscribe_events(events=["not_an_event"]))
|
||||
await asyncio.sleep(0)
|
||||
await client.receive_ServerGamescript(
|
||||
None, data={"command": "subscribe_events", "request_id": 1,
|
||||
"error": "unknown_event", "event": "not_an_event"})
|
||||
with pytest.raises(ValueError, match="unknown_event"):
|
||||
await task
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_events_timeout():
|
||||
client = make_admin()
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await client.subscribe_events(timeout=0.05)
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_events():
|
||||
client = make_admin()
|
||||
|
||||
task = asyncio.ensure_future(client.unsubscribe_events())
|
||||
await asyncio.sleep(0)
|
||||
assert decode_gamescript_payload(client._protocol.sent[0]) == {
|
||||
"command": "unsubscribe_events", "request_id": 1,
|
||||
}
|
||||
|
||||
reply = {"command": "unsubscribe_events", "request_id": 1, "events": []}
|
||||
await client.receive_ServerGamescript(None, data=reply)
|
||||
assert await task == reply
|
||||
|
||||
|
||||
# --- Receiving events ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_batch_reaches_on_event_and_bypasses_on_gamescript():
|
||||
client = make_admin()
|
||||
seen, other = [], []
|
||||
client.on_event = seen.append
|
||||
client.on_gamescript = other.append
|
||||
|
||||
depart = {"event": "vehicle_depart", "tick": 120, "vehicle_id": 7,
|
||||
"station_id": 3, "dwell": 74}
|
||||
await client.receive_ServerGamescript(None, data=events_packet(arrival(), depart))
|
||||
|
||||
assert [event["event"] for event in seen] == ["vehicle_arrive", "vehicle_depart"]
|
||||
assert other == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_event_gamescript_payloads_still_reach_on_gamescript():
|
||||
client = make_admin()
|
||||
seen, other = [], []
|
||||
client.on_event = seen.append
|
||||
client.on_gamescript = other.append
|
||||
|
||||
# A reply that merely mentions events, and a malformed batch, are not event batches.
|
||||
await client.receive_ServerGamescript(None, data={"vehicles": []})
|
||||
await client.receive_ServerGamescript(None, data={"command": "events", "events": "nope"})
|
||||
|
||||
assert seen == []
|
||||
assert other == [{"vehicles": []}, {"command": "events", "events": "nope"}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_events_are_buffered_until_awaited():
|
||||
client = make_admin()
|
||||
await client.receive_ServerGamescript(None, data=events_packet(arrival(station_id=3),
|
||||
arrival(station_id=4)))
|
||||
|
||||
# The oldest matching event is handed out first, and only once.
|
||||
first = await client.wait_for_event("vehicle_arrive")
|
||||
second = await client.wait_for_event(GameEventType.VehicleArrive)
|
||||
assert (first["station_id"], second["station_id"]) == (3, 4)
|
||||
assert len(client._event_buffer) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_event_resolves_on_arrival():
|
||||
client = make_admin()
|
||||
task = asyncio.ensure_future(client.wait_for_event("cargo_waiting", timeout=5.0))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
cargo = {"event": "cargo_waiting", "tick": 300, "station_id": 3,
|
||||
"cargo_id": 0, "waiting": 25, "delta": 15}
|
||||
await client.receive_ServerGamescript(None, data=events_packet(arrival(), cargo))
|
||||
|
||||
assert await task == cargo
|
||||
# The event that did not match the waiter is still buffered for the next caller.
|
||||
assert await client.wait_for_event() == arrival()
|
||||
assert client._event_waiters == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_event_accepts_several_kinds():
|
||||
client = make_admin()
|
||||
task = asyncio.ensure_future(
|
||||
client.wait_for_event({GameEventType.VehicleArrive, GameEventType.VehicleDepart}))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await client.receive_ServerGamescript(
|
||||
None, data=events_packet({"event": "town_founded", "tick": 5, "town_id": 2}, arrival()))
|
||||
assert (await task)["event"] == "vehicle_arrive"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_event_ignores_non_dict_events_when_filtering():
|
||||
client = make_admin()
|
||||
task = asyncio.ensure_future(client.wait_for_event("vehicle_arrive", timeout=0.05))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# A malformed entry inside a batch must not satisfy a filtered waiter.
|
||||
await client.receive_ServerGamescript(None, data=events_packet("garbage"))
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await task
|
||||
assert list(client._event_buffer) == ["garbage"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_event_timeout_deregisters_the_waiter():
|
||||
client = make_admin()
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await client.wait_for_event("vehicle_arrive", timeout=0.05)
|
||||
assert client._event_waiters == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_already_resolved_waiters_are_skipped():
|
||||
client = make_admin()
|
||||
# A waiter whose future completed but that has not been cleaned up yet must not swallow
|
||||
# the event; it goes to the next live waiter instead.
|
||||
stale = asyncio.get_running_loop().create_future()
|
||||
stale.set_result("already done")
|
||||
live = asyncio.get_running_loop().create_future()
|
||||
client._event_waiters = [(None, stale), (None, live)]
|
||||
|
||||
await client.receive_ServerGamescript(None, data=events_packet(arrival()))
|
||||
assert live.result() == arrival()
|
||||
assert len(client._event_buffer) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_buffer_is_bounded():
|
||||
client = make_admin(event_buffer_size=2)
|
||||
await client.receive_ServerGamescript(None, data=events_packet(
|
||||
arrival(station_id=1), arrival(station_id=2), arrival(station_id=3)))
|
||||
|
||||
# The oldest event is dropped rather than growing the buffer without bound.
|
||||
assert [event["station_id"] for event in client._event_buffer] == [2, 3]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_fails_pending_event_waiters():
|
||||
client = make_admin()
|
||||
task = asyncio.ensure_future(client.wait_for_event())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
client.disconnect(None)
|
||||
with pytest.raises(ConnectionError):
|
||||
await task
|
||||
assert client._event_waiters == []
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Checks the checked-in AdminBridge GameScript against the client that talks to it.
|
||||
|
||||
The bridge is Squirrel, and CI has no Squirrel toolchain, so these are not a substitute for
|
||||
running it (see gamescript/AdminBridge/README.md). They cover the one class of breakage that
|
||||
is invisible until a server is in front of you: the two halves of the protocol drifting apart
|
||||
-- a version bumped on one side only, an event kind or a command the client uses that the
|
||||
bridge does not implement, or the docker setup no longer serving the tracked copy.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from openttd.protocol import GS_BRIDGE_VERSION, GameEventType
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
GS_DIR = ROOT / "gamescript" / "AdminBridge"
|
||||
INFO_NUT = (GS_DIR / "info.nut").read_text()
|
||||
MAIN_NUT = (GS_DIR / "main.nut").read_text()
|
||||
|
||||
|
||||
def _block(source, opening, closing):
|
||||
"""Return the text between `opening` and the next `closing`, e.g. an array or table body."""
|
||||
start = source.index(opening) + len(opening)
|
||||
return source[start:source.index(closing, start)]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_gamescript_version_matches_client():
|
||||
"""info.nut, main.nut and the client must all name the same protocol version.
|
||||
|
||||
A GameScript cannot read its own info.nut at runtime, so main.nut duplicates the version;
|
||||
this is what keeps the copy honest, and what makes a bump that misses a file fail here.
|
||||
"""
|
||||
info_version = int(re.search(r"function GetVersion\(\)\s*{\s*return (\d+);", INFO_NUT).group(1))
|
||||
main_version = int(re.search(r"BRIDGE_VERSION = (\d+);", MAIN_NUT).group(1))
|
||||
assert info_version == main_version == GS_BRIDGE_VERSION
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_gamescript_can_load_older_savegames():
|
||||
"""The bridge must stay loadable by savegames that pinned an older version of it.
|
||||
|
||||
OpenTTD defaults MinVersionToLoad() to GetVersion(), so without an explicit override every
|
||||
version bump orphans existing savegames: the engine finds no compatible script and falls
|
||||
back with a warning. The bridge keeps no savegame state, so any version can take over.
|
||||
"""
|
||||
min_version = int(re.search(r"function MinVersionToLoad\(\)\s*{\s*return (\d+);", INFO_NUT).group(1))
|
||||
assert min_version <= GS_BRIDGE_VERSION
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_gamescript_event_catalogue_matches_client_enum():
|
||||
"""Every kind the bridge can push has a GameEventType, and vice versa.
|
||||
|
||||
GameEventType is what subscribe_events() validates against, so a kind in one list and not
|
||||
the other is either an event nobody can subscribe to or a subscription the bridge rejects.
|
||||
"""
|
||||
catalogue = set(re.findall(r'"(\w+)"', _block(MAIN_NUT, "this.event_order = [", "];")))
|
||||
# EventsDropped is emitted by the bridge itself and never subscribed to, so it is
|
||||
# deliberately absent from the subscribable catalogue.
|
||||
assert catalogue == {e.value for e in GameEventType} - {GameEventType.EventsDropped}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_gamescript_implements_every_command_the_client_sends():
|
||||
"""Each command in a client payload must have a handler in the bridge's dispatch table.
|
||||
|
||||
A command the bridge does not know is silently dropped, so the caller only sees a timeout.
|
||||
Checked one way round only: the bridge is allowed to implement more than the client wraps,
|
||||
which is how list_cargo sat there answering nobody until a method was written for it.
|
||||
"""
|
||||
commands = set(re.findall(r"^\s*(\w+)\s*=\s*{ handler",
|
||||
_block(MAIN_NUT, "COMMANDS = {", "\n\t};"), re.MULTILINE))
|
||||
client_source = (ROOT / "lib" / "openttd" / "client.py").read_text()
|
||||
sent = set(re.findall(r'{"command": "(\w+)"', client_source))
|
||||
assert sent, "no GameScript commands found in the client -- has the payload spelling changed?"
|
||||
assert sent <= commands
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_docker_serves_the_tracked_gamescript():
|
||||
"""The container must run the copy in git, not an untracked one under docker/config/.
|
||||
|
||||
That mount is the whole reason the tracked copy stays honest: without it the server reads a
|
||||
file nobody reviews, which is how the bridge went unversioned in the first place.
|
||||
"""
|
||||
compose = (ROOT / "docker" / "docker-compose.yml").read_text()
|
||||
assert "../gamescript/AdminBridge:" in compose
|
||||
+8
-5
@@ -1,8 +1,13 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
import hashlib
|
||||
from openttd.protocol import PacketGameType
|
||||
|
||||
import pytest
|
||||
from openttd.client import OpenTTDClient
|
||||
from openttd.protocol import PacketGameType
|
||||
|
||||
|
||||
class FakeNetworkError(OSError):
|
||||
"""Stand-in for a socket-level failure, so it matches the client's narrowed handlers."""
|
||||
|
||||
def test_packet_game_type_values():
|
||||
assert PacketGameType.ServerFull == 0
|
||||
@@ -61,7 +66,7 @@ async def test_client_connect_success(monkeypatch):
|
||||
async def test_client_connect_failure(monkeypatch):
|
||||
client = OpenTTDClient(host="127.0.0.1")
|
||||
async def mock_fail(*args, **kwargs):
|
||||
raise Exception("Async Failure")
|
||||
raise FakeNetworkError("Async Failure")
|
||||
monkeypatch.setattr(asyncio.get_running_loop(), "create_connection", mock_fail)
|
||||
with pytest.raises(Exception, match="Async Failure"):
|
||||
await client.connect()
|
||||
@@ -118,7 +123,6 @@ async def test_fallback_handlers():
|
||||
await client.receive_ServerConfigurationUpdate(None)
|
||||
await client.receive_ServerClientInfo(None)
|
||||
await client.receive_ServerExternalChat(None)
|
||||
await client.receive_ServerCommand(None)
|
||||
await client.receive_ClientAck(None)
|
||||
await client.receive_ClientIdentify(None)
|
||||
await client.receive_ServerCompanyUpdate(None)
|
||||
@@ -224,7 +228,6 @@ async def test_unit_client_noop_callbacks(server_config):
|
||||
await client.receive_ServerMapData(None)
|
||||
await client.receive_ServerConfigurationUpdate(None)
|
||||
await client.receive_ServerExternalChat(None)
|
||||
await client.receive_ServerCommand(None)
|
||||
await client.receive_ServerFull(None)
|
||||
await client.receive_ServerBanned(None)
|
||||
await client.receive_ClientAck(None)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import pytest
|
||||
import contextlib
|
||||
import struct
|
||||
|
||||
import monocypher
|
||||
import pytest
|
||||
from openttd.protocol import OpenTTDProtocol, PacketGameType
|
||||
from openttd_protocol.wire.exceptions import SocketClosed
|
||||
|
||||
|
||||
class MockTransport:
|
||||
def __init__(self): self._closing = False
|
||||
def is_closing(self): return self._closing
|
||||
@@ -38,7 +41,6 @@ def test_protocol_static_parsers():
|
||||
assert res["token"] == 7
|
||||
|
||||
assert OpenTTDProtocol.receive_ServerExternalChat(None, b"") == {}
|
||||
assert OpenTTDProtocol.receive_ServerCommand(None, b"") == {}
|
||||
assert OpenTTDProtocol.receive_ServerFull(None, b"") == {}
|
||||
assert OpenTTDProtocol.receive_ServerBanned(None, b"") == {}
|
||||
assert OpenTTDProtocol.receive_ClientIdentify(None, b"") == {}
|
||||
@@ -60,10 +62,8 @@ def test_protocol_static_parsers():
|
||||
assert OpenTTDProtocol.receive_ServerNeedCompanyPassword(None, memoryview(struct.pack("<I", 1234) + b"sid\x00")) == {"seed": 1234, "server_id": "sid"}
|
||||
|
||||
# Coverage for receive_ServerGameInfo
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
OpenTTDProtocol.receive_ServerGameInfo(None, memoryview(b"\x00" * 200))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_protocol_exception_handling():
|
||||
@@ -72,7 +72,7 @@ async def test_protocol_exception_handling():
|
||||
proto.transport = MockTransport()
|
||||
|
||||
# Passing data that causes struct.unpack to fail (too short for uint16)
|
||||
ptype, kwargs = proto.receive_packet(None, memoryview(b"\x01"))
|
||||
ptype, _ = proto.receive_packet(None, memoryview(b"\x01"))
|
||||
assert ptype == PacketGameType.ServerUnused
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -113,7 +113,7 @@ async def test_protocol_decryption_failure():
|
||||
|
||||
# Needs to be at least 18 bytes for read_uint16 + mac
|
||||
wire_data = memoryview(b"\x14\x00" + b"X" * 16 + b"junk")
|
||||
ptype, kwargs = proto.receive_packet(None, wire_data)
|
||||
ptype, _ = proto.receive_packet(None, wire_data)
|
||||
assert ptype == PacketGameType.ServerUnused
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -128,9 +128,10 @@ async def test_protocol_is_closing_failure():
|
||||
await proto.send_packet(b"\x02\x00")
|
||||
|
||||
def test_admin_protocol_static_receives():
|
||||
from openttd.protocol import OpenTTDAdminProtocol
|
||||
import struct
|
||||
|
||||
from openttd.protocol import OpenTTDAdminProtocol
|
||||
|
||||
# 1. receive_ServerProtocol
|
||||
data = memoryview(struct.pack("<B B H H B", 3, 1, 10, 100, 0))
|
||||
res = OpenTTDAdminProtocol.receive_ServerProtocol(None, data)
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
import pytest
|
||||
from openttd import OpenTTDClient
|
||||
from openttd.protocol import (
|
||||
INVALID_VEH_ORDER_ID,
|
||||
GameCommand,
|
||||
ModifyTimetableCtrlFlag,
|
||||
ModifyTimetableFlags,
|
||||
OpenTTDProtocol,
|
||||
OrderNonStopFlags,
|
||||
OrderStopLocation,
|
||||
OrderType,
|
||||
read_varuint,
|
||||
read_varuint_signed,
|
||||
write_varuint,
|
||||
write_varuint_signed,
|
||||
)
|
||||
from openttd_protocol.wire.read import read_uint8, read_uint16
|
||||
|
||||
|
||||
class MockTransport:
|
||||
def is_closing(self):
|
||||
return False
|
||||
def write(self, data):
|
||||
return len(data)
|
||||
|
||||
class MockProtocol:
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
async def send_packet(self, data):
|
||||
self.sent.append(data)
|
||||
return len(data)
|
||||
|
||||
|
||||
def decode_sent_command(packet):
|
||||
"""Decode a ClientCommand packet by reusing the ServerCommand parser, padding on the
|
||||
frame/my_cmd trailer that only ServerCommand carries on the wire (ClientCommand doesn't)."""
|
||||
padded = bytes(packet)[3:] + b"\x00\x00\x00\x00\x00"
|
||||
return OpenTTDProtocol.receive_ServerCommand(None, memoryview(padded))
|
||||
|
||||
def new_client():
|
||||
client = OpenTTDClient("127.0.0.1")
|
||||
client._protocol = MockProtocol()
|
||||
client._transport = MockTransport()
|
||||
client._target_company = 0
|
||||
return client
|
||||
|
||||
|
||||
# --- Varuint codec ---
|
||||
|
||||
@pytest.mark.parametrize("value", [
|
||||
0, 1, 127,
|
||||
128, 16383,
|
||||
16384, 2097151,
|
||||
2097152, 268435455,
|
||||
268435456, 34359738367,
|
||||
34359738368, 4398046511103,
|
||||
4398046511104, 562949953421311,
|
||||
562949953421312, 72057594037927935,
|
||||
72057594037927936, 18446744073709551615,
|
||||
])
|
||||
def test_varuint_roundtrip_boundaries(value):
|
||||
buf = bytearray()
|
||||
write_varuint(buf, value)
|
||||
decoded, rest = read_varuint(memoryview(bytes(buf)))
|
||||
assert decoded == value
|
||||
assert bytes(rest) == b""
|
||||
|
||||
def test_varuint_rejects_negative():
|
||||
with pytest.raises(ValueError):
|
||||
write_varuint(bytearray(), -1)
|
||||
|
||||
@pytest.mark.parametrize("value", [0, 1, -1, 2, -2, 1000000, -1000000, 9223372036854775807, -9223372036854775808])
|
||||
def test_varuint_signed_roundtrip(value):
|
||||
buf = bytearray()
|
||||
write_varuint_signed(buf, value)
|
||||
decoded, rest = read_varuint_signed(memoryview(bytes(buf)))
|
||||
assert decoded == value
|
||||
assert bytes(rest) == b""
|
||||
|
||||
|
||||
# --- OpenTTDProtocol.receive_ServerCommand ---
|
||||
|
||||
def build_server_command_bytes(company, cmd, payload, callback=0, callback_param=0, frame=42, my_cmd=True):
|
||||
import struct
|
||||
body = bytearray()
|
||||
body += struct.pack("<B", company)
|
||||
body += struct.pack("<H", cmd)
|
||||
body += struct.pack("<H", 0)
|
||||
body += struct.pack("<I", 0)
|
||||
body += struct.pack("<H", len(payload))
|
||||
body += payload
|
||||
body += struct.pack("<B", callback)
|
||||
if callback != 0:
|
||||
body += struct.pack("<I", callback_param)
|
||||
body += struct.pack("<I", frame)
|
||||
body += struct.pack("<B", 1 if my_cmd else 0)
|
||||
return bytes(body)
|
||||
|
||||
def test_protocol_receive_server_command_no_callback():
|
||||
payload = bytearray()
|
||||
write_varuint(payload, 7)
|
||||
data = build_server_command_bytes(1, GameCommand.ChangeTimetable, payload, callback=0, frame=42, my_cmd=True)
|
||||
res = OpenTTDProtocol.receive_ServerCommand(None, memoryview(data))
|
||||
assert res["company"] == 1
|
||||
assert res["cmd"] == GameCommand.ChangeTimetable
|
||||
assert res["callback"] == 0
|
||||
assert res["callback_param"] == 0
|
||||
assert res["frame"] == 42
|
||||
assert res["my_cmd"] is True
|
||||
assert bytes(res["payload"]) == bytes(payload)
|
||||
|
||||
def test_protocol_receive_server_command_with_callback():
|
||||
payload = bytearray()
|
||||
write_varuint(payload, 9)
|
||||
data = build_server_command_bytes(2, GameCommand.SetVehicleOnTime, payload, callback=5, callback_param=999, frame=100, my_cmd=False)
|
||||
res = OpenTTDProtocol.receive_ServerCommand(None, memoryview(data))
|
||||
assert res["callback"] == 5
|
||||
assert res["callback_param"] == 999
|
||||
assert res["my_cmd"] is False
|
||||
|
||||
|
||||
# --- OpenTTDClient outgoing command methods ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_send_command_with_callback_includes_callback_param():
|
||||
client = new_client()
|
||||
await client._send_command(GameCommand.ChangeTimetable, bytearray(), callback=5)
|
||||
parsed = decode_sent_command(client._protocol.sent[0])
|
||||
assert parsed["callback"] == 5
|
||||
assert parsed["callback_param"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_change_timetable_sends_expected_payload():
|
||||
client = new_client()
|
||||
await client.change_timetable(7, 3, ModifyTimetableFlags.WaitTime, 120)
|
||||
assert len(client._protocol.sent) == 1
|
||||
parsed = decode_sent_command(client._protocol.sent[0])
|
||||
assert parsed["cmd"] == GameCommand.ChangeTimetable
|
||||
assert parsed["company"] == 0
|
||||
vehicle_id, rest = read_varuint(parsed["payload"])
|
||||
order_position, rest = read_uint16(rest)
|
||||
flag, rest = read_uint8(rest)
|
||||
value, rest = read_varuint(rest)
|
||||
ctrl_flags, _ = read_uint8(rest)
|
||||
assert (vehicle_id, order_position, flag, value, ctrl_flags) == (7, 3, ModifyTimetableFlags.WaitTime, 120, 0)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_change_timetable_clear_field_sets_ctrl_flag():
|
||||
client = new_client()
|
||||
await client.change_timetable(7, 3, ModifyTimetableFlags.TravelTime, 0, clear_field=True)
|
||||
parsed = decode_sent_command(client._protocol.sent[0])
|
||||
_, rest = read_varuint(parsed["payload"])
|
||||
_, rest = read_uint16(rest)
|
||||
flag, rest = read_uint8(rest)
|
||||
_, rest = read_varuint(rest)
|
||||
ctrl_flags, _ = read_uint8(rest)
|
||||
assert flag == ModifyTimetableFlags.TravelTime
|
||||
assert ctrl_flags == ModifyTimetableCtrlFlag.ClearField
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_autofill_timetable_sends_expected_payload():
|
||||
client = new_client()
|
||||
await client.autofill_timetable(7, autofill=True, preserve_wait_time=False)
|
||||
parsed = decode_sent_command(client._protocol.sent[0])
|
||||
assert parsed["cmd"] == GameCommand.AutofillTimetable
|
||||
vehicle_id, rest = read_varuint(parsed["payload"])
|
||||
autofill, rest = read_uint8(rest)
|
||||
preserve_wait_time, _ = read_uint8(rest)
|
||||
assert (vehicle_id, autofill, preserve_wait_time) == (7, 1, 0)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_set_timetable_start_sends_expected_payload():
|
||||
client = new_client()
|
||||
await client.set_timetable_start(7, True, -12345)
|
||||
parsed = decode_sent_command(client._protocol.sent[0])
|
||||
assert parsed["cmd"] == GameCommand.SetTimetableStart
|
||||
vehicle_id, rest = read_varuint(parsed["payload"])
|
||||
timetable_all, rest = read_uint8(rest)
|
||||
start_date, _ = read_varuint_signed(rest)
|
||||
assert (vehicle_id, timetable_all, start_date) == (7, 1, -12345)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_set_vehicle_on_time_sends_expected_payload():
|
||||
client = new_client()
|
||||
await client.set_vehicle_on_time(7, apply_to_group=True)
|
||||
parsed = decode_sent_command(client._protocol.sent[0])
|
||||
assert parsed["cmd"] == GameCommand.SetVehicleOnTime
|
||||
vehicle_id, rest = read_varuint(parsed["payload"])
|
||||
apply_to_group, _ = read_uint8(rest)
|
||||
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])
|
||||
_, sel_ord, order_type, _, _ = _decode_insert_order_payload(parsed["payload"])
|
||||
assert sel_ord == 1 # insert before order position 1
|
||||
assert order_type == (OrderType.GotoStation
|
||||
| (OrderStopLocation.PlatformFarEnd << 4)
|
||||
| (OrderNonStopFlags.NoStopAtIntermediate << 6))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_remove_order_sends_expected_payload():
|
||||
client = new_client()
|
||||
await client.remove_order(7, 2)
|
||||
parsed = decode_sent_command(client._protocol.sent[0])
|
||||
assert parsed["cmd"] == GameCommand.DeleteOrder
|
||||
vehicle_id, rest = read_varuint(parsed["payload"])
|
||||
order_position, _ = read_uint16(rest)
|
||||
assert (vehicle_id, order_position) == (7, 2)
|
||||
|
||||
|
||||
# --- OpenTTDClient scheduled dispatch edit commands ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_set_scheduled_dispatch_payload():
|
||||
client = new_client()
|
||||
await client.set_scheduled_dispatch(7, True)
|
||||
parsed = decode_sent_command(client._protocol.sent[0])
|
||||
assert parsed["cmd"] == GameCommand.SchDispatch
|
||||
vehicle_id, rest = read_varuint(parsed["payload"])
|
||||
enabled, _ = read_uint8(rest)
|
||||
assert (vehicle_id, enabled) == (7, 1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_add_dispatch_schedule_payload():
|
||||
client = new_client()
|
||||
await client.add_dispatch_schedule(7, -1234, 3000)
|
||||
parsed = decode_sent_command(client._protocol.sent[0])
|
||||
assert parsed["cmd"] == GameCommand.SchDispatchAddNewSchedule
|
||||
vehicle_id, rest = read_varuint(parsed["payload"])
|
||||
start_tick, rest = read_varuint_signed(rest)
|
||||
duration, _ = read_varuint(rest)
|
||||
assert (vehicle_id, start_tick, duration) == (7, -1234, 3000)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_remove_dispatch_schedule_payload():
|
||||
client = new_client()
|
||||
await client.remove_dispatch_schedule(7, 2)
|
||||
parsed = decode_sent_command(client._protocol.sent[0])
|
||||
assert parsed["cmd"] == GameCommand.SchDispatchRemoveSchedule
|
||||
vehicle_id, rest = read_varuint(parsed["payload"])
|
||||
schedule_index, _ = read_varuint(rest)
|
||||
assert (vehicle_id, schedule_index) == (7, 2)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_add_dispatch_slot_payload_defaults_and_extras():
|
||||
client = new_client()
|
||||
# Defaults: single slot, no interval/extra/flags/route.
|
||||
await client.add_dispatch_slot(7, 1, 500)
|
||||
# Bulk: three extra slots spaced 250 ticks apart, with flags and route id.
|
||||
await client.add_dispatch_slot(7, 1, 500, interval=250, extra_slots=3, slot_flags=5, route_id=2)
|
||||
|
||||
def decode(payload):
|
||||
vehicle_id, rest = read_varuint(payload)
|
||||
schedule_index, rest = read_varuint(rest)
|
||||
offset, rest = read_varuint(rest)
|
||||
interval, rest = read_varuint(rest)
|
||||
extra_slots, rest = read_varuint(rest)
|
||||
slot_flags, rest = read_uint16(rest)
|
||||
route_id, _ = read_uint8(rest)
|
||||
return (vehicle_id, schedule_index, offset, interval, extra_slots, slot_flags, route_id)
|
||||
|
||||
p0 = decode_sent_command(client._protocol.sent[0])
|
||||
p1 = decode_sent_command(client._protocol.sent[1])
|
||||
assert p0["cmd"] == GameCommand.SchDispatchAdd
|
||||
assert decode(p0["payload"]) == (7, 1, 500, 0, 0, 0, 0)
|
||||
assert decode(p1["payload"]) == (7, 1, 500, 250, 3, 5, 2)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_remove_dispatch_slot_payload():
|
||||
client = new_client()
|
||||
await client.remove_dispatch_slot(7, 1, 500)
|
||||
parsed = decode_sent_command(client._protocol.sent[0])
|
||||
assert parsed["cmd"] == GameCommand.SchDispatchRemove
|
||||
vehicle_id, rest = read_varuint(parsed["payload"])
|
||||
schedule_index, rest = read_varuint(rest)
|
||||
offset, _ = read_varuint(rest)
|
||||
assert (vehicle_id, schedule_index, offset) == (7, 1, 500)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_clear_dispatch_schedule_payload():
|
||||
client = new_client()
|
||||
await client.clear_dispatch_schedule(7, 1)
|
||||
parsed = decode_sent_command(client._protocol.sent[0])
|
||||
assert parsed["cmd"] == GameCommand.SchDispatchClear
|
||||
vehicle_id, rest = read_varuint(parsed["payload"])
|
||||
schedule_index, _ = read_varuint(rest)
|
||||
assert (vehicle_id, schedule_index) == (7, 1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_set_dispatch_duration_payload():
|
||||
client = new_client()
|
||||
await client.set_dispatch_duration(7, 1, 4000)
|
||||
parsed = decode_sent_command(client._protocol.sent[0])
|
||||
assert parsed["cmd"] == GameCommand.SchDispatchSetDuration
|
||||
vehicle_id, rest = read_varuint(parsed["payload"])
|
||||
schedule_index, rest = read_varuint(rest)
|
||||
duration, _ = read_varuint(rest)
|
||||
assert (vehicle_id, schedule_index, duration) == (7, 1, 4000)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_set_dispatch_start_date_payload():
|
||||
client = new_client()
|
||||
await client.set_dispatch_start_date(7, 1, 1_000_000)
|
||||
parsed = decode_sent_command(client._protocol.sent[0])
|
||||
assert parsed["cmd"] == GameCommand.SchDispatchSetStartDate
|
||||
vehicle_id, rest = read_varuint(parsed["payload"])
|
||||
schedule_index, rest = read_varuint(rest)
|
||||
start_tick, _ = read_varuint_signed(rest)
|
||||
assert (vehicle_id, schedule_index, start_tick) == (7, 1, 1_000_000)
|
||||
|
||||
|
||||
# --- OpenTTDClient.receive_ServerCommand dispatch ---
|
||||
|
||||
async def feed_command(client, cmd, payload):
|
||||
parsed = OpenTTDProtocol.receive_ServerCommand(None, memoryview(build_server_command_bytes(0, cmd, payload)))
|
||||
await client.receive_ServerCommand(None, **parsed)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_receive_change_timetable_updates_order_state():
|
||||
client = new_client()
|
||||
payload = bytearray()
|
||||
write_varuint(payload, 7)
|
||||
payload += (3).to_bytes(2, "little")
|
||||
payload.append(ModifyTimetableFlags.WaitTime)
|
||||
write_varuint(payload, 120)
|
||||
payload.append(0)
|
||||
await feed_command(client, GameCommand.ChangeTimetable, payload)
|
||||
assert client.get_vehicle_timetable(7) == {"orders": {3: {"wait_time": 120}}}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_receive_change_timetable_clear_field_sets_none():
|
||||
client = new_client()
|
||||
payload = bytearray()
|
||||
write_varuint(payload, 7)
|
||||
payload += (3).to_bytes(2, "little")
|
||||
payload.append(ModifyTimetableFlags.TravelTime)
|
||||
write_varuint(payload, 0)
|
||||
payload.append(ModifyTimetableCtrlFlag.ClearField)
|
||||
await feed_command(client, GameCommand.ChangeTimetable, payload)
|
||||
assert client.get_vehicle_timetable(7)["orders"][3]["travel_time"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_receive_change_timetable_wait_fixed_stores_bool():
|
||||
client = new_client()
|
||||
payload = bytearray()
|
||||
write_varuint(payload, 7)
|
||||
payload += (0).to_bytes(2, "little")
|
||||
payload.append(ModifyTimetableFlags.SetWaitFixed)
|
||||
write_varuint(payload, 1)
|
||||
payload.append(0)
|
||||
await feed_command(client, GameCommand.ChangeTimetable, payload)
|
||||
assert client.get_vehicle_timetable(7)["orders"][0]["wait_time_fixed"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_receive_autofill_timetable_updates_state():
|
||||
client = new_client()
|
||||
payload = bytearray()
|
||||
write_varuint(payload, 7)
|
||||
payload.append(1)
|
||||
payload.append(0)
|
||||
await feed_command(client, GameCommand.AutofillTimetable, payload)
|
||||
entry = client.get_vehicle_timetable(7)
|
||||
assert entry["autofill"] is True
|
||||
assert entry["autofill_preserve_wait_time"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_receive_set_timetable_start_updates_state():
|
||||
client = new_client()
|
||||
payload = bytearray()
|
||||
write_varuint(payload, 7)
|
||||
payload.append(1)
|
||||
write_varuint_signed(payload, 555)
|
||||
await feed_command(client, GameCommand.SetTimetableStart, payload)
|
||||
entry = client.get_vehicle_timetable(7)
|
||||
assert entry["timetable_all"] is True
|
||||
assert entry["timetable_start"] == 555
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_receive_set_vehicle_on_time_updates_state():
|
||||
client = new_client()
|
||||
payload = bytearray()
|
||||
write_varuint(payload, 7)
|
||||
payload.append(1)
|
||||
await feed_command(client, GameCommand.SetVehicleOnTime, payload)
|
||||
assert client.get_vehicle_timetable(7)["on_time_apply_to_group"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_receive_unknown_command_is_ignored():
|
||||
client = new_client()
|
||||
await feed_command(client, 999, bytearray())
|
||||
assert client.vehicle_timetables == {}
|
||||
|
||||
|
||||
# --- get_vehicle_timetable ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_vehicle_timetable_known_and_unknown():
|
||||
client = new_client()
|
||||
assert client.get_vehicle_timetable(7) is None
|
||||
payload = bytearray()
|
||||
write_varuint(payload, 7)
|
||||
payload.append(1)
|
||||
await feed_command(client, GameCommand.SetVehicleOnTime, payload)
|
||||
assert client.get_vehicle_timetable(7) is not None
|
||||
assert client.get_vehicle_timetable(42) is None
|
||||
Reference in New Issue
Block a user