Merge pull request 'Add vehicle listing support to admin client' (#16) from claude/listing-vehicles-support-d07bf0 into main
All checks were successful
Continuous Integration / lint-and-security (push) Successful in 22s
Continuous Integration / tests-and-coverage (push) Successful in 24s

Reviewed-on: #16
This commit is contained in:
2026-07-16 20:46:58 +02:00
6 changed files with 67 additions and 1 deletions

View File

@@ -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

View File

@@ -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).

View File

@@ -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

View File

@@ -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 ---")

View File

@@ -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")

View File

@@ -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 ---