--- /dev/null
+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'
--- /dev/null
+class ArgError(Exception):
+ pass
+
+
+class GameError(Exception):
+ pass
+
+
+class BrokenSocketConnection(Exception):
+ pass
--- /dev/null
+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
--- /dev/null
+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)
--- /dev/null
+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)
--- /dev/null
+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
--- /dev/null
+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()
--- /dev/null
+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'
+
--- /dev/null
+<!DOCTYPE HTML>
+<html>
+<style>
+canvas { border: 1px solid black; }
+</style>
+<body>
+<canvas id="terminal" />
+<script>
+"use strict";
+let websocket_location = "ws://localhost:8000"
+
+let terminal = {
+ rows: 24,
+ cols: 80,
+ charHeight: 24,
+ initialize: function() {
+ this.ctx = document.getElementById("terminal").getContext("2d"),
+ this.set_font();
+ this.charWidth = this.ctx.measureText("M").width;
+ this.ctx.canvas.height = this.charHeight * this.rows;
+ this.ctx.canvas.width = this.charWidth * this.cols;
+ this.set_font(); // ctx.font gets reset to default on canvas size change, so we have to re-set our own
+ this.ctx.textBaseline = "top";
+ },
+ set_font: function(type='normal') {
+ this.ctx.font = type + ' ' + this.charHeight + 'px monospace';
+ },
+ write: function(start_y, start_x, msg, foreground_color='black') {
+ this.ctx.fillStyle = foreground_color;
+ this.ctx.fillRect(start_x*this.charWidth, start_y*this.charHeight,
+ this.charWidth*msg.length, this.charHeight);
+ if (foreground_color === 'black') {
+ this.ctx.fillStyle = 'white';
+ } else {
+ this.ctx.fillStyle = 'black';
+ }
+ this.ctx.fillText(msg, start_x*this.charWidth, start_y*this.charHeight);
+ },
+ drawBox: function (start_y, start_x, height, width, color='white') {
+ this.ctx.fillStyle = color;
+ this.ctx.fillRect(start_x*this.charWidth, start_y*this.charHeight,
+ this.charWidth*width, this.charHeight*height);
+ }
+}
+
+let parser = {
+ tokenize: function(str) {
+ let tokens = [];
+ let token = ''
+ let quoted = false;
+ let escaped = false;
+ for (let i = 0; i < str.length; i++) {
+ let c = str[i];
+ if (quoted) {
+ if (escaped) {
+ token += c;
+ escaped = false;
+ } else if (c == '\\') {
+ escaped = true;
+ } else if (c == '"') {
+ quoted = false
+ } else {
+ token += c;
+ }
+ } else if (c == '"') {
+ quoted = true
+ } else if (c === ' ') {
+ if (token.length > 0) {
+ tokens.push(token);
+ token = '';
+ }
+ } else {
+ token += c;
+ }
+ }
+ if (token.length > 0) {
+ tokens.push(token);
+ }
+ return tokens;
+ },
+ parse_position(position_string) {
+ let coordinate_strings = position_string.split(',')
+ let position = [0, 0];
+ position[0] = coordinate_strings[0].slice(2);
+ position[1] = coordinate_strings[1].slice(2);
+ return position;
+ }
+}
+
+let tui = {
+ log_msg: function(msg) {
+ chat.history.unshift(msg);
+ if (chat.history.length > terminal.rows - 2) {
+ chat.history.pop();
+ }
+ terminal.drawBox(1, terminal.cols / 2, terminal.rows - 2, terminal.cols);
+ let i = 0;
+ for (let line of chat.history) {
+ terminal.write(terminal.rows - 2 - i, terminal.cols / 2, line);
+ i += 1;
+ // if (i > terminal.rows - 3) {
+ // break;
+ // }
+ }
+ },
+ draw_map: function() {
+ terminal.drawBox(0, 0, terminal.rows, terminal.cols / 2);
+ for (const t in game.things) {
+ terminal.write(game.things[t][0], game.things[t][1], '@');
+ }
+ },
+ draw_tick_line: function(n) {
+ terminal.drawBox(0, 0, terminal.rows, terminal.cols / 2);
+ terminal.write(0, terminal.cols / 2, 'tick: ' + game.tick);
+ },
+ draw_input_line: function() {
+ terminal.drawBox(terminal.rows - 1, terminal.cols / 2, 1, terminal.cols / 2, 'black');
+ terminal.write(terminal.rows - 1, terminal.cols / 2, chat.input_line);
+ }
+}
+
+let game = {
+ things: {},
+ tick: 0
+}
+
+let chat = {
+ input_line: "",
+ history: []
+}
+
+terminal.initialize()
+terminal.drawBox(terminal.rows - 1, terminal.cols / 2, 1, terminal.cols, 'black');
+
+document.addEventListener('keydown', (event) => {
+ if (chat.input_line === '') {
+ terminal.drawBox(terminal.rows - 1, terminal.cols / 2, 1, terminal.rows, 'black');
+ }
+ if (event.key && event.key.length === 1) {
+ chat.input_line += event.key;
+ tui.draw_input_line();
+ } else if (event.key === 'Backspace') {
+ chat.input_line = chat.input_line.slice(0, -1);
+ tui.draw_input_line();
+ } else if (event.key === 'Enter') {
+ websocket.send(chat.input_line);
+ chat.input_line = ''
+ tui.draw_input_line();
+ } else if (event.key === 'ArrowLeft') {
+ websocket.send('TASK:MOVE LEFT');
+ } else if (event.key === 'ArrowRight') {
+ websocket.send('TASK:MOVE RIGHT');
+ } else if (event.key === 'ArrowUp') {
+ websocket.send('TASK:MOVE UP');
+ } else if (event.key === 'ArrowDown') {
+ websocket.send('TASK:MOVE DOWN');
+ };
+ console.log(event.key);
+}, false);
+
+let websocket = new WebSocket(websocket_location);
+websocket.onmessage = function (event) {
+ let tokens = parser.tokenize(event.data);
+ if (tokens[0] === 'TURN') {
+ game.things = {}
+ game.tick = parseInt(tokens[1]);
+ tui.draw_tick_line();
+ } else if (tokens[0] === 'THING_POS') {
+ game.things[tokens[1]] = parser.parse_position(tokens[2]);
+ tui.draw_map();
+ } else if (tokens[0] === 'LOG') {
+ tui.log_msg(' ' + tokens[1]);
+ } else if (tokens[0] === 'ARGUMENT_ERROR') {
+ tui.log_msg('syntax error: ' + tokens[1]);
+ } else if (tokens[0] === 'GAME_ERROR') {
+ tui.log_msg('game error: ' + tokens[1]);
+ } else {
+ tui.log_msg('unhandled input: ' + event.data);
+ }
+}
+</script>
+</body>
+</html>
--- /dev/null
+#!/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)