From 09d3d8684c76fd8f3a373aa9a3431744e889e120 Mon Sep 17 00:00:00 2001 From: Paul Brinkmeier Date: Thu, 19 Jan 2023 02:03:44 +0100 Subject: [PATCH] Rewrite server using quart instead of flask --- glebby-server/glebby.py | 171 ------------------------------- glebby-server/glebby/__init__.py | 36 +++++++ glebby-server/glebby/board.py | 28 +++++ glebby-server/glebby/model.py | 86 ++++++++++++++++ glebby-server/requirements.txt | 14 ++- glebby-server/run.py | 4 + 6 files changed, 164 insertions(+), 175 deletions(-) delete mode 100644 glebby-server/glebby.py create mode 100644 glebby-server/glebby/__init__.py create mode 100644 glebby-server/glebby/board.py create mode 100644 glebby-server/glebby/model.py create mode 100644 glebby-server/run.py diff --git a/glebby-server/glebby.py b/glebby-server/glebby.py deleted file mode 100644 index b9562d6..0000000 --- a/glebby-server/glebby.py +++ /dev/null @@ -1,171 +0,0 @@ -import json -import simple_websocket - -from collections import OrderedDict -from flask import Flask -from flask_sock import Sock -from queue import Queue -from random import Random -from threading import Thread, Lock - -class BoardGenerator: - def __init__(self, seed): - self.dice = [ - 'aeaneg', - 'wngeeh', - 'ahspco', - 'lnhnrz', - 'aspffk', - 'tstiyd', - 'objoab', - 'owtoat', - 'iotmuc', - 'erttyl', - 'ryvdel', - 'toessi', - 'lreixd', - 'terwhv', - 'eiunes', - 'nuihmq' - ] - self.rng = Random(seed) - - def generate_board(self): - shuffled_dice = self.rng.sample(self.dice, k=len(self.dice)) - roll_results = [self.rng.choice(die) for die in shuffled_dice] - return [roll_results[4 * i : 4 * (i+1)] for i in range(0, 4)] - -class Client: - def __init__(self, sock, client_id): - self.sock = sock - self.data = { 'id': client_id, 'name': '' } - -class GlebbyState: - def __init__(self): - self.clients_lock = Lock() - # We want to preserve the order that clients arrived in, - # e.g. for whose turn it is - self.clients = OrderedDict() - - self.next_client_id_lock = Lock() - self.next_client_id = 0 - - self.incoming_messages = Queue() - - self.board_generator = BoardGenerator(42) - self.board = None - - # domain stuff - - def handle_message_from(self, client_id, payload): - print(f"{client_id}: {payload}") - - if payload['type'] == 'set-name': - self.clients[client_id].data['name'] = payload['name'] - self.broadcast(client_id, { - 'type': 'set-name', - 'name': payload['name'] - }) - elif payload['type'] == 'chat': - self.broadcast(client_id, { - 'type': 'chat', - 'message': payload['message'] - }) - elif payload['type'] == 'roll': - self.board = self.board_generator.generate_board() - self.broadcast(client_id, { - 'type': 'roll', - 'board': self.board - }) - else: - print("Unhandled!") - - def get_state_dto(self, client_id): - return { - 'yourId': client_id, - 'players': [ - self.clients[other_client_id].data - for other_client_id in self.clients - ], - 'board': self.board - } - - # message receiving and sending - - def put_incoming_message(self, client_id, payload): - self.incoming_messages.put({ - 'from': client_id, - 'payload': json.loads(payload) - }) - - def send_to(self, client_id_from, client_id_to, payload): - self.clients[client_id_to].sock.send(json.dumps({ - 'from': client_id_from, - 'payload': payload - })) - - def broadcast(self, client_id_from, payload): - with self.clients_lock: - for client_id_to in self.clients: - self.send_to(client_id_from, client_id_to, payload) - - # client management - - def _get_next_client_id(self): - with self.next_client_id_lock: - client_id = self.next_client_id - self.next_client_id += 1 - return client_id - - # TODO: Instead of using clients_lock, synchronize clients throught incoming_messages - # Make add_client and remove_client simply push events into the queue - def add_client(self, sock): - client_id = self._get_next_client_id() - self.broadcast(client_id, {'type': 'join'}) - with self.clients_lock: - self.clients[client_id] = Client(sock, client_id) - self.send_to(None, client_id, { - 'type': 'init', - 'state': self.get_state_dto(client_id) - }) - return client_id - - def remove_client(self, client_id): - with self.clients_lock: - del self.clients[client_id] - self.broadcast(client_id, {'type': 'leave'}) - - # event thread stuff - - def start_event_thread(self): - event_thread = Thread(target=lambda: self._event_thread()) - event_thread.daemon = True - event_thread.start() - - def _event_thread(self): - while True: - msg = self.incoming_messages.get() - self.handle_message_from(msg['from'], msg['payload']) - -# Initialization - -state = GlebbyState() -state.start_event_thread() - -# TODO: Examine Quart (basically an asyncio version of flask) -app = Flask(__name__, static_url_path='') -sock = Sock(app) - -@app.route('/') -def index(): - return app.send_static_file('index.html') - -@sock.route('/glebby') -def echo(sock): - client_id = state.add_client(sock) - try: - while True: - data = sock.receive() - state.put_incoming_message(client_id, data) - except simple_websocket.ConnectionClosed: - state.remove_client(client_id) diff --git a/glebby-server/glebby/__init__.py b/glebby-server/glebby/__init__.py new file mode 100644 index 0000000..da6c5a4 --- /dev/null +++ b/glebby-server/glebby/__init__.py @@ -0,0 +1,36 @@ +import asyncio + +from quart import Quart, websocket + +from glebby.model import Model + +model = Model() +app = Quart(__name__, static_url_path='') + +async def send_outgoing(queue): + while True: + data = await queue.get() + await websocket.send(data) + +@app.websocket('/glebby') +async def ws(): + global model + + # For each connected client, we create a queue to store its outgoing messages. + # We pass this queue to the model so that it can communicate with the clients. + outgoing_queue = asyncio.Queue() + client_id = await model.add_client(outgoing_queue) + + try: + # In order to get the queued messages through the websocket, we need to create a task + # since the current one will be busy receiving incoming messages below. + outgoing_task = asyncio.create_task(send_outgoing(outgoing_queue)) + + while True: + data = await websocket.receive() + await model.handle_incoming(client_id, data) + # When a client disconnects, we shut down the message pumping task and wait for it to finish + except asyncio.CancelledError: + await model.remove_client(client_id) + outgoing_task.cancel() + await outgoing_task diff --git a/glebby-server/glebby/board.py b/glebby-server/glebby/board.py new file mode 100644 index 0000000..8c259a1 --- /dev/null +++ b/glebby-server/glebby/board.py @@ -0,0 +1,28 @@ +from random import Random + +class BoardGenerator: + def __init__(self, seed): + self.dice = [ + 'aeaneg', + 'wngeeh', + 'ahspco', + 'lnhnrz', + 'aspffk', + 'tstiyd', + 'objoab', + 'owtoat', + 'iotmuc', + 'erttyl', + 'ryvdel', + 'toessi', + 'lreixd', + 'terwhv', + 'eiunes', + 'nuihmq' + ] + self.rng = Random(seed) + + def generate_board(self): + shuffled_dice = self.rng.sample(self.dice, k=len(self.dice)) + roll_results = [self.rng.choice(die) for die in shuffled_dice] + return [roll_results[4 * i : 4 * (i+1)] for i in range(0, 4)] diff --git a/glebby-server/glebby/model.py b/glebby-server/glebby/model.py new file mode 100644 index 0000000..42271ce --- /dev/null +++ b/glebby-server/glebby/model.py @@ -0,0 +1,86 @@ +import itertools +import json + +from collections import OrderedDict + +from glebby.board import BoardGenerator + +class Client: + def __init__(self, client_id, outgoing_queue): + self.outgoing_queue = outgoing_queue + self.data = { 'id': client_id, 'name': '' } + + async def put_outgoing_message(self, message): + await self.outgoing_queue.put(message) + +class Model: + def __init__(self): + self.clients = OrderedDict() + self.id_generator = itertools.count(start=0, step=1) + self.board_generator = BoardGenerator(None) + self.board = None + + async def add_client(self, outgoing_queue): + client_id = next(self.id_generator) + await self.broadcast(client_id, { 'type': 'join' }) + + self.clients[client_id] = Client(client_id, outgoing_queue) + print(f'<{client_id}|join>') + + await self.send_to(None, client_id, { + 'type': 'init', + 'state': self.get_state_dto(client_id) + }) + + return client_id + + async def remove_client(self, client_id): + del self.clients[client_id] + await self.broadcast(client_id, { 'type': 'leave' }) + + print(f'<{client_id}|leave>') + + async def send_to(self, client_id_from, client_id_to, payload): + await self.clients[client_id_to].put_outgoing_message(json.dumps({ + 'from': client_id_from, + 'payload': payload + })) + + async def broadcast(self, client_id_from, payload): + for client_id_to in self.clients: + await self.send_to(client_id_from, client_id_to, payload) + + async def handle_incoming(self, client_id, message): + print(f'<{client_id}|data> {message}') + payload = json.loads(message) + + if payload['type'] == 'set-name': + self.clients[client_id].data['name'] = payload['name'] + await self.broadcast(client_id, { + 'type': 'set-name', + 'name': payload['name'] + }) + elif payload['type'] == 'chat': + await self.broadcast(client_id, { + 'type': 'chat', + 'message': payload['message'] + }) + elif payload['type'] == 'roll': + self.board = self.board_generator.generate_board() + await self.broadcast(client_id, { + 'type': 'roll', + 'board': self.board + }) + else: + print("Unhandled!") + + def get_state_dto(self, client_id): + return { + 'yourId': client_id, + 'players': [ + client.data + for client in self.clients.values() + ], + 'board': self.board + } + diff --git a/glebby-server/requirements.txt b/glebby-server/requirements.txt index 9ca9feb..6d9ccb8 100644 --- a/glebby-server/requirements.txt +++ b/glebby-server/requirements.txt @@ -1,12 +1,18 @@ +aiofiles==22.1.0 +blinker==1.5 click==8.1.3 -Flask==2.2.2 -flask-sock==0.6.0 h11==0.14.0 +h2==4.1.0 +hpack==4.0.0 +hypercorn==0.14.3 +hyperframe==6.0.1 importlib-metadata==6.0.0 itsdangerous==2.1.2 Jinja2==3.1.2 -MarkupSafe==2.1.1 -simple-websocket==0.9.0 +MarkupSafe==2.1.2 +priority==2.0.0 +quart==0.18.3 +toml==0.10.2 Werkzeug==2.2.2 wsproto==1.2.0 zipp==3.11.0 diff --git a/glebby-server/run.py b/glebby-server/run.py new file mode 100644 index 0000000..c005d6d --- /dev/null +++ b/glebby-server/run.py @@ -0,0 +1,4 @@ +import glebby + +# For development use only +glebby.app.run()