Add admin port support with other major refactorations #9
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
venv
|
venv
|
||||||
__pycache__
|
__pycache__
|
||||||
|
docker/config
|
||||||
@@ -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)
|
||||||
|
|||||||
111
check_public_calls.py
Executable file
111
check_public_calls.py
Executable file
@@ -0,0 +1,111 @@
|
|||||||
|
#!/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
|
||||||
|
import tests.test_e2e as test_e2e
|
||||||
|
|
||||||
|
# 1. Gather public functions dynamically at runtime using reflection
|
||||||
|
classes = [OpenTTDClient, OpenTTDAdminClient, OpenTTDProtocol, OpenTTDAdminProtocol]
|
||||||
|
public_funcs = {} # name -> has_params
|
||||||
|
|
||||||
|
# Standard asyncio/lifecycle protocol methods that are not user-facing APIs
|
||||||
|
auto_exclude = {
|
||||||
|
"connection_made", "connection_lost", "data_received",
|
||||||
|
"eof_received", "pause_writing", "resume_writing", "connected"
|
||||||
|
}
|
||||||
|
|
||||||
|
for cls in classes:
|
||||||
|
# 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 name in auto_exclude or 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
|
||||||
|
|
||||||
|
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 defined inside tests.test_e2e
|
||||||
|
for name, val in inspect.getmembers(test_e2e, predicate=inspect.isfunction):
|
||||||
|
# Only analyze functions defined directly in the module (skips imported ones)
|
||||||
|
if inspect.getmodule(val) == 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
5
docker/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
OpenTTD-patches/.git
|
||||||
|
OpenTTD-patches/build
|
||||||
|
config
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
60
docker/Dockerfile
Normal file
60
docker/Dockerfile
Normal 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
37
docker/README.md
Normal 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
25
docker/docker-compose.yml
Normal 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
|
||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
104
docs/TESTING.md
Normal 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.
|
||||||
@@ -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']
|
||||||
|
|||||||
@@ -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')}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
4
lib/openttd/decorators.py
Normal file
4
lib/openttd/decorators.py
Normal 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
|
||||||
@@ -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,233 @@ 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 Exception:
|
||||||
|
return {"raw_data": json_str}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def receive_ServerPong(source, data):
|
||||||
|
payload, _ = read_uint32(data)
|
||||||
|
return {"payload": payload}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
65
main_admin.py
Normal file
65
main_admin.py
Normal 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
5
pytest.ini
Normal 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
12
tests/conftest.py
Normal 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
170
tests/test_admin.py
Normal 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()
|
||||||
@@ -1,54 +1,326 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import pytest
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
# 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
|
||||||
|
)
|
||||||
|
|
||||||
@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
|
||||||
await asyncio.wait_for(client.joined.wait(), timeout=15.0)
|
async def connected_client(server_config):
|
||||||
|
"""Fixture to yield a connected and joined spectator OpenTTDClient."""
|
||||||
assert client.joined.is_set()
|
client_name = f"E2E_Player_{random.randint(1000, 9999)}"
|
||||||
assert client.client_id is not None
|
client = OpenTTDClient(
|
||||||
|
host=server_config["host"],
|
||||||
# 4. Stay briefly to ensure keep-alive/frames work
|
port=server_config["game_port"],
|
||||||
await asyncio.sleep(2)
|
username=client_name
|
||||||
|
)
|
||||||
# 5. Graceful Quit
|
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)
|
||||||
|
yield client
|
||||||
|
if hasattr(client, '_transport') and not client.shutdown_event.is_set():
|
||||||
await client.quit()
|
await client.quit()
|
||||||
|
|
||||||
# 6. Wait for shutdown event
|
|
||||||
await asyncio.wait_for(client.shutdown_event.wait(), timeout=5.0)
|
|
||||||
assert client.shutdown_event.is_set()
|
|
||||||
|
|
||||||
except Exception as e:
|
# ==============================================================================
|
||||||
pytest.fail(f"E2E Test failed: {e}")
|
# --- End-to-End Tests (Covering all public functions with multiple inputs) ---
|
||||||
finally:
|
# ==============================================================================
|
||||||
if not client.shutdown_event.is_set():
|
|
||||||
await client.quit()
|
# --- 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()
|
||||||
|
|
||||||
|
# Input 2: disconnect callback with custom string
|
||||||
|
client.disconnect("network_lost")
|
||||||
|
|
||||||
|
# Public function: 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user