From cb23d3c516cee27d5a67f55e26f1cf04c1bd7de5 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Wed, 26 Nov 2025 14:34:19 +0530 Subject: [PATCH] feat: add PlayerWebSocket class abstraction for Nakama TicTacToe client - Introduce PlayerWebSocket class to encapsulate player behavior - Handle login, websocket connect, message listening, and cleanup - Add helpers: create_match, join_match, send_move - Remove global websocket/session handling - Update main() to use object-oriented player flow - Improves readability, scalability, and multiplayer orchestration --- game_flow.py | 211 ++++++++++++++++++++++++++------------------------- 1 file changed, 108 insertions(+), 103 deletions(-) diff --git a/game_flow.py b/game_flow.py index 7dd19b0..95c054d 100644 --- a/game_flow.py +++ b/game_flow.py @@ -18,140 +18,145 @@ def print_board(board): print("\n" + separator.join(rows) + "\n") -# ---------- Auth ---------- +class PlayerWebSocketHandler(object): + def __init__(self, custom_id: str, label: str): + self.custom_id = custom_id + self.label = label + self.token = None + self.ws = None + self.listener_task = None -def login(custom_id: str) -> str: - """Authenticate via custom ID and return Nakama session token (JWT).""" - basic = base64.b64encode(f"{SERVER_KEY}:".encode()).decode() - r = requests.post( - f"{HOST}/v2/account/authenticate/custom?create=true", - headers={"Authorization": f"Basic {basic}"}, - json={"id": custom_id}, - ) - r.raise_for_status() - body = r.json() - return body["token"] + # ---------- Auth & Connect ---------- + def login(self): + """Authenticate via custom ID and store token.""" + basic = base64.b64encode(f"{SERVER_KEY}:".encode()).decode() + r = requests.post( + f"{HOST}/v2/account/authenticate/custom?create=true", + headers={"Authorization": f"Basic {basic}"}, + json={"id": self.custom_id}, + ) + r.raise_for_status() + self.token = r.json()["token"] + return self.token -# ---------- WebSocket helpers ---------- + async def connect(self): + """Open Nakama WebSocket using stored auth token.""" + if not self.token: + self.login() -async def connect(token: str) -> websockets.ClientConnection: - url = f"{WS}/ws?token={token}" - ws = await websockets.connect(url) - return ws + url = f"{WS}/ws?token={self.token}" + self.ws = await websockets.connect(url) + # ---------- Listener ---------- -async def listener(ws, label: str): - """Log all messages for a given socket.""" - try: - while True: - raw = await ws.recv() - data = json.loads(raw) - if "match_data" not in data: - print(f"[{label}] {data}") - continue - md = data["match_data"] - parse_match_data(md, label) - except websockets.exceptions.ConnectionClosedOK: - print(f"[{label}] WebSocket closed cleanly") - return - except websockets.exceptions.ConnectionClosedError as e: - print(f"[{label}] WebSocket closed with error: {e}") - return + async def listener(self): + """Continuously receive events + decode match data.""" + try: + while True: + raw = await self.ws.recv() + msg = json.loads(raw) -def parse_match_data(md, label: str): - op = int(md.get("op_code", 0)) + if "match_data" not in msg: + print(f"[{self.label}] {msg}") + continue - # decode base64 payload - payload_json = base64.b64decode(md["data"]).decode() - payload = json.loads(payload_json) + md = msg["match_data"] + op = int(md.get("op_code", 0)) + payload = json.loads(base64.b64decode(md["data"]).decode()) - # OpMove → just log move - if op == 1: - print(f"[{label}] MOVE -> {payload}") - return + 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}") - # OpState → pretty-print board - if op == 2: - print(f"\n[{label}] GAME STATE") - print_board(payload["board"]) - print(f"Turn={payload['turn']} Winner={payload['winner']}\n") - return + except websockets.exceptions.ConnectionClosedOK: + print(f"[{self.label}] WebSocket closed gracefully") + except websockets.exceptions.ConnectionClosedError as e: + print(f"[{self.label}] WebSocket closed with error: {e}") - # other opcodes - raise RuntimeError(f"[{label}] UNKNOWN OPCODE {op}: {payload}") + def start_listener(self): + """Spawn background task for async message stream.""" + if self.ws: + self.listener_task = asyncio.create_task(self.listener()) + # ---------- Match Helpers ---------- -async def send_move(ws, match_id: str, row: int, col: int): - """Send a TicTacToe move using OpMove = 1 with base64-encoded data.""" - payload = {"row": row, "col": col} - # Nakama expects `data` as bytes -> base64 string in JSON - data_bytes = json.dumps(payload).encode("utf-8") - data_b64 = base64.b64encode(data_bytes).decode("ascii") + async def create_match(self) -> str: + await self.ws.send(json.dumps({"match_create": {}})) + msg = json.loads(await self.ws.recv()) - msg = { - "match_data_send": { - "match_id": match_id, - "op_code": 1, # OpMove - "data": data_b64, - } - } - await ws.send(json.dumps(msg)) + match_id = msg["match"]["match_id"] + print(f"[{self.label}] Created match: {match_id}") + return match_id + async def join_match(self, match_id: str): + await self.ws.send(json.dumps({"match_join": {"match_id": match_id}})) + print(f"[{self.label}] Joined match: {match_id}") + + # ---------- Gameplay ---------- + + async def send_move(self, match_id: str, row: int, col: int): + payload = {"row": row, "col": col} + encoded = base64.b64encode(json.dumps(payload).encode()).decode() + + await self.ws.send(json.dumps({ + "match_data_send": { + "match_id": match_id, + "op_code": 1, + "data": encoded, + } + })) + 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") -# ---------- Main flow ---------- async def main(): - # 1) Login 2 players - token1 = login("player_one_123456") - token2 = login("player_two_123456") + # Create players + p1 = PlayerWebSocketHandler("player_one_123456", "P1") + p2 = PlayerWebSocketHandler("player_two_123456", "P2") - # 2) Connect sockets - ws1 = await connect(token1) - ws2 = await connect(token2) + # Connect + await p1.connect() + await p2.connect() - # 3) Create a match from P1 - await ws1.send(json.dumps({"match_create": {}})) - raw = await ws1.recv() - msg = json.loads(raw) - match_id = msg["match"]["match_id"] - print("Match:", match_id) + # Match create + join + match_id = await p1.create_match() + await p2.join_match(match_id) - # 4) Only P2 explicitly joins (creator is auto-joined) - await ws2.send(json.dumps({"match_join": {"match_id": match_id}})) + # Start listeners + p1.start_listener() + p2.start_listener() - # 5) Start listeners - asyncio.create_task(listener(ws1, "P1")) - asyncio.create_task(listener(ws2, "P2")) - - # Give server time to process joins and initial state await asyncio.sleep(1) - # 6) Play a quick winning game for P1 (X) - # P1: (0,0) - await send_move(ws1, match_id, 0, 0) + # Play moves + await p1.send_move(match_id, 0, 0) await asyncio.sleep(0.3) - - # P2: (1,1) - await send_move(ws2, match_id, 1, 1) + await p2.send_move(match_id, 1, 1) await asyncio.sleep(0.3) - - # P1: (0,1) - await send_move(ws1, match_id, 0, 1) + await p1.send_move(match_id, 0, 1) await asyncio.sleep(0.3) - - # P2: (2,2) - await send_move(ws2, match_id, 2, 2) + await p2.send_move(match_id, 2, 2) await asyncio.sleep(0.3) + await p1.send_move(match_id, 0, 2) - # P1: (0,2) -> X wins by top row - await send_move(ws1, match_id, 0, 2) - - # Wait to receive the final state broadcast (OpState = 2) await asyncio.sleep(2) - await ws1.close() - await ws2.close() + await p1.close() + await p2.close() if __name__ == "__main__":