Files
maze32/scripts/ws_protocol_client.py
T

367 lines
14 KiB
Python

#!/usr/bin/env python3
"""Dependency-free maze32 WebSocket protocol and six-client verifier."""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import os
import socket
import struct
import threading
import time
from typing import Any
class WebSocket:
def __init__(self, host: str, port: int = 80, timeout: float = 8.0):
self.host = host
self.sequence = 0
self.socket = socket.create_connection((host, port), timeout=timeout)
self.socket.settimeout(timeout)
key = base64.b64encode(os.urandom(16)).decode("ascii")
request = (
f"GET /ws HTTP/1.1\r\nHost: {host}:{port}\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
"Sec-WebSocket-Version: 13\r\n\r\n"
)
self.socket.sendall(request.encode("ascii"))
response = self._receive_until(b"\r\n\r\n")
if not response.startswith(b"HTTP/1.1 101"):
self.socket.close()
raise RuntimeError(response.decode("utf-8", "replace").strip())
expected = base64.b64encode(
hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()
)
if expected not in response:
self.socket.close()
raise RuntimeError("WebSocket accept key did not match")
def _receive_until(self, marker: bytes) -> bytes:
data = bytearray()
while marker not in data:
chunk = self.socket.recv(1024)
if not chunk:
raise ConnectionError("connection closed")
data.extend(chunk)
return bytes(data)
def send(self, message_type: str, payload: dict[str, Any], request_id: str) -> None:
self.sequence += 1
message = json.dumps(
{"v": 1, "type": message_type, "seq": self.sequence,
"requestId": request_id, "payload": payload},
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8")
self.send_raw(message)
def send_raw(self, message: bytes) -> None:
mask = os.urandom(4)
length = len(message)
if length < 126:
header = bytes((0x81, 0x80 | length))
else:
header = bytes((0x81, 0x80 | 126)) + struct.pack("!H", length)
masked = bytes(value ^ mask[index % 4] for index, value in enumerate(message))
self.socket.sendall(header + mask + masked)
def receive(self) -> dict[str, Any]:
first, second = self._read_exact(2)
opcode = first & 0x0F
length = second & 0x7F
if length == 126:
length = struct.unpack("!H", self._read_exact(2))[0]
elif length == 127:
length = struct.unpack("!Q", self._read_exact(8))[0]
if second & 0x80:
mask = self._read_exact(4)
else:
mask = None
payload = self._read_exact(length)
if mask:
payload = bytes(value ^ mask[index % 4] for index, value in enumerate(payload))
if opcode == 0x8:
raise ConnectionError("WebSocket closed")
if opcode != 0x1:
return self.receive()
return json.loads(payload.decode("utf-8"))
def receive_type(self, message_type: str, attempts: int = 20) -> dict[str, Any]:
for _ in range(attempts):
message = self.receive()
if message.get("type") == message_type:
return message
raise RuntimeError(f"did not receive {message_type}")
def _read_exact(self, length: int) -> bytes:
data = bytearray()
while len(data) < length:
chunk = self.socket.recv(length - len(data))
if not chunk:
raise ConnectionError("connection closed")
data.extend(chunk)
return bytes(data)
def close(self) -> None:
try:
self.socket.sendall(b"\x88\x80" + b"\0\0\0\0")
except OSError:
pass
self.socket.close()
def join(client: WebSocket, role: str, name: str | None = None) -> dict[str, Any]:
payload = {"role": role}
if name is not None:
payload["name"] = name
client.send("join", payload, f"join-{role}")
return client.receive_type("session")["payload"]
def receive_view(client: WebSocket, maximum_messages: int = 800) -> dict[str, Any]:
parts: dict[int, dict[str, Any]] = {}
sequence: int | None = None
expected = 0
for _ in range(maximum_messages):
message = client.receive()
if message.get("type") != "view":
continue
payload = message["payload"]
if sequence != message["seq"]:
sequence = message["seq"]
expected = payload["chunks"]
parts = {}
parts[payload["chunk"]] = payload
if len(parts) == expected:
first = dict(parts[0])
first["cells"] = [cell for index in range(expected) for cell in parts[index]["cells"]]
first["objects"] = [
item for index in range(expected) for item in parts[index]["objects"]
]
return first
raise RuntimeError("did not receive a complete view")
def open_direction(view: dict[str, Any]) -> str:
passages = next(cell[1] for cell in view["cells"] if cell[0] == view["self"])
for bit, direction in ((1, "north"), (2, "east"), (4, "south"), (8, "west")):
if passages & bit:
return direction
raise AssertionError("player cell has no open passage")
def verify_m009(host: str) -> None:
clients: list[WebSocket] = []
try:
player_1 = WebSocket(host)
player_2 = WebSocket(host)
clients.extend((player_1, player_2))
join(player_1, "player", "Наблюдаемый Один")
session_2 = join(player_2, "player", "Наблюдаемый Два")
observers = [WebSocket(host) for _ in range(4)]
clients.extend(observers)
for observer in observers:
join(observer, "observer")
observer.socket.settimeout(30.0)
observer_views: list[dict[str, Any] | None] = [None] * 4
failures: list[BaseException] = []
def read_observer(index: int) -> None:
try:
observers[index].receive_type("active", attempts=40)
observer_views[index] = receive_view(observers[index])
except BaseException as error: # Preserve worker failure for the main thread.
failures.append(error)
readers = [threading.Thread(target=read_observer, args=(index,)) for index in range(4)]
for reader in readers:
reader.start()
player_1.send("start", {}, "m009-start")
player_1.receive_type("countdown")
player_2.receive_type("countdown")
player_1.receive_type("active")
player_2.receive_type("active")
player_1_view = receive_view(player_1)
player_2_view = receive_view(player_2)
for reader in readers:
reader.join(timeout=35.0)
assert not failures and all(not reader.is_alive() for reader in readers)
assert all(view is not None and len(view["cells"]) == 8192 for view in observer_views)
print("four-observer full-map delivery: PASS (8192 cells each)")
observers[0].send("move", {"direction": "north"}, "observer-move")
error = observers[0].receive_type("error")
assert error["payload"]["code"] == "read_only"
print("observer mutation authorization: PASS (read_only)")
direction = open_direction(player_1_view)
player_1.send("move", {"direction": direction}, "before-observer-close")
player_1.receive_type("ack")
player_1_view = receive_view(player_1)
observers[-1].close()
clients.remove(observers[-1])
observers.pop()
time.sleep(0.2)
player_1.send("move", {"direction": open_direction(player_1_view)},
"after-observer-close")
player_1.receive_type("ack")
print("observer disconnect continuity: PASS")
preserved_cell = player_2_view["self"]
preserved_inventory = player_2_view.get("inventory")
token_2 = session_2["token"]
player_2.close()
clients.remove(player_2)
pause = player_1.receive_type("pause", attempts=30)["payload"]
assert pause["slot"] == 2
player_1.send("move", {"direction": "north"}, "paused-move")
error = player_1.receive_type("error", attempts=30)
assert error["payload"]["code"] == "premature"
restored = WebSocket(host)
clients.append(restored)
restored.send("restore", {"token": token_2}, "restore-player-2")
restored_session = restored.receive_type("session")["payload"]
assert restored_session["slot"] == 2 and restored_session["token"] == token_2
player_1.receive_type("resume", attempts=30)
restored_view = receive_view(restored)
assert restored_view["self"] == preserved_cell
assert restored_view.get("inventory") == preserved_inventory
print("player pause and reconnect: PASS (same position and inventory)")
restored.close()
clients.remove(restored)
player_1.socket.settimeout(70.0)
started = time.monotonic()
result = player_1.receive_type("match_result", attempts=60)["payload"]
elapsed = time.monotonic() - started
assert result["result"] == "draw" and elapsed >= 59.0
print(f"reconnect timeout draw: PASS ({elapsed:.1f} seconds)")
finally:
for client in clients:
client.close()
def verify(host: str) -> None:
clients: list[WebSocket] = []
try:
player_1 = WebSocket(host)
clients.append(player_1)
player_1.send_raw(b"{")
error = player_1.receive_type("error")
assert error["payload"]["code"] == "malformed"
session_1 = join(player_1, "player", "Тестовый Пилот")
player_1.send_raw(
b'{"v":1,"type":"start","seq":1,"requestId":"stale","payload":{}}'
)
error = player_1.receive_type("error")
assert error["payload"]["code"] == "stale_sequence"
print("malformed and stale message rejection: PASS")
duplicate = WebSocket(host)
clients.append(duplicate)
duplicate.send("join", {"role": "player", "name": "Тестовый Пилот"}, "duplicate")
error = duplicate.receive_type("error")
assert error["payload"]["code"] == "duplicate_name"
duplicate.send("join", {"role": "player", "name": "Запасной Пилот"}, "player-2")
session_2 = duplicate.receive_type("session")["payload"]
assert session_1["slot"] == 1 and session_2["slot"] == 2
observers = []
for _ in range(4):
observer = WebSocket(host)
clients.append(observer)
join(observer, "observer")
observers.append(observer)
lobby = observers[-1].receive_type("lobby")["payload"]
assert lobby["ready"] and lobby["observers"] == 4
print("six-client capacity: PASS (2 players + 4 observers)")
print("duplicate player name: PASS")
try:
excess = WebSocket(host, timeout=1.0)
except (OSError, RuntimeError, ConnectionError):
print("seventh connection rejection: PASS")
else:
try:
excess.send("join", {"role": "observer"}, "excess")
excess.receive()
except (OSError, ConnectionError):
print("seventh connection rejection: PASS")
else:
raise AssertionError("seventh WebSocket unexpectedly remained connected")
finally:
excess.close()
time.sleep(0.5)
duplicate.send("start", {}, "not-owner")
error = duplicate.receive_type("error")
assert error["payload"]["code"] == "not_owner"
print("Player 2 start authorization: PASS")
token_2 = session_2["token"]
duplicate.close()
clients.remove(duplicate)
time.sleep(0.15)
restored = WebSocket(host)
clients.append(restored)
restored.send("restore", {"token": token_2}, "restore")
restored_session = restored.receive_type("session")["payload"]
assert restored_session["slot"] == 2 and restored_session["token"] == token_2
print("session refresh restoration: PASS (Player 2 retained slot 2)")
player_1.send("start", {}, "owner-start")
countdown_1 = player_1.receive_type("countdown")["payload"]
countdown_2 = restored.receive_type("countdown")["payload"]
assert countdown_1 == countdown_2
assert countdown_1["steps"] == [3, 2, 1]
active_1 = player_1.receive_type("active")["payload"]
active_2 = restored.receive_type("active")["payload"]
assert active_1["activatedAtMs"] == countdown_1["activationAtMs"]
assert active_1 == active_2
print(
"authoritative countdown: PASS "
f"(activationAtMs={countdown_1['activationAtMs']})"
)
observer = observers.pop()
observer.close()
clients.remove(observer)
time.sleep(0.5)
oversized = WebSocket(host)
oversized.send_raw(b"x" * 513)
try:
error = oversized.receive_type("error")
assert error["payload"]["code"] == "too_large"
except (OSError, ConnectionError):
pass
print("oversized message rejection: PASS")
oversized.close()
finally:
for client in clients:
client.close()
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("host", help="maze32 IP address or hostname")
parser.add_argument("--verify", action="store_true", help="run the Milestone 004 scenario")
parser.add_argument("--verify-m009", action="store_true", help="run the Milestone 009 scenario")
arguments = parser.parse_args()
if arguments.verify_m009:
verify_m009(arguments.host)
elif arguments.verify:
verify(arguments.host)
else:
client = WebSocket(arguments.host)
client.send("suggest_name", {}, "manual")
print(json.dumps(client.receive(), ensure_ascii=False, indent=2))
client.close()
if __name__ == "__main__":
main()