diff --git a/asciifarm/client/gameclient.py b/asciifarm/client/gameclient.py index d9d0c89..3cd2050 100644 --- a/asciifarm/client/gameclient.py +++ b/asciifarm/client/gameclient.py @@ -79,9 +79,9 @@ class Client: self.close("error: name is already taken") return if error == "invalidname": - self.close("error: "+ msg[2]) + self.close("Invalid name error: "+ msg[2:]) return - self.log(error) + self.log(" ".join(msg[1:])) if msgType == 'field': field = msg[1] fieldWidth = field['width'] diff --git a/asciifarm/server/game.py b/asciifarm/server/game.py index c1c1d8b..01da67b 100644 --- a/asciifarm/server/game.py +++ b/asciifarm/server/game.py @@ -17,7 +17,7 @@ class Game: def __init__(self, socketType, worldFile=None, saveDir=None, saveInterval=1): - self.server = gameserver.GameServer(self, socketType) + self.server = gameserver.GameServer(socketType) worldLoader = worldloader.WorldLoader(saveDir) roomLoader = roomloader.RoomLoader(worldFile, os.path.join(saveDir, "rooms")) diff --git a/asciifarm/server/gameserver.py b/asciifarm/server/gameserver.py index 0efb739..aebaa67 100644 --- a/asciifarm/server/gameserver.py +++ b/asciifarm/server/gameserver.py @@ -4,16 +4,21 @@ import json import queue import string +import threading + from . import view from . import socketserver as server from . import player +import re + +nameRegex = re.compile("(~|\w)\w*") class GameServer: - def __init__(self, game, socketType): + def __init__(self, socketType): self.serv = server.Server(socketType, self.newConnection, self.receive, self.close) @@ -21,12 +26,13 @@ class GameServer: self.players = {} - self.game = game self.messages = queue.Queue() def start(self, address): - self.serv.start(address) + + self.listener = threading.Thread(target=self.serv.listen, daemon=True, args=(address,)) + self.listener.start() def sendState(self, view): @@ -43,49 +49,54 @@ class GameServer: def receive(self, n, data): try: - data = json.loads(data.decode('utf-8')) - if isinstance(data[0], str): - data = [data] - for msg in data: - msgType = msg[0] - if msgType == "name": - name = msg[1] - - if name in self.players: - self.error(n, "nametaken", "another connection to this player already exists") - return - if len(name) < 1: - self.error(n, "invalidname", "name needs at least one character") - return - if name[0] not in string.ascii_letters + string.digits: - if name[0] != "~": - self.error(n, "invalidname", "custom name must start with an alphanumeric character") - return - if name[1:] != self.serv.getUsername(n): - self.error(n, "invalidname", "tildenames are only available on unix sockets and when the rest of the name equals the username") - return - if any(char not in string.ascii_letters + string.digits + string.punctuation for char in name): - self.error(n, "invalidname", "names can only consist of printable ascii characters") - return - self.connections[n] = name - self.players[name] = n - self.messages.put(("join", name)) - print("new player: "+name) - self.broadcast("{} has connected".format(name), "connect") + try: + data = json.loads(data.decode('utf-8')) + except json.JSONDecodeError: + self.error(n, "invalidmessage", "Invalid JSON") + return + if not isinstance(data, list) or len(data) != 2: + self.error(n, "invalidmessage", "Message must be a json list of length 2") + return + msg = data + msgType = msg[0] + if msgType == "name": + name = msg[1] + + if len(name) < 1: + self.error(n, "invalidname", "name needs at least one character") return - elif msgType == "input": - if n in self.connections: - self.messages.put(("input", self.connections[n], msg[1])) - elif msgType == "chat": - if n in self.connections: - name = self.connections[n] - message = name + ": " + msg[1] - print(message) - self.broadcast(message, "chat") + if len(bytes(name, "utf-8")) > 256: + self.error(n, "invalidname", "name may not be longer than 256 utf8 bytes") + return + if nameRegex.match(name) is None: + self.error(n, "invalidname", "Name must match the regex: {}".format(nameRegex.pattern)) + return + if name[0] == "~" and name[1:] != self.serv.getUsername(n): + self.error(n, "invalidname", "tildenames are only available on unix sockets and when the rest of the name equals the username") + return + if name in self.players: + self.error(n, "nametaken", "another connection to this player already exists") + return + self.connections[n] = name + self.players[name] = n + self.messages.put(("join", name)) + print("new player: "+name) + self.broadcast("{} has connected".format(name), "connect") + elif msgType == "input": + if n in self.connections: + self.messages.put(("input", self.connections[n], msg[1])) + elif msgType == "chat": + if n in self.connections: + name = self.connections[n] + if not msg[1].isprintable(): + self.error("invalidmessage", "Chat message may only contain printable unicode characters") + message = name + ": " + msg[1] + print(message) + self.broadcast(message, "chat") except Exception as e: print(e) - self.error(n, "invalidmessage", repr(e)) + self.error(n, "invalidmessage", "An unknown error occured in handling the message") def error(self, n, errtype, *data): @@ -109,7 +120,11 @@ class GameServer: def readMessages(self): m = [] while not self.messages.empty(): - m.append(self.messages.get()) + try: + message = self.messages.get_nowait() + except queue.Empty: + return m + m.append(message) return m diff --git a/asciifarm/server/socketserver.py b/asciifarm/server/socketserver.py index 27647ee..5ecf9a9 100644 --- a/asciifarm/server/socketserver.py +++ b/asciifarm/server/socketserver.py @@ -1,12 +1,38 @@ import os import socket import sys -import threading import struct +import selectors from asciifarm.common.tcommunicate import send, receive +class _BytesBuffer: + + def __init__(self): + self.buff = bytearray() + self.msglen = None + + def addBytes(self, data): + self.buff.extend(data) + + def readMessages(self): + messages = [] + while True: + if self.msglen is None: + if len(self.buff) < 4: + break + header = self.buff[:4] + self.msglen = int.from_bytes(header, byteorder="big") + self.buff = self.buff[4:] + elif len(self.buff) >= self.msglen: + messages.append(self.buff[:self.msglen]) + self.buff = self.buff[self.msglen:] + self.msglen = None + else: + break + return messages + # Class to open a TCP Socket # will execute callback functions on new connections, closing connections and received messages # also provides a send function @@ -27,9 +53,10 @@ class Server: self.onConnection = onConnection self.onMessage = onMessage self.onConnectionClose = onConnectionClose + self.sel = selectors.DefaultSelector() - def start(self, address): + def listen(self, address): print("starting {} socket server on address {}".format(self.socketType, address)) try: self.sock.bind(address) @@ -42,32 +69,52 @@ class Server: self.sock.listen() - self.listener = threading.Thread(target=self._listen, daemon=True) - self.listener.start() - - - def _listen(self): - self.connections = set() + self.sock.setblocking(False) + + self.sel.register(self.sock, selectors.EVENT_READ, "ACCEPT") + + self.connections = {} print("listening") while True: - connection, client_address = self.sock.accept() - listener = threading.Thread(target=self._listenCon, args=(connection,), daemon=True) - listener.start() + events = self.sel.select() + for key, mask in events: + if key.data == "ACCEPT": + sock = key.fileobj + connection, client_address = sock.accept() + connection.setblocking(False) + self.sel.register(connection, selectors.EVENT_READ, "RECEIVE") + self.connections[connection] = _BytesBuffer() + self.onConnection(connection) + elif key.data == "RECEIVE": + connection = key.fileobj + data = connection.recv(4096) + if data: + buff = self.connections[connection] + buff.addBytes(data) + for message in buff.readMessages(): + self.onMessage(connection, message) + else: + del self.connections[connection] + self.onConnectionClose(connection) + + #listener = threading.Thread(target=self._listenCon, args=(connection,), daemon=True) + #listener.start() + - def _listenCon(self, connection): - self.connections.add(connection) - self.onConnection(connection) - data = receive(connection) - while data: - self.onMessage(connection, data) - try: - data = receive(connection) - except socket.error: - break - if not len(data): - break - self.connections.discard(connection) - self.onConnectionClose(connection) + #def _listenCon(self, connection): + #self.connections.add(connection) + #self.onConnection(connection) + #data = receive(connection) + #while data: + #self.onMessage(connection, data) + #try: + #data = receive(connection) + #except socket.error: + #break + #if not len(data): + #break + #self.connections.discard(connection) + #self.onConnectionClose(connection) def getUsername(self, connection): @@ -83,9 +130,11 @@ class Server: def send(self, connection, msg): try: - send(connection, msg) + length = len(msg) + header = length.to_bytes(4, byteorder="big") + connection.sendall(header + msg) except Exception: - self.connections.discard(connection) + del self.connections[connection] self.onConnectionClose(connection) print("failed to send to client") diff --git a/docs/communication.md b/docs/communication.md index 02cde3e..3509694 100644 --- a/docs/communication.md +++ b/docs/communication.md @@ -37,7 +37,7 @@ For example, the whole message could look like this: ## Client to server messages -Currently there are two types of messages that the client can send to the server: 'name' and 'input' +Currently there are three types of messages that the client can send to the server: 'name', 'input' and 'chat' ### 'name' messages @@ -47,7 +47,7 @@ This message must be sent before any other communication. Example message: `["name", "troido"]` -If a player with that name did not exist yet, it will be created and the connection will be connecte that player. +If a player with that name did not exist yet, it will be created and the connection will be connected that player. If a player with that name does exist, but it no other connection is currenty connected to that player, the connection will be connected to that player too. @@ -64,10 +64,18 @@ The first item in the list is the type of command, later items are arguments Example message: `["input", ["move", "east"]]` +### 'chat' messages + +'chat' messages are used to send messages to all connections, without going through the world first. + +The value of this message is the string to send. +This string may only contain printable characters. + +Example message: `["chat", "hello, other asciifarm players"]` ## Server to client messages -There are 5 types of messages that the server can send to the client: 'field', 'inventory', 'health', 'ground' and 'error' +There are several types of messages that the server can send to the client: 'field', 'inventory', 'health', 'ground' and 'error' Currently, 'chanchedcells' and 'info' messages are always sent in the same dictionary. If there is a 'field' message, it will also be sent in that dictionary. @@ -75,7 +83,9 @@ If there is a 'field' message, it will also be sent in that dictionary. ### 'error' messages 'error' messages are sent when a player tries to connect with a name that is already connected or when the input is wrong. -Wrong input is not always guaranteed to give an error message. The first value is a string to give the type of error. +Wrong input is not always guaranteed to give an error message. +The first value is a string to give the type of error. +The message may have more data to explain the error. Example message: `["error", "nametaken"]` @@ -90,7 +100,7 @@ It has the following properties: - field: a 1 dimensional array (length: width*height) of integers representing the sprites in all cells of the room. - mapping: an array or dictionary of spritenames. The values in field are the indices for the mapping. -Only one sprite per cell is sent: the one with the larges height. +Only one sprite per cell is sent: the one with the largest height. Example message: