#!/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())