Compare commits

..
8 Commits
Author SHA1 Message Date
paul 772f7d5a9c Rearrange model code
continuous-integration/drone/push Build is passing
2023-01-21 22:49:14 +01:00
paul 2761172a7c Use fancy new match syntax
continuous-integration/drone/push Build is passing
2023-01-20 04:54:34 +01:00
paul 1c1ca14fe7 Add types
continuous-integration/drone/push Build is passing
2023-01-20 04:27:47 +01:00
paul 9986fb5faa Seperate connection management into its own module 2023-01-20 04:17:01 +01:00
paul a2a17e5ff9 Add type hints and mypy
continuous-integration/drone/push Build is passing
2023-01-19 03:42:25 +01:00
paul 9cd3803b61 Fix up dockerfile
continuous-integration/drone/push Build is passing
2023-01-19 02:43:34 +01:00
paul 09d3d8684c Rewrite server using quart instead of flask 2023-01-19 02:03:44 +01:00
paul d9eb2d7b05 Save player object for each chat message 2023-01-18 21:55:57 +01:00
12 changed files with 294 additions and 185 deletions
+14 -2
View File
@@ -1,11 +1,23 @@
--- ---
kind: pipeline kind: pipeline
type: docker type: docker
name: client name: frontend
steps: steps:
- name: build - name: build
image: node:19.4-alpine image: node:19.4-alpine
commands: commands:
- cd glebby-client - cd glebby-client
- npm install - npm install
- npm run build - VITE_GLEBBY_SERVER_URL=ws://localhost:5000/glebby npx vite build
---
kind: pipeline
type: docker
name: backend
steps:
- name: typecheck
image: python:3.11-alpine
commands:
- cd glebby-server
- pip install -r requirements-dev.txt
- mypy --strict glebby
+3 -3
View File
@@ -14,7 +14,7 @@ FROM python:3.11-alpine
COPY glebby-server /glebby-server COPY glebby-server /glebby-server
WORKDIR /glebby-server WORKDIR /glebby-server
RUN pip install -r requirements.txt RUN pip install -r requirements.txt
RUN pip install gunicorn RUN pip install hypercorn
COPY --from=frontend /glebby-client/static /glebby-server/static COPY --from=frontend /glebby-client/static /glebby-server/glebby/static
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--threads", "100", "glebby:app"] CMD ["hypercorn", "--bind", "0.0.0.0:5000", "glebby:app"]
+3 -1
View File
@@ -1,5 +1,7 @@
- [ ] composition api - [ ] composition api
- [ ] quart - [x] quart
- [x] fix dockerfile
- [ ] lobby - [ ] lobby
- [ ] actual game logic - [ ] actual game logic
- [ ] websocket url config - [ ] websocket url config
- [ ] use poetry?
+4 -4
View File
@@ -27,7 +27,7 @@ export default defineComponent({
}, },
data() { data() {
return { return {
chatMessages: [] as { from: number, message: string }[], chatMessages: [] as { from: Player, message: string }[],
playerName: '', playerName: '',
chatMessage: '' chatMessage: ''
@@ -55,7 +55,7 @@ export default defineComponent({
break break
case 'chat': case 'chat':
this.chatMessages.push({ this.chatMessages.push({
from: message.from, from: getPlayer(this.model, message.from)!,
message: payload.message message: payload.message
}) })
break break
@@ -127,8 +127,8 @@ export default defineComponent({
<h2>chat</h2> <h2>chat</h2>
<div class="chatbox-scroller"> <div class="chatbox-scroller">
<div class="chatbox-scroller-content"> <div class="chatbox-scroller-content">
<div v-for="message in chatMessages" class="chatbox-message"> <div v-for="chatMessage in chatMessages" class="chatbox-message">
<code v-if="model.players.get(message.from)">{{ model.players.get(message.from)!.name }}#{{ message.from }}:</code> {{ message.message }} <code>{{ chatMessage.from.name }}#{{ chatMessage.from.id }}:</code> {{ chatMessage.message }}
</div> </div>
</div> </div>
</div> </div>
-171
View File
@@ -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)
+25
View File
@@ -0,0 +1,25 @@
import asyncio
from quart import Quart, Response, websocket
from typing import NoReturn
from glebby.connection_manager import ConnectionManager
from glebby.model import Model
connection_manager = ConnectionManager()
model = Model(connection_manager)
# Files in ./static are served directly under / instead of under /static
app = Quart(__name__, static_url_path='')
# Root route should point to ./static/index.html
@app.route('/')
async def root() -> Response:
return await app.send_static_file('index.html')
# Every time a websocket connection to /glebby is opened, a new connection
# is set up in the connection manager. It will handle sending and receiving
# and keeping the model up to date.
@app.websocket('/glebby')
async def ws() -> NoReturn:
await connection_manager.setup_connection()
+32
View File
@@ -0,0 +1,32 @@
from random import Random
from typing import Optional
class BoardGenerator:
dice: list[str]
rng: Random
def __init__(self, seed: Optional[int]):
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) -> list[list[str]]:
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)]
@@ -0,0 +1,73 @@
import asyncio
import itertools
from collections import OrderedDict
from quart import websocket
from typing import Any, AsyncGenerator, Awaitable, Callable, Iterator, NoReturn, Optional
# Represents a single websocket connection
class Connection:
outgoing_queue: asyncio.Queue[str]
def __init__(self) -> None:
self.outgoing_queue = asyncio.Queue()
# Sending a message simply puts it in the outgoing queue...
async def send(self, data: str) -> None:
await self.outgoing_queue.put(data)
# ... and this infinite loop takes care of actually sending the outgoing
# messages through the websocket connection.
async def _send_loop(self) -> None:
while True:
data = await self.outgoing_queue.get()
await websocket.send(data)
class ConnectionManager:
# Provides a *unique* identifier for each connection
id_generator: Iterator[int]
connections: OrderedDict[int, Connection]
on_open: Optional[Callable[[int], Awaitable[Any]]]
on_message: Optional[Callable[[int, str], Awaitable[Any]]]
on_close: Optional[Callable[[int], Awaitable[Any]]]
def __init__(self) -> None:
self.id_generator = itertools.count(start=0, step=1)
self.connections = OrderedDict()
self.on_open = None
self.on_message = None
self.on_close = None
# Wires up asyncio tasks etc. such that on_open, on_message and on_close
# are called at the right time with the right arguments.
async def setup_connection(self) -> NoReturn:
connection_id = next(self.id_generator)
connection = Connection()
self.connections[connection_id] = connection
if self.on_open:
await self.on_open(connection_id)
try:
send_task = asyncio.create_task(connection._send_loop())
while True:
data = await websocket.receive()
if self.on_message:
await self.on_message(connection_id, data)
except asyncio.CancelledError:
del self.connections[connection_id]
send_task.cancel()
await send_task
if self.on_close:
await self.on_close(connection_id)
raise
# Interface for the model to send messages. The model shouldn't
# use the Connection class at all.
async def send_to(self, connection_id: int, data: str) -> None:
await self.connections[connection_id].send(data)
+121
View File
@@ -0,0 +1,121 @@
import json
from collections import OrderedDict
from typing import Any, Iterator, Optional
from glebby.board import BoardGenerator
from glebby.connection_manager import ConnectionManager
class Client:
id: int
name: str
def __init__(self, client_id: int):
self.id = client_id
self.name = ''
def get_dto(self) -> dict[str, Any]:
return { 'id': self.id, 'name': self.name }
class Model:
connection_manager: ConnectionManager
clients: OrderedDict[int, Client]
board_generator: BoardGenerator
board: Optional[list[list[str]]]
def __init__(self, connection_manager: ConnectionManager):
self.connection_manager = connection_manager
self.clients = OrderedDict()
self.board_generator = BoardGenerator(None)
self.board = None
self.connection_manager.on_open = self.add_client
self.connection_manager.on_message = self.handle_incoming
self.connection_manager.on_close = self.remove_client
# Event handlers
async def add_client(self, client_id: int) -> None:
print(f'<{client_id}|OPEN>')
await self.broadcast(client_id, {
'type': 'join'
})
self.clients[client_id] = Client(client_id)
await self.send_to(None, client_id, {
'type': 'init',
'state': self.get_state_dto(client_id)
})
async def remove_client(self, client_id: int) -> None:
print(f'<{client_id}|CLOS>')
del self.clients[client_id]
await self.broadcast(client_id, {
'type': 'leave'
})
async def handle_incoming(self, client_id: int, data: str) -> None:
print(f'<{client_id}|DATA> {data}')
try:
payload = json.loads(data)
except:
await self.send_to(None, client_id, {
'type': 'error',
'errorMessage': 'you sent invalid JSON!'
})
return
if 'type' not in payload:
await self.send_to(None, client_id, {
'type': 'error',
'errorMessage': 'missing message type!'
})
return
match payload:
case {'type': 'set-name', 'name': name}:
self.clients[client_id].name = name
await self.broadcast(client_id, {
'type': 'set-name',
'name': name
})
case {'type': 'chat', 'message': message}:
await self.broadcast(client_id, {
'type': 'chat',
'message': message
})
case {'type': 'roll'}:
self.board = self.board_generator.generate_board()
await self.broadcast(client_id, {
'type': 'roll',
'board': self.board
})
case _:
print(' Unhandled!')
def get_state_dto(self, client_id: int) -> Any:
return {
'yourId': client_id,
'players': [
client.get_dto()
for client in self.clients.values()
],
'board': self.board
}
# Message sending
async def send_to(self, client_id_from: Optional[int], client_id_to: int, payload: Any) -> None:
await self.connection_manager.send_to(client_id_to, json.dumps({
'from': client_id_from,
'payload': payload
}))
async def broadcast(self, client_id_from: Optional[int], payload: Any) -> None:
for client_id_to in self.clients:
await self.send_to(client_id_from, client_id_to, payload)
+5
View File
@@ -0,0 +1,5 @@
-r requirements.txt
mypy==0.991
mypy-extensions==0.4.3
tomli==2.0.1
typing_extensions==4.4.0
+10 -4
View File
@@ -1,12 +1,18 @@
aiofiles==22.1.0
blinker==1.5
click==8.1.3 click==8.1.3
Flask==2.2.2
flask-sock==0.6.0
h11==0.14.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 importlib-metadata==6.0.0
itsdangerous==2.1.2 itsdangerous==2.1.2
Jinja2==3.1.2 Jinja2==3.1.2
MarkupSafe==2.1.1 MarkupSafe==2.1.2
simple-websocket==0.9.0 priority==2.0.0
quart==0.18.3
toml==0.10.2
Werkzeug==2.2.2 Werkzeug==2.2.2
wsproto==1.2.0 wsproto==1.2.0
zipp==3.11.0 zipp==3.11.0
+4
View File
@@ -0,0 +1,4 @@
import glebby
# For development use only
glebby.app.run()