Compare commits

..

2 Commits

Author SHA1 Message Date
fa1741f95b 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
2026-07-16 20:46:58 +02:00
0a8271d57c Add vehicle listing support to admin client
All checks were successful
Continuous Integration / lint-and-security (pull_request) Successful in 31s
Continuous Integration / tests-and-coverage (pull_request) Successful in 25s
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 <noreply@anthropic.com>
2026-07-16 20:44:44 +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. - **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. - **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). - **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 ## 🛠 Setup

View File

@@ -26,6 +26,11 @@ Similar to the Game Port, the Admin Network uses X25519 PAKE for secure authenti
### Update Frequencies ### Update Frequencies
Admins can subscribe to various updates (Date, Client Info, Company Info, etc.) at different frequencies (Poll, Daily, Weekly, Monthly, Quarterly, Annually, Automatic). 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) ## Stream Encryption (AEAD)
Once `ServerEnableEncryption` is received, all subsequent packets use **XChaCha20-Poly1305** (Authenticated Encryption with Associated Data). 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 from .protocol import AdminUpdateType
await self.poll(AdminUpdateType.CompanyStats, company_id) 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): async def send_gamescript(self, json_data):
"""Send a JSON string to the GameScript.""" """Send a JSON string to the GameScript."""
import json import json

View File

@@ -48,7 +48,7 @@ async def run_admin():
await admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic) await admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
print("--- Requesting vehicle info via GameScript ---") print("--- Requesting vehicle info via GameScript ---")
await admin.send_gamescript({"command": "list_vehicles"}) await admin.list_vehicles()
await asyncio.sleep(5) await asyncio.sleep(5)
print("--- Quitting ---") print("--- Quitting ---")

View File

@@ -1,10 +1,15 @@
import pytest import pytest
import asyncio import asyncio
import json
import os import os
import monocypher import monocypher
from openttd import OpenTTDAdminClient from openttd import OpenTTDAdminClient
from openttd.protocol import PacketAdminType, AdminUpdateType, AdminUpdateFrequency 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: class MockTransport:
def __init__(self): def __init__(self):
self._closing = False self._closing = False
@@ -27,6 +32,20 @@ def test_admin_packet_types():
assert PacketAdminType.ServerWelcome == 104 assert PacketAdminType.ServerWelcome == 104
assert PacketAdminType.ServerAuthRequest == 128 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 @pytest.mark.asyncio
async def test_admin_client_connect_and_actions(monkeypatch): async def test_admin_client_connect_and_actions(monkeypatch):
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin") client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")

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 connected_admin.send_gamescript({"command": "ping", "sequence": 1})
await asyncio.sleep(0.5) 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 --- # --- Protocol Public Functions ---