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
This commit is contained in:
211
game_flow.py
211
game_flow.py
@@ -18,140 +18,145 @@ def print_board(board):
|
|||||||
print("\n" + separator.join(rows) + "\n")
|
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:
|
# ---------- Auth & Connect ----------
|
||||||
"""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"]
|
|
||||||
|
|
||||||
|
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={self.token}"
|
||||||
url = f"{WS}/ws?token={token}"
|
self.ws = await websockets.connect(url)
|
||||||
ws = await websockets.connect(url)
|
|
||||||
return ws
|
|
||||||
|
|
||||||
|
# ---------- Listener ----------
|
||||||
|
|
||||||
async def listener(ws, label: str):
|
async def listener(self):
|
||||||
"""Log all messages for a given socket."""
|
"""Continuously receive events + decode match data."""
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
raw = await ws.recv()
|
raw = await self.ws.recv()
|
||||||
data = json.loads(raw)
|
msg = 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
|
|
||||||
|
|
||||||
def parse_match_data(md, label: str):
|
if "match_data" not in msg:
|
||||||
op = int(md.get("op_code", 0))
|
print(f"[{self.label}] {msg}")
|
||||||
|
continue
|
||||||
|
|
||||||
# decode base64 payload
|
md = msg["match_data"]
|
||||||
payload_json = base64.b64decode(md["data"]).decode()
|
op = int(md.get("op_code", 0))
|
||||||
payload = json.loads(payload_json)
|
payload = json.loads(base64.b64decode(md["data"]).decode())
|
||||||
|
|
||||||
# OpMove → just log move
|
if op == 1:
|
||||||
if op == 1:
|
print(f"[{self.label}] MOVE -> {payload}")
|
||||||
print(f"[{label}] MOVE -> {payload}")
|
elif op == 2:
|
||||||
return
|
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
|
except websockets.exceptions.ConnectionClosedOK:
|
||||||
if op == 2:
|
print(f"[{self.label}] WebSocket closed gracefully")
|
||||||
print(f"\n[{label}] GAME STATE")
|
except websockets.exceptions.ConnectionClosedError as e:
|
||||||
print_board(payload["board"])
|
print(f"[{self.label}] WebSocket closed with error: {e}")
|
||||||
print(f"Turn={payload['turn']} Winner={payload['winner']}\n")
|
|
||||||
return
|
|
||||||
|
|
||||||
# other opcodes
|
def start_listener(self):
|
||||||
raise RuntimeError(f"[{label}] UNKNOWN OPCODE {op}: {payload}")
|
"""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):
|
async def create_match(self) -> str:
|
||||||
"""Send a TicTacToe move using OpMove = 1 with base64-encoded data."""
|
await self.ws.send(json.dumps({"match_create": {}}))
|
||||||
payload = {"row": row, "col": col}
|
msg = json.loads(await self.ws.recv())
|
||||||
# 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")
|
|
||||||
|
|
||||||
msg = {
|
match_id = msg["match"]["match_id"]
|
||||||
"match_data_send": {
|
print(f"[{self.label}] Created match: {match_id}")
|
||||||
"match_id": match_id,
|
return match_id
|
||||||
"op_code": 1, # OpMove
|
|
||||||
"data": data_b64,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await ws.send(json.dumps(msg))
|
|
||||||
|
|
||||||
|
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():
|
async def main():
|
||||||
# 1) Login 2 players
|
# Create players
|
||||||
token1 = login("player_one_123456")
|
p1 = PlayerWebSocketHandler("player_one_123456", "P1")
|
||||||
token2 = login("player_two_123456")
|
p2 = PlayerWebSocketHandler("player_two_123456", "P2")
|
||||||
|
|
||||||
# 2) Connect sockets
|
# Connect
|
||||||
ws1 = await connect(token1)
|
await p1.connect()
|
||||||
ws2 = await connect(token2)
|
await p2.connect()
|
||||||
|
|
||||||
# 3) Create a match from P1
|
# Match create + join
|
||||||
await ws1.send(json.dumps({"match_create": {}}))
|
match_id = await p1.create_match()
|
||||||
raw = await ws1.recv()
|
await p2.join_match(match_id)
|
||||||
msg = json.loads(raw)
|
|
||||||
match_id = msg["match"]["match_id"]
|
|
||||||
print("Match:", match_id)
|
|
||||||
|
|
||||||
# 4) Only P2 explicitly joins (creator is auto-joined)
|
# Start listeners
|
||||||
await ws2.send(json.dumps({"match_join": {"match_id": match_id}}))
|
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)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
# 6) Play a quick winning game for P1 (X)
|
# Play moves
|
||||||
# P1: (0,0)
|
await p1.send_move(match_id, 0, 0)
|
||||||
await send_move(ws1, match_id, 0, 0)
|
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
|
await p2.send_move(match_id, 1, 1)
|
||||||
# P2: (1,1)
|
|
||||||
await send_move(ws2, match_id, 1, 1)
|
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
|
await p1.send_move(match_id, 0, 1)
|
||||||
# P1: (0,1)
|
|
||||||
await send_move(ws1, match_id, 0, 1)
|
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
|
await p2.send_move(match_id, 2, 2)
|
||||||
# P2: (2,2)
|
|
||||||
await send_move(ws2, match_id, 2, 2)
|
|
||||||
await asyncio.sleep(0.3)
|
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 asyncio.sleep(2)
|
||||||
|
|
||||||
await ws1.close()
|
await p1.close()
|
||||||
await ws2.close()
|
await p2.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user