From 0a8271d57c64088b5d312d653c1282a65c186c40 Mon Sep 17 00:00:00 2001 From: kovagoadi Date: Thu, 16 Jul 2026 20:44:44 +0200 Subject: [PATCH] Add vehicle listing support to admin client The Admin Network has no native packet for listing individual vehicles, so list_vehicles() sends a "list_vehicles" command over the existing GameScript JSON channel and relies on a companion server-side script to reply with vehicle data via ServerGamescript. Requires subscribing to Gamescript updates (documented in docs/PROTOCOL.md) to receive the reply. Co-Authored-By: Claude Sonnet 5 --- README.md | 1 + docs/PROTOCOL.md | 5 +++++ lib/openttd/client.py | 7 +++++++ main_admin.py | 2 +- tests/test_admin.py | 19 +++++++++++++++++++ tests/test_e2e.py | 34 ++++++++++++++++++++++++++++++++++ 6 files changed, 67 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 556dce4..eabf569 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A high-performance, Object-Oriented Python client for OpenTTD servers, specifica - **Modular Design:** Separates low-level binary protocol handling from high-level game logic. - **State Management:** Handles the full join sequence including Map download and synchronization. - **Comprehensive Testing:** Robustly tested with unit, logic, and E2E tests (including 100% coverage for unit/logic tests). +- **Vehicle Listing:** Query vehicle data via the Admin GameScript channel with `list_vehicles()`. ## 🛠 Setup diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 68f5bcf..3943681 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -26,6 +26,11 @@ Similar to the Game Port, the Admin Network uses X25519 PAKE for secure authenti ### Update Frequencies Admins can subscribe to various updates (Date, Client Info, Company Info, etc.) at different frequencies (Poll, Daily, Weekly, Monthly, Quarterly, Annually, Automatic). +### 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. + ## Stream Encryption (AEAD) Once `ServerEnableEncryption` is received, all subsequent packets use **XChaCha20-Poly1305** (Authenticated Encryption with Associated Data). diff --git a/lib/openttd/client.py b/lib/openttd/client.py index 9ddfe8e..70b8f9e 100644 --- a/lib/openttd/client.py +++ b/lib/openttd/client.py @@ -322,6 +322,13 @@ class OpenTTDAdminClient: from .protocol import AdminUpdateType await self.poll(AdminUpdateType.CompanyStats, company_id) + async def list_vehicles(self, company_id=None): + """Request a list of vehicles via GameScript. company_id=None for all companies.""" + payload = {"command": "list_vehicles"} + if company_id is not None: + payload["company_id"] = company_id + await self.send_gamescript(payload) + async def send_gamescript(self, json_data): """Send a JSON string to the GameScript.""" import json diff --git a/main_admin.py b/main_admin.py index 3e444db..738b3f4 100644 --- a/main_admin.py +++ b/main_admin.py @@ -48,7 +48,7 @@ async def run_admin(): await admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic) print("--- Requesting vehicle info via GameScript ---") - await admin.send_gamescript({"command": "list_vehicles"}) + await admin.list_vehicles() await asyncio.sleep(5) print("--- Quitting ---") diff --git a/tests/test_admin.py b/tests/test_admin.py index c6dbd23..09bc657 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -1,10 +1,15 @@ import pytest import asyncio +import json import os import monocypher from openttd import OpenTTDAdminClient from openttd.protocol import PacketAdminType, AdminUpdateType, AdminUpdateFrequency +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 @@ -27,6 +32,20 @@ def test_admin_packet_types(): assert PacketAdminType.ServerWelcome == 104 assert PacketAdminType.ServerAuthRequest == 128 +@pytest.mark.asyncio +async def test_admin_list_vehicles(): + client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin") + proto = MockProtocol() + client._protocol = proto + client._transport = MockTransport() + + await client.list_vehicles() + await client.list_vehicles(company_id=2) + + assert len(proto.sent) == 2 + assert decode_gamescript_payload(proto.sent[0]) == {"command": "list_vehicles"} + assert decode_gamescript_payload(proto.sent[1]) == {"command": "list_vehicles", "company_id": 2} + @pytest.mark.asyncio async def test_admin_client_connect_and_actions(monkeypatch): client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin") diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 9d4e699..45ac7dd 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -267,6 +267,40 @@ async def test_e2e_admin_send_gamescript_multiple_inputs(connected_admin): await connected_admin.send_gamescript({"command": "ping", "sequence": 1}) await asyncio.sleep(0.5) +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_e2e_admin_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) + # --- Protocol Public Functions ---