245 lines
8.5 KiB
Python
245 lines
8.5 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""GABS TCP connectivity and JSON-RPC smoke test.
|
|||
|
|
|
|||
|
|
Tests connectivity from Hermes to the Bannerlord.GABS TCP server running on the
|
|||
|
|
Windows VM. Covers:
|
|||
|
|
1. TCP socket connection (port 4825 reachable)
|
|||
|
|
2. JSON-RPC ping round-trip
|
|||
|
|
3. get_game_state call (game must be running)
|
|||
|
|
4. Latency — target < 100 ms on LAN
|
|||
|
|
|
|||
|
|
Usage:
|
|||
|
|
python scripts/test_gabs_connectivity.py --host 10.0.0.50
|
|||
|
|
python scripts/test_gabs_connectivity.py --host 10.0.0.50 --port 4825 --timeout 5
|
|||
|
|
|
|||
|
|
Refs: #1098 (Bannerlord Infra — Windows VM Setup + GABS Mod Installation)
|
|||
|
|
Epic: #1091 (Project Bannerlord)
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import argparse
|
|||
|
|
import json
|
|||
|
|
import socket
|
|||
|
|
import sys
|
|||
|
|
import time
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
DEFAULT_HOST = "127.0.0.1"
|
|||
|
|
DEFAULT_PORT = 4825
|
|||
|
|
DEFAULT_TIMEOUT = 5 # seconds
|
|||
|
|
LATENCY_TARGET_MS = 100.0
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── Low-level TCP helpers ─────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _tcp_connect(host: str, port: int, timeout: float) -> socket.socket:
|
|||
|
|
"""Open a TCP connection and return the socket. Raises on failure."""
|
|||
|
|
sock = socket.create_connection((host, port), timeout=timeout)
|
|||
|
|
sock.settimeout(timeout)
|
|||
|
|
return sock
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _send_recv(sock: socket.socket, payload: dict[str, Any]) -> dict[str, Any]:
|
|||
|
|
"""Send a newline-delimited JSON-RPC request and return the parsed response."""
|
|||
|
|
raw = json.dumps(payload) + "\n"
|
|||
|
|
sock.sendall(raw.encode())
|
|||
|
|
|
|||
|
|
buf = b""
|
|||
|
|
while b"\n" not in buf:
|
|||
|
|
chunk = sock.recv(4096)
|
|||
|
|
if not chunk:
|
|||
|
|
raise ConnectionError("Connection closed before response received")
|
|||
|
|
buf += chunk
|
|||
|
|
|
|||
|
|
line = buf.split(b"\n", 1)[0]
|
|||
|
|
return json.loads(line.decode())
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _rpc(sock: socket.socket, method: str, params: dict | None = None, req_id: int = 1) -> dict[str, Any]:
|
|||
|
|
"""Build and send a JSON-RPC 2.0 request, return the response dict."""
|
|||
|
|
payload: dict[str, Any] = {
|
|||
|
|
"jsonrpc": "2.0",
|
|||
|
|
"method": method,
|
|||
|
|
"id": req_id,
|
|||
|
|
}
|
|||
|
|
if params:
|
|||
|
|
payload["params"] = params
|
|||
|
|
return _send_recv(sock, payload)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── Test cases ────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_tcp_connection(host: str, port: int, timeout: float) -> tuple[bool, socket.socket | None]:
|
|||
|
|
"""PASS: TCP connection to host:port succeeds."""
|
|||
|
|
print(f"\n[1/4] TCP connection → {host}:{port}")
|
|||
|
|
try:
|
|||
|
|
t0 = time.monotonic()
|
|||
|
|
sock = _tcp_connect(host, port, timeout)
|
|||
|
|
elapsed_ms = (time.monotonic() - t0) * 1000
|
|||
|
|
print(f" ✓ Connected ({elapsed_ms:.1f} ms)")
|
|||
|
|
return True, sock
|
|||
|
|
except OSError as exc:
|
|||
|
|
print(f" ✗ Connection failed: {exc}")
|
|||
|
|
print(f" Checklist:")
|
|||
|
|
print(f" - Is Bannerlord running with GABS mod enabled?")
|
|||
|
|
print(f" - Is port {port} open in Windows Firewall?")
|
|||
|
|
print(f" - Is the VM IP correct? (got: {host})")
|
|||
|
|
return False, None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_ping(sock: socket.socket) -> bool:
|
|||
|
|
"""PASS: JSON-RPC ping returns a 2.0 response."""
|
|||
|
|
print(f"\n[2/4] JSON-RPC ping")
|
|||
|
|
try:
|
|||
|
|
t0 = time.monotonic()
|
|||
|
|
resp = _rpc(sock, "ping", req_id=1)
|
|||
|
|
elapsed_ms = (time.monotonic() - t0) * 1000
|
|||
|
|
if resp.get("jsonrpc") == "2.0" and "error" not in resp:
|
|||
|
|
print(f" ✓ Ping OK ({elapsed_ms:.1f} ms): {json.dumps(resp)}")
|
|||
|
|
return True
|
|||
|
|
print(f" ✗ Unexpected response ({elapsed_ms:.1f} ms): {json.dumps(resp)}")
|
|||
|
|
return False
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f" ✗ Ping failed: {exc}")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_game_state(sock: socket.socket) -> bool:
|
|||
|
|
"""PASS: get_game_state returns a result (game must be in a campaign)."""
|
|||
|
|
print(f"\n[3/4] get_game_state call")
|
|||
|
|
try:
|
|||
|
|
t0 = time.monotonic()
|
|||
|
|
resp = _rpc(sock, "get_game_state", req_id=2)
|
|||
|
|
elapsed_ms = (time.monotonic() - t0) * 1000
|
|||
|
|
if "error" in resp:
|
|||
|
|
code = resp["error"].get("code", "?")
|
|||
|
|
msg = resp["error"].get("message", "")
|
|||
|
|
if code == -32601:
|
|||
|
|
# Method not found — GABS version may not expose this method
|
|||
|
|
print(f" ~ Method not available ({elapsed_ms:.1f} ms): {msg}")
|
|||
|
|
print(f" This is acceptable if game is not yet in a campaign.")
|
|||
|
|
return True
|
|||
|
|
print(f" ✗ RPC error ({elapsed_ms:.1f} ms) [{code}]: {msg}")
|
|||
|
|
return False
|
|||
|
|
result = resp.get("result", {})
|
|||
|
|
print(f" ✓ Game state received ({elapsed_ms:.1f} ms):")
|
|||
|
|
for k, v in result.items():
|
|||
|
|
print(f" {k}: {v}")
|
|||
|
|
return True
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f" ✗ get_game_state failed: {exc}")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_latency(host: str, port: int, timeout: float, iterations: int = 5) -> bool:
|
|||
|
|
"""PASS: Average round-trip latency is under LATENCY_TARGET_MS."""
|
|||
|
|
print(f"\n[4/4] Latency test ({iterations} pings, target < {LATENCY_TARGET_MS:.0f} ms)")
|
|||
|
|
try:
|
|||
|
|
times: list[float] = []
|
|||
|
|
for i in range(iterations):
|
|||
|
|
sock = _tcp_connect(host, port, timeout)
|
|||
|
|
try:
|
|||
|
|
t0 = time.monotonic()
|
|||
|
|
_rpc(sock, "ping", req_id=i + 10)
|
|||
|
|
times.append((time.monotonic() - t0) * 1000)
|
|||
|
|
finally:
|
|||
|
|
sock.close()
|
|||
|
|
|
|||
|
|
avg_ms = sum(times) / len(times)
|
|||
|
|
min_ms = min(times)
|
|||
|
|
max_ms = max(times)
|
|||
|
|
print(f" avg={avg_ms:.1f} ms min={min_ms:.1f} ms max={max_ms:.1f} ms")
|
|||
|
|
|
|||
|
|
if avg_ms <= LATENCY_TARGET_MS:
|
|||
|
|
print(f" ✓ Latency within target ({avg_ms:.1f} ms ≤ {LATENCY_TARGET_MS:.0f} ms)")
|
|||
|
|
return True
|
|||
|
|
print(
|
|||
|
|
f" ✗ Latency too high ({avg_ms:.1f} ms > {LATENCY_TARGET_MS:.0f} ms)\n"
|
|||
|
|
f" Check network path between Hermes and the VM."
|
|||
|
|
)
|
|||
|
|
return False
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f" ✗ Latency test failed: {exc}")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main() -> int:
|
|||
|
|
parser = argparse.ArgumentParser(description="GABS TCP connectivity smoke test")
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--host",
|
|||
|
|
default=DEFAULT_HOST,
|
|||
|
|
help=f"Bannerlord VM IP or hostname (default: {DEFAULT_HOST})",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--port",
|
|||
|
|
type=int,
|
|||
|
|
default=DEFAULT_PORT,
|
|||
|
|
help=f"GABS TCP port (default: {DEFAULT_PORT})",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--timeout",
|
|||
|
|
type=float,
|
|||
|
|
default=DEFAULT_TIMEOUT,
|
|||
|
|
help=f"Socket timeout in seconds (default: {DEFAULT_TIMEOUT})",
|
|||
|
|
)
|
|||
|
|
args = parser.parse_args()
|
|||
|
|
|
|||
|
|
print("=" * 60)
|
|||
|
|
print(f"GABS Connectivity Test Suite")
|
|||
|
|
print(f"Target: {args.host}:{args.port}")
|
|||
|
|
print(f"Timeout: {args.timeout}s")
|
|||
|
|
print("=" * 60)
|
|||
|
|
|
|||
|
|
results: dict[str, bool] = {}
|
|||
|
|
|
|||
|
|
# Test 1: TCP connection (gate — skip remaining if unreachable)
|
|||
|
|
ok, sock = test_tcp_connection(args.host, args.port, args.timeout)
|
|||
|
|
results["tcp_connection"] = ok
|
|||
|
|
if not ok:
|
|||
|
|
_print_summary(results)
|
|||
|
|
return 1
|
|||
|
|
|
|||
|
|
# Tests 2–3 reuse the same socket
|
|||
|
|
try:
|
|||
|
|
results["ping"] = test_ping(sock)
|
|||
|
|
results["game_state"] = test_game_state(sock)
|
|||
|
|
finally:
|
|||
|
|
sock.close()
|
|||
|
|
|
|||
|
|
# Test 4: latency uses fresh connections
|
|||
|
|
results["latency"] = test_latency(args.host, args.port, args.timeout)
|
|||
|
|
|
|||
|
|
return _print_summary(results)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _print_summary(results: dict[str, bool]) -> int:
|
|||
|
|
passed = sum(results.values())
|
|||
|
|
total = len(results)
|
|||
|
|
print("\n" + "=" * 60)
|
|||
|
|
print(f"Results: {passed}/{total} passed")
|
|||
|
|
print("=" * 60)
|
|||
|
|
for name, ok in results.items():
|
|||
|
|
icon = "✓" if ok else "✗"
|
|||
|
|
print(f" {icon} {name}")
|
|||
|
|
|
|||
|
|
if passed == total:
|
|||
|
|
print("\n✓ GABS connectivity verified. Timmy can reach the game.")
|
|||
|
|
print(" Next step: run benchmark level 0 (JSON compliance check).")
|
|||
|
|
elif not results.get("tcp_connection"):
|
|||
|
|
print("\n✗ TCP connection failed. VM/firewall setup incomplete.")
|
|||
|
|
print(" See docs/research/bannerlord-vm-setup.md for checklist.")
|
|||
|
|
else:
|
|||
|
|
print("\n~ Partial pass — review failures above.")
|
|||
|
|
|
|||
|
|
return 0 if passed == total else 1
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
sys.exit(main())
|