feat: added mudserver.py
This commit is contained in:
parent
b88e93f352
commit
a9d7df5fb2
|
@ -0,0 +1,405 @@
|
|||
"""Basic MUD server module for creating text-based Multi-User Dungeon
|
||||
(MUD) games.
|
||||
|
||||
Contains one class, MudServer, which can be instantiated to start a
|
||||
server running then used to send and receive messages from players.
|
||||
|
||||
author: Mark Frimston - mfrimston@gmail.com
|
||||
"""
|
||||
|
||||
|
||||
import socket
|
||||
import select
|
||||
import time
|
||||
import sys
|
||||
|
||||
|
||||
class MudServer(object):
|
||||
"""A basic server for text-based Multi-User Dungeon (MUD) games.
|
||||
|
||||
Once created, the server will listen for players connecting using
|
||||
Telnet. Messages can then be sent to and from multiple connected
|
||||
players.
|
||||
|
||||
The 'update' method should be called in a loop to keep the server
|
||||
running.
|
||||
"""
|
||||
|
||||
# An inner class which is instantiated for each connected client to store
|
||||
# info about them
|
||||
|
||||
class _Client(object):
|
||||
"""Holds information about a connected player"""
|
||||
|
||||
# the socket object used to communicate with this client
|
||||
socket = None
|
||||
# the ip address of this client
|
||||
address = ""
|
||||
# holds data send from the client until a full message is received
|
||||
buffer = ""
|
||||
# the last time we checked if the client was still connected
|
||||
lastcheck = 0
|
||||
|
||||
def __init__(self, socket, address, buffer, lastcheck):
|
||||
self.socket = socket
|
||||
self.address = address
|
||||
self.buffer = buffer
|
||||
self.lastcheck = lastcheck
|
||||
|
||||
# Used to store different types of occurences
|
||||
_EVENT_NEW_PLAYER = 1
|
||||
_EVENT_PLAYER_LEFT = 2
|
||||
_EVENT_COMMAND = 3
|
||||
|
||||
# Different states we can be in while reading data from client
|
||||
# See _process_sent_data function
|
||||
_READ_STATE_NORMAL = 1
|
||||
_READ_STATE_COMMAND = 2
|
||||
_READ_STATE_SUBNEG = 3
|
||||
|
||||
# Command codes used by Telnet protocol
|
||||
# See _process_sent_data function
|
||||
_TN_INTERPRET_AS_COMMAND = 255
|
||||
_TN_ARE_YOU_THERE = 246
|
||||
_TN_WILL = 251
|
||||
_TN_WONT = 252
|
||||
_TN_DO = 253
|
||||
_TN_DONT = 254
|
||||
_TN_SUBNEGOTIATION_START = 250
|
||||
_TN_SUBNEGOTIATION_END = 240
|
||||
|
||||
# socket used to listen for new clients
|
||||
_listen_socket = None
|
||||
# holds info on clients. Maps client id to _Client object
|
||||
_clients = {}
|
||||
# counter for assigning each client a new id
|
||||
_nextid = 0
|
||||
# list of occurences waiting to be handled by the code
|
||||
_events = []
|
||||
# list of newly-added occurences
|
||||
_new_events = []
|
||||
|
||||
def __init__(self):
|
||||
"""Constructs the MudServer object and starts listening for
|
||||
new players.
|
||||
"""
|
||||
|
||||
self._clients = {}
|
||||
self._nextid = 0
|
||||
self._events = []
|
||||
self._new_events = []
|
||||
|
||||
# create a new tcp socket which will be used to listen for new clients
|
||||
self._listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
|
||||
# set a special option on the socket which allows the port to be
|
||||
# immediately without having to wait
|
||||
self._listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR,
|
||||
1)
|
||||
|
||||
# bind the socket to an ip address and port. Port 23 is the standard
|
||||
# telnet port which telnet clients will use, however on some platforms
|
||||
# this requires root permissions, so we use a higher arbitrary port
|
||||
# number instead: 1234. Address 0.0.0.0 means that we will bind to all
|
||||
# of the available network interfaces
|
||||
self._listen_socket.bind(("0.0.0.0", 1234))
|
||||
|
||||
# set to non-blocking mode. This means that when we call 'accept', it
|
||||
# will return immediately without waiting for a connection
|
||||
self._listen_socket.setblocking(False)
|
||||
|
||||
# start listening for connections on the socket
|
||||
self._listen_socket.listen(1)
|
||||
|
||||
def update(self):
|
||||
"""Checks for new players, disconnected players, and new
|
||||
messages sent from players. This method must be called before
|
||||
up-to-date info can be obtained from the 'get_new_players',
|
||||
'get_disconnected_players' and 'get_commands' methods.
|
||||
It should be called in a loop to keep the game running.
|
||||
"""
|
||||
|
||||
# check for new stuff
|
||||
self._check_for_new_connections()
|
||||
self._check_for_disconnected()
|
||||
self._check_for_messages()
|
||||
|
||||
# move the new events into the main events list so that they can be
|
||||
# obtained with 'get_new_players', 'get_disconnected_players' and
|
||||
# 'get_commands'. The previous events are discarded
|
||||
self._events = list(self._new_events)
|
||||
self._new_events = []
|
||||
|
||||
def get_new_players(self):
|
||||
"""Returns a list containing info on any new players that have
|
||||
entered the game since the last call to 'update'. Each item in
|
||||
the list is a player id number.
|
||||
"""
|
||||
retval = []
|
||||
# go through all the events in the main list
|
||||
for ev in self._events:
|
||||
# if the event is a new player occurence, add the info to the list
|
||||
if ev[0] == self._EVENT_NEW_PLAYER:
|
||||
retval.append(ev[1])
|
||||
# return the info list
|
||||
return retval
|
||||
|
||||
def get_disconnected_players(self):
|
||||
"""Returns a list containing info on any players that have left
|
||||
the game since the last call to 'update'. Each item in the list
|
||||
is a player id number.
|
||||
"""
|
||||
retval = []
|
||||
# go through all the events in the main list
|
||||
for ev in self._events:
|
||||
# if the event is a player disconnect occurence, add the info to
|
||||
# the list
|
||||
if ev[0] == self._EVENT_PLAYER_LEFT:
|
||||
retval.append(ev[1])
|
||||
# return the info list
|
||||
return retval
|
||||
|
||||
def get_commands(self):
|
||||
"""Returns a list containing any commands sent from players
|
||||
since the last call to 'update'. Each item in the list is a
|
||||
3-tuple containing the id number of the sending player, a
|
||||
string containing the command (i.e. the first word of what
|
||||
they typed), and another string containing the text after the
|
||||
command
|
||||
"""
|
||||
retval = []
|
||||
# go through all the events in the main list
|
||||
for ev in self._events:
|
||||
# if the event is a command occurence, add the info to the list
|
||||
if ev[0] == self._EVENT_COMMAND:
|
||||
retval.append((ev[1], ev[2], ev[3]))
|
||||
# return the info list
|
||||
return retval
|
||||
|
||||
def send_message(self, to, message):
|
||||
"""Sends the text in the 'message' parameter to the player with
|
||||
the id number given in the 'to' parameter. The text will be
|
||||
printed out in the player's terminal.
|
||||
"""
|
||||
# we make sure to put a newline on the end so the client receives the
|
||||
# message on its own line
|
||||
self._attempt_send(to, message+"\n\r")
|
||||
|
||||
def shutdown(self):
|
||||
"""Closes down the server, disconnecting all clients and
|
||||
closing the listen socket.
|
||||
"""
|
||||
# for each client
|
||||
for cl in self._clients.values():
|
||||
# close the socket, disconnecting the client
|
||||
cl.socket.shutdown(socket.SHUT_RDWR)
|
||||
cl.socket.close()
|
||||
# stop listening for new clients
|
||||
self._listen_socket.close()
|
||||
|
||||
def _attempt_send(self, clid, data):
|
||||
# python 2/3 compatability fix - convert non-unicode string to unicode
|
||||
if sys.version < '3' and type(data) != unicode:
|
||||
data = unicode(data, "latin1")
|
||||
try:
|
||||
# look up the client in the client map and use 'sendall' to send
|
||||
# the message string on the socket. 'sendall' ensures that all of
|
||||
# the data is sent in one go
|
||||
self._clients[clid].socket.sendall(bytearray(data, "latin1"))
|
||||
# KeyError will be raised if there is no client with the given id in
|
||||
# the map
|
||||
except KeyError:
|
||||
pass
|
||||
# If there is a connection problem with the client (e.g. they have
|
||||
# disconnected) a socket error will be raised
|
||||
except socket.error:
|
||||
self._handle_disconnect(clid)
|
||||
|
||||
def _check_for_new_connections(self):
|
||||
|
||||
# 'select' is used to check whether there is data waiting to be read
|
||||
# from the socket. We pass in 3 lists of sockets, the first being those
|
||||
# to check for readability. It returns 3 lists, the first being
|
||||
# the sockets that are readable. The last parameter is how long to wait
|
||||
# - we pass in 0 so that it returns immediately without waiting
|
||||
rlist, wlist, xlist = select.select([self._listen_socket], [], [], 0)
|
||||
|
||||
# if the socket wasn't in the readable list, there's no data available,
|
||||
# meaning no clients waiting to connect, and so we can exit the method
|
||||
# here
|
||||
if self._listen_socket not in rlist:
|
||||
return
|
||||
|
||||
# 'accept' returns a new socket and address info which can be used to
|
||||
# communicate with the new client
|
||||
joined_socket, addr = self._listen_socket.accept()
|
||||
|
||||
# set non-blocking mode on the new socket. This means that 'send' and
|
||||
# 'recv' will return immediately without waiting
|
||||
joined_socket.setblocking(False)
|
||||
|
||||
# construct a new _Client object to hold info about the newly connected
|
||||
# client. Use 'nextid' as the new client's id number
|
||||
self._clients[self._nextid] = MudServer._Client(joined_socket, addr[0],
|
||||
"", time.time())
|
||||
|
||||
# add a new player occurence to the new events list with the player's
|
||||
# id number
|
||||
self._new_events.append((self._EVENT_NEW_PLAYER, self._nextid))
|
||||
|
||||
# add 1 to 'nextid' so that the next client to connect will get a
|
||||
# unique id number
|
||||
self._nextid += 1
|
||||
|
||||
def _check_for_disconnected(self):
|
||||
|
||||
# go through all the clients
|
||||
for id, cl in list(self._clients.items()):
|
||||
|
||||
# if we last checked the client less than 5 seconds ago, skip this
|
||||
# client and move on to the next one
|
||||
if time.time() - cl.lastcheck < 5.0:
|
||||
continue
|
||||
|
||||
# send the client an invisible character. It doesn't actually
|
||||
# matter what we send, we're really just checking that data can
|
||||
# still be written to the socket. If it can't, an error will be
|
||||
# raised and we'll know that the client has disconnected.
|
||||
self._attempt_send(id, "\x00")
|
||||
|
||||
# update the last check time
|
||||
cl.lastcheck = time.time()
|
||||
|
||||
def _check_for_messages(self):
|
||||
|
||||
# go through all the clients
|
||||
for id, cl in list(self._clients.items()):
|
||||
|
||||
# we use 'select' to test whether there is data waiting to be read
|
||||
# from the client socket. The function takes 3 lists of sockets,
|
||||
# the first being those to test for readability. It returns 3 list
|
||||
# of sockets, the first being those that are actually readable.
|
||||
rlist, wlist, xlist = select.select([cl.socket], [], [], 0)
|
||||
|
||||
# if the client socket wasn't in the readable list, there is no
|
||||
# new data from the client - we can skip it and move on to the next
|
||||
# one
|
||||
if cl.socket not in rlist:
|
||||
continue
|
||||
|
||||
try:
|
||||
# read data from the socket, using a max length of 4096
|
||||
data = cl.socket.recv(4096).decode("latin1")
|
||||
|
||||
# process the data, stripping out any special Telnet commands
|
||||
message = self._process_sent_data(cl, data)
|
||||
|
||||
# if there was a message in the data
|
||||
if message:
|
||||
|
||||
# remove any spaces, tabs etc from the start and end of
|
||||
# the message
|
||||
message = message.strip()
|
||||
|
||||
# separate the message into the command (the first word)
|
||||
# and its parameters (the rest of the message)
|
||||
command, params = (message.split(" ", 1) + ["", ""])[:2]
|
||||
|
||||
# add a command occurence to the new events list with the
|
||||
# player's id number, the command and its parameters
|
||||
self._new_events.append((self._EVENT_COMMAND, id,
|
||||
command.lower(), params))
|
||||
|
||||
# if there is a problem reading from the socket (e.g. the client
|
||||
# has disconnected) a socket error will be raised
|
||||
except socket.error:
|
||||
self._handle_disconnect(id)
|
||||
|
||||
def _handle_disconnect(self, clid):
|
||||
|
||||
# remove the client from the clients map
|
||||
del(self._clients[clid])
|
||||
|
||||
# add a 'player left' occurence to the new events list, with the
|
||||
# player's id number
|
||||
self._new_events.append((self._EVENT_PLAYER_LEFT, clid))
|
||||
|
||||
def _process_sent_data(self, client, data):
|
||||
|
||||
# the Telnet protocol allows special command codes to be inserted into
|
||||
# messages. For our very simple server we don't need to response to
|
||||
# any of these codes, but we must at least detect and skip over them
|
||||
# so that we don't interpret them as text data.
|
||||
# More info on the Telnet protocol can be found here:
|
||||
# http://pcmicro.com/netfoss/telnet.html
|
||||
|
||||
# start with no message and in the normal state
|
||||
message = None
|
||||
state = self._READ_STATE_NORMAL
|
||||
|
||||
# go through the data a character at a time
|
||||
for c in data:
|
||||
|
||||
# handle the character differently depending on the state we're in:
|
||||
|
||||
# normal state
|
||||
if state == self._READ_STATE_NORMAL:
|
||||
|
||||
# if we received the special 'interpret as command' code,
|
||||
# switch to 'command' state so that we handle the next
|
||||
# character as a command code and not as regular text data
|
||||
if ord(c) == self._TN_INTERPRET_AS_COMMAND:
|
||||
state = self._READ_STATE_COMMAND
|
||||
|
||||
# if we get a newline character, this is the end of the
|
||||
# message. Set 'message' to the contents of the buffer and
|
||||
# clear the buffer
|
||||
elif c == "\n":
|
||||
message = client.buffer
|
||||
client.buffer = ""
|
||||
|
||||
# some telnet clients send the characters as soon as the user
|
||||
# types them. So if we get a backspace character, this is where
|
||||
# the user has deleted a character and we should delete the
|
||||
# last character from the buffer.
|
||||
elif c == "\x08":
|
||||
client.buffer = client.buffer[:-1]
|
||||
|
||||
# otherwise it's just a regular character - add it to the
|
||||
# buffer where we're building up the received message
|
||||
else:
|
||||
client.buffer += c
|
||||
|
||||
# command state
|
||||
elif state == self._READ_STATE_COMMAND:
|
||||
|
||||
# the special 'start of subnegotiation' command code indicates
|
||||
# that the following characters are a list of options until
|
||||
# we're told otherwise. We switch into 'subnegotiation' state
|
||||
# to handle this
|
||||
if ord(c) == self._TN_SUBNEGOTIATION_START:
|
||||
state = self._READ_STATE_SUBNEG
|
||||
|
||||
# if the command code is one of the 'will', 'wont', 'do' or
|
||||
# 'dont' commands, the following character will be an option
|
||||
# code so we must remain in the 'command' state
|
||||
elif ord(c) in (self._TN_WILL, self._TN_WONT, self._TN_DO,
|
||||
self._TN_DONT):
|
||||
state = self._READ_STATE_COMMAND
|
||||
|
||||
# for all other command codes, there is no accompanying data so
|
||||
# we can return to 'normal' state.
|
||||
else:
|
||||
state = self._READ_STATE_NORMAL
|
||||
|
||||
# subnegotiation state
|
||||
elif state == self._READ_STATE_SUBNEG:
|
||||
|
||||
# if we reach an 'end of subnegotiation' command, this ends the
|
||||
# list of options and we can return to 'normal' state.
|
||||
# Otherwise we must remain in this state
|
||||
if ord(c) == self._TN_SUBNEGOTIATION_END:
|
||||
state = self._READ_STATE_NORMAL
|
||||
|
||||
# return the contents of 'message' which is either a string or None
|
||||
return message
|
Loading…
Reference in New Issue