Add admin port support with other major refactorations
Some checks failed
Continuous Integration / lint-and-security (pull_request) Failing after 39s
Continuous Integration / tests-and-coverage (pull_request) Successful in 24s

This commit is contained in:
2026-06-29 19:47:48 +02:00
parent df629b2922
commit aebd5f4ef5
22 changed files with 1746 additions and 67 deletions

View File

@@ -51,4 +51,10 @@ jobs:
# We run tests and fail if coverage is below 100%. # We run tests and fail if coverage is below 100%.
# E2E test is skipped in CI because no local OpenTTD server is available. # E2E test is skipped in CI because no local OpenTTD server is available.
run: | 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
.gitignore vendored
View File

@@ -1,2 +1,3 @@
venv venv
__pycache__ __pycache__
docker/config

View File

@@ -8,7 +8,7 @@ A high-performance, Object-Oriented Python client for OpenTTD servers, specifica
- **Stream Encryption:** Automatic XChaCha20-Poly1305 authenticated encryption for all game traffic. - **Stream Encryption:** Automatic XChaCha20-Poly1305 authenticated encryption for all game traffic.
- **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.
- **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).
## 🛠 Setup ## 🛠 Setup
@@ -63,10 +63,11 @@ await client.joined.wait()
- `tests/`: Comprehensive test suite (Logic, Protocol, E2E). - `tests/`: Comprehensive test suite (Logic, Protocol, E2E).
## 🧪 Testing ## 🧪 Testing
We maintain 100% test coverage. To run tests: We maintain high test coverage. To run normal (non-E2E) tests:
```bash ```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 ## 📜 Documentation
- [Architecture & Design](docs/ARCHITECTURE.md) - [Architecture & Design](docs/ARCHITECTURE.md)

108
check_public_calls.py Executable file
View File

@@ -0,0 +1,108 @@
#!/usr/bin/env python3
import ast
import inspect
import sys
import os
# 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 OpenTTDClient, OpenTTDAdminClient
from openttd.protocol import OpenTTDProtocol, OpenTTDAdminProtocol
# 1. Gather public functions dynamically at runtime using reflection markers
classes = [OpenTTDClient, OpenTTDAdminClient, OpenTTDProtocol, OpenTTDAdminProtocol]
public_funcs = {} # name -> has_params
for cls in classes:
# Class must be marked as public
if not getattr(cls, '__is_public_api__', False):
continue
# 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 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
import tests.test_e2e as test_e2e
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 in tests.test_e2e
for name, val in inspect.getmembers(test_e2e, predicate=inspect.isfunction):
if name.startswith("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)

5
docker/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
OpenTTD-patches/.git
OpenTTD-patches/build
config
Dockerfile
docker-compose.yml

60
docker/Dockerfile Normal file
View File

@@ -0,0 +1,60 @@
# Build stage
FROM debian:bookworm 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
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y \
libcurl3-gnutls \
liblzma5 \
liblzo2-2 \
libpng16-16 \
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"]

37
docker/README.md Normal file
View File

@@ -0,0 +1,37 @@
# 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/`.
## JGRPP Source
The source code is cloned from the `jgrpp` branch of `https://github.com/JGRennison/OpenTTD-patches`.
To update the server to a newer JGRPP version:
1. Update the `OpenTTD-patches` directory:
```bash
cd OpenTTD-patches && git pull && cd ..
```
2. Rebuild the image:
```bash
docker-compose up -d --build
```

25
docker/docker-compose.yml Normal file
View File

@@ -0,0 +1,25 @@
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
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

View File

@@ -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. - **Minimal Dependencies:** Only add new dependencies if absolutely necessary.
## Testing Mandate ## 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 ### 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 ```bash
PYTHONPATH=lib ./venv/bin/pytest --cov=openttd --cov-report=term-missing tests/ ./venv/bin/pytest -m "not e2e"
``` ```
### Types of Tests Quick command to run all tests (including E2E):
- **Logic Tests (`tests/test_logic.py`):** High-level client state and API behavior. ```bash
- **Protocol Tests (`tests/test_protocol.py`):** Low-level binary parsing and encryption. ./venv/bin/pytest
- **E2E Tests (`tests/test_e2e.py`):** Integration tests against a live server. ```
## Submitting Changes ## Submitting Changes
1. **Fork the repo** and create your branch from `main`. 1. **Fork the repo** and create your branch from `main`.

View File

@@ -15,6 +15,17 @@ We use **Blake2b** (64-byte digest) to derive two 32-byte session keys.
### Handshake Nonces ### 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. 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).
## 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).

104
docs/TESTING.md Normal file
View File

@@ -0,0 +1,104 @@
# 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_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_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.

View File

@@ -1,3 +1,4 @@
from .client import OpenTTDClient from .decorators import exclude_call_check
from .client import OpenTTDClient, OpenTTDAdminClient
__all__ = ['OpenTTDClient'] __all__ = ['OpenTTDClient', 'OpenTTDAdminClient', 'exclude_call_check']

View File

@@ -4,8 +4,9 @@ import uuid
import monocypher import monocypher
import os import os
import hashlib import hashlib
from openttd_protocol.wire.write import write_init, write_string, write_uint8, write_uint32, write_presend, SEND_TCP_MTU from openttd_protocol.wire.write import write_init, write_string, write_uint8, write_uint16, write_uint32, write_presend, SEND_TCP_MTU
from .protocol import PacketGameType, OpenTTDProtocol from .protocol import PacketGameType, OpenTTDProtocol, PacketAdminType, OpenTTDAdminProtocol, NetworkAuthenticationMethod
from .decorators import exclude_call_check
class OpenTTDClient: class OpenTTDClient:
"""High-level OpenTTD client for easy integration.""" """High-level OpenTTD client for easy integration."""
@@ -80,6 +81,7 @@ class OpenTTDClient:
# --- Internal Protocol Callbacks --- # --- Internal Protocol Callbacks ---
@exclude_call_check
def connected(self, source): pass def connected(self, source): pass
async def receive_ServerGameInfo(self, source, **kwargs): async def receive_ServerGameInfo(self, source, **kwargs):
@@ -90,7 +92,31 @@ class OpenTTDClient:
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU)) await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
async def receive_ServerError(self, source, error_code): 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}')}") self.log.error(f"Server Error: {error_names.get(error_code, f'Code {error_code}')}")
await self.quit() await self.quit()
@@ -98,8 +124,9 @@ class OpenTTDClient:
if auth_type == 1: if auth_type == 1:
server_pub = bytes(data[:32]) server_pub = bytes(data[:32])
nonce = bytes(data[32:56]) nonce = bytes(data[32:56])
our_priv, our_pub = monocypher.generate_key_exchange_key_pair() our_priv = monocypher.generate_key()
shared_secret = monocypher.key_exchange(our_priv, server_pub) 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()) 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] self._session_key_send, self._session_key_recv = derived[:32], derived[32:64]
challenge = os.urandom(8) challenge = os.urandom(8)
@@ -177,3 +204,230 @@ class OpenTTDClient:
async def receive_ServerBanned(self, source, **kwargs): pass async def receive_ServerBanned(self, source, **kwargs): pass
async def receive_ClientAck(self, source, **kwargs): pass async def receive_ClientAck(self, source, **kwargs): pass
async def receive_ClientIdentify(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"):
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
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.")
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 Exception:
pass
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 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):
if self.on_gamescript:
self.on_gamescript(kwargs.get('data'))
else:
self.log.info(f"GAMESCRIPT: {kwargs.get('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')}")

View File

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

View File

@@ -49,6 +49,80 @@ class PacketGameType(IntEnum):
ServerCompanyUpdate = 45 ServerCompanyUpdate = 45
PACKET_END = 100 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
class OpenTTDProtocol(TCPProtocol): class OpenTTDProtocol(TCPProtocol):
"""Low-level OpenTTD TCP protocol handler with encryption support.""" """Low-level OpenTTD TCP protocol handler with encryption support."""
PacketType = PacketGameType PacketType = PacketGameType
@@ -69,9 +143,6 @@ class OpenTTDProtocol(TCPProtocol):
raise SocketClosed("Decryption failed") raise SocketClosed("Decryption failed")
data = memoryview(struct.pack("<H", len(payload) + 2) + payload) 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) return super().receive_packet(source, data)
except Exception: except Exception:
return PacketGameType.ServerUnused, {} return PacketGameType.ServerUnused, {}
@@ -84,7 +155,6 @@ class OpenTTDProtocol(TCPProtocol):
mac, ciphertext = self.handler._send_aead.lock(payload.tobytes()) mac, ciphertext = self.handler._send_aead.lock(payload.tobytes())
data = struct.pack("<H", 18 + len(ciphertext)) + mac + ciphertext data = struct.pack("<H", 18 + len(ciphertext)) + mac + ciphertext
# Coverage for protocol.py:92-93: original send logic
await self._can_write.wait() await self._can_write.wait()
if self.transport.is_closing(): if self.transport.is_closing():
raise SocketClosed raise SocketClosed
@@ -171,3 +241,227 @@ class OpenTTDProtocol(TCPProtocol):
return {"frame": f, "token": t} return {"frame": f, "token": t}
@staticmethod @staticmethod
def receive_ClientIdentify(source, data): return {} 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:
return {"raw_data": json_str}
@staticmethod
def receive_ServerPong(source, data):
payload, _ = read_uint32(data)
return {"payload": payload}

65
main_admin.py Normal file
View File

@@ -0,0 +1,65 @@
import asyncio
import logging
import sys
import os
# 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 AdminUpdateType, AdminUpdateFrequency
# 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.send_gamescript({"command": "list_vehicles"})
await asyncio.sleep(5)
print("--- Quitting ---")
await admin.quit()
except Exception as e:
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

5
pytest.ini Normal file
View File

@@ -0,0 +1,5 @@
[pytest]
pythonpath = lib
markers =
e2e: End-to-end tests requiring a running OpenTTD server.
unit: Fast in-memory unit tests.

12
tests/conftest.py Normal file
View File

@@ -0,0 +1,12 @@
import pytest
import os
@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")
}

170
tests/test_admin.py Normal file
View File

@@ -0,0 +1,170 @@
import pytest
import asyncio
import os
import monocypher
from openttd import OpenTTDAdminClient
from openttd.protocol import PacketAdminType, AdminUpdateType, AdminUpdateFrequency
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_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 Exception("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 Exception("Fail")
client._transport = MockTransport()
client._protocol = BadProtocol()
await client.quit()
assert client.shutdown_event.is_set()

View File

@@ -1,54 +1,329 @@
import asyncio import asyncio
import pytest import pytest
import pytest_asyncio
import sys import sys
import os import os
import random
from unittest.mock import MagicMock, AsyncMock
# Add lib to path # 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 OpenTTDClient, OpenTTDAdminClient
from openttd.protocol import (
OpenTTDProtocol,
OpenTTDAdminProtocol,
AdminUpdateType,
AdminUpdateFrequency,
PacketGameType,
PacketAdminType
)
@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"
client = OpenTTDClient(host=SERVER_IP, username="TestRunner") # --- Pytest Fixtures ---
# Track chat for coverage
chat_received = asyncio.Event()
def chat_handler(cid, msg):
chat_received.set()
client.on_chat = chat_handler
try:
# 1. Connect
await client.connect(server_password=SERVER_PW)
# 2. Join company @pytest_asyncio.fixture
await client.join_company(company_id=COMPANY_ID, company_password=COMPANY_PW) 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()
# 3. Wait for join (timeout after 15s to be safe) @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) await asyncio.wait_for(client.joined.wait(), timeout=15.0)
yield client
assert client.joined.is_set() if hasattr(client, '_transport') and not client.shutdown_event.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
await client.quit() await client.quit()
# 6. Wait for shutdown event
await asyncio.wait_for(client.shutdown_event.wait(), timeout=5.0) # ==============================================================================
# --- 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() assert client.shutdown_event.is_set()
except Exception as e: # Input 2: disconnect callback with custom string
pytest.fail(f"E2E Test failed: {e}") client.disconnect("network_lost")
finally:
if not client.shutdown_event.is_set(): # Public function: quit()
await client.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()
# --- 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)
# --- 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, res_data1 = 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, res_data2 = 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

View File

@@ -77,8 +77,9 @@ async def test_client_error_handling():
client.shutdown_event.clear() client.shutdown_event.clear()
await client.receive_ServerError(None, 10) # WrongPassword await client.receive_ServerError(None, 10) # WrongPassword
await client.receive_ServerError(None, 11) # NameInUse await client.receive_ServerError(None, 9) # NameInUse
await client.receive_ServerError(None, 17) # Timeout await client.receive_ServerError(None, 11) # CompanyMismatch
await client.receive_ServerError(None, 17) # TimeoutComputer
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_client_server_full_banned(): async def test_client_server_full_banned():
@@ -165,3 +166,75 @@ async def test_client_full_handshake_flow():
client._transport.close() client._transport.close()
await client.quit() 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_ServerCommand(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

View File

@@ -126,3 +126,169 @@ async def test_protocol_is_closing_failure():
with pytest.raises(SocketClosed): with pytest.raises(SocketClosed):
await proto.send_packet(b"\x02\x00") await proto.send_packet(b"\x02\x00")
def test_admin_protocol_static_receives():
from openttd.protocol import OpenTTDAdminProtocol
import struct
# 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}