Compare commits
59
Commits
df629b2922
...
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 | ||
|
|
fc30def3e7 | ||
|
|
c69f146e65 | ||
|
|
94c0cf6ad7 | ||
|
|
aebd5f4ef5 |
@@ -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'
|
||||
|
||||
@@ -51,4 +51,10 @@ jobs:
|
||||
# We run tests and fail if coverage is below 100%.
|
||||
# E2E test is skipped in CI because no local OpenTTD server is available.
|
||||
run: |
|
||||
pytest --cov=openttd --cov-report=term-missing --cov-fail-under=100 -k "not test_server_connection_and_join" tests/
|
||||
pytest --cov=openttd --cov-report=term-missing --cov-fail-under=100 -m "not e2e" tests/
|
||||
|
||||
- name: Verify Public API E2E Call Constraints
|
||||
env:
|
||||
PYTHONPATH: lib
|
||||
run: |
|
||||
python check_public_calls.py
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
venv
|
||||
__pycache__
|
||||
docker/config
|
||||
.coverage
|
||||
.pytest_cache
|
||||
@@ -8,7 +8,18 @@ A high-performance, Object-Oriented Python client for OpenTTD servers, specifica
|
||||
- **Stream Encryption:** Automatic XChaCha20-Poly1305 authenticated encryption for all game traffic.
|
||||
- **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.
|
||||
- **100% Test Coverage:** Robustly tested with unit, logic, and E2E tests.
|
||||
- **Comprehensive Testing:** Robustly tested with unit, logic, and E2E tests (including 100% coverage for unit/logic tests).
|
||||
- **Vehicle Listing:** Query vehicle data via the Admin GameScript channel with `list_vehicles()`.
|
||||
- **Vehicle 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,16 +70,21 @@ 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).
|
||||
|
||||
## 🧪 Testing
|
||||
We maintain 100% test coverage. To run tests:
|
||||
We maintain high test coverage. To run normal (non-E2E) tests:
|
||||
```bash
|
||||
PYTHONPATH=lib pytest --cov=openttd tests/
|
||||
pytest -m "not e2e"
|
||||
```
|
||||
For detailed instructions on E2E testing and coverage reports, see the [Testing Guide](file:///home/kovagoadi/openttd-client/docs/TESTING.md).
|
||||
|
||||
## 📜 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)
|
||||
|
||||
Executable
+112
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
import ast
|
||||
import inspect
|
||||
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 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]
|
||||
public_funcs = {} # name -> has_params
|
||||
|
||||
# Standard asyncio/lifecycle protocol methods that are not user-facing APIs
|
||||
auto_exclude = {
|
||||
"connection_made", "connection_lost", "data_received",
|
||||
"eof_received", "pause_writing", "resume_writing", "connected"
|
||||
}
|
||||
|
||||
for cls in classes:
|
||||
# Class constructor call (e.g. OpenTTDClient(host, ...))
|
||||
sig_init = inspect.signature(cls.__init__)
|
||||
has_params_init = len([p for name, p in sig_init.parameters.items() if name != 'self']) > 0
|
||||
public_funcs[cls.__name__] = has_params_init
|
||||
|
||||
# Get all functions on the class (including inherited)
|
||||
for name, val in inspect.getmembers(cls):
|
||||
if name == '__init__':
|
||||
continue
|
||||
if not (inspect.isfunction(val) or inspect.ismethod(val) or inspect.iscoroutinefunction(val)):
|
||||
continue
|
||||
# Exclude private methods
|
||||
if name.startswith('_'):
|
||||
continue
|
||||
# Exclude protocol dispatch targets (receive_* except receive_packet)
|
||||
if name.startswith('receive_') and name != 'receive_packet':
|
||||
continue
|
||||
# Exclude lifecycle callbacks and manually decorated ones
|
||||
if name in auto_exclude or getattr(val, '__exclude_call_check__', False):
|
||||
continue
|
||||
|
||||
sig = inspect.signature(val)
|
||||
has_params = len([p for p_name, p in sig.parameters.items() if p_name != 'self']) > 0
|
||||
public_funcs[name] = has_params
|
||||
|
||||
# 2. Inspect E2E test module dynamically at runtime using reflection
|
||||
|
||||
calls = {name: [] for name in public_funcs}
|
||||
|
||||
class E2ECallVisitor(ast.NodeVisitor):
|
||||
def visit_Call(self, node):
|
||||
method_name = None
|
||||
if isinstance(node.func, ast.Attribute):
|
||||
method_name = node.func.attr
|
||||
elif isinstance(node.func, ast.Name):
|
||||
method_name = node.func.id
|
||||
|
||||
if method_name in public_funcs:
|
||||
# Serialize arguments to string to compare data
|
||||
args_str = [ast.unparse(a) for a in node.args]
|
||||
kwargs_str = [f"{kw.arg}={ast.unparse(kw.value)}" for kw in node.keywords]
|
||||
call_data = (tuple(args_str), tuple(sorted(kwargs_str)))
|
||||
calls[method_name].append(call_data)
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
visitor = E2ECallVisitor()
|
||||
|
||||
# Reflectively iterate through all test functions defined inside tests.test_e2e
|
||||
for name, val in inspect.getmembers(test_e2e, predicate=inspect.isfunction):
|
||||
# Only analyze functions defined directly in the module (skips imported ones)
|
||||
if inspect.getmodule(val) == test_e2e:
|
||||
# Retrieve function source dynamically via reflection
|
||||
source = inspect.getsource(val)
|
||||
func_tree = ast.parse(source)
|
||||
visitor.visit(func_tree)
|
||||
|
||||
# 3. Perform assertion checks
|
||||
failed = False
|
||||
print("=== Public Function E2E Calls Verification (Reflection API Inspection) ===")
|
||||
for name, has_params in sorted(public_funcs.items()):
|
||||
func_calls = calls[name]
|
||||
num_calls = len(func_calls)
|
||||
|
||||
if num_calls < 2:
|
||||
print(f"❌ {name}: Called {num_calls} time(s) (expected at least 2).")
|
||||
failed = True
|
||||
continue
|
||||
|
||||
if has_params:
|
||||
unique_calls = set(func_calls)
|
||||
num_unique = len(unique_calls)
|
||||
if num_unique < 2:
|
||||
print(f"❌ {name}: Called {num_calls} times but with identical data: {unique_calls}")
|
||||
failed = True
|
||||
else:
|
||||
print(f"✅ {name}: Called {num_calls} times with {num_unique} different inputs.")
|
||||
else:
|
||||
print(f"✅ {name}: Called {num_calls} times (no parameters).")
|
||||
|
||||
if failed:
|
||||
print("❌ Verification FAILED: Some public functions do not meet E2E call requirement.")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("✅ Verification PASSED: All public functions called at least 2 times with different data.")
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,5 @@
|
||||
OpenTTD-patches/.git
|
||||
OpenTTD-patches/build
|
||||
config
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
@@ -0,0 +1,63 @@
|
||||
# Build stage
|
||||
FROM debian:13@sha256:9cc080028c43b27d2074d63a5f9caf7166d731494965616c1a6d2827a004585c AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
cmake \
|
||||
git \
|
||||
libcurl4-gnutls-dev \
|
||||
liblzma-dev \
|
||||
liblzo2-dev \
|
||||
libpng-dev \
|
||||
libzstd-dev \
|
||||
zlib1g-dev \
|
||||
pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /src
|
||||
COPY OpenTTD-patches /src/OpenTTD-patches
|
||||
|
||||
WORKDIR /src/OpenTTD-patches/build
|
||||
RUN cmake .. \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DOPTION_DEDICATED=ON \
|
||||
-DOPTION_INSTALL_HTML_DOCS=OFF \
|
||||
-DOPTION_USE_ASSERTS=OFF \
|
||||
&& make -j$(nproc) install
|
||||
|
||||
# Runtime stage
|
||||
# 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 \
|
||||
libcurl3t64-gnutls \
|
||||
liblzma5 \
|
||||
liblzo2-2 \
|
||||
libpng16-16t64 \
|
||||
libzstd1 \
|
||||
zlib1g \
|
||||
ca-certificates \
|
||||
openttd-opengfx \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy binaries from builder
|
||||
COPY --from=builder /usr/local/games/openttd /usr/local/bin/openttd
|
||||
COPY --from=builder /usr/local/share/games/openttd /usr/local/share/games/openttd
|
||||
|
||||
# Ensure base graphics are in a path openttd searches
|
||||
RUN mkdir -p /usr/local/share/games/openttd/baseset && \
|
||||
cp -r /usr/share/games/openttd/baseset/* /usr/local/share/games/openttd/baseset/
|
||||
|
||||
# Create openttd user
|
||||
RUN useradd -m -u 1000 openttd
|
||||
USER openttd
|
||||
WORKDIR /home/openttd
|
||||
|
||||
# Create config directory
|
||||
RUN mkdir -p /home/openttd/.local/share/openttd/save
|
||||
|
||||
EXPOSE 3979/tcp 3979/udp 3977/tcp
|
||||
|
||||
CMD ["openttd", "-D"]
|
||||
@@ -0,0 +1,55 @@
|
||||
# OpenTTD JGRPP Docker Server
|
||||
|
||||
This setup builds OpenTTD with the JGR Patch Pack (JGRPP) from source and runs it in a Docker container.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Build and start the server:**
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
2. **Accessing the console:**
|
||||
```bash
|
||||
docker exec -it openttd-jgrpp openttd -D
|
||||
```
|
||||
(Actually, the server runs in dedicated mode. You can view logs with:)
|
||||
```bash
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
3. **Configuration:**
|
||||
The configuration is stored in `config/openttd.cfg`. The default password is set to `asd`.
|
||||
|
||||
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`
|
||||
(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. Reapply (rebase if needed) the local patches from `patches/`.
|
||||
3. Rebuild the image:
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
@@ -0,0 +1,29 @@
|
||||
services:
|
||||
openttd:
|
||||
build: .
|
||||
container_name: openttd-jgrpp
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3979:3979/tcp"
|
||||
- "3979:3979/udp"
|
||||
- "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
|
||||
# Use exec to ensure openttd receives SIGTERM directly for autosave_on_exit
|
||||
command: >
|
||||
bash -c "
|
||||
if [ -f /home/openttd/.local/share/openttd/save/autosave/exit.sav ]; then
|
||||
echo 'Found autosave/exit.sav, loading...'
|
||||
exec openttd -D -g save/autosave/exit.sav
|
||||
else
|
||||
exec openttd -D
|
||||
fi"
|
||||
stop_signal: SIGTERM
|
||||
stop_grace_period: 30s
|
||||
@@ -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.
|
||||
|
||||
@@ -8,18 +8,20 @@ We welcome contributions from the community! To maintain the high quality of thi
|
||||
- **Minimal Dependencies:** Only add new dependencies if absolutely necessary.
|
||||
|
||||
## Testing Mandate
|
||||
We enforce **100% test coverage**. Any new feature or bug fix must include corresponding tests.
|
||||
We enforce **100% test coverage for normal (non-E2E) tests**. Any new feature or bug fix must include corresponding tests and maintain this coverage standard. E2E tests are only required to cover every public function with multiple inputs, and do not require 100% code coverage.
|
||||
|
||||
### Running Tests
|
||||
Use `pytest` within the virtual environment:
|
||||
For detailed testing instructions, please refer to the [Testing Guide](file:///home/kovagoadi/openttd-client/docs/TESTING.md).
|
||||
|
||||
Quick command to run normal (non-E2E) tests:
|
||||
```bash
|
||||
PYTHONPATH=lib ./venv/bin/pytest --cov=openttd --cov-report=term-missing tests/
|
||||
./venv/bin/pytest -m "not e2e"
|
||||
```
|
||||
|
||||
### Types of Tests
|
||||
- **Logic Tests (`tests/test_logic.py`):** High-level client state and API behavior.
|
||||
- **Protocol Tests (`tests/test_protocol.py`):** Low-level binary parsing and encryption.
|
||||
- **E2E Tests (`tests/test_e2e.py`):** Integration tests against a live server.
|
||||
Quick command to run all tests (including E2E):
|
||||
```bash
|
||||
./venv/bin/pytest
|
||||
```
|
||||
|
||||
## Submitting Changes
|
||||
1. **Fork the repo** and create your branch from `main`.
|
||||
|
||||
+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.
|
||||
@@ -15,6 +15,148 @@ We use **Blake2b** (64-byte digest) to derive two 32-byte session keys.
|
||||
### Handshake Nonces
|
||||
The server provides a 24-byte nonce in the `ServerAuthenticationRequest`. This nonce is used for the AEAD challenge during the auth response and for the initial stream encryption setup.
|
||||
|
||||
## Admin Network (TCP 3977)
|
||||
The Admin Network allows external applications to monitor and control the server. It supports both unsecured and secure (X25519 PAKE) authentication.
|
||||
|
||||
### Secure Authentication
|
||||
Similar to the Game Port, the Admin Network uses X25519 PAKE for secure authentication.
|
||||
- **Packet:** `AdminJoinSecure` starts the handshake.
|
||||
- **Encryption:** Once enabled via `ServerEnableEncryption`, all subsequent traffic is encrypted using XChaCha20-Poly1305.
|
||||
|
||||
### 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).
|
||||
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
# Testing Guide for OpenTTD Python Client
|
||||
|
||||
This guide explains how the test suite is structured, the types of tests available, and how to execute them.
|
||||
|
||||
---
|
||||
|
||||
## 📂 Test Suite Structure
|
||||
|
||||
The tests are located in the `tests/` directory:
|
||||
|
||||
| Test File | Target | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| [`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. |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 How to Run Tests
|
||||
|
||||
Pytest automatically uses [`pytest.ini`](file:///home/kovagoadi/openttd-client/pytest.ini) to configure the Python import path (`pythonpath = lib`). You do not need to manually configure `PYTHONPATH`.
|
||||
|
||||
Make sure your virtual environment is active before running commands:
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
### 1. Run Normal (Non-E2E) Tests
|
||||
These are unit and logic tests that run instantly in memory without external dependencies:
|
||||
```bash
|
||||
pytest -m "not e2e"
|
||||
```
|
||||
|
||||
### 2. Run E2E Tests Only
|
||||
Requires a running local OpenTTD server configured with password `"asd"`. If the server is offline, this test will fail:
|
||||
```bash
|
||||
pytest -m "e2e"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Starting the OpenTTD Server (Docker)
|
||||
|
||||
To run E2E tests locally, you can start the dedicated JGRPP OpenTTD server using the provided Docker Compose configuration in the `docker/` directory.
|
||||
|
||||
### 1. Build and Start the Server
|
||||
Run this command from the project root directory:
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml up -d --build
|
||||
```
|
||||
*Note: The first build will clone and compile the JGRPP source code, which may take a few minutes.*
|
||||
|
||||
### 2. Monitor Server Logs
|
||||
To watch the server logs (e.g., to see client connections and events):
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml logs -f
|
||||
```
|
||||
|
||||
### 3. Stop the Server
|
||||
To stop the server container:
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml down
|
||||
```
|
||||
|
||||
### 3. Run All Tests
|
||||
Runs both unit/logic tests and E2E tests:
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
|
||||
### 4. Run with Coverage Report
|
||||
To view statement coverage statistics:
|
||||
```bash
|
||||
pytest --cov=openttd --cov-report=term-missing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Testing Mandate
|
||||
|
||||
We enforce the following testing mandates:
|
||||
|
||||
* **Normal/Unit tests (`pytest -m "not e2e"`)** must achieve 100% code coverage on the codebase independently.
|
||||
* **E2E tests (`pytest -m "e2e"`)** do NOT require 100% code coverage. Instead, they are only required to cover every public function of the client classes (`OpenTTDClient` and `OpenTTDAdminClient`) and protocol classes with multiple (varied) inputs/scenarios.
|
||||
|
||||
To run/verify tests:
|
||||
* **Normal Tests (Must achieve 100% coverage):**
|
||||
```bash
|
||||
pytest -m "not e2e" --cov=openttd --cov-report=term-missing --cov-fail-under=100
|
||||
```
|
||||
* **E2E Tests (No coverage mandate, must cover all public functions with multiple inputs):**
|
||||
```bash
|
||||
pytest -m "e2e"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Pytest Configuration (`pytest.ini`)
|
||||
|
||||
The project uses a [`pytest.ini`](file:///home/kovagoadi/openttd-client/pytest.ini) file at the root:
|
||||
- **`pythonpath = lib`**: Simplifies invocation by resolving imports from the local `lib/` directory.
|
||||
- **`markers`**: Registers the custom `e2e` marker for pytest classification.
|
||||
@@ -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,3 +1,4 @@
|
||||
from .client import OpenTTDClient
|
||||
from .client import OpenTTDAdminClient, OpenTTDClient
|
||||
from .decorators import exclude_call_check
|
||||
|
||||
__all__ = ['OpenTTDClient']
|
||||
__all__ = ['OpenTTDAdminClient', 'OpenTTDClient', 'exclude_call_check']
|
||||
|
||||
+899
-12
@@ -1,11 +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_uint32, write_presend, SEND_TCP_MTU
|
||||
from .protocol import PacketGameType, OpenTTDProtocol
|
||||
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."""
|
||||
@@ -21,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 = ""
|
||||
@@ -61,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.")
|
||||
@@ -73,13 +286,15 @@ 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()
|
||||
|
||||
# --- Internal Protocol Callbacks ---
|
||||
|
||||
@exclude_call_check
|
||||
def connected(self, source): pass
|
||||
|
||||
async def receive_ServerGameInfo(self, source, **kwargs):
|
||||
@@ -90,7 +305,31 @@ class OpenTTDClient:
|
||||
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
|
||||
|
||||
async def receive_ServerError(self, source, error_code):
|
||||
error_names = {8: "WrongRevision", 10: "WrongPassword", 11: "NameInUse", 17: "TimeoutComputer"}
|
||||
error_names = {
|
||||
0: "General",
|
||||
1: "Desync",
|
||||
2: "SavegameFailed",
|
||||
3: "ConnectionLost",
|
||||
4: "IllegalPacket",
|
||||
5: "NewGRFMismatch",
|
||||
6: "NotAuthorized",
|
||||
7: "NotExpected",
|
||||
8: "WrongRevision",
|
||||
9: "NameInUse",
|
||||
10: "WrongPassword",
|
||||
11: "CompanyMismatch",
|
||||
12: "Kicked",
|
||||
13: "Cheater",
|
||||
14: "ServerFull",
|
||||
15: "TooManyCommands",
|
||||
16: "TimeoutPassword",
|
||||
17: "TimeoutComputer",
|
||||
18: "TimeoutMap",
|
||||
19: "TimeoutJoin",
|
||||
20: "InvalidClientName",
|
||||
21: "NotOnAllowList",
|
||||
22: "NoAuthenticationMethodAvailable"
|
||||
}
|
||||
self.log.error(f"Server Error: {error_names.get(error_code, f'Code {error_code}')}")
|
||||
await self.quit()
|
||||
|
||||
@@ -98,8 +337,9 @@ class OpenTTDClient:
|
||||
if auth_type == 1:
|
||||
server_pub = bytes(data[:32])
|
||||
nonce = bytes(data[32:56])
|
||||
our_priv, our_pub = monocypher.generate_key_exchange_key_pair()
|
||||
shared_secret = monocypher.key_exchange(our_priv, server_pub)
|
||||
our_priv = monocypher.generate_key()
|
||||
our_pub = monocypher.x25519_public_key(our_priv)
|
||||
shared_secret = monocypher.x25519(our_priv, server_pub)
|
||||
derived = monocypher.blake2b(shared_secret + server_pub + our_pub + self._server_password.encode())
|
||||
self._session_key_send, self._session_key_recv = derived[:32], derived[32:64]
|
||||
challenge = os.urandom(8)
|
||||
@@ -172,8 +412,655 @@ 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
|
||||
async def receive_ClientIdentify(self, source, **kwargs): pass
|
||||
|
||||
class OpenTTDAdminClient:
|
||||
"""High-level OpenTTD Admin client."""
|
||||
def __init__(self, host, port=3977, admin_name="GeminiAdmin", event_buffer_size=256):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.admin_name = admin_name
|
||||
self.log = logging.getLogger(f"OTTDA-{admin_name}")
|
||||
|
||||
# State
|
||||
self.encryption_enabled = False
|
||||
self.joined = asyncio.Event()
|
||||
self.shutdown_event = asyncio.Event()
|
||||
|
||||
# Internal crypto
|
||||
self._admin_password = ""
|
||||
self._session_key_send = None
|
||||
self._session_key_recv = None
|
||||
self._encryption_nonce = None
|
||||
self._send_aead = None
|
||||
self._recv_aead = None
|
||||
|
||||
# Callbacks
|
||||
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."""
|
||||
self._admin_password = admin_password
|
||||
self.log.info(f"Connecting to admin {self.host}:{self.port}...")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
self._transport, self._protocol = await loop.create_connection(
|
||||
lambda: OpenTTDAdminProtocol(self), self.host, self.port
|
||||
)
|
||||
if secure:
|
||||
d = write_init(PacketAdminType.AdminJoinSecure)
|
||||
write_string(d, self.admin_name)
|
||||
write_string(d, "1.0")
|
||||
# Bitmask: 1 << NetworkAuthenticationMethod.X25519_PAKE
|
||||
write_uint16(d, 1 << NetworkAuthenticationMethod.X25519_PAKE)
|
||||
else:
|
||||
d = write_init(PacketAdminType.AdminJoin)
|
||||
write_string(d, self._admin_password)
|
||||
write_string(d, self.admin_name)
|
||||
write_string(d, "1.0")
|
||||
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
|
||||
except Exception as e:
|
||||
self.log.error(f"Admin connection failed: {e}")
|
||||
raise
|
||||
|
||||
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):
|
||||
"""Gracefully disconnect from the server."""
|
||||
if hasattr(self, '_protocol') and not self._transport.is_closing():
|
||||
try:
|
||||
d = write_init(PacketAdminType.AdminQuit)
|
||||
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
|
||||
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()
|
||||
|
||||
async def send_rcon(self, command):
|
||||
"""Send an RCON command."""
|
||||
d = write_init(PacketAdminType.AdminRcon)
|
||||
write_string(d, command)
|
||||
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
|
||||
|
||||
async def send_chat(self, message, action=1, dest_type=0, dest_id=0):
|
||||
"""Send a chat message as admin. action=1 (CHAT), dest_type=0 (BROADCAST)."""
|
||||
d = write_init(PacketAdminType.AdminChat)
|
||||
write_uint8(d, action)
|
||||
write_uint8(d, dest_type)
|
||||
write_uint32(d, dest_id)
|
||||
write_string(d, message)
|
||||
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
|
||||
|
||||
async def update_frequency(self, update_type, frequency):
|
||||
"""Update the frequency of a certain piece of information."""
|
||||
d = write_init(PacketAdminType.AdminUpdateFrequency)
|
||||
write_uint16(d, update_type)
|
||||
write_uint16(d, frequency)
|
||||
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
|
||||
|
||||
async def poll(self, update_type, data=0xFFFFFFFF):
|
||||
"""Poll the server for certain updates."""
|
||||
d = write_init(PacketAdminType.AdminPoll)
|
||||
write_uint8(d, update_type)
|
||||
write_uint32(d, data)
|
||||
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
|
||||
|
||||
async def poll_clients(self, client_id=0xFFFFFFFF):
|
||||
"""Poll for client information."""
|
||||
from .protocol import AdminUpdateType
|
||||
await self.poll(AdminUpdateType.ClientInfo, client_id)
|
||||
|
||||
async def poll_companies(self, company_id=0xFFFFFFFF):
|
||||
"""Poll for company information."""
|
||||
from .protocol import AdminUpdateType
|
||||
await self.poll(AdminUpdateType.CompanyInfo, company_id)
|
||||
|
||||
async def poll_economy(self, company_id=0xFFFFFFFF):
|
||||
"""Poll for company economy information."""
|
||||
from .protocol import AdminUpdateType
|
||||
await self.poll(AdminUpdateType.CompanyEconomy, company_id)
|
||||
|
||||
async def poll_stats(self, company_id=0xFFFFFFFF):
|
||||
"""Poll for company statistics."""
|
||||
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
|
||||
d = write_init(PacketAdminType.AdminGamescript)
|
||||
write_string(d, json.dumps(json_data))
|
||||
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
|
||||
|
||||
# --- Internal Protocol Callbacks ---
|
||||
|
||||
@exclude_call_check
|
||||
def connected(self, source): pass
|
||||
|
||||
async def receive_ServerAuthRequest(self, source, auth_type, data):
|
||||
if auth_type == 1: # X25519_PAKE
|
||||
server_pub = bytes(data[:32])
|
||||
nonce = bytes(data[32:56])
|
||||
our_priv = monocypher.generate_key()
|
||||
our_pub = monocypher.x25519_public_key(our_priv)
|
||||
shared_secret = monocypher.x25519(our_priv, server_pub)
|
||||
derived = monocypher.blake2b(shared_secret + server_pub + our_pub + self._admin_password.encode())
|
||||
self._session_key_send, self._session_key_recv = derived[:32], derived[32:64]
|
||||
challenge = os.urandom(8)
|
||||
mac, ciphertext = monocypher.lock(self._session_key_send, nonce, challenge, associated_data=our_pub)
|
||||
d = write_init(PacketAdminType.AdminAuthResponse)
|
||||
d.extend(our_pub + mac + ciphertext)
|
||||
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
|
||||
|
||||
async def receive_ServerEnableEncryption(self, source, data):
|
||||
self._encryption_nonce = bytes(data)
|
||||
self.encryption_enabled = True
|
||||
self.log.info("Admin encryption enabled.")
|
||||
|
||||
async def receive_ServerProtocol(self, source, version, updates):
|
||||
self.log.info(f"Admin Protocol version {version}")
|
||||
|
||||
async def receive_ServerWelcome(self, source, **kwargs):
|
||||
self.log.info(f"Admin welcomed to {kwargs.get('server_name')}")
|
||||
self.joined.set()
|
||||
|
||||
async def receive_ServerError(self, source, error_code):
|
||||
self.log.error(f"Admin Server Error: {error_code}")
|
||||
await self.quit()
|
||||
|
||||
async def receive_ServerChat(self, source, **kwargs):
|
||||
if self.on_chat:
|
||||
self.on_chat(**kwargs)
|
||||
else:
|
||||
self.log.info(f"ADMIN CHAT: <{kwargs.get('client_id')}> {kwargs.get('message')}")
|
||||
|
||||
async def receive_ServerConsole(self, source, **kwargs):
|
||||
if self.on_console:
|
||||
self.on_console(**kwargs)
|
||||
else:
|
||||
self.log.info(f"CONSOLE: [{kwargs.get('origin')}] {kwargs.get('text')}")
|
||||
|
||||
async def receive_ServerRcon(self, source, **kwargs):
|
||||
self.log.info(f"RCON: {kwargs.get('text')}")
|
||||
|
||||
async def receive_ServerRconEnd(self, source, **kwargs):
|
||||
self.log.info(f"RCON End: {kwargs.get('command')}")
|
||||
|
||||
async def receive_ServerClientJoin(self, source, **kwargs):
|
||||
self.log.info(f"Admin: Client {kwargs.get('client_id')} joined.")
|
||||
|
||||
async def receive_ServerClientInfo(self, source, **kwargs):
|
||||
self.log.info(f"Admin: Client Info: {kwargs.get('name')} (ID: {kwargs.get('client_id')}, IP: {kwargs.get('network_address')})")
|
||||
|
||||
async def receive_ServerClientUpdate(self, source, **kwargs):
|
||||
self.log.info(f"Admin: Client {kwargs.get('client_id')} updated.")
|
||||
|
||||
async def receive_ServerClientQuit(self, source, **kwargs):
|
||||
self.log.info(f"Admin: Client {kwargs.get('client_id')} quit.")
|
||||
|
||||
async def receive_ServerClientError(self, source, **kwargs):
|
||||
self.log.info(f"Admin: Client {kwargs.get('client_id')} error: {kwargs.get('error_code')}")
|
||||
|
||||
async def receive_ServerCompanyNew(self, source, **kwargs):
|
||||
self.log.info(f"Admin: Company {kwargs.get('company_id')} created.")
|
||||
|
||||
async def receive_ServerCompanyInfo(self, source, **kwargs):
|
||||
self.log.info(f"Admin: Company Info: {kwargs.get('name')} (ID: {kwargs.get('company_id')})")
|
||||
|
||||
async def receive_ServerCompanyUpdate(self, source, **kwargs):
|
||||
self.log.info(f"Admin: Company {kwargs.get('company_id')} updated.")
|
||||
|
||||
async def receive_ServerCompanyRemove(self, source, **kwargs):
|
||||
self.log.info(f"Admin: Company {kwargs.get('company_id')} removed.")
|
||||
|
||||
async def receive_ServerCompanyEconomy(self, source, **kwargs):
|
||||
self.log.info(f"Admin: Company {kwargs.get('company_id')} Economy: Money={kwargs.get('money')}, Loan={kwargs.get('loan')}")
|
||||
|
||||
async def receive_ServerCompanyStats(self, source, **kwargs):
|
||||
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(data)
|
||||
else:
|
||||
self.log.info(f"GAMESCRIPT: {data}")
|
||||
|
||||
async def receive_ServerDate(self, source, **kwargs): pass
|
||||
async def receive_ServerFull(self, source, **kwargs): await self.quit()
|
||||
async def receive_ServerBanned(self, source, **kwargs): await self.quit()
|
||||
async def receive_ServerShutdown(self, source, **kwargs): await self.quit()
|
||||
async def receive_ServerNewGame(self, source, **kwargs): pass
|
||||
async def receive_ServerPong(self, source, **kwargs):
|
||||
self.log.info(f"Admin: Pong received: {kwargs.get('payload')}")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
def exclude_call_check(obj):
|
||||
"""Decorator to mark a class or method to be excluded from public calls verification checks."""
|
||||
obj.__exclude_call_check__ = True
|
||||
return obj
|
||||
+468
-12
@@ -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
|
||||
@@ -49,6 +146,121 @@ class PacketGameType(IntEnum):
|
||||
ServerCompanyUpdate = 45
|
||||
PACKET_END = 100
|
||||
|
||||
class PacketAdminType(IntEnum):
|
||||
AdminJoin = 0
|
||||
AdminQuit = 1
|
||||
AdminUpdateFrequency = 2
|
||||
AdminPoll = 3
|
||||
AdminChat = 4
|
||||
AdminRcon = 5
|
||||
AdminGamescript = 6
|
||||
AdminPing = 7
|
||||
AdminExternalChat = 8
|
||||
AdminJoinSecure = 9
|
||||
AdminAuthResponse = 10
|
||||
|
||||
ServerFull = 100
|
||||
ServerBanned = 101
|
||||
ServerError = 102
|
||||
ServerProtocol = 103
|
||||
ServerWelcome = 104
|
||||
ServerNewGame = 105
|
||||
ServerShutdown = 106
|
||||
|
||||
ServerDate = 107
|
||||
ServerClientJoin = 108
|
||||
ServerClientInfo = 109
|
||||
ServerClientUpdate = 110
|
||||
ServerClientQuit = 111
|
||||
ServerClientError = 112
|
||||
ServerCompanyNew = 113
|
||||
ServerCompanyInfo = 114
|
||||
ServerCompanyUpdate = 115
|
||||
ServerCompanyRemove = 116
|
||||
ServerCompanyEconomy = 117
|
||||
ServerCompanyStats = 118
|
||||
ServerChat = 119
|
||||
ServerRcon = 120
|
||||
ServerConsole = 121
|
||||
ServerCmdNames = 122
|
||||
ServerCmdLoggingOld = 123
|
||||
ServerGamescript = 124
|
||||
ServerRconEnd = 125
|
||||
ServerPong = 126
|
||||
ServerCmdLogging = 127
|
||||
ServerAuthRequest = 128
|
||||
ServerEnableEncryption = 129
|
||||
|
||||
PACKET_END = 130
|
||||
|
||||
class AdminUpdateFrequency(IntEnum):
|
||||
Poll = 1 << 0
|
||||
Daily = 1 << 1
|
||||
Weekly = 1 << 2
|
||||
Monthly = 1 << 3
|
||||
Quarterly = 1 << 4
|
||||
Annually = 1 << 5
|
||||
Automatic = 1 << 6
|
||||
|
||||
class AdminUpdateType(IntEnum):
|
||||
Date = 0
|
||||
ClientInfo = 1
|
||||
CompanyInfo = 2
|
||||
CompanyEconomy = 3
|
||||
CompanyStats = 4
|
||||
Chat = 5
|
||||
Console = 6
|
||||
CmdNames = 7
|
||||
CmdLogging = 8
|
||||
Gamescript = 9
|
||||
End = 10
|
||||
|
||||
class NetworkAuthenticationMethod(IntEnum):
|
||||
X25519_KeyExchangeOnly = 0
|
||||
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
|
||||
@@ -63,28 +275,24 @@ 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)
|
||||
|
||||
# Use library's dispatcher
|
||||
# Missing lines 92-93 in protocol.py were here in the previous version
|
||||
# Let's ensure this is called
|
||||
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
|
||||
|
||||
# Coverage for protocol.py:92-93: original send logic
|
||||
await self._can_write.wait()
|
||||
if self.transport.is_closing():
|
||||
raise SocketClosed
|
||||
@@ -122,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:
|
||||
@@ -159,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
|
||||
@@ -171,3 +397,233 @@ class OpenTTDProtocol(TCPProtocol):
|
||||
return {"frame": f, "token": t}
|
||||
@staticmethod
|
||||
def receive_ClientIdentify(source, data): return {}
|
||||
|
||||
class OpenTTDAdminProtocol(OpenTTDProtocol):
|
||||
"""Low-level OpenTTD Admin TCP protocol handler."""
|
||||
PacketType = PacketAdminType
|
||||
PACKET_END = PacketAdminType.PACKET_END
|
||||
|
||||
@staticmethod
|
||||
def receive_ServerProtocol(source, data):
|
||||
v, data = read_uint8(data)
|
||||
updates = {}
|
||||
while True:
|
||||
has_more, data = read_uint8(data)
|
||||
if not has_more:
|
||||
break
|
||||
ut, data = read_uint16(data)
|
||||
freqs, data = read_uint16(data)
|
||||
updates[ut] = freqs
|
||||
return {"version": v, "updates": updates}
|
||||
|
||||
@staticmethod
|
||||
def receive_ServerWelcome(source, data):
|
||||
name, data = read_string(data)
|
||||
ver, data = read_string(data)
|
||||
dedi, data = read_uint8(data)
|
||||
map_name, data = read_string(data)
|
||||
seed, data = read_uint32(data)
|
||||
land, data = read_uint8(data)
|
||||
date, data = read_uint32(data)
|
||||
width, data = read_uint16(data)
|
||||
height, _ = read_uint16(data)
|
||||
return {
|
||||
"server_name": name, "openttd_version": ver, "dedicated": bool(dedi),
|
||||
"map_name": map_name, "generation_seed": seed, "landscape": land,
|
||||
"start_date": date, "map_width": width, "map_height": height
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def receive_ServerDate(source, data):
|
||||
d, _ = read_uint32(data)
|
||||
return {"date": d}
|
||||
|
||||
@staticmethod
|
||||
def receive_ServerChat(source, data):
|
||||
action, data = read_uint8(data)
|
||||
dest_type, data = read_uint8(data)
|
||||
cid, data = read_uint32(data)
|
||||
msg, data = read_string(data)
|
||||
money = 0
|
||||
if len(data) >= 8:
|
||||
import struct
|
||||
money = struct.unpack("<Q", data[:8])[0]
|
||||
return {"action": action, "dest_type": dest_type, "client_id": cid, "message": msg, "money": money}
|
||||
|
||||
@staticmethod
|
||||
def receive_ServerConsole(source, data):
|
||||
origin, data = read_string(data)
|
||||
text, _ = read_string(data)
|
||||
return {"origin": origin, "text": text}
|
||||
|
||||
@staticmethod
|
||||
def receive_ServerRcon(source, data):
|
||||
color, data = read_uint16(data)
|
||||
text, _ = read_string(data)
|
||||
return {"color": color, "text": text}
|
||||
|
||||
@staticmethod
|
||||
def receive_ServerRconEnd(source, data):
|
||||
cmd, _ = read_string(data)
|
||||
return {"command": cmd}
|
||||
|
||||
@staticmethod
|
||||
def receive_ServerAuthRequest(source, data):
|
||||
at, rest = read_uint8(data)
|
||||
return {"auth_type": at, "data": rest}
|
||||
|
||||
@staticmethod
|
||||
def receive_ServerEnableEncryption(source, data): return {"data": data}
|
||||
@staticmethod
|
||||
def receive_ServerError(source, data):
|
||||
ec, _ = read_uint8(data)
|
||||
return {"error_code": ec}
|
||||
@staticmethod
|
||||
def receive_ServerFull(source, data): return {}
|
||||
@staticmethod
|
||||
def receive_ServerBanned(source, data): return {}
|
||||
@staticmethod
|
||||
def receive_ServerShutdown(source, data): return {}
|
||||
@staticmethod
|
||||
def receive_ServerNewGame(source, data): return {}
|
||||
@staticmethod
|
||||
def receive_ServerClientJoin(source, data):
|
||||
cid, _ = read_uint32(data)
|
||||
return {"client_id": cid}
|
||||
|
||||
@staticmethod
|
||||
def receive_ServerClientInfo(source, data):
|
||||
cid, data = read_uint32(data)
|
||||
addr, data = read_string(data)
|
||||
name, data = read_string(data)
|
||||
lang, data = read_uint8(data)
|
||||
date, data = read_uint32(data)
|
||||
playas, _ = read_uint8(data)
|
||||
return {
|
||||
"client_id": cid, "network_address": addr, "name": name,
|
||||
"language": lang, "join_date": date, "play_as": playas
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def receive_ServerClientUpdate(source, data):
|
||||
cid, data = read_uint32(data)
|
||||
name, data = read_string(data)
|
||||
playas, _ = read_uint8(data)
|
||||
return {"client_id": cid, "name": name, "play_as": playas}
|
||||
|
||||
@staticmethod
|
||||
def receive_ServerClientQuit(source, data):
|
||||
cid, _ = read_uint32(data)
|
||||
return {"client_id": cid}
|
||||
|
||||
@staticmethod
|
||||
def receive_ServerClientError(source, data):
|
||||
cid, data = read_uint32(data)
|
||||
error, _ = read_uint8(data)
|
||||
return {"client_id": cid, "error_code": error}
|
||||
|
||||
@staticmethod
|
||||
def receive_ServerCompanyNew(source, data):
|
||||
cid, _ = read_uint8(data)
|
||||
return {"company_id": cid}
|
||||
|
||||
@staticmethod
|
||||
def receive_ServerCompanyInfo(source, data):
|
||||
cid, data = read_uint8(data)
|
||||
name, data = read_string(data)
|
||||
manager, data = read_string(data)
|
||||
color, data = read_uint8(data)
|
||||
protected, data = read_uint8(data)
|
||||
year, data = read_uint32(data)
|
||||
is_ai, _ = read_uint8(data)
|
||||
return {
|
||||
"company_id": cid, "name": name, "manager_name": manager,
|
||||
"color": color, "password_protected": bool(protected),
|
||||
"inaugurated_year": year, "is_ai": bool(is_ai)
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def receive_ServerCompanyUpdate(source, data):
|
||||
cid, data = read_uint8(data)
|
||||
name, data = read_string(data)
|
||||
manager, data = read_string(data)
|
||||
color, data = read_uint8(data)
|
||||
protected, data = read_uint8(data)
|
||||
bankrupt, data = read_uint8(data)
|
||||
s1, data = read_uint8(data)
|
||||
s2, data = read_uint8(data)
|
||||
s3, data = read_uint8(data)
|
||||
s4, _ = read_uint8(data)
|
||||
return {
|
||||
"company_id": cid, "name": name, "manager_name": manager,
|
||||
"color": color, "password_protected": bool(protected),
|
||||
"quarters_of_bankruptcy": bankrupt, "share_owners": [s1, s2, s3, s4]
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def receive_ServerCompanyRemove(source, data):
|
||||
cid, data = read_uint8(data)
|
||||
reason, _ = read_uint8(data)
|
||||
return {"company_id": cid, "reason": reason}
|
||||
|
||||
@staticmethod
|
||||
def receive_ServerCompanyEconomy(source, data):
|
||||
import struct
|
||||
cid, data = read_uint8(data)
|
||||
money = struct.unpack("<Q", data[:8])[0]
|
||||
data = data[8:]
|
||||
loan = struct.unpack("<Q", data[:8])[0]
|
||||
data = data[8:]
|
||||
income = struct.unpack("<q", data[:8])[0]
|
||||
data = data[8:]
|
||||
delivered, data = read_uint16(data)
|
||||
val_lq = struct.unpack("<Q", data[:8])[0]
|
||||
data = data[8:]
|
||||
perf_lq, data = read_uint16(data)
|
||||
del_lq, data = read_uint16(data)
|
||||
val_pq = struct.unpack("<Q", data[:8])[0]
|
||||
data = data[8:]
|
||||
perf_pq, data = read_uint16(data)
|
||||
del_pq, _ = read_uint16(data)
|
||||
return {
|
||||
"company_id": cid, "money": money, "loan": loan, "income": income,
|
||||
"delivered_cargo": delivered, "value_last_quarter": val_lq,
|
||||
"performance_last_quarter": perf_lq, "delivered_cargo_last_quarter": del_lq,
|
||||
"value_previous_quarter": val_pq, "performance_previous_quarter": perf_pq,
|
||||
"delivered_cargo_previous_quarter": del_pq
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def receive_ServerCompanyStats(source, data):
|
||||
cid, data = read_uint8(data)
|
||||
trains, data = read_uint16(data)
|
||||
lorries, data = read_uint16(data)
|
||||
buses, data = read_uint16(data)
|
||||
planes, data = read_uint16(data)
|
||||
ships, data = read_uint16(data)
|
||||
t_stations, data = read_uint16(data)
|
||||
l_stations, data = read_uint16(data)
|
||||
b_stops, data = read_uint16(data)
|
||||
airports, data = read_uint16(data)
|
||||
harbours, _ = read_uint16(data)
|
||||
return {
|
||||
"company_id": cid,
|
||||
"vehicles": {"trains": trains, "lorries": lorries, "buses": buses, "planes": planes, "ships": ships},
|
||||
"stations": {"train": t_stations, "lorry": l_stations, "bus": b_stops, "airport": airports, "harbour": harbours}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def receive_ServerGamescript(source, data):
|
||||
json_str, _ = read_string(data)
|
||||
import json
|
||||
try:
|
||||
return {"data": json.loads(json_str)}
|
||||
except json.JSONDecodeError:
|
||||
return {"raw_data": json_str}
|
||||
|
||||
@staticmethod
|
||||
def receive_ServerPong(source, data):
|
||||
payload, _ = read_uint32(data)
|
||||
return {"payload": payload}
|
||||
|
||||
|
||||
|
||||
@@ -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__":
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import asyncio
|
||||
import logging
|
||||
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 AdminUpdateFrequency, AdminUpdateType, GameEventType
|
||||
|
||||
# Configuration
|
||||
SERVER_HOST = "127.0.0.1"
|
||||
ADMIN_PORT = 3977
|
||||
ADMIN_PASSWORD = "asd"
|
||||
|
||||
async def run_admin():
|
||||
admin = OpenTTDAdminClient(host=SERVER_HOST, port=ADMIN_PORT, admin_name="GeminiAdmin")
|
||||
|
||||
# Setup callbacks
|
||||
def chat_logger(**kwargs):
|
||||
print(f">>> [ADMIN CHAT] <{kwargs.get('client_id')}> {kwargs.get('message')}")
|
||||
|
||||
def console_logger(**kwargs):
|
||||
print(f">>> [CONSOLE] [{kwargs.get('origin')}] {kwargs.get('text')}")
|
||||
|
||||
def gamescript_logger(data):
|
||||
print(f">>> [GAMESCRIPT] {data}")
|
||||
|
||||
admin.on_chat = chat_logger
|
||||
admin.on_console = console_logger
|
||||
admin.on_gamescript = gamescript_logger
|
||||
|
||||
try:
|
||||
await admin.connect(admin_password=ADMIN_PASSWORD, secure=True)
|
||||
await admin.joined.wait()
|
||||
print("--- Admin joined ---")
|
||||
|
||||
# Initial poll
|
||||
print("--- Initial Poll ---")
|
||||
await admin.poll_companies()
|
||||
await admin.poll_clients()
|
||||
|
||||
# Subscribe to everything
|
||||
await admin.update_frequency(AdminUpdateType.ClientInfo, AdminUpdateFrequency.Automatic)
|
||||
await admin.update_frequency(AdminUpdateType.CompanyInfo, AdminUpdateFrequency.Automatic)
|
||||
await admin.update_frequency(AdminUpdateType.Chat, AdminUpdateFrequency.Automatic)
|
||||
await admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
|
||||
|
||||
print("--- Requesting vehicle info via GameScript ---")
|
||||
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}")
|
||||
|
||||
print("--- Quitting ---")
|
||||
await admin.quit()
|
||||
|
||||
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__":
|
||||
logging.basicConfig(level=logging.INFO, format='%(levelname)s:%(name)s:%(message)s')
|
||||
try:
|
||||
asyncio.run(run_admin())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
@@ -0,0 +1,5 @@
|
||||
[pytest]
|
||||
pythonpath = lib
|
||||
markers =
|
||||
e2e: End-to-end tests requiring a running OpenTTD server.
|
||||
unit: Fast in-memory unit tests.
|
||||
@@ -0,0 +1,14 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def server_config():
|
||||
"""Provides server connection parameters from environment variables with defaults."""
|
||||
return {
|
||||
"host": os.getenv("OPENTTD_HOST", "127.0.0.1"),
|
||||
"game_port": int(os.getenv("OPENTTD_GAME_PORT", "3979")),
|
||||
"admin_port": int(os.getenv("OPENTTD_ADMIN_PORT", "3977")),
|
||||
"password": os.getenv("OPENTTD_PASSWORD", "asd")
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
import monocypher
|
||||
import pytest
|
||||
from openttd import OpenTTDAdminClient
|
||||
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):
|
||||
self._closing = False
|
||||
def is_closing(self):
|
||||
return self._closing
|
||||
def close(self):
|
||||
self._closing = True
|
||||
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 test_admin_packet_types():
|
||||
assert PacketAdminType.AdminJoin == 0
|
||||
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")
|
||||
assert client.host == "127.0.0.1"
|
||||
assert client.port == 3977
|
||||
assert client.admin_name == "TestAdmin"
|
||||
|
||||
# 1. Connect (secure=False)
|
||||
proto = MockProtocol()
|
||||
transport = MockTransport()
|
||||
async def mock_connect(*args, **kwargs):
|
||||
return transport, proto
|
||||
|
||||
monkeypatch.setattr(asyncio.get_running_loop(), "create_connection", mock_connect)
|
||||
await client.connect(admin_password="asd", secure=False)
|
||||
assert len(proto.sent) == 1
|
||||
|
||||
# 2. Connect (secure=True)
|
||||
proto.sent.clear()
|
||||
await client.connect(admin_password="asd", secure=True)
|
||||
assert len(proto.sent) == 1
|
||||
|
||||
# 3. Connect Exception
|
||||
async def mock_fail(*args, **kwargs):
|
||||
raise FakeNetworkError("Connection Failed")
|
||||
monkeypatch.setattr(asyncio.get_running_loop(), "create_connection", mock_fail)
|
||||
with pytest.raises(Exception, match="Connection Failed"):
|
||||
await client.connect()
|
||||
|
||||
# Reset connection mock for further tests
|
||||
client._protocol = proto
|
||||
client._transport = transport
|
||||
|
||||
# 4. Actions
|
||||
proto.sent.clear()
|
||||
await client.send_rcon("help")
|
||||
await client.send_chat("hello")
|
||||
await client.update_frequency(AdminUpdateType.Chat, AdminUpdateFrequency.Automatic)
|
||||
await client.poll(AdminUpdateType.ClientInfo)
|
||||
await client.poll_clients(1)
|
||||
await client.poll_companies(2)
|
||||
await client.poll_economy(3)
|
||||
await client.poll_stats(4)
|
||||
await client.send_gamescript({"cmd": "test"})
|
||||
assert len(proto.sent) == 9
|
||||
|
||||
# 5. Protocol callbacks
|
||||
client.connected(None)
|
||||
|
||||
# X25519 authentication handshake callback
|
||||
server_pub = monocypher.x25519_public_key(monocypher.generate_key())
|
||||
nonce = os.urandom(24)
|
||||
await client.receive_ServerAuthRequest(None, 1, server_pub + nonce)
|
||||
assert client._session_key_send is not None
|
||||
|
||||
await client.receive_ServerEnableEncryption(None, os.urandom(24))
|
||||
assert client.encryption_enabled is True
|
||||
|
||||
await client.receive_ServerProtocol(None, 3, {})
|
||||
await client.receive_ServerWelcome(None, server_name="JGRPP")
|
||||
assert client.joined.is_set()
|
||||
|
||||
# Chat callback with and without handler
|
||||
chat_events = []
|
||||
client.on_chat = lambda **k: chat_events.append(k)
|
||||
await client.receive_ServerChat(None, client_id=1, message="hello")
|
||||
assert chat_events == [{"client_id": 1, "message": "hello"}]
|
||||
client.on_chat = None
|
||||
await client.receive_ServerChat(None, client_id=1, message="hello")
|
||||
|
||||
# Console callback with and without handler
|
||||
console_events = []
|
||||
client.on_console = lambda **k: console_events.append(k)
|
||||
await client.receive_ServerConsole(None, origin="server", text="welcome")
|
||||
assert console_events == [{"origin": "server", "text": "welcome"}]
|
||||
client.on_console = None
|
||||
await client.receive_ServerConsole(None, origin="server", text="welcome")
|
||||
|
||||
# Other callbacks
|
||||
await client.receive_ServerRcon(None, text="res")
|
||||
await client.receive_ServerRconEnd(None, command="help")
|
||||
await client.receive_ServerClientJoin(None, client_id=1)
|
||||
await client.receive_ServerClientInfo(None, client_id=1, name="user", network_address="127.0.0.1")
|
||||
await client.receive_ServerClientUpdate(None, client_id=1, name="user2")
|
||||
await client.receive_ServerClientQuit(None, client_id=1)
|
||||
await client.receive_ServerClientError(None, client_id=1, error_code=3)
|
||||
await client.receive_ServerCompanyNew(None, company_id=1)
|
||||
await client.receive_ServerCompanyInfo(None, company_id=1, name="company")
|
||||
await client.receive_ServerCompanyUpdate(None, company_id=1)
|
||||
await client.receive_ServerCompanyRemove(None, company_id=1)
|
||||
await client.receive_ServerCompanyEconomy(None, company_id=1, money=1000, loan=100)
|
||||
await client.receive_ServerCompanyStats(None, company_id=1, vehicles={}, stations={})
|
||||
|
||||
# Gamescript callback with and without handler
|
||||
gs_events = []
|
||||
client.on_gamescript = lambda data: gs_events.append(data)
|
||||
await client.receive_ServerGamescript(None, data={"a": 1})
|
||||
assert gs_events == [{"a": 1}]
|
||||
client.on_gamescript = None
|
||||
await client.receive_ServerGamescript(None, data={"a": 1})
|
||||
|
||||
await client.receive_ServerDate(None)
|
||||
|
||||
# Error callbacks that shut down the client
|
||||
client.shutdown_event.clear()
|
||||
await client.receive_ServerError(None, 5)
|
||||
assert client.shutdown_event.is_set()
|
||||
|
||||
client.shutdown_event.clear()
|
||||
await client.receive_ServerFull(None)
|
||||
assert client.shutdown_event.is_set()
|
||||
|
||||
client.shutdown_event.clear()
|
||||
await client.receive_ServerBanned(None)
|
||||
assert client.shutdown_event.is_set()
|
||||
|
||||
client.shutdown_event.clear()
|
||||
await client.receive_ServerShutdown(None)
|
||||
assert client.shutdown_event.is_set()
|
||||
|
||||
await client.receive_ServerNewGame(None)
|
||||
await client.receive_ServerPong(None, payload=123)
|
||||
|
||||
# 6. Disconnect
|
||||
client.shutdown_event.clear()
|
||||
client.disconnect(None)
|
||||
assert client.shutdown_event.is_set()
|
||||
|
||||
# 7. Quit
|
||||
client._transport = MockTransport()
|
||||
await client.quit()
|
||||
assert client.shutdown_event.is_set()
|
||||
|
||||
# 8. Quit Exception
|
||||
class BadProtocol:
|
||||
async def send_packet(self, data):
|
||||
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()
|
||||
|
||||
+921
-37
@@ -1,54 +1,938 @@
|
||||
import asyncio
|
||||
import pytest
|
||||
import sys
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
# Add lib to path
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'lib'))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'lib'))
|
||||
|
||||
from openttd import OpenTTDClient
|
||||
from openttd import OpenTTDAdminClient, OpenTTDClient
|
||||
from openttd.protocol import (
|
||||
GS_BRIDGE_VERSION,
|
||||
AdminUpdateFrequency,
|
||||
AdminUpdateType,
|
||||
GameEventType,
|
||||
ModifyTimetableFlags,
|
||||
OpenTTDAdminProtocol,
|
||||
OpenTTDProtocol,
|
||||
PacketGameType,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_connection_and_join():
|
||||
# Configuration matches your local server
|
||||
SERVER_IP = "127.0.0.1"
|
||||
SERVER_PW = "asd"
|
||||
COMPANY_ID = 0
|
||||
COMPANY_PW = "asd123"
|
||||
# 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
|
||||
|
||||
client = OpenTTDClient(host=SERVER_IP, username="TestRunner")
|
||||
|
||||
# Track chat for coverage
|
||||
chat_received = asyncio.Event()
|
||||
def chat_handler(cid, msg):
|
||||
chat_received.set()
|
||||
client.on_chat = chat_handler
|
||||
# --- Pytest Fixtures ---
|
||||
|
||||
try:
|
||||
# 1. Connect
|
||||
await client.connect(server_password=SERVER_PW)
|
||||
|
||||
# 2. Join company
|
||||
await client.join_company(company_id=COMPANY_ID, company_password=COMPANY_PW)
|
||||
|
||||
# 3. Wait for join (timeout after 15s to be safe)
|
||||
@pytest_asyncio.fixture
|
||||
async def connected_admin(server_config):
|
||||
"""Fixture to yield a connected and authenticated OpenTTDAdminClient."""
|
||||
admin_name = f"E2E_Admin_{random.randint(1000, 9999)}"
|
||||
admin = OpenTTDAdminClient(
|
||||
host=server_config["host"],
|
||||
port=server_config["admin_port"],
|
||||
admin_name=admin_name
|
||||
)
|
||||
await admin.connect(admin_password=server_config["password"], secure=True)
|
||||
await asyncio.wait_for(admin.joined.wait(), timeout=10.0)
|
||||
yield admin
|
||||
if hasattr(admin, '_transport') and not admin.shutdown_event.is_set():
|
||||
await admin.quit()
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def connected_client(server_config):
|
||||
"""Fixture to yield a connected and joined spectator OpenTTDClient."""
|
||||
client_name = f"E2E_Player_{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=255, company_password="")
|
||||
await asyncio.wait_for(client.joined.wait(), timeout=15.0)
|
||||
|
||||
assert client.joined.is_set()
|
||||
assert client.client_id is not None
|
||||
|
||||
# 4. Stay briefly to ensure keep-alive/frames work
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 5. Graceful Quit
|
||||
yield client
|
||||
if hasattr(client, '_transport') and not client.shutdown_event.is_set():
|
||||
await client.quit()
|
||||
|
||||
# 6. Wait for shutdown event
|
||||
await asyncio.wait_for(client.shutdown_event.wait(), timeout=5.0)
|
||||
@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) ---
|
||||
# ==============================================================================
|
||||
|
||||
# --- Game Client Public Functions ---
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_client_init_and_connect_multiple_inputs(server_config):
|
||||
# Public function: __init__()
|
||||
# Input 1: Custom port & default username
|
||||
client1 = OpenTTDClient(host=server_config["host"], port=server_config["game_port"])
|
||||
assert client1.host == server_config["host"]
|
||||
assert client1.port == server_config["game_port"]
|
||||
assert client1.username == "GeminiUser"
|
||||
|
||||
# Input 2: Custom port & custom username
|
||||
client2 = OpenTTDClient(host=server_config["host"], port=server_config["game_port"], username="E2E_Player_Custom")
|
||||
assert client2.username == "E2E_Player_Custom"
|
||||
|
||||
# Public function: connect()
|
||||
# Input 1: Correct server password
|
||||
await client1.connect(server_password=server_config["password"])
|
||||
assert client1._transport is not None
|
||||
await client1.quit()
|
||||
|
||||
# Input 2: Incorrect server password
|
||||
await client2.connect(server_password="wrong_password")
|
||||
await asyncio.sleep(0.5)
|
||||
assert client2.shutdown_event.is_set()
|
||||
await client2.quit()
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_client_join_company_multiple_inputs(server_config):
|
||||
# Public function: join_company()
|
||||
# Input 1: Join as spectator (company_id=255)
|
||||
client1 = OpenTTDClient(host=server_config["host"], port=server_config["game_port"], username="E2E_Spectator")
|
||||
await client1.connect(server_password=server_config["password"])
|
||||
await client1.join_company(company_id=255, company_password="")
|
||||
await asyncio.wait_for(client1.joined.wait(), timeout=10.0)
|
||||
assert client1.joined.is_set()
|
||||
await client1.quit()
|
||||
|
||||
# Input 2: Join specific company ID (company_id=1)
|
||||
client2 = OpenTTDClient(host=server_config["host"], port=server_config["game_port"], username="E2E_Player_Join_1")
|
||||
await client2.connect(server_password=server_config["password"])
|
||||
await client2.join_company(company_id=1, company_password="comp_password")
|
||||
await asyncio.sleep(0.5)
|
||||
await client2.quit()
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_client_quit_and_disconnect_multiple_inputs(server_config):
|
||||
# Public function: quit() and disconnect()
|
||||
client = OpenTTDClient(host=server_config["host"], port=server_config["game_port"])
|
||||
await client.connect(server_password=server_config["password"])
|
||||
|
||||
# Public function: disconnect()
|
||||
# Input 1: disconnect callback with None
|
||||
client.disconnect(None)
|
||||
assert client.shutdown_event.is_set()
|
||||
|
||||
except Exception as e:
|
||||
pytest.fail(f"E2E Test failed: {e}")
|
||||
# Input 2: disconnect callback with custom string
|
||||
client.disconnect("network_lost")
|
||||
|
||||
# Public function: quit()
|
||||
client2 = OpenTTDClient(host=server_config["host"], port=server_config["game_port"])
|
||||
await client2.connect(server_password=server_config["password"])
|
||||
# Input 1: quit active connection
|
||||
await client2.quit()
|
||||
assert client2.shutdown_event.is_set()
|
||||
|
||||
# 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 ---
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_init_and_connect_multiple_inputs(server_config):
|
||||
# Public function: __init__()
|
||||
# Input 1: Custom admin name
|
||||
admin1 = OpenTTDAdminClient(host=server_config["host"], port=server_config["admin_port"], admin_name="E2E_Admin_1")
|
||||
assert admin1.admin_name == "E2E_Admin_1"
|
||||
|
||||
# Input 2: Alternative admin name
|
||||
admin2 = OpenTTDAdminClient(host=server_config["host"], port=server_config["admin_port"], admin_name="E2E_Admin_2")
|
||||
assert admin2.admin_name == "E2E_Admin_2"
|
||||
|
||||
# Public function: connect()
|
||||
# Input 1: secure=True (PAKE auth)
|
||||
await admin1.connect(admin_password=server_config["password"], secure=True)
|
||||
await asyncio.wait_for(admin1.joined.wait(), timeout=10.0)
|
||||
assert admin1.joined.is_set()
|
||||
await admin1.quit()
|
||||
|
||||
# Input 2: secure=False (plaintext auth, rejected by server)
|
||||
await admin2.connect(admin_password=server_config["password"], secure=False)
|
||||
await asyncio.sleep(0.5)
|
||||
assert admin2.shutdown_event.is_set()
|
||||
await admin2.quit()
|
||||
|
||||
# Input 3: incorrect password
|
||||
admin3 = OpenTTDAdminClient(host=server_config["host"], port=server_config["admin_port"])
|
||||
await admin3.connect(admin_password="wrong_password", secure=True)
|
||||
await asyncio.sleep(0.5)
|
||||
assert admin3.shutdown_event.is_set()
|
||||
await admin3.quit()
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_quit_and_disconnect_multiple_inputs(server_config):
|
||||
# Public function: quit() and disconnect()
|
||||
admin = OpenTTDAdminClient(host=server_config["host"], port=server_config["admin_port"])
|
||||
await admin.connect(admin_password=server_config["password"], secure=True)
|
||||
|
||||
# Public function: disconnect()
|
||||
# Input 1: disconnect callback with None
|
||||
admin.disconnect(None)
|
||||
assert admin.shutdown_event.is_set()
|
||||
|
||||
# Input 2: disconnect callback with custom string
|
||||
admin.disconnect("admin_shutdown")
|
||||
|
||||
# Public function: quit()
|
||||
admin2 = OpenTTDAdminClient(host=server_config["host"], port=server_config["admin_port"])
|
||||
await admin2.connect(admin_password=server_config["password"], secure=True)
|
||||
# Input 1: quit active connection
|
||||
await admin2.quit()
|
||||
assert admin2.shutdown_event.is_set()
|
||||
|
||||
# Input 2: quit already inactive client
|
||||
await admin2.quit()
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_send_rcon_multiple_inputs(connected_admin):
|
||||
# Public function: send_rcon()
|
||||
# Input 1: command "help"
|
||||
await connected_admin.send_rcon("help")
|
||||
|
||||
# Input 2: command "setting max_clients"
|
||||
await connected_admin.send_rcon("setting max_clients")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_send_chat_multiple_inputs(connected_admin, connected_client):
|
||||
# Public function: send_chat()
|
||||
# Input 1: ChatBroadcast (action=1, dest_type=0, dest_id=0)
|
||||
await connected_admin.send_chat("Hello from E2E Broadcast!", action=1, dest_type=0, dest_id=0)
|
||||
|
||||
# Input 2: Chat direct to client (action=1, dest_type=1, dest_id=client_id)
|
||||
client_id = connected_client.client_id if connected_client.client_id is not None else 1
|
||||
await connected_admin.send_chat("Hello private", action=1, dest_type=1, dest_id=client_id)
|
||||
|
||||
# Input 3: ChatBroadcast action (action=3, dest_type=0)
|
||||
await connected_admin.send_chat("wave", action=3, dest_type=0, dest_id=0)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_update_frequency_multiple_inputs(connected_admin):
|
||||
# Public function: update_frequency()
|
||||
# Input 1: Chat update to Automatic
|
||||
await connected_admin.update_frequency(AdminUpdateType.Chat, AdminUpdateFrequency.Automatic)
|
||||
|
||||
# Input 2: Console update to Poll
|
||||
await connected_admin.update_frequency(AdminUpdateType.Console, AdminUpdateFrequency.Poll)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_poll_and_helpers_multiple_inputs(connected_admin):
|
||||
# Public functions: poll(), poll_clients(), poll_companies(), poll_economy(), poll_stats()
|
||||
# Input 1 for poll(): ClientInfo poll with 0xFFFFFFFF
|
||||
await connected_admin.poll(AdminUpdateType.ClientInfo, 0xFFFFFFFF)
|
||||
# Input 2 for poll(): CompanyInfo poll with 0
|
||||
await connected_admin.poll(AdminUpdateType.CompanyInfo, 0)
|
||||
|
||||
# Input 1 for poll_clients(): 0xFFFFFFFF
|
||||
await connected_admin.poll_clients(0xFFFFFFFF)
|
||||
# Input 2 for poll_clients(): specific client ID 1
|
||||
await connected_admin.poll_clients(1)
|
||||
|
||||
# Input 1 for poll_companies(): 0xFFFFFFFF
|
||||
await connected_admin.poll_companies(0xFFFFFFFF)
|
||||
# Input 2 for poll_companies(): specific company ID 0
|
||||
await connected_admin.poll_companies(0)
|
||||
|
||||
# Input 1 for poll_economy(): 0xFFFFFFFF
|
||||
await connected_admin.poll_economy(0xFFFFFFFF)
|
||||
# Input 2 for poll_economy(): specific company ID 0
|
||||
await connected_admin.poll_economy(0)
|
||||
|
||||
# Input 1 for poll_stats(): 0xFFFFFFFF
|
||||
await connected_admin.poll_stats(0xFFFFFFFF)
|
||||
# Input 2 for poll_stats(): specific company ID 0
|
||||
await connected_admin.poll_stats(0)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_send_gamescript_multiple_inputs(connected_admin):
|
||||
# Public function: send_gamescript()
|
||||
# Input 1: healthcheck dict
|
||||
await connected_admin.send_gamescript({"command": "healthcheck"})
|
||||
|
||||
# Input 2: alternative command dict
|
||||
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:
|
||||
if not client.shutdown_event.is_set():
|
||||
await client.quit()
|
||||
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 ---
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_protocol_public_functions_multiple_inputs(server_config):
|
||||
# Public functions: __init__(), receive_packet(), send_packet()
|
||||
client_alt = OpenTTDClient(host=server_config["host"], port=server_config["game_port"])
|
||||
admin_alt = OpenTTDAdminClient(host=server_config["host"], port=server_config["admin_port"])
|
||||
|
||||
# 1. Test __init__() with multiple inputs (handlers)
|
||||
proto_game = OpenTTDProtocol(client_alt)
|
||||
proto_admin = OpenTTDAdminProtocol(admin_alt)
|
||||
assert proto_game.handler == client_alt
|
||||
assert proto_admin.handler == admin_alt
|
||||
|
||||
client_alt2 = OpenTTDClient(host=server_config["host"], port=server_config["game_port"])
|
||||
admin_alt2 = OpenTTDAdminClient(host=server_config["host"], port=server_config["admin_port"])
|
||||
proto_game2 = OpenTTDProtocol(client_alt2)
|
||||
proto_admin2 = OpenTTDAdminProtocol(admin_alt2)
|
||||
assert proto_game2.handler == client_alt2
|
||||
assert proto_admin2.handler == admin_alt2
|
||||
|
||||
# 2. Test receive_packet() with multiple inputs
|
||||
# Input 1: Valid packet structure (length >= 3)
|
||||
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, _ = 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)
|
||||
class FakeTransport:
|
||||
def __init__(self):
|
||||
self.written = []
|
||||
self.closed = False
|
||||
def write(self, data):
|
||||
self.written.append(data)
|
||||
def is_closing(self):
|
||||
return self.closed
|
||||
|
||||
transport = FakeTransport()
|
||||
proto_game.transport = transport
|
||||
proto_game._can_write.set()
|
||||
|
||||
# Input 1: send_packet with encryption disabled
|
||||
client_alt.encryption_enabled = False
|
||||
await proto_game.send_packet(b"\x03\x00\x04") # ClientUnused packet
|
||||
assert len(transport.written) == 1
|
||||
|
||||
# Input 2: send_packet with encryption enabled
|
||||
client_alt.encryption_enabled = True
|
||||
client_alt._session_key_send = b"\x00" * 32
|
||||
client_alt._encryption_nonce = b"\x00" * 24
|
||||
await proto_game.send_packet(b"\x03\x00\x05")
|
||||
assert len(transport.written) == 2
|
||||
|
||||
@@ -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
|
||||
+82
-6
@@ -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()
|
||||
@@ -77,8 +82,9 @@ async def test_client_error_handling():
|
||||
|
||||
client.shutdown_event.clear()
|
||||
await client.receive_ServerError(None, 10) # WrongPassword
|
||||
await client.receive_ServerError(None, 11) # NameInUse
|
||||
await client.receive_ServerError(None, 17) # Timeout
|
||||
await client.receive_ServerError(None, 9) # NameInUse
|
||||
await client.receive_ServerError(None, 11) # CompanyMismatch
|
||||
await client.receive_ServerError(None, 17) # TimeoutComputer
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_server_full_banned():
|
||||
@@ -117,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)
|
||||
@@ -165,3 +170,74 @@ async def test_client_full_handshake_flow():
|
||||
|
||||
client._transport.close()
|
||||
await client.quit()
|
||||
|
||||
|
||||
# --- Game Client Edge Cases & Callback Unit Tests ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unit_client_connection_failure(server_config):
|
||||
client = OpenTTDClient(host=server_config["host"], port=9999)
|
||||
with pytest.raises(OSError):
|
||||
await client.connect()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unit_client_already_joined_warning(server_config):
|
||||
client = OpenTTDClient(host=server_config["host"], port=server_config["game_port"])
|
||||
client.joined.set()
|
||||
await client.join_company(255)
|
||||
assert client.joined.is_set()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unit_client_disconnect(server_config):
|
||||
client = OpenTTDClient(host=server_config["host"], port=server_config["game_port"])
|
||||
client.disconnect(None)
|
||||
assert client.shutdown_event.is_set()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unit_client_receive_chat_no_callback(server_config):
|
||||
client = OpenTTDClient(host=server_config["host"], port=server_config["game_port"])
|
||||
client.on_chat = None
|
||||
await client.receive_ServerChat(None, 1, "hello")
|
||||
assert client.on_chat is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unit_client_receive_chat_with_callback(server_config):
|
||||
client = OpenTTDClient(host=server_config["host"], port=server_config["game_port"])
|
||||
chats = []
|
||||
client.on_chat = lambda cid, msg: chats.append((cid, msg))
|
||||
await client.receive_ServerChat(None, 2, "world")
|
||||
assert chats == [(2, "world")]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unit_client_receive_auth_request_invalid_type(server_config):
|
||||
client = OpenTTDClient(host=server_config["host"], port=server_config["game_port"])
|
||||
await client.receive_ServerAuthenticationRequest(None, 2, b"")
|
||||
assert not client.encryption_enabled
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unit_client_noop_callbacks(server_config):
|
||||
client = OpenTTDClient(host=server_config["host"], port=server_config["game_port"])
|
||||
client.connected(None)
|
||||
await client.receive_ServerUnused(None)
|
||||
await client.receive_ServerCompanyUpdate(None)
|
||||
await client.receive_ServerClientInfo(None)
|
||||
await client.receive_ServerSync(None)
|
||||
await client.receive_ServerClientJoined(None)
|
||||
await client.receive_ServerMapBegin(None)
|
||||
await client.receive_ServerMapSize(None)
|
||||
await client.receive_ServerMapData(None)
|
||||
await client.receive_ServerConfigurationUpdate(None)
|
||||
await client.receive_ServerExternalChat(None)
|
||||
await client.receive_ServerFull(None)
|
||||
await client.receive_ServerBanned(None)
|
||||
await client.receive_ClientAck(None)
|
||||
await client.receive_ClientIdentify(None)
|
||||
assert not client.shutdown_event.is_set()
|
||||
assert not client.joined.is_set()
|
||||
|
||||
|
||||
def test_unit_exclude_call_check_decorator():
|
||||
from openttd import exclude_call_check
|
||||
@exclude_call_check
|
||||
def dummy(): pass
|
||||
assert dummy.__exclude_call_check__ is True
|
||||
|
||||
+174
-7
@@ -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
|
||||
@@ -126,3 +126,170 @@ async def test_protocol_is_closing_failure():
|
||||
|
||||
with pytest.raises(SocketClosed):
|
||||
await proto.send_packet(b"\x02\x00")
|
||||
|
||||
def test_admin_protocol_static_receives():
|
||||
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)
|
||||
assert res == {"version": 3, "updates": {10: 100}}
|
||||
|
||||
# 2. receive_ServerWelcome
|
||||
data = memoryview(b"srv_name\x00" + b"1.0\x00" + struct.pack("<B", 1) + b"map_name\x00" + struct.pack("<I B I H H", 1234, 2, 5678, 100, 200))
|
||||
res = OpenTTDAdminProtocol.receive_ServerWelcome(None, data)
|
||||
assert res["server_name"] == "srv_name"
|
||||
assert res["openttd_version"] == "1.0"
|
||||
assert res["dedicated"] is True
|
||||
assert res["map_name"] == "map_name"
|
||||
assert res["generation_seed"] == 1234
|
||||
assert res["landscape"] == 2
|
||||
assert res["start_date"] == 5678
|
||||
assert res["map_width"] == 100
|
||||
assert res["map_height"] == 200
|
||||
|
||||
# 3. receive_ServerDate
|
||||
data = memoryview(struct.pack("<I", 12345))
|
||||
res = OpenTTDAdminProtocol.receive_ServerDate(None, data)
|
||||
assert res == {"date": 12345}
|
||||
|
||||
# 4. receive_ServerChat
|
||||
data = memoryview(struct.pack("<B B I", 1, 2, 3) + b"msg\x00" + struct.pack("<Q", 100))
|
||||
res = OpenTTDAdminProtocol.receive_ServerChat(None, data)
|
||||
assert res["action"] == 1
|
||||
assert res["dest_type"] == 2
|
||||
assert res["client_id"] == 3
|
||||
assert res["message"] == "msg"
|
||||
assert res["money"] == 100
|
||||
|
||||
# 5. receive_ServerConsole
|
||||
data = memoryview(b"origin\x00" + b"text\x00")
|
||||
res = OpenTTDAdminProtocol.receive_ServerConsole(None, data)
|
||||
assert res == {"origin": "origin", "text": "text"}
|
||||
|
||||
# 6. receive_ServerRcon
|
||||
data = memoryview(struct.pack("<H", 7) + b"rcon_text\x00")
|
||||
res = OpenTTDAdminProtocol.receive_ServerRcon(None, data)
|
||||
assert res == {"color": 7, "text": "rcon_text"}
|
||||
|
||||
# 7. receive_ServerRconEnd
|
||||
data = memoryview(b"cmd\x00")
|
||||
res = OpenTTDAdminProtocol.receive_ServerRconEnd(None, data)
|
||||
assert res == {"command": "cmd"}
|
||||
|
||||
# 8. receive_ServerAuthRequest
|
||||
data = memoryview(struct.pack("<B", 1) + b"auth_data")
|
||||
res = OpenTTDAdminProtocol.receive_ServerAuthRequest(None, data)
|
||||
assert res == {"auth_type": 1, "data": b"auth_data"}
|
||||
|
||||
# 9. receive_ServerEnableEncryption
|
||||
res = OpenTTDAdminProtocol.receive_ServerEnableEncryption(None, memoryview(b"enc_nonce"))
|
||||
assert res == {"data": b"enc_nonce"}
|
||||
|
||||
# 10. receive_ServerError
|
||||
data = memoryview(struct.pack("<B", 10))
|
||||
res = OpenTTDAdminProtocol.receive_ServerError(None, data)
|
||||
assert res == {"error_code": 10}
|
||||
|
||||
# 11. receive_ServerFull, receive_ServerBanned, receive_ServerShutdown, receive_ServerNewGame
|
||||
assert OpenTTDAdminProtocol.receive_ServerFull(None, memoryview(b"")) == {}
|
||||
assert OpenTTDAdminProtocol.receive_ServerBanned(None, memoryview(b"")) == {}
|
||||
assert OpenTTDAdminProtocol.receive_ServerShutdown(None, memoryview(b"")) == {}
|
||||
assert OpenTTDAdminProtocol.receive_ServerNewGame(None, memoryview(b"")) == {}
|
||||
|
||||
# 12. receive_ServerClientJoin
|
||||
data = memoryview(struct.pack("<I", 12))
|
||||
res = OpenTTDAdminProtocol.receive_ServerClientJoin(None, data)
|
||||
assert res == {"client_id": 12}
|
||||
|
||||
# 13. receive_ServerClientInfo
|
||||
data = memoryview(struct.pack("<I", 1) + b"127.0.0.1\x00" + b"clientname\x00" + struct.pack("<B I B", 2, 3456, 3))
|
||||
res = OpenTTDAdminProtocol.receive_ServerClientInfo(None, data)
|
||||
assert res["client_id"] == 1
|
||||
assert res["network_address"] == "127.0.0.1"
|
||||
assert res["name"] == "clientname"
|
||||
assert res["language"] == 2
|
||||
assert res["join_date"] == 3456
|
||||
assert res["play_as"] == 3
|
||||
|
||||
# 14. receive_ServerClientUpdate
|
||||
data = memoryview(struct.pack("<I", 1) + b"newname\x00" + struct.pack("<B", 2))
|
||||
res = OpenTTDAdminProtocol.receive_ServerClientUpdate(None, data)
|
||||
assert res == {"client_id": 1, "name": "newname", "play_as": 2}
|
||||
|
||||
# 15. receive_ServerClientQuit
|
||||
data = memoryview(struct.pack("<I", 1))
|
||||
res = OpenTTDAdminProtocol.receive_ServerClientQuit(None, data)
|
||||
assert res == {"client_id": 1}
|
||||
|
||||
# 16. receive_ServerClientError
|
||||
data = memoryview(struct.pack("<I B", 1, 2))
|
||||
res = OpenTTDAdminProtocol.receive_ServerClientError(None, data)
|
||||
assert res == {"client_id": 1, "error_code": 2}
|
||||
|
||||
# 17. receive_ServerCompanyNew
|
||||
data = memoryview(struct.pack("<B", 1))
|
||||
res = OpenTTDAdminProtocol.receive_ServerCompanyNew(None, data)
|
||||
assert res == {"company_id": 1}
|
||||
|
||||
# 18. receive_ServerCompanyInfo
|
||||
data = memoryview(struct.pack("<B", 1) + b"companyname\x00" + b"managername\x00" + struct.pack("<B B I B", 2, 1, 1990, 0))
|
||||
res = OpenTTDAdminProtocol.receive_ServerCompanyInfo(None, data)
|
||||
assert res["company_id"] == 1
|
||||
assert res["name"] == "companyname"
|
||||
assert res["manager_name"] == "managername"
|
||||
assert res["color"] == 2
|
||||
assert res["password_protected"] is True
|
||||
assert res["inaugurated_year"] == 1990
|
||||
assert res["is_ai"] is False
|
||||
|
||||
# 19. receive_ServerCompanyUpdate
|
||||
data = memoryview(struct.pack("<B", 1) + b"companyname\x00" + b"managername\x00" + struct.pack("<B B B B B B B", 2, 1, 0, 255, 255, 255, 255))
|
||||
res = OpenTTDAdminProtocol.receive_ServerCompanyUpdate(None, data)
|
||||
assert res["company_id"] == 1
|
||||
assert res["name"] == "companyname"
|
||||
assert res["manager_name"] == "managername"
|
||||
assert res["color"] == 2
|
||||
assert res["password_protected"] is True
|
||||
assert res["quarters_of_bankruptcy"] == 0
|
||||
assert res["share_owners"] == [255, 255, 255, 255]
|
||||
|
||||
# 20. receive_ServerCompanyRemove
|
||||
data = memoryview(struct.pack("<B B", 1, 2))
|
||||
res = OpenTTDAdminProtocol.receive_ServerCompanyRemove(None, data)
|
||||
assert res == {"company_id": 1, "reason": 2}
|
||||
|
||||
# 21. receive_ServerCompanyEconomy
|
||||
data = memoryview(struct.pack("<B Q Q q H Q H H Q H H", 1, 1000, 200, -50, 10, 1200, 8, 9, 1100, 7, 8))
|
||||
res = OpenTTDAdminProtocol.receive_ServerCompanyEconomy(None, data)
|
||||
assert res["company_id"] == 1
|
||||
assert res["money"] == 1000
|
||||
assert res["loan"] == 200
|
||||
assert res["income"] == -50
|
||||
assert res["delivered_cargo"] == 10
|
||||
assert res["value_last_quarter"] == 1200
|
||||
assert res["performance_last_quarter"] == 8
|
||||
assert res["delivered_cargo_last_quarter"] == 9
|
||||
assert res["value_previous_quarter"] == 1100
|
||||
assert res["performance_previous_quarter"] == 7
|
||||
assert res["delivered_cargo_previous_quarter"] == 8
|
||||
|
||||
# 22. receive_ServerCompanyStats
|
||||
data = memoryview(struct.pack("<B H H H H H H H H H H", 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11))
|
||||
res = OpenTTDAdminProtocol.receive_ServerCompanyStats(None, data)
|
||||
assert res["company_id"] == 1
|
||||
assert res["vehicles"] == {"trains": 2, "lorries": 3, "buses": 4, "planes": 5, "ships": 6}
|
||||
assert res["stations"] == {"train": 7, "lorry": 8, "bus": 9, "airport": 10, "harbour": 11}
|
||||
|
||||
# 23. receive_ServerGamescript (valid and invalid JSON)
|
||||
res = OpenTTDAdminProtocol.receive_ServerGamescript(None, memoryview(b'{"a": 1}\x00'))
|
||||
assert res == {"data": {"a": 1}}
|
||||
res = OpenTTDAdminProtocol.receive_ServerGamescript(None, memoryview(b'invalid_json\x00'))
|
||||
assert res == {"raw_data": "invalid_json"}
|
||||
|
||||
# 24. receive_ServerPong
|
||||
data = memoryview(struct.pack("<I", 999))
|
||||
res = OpenTTDAdminProtocol.receive_ServerPong(None, data)
|
||||
assert res == {"payload": 999}
|
||||
|
||||
@@ -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