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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ---")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user