Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bcdc76594 | |||
| 087616a67e | |||
| 10058710fb | |||
| eb35ccd180 | |||
| bd376123b3 | |||
| ea1a70b212 | |||
| 908eebefdd | |||
| 0fb448dd45 | |||
| fa4d4d00be | |||
| 22993d6d37 | |||
| de4bfb6c07 | |||
| d260c9a1ef | |||
| 1b4e7a5ee0 | |||
| cb23d3c516 | |||
| 37b20c6c36 |
@@ -1,4 +1,5 @@
|
|||||||
# ✅ Project Status Report — Multiplayer Tic-Tac-Toe Platform
|
# ✅ Project Status Report
|
||||||
|
## Multiplayer Tic-Tac-Toe Platform
|
||||||
|
|
||||||
**To:** CTO & Technical Interview Panel
|
**To:** CTO & Technical Interview Panel
|
||||||
|
|
||||||
|
|||||||
426
game_flow.py
426
game_flow.py
@@ -1,6 +1,8 @@
|
|||||||
|
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
|
||||||
@@ -18,140 +20,338 @@ def print_board(board):
|
|||||||
print("\n" + separator.join(rows) + "\n")
|
print("\n" + separator.join(rows) + "\n")
|
||||||
|
|
||||||
|
|
||||||
# ---------- Auth ----------
|
class WebSocketHandler(object):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
custom_id: str,
|
||||||
|
label: str,
|
||||||
|
):
|
||||||
|
self.custom_id = custom_id
|
||||||
|
self.label = label
|
||||||
|
|
||||||
def login(custom_id: str) -> str:
|
self.token = None
|
||||||
"""Authenticate via custom ID and return Nakama session token (JWT)."""
|
self.ws = None
|
||||||
basic = base64.b64encode(f"{SERVER_KEY}:".encode()).decode()
|
self.listener_task = None
|
||||||
r = requests.post(
|
|
||||||
f"{HOST}/v2/account/authenticate/custom?create=true",
|
async def on_message(self, msg: dict):
|
||||||
headers={"Authorization": f"Basic {basic}"},
|
raise NotImplementedError("Override me!")
|
||||||
json={"id": custom_id},
|
|
||||||
)
|
# ---------- Auth & Connect ----------
|
||||||
r.raise_for_status()
|
def login(self):
|
||||||
body = r.json()
|
"""Authenticate via custom ID and store token."""
|
||||||
return body["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
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
"""Open Nakama WebSocket using stored auth token."""
|
||||||
|
if not self.token:
|
||||||
|
self.login()
|
||||||
|
|
||||||
|
url = f"{WS}/ws?token={self.token}"
|
||||||
|
self.ws = await websockets.connect(url)
|
||||||
|
|
||||||
|
# ---------- Listener ----------
|
||||||
|
async def listener(self):
|
||||||
|
"""Continuously receive events + decode match data."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
raw = await self.ws.recv()
|
||||||
|
msg = json.loads(raw)
|
||||||
|
await self.on_message(msg) # ✅ handoff
|
||||||
|
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}")
|
||||||
|
|
||||||
|
def start_listener(self):
|
||||||
|
"""Spawn background task for async message stream."""
|
||||||
|
if self.ws:
|
||||||
|
self.listener_task = asyncio.create_task(self.listener())
|
||||||
|
|
||||||
|
# ---------- 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")
|
||||||
|
|
||||||
|
|
||||||
# ---------- WebSocket helpers ----------
|
class PlayerWebSocketHandler(WebSocketHandler):
|
||||||
|
@classmethod
|
||||||
|
async def setup_player(
|
||||||
|
cls,
|
||||||
|
name: str
|
||||||
|
) -> 'PlayerWebSocketHandler':
|
||||||
|
"""Create WS handler, login, connect, start listener."""
|
||||||
|
handler = cls(name, name)
|
||||||
|
handler.login()
|
||||||
|
await handler.connect()
|
||||||
|
return handler
|
||||||
|
|
||||||
async def connect(token: str) -> websockets.ClientConnection:
|
def __init__(self, custom_id: str, label: str):
|
||||||
url = f"{WS}/ws?token={token}"
|
super().__init__(custom_id, label)
|
||||||
ws = await websockets.connect(url)
|
self.ticket = None
|
||||||
return ws
|
self.match_id = None
|
||||||
|
|
||||||
|
async def on_message(self, msg):
|
||||||
|
if "matchmaker_matched" in msg:
|
||||||
|
match_id = msg["matchmaker_matched"]["match_id"]
|
||||||
|
print(f"[{self.label}] ✅ Match found: {match_id}")
|
||||||
|
await self.join_match(match_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if "matchmaker_ticket" in msg:
|
||||||
|
self.ticket = msg["matchmaker_ticket"]["ticket"]
|
||||||
|
print(f"[{self.label}] ✅ Received ticket: {self.ticket}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if "match_data" not in msg:
|
||||||
|
print(f"[{self.label}] {msg}")
|
||||||
|
return
|
||||||
|
|
||||||
|
md = msg["match_data"]
|
||||||
|
op = int(md.get("op_code", 0))
|
||||||
|
data = md.get("data")
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
payload = json.loads(base64.b64decode(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 join_matchmaking(self, mode: str = "classic"):
|
||||||
|
"""Queue into Nakama matchmaker."""
|
||||||
|
await self.ws.send(json.dumps({
|
||||||
|
"matchmaker_add": {
|
||||||
|
"min_count": 2,
|
||||||
|
"max_count": 2,
|
||||||
|
"string_properties": {"mode": mode}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
print(f"[{self.label}] Searching match for mode={mode}...")
|
||||||
|
|
||||||
|
async def leave_matchmaking(self, ticket: str):
|
||||||
|
"""Remove player from Nakama matchmaking queue."""
|
||||||
|
await self.ws.send(json.dumps({
|
||||||
|
"matchmaker_remove": {
|
||||||
|
"ticket": ticket
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
print(f"[{self.label}] Left matchmaking queue")
|
||||||
|
|
||||||
|
async def create_match(self) -> str:
|
||||||
|
await self.ws.send(json.dumps({"match_create": {}}))
|
||||||
|
msg = json.loads(await self.ws.recv())
|
||||||
|
|
||||||
|
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}")
|
||||||
|
self.match_id = 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}")
|
||||||
|
|
||||||
|
|
||||||
async def listener(ws, label: str):
|
async def happy_path(
|
||||||
"""Log all messages for a given socket."""
|
match_id: str,
|
||||||
try:
|
p1: PlayerWebSocketHandler,
|
||||||
while True:
|
p2: PlayerWebSocketHandler
|
||||||
raw = await ws.recv()
|
):
|
||||||
data = json.loads(raw)
|
# Play moves
|
||||||
if "match_data" not in data:
|
await p1.send_move(match_id, 0, 0)
|
||||||
print(f"[{label}] {data}")
|
await asyncio.sleep(0.3)
|
||||||
continue
|
await p2.send_move(match_id, 1, 1)
|
||||||
md = data["match_data"]
|
await asyncio.sleep(0.3)
|
||||||
parse_match_data(md, label)
|
await p1.send_move(match_id, 0, 1)
|
||||||
except websockets.exceptions.ConnectionClosedOK:
|
await asyncio.sleep(0.3)
|
||||||
print(f"[{label}] WebSocket closed cleanly")
|
await p2.send_move(match_id, 2, 2)
|
||||||
return
|
await asyncio.sleep(0.3)
|
||||||
except websockets.exceptions.ConnectionClosedError as e:
|
await p1.send_move(match_id, 0, 2)
|
||||||
print(f"[{label}] WebSocket closed with error: {e}")
|
|
||||||
return
|
|
||||||
|
|
||||||
def parse_match_data(md, label: str):
|
|
||||||
op = int(md.get("op_code", 0))
|
|
||||||
|
|
||||||
# decode base64 payload
|
|
||||||
payload_json = base64.b64decode(md["data"]).decode()
|
|
||||||
payload = json.loads(payload_json)
|
|
||||||
|
|
||||||
# OpMove → just log move
|
|
||||||
if op == 1:
|
|
||||||
print(f"[{label}] MOVE -> {payload}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# other opcodes
|
|
||||||
raise RuntimeError(f"[{label}] UNKNOWN OPCODE {op}: {payload}")
|
|
||||||
|
|
||||||
|
|
||||||
async def send_move(ws, match_id: str, row: int, col: int):
|
async def p2_wins_diagonal(
|
||||||
"""Send a TicTacToe move using OpMove = 1 with base64-encoded data."""
|
match_id: str,
|
||||||
payload = {"row": row, "col": col}
|
p1: PlayerWebSocketHandler,
|
||||||
# Nakama expects `data` as bytes -> base64 string in JSON
|
p2: PlayerWebSocketHandler
|
||||||
data_bytes = json.dumps(payload).encode("utf-8")
|
):
|
||||||
data_b64 = base64.b64encode(data_bytes).decode("ascii")
|
await p1.send_move(match_id, 0, 0)
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
msg = {
|
await p2.send_move(match_id, 0, 2)
|
||||||
"match_data_send": {
|
await asyncio.sleep(0.3)
|
||||||
"match_id": match_id,
|
await p1.send_move(match_id, 1, 0)
|
||||||
"op_code": 1, # OpMove
|
await asyncio.sleep(0.3)
|
||||||
"data": data_b64,
|
await p2.send_move(match_id, 1, 1)
|
||||||
}
|
await asyncio.sleep(0.3)
|
||||||
}
|
await p1.send_move(match_id, 2, 1)
|
||||||
await ws.send(json.dumps(msg))
|
await asyncio.sleep(0.3)
|
||||||
|
await p2.send_move(match_id, 2, 0) # P2 wins
|
||||||
|
|
||||||
|
|
||||||
# ---------- Main flow ----------
|
async def draw_game(
|
||||||
|
match_id: str,
|
||||||
|
p1: PlayerWebSocketHandler,
|
||||||
|
p2: PlayerWebSocketHandler
|
||||||
|
):
|
||||||
|
moves = [
|
||||||
|
(p1, 0, 0), (p2, 0, 1),
|
||||||
|
(p1, 0, 2), (p2, 1, 0),
|
||||||
|
(p1, 1, 2), (p2, 1, 1),
|
||||||
|
(p1, 2, 1), (p2, 2, 2),
|
||||||
|
(p1, 2, 0),
|
||||||
|
]
|
||||||
|
for player, r, c in moves:
|
||||||
|
await player.send_move(match_id, r, c)
|
||||||
|
await asyncio.sleep(0.25)
|
||||||
|
|
||||||
|
|
||||||
|
async def illegal_occupied_cell(
|
||||||
|
match_id: str,
|
||||||
|
p1: PlayerWebSocketHandler,
|
||||||
|
p2: PlayerWebSocketHandler
|
||||||
|
):
|
||||||
|
await p1.send_move(match_id, 0, 0)
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
# P2 tries same spot → should trigger server rejection
|
||||||
|
await p2.send_move(match_id, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
async def illegal_out_of_turn(
|
||||||
|
match_id: str,
|
||||||
|
p1: PlayerWebSocketHandler,
|
||||||
|
p2: PlayerWebSocketHandler
|
||||||
|
):
|
||||||
|
await p1.send_move(match_id, 2, 2)
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
# P1 tries again before P2
|
||||||
|
await p1.send_move(match_id, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
async def illegal_out_of_bounds(
|
||||||
|
match_id: str,
|
||||||
|
p1: PlayerWebSocketHandler,
|
||||||
|
p2: PlayerWebSocketHandler
|
||||||
|
):
|
||||||
|
await p1.send_move(match_id, 3, 3) # Invalid indices
|
||||||
|
|
||||||
|
|
||||||
|
async def midgame_disconnect(
|
||||||
|
match_id: str,
|
||||||
|
p1: PlayerWebSocketHandler,
|
||||||
|
p2: PlayerWebSocketHandler
|
||||||
|
):
|
||||||
|
await p1.send_move(match_id, 0, 0)
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
await p2.close() # simulate rage quit
|
||||||
|
|
||||||
|
|
||||||
|
async def abandoned_lobby(
|
||||||
|
match_id: str,
|
||||||
|
p1: PlayerWebSocketHandler,
|
||||||
|
p2: PlayerWebSocketHandler
|
||||||
|
):
|
||||||
|
# No p2.join_match
|
||||||
|
await asyncio.sleep(5) # test timeout, cleanup, match state
|
||||||
|
|
||||||
|
|
||||||
|
async def spam_moves(
|
||||||
|
match_id: str,
|
||||||
|
p1: PlayerWebSocketHandler,
|
||||||
|
p2: PlayerWebSocketHandler
|
||||||
|
):
|
||||||
|
for _ in range(10):
|
||||||
|
await p1.send_move(match_id, 0, 0)
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
|
||||||
|
async def random_game(
|
||||||
|
match_id: str,
|
||||||
|
p1: PlayerWebSocketHandler,
|
||||||
|
p2: PlayerWebSocketHandler
|
||||||
|
):
|
||||||
|
board = {(r, c) for r in range(3) for c in range(3)}
|
||||||
|
players = [p1, p2]
|
||||||
|
|
||||||
|
for i in range(9):
|
||||||
|
player = players[i % 2]
|
||||||
|
print(f"[{player.label}] Playing move...")
|
||||||
|
r, c = random.choice(list(board))
|
||||||
|
board.remove((r, c))
|
||||||
|
await player.send_move(match_id, r, c)
|
||||||
|
await asyncio.sleep(0.25)
|
||||||
|
|
||||||
|
|
||||||
|
TEST_SCENARIOS = [
|
||||||
|
happy_path,
|
||||||
|
p2_wins_diagonal,
|
||||||
|
draw_game,
|
||||||
|
illegal_occupied_cell,
|
||||||
|
illegal_out_of_turn,
|
||||||
|
illegal_out_of_bounds,
|
||||||
|
midgame_disconnect,
|
||||||
|
abandoned_lobby,
|
||||||
|
spam_moves,
|
||||||
|
random_game,
|
||||||
|
]
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
# 1) Login 2 players
|
# Initialize players (login + connect + start listener)
|
||||||
token1 = login("player_one_123456")
|
p1 = await PlayerWebSocketHandler.setup_player("player_one_123456")
|
||||||
token2 = login("player_two_123456")
|
p2 = await PlayerWebSocketHandler.setup_player("player_two_123456")
|
||||||
|
|
||||||
# 2) Connect sockets
|
# Start listeners
|
||||||
ws1 = await connect(token1)
|
p1.start_listener()
|
||||||
ws2 = await connect(token2)
|
p2.start_listener()
|
||||||
|
|
||||||
# 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)
|
print(f"\n✅ Match ready: {match_id}\n")
|
||||||
await ws2.send(json.dumps({"match_join": {"match_id": match_id}}))
|
|
||||||
|
|
||||||
# 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)
|
for test_scenario in TEST_SCENARIOS:
|
||||||
# P1: (0,0)
|
print(f"\n🚀 Running '{test_scenario.__name__}'...\n")
|
||||||
await send_move(ws1, match_id, 0, 0)
|
await test_scenario(match_id, p1, p2)
|
||||||
await asyncio.sleep(0.3)
|
|
||||||
|
|
||||||
# P2: (1,1)
|
await asyncio.sleep(1.0)
|
||||||
await send_move(ws2, match_id, 1, 1)
|
|
||||||
await asyncio.sleep(0.3)
|
|
||||||
|
|
||||||
# P1: (0,1)
|
print("\n✅ All scenarios executed.\n")
|
||||||
await send_move(ws1, match_id, 0, 1)
|
|
||||||
await asyncio.sleep(0.3)
|
|
||||||
|
|
||||||
# P2: (2,2)
|
await p1.close()
|
||||||
await send_move(ws2, match_id, 2, 2)
|
await p2.close()
|
||||||
await asyncio.sleep(0.3)
|
|
||||||
|
|
||||||
# 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()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
101
match_making_flow.py
Normal file
101
match_making_flow.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
from game_flow import PlayerWebSocketHandler, TEST_SCENARIOS
|
||||||
|
|
||||||
|
|
||||||
|
async def simulate_matchmaking(num_players: int = 6):
|
||||||
|
print(f"\n🎮 Spawning {num_players} players...\n")
|
||||||
|
|
||||||
|
# 1) Login + connect
|
||||||
|
players = await asyncio.gather(*[
|
||||||
|
PlayerWebSocketHandler.setup_player(f"player_{i}")
|
||||||
|
for i in range(num_players)
|
||||||
|
])
|
||||||
|
|
||||||
|
print("\n✅ All players authenticated + connected\n")
|
||||||
|
|
||||||
|
# 2) Start listeners BEFORE matchmaking
|
||||||
|
for p in players:
|
||||||
|
p.start_listener()
|
||||||
|
|
||||||
|
print("\n👂 WebSocket listeners active\n")
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
# ✅ 3) Split evenly between classic & blitz
|
||||||
|
half = num_players // 2
|
||||||
|
assignments = ["classic"] * half + ["blitz"] * (num_players - half)
|
||||||
|
random.shuffle(assignments)
|
||||||
|
|
||||||
|
print("\n🎯 Queuing players:")
|
||||||
|
for p, mode in zip(players, assignments):
|
||||||
|
print(f" - {p.label} -> {mode}")
|
||||||
|
|
||||||
|
await asyncio.gather(*[
|
||||||
|
p.join_matchmaking(mode)
|
||||||
|
for p, mode in zip(players, assignments)
|
||||||
|
])
|
||||||
|
|
||||||
|
print("\n✅ All players queued — waiting for matches...\n")
|
||||||
|
|
||||||
|
# ✅ 4) Collect matches
|
||||||
|
matches = {}
|
||||||
|
timeout = 15
|
||||||
|
start = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
|
matches = dict()
|
||||||
|
while asyncio.get_event_loop().time() - start < timeout:
|
||||||
|
for p in players:
|
||||||
|
# print(f'player = {p.label} for match_id = {p.match_id}')
|
||||||
|
# print(f'players = {len(matches.get(p.match_id, list()))} for match = {p.match_id}')
|
||||||
|
if p.match_id:
|
||||||
|
# matches.setdefault(p.match_id, []).append(p)
|
||||||
|
if p.match_id not in matches:
|
||||||
|
matches[p.match_id] = [p]
|
||||||
|
elif p not in matches[p.match_id]:
|
||||||
|
matches[p.match_id].append(p)
|
||||||
|
# print(f'player = {p.label} for match = {p.match_id}')
|
||||||
|
print(f'players = {len(matches[p.match_id])} for match = {p.match_id}')
|
||||||
|
|
||||||
|
# stop early if all assigned
|
||||||
|
if sum(len(v) for v in matches.values()) >= num_players:
|
||||||
|
break
|
||||||
|
|
||||||
|
await asyncio.sleep(0.25)
|
||||||
|
|
||||||
|
print(f"\n✅ Matchmaking complete — {len(matches)} matches formed\n")
|
||||||
|
|
||||||
|
# ✅ 5) Assign random scenarios per match & run
|
||||||
|
tasks = []
|
||||||
|
|
||||||
|
for match_id, grouped_players in matches.items():
|
||||||
|
if len(grouped_players) != 2:
|
||||||
|
print(f"⚠️ Skipping match {match_id} — not 1v1")
|
||||||
|
continue
|
||||||
|
|
||||||
|
p1, p2 = grouped_players
|
||||||
|
scenario = random.choice(TEST_SCENARIOS)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"🎭 Running scenario '{scenario.__name__}' — "
|
||||||
|
f"{p1.label} vs {p2.label} | match={match_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks.append(asyncio.create_task(
|
||||||
|
scenario(match_id, p1, p2)
|
||||||
|
))
|
||||||
|
|
||||||
|
# ✅ 6) Wait for all mock games to finish
|
||||||
|
if tasks:
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
else:
|
||||||
|
print("⚠️ No playable matches found")
|
||||||
|
|
||||||
|
# ✅ 7) Cleanup connections
|
||||||
|
print("\n🧹 Closing player connections...\n")
|
||||||
|
await asyncio.gather(*[p.close() for p in players])
|
||||||
|
|
||||||
|
print("\n🏁 Matchmaking test run complete ✅\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(simulate_matchmaking(6))
|
||||||
@@ -7,12 +7,13 @@ import (
|
|||||||
"github.com/heroiclabs/nakama-common/runtime"
|
"github.com/heroiclabs/nakama-common/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Example RPC
|
||||||
func HelloWorld(
|
func HelloWorld(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
logger runtime.Logger,
|
logger runtime.Logger,
|
||||||
db *sql.DB,
|
db *sql.DB,
|
||||||
nk runtime.NakamaModule,
|
nk runtime.NakamaModule,
|
||||||
payload string,
|
payload string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
logger.Info("HelloWorld RPC called — payload: %s", payload)
|
logger.Info("HelloWorld RPC called — payload: %s", payload)
|
||||||
return `{"message": "Hello from Go RPC!"}`, nil
|
return `{"message": "Hello from Go RPC!"}`, nil
|
||||||
@@ -34,6 +35,15 @@ func InitModule(
|
|||||||
logger.Error("Failed to register RPC: %v", err)
|
logger.Error("Failed to register RPC: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Match making
|
||||||
|
if err := initializer.RegisterRpc("leave_matchmaking", rpcLeaveMatchmaking); err != nil {
|
||||||
|
logger.Error("RegisterRpc leave_matchmaking failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := initializer.RegisterMatchmakerMatched(MatchmakerMatched); err != nil {
|
||||||
|
logger.Error("RegisterMatchmakerMatched failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
logger.Info("Go module loaded successfully!")
|
logger.Info("Go module loaded successfully!")
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
85
plugins/matchmaking.go
Normal file
85
plugins/matchmaking.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/heroiclabs/nakama-common/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MatchmakingTicket struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchmakerMatched is triggered automatically when enough players form a match.
|
||||||
|
func MatchmakerMatched(
|
||||||
|
ctx context.Context,
|
||||||
|
logger runtime.Logger,
|
||||||
|
db *sql.DB,
|
||||||
|
nk runtime.NakamaModule,
|
||||||
|
entries []runtime.MatchmakerEntry,
|
||||||
|
) (string, error) {
|
||||||
|
|
||||||
|
if len(entries) != 2 {
|
||||||
|
logger.Warn("MatchmakerMatched triggered with %d players", len(entries))
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
propsA := entries[0].GetProperties()
|
||||||
|
propsB := entries[1].GetProperties()
|
||||||
|
|
||||||
|
modeA, okA := propsA["mode"].(string)
|
||||||
|
modeB, okB := propsB["mode"].(string)
|
||||||
|
|
||||||
|
if !okA || !okB {
|
||||||
|
logger.Warn("MatchmakerMatched missing mode property — ignoring")
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ If modes don’t match, let Nakama find another pairing
|
||||||
|
if modeA != modeB {
|
||||||
|
logger.Warn("Mode mismatch %s vs %s — retrying matchmaking", modeA, modeB)
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Create authoritative match
|
||||||
|
matchParams := map[string]interface{}{
|
||||||
|
"mode": modeA,
|
||||||
|
}
|
||||||
|
|
||||||
|
matchID, err := nk.MatchCreate(ctx, "tictactoe", matchParams)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("MatchCreate failed: %v", err)
|
||||||
|
return "", runtime.NewError("failed to create match", 13)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("✅ Match created %s — mode=%s", matchID, modeA)
|
||||||
|
return matchID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPC to leave matchmaking queue
|
||||||
|
func rpcLeaveMatchmaking(
|
||||||
|
ctx context.Context,
|
||||||
|
logger runtime.Logger,
|
||||||
|
db *sql.DB,
|
||||||
|
nk runtime.NakamaModule,
|
||||||
|
payload string,
|
||||||
|
) (string, error) {
|
||||||
|
|
||||||
|
var input struct {
|
||||||
|
Ticket string `json:"ticket"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(payload), &input); err != nil {
|
||||||
|
return "", runtime.NewError("invalid JSON", 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.Ticket == "" {
|
||||||
|
return "", runtime.NewError("missing ticket", 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("✅ Matchmaking ticket removed: %s", input.Ticket)
|
||||||
|
return "{}", nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user