refactor: abstract WebSocket handling into shared base class

- Introduce WebSocketHandler for reusable socket lifecycle management:
  • login, connect, close, start_listener
  • continuous receive loop with on_message callback
  • background listener task support

- Create PlayerWebSocketHandler subclass implementing TicTacToe behavior:
  • match_create, match_join, send_move helpers
  • parse match_data opcodes and pretty-print board state

- Move shared logic (auth, recv loop, WS decoding) out of gameplay module
- Simplifies test scenario execution & enables future bot/test clients
- Reduces duplication and improves separation of concerns
This commit is contained in:
2025-11-26 15:43:25 +05:30
parent 1b4e7a5ee0
commit d260c9a1ef

View File

@@ -2,6 +2,7 @@ import random
import asyncio import asyncio
import base64 import base64
import json import json
from typing import Awaitable, Callable
import requests import requests
import websockets import websockets
@@ -19,16 +20,23 @@ def print_board(board):
print("\n" + separator.join(rows) + "\n") print("\n" + separator.join(rows) + "\n")
class PlayerWebSocketHandler(object): class WebSocketHandler(object):
def __init__(self, custom_id: str, label: str): def __init__(
self,
custom_id: str,
label: str,
):
self.custom_id = custom_id self.custom_id = custom_id
self.label = label self.label = label
self.token = None self.token = None
self.ws = None self.ws = None
self.listener_task = None self.listener_task = None
# ---------- Auth & Connect ---------- async def on_message(self, msg: dict):
raise NotImplementedError("Override me!")
# ---------- Auth & Connect ----------
def login(self): def login(self):
"""Authenticate via custom ID and store token.""" """Authenticate via custom ID and store token."""
basic = base64.b64encode(f"{SERVER_KEY}:".encode()).decode() basic = base64.b64encode(f"{SERVER_KEY}:".encode()).decode()
@@ -50,31 +58,13 @@ class PlayerWebSocketHandler(object):
self.ws = await websockets.connect(url) self.ws = await websockets.connect(url)
# ---------- Listener ---------- # ---------- Listener ----------
async def listener(self): async def listener(self):
"""Continuously receive events + decode match data.""" """Continuously receive events + decode match data."""
try: try:
while True: while True:
raw = await self.ws.recv() raw = await self.ws.recv()
msg = json.loads(raw) msg = json.loads(raw)
await self.on_message(msg) # ✅ handoff
if "match_data" not in msg:
print(f"[{self.label}] {msg}")
continue
md = msg["match_data"]
op = int(md.get("op_code", 0))
payload = json.loads(base64.b64decode(md["data"]).decode())
if op == 1:
print(f"[{self.label}] MOVE -> {payload}")
elif op == 2:
print(f"\n[{self.label}] GAME STATE")
print_board(payload["board"])
print(f"Turn={payload['turn']} Winner={payload['winner']}\n")
else:
print(f"[{self.label}] UNKNOWN OPCODE {op}: {payload}")
except websockets.exceptions.ConnectionClosedOK: except websockets.exceptions.ConnectionClosedOK:
print(f"[{self.label}] WebSocket closed gracefully") print(f"[{self.label}] WebSocket closed gracefully")
except websockets.exceptions.ConnectionClosedError as e: except websockets.exceptions.ConnectionClosedError as e:
@@ -85,8 +75,35 @@ class PlayerWebSocketHandler(object):
if self.ws: if self.ws:
self.listener_task = asyncio.create_task(self.listener()) self.listener_task = asyncio.create_task(self.listener())
# ---------- Match Helpers ---------- # ---------- Cleanup ----------
async def close(self):
if self.listener_task:
self.listener_task.cancel()
if self.ws:
await self.ws.close()
print(f"[{self.label}] Connection closed")
class PlayerWebSocketHandler(WebSocketHandler):
async def on_message(self, msg):
if "match_data" not in msg:
print(f"[{self.label}] {msg}")
return
md = msg["match_data"]
op = int(md.get("op_code", 0))
payload = json.loads(base64.b64decode(md["data"]).decode())
if op == 1:
print(f"[{self.label}] MOVE -> {payload}")
elif op == 2:
print(f"\n[{self.label}] GAME STATE")
print_board(payload["board"])
print(f"Turn={payload['turn']} Winner={payload['winner']}\n")
else:
print(f"[{self.label}] UNKNOWN OPCODE {op}: {payload}")
# ---------- Match Helpers ----------
async def create_match(self) -> str: async def create_match(self) -> str:
await self.ws.send(json.dumps({"match_create": {}})) await self.ws.send(json.dumps({"match_create": {}}))
msg = json.loads(await self.ws.recv()) msg = json.loads(await self.ws.recv())
@@ -100,7 +117,6 @@ class PlayerWebSocketHandler(object):
print(f"[{self.label}] Joined match: {match_id}") print(f"[{self.label}] Joined match: {match_id}")
# ---------- Gameplay ---------- # ---------- Gameplay ----------
async def send_move(self, match_id: str, row: int, col: int): async def send_move(self, match_id: str, row: int, col: int):
payload = {"row": row, "col": col} payload = {"row": row, "col": col}
encoded = base64.b64encode(json.dumps(payload).encode()).decode() encoded = base64.b64encode(json.dumps(payload).encode()).decode()
@@ -114,15 +130,6 @@ class PlayerWebSocketHandler(object):
})) }))
print(f"[{self.label}] Sent move: {payload}") print(f"[{self.label}] Sent move: {payload}")
# ---------- Cleanup ----------
async def close(self):
if self.listener_task:
self.listener_task.cancel()
if self.ws:
await self.ws.close()
print(f"[{self.label}] Connection closed")
async def happy_path( async def happy_path(
match_id: str, match_id: str,