From 33bfdaf647c6736d99aadc017ee935f3301d758a Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Sun, 25 Oct 2020 01:35:20 +0200 Subject: [PATCH] Add basic multiplayer roguelike chat example. --- new2/plomrogue/commands.py | 37 ++++++++ new2/plomrogue/errors.py | 10 ++ new2/plomrogue/game.py | 123 +++++++++++++++++++++++++ new2/plomrogue/mapping.py | 14 +++ new2/plomrogue/misc.py | 10 ++ new2/plomrogue/parser.py | 93 +++++++++++++++++++ new2/plomrogue/tasks.py | 46 ++++++++++ new2/plomrogue/things.py | 78 ++++++++++++++++ new2/rogue_chat.html | 183 +++++++++++++++++++++++++++++++++++++ new2/rogue_chat.py | 5 + 10 files changed, 599 insertions(+) create mode 100644 new2/plomrogue/commands.py create mode 100644 new2/plomrogue/errors.py create mode 100755 new2/plomrogue/game.py create mode 100644 new2/plomrogue/mapping.py create mode 100644 new2/plomrogue/misc.py create mode 100644 new2/plomrogue/parser.py create mode 100644 new2/plomrogue/tasks.py create mode 100644 new2/plomrogue/things.py create mode 100644 new2/rogue_chat.html create mode 100755 new2/rogue_chat.py diff --git a/new2/plomrogue/commands.py b/new2/plomrogue/commands.py new file mode 100644 index 0000000..a68efc2 --- /dev/null +++ b/new2/plomrogue/commands.py @@ -0,0 +1,37 @@ +from plomrogue.misc import quote + + + +def cmd_ALL(game, msg, connection_id): + if not connection_id in game.sessions: + game.io.send('LOG' + quote('need to be logged in for this'), connection_id) + return + t = game.get_thing(game.sessions[connection_id], False) + game.io.send('LOG ' + quote(t.nickname + ': ' + msg)) +cmd_ALL.argtypes = 'string' + +def cmd_LOGIN(game, nick, connection_id): + for t in [t for t in game.things if t.type_ == 'player' and t.nickname == nick]: + game.io.send('LOG ' + quote('name already in use'), connection_id) + return + t = game.thing_types['player'](game) + t.nickname = nick + game.things += [t] # TODO refactor into Thing.__init__? + game.sessions[connection_id] = t.id_ + game.io.send('LOG ' + quote('your are now: ' + nick), connection_id) +cmd_LOGIN.argtypes = 'string' + +def cmd_QUERY(game, target_nick, msg, connection_id): + if not connection_id in game.sessions: + game.io.send('LOG ' + quote('can only query when logged in'), connection_id) + t = game.get_thing(game.sessions[connection_id], False) + source_nick = t.nickname + for t in [t for t in game.things if t.type_ == 'player' and t.nickname == target_nick]: + for c_id in game.sessions: + if game.sessions[c_id] == t.id_: + game.io.send('LOG ' + quote(source_nick+ '->' + target_nick + ': ' + msg), c_id) + game.io.send('LOG ' + quote(source_nick+ '->' + target_nick + ': ' + msg), connection_id) + return + game.io.send('LOG ' + quote('target user offline?')) + game.io.send('LOG ' + quote('can only query with registered nicknames')) +cmd_QUERY.argtypes = 'string string' diff --git a/new2/plomrogue/errors.py b/new2/plomrogue/errors.py new file mode 100644 index 0000000..bc37495 --- /dev/null +++ b/new2/plomrogue/errors.py @@ -0,0 +1,10 @@ +class ArgError(Exception): + pass + + +class GameError(Exception): + pass + + +class BrokenSocketConnection(Exception): + pass diff --git a/new2/plomrogue/game.py b/new2/plomrogue/game.py new file mode 100755 index 0000000..e0c0715 --- /dev/null +++ b/new2/plomrogue/game.py @@ -0,0 +1,123 @@ +from plomrogue.tasks import Task_WAIT, Task_MOVE +from plomrogue.errors import GameError +from plomrogue.commands import cmd_ALL, cmd_LOGIN, cmd_QUERY +from plomrogue.io import GameIO +from plomrogue.misc import quote +from plomrogue.things import Thing, ThingPlayer + + + +class GameBase: + + def __init__(self): + pass + self.turn = 0 + self.things = [] + + def get_thing(self, id_, create_unfound): + # No default for create_unfound because every call to get_thing + # should be accompanied by serious consideration whether to use it. + for thing in self.things: + if id_ == thing.id_: + return thing + if create_unfound: + t = self.thing_type(self, id_) + self.things += [t] + return t + return None + + + +class Game(GameBase): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.changed = True + self.io = GameIO(self) + self.tasks = {'WAIT': Task_WAIT, + 'MOVE': Task_MOVE} + self.commands = {'QUERY': cmd_QUERY, 'ALL': cmd_ALL, 'LOGIN': cmd_LOGIN} + self.thing_type = Thing + self.thing_types = {'player': ThingPlayer} + self.sessions = {} + + def get_string_options(self, string_option_type): + if string_option_type == 'direction': + return ['UP', 'DOWN', 'LEFT', 'RIGHT'] + return None + + def send_gamestate(self, connection_id=None): + """Send out game state data relevant to clients.""" + + def send_thing(thing): + self.io.send('THING_POS %s %s' % (thing.id_, t.position)) + + self.io.send('TURN ' + str(self.turn)) + for t in self.things: + send_thing(t) + + def run_tick(self): + to_delete = [] + for connection_id in self.sessions: + if not connection_id in self.io.server.clients: + t = self.get_thing(self.sessions[connection_id], create_unfound=False) + self.things.remove(t) + to_delete += [connection_id] + for connection_id in to_delete: + del self.sessions[connection_id] + self.changed = True + for t in [t for t in self.things]: + if t in self.things: + try: + t.proceed() + except GameError as e: + for connection_id in [c_id for c_id in self.sessions + if self.sessions[c_id] == t.id_]: + self.io.send('GAME_ERROR ' + quote(str(e)), connection_id) + self.turn += 1 + if self.changed: + self.send_gamestate() + self.changed = False + + def get_command(self, command_name): + + def partial_with_attrs(f, *args, **kwargs): + from functools import partial + p = partial(f, *args, **kwargs) + p.__dict__.update(f.__dict__) + return p + + def cmd_TASK_colon(task_name, game, *args, connection_id): + if connection_id not in game.sessions: + raise GameError('Not registered as player.') + t = game.get_thing(game.sessions[connection_id], create_unfound=False) + t.set_next_task(task_name, args) + + def task_prefixed(command_name, task_prefix, task_command): + if command_name.startswith(task_prefix): + task_name = command_name[len(task_prefix):] + if task_name in self.tasks: + f = partial_with_attrs(task_command, task_name, self) + task = self.tasks[task_name] + f.argtypes = task.argtypes + return f + return None + + command = task_prefixed(command_name, 'TASK:', cmd_TASK_colon) + if command: + return command + if command_name in self.commands: + f = partial_with_attrs(self.commands[command_name], self) + return f + return None + + def new_thing_id(self): + if len(self.things) == 0: + return 0 + # DANGEROUS – if anywhere we append a thing to the list of lower + # ID than the highest-value ID, this might lead to re-using an + # already active ID. This condition /should/ not be fulfilled + # anywhere in the code, but if it does, trouble here is one of + # the more obvious indicators that it does – that's why there's + # no safeguard here against this. + return self.things[-1].id_ + 1 diff --git a/new2/plomrogue/mapping.py b/new2/plomrogue/mapping.py new file mode 100644 index 0000000..1847f69 --- /dev/null +++ b/new2/plomrogue/mapping.py @@ -0,0 +1,14 @@ +import collections + + + +class YX(collections.namedtuple('YX', ('y', 'x'))): + + def __add__(self, other): + return YX(self.y + other.y, self.x + other.x) + + def __sub__(self, other): + return YX(self.y - other.y, self.x - other.x) + + def __str__(self): + return 'Y:%s,X:%s' % (self.y, self.x) diff --git a/new2/plomrogue/misc.py b/new2/plomrogue/misc.py new file mode 100644 index 0000000..a3f7298 --- /dev/null +++ b/new2/plomrogue/misc.py @@ -0,0 +1,10 @@ +def quote(string): + """Quote & escape string so client interprets it as single token.""" + quoted = [] + quoted += ['"'] + for c in string: + if c in {'"', '\\'}: + quoted += ['\\'] + quoted += [c] + quoted += ['"'] + return ''.join(quoted) diff --git a/new2/plomrogue/parser.py b/new2/plomrogue/parser.py new file mode 100644 index 0000000..a56b5d1 --- /dev/null +++ b/new2/plomrogue/parser.py @@ -0,0 +1,93 @@ +import unittest +from plomrogue.errors import ArgError + + +class Parser: + + def __init__(self, game=None): + self.game = game + + def tokenize(self, msg): + """Parse msg string into tokens. + + Separates by ' ' and '\n', but allows whitespace in tokens quoted by + '"', and allows escaping within quoted tokens by a prefixed backslash. + """ + tokens = [] + token = '' + quoted = False + escaped = False + for c in msg: + if quoted: + if escaped: + token += c + escaped = False + elif c == '\\': + escaped = True + elif c == '"': + quoted = False + else: + token += c + elif c == '"': + quoted = True + elif c in {' ', '\n'}: + if len(token) > 0: + tokens += [token] + token = '' + else: + token += c + if len(token) > 0: + tokens += [token] + return tokens + + def parse(self, msg): + """Parse msg as call to function, return function with args tuple. + + Respects function signature defined in function's .argtypes attribute. + """ + tokens = self.tokenize(msg) + if len(tokens) == 0: + return None, () + func = self.game.get_command(tokens[0]) + argtypes = '' + if hasattr(func, 'argtypes'): + argtypes = func.argtypes + if func is None: + return None, () + if len(argtypes) == 0: + if len(tokens) > 1: + raise ArgError('Command expects no argument(s).') + return func, () + if len(tokens) == 1: + raise ArgError('Command expects argument(s).') + args_candidates = tokens[1:] + args = self.argsparse(argtypes, args_candidates) + return func, args + + def argsparse(self, signature, args_tokens): + tmpl_tokens = signature.split() + if len(tmpl_tokens) != len(args_tokens): + raise ArgError('Number of arguments (' + str(len(args_tokens)) + + ') not expected number (' + str(len(tmpl_tokens)) + + ').') + args = [] + string_string = 'string' + for i in range(len(tmpl_tokens)): + tmpl = tmpl_tokens[i] + arg = args_tokens[i] + if tmpl == string_string: + args += [arg] + elif tmpl[:len(string_string) + 1] == string_string + ':': + if not hasattr(self.game, 'get_string_options'): + raise ArgError('No string option directory.') + string_option_type = tmpl[len(string_string) + 1:] + options = self.game.get_string_options(string_option_type) + if options is None: + raise ArgError('Unknown string option type.') + if arg not in options: + msg = 'Argument #%s must be one of: %s' % (i + 1, options) + raise ArgError(msg) + args += [arg] + else: + raise ArgError('Unknown argument type: %s' % tmpl) + return args diff --git a/new2/plomrogue/tasks.py b/new2/plomrogue/tasks.py new file mode 100644 index 0000000..402dfcb --- /dev/null +++ b/new2/plomrogue/tasks.py @@ -0,0 +1,46 @@ +from plomrogue.errors import GameError +#from plomrogue.misc import quote +from plomrogue.mapping import YX + + + +class Task: + argtypes = '' + todo = 3 + + def __init__(self, thing, args=()): + self.thing = thing + self.args = args + + def check(self): + pass + + + +class Task_WAIT(Task): + todo = 1 + + def do(self): + return 'success' + + + +class Task_MOVE(Task): + argtypes = 'string:direction' + + def get_move_target(self): + moves = { + 'UP': YX(-1, 0), + 'DOWN': YX(1, 0), + 'LEFT': YX(0, -1), + 'RIGHT': YX(0, 1), + } + return self.thing.position + moves[self.args[0]] + + def check(self): + test_pos = self.get_move_target() + if test_pos.y < 0 or test_pos.x < 0 or test_pos.y >= 24 or test_pos.x >= 40: + raise GameError('would move out of map') + + def do(self): + self.thing.position = self.get_move_target() diff --git a/new2/plomrogue/things.py b/new2/plomrogue/things.py new file mode 100644 index 0000000..138a1fa --- /dev/null +++ b/new2/plomrogue/things.py @@ -0,0 +1,78 @@ +from plomrogue.errors import GameError +from plomrogue.mapping import YX + + + +class ThingBase: + type_ = '?' + + def __init__(self, game, id_=None, position=(YX(0,0))): + self.game = game + if id_ is None: + self.id_ = self.game.new_thing_id() + else: + self.id_ = id_ + self.position = position + + + +class Thing(ThingBase): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def proceed(self): + pass + + + +class ThingAnimate(Thing): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.next_tasks = [] + self.set_task('WAIT') + + def set_task(self, task_name, args=()): + task_class = self.game.tasks[task_name] + self.task = task_class(self, args) + self.task.check() # will throw GameError if necessary + + def set_next_task(self, task_name, args=()): + task_class = self.game.tasks[task_name] + self.next_tasks += [task_class(self, args)] + + def get_next_task(self): + if len(self.next_tasks) > 0: + task = self.next_tasks.pop(0) + task.check() + return task + else: + return None + + def proceed(self): + if self.task is None: + self.task = self.get_next_task() + return + + try: + self.task.check() + except GameError as e: + self.task = None + raise GameError + return + self.task.todo -= 1 + if self.task.todo <= 0: + self._last_task_result = self.task.do() + self.game.changed = True + self.task = self.get_next_task() + + + +class ThingPlayer(ThingAnimate): + type_ = 'player' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.nickname = 'undefined' + diff --git a/new2/rogue_chat.html b/new2/rogue_chat.html new file mode 100644 index 0000000..6fec908 --- /dev/null +++ b/new2/rogue_chat.html @@ -0,0 +1,183 @@ + + + + + + + + diff --git a/new2/rogue_chat.py b/new2/rogue_chat.py new file mode 100755 index 0000000..415b8c1 --- /dev/null +++ b/new2/rogue_chat.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +from plomrogue.game import Game +from plomrogue.io_websocket import PlomWebSocketServer +game = Game() +game.io.run_loop_with_server(8000, PlomWebSocketServer) -- 2.30.2